Support Atreides special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m46s
Verify and publish checkpoint container / container (push) Successful in 10m46s
This commit is contained in:
1 parent
add8e1d51a
commit
5079ab556f
16 files changed
+408
-19
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, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, 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';
|
||||
@@ -57,6 +57,7 @@ const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Co
|
||||
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',
|
||||
karamaAtreidesSpecial: 'Use Atreides’ special Karama in this battle',
|
||||
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. */
|
||||
@@ -67,6 +68,8 @@ const windowOpportunity = (game: GameView, target: string, effect: WindowCardEff
|
||||
(!opportunity || parseKaramaHarkonnenSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
if (effect === 'karamaEmperorSpecial' &&
|
||||
(!opportunity || parseKaramaEmperorSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
if (effect === 'karamaAtreidesSpecial' &&
|
||||
(!opportunity || parseKaramaAtreidesSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
return opportunity;
|
||||
};
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
@@ -272,11 +275,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 === 'thumper' || effect === 'amal')
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || 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') || !!windowOpportunity(game, target, effect) || value.effect === effect)
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => (effect !== 'karamaBattleCancel' && effect !== 'karamaHarkonnenSpecial' && effect !== 'karamaEmperorSpecial' && effect !== 'karamaAtreidesSpecial') || !!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>
|
||||
@@ -294,6 +297,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
: 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 === 'karamaAtreidesSpecial' ? `Turn ${currentWindow.turn} · Atreides special Karama in this battle`
|
||||
: 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'> =>
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel' | 'karamaHarkonnenSpecial' | 'karamaEmperorSpecial' | 'karamaAtreidesSpecial'> =>
|
||||
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', thumper: 'Thumper', amal: 'Amal' };
|
||||
karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor special Karama', karamaAtreidesSpecial: 'Atreides 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));
|
||||
@@ -133,6 +133,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const emperorRoleNamed = !!windowPromise && !!currentEmperorWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaEmperorSpecial', currentEmperorWindow.source);
|
||||
const printedEmperorAction = action.type === 'card' && action.mode === 'special' && emperorRoleNamed;
|
||||
const currentAtreidesWindow = windowCardOpportunity(game, 'karamaAtreidesSpecial');
|
||||
const atreidesRoleNamed = !!windowPromise && !!currentAtreidesWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaAtreidesSpecial', currentAtreidesWindow.source);
|
||||
const printedAtreidesAction = action.type === 'card' && action.mode === 'special' && atreidesRoleNamed;
|
||||
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);
|
||||
@@ -142,7 +146,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? 'karamaShipment' : printedBattleCancelAction && heldCard?.effect === windowCardNativeEffect('karamaBattleCancel')
|
||||
? 'karamaBattleCancel' : printedHarkonnenAction && heldCard?.effect === windowCardNativeEffect('karamaHarkonnenSpecial')
|
||||
? 'karamaHarkonnenSpecial' : printedEmperorAction && heldCard?.effect === windowCardNativeEffect('karamaEmperorSpecial')
|
||||
? 'karamaEmperorSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
? 'karamaEmperorSpecial' : printedAtreidesAction && heldCard?.effect === windowCardNativeEffect('karamaAtreidesSpecial')
|
||||
? 'karamaAtreidesSpecial' : 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({
|
||||
@@ -284,7 +289,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'] : [])].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' ? ['mode', 'target'] : [])].includes(key)) &&
|
||||
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
|
||||
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
|
||||
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
|
||||
@@ -321,6 +326,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
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 nativeAtreidesCard = windowEffect === 'karamaAtreidesSpecial' && !!me && me.faction === 'atreides' &&
|
||||
me.specialKaramaUsed === false && phase === 6 && action.mode === 'special' &&
|
||||
typeof action.target === 'string' && !!current.battle && !current.battle.revealed &&
|
||||
[current.battle.attacker, current.battle.defender].includes(action.target) &&
|
||||
JSON.stringify(publicBattleContext(current)) === battleSignature;
|
||||
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);
|
||||
@@ -336,6 +346,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: windowEffect === 'karamaBattleCancel' ? action.mode === 'cancel' && nativeBattleCancelCard
|
||||
: windowEffect === 'karamaHarkonnenSpecial' ? nativeHarkonnenCard
|
||||
: windowEffect === 'karamaEmperorSpecial' ? nativeEmperorCard
|
||||
: windowEffect === 'karamaAtreidesSpecial' ? nativeAtreidesCard
|
||||
: 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);
|
||||
@@ -497,7 +508,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) && (!printedEmperorAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!printedHarkonnenAction || printedWindow) && (!printedEmperorAction || printedWindow) && (!printedAtreidesAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
(!amalReadyAction || openingReady) &&
|
||||
@@ -532,6 +543,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: 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' && windowEffect === 'karamaAtreidesSpecial' ? `Use Atreides’ special Karama to inspect ${recipient}’s battle plan 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,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Atreides special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In a clean unrevealed Advanced battle, a whole-turn question can bind
|
||||
Atreides' actual special Karama use. The forecast retains the public battle
|
||||
and target-first rule without reading hidden plans. The guide also now names
|
||||
the correct Emperor or Harkonnen promise after an earlier unrelated answer.
|
||||
Nine related files pass 229 cases; the Atreides fixture stages conserved
|
||||
Truthtrance custody. Kull, Semuta, broader interactions and deployed
|
||||
verification remain open.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#atreides-special-karama-in-battle--29-october-2026).
|
||||
|
||||
**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
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Truthtrance combined current-turn actions
|
||||
|
||||
## Atreides special Karama in battle — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a supported clean Advanced battle before plans
|
||||
reveal, a whole-turn question can name Atreides' successful once-per-game
|
||||
special Karama inspection. Its public source binds the turn, Atreides owner,
|
||||
original battle event and territory, never either hidden plan. A future actual
|
||||
use of a physical printed Karama in that role after the answer counts; normal
|
||||
Karama use, a prior play, a failed or prevented activation and another player's
|
||||
card do not. Yes and No use the existing shared completion planner and native
|
||||
special action. Existing target-first commitment and sealed-plan timing remain;
|
||||
a sealed target's plan is private to Atreides and no plan is reopened.
|
||||
|
||||
The detached witness pays the actual card and records the public target-first
|
||||
obligation without reading or deriving benefits from an opposing sealed plan.
|
||||
Current Kull and Semuta profiles, other special powers and broader battle
|
||||
interactions remain outside this bounded prototype. These choices compose the
|
||||
general current-turn Truthtrance authority with the supplied revision's
|
||||
physical p27 F; they are not a separate publisher ruling.
|
||||
|
||||
The source fixture reaches Advanced Battle through native actions. It stages
|
||||
one original Truthtrance card into Guild's hand with a conserving swap; card
|
||||
acquisition is not claimed. Nine related files pass 229 cases, including Yes,
|
||||
No, an already-sealed target, private views and minimal bot guidance. Broader
|
||||
modes, saved-game assurance and deployed verification remain open.
|
||||
|
||||
When an earlier unrelated whole-turn promise is followed by an Emperor or
|
||||
Harkonnen special-use promise, the guide now carries the event belonging to the
|
||||
actual special-use row. The selected native action and each original promise
|
||||
remain unchanged; focused regressions cover both owner factions.
|
||||
|
||||
## Emperor special Karama during Revival — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A whole-turn question may name the Emperor's
|
||||
|
||||
+36
-4
@@ -19001,7 +19001,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
|
||||
required.effect === 'karamaBattleCancel' && !!g.response &&
|
||||
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response }) ||
|
||||
required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen' ||
|
||||
required.effect === 'karamaEmperorSpecial' && purpose === 'special:emperor') &&
|
||||
required.effect === 'karamaEmperorSpecial' && purpose === 'special:emperor' ||
|
||||
required.effect === 'karamaAtreidesSpecial' && purpose === 'special:atreides') &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
@@ -20493,7 +20494,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'] as const).some(effect => {
|
||||
return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial'] as const).some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
|
||||
});
|
||||
@@ -22387,6 +22388,18 @@ 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 === 'karamaAtreidesSpecial') {
|
||||
if (player.faction !== 'atreides' || 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.battle) return;
|
||||
for (const card of player.hand.filter(card => card.kind === 'special' && card.effect === 'karama'))
|
||||
for (const target of [g.battle.attacker, g.battle.defender]) {
|
||||
const action: Action = { type: 'card', mode: 'special', card: card.id, target };
|
||||
try { if (prepareSpecialKaramaIntent(g, actor, action).kind === 'atreides') yield action; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect === 'karamaEmperorSpecial') {
|
||||
if (player.faction !== 'emperor' || g.kullPreview || g.pendingKull || g.semutaPreview ||
|
||||
g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview || g.truthtrance ||
|
||||
@@ -22505,7 +22518,17 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
|
||||
for (const action of nativeWindowCardActions(state, actor, required.effect)) {
|
||||
try {
|
||||
let next: Game;
|
||||
if (required.effect === 'karamaHarkonnenSpecial') {
|
||||
if (required.effect === 'karamaAtreidesSpecial') {
|
||||
// Quote the cost and public target-first obligation without
|
||||
// inspecting or sampling the opponent's sealed battle plan.
|
||||
next = structuredClone(state);
|
||||
const intent = prepareSpecialKaramaIntent(next, actor, action);
|
||||
requireRule(intent.kind === 'atreides' && next.battle, 'Retain the original Atreides special battle.');
|
||||
const owner = getPlayer(next, actor);
|
||||
discard(next, owner, intent.card);
|
||||
owner.specialKaramaUsed = true;
|
||||
next.battle.fullPlan = { owner: actor, target: intent.target };
|
||||
} else if (required.effect === 'karamaHarkonnenSpecial') {
|
||||
// An opaque legal witness returns the unseen drawn cards to the
|
||||
// target. It spends the actual card but never samples, copies or
|
||||
// derives a benefit from another player's private hand.
|
||||
@@ -23761,7 +23784,11 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
: action.type === 'move' ? player.hand.find(card => card.id === action.movementCard) : undefined;
|
||||
const printedWindow = held?.kind === 'special' && held.effect === 'karama' && action.mode === 'purchase'
|
||||
? 'karamaAuction' : held?.kind === 'special' && held.effect === 'karama' && action.mode === 'shipment'
|
||||
? 'karamaShipment' : isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
? 'karamaShipment' : held?.kind === 'special' && held.effect === 'karama' && action.mode === 'special'
|
||||
? player.faction === 'harkonnen' ? 'karamaHarkonnenSpecial'
|
||||
: player.faction === 'emperor' ? 'karamaEmperorSpecial'
|
||||
: player.faction === 'atreides' ? 'karamaAtreidesSpecial' : null
|
||||
: isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
|
||||
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
|
||||
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
|
||||
@@ -42773,7 +42800,12 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
} else if (intent.kind === 'atreides') {
|
||||
const b = g.battle!,
|
||||
target = intent.target;
|
||||
const opportunity = windowCardOpportunity(g, 'karamaAtreidesSpecial');
|
||||
if (opportunity && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaAtreidesSpecial', opportunity.source);
|
||||
p.specialKaramaUsed = true;
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
|
||||
guardTurnActionPrintedCardCost(g, p, card);
|
||||
b.fullPlan = { owner: p.id, target };
|
||||
g.decision = null;
|
||||
log(
|
||||
|
||||
@@ -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 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 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, parseKaramaEmperorSpecialSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, 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,
|
||||
@@ -511,6 +511,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
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.effect === 'karamaAtreidesSpecial')
|
||||
check(parseKaramaAtreidesSpecialSource(action.source)?.owner === respondent.id &&
|
||||
!g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview,
|
||||
'Choose Atreides’ own special Karama in a supported clean battle profile.');
|
||||
}
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
@@ -915,6 +919,8 @@ export function truthQuestionText(
|
||||
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 === 'karamaAtreidesSpecial')
|
||||
return 'you use Atreides’ special Karama after this answer in this battle';
|
||||
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, type WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, 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 === 'karamaAtreidesSpecial') check(!!parseKaramaAtreidesSpecialSource(value.source),
|
||||
'Retain the original Atreides special Karama battle window.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
@@ -377,6 +379,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('karamaAtreidesSpecial') ?? []) {
|
||||
const binding = parseKaramaAtreidesSpecialSource(source);
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Atreides special Karama battle 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,12 +1,13 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'thumper', 'amal'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', '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' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaHarkonnenSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaEmperorSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaAtreidesSpecialSource = { turn: number; owner: string; battle: string; territory: string };
|
||||
export type KaramaBattleCancelSource = {
|
||||
turn: number;
|
||||
battle: string;
|
||||
@@ -88,6 +89,17 @@ export function parseKaramaEmperorSpecialSource(source: unknown): KaramaEmperorS
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Decode the public battle window even after the inspection closes. */
|
||||
export function parseKaramaAtreidesSpecialSource(source: unknown): KaramaAtreidesSpecialSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple.length !== 5 || tuple[0] !== 'karamaAtreidesSpecial' ||
|
||||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || !identifier(tuple[3]) ||
|
||||
!identifier(tuple[4]) || JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], owner: tuple[2], battle: tuple[3], territory: tuple[4] };
|
||||
}
|
||||
|
||||
/** Source decoding remains available after the actual grant closes its window. */
|
||||
export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
@@ -202,6 +214,19 @@ 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 === 'karamaAtreidesSpecial') {
|
||||
const players = game.players, battle = game.battle;
|
||||
if (game.advanced !== true || game.phase !== 6 || !battle || battle.revealed !== false ||
|
||||
!identifier(battle.event) || !identifier(battle.territory) ||
|
||||
!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 === 'atreides');
|
||||
if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null;
|
||||
return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id, battle.event, battle.territory]) };
|
||||
}
|
||||
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,102 @@
|
||||
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 { createAtreidesFullPlanWindowFixture, actAtreidesFullPlanWindow as act,
|
||||
atreidesFullPlanWindowPlayer as player, atreidesFullPlanWindowInventory as inventory,
|
||||
finishAtreidesFullPlanWindow as finish } from './fixture-atreides-full-plan-window';
|
||||
|
||||
/** The underlying battle and Karama are earned by native actions. Stage one
|
||||
* original Truthtrance into Guild's hand by exchanging it with an undealt card;
|
||||
* this does not claim the question card was earned through play. */
|
||||
function fixture() {
|
||||
const f = createAtreidesFullPlanWindowFixture(), game = structuredClone(f.game);
|
||||
const asker = player(game, f.guild);
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const original = asker.hand.findIndex(card => card.effect !== 'truthtrance'); assert.ok(original >= 0);
|
||||
[game.deck[index], asker.hand[original]] = [asker.hand[original], game.deck[index]];
|
||||
const truth = asker.hand[original].id;
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
const window = windowCardOpportunity(game, 'karamaAtreidesSpecial'); assert.ok(window);
|
||||
return { ...f, game, asker: asker.id, truth, source: window.source };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function declare(f: ReturnType<typeof fixture>, source = f.game) {
|
||||
let game = act(source, f.asker, { type: 'card', card: f.truth });
|
||||
for (let i = 0; game.truthtrance?.stage === 'priority' && i < game.players.length; i++) {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return game;
|
||||
}
|
||||
function question(f: ReturnType<typeof fixture>, target = f.owner) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function ask(f: ReturnType<typeof fixture>, source = f.game) {
|
||||
return act(declare(f, source), f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
|
||||
void test('Advanced Atreides special Yes uses the original physical card and target-first plan', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.opponent).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(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
game = act(game, f.owner, f.inspect);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.fulfilled, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.windowCards!.karamaAtreidesSpecial!.played, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
reject(game, f.owner, f.ownPlan);
|
||||
game = act(game, f.opponent, f.opponentPlan);
|
||||
assert.ok(viewGame(game, f.owner).battle?.fullPlanInsight);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
game = finish(game, f);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('Advanced Atreides special No blocks that role but preserves the held 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.inspect);
|
||||
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.originalInventory);
|
||||
});
|
||||
|
||||
void test('a sealed target stays private and does not alter the availability of the same commitment', () => {
|
||||
const f = fixture();
|
||||
const sealed = act(f.game, f.opponent, f.opponentPlan);
|
||||
assert.equal(viewGame(sealed, f.asker).battle?.plans[f.opponent], undefined);
|
||||
const asked = ask(f, sealed);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(game.battle!.plans);
|
||||
game = act(game, f.owner, f.inspect);
|
||||
assert.deepEqual(game.battle!.plans, before);
|
||||
assert.ok(viewGame(game, f.owner).battle?.fullPlanInsight);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('Atreides source rejects a different respondent or changed battle event', () => {
|
||||
const f = fixture(), game = declare(f);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
||||
const forged = question(f);
|
||||
forged.mixed.action.source = JSON.stringify(['karamaAtreidesSpecial', game.turn, f.owner, 'other-battle', f.territory]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
@@ -101,3 +101,29 @@ void test('the Emperor special source rejects other respondents and forged turns
|
||||
forged.mixed.action.source = JSON.stringify(['karamaEmperorSpecial', game.turn + 1, f.target]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
|
||||
void test('Emperor special guide uses its own promise event after an earlier unrelated whole-turn answer', () => {
|
||||
const f = fixture();
|
||||
// Both original Truthtrances were dealt to the asker by the native fixture.
|
||||
let game = declareTruthRevival(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: game.turn,
|
||||
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
} });
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('no'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const rows = game.turnActionPromises!.filter(row => row.player === f.target && !row.fulfilled && !row.released);
|
||||
assert.equal(rows.length, 2);
|
||||
const special = rows.find(row => row.windowCards?.karamaEmperorSpecial); assert.ok(special);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, special.event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, special.event);
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.ok(game.turnActionPromises!.find(row => row.event === special.event)?.fulfilled);
|
||||
assert.deepEqual(inventory(settle(game)), f.originalCards);
|
||||
});
|
||||
@@ -153,3 +153,48 @@ void test('special-Karama OR Hajr preserves either alternative and No forbids th
|
||||
assert.ok(player(no, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(no), f.cards);
|
||||
});
|
||||
|
||||
void test('Harkonnen special guide uses its own promise event after an earlier unrelated whole-turn answer', () => {
|
||||
const f = fixture(), game = structuredClone(f.game);
|
||||
// Move the second original Truthtrance from Harkonnen to the asker; no card
|
||||
// is created or removed and Harkonnen retains the real Karama.
|
||||
const owner = player(game, f.owner), asker = player(game, f.asker);
|
||||
const index = owner.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const second = owner.hand.splice(index, 1)[0]; asker.hand.push(second);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
let current = act(game, f.asker, { type: 'card', card: f.truth, cards: [second.id] });
|
||||
while (current.truthtrance?.stage === 'priority') {
|
||||
const seat = current.players.find(p => !current.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
||||
current = act(current, seat.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(current.truthtrance?.stage, 'ask');
|
||||
current = act(current, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.owner, turn: current.turn,
|
||||
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
} });
|
||||
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
current = act(current, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(current.truthtrance?.stage, 'ask');
|
||||
current = act(current, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
current = act(current, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const rows = current.turnActionPromises!.filter(row => row.player === f.owner && !row.fulfilled && !row.released);
|
||||
assert.equal(rows.length, 2);
|
||||
const specialRow = rows.find(row => row.windowCards?.karamaHarkonnenSpecial); assert.ok(specialRow);
|
||||
const guide = viewGame(current, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, specialRow.event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, specialRow.event);
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
current = act(current, f.owner, guide.action);
|
||||
assert.ok(current.turnActionPromises!.find(row => row.event === specialRow.event)?.fulfilled);
|
||||
if (current.decision?.kind === 'handExchange') {
|
||||
const returned = player(current, f.owner).hand[0]; assert.ok(returned);
|
||||
current = act(current, f.owner, { type: 'decision', returnCards: [returned.id] });
|
||||
} else {
|
||||
// After spending the only held card, the native exchange automatically
|
||||
// returns the one unseen card because it is the entire remaining hand.
|
||||
assert.equal(current.pendingExchange, null);
|
||||
assert.ok(current.log.some(entry => entry.text.includes('returned 1 cards') && entry.text.includes('automatically')));
|
||||
}
|
||||
assert.deepEqual(inventory(current), f.cards);
|
||||
});
|
||||
@@ -2195,3 +2195,38 @@ void test('Emperor special Karama guide forwards only a current legal force pack
|
||||
owner.tanks = 1; press(button); assert.equal(sent.length, 1);
|
||||
owner.tanks = 3; owner.specialKaramaUsed = true; 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;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const other = game.players.find(player => player.id === 'a')!;
|
||||
owner.faction = 'atreides'; owner.specialKaramaUsed = false; other.faction = 'emperor';
|
||||
const source = JSON.stringify(['karamaAtreidesSpecial', game.turn, 'p', game.battle!.event, game.battle!.territory]);
|
||||
const initial = windowClaim('karamaAtreidesSpecial', 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 Atreides’ special Karama in this battle/);
|
||||
assert.match(e.html(), /Turn 2 · Atreides special Karama in this battle/);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'));
|
||||
owner.specialKaramaUsed = true; assert.ok(e.error());
|
||||
owner.specialKaramaUsed = false; game.battle!.revealed = true; assert.ok(e.error());
|
||||
});
|
||||
|
||||
void test('Atreides special Karama guide forwards only the live target battle packet', () => {
|
||||
const game = combat(false, { type:'card', mode:'special', card:'controlled-printed-karama', target:'r' });
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const asker = game.players.find(player => player.id === 'a')!;
|
||||
owner.faction = 'atreides'; owner.specialKaramaUsed = false; asker.faction = 'emperor';
|
||||
// The Atreides owner may inspect a battle in which they are not a participant.
|
||||
game.battle!.attacker = 'a'; game.battle!.defender = 'r';
|
||||
const source = JSON.stringify(['karamaAtreidesSpecial', 2, 'p', game.battle!.event, game.battle!.territory]);
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaAtreidesSpecial', 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), /Atreides’ special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
game.battle!.defender = 'p'; press(button); assert.equal(sent.length, 1);
|
||||
game.battle!.defender = 'r'; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -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 === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial'
|
||||
? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : 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 =>
|
||||
@@ -1933,3 +1934,21 @@ void test('Emperor special No and source binding retain exact responder, turn an
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
|
||||
|
||||
void test('Atreides special window retains owner, battle and ordinary Yes/No outcomes', () => {
|
||||
const source = windowSource('karamaAtreidesSpecial');
|
||||
const yes = windowed(group('and', windowCard('karamaAtreidesSpecial'), pay()));
|
||||
assert.deepEqual(branches(yes), [[{ kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source, answer: true }, signedPay()]]);
|
||||
const actual = recordTurnActionWindowCardPlay([yes], 'respondent', 1, 'karamaAtreidesSpecial', source);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const no = windowed(windowCard('karamaAtreidesSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAtreidesSpecial', source), TurnActionError);
|
||||
assert.equal(closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAtreidesSpecial', source)[0].fulfilled, true);
|
||||
for (const bad of ['', JSON.stringify(['karamaAtreidesSpecial', 0, 'respondent', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', '', 'carthag'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaAtreidesSpecial', 2, 'respondent', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 1, 'other', 'battle:1', 'carthag'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaAtreidesSpecial', bad))), TurnActionError);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaAtreidesSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 6, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'a', faction: 'atreides', specialKaramaUsed: false },
|
||||
{ id: 'h', faction: 'harkonnen' }], battle: { event: 'battle:1', territory: 'carthag', revealed: false } });
|
||||
const event = JSON.stringify(['karamaAtreidesSpecial', 2, 'a', 'battle:1', 'carthag']);
|
||||
|
||||
void test('Atreides special source is public and bound to the original unrevealed battle', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaAtreidesSpecial'),
|
||||
{ effect: 'karamaAtreidesSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'a', battle: 'battle:1', territory: 'carthag' });
|
||||
assert.equal(windowCardNativeEffect('karamaAtreidesSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaAtreidesSpecial')?.source, event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('Atreides special source rejects changed battle, use, timing and malformed tuples', () => {
|
||||
const game = source();
|
||||
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 3 }, { turn: 0 },
|
||||
{ battle: null }, { battle: { event: 'battle:1', territory: 'carthag', revealed: true } },
|
||||
{ battle: { event: '', territory: 'carthag', revealed: false } },
|
||||
{ players: [{ id: 'a', faction: 'atreides', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'a', faction: 'atreides' }, { id: 'b', faction: 'atreides' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaAtreidesSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaAtreidesSpecial", 2,"a","battle:1","carthag"]',
|
||||
JSON.stringify(['karamaAtreidesSpecial', 0, 'a', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 2, 'a', '', 'carthag'])]) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, battle: { event: 'battle:2', territory: 'carthag', revealed: false } },
|
||||
'karamaAtreidesSpecial', event), null);
|
||||
});
|
||||
@@ -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', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', '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