From 5b8d510470127fb09f0302d0ef271074734c69f3 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:26:33 +1100 Subject: [PATCH] Support Fremen special Karama truth commitments --- components/turn-action-fields.tsx | 11 +- components/turn-action-promises.tsx | 27 +++- docs/CURRENT_STATUS.md | 10 ++ docs/TRUTHTRANCE_TURN_ACTIONS.md | 25 +++ game/engine.ts | 46 +++++- game/reference.ts | 1 + game/truthtrance.ts | 10 +- game/turn-action-promises.ts | 9 +- game/window-card-play.ts | 26 ++- ...ruth-fremen-special-karama-runtime.test.ts | 152 ++++++++++++++++++ tests/truth-turn-action-controls.test.tsx | 40 +++++ tests/turn-action-promises.test.ts | 38 ++++- tests/window-card-fremen-special.test.ts | 37 +++++ tests/window-card-play.test.ts | 4 +- 14 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 tests/truth-fremen-special-karama-runtime.test.ts create mode 100644 tests/window-card-fremen-special.test.ts diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index 813a996d..3e9d29ab 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -9,7 +9,7 @@ import { parseBattleActionClaim } from '@/game/mixed-battle-question'; import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question'; import { battleCardLabel, voiceChoices } from '@/game/battle-cards'; import { nativeStormDialOpportunity } from '@/game/storm-dial-question'; -import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play'; +import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, 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'; @@ -58,6 +58,7 @@ const windowRoleNames: Record = { weather: 'Weather Co karamaHarkonnenSpecial: 'Use Harkonnen’s special Karama during Bidding', karamaEmperorSpecial: 'Use Emperor’s special Karama during Revival', karamaAtreidesSpecial: 'Use Atreides’ special Karama in this battle', + karamaFremenSpecial: 'Use Fremen’s special Karama during Spice Blow', 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. */ @@ -70,6 +71,8 @@ const windowOpportunity = (game: GameView, target: string, effect: WindowCardEff (!opportunity || parseKaramaEmperorSpecialSource(opportunity.source)?.owner !== target)) return null; if (effect === 'karamaAtreidesSpecial' && (!opportunity || parseKaramaAtreidesSpecialSource(opportunity.source)?.owner !== target)) return null; + if (effect === 'karamaFremenSpecial' && + (!opportunity || parseKaramaFremenSpecialSource(opportunity.source)?.owner !== target)) return null; return opportunity; }; const config: MixedActionConfig = { @@ -275,11 +278,11 @@ const config: MixedActionConfig = { @@ -298,11 +301,13 @@ const config: MixedActionConfig = { : 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 === 'karamaFremenSpecial' ? `Turn ${currentWindow.turn} · Fremen special Karama during Spice Blow` : 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`}} {!currentWindow &&

This printed role has no current original open native window.

} + {value.effect === 'karamaFremenSpecial' &&

The current guide uses empty sand without spice. Other active commitments may restrict the chosen territory.

} {value.effect === 'thumper' && game.turn === 1 &&

On turn 1, playing Thumper still discards the card, but its worm encounter is ignored and the normal Spice Card draw continues.

} : value.kind === 'battlePower' ? <> diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index 7945f442..75f4a367 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -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 => +const isWindowEffect = (effect: unknown): effect is Exclude => effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal'; const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester', karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price', karamaBattleCancel: 'printed Karama for this battle response', - karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor special Karama', karamaAtreidesSpecial: 'Atreides special Karama', thumper: 'Thumper', amal: 'Amal' }; + karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor special Karama', karamaAtreidesSpecial: 'Atreides special Karama', karamaFremenSpecial: 'Fremen special Karama', thumper: 'Thumper', amal: 'Amal' }; const namesWindow = (mixed: MixedActionExpression, 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)); @@ -137,6 +137,10 @@ export function TurnActionGuideButton(props: TurnActionProps) { const atreidesRoleNamed = !!windowPromise && !!currentAtreidesWindow && namesWindow(windowPromise.mixed, 'karamaAtreidesSpecial', currentAtreidesWindow.source); const printedAtreidesAction = action.type === 'card' && action.mode === 'special' && atreidesRoleNamed; + const currentFremenWindow = windowCardOpportunity(game, 'karamaFremenSpecial'); + const fremenRoleNamed = !!windowPromise && !!currentFremenWindow && + namesWindow(windowPromise.mixed, 'karamaFremenSpecial', currentFremenWindow.source); + const printedFremenAction = action.type === 'card' && action.mode === 'special' && fremenRoleNamed; 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); @@ -147,7 +151,8 @@ export function TurnActionGuideButton(props: TurnActionProps) { ? 'karamaBattleCancel' : printedHarkonnenAction && heldCard?.effect === windowCardNativeEffect('karamaHarkonnenSpecial') ? 'karamaHarkonnenSpecial' : printedEmperorAction && heldCard?.effect === windowCardNativeEffect('karamaEmperorSpecial') ? 'karamaEmperorSpecial' : printedAtreidesAction && heldCard?.effect === windowCardNativeEffect('karamaAtreidesSpecial') - ? 'karamaAtreidesSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null; + ? 'karamaAtreidesSpecial' : printedFremenAction && heldCard?.effect === windowCardNativeEffect('karamaFremenSpecial') + ? 'karamaFremenSpecial' : 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({ @@ -158,6 +163,8 @@ export function TurnActionGuideButton(props: TurnActionProps) { amalOpen: current.amalWindowOpen, amalReady: current.amalReady, opening: current.phaseOpening, stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed, blow: current.spiceWindow, + ...(windowEffect === 'karamaFremenSpecial' ? { summonedWorm: current.summonedWorm, spice: current.spice, + forces: current.players.map(player => ({ id: player.id, forces: player.forces })) } : {}), ...(current.phase === 3 ? { auction: auctionSource(current) } : {}), ...(current.phase === 5 ? { shipment: { active: current.active, remaining: current.movementRemaining, windows: current.karamaShipmentWindows, grant: current.karamaShipping } } : {}), @@ -289,7 +296,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { ? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader)) : action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite)); const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase', - ...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : windowEffect === 'karamaHarkonnenSpecial' ? ['mode', 'target', 'amount'] : windowEffect === 'karamaEmperorSpecial' ? ['mode', 'leader', 'amount', 'elite'] : windowEffect === 'karamaAtreidesSpecial' ? ['mode', 'target'] : [])].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'] : windowEffect === 'karamaFremenSpecial' ? ['mode', 'territory'] : [])].includes(key)) && (windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10); const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow && heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise && @@ -331,6 +338,14 @@ export function TurnActionGuideButton(props: TurnActionProps) { typeof action.target === 'string' && !!current.battle && !current.battle.revealed && [current.battle.attacker, current.battle.defender].includes(action.target) && JSON.stringify(publicBattleContext(current)) === battleSignature; + const fremenTerritory = typeof action.territory === 'string' ? action.territory : ''; + const nativeFremenCard = windowEffect === 'karamaFremenSpecial' && !!me && me.faction === 'fremen' && + me.specialKaramaUsed === false && phase === 1 && action.mode === 'special' && + typeof action.territory === 'string' && TERRITORIES.some(territory => territory.id === action.territory && territory.type === 'sand') && + !current.summonedWorm && !current.spiceWindow && !current.nexus && + Object.entries(current.spice).every(([key, amount]) => !key.startsWith(`${fremenTerritory}:`) || amount <= 0) && + current.players.every(player => Object.entries(player.forces).every(([key, amount]) => + !key.startsWith(`${fremenTerritory}:`) || amount <= 0)); const 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); @@ -347,6 +362,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { : windowEffect === 'karamaHarkonnenSpecial' ? nativeHarkonnenCard : windowEffect === 'karamaEmperorSpecial' ? nativeEmperorCard : windowEffect === 'karamaAtreidesSpecial' ? nativeAtreidesCard + : windowEffect === 'karamaFremenSpecial' ? nativeFremenCard : 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); @@ -393,7 +409,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { !battle.revealed && [battle.attacker, battle.defender].includes(actor) && action.event === battle.event && action.event === residual.event && action.target === residual.target && action.target === (battle.attacker === actor ? battle.defender : battle.attacker) && Object.keys(action).every(key => ['type', 'card', 'event', 'target', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); - const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && !printedEmperorAction && quotedBoxCard && + const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && !printedEmperorAction && !printedAtreidesAction && !printedFremenAction && 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); @@ -544,6 +560,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { : 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' && windowEffect === 'karamaFremenSpecial' ? `Use Fremen’s special Karama to summon Shai-Hulud in empty ${destination} 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' diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8e9b5eb0..5449cdfd 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Fremen special Karama Truthtrance — 29 October. Prototyped / Partial.** +In a clean Advanced classic pre-blow window, a whole-turn question can bind +Fremen's actual special Karama worm. A detached empty-sand witness executes the +real native worm/Nexus continuation; a standalone Yes may choose a populated +territory, while other commitments retain conservative destination guards. +Five related files pass 219 cases; the populated-worm fixture stages conserved +forces and a counter card. Kull, Semuta, optional modules and deployed +verification remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#fremen-special-karama-before-spice-blow--29-october-2026). + **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 diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 29ed2491..f8fb2dab 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -1,5 +1,30 @@ # Truthtrance combined current-turn actions +## Fremen special Karama before Spice Blow — 29 October 2026 + +**Prototyped / Partial.** In a clean Advanced classic +pre-blow window, a whole-turn question can name Fremen's once-per-game special +Karama summon. Its public source binds the turn and native Fremen owner, not a +hidden card or a chosen destination. Only successful use of a physical printed +Karama in that role after the answer counts; a normal Karama use, previous play +or prevented activation does not. No forbids this role while the source stays +open. The native ability retains its original Shai-Hulud occurrence, physical +cost and continuation. + +The initial shared completion witness uses an actually executed appearance in +empty sand with no spice, preserving the native summoned-worm/Nexus timing +without assuming a foreign protection or ride decision. A standalone Yes may +still use any native legal destination; while other live commitments remain, +populated or spice-bearing destinations are guarded until their consequences +can be shared safely with the planner. The action editor, owner guide and minimal bot use the same native action. Kull, Semuta and +other optional module combinations are excluded from this bounded profile. + +This combines the general current-turn Truthtrance authority with the native +Fremen special power and is a provisional online policy, not a dedicated +publisher ruling. Five related files pass 219 cases. The native fixture selects unopened deal +positions; the populated-worm case stages conserved forces and a counter card. +Complete rules, saved-game assurance and deployed verification remain open. + ## Atreides special Karama in battle — 29 October 2026 **Prototyped / Partial.** In a supported clean Advanced battle before plans diff --git a/game/engine.ts b/game/engine.ts index bad21fdc..103639d7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -19002,7 +19002,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response }) || required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen' || required.effect === 'karamaEmperorSpecial' && purpose === 'special:emperor' || - required.effect === 'karamaAtreidesSpecial' && purpose === 'special:atreides') && + required.effect === 'karamaAtreidesSpecial' && purpose === 'special:atreides' || + required.effect === 'karamaFremenSpecial' && purpose === 'special:fremen') && windowCardOpportunity(g, required.effect)?.source === required.source)) return 'Retain this opportunity without a forbidden printed Karama cost.'; const kullBlock = kullKaramaBlock(g, p, purpose); @@ -20494,7 +20495,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered // still-open Harkonnen special promise before a foreign response can park // the shared reconciler. if (card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => { - return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial'] as const).some(effect => { + return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial'] as const).some(effect => { const stage = row.windowCards?.[effect]; return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source; }); @@ -22385,9 +22386,28 @@ function quotedAmalReady(g: Game, actor: string): boolean { return !!windowCardOpportunity(g, 'amal') && !!g.phaseOpening && !g.phaseOpening.passed.includes(actor) && !g.truthtrance && !g.pendingTreacheryDiscard; } +/** The first detached worm witness has no foreign losses or decisions. */ +function emptyFremenSpecialWormTerritory(g: Game, id: string): boolean { + return TERRITORIES.some(row => row.id === id && row.type === 'sand') && + g.players.every(player => at(player, id) === 0) && + !Object.entries(g.spice).some(([key, amount]) => amount > 0 && splitLocation(key).territory === id); +} function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator { if (!windowCardOpportunity(g, effect)) return; const player = getPlayer(g, actor); + if (effect === 'karamaFremenSpecial') { + if (player.faction !== 'fremen' || g.expansions.length || g.nexusCards || g.homeworlds || g.discoveryEnabled || + g.kullPreview || g.pendingKull || g.semutaPreview || g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview || + g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || + g.pendingNullentropy || g.spiceWindow || g.spiceSequence || g.spiceResolution || g.nexus || g.summonedWorm || g.wormRides.length) return; + for (const card of player.hand.filter(card => card.kind === 'special' && card.effect === 'karama')) + for (const territory of TERRITORIES.filter(row => emptyFremenSpecialWormTerritory(g, row.id))) { + const action: Action = { type: 'card', mode: 'special', card: card.id, territory: territory.id }; + try { if (prepareSpecialKaramaIntent(g, actor, action).kind === 'fremen') yield action; } + catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return; + } if (effect === 'karamaAtreidesSpecial') { if (player.faction !== 'atreides' || g.kullPreview || g.pendingKull || g.semutaPreview || g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview || g.truthtrance || g.response || @@ -23787,7 +23807,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act ? 'karamaShipment' : held?.kind === 'special' && held.effect === 'karama' && action.mode === 'special' ? player.faction === 'harkonnen' ? 'karamaHarkonnenSpecial' : player.faction === 'emperor' ? 'karamaEmperorSpecial' - : player.faction === 'atreides' ? 'karamaAtreidesSpecial' : null + : player.faction === 'atreides' ? 'karamaAtreidesSpecial' + : player.faction === 'fremen' ? 'karamaFremenSpecial' : null : isWindowCardEffect(held?.effect) ? held.effect : null; const windows: WindowCardEffect[] = printedWindow ? [printedWindow] : action.type === 'decision' && action.karama === true ? ['karamaAuction'] @@ -42767,7 +42788,26 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec ); } else if (intent.kind === 'fremen') { const t = intent.territory; + const opportunity = windowCardOpportunity(g, 'karamaFremenSpecial'); + const live = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn); + if (opportunity && card.kind === 'special' && card.effect === 'karama' && live.some(row => + row.windowCards?.karamaFremenSpecial?.source === opportunity.source && !row.windowCards.karamaFremenSpecial.closed)) { + recordNativeTurnActionWindowCard(g, p.id, 'karamaFremenSpecial', opportunity.source); + // With other owned promises, the first composition retains only an empty + // appearance until foreign worm decisions can be jointly forecast. A + // standalone fulfilled answer does not restrict the native destination. + const other = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length || + liveTravelPromises(g.travelPromises ?? [], p.id, g.turn).length || + liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || hasMovementPromises(g, p.id) || + hasBribePromises(g, p.id) || liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length || + liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], p.id, g.turn).length; + requireRule(!other || emptyFremenSpecialWormTerritory(g, t), + 'Choose empty sand without spice while other Truthtrance commitments remain.'); + } p.specialKaramaUsed = true; + if (live.length) guardTurnActionPrintedCardCost(g, p, card); g.summonedWorm = { territory: t, event: crypto.randomUUID(), diff --git a/game/reference.ts b/game/reference.ts index 7da439a4..8ebaa059 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3367,6 +3367,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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 Fremen special-Karama condition covers clean Advanced classic pre-blow. Its owned witness executes an empty sand appearance without spice; standalone Yes may choose a native destination, while populated destinations remain guarded with other commitments. Kull, Semuta and other modules remain excluded.', 'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.', '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.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 7abe69f9..a6ae2f87 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -5,7 +5,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises'; import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question'; -import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play'; +import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, 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, @@ -515,6 +515,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 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.effect === 'karamaFremenSpecial') + check(parseKaramaFremenSpecialSource(action.source)?.owner === respondent.id && + !g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview && + g.expansions.length === 0 && !g.nexusCards && !g.homeworlds && !g.discoveryEnabled && + !g.spiceWindow && !g.spiceSequence && !g.spiceResolution && !g.nexus && !g.summonedWorm && !g.wormRides.length, + 'Choose Fremen’s own special Karama in a supported clean Spice Blow profile.'); } if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') { const battle = g.battle; @@ -921,6 +927,8 @@ export function truthQuestionText( 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 === 'karamaFremenSpecial') + return 'you use Fremen’s special Karama after this answer during Spice Blow'; 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`; diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index d20538ef..7ef7e71e 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -7,7 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question'; import { PlanClaimError } from './battle-promises'; import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play'; -import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, type WindowCardEffect } from './window-card-play'; +import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, type WindowCardEffect } from './window-card-play'; import { StormDialQuestionError, parseStormDialClaim, parseStormDialSource, stormDialMatches, type StormDialClaim } from './storm-dial-question'; import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope'; import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim, @@ -171,6 +171,8 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { 'Retain the original Harkonnen special Karama Bidding window.'); if (value.effect === 'karamaEmperorSpecial') check(!!parseKaramaEmperorSpecialSource(value.source), 'Retain the original Emperor special Karama Revival window.'); + if (value.effect === 'karamaFremenSpecial') check(!!parseKaramaFremenSpecialSource(value.source), + 'Choose the original Fremen special-Karama 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 }; @@ -379,6 +381,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction 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 === 'fremen'); + 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 || diff --git a/tests/truth-fremen-special-karama-runtime.test.ts b/tests/truth-fremen-special-karama-runtime.test.ts new file mode 100644 index 00000000..5e462523 --- /dev/null +++ b/tests/truth-fremen-special-karama-runtime.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { windowCardOpportunity } from '../game/window-card-play'; +import { actIxSpice as act, advanceIxSpice as advance, beforeIxSpiceDraw } from './fixture-ix-spice-cards'; +import { truthBidInventory as inventory, truthBidPlayer as player } from './fixture-truth-bid'; + +/** Fresh Advanced classic setup and native first Spice Blow. Only original + * unopened deal positions are chosen; no played state, cards or forces are + * fabricated. The selected empty sand territory preserves the bounded preview. */ +function fixture() { + const asker = 'harkonnen', owner = 'fremen', observer = 'atreides'; + let game = createGame('TRUTHFREMENSPECIAL', newPlayer(asker, 'Harkonnen', 'harkonnen'), true, []); + joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); + joinGame(game, newPlayer(owner, 'Fremen', 'fremen')); + for (const seat of game.players) game = act(game, seat.id, { type: 'ready' }); + game = act(game, asker, { type: 'start', advancedPreview: true }); + const originalCards = inventory(game); + const truths = game.deck.filter(card => card.effect === 'truthtrance'); + const karama = game.deck.find(card => card.effect === 'karama'); + const filler = game.deck.find(card => card.kind === 'shield'); + assert.ok(truths.length === 2 && karama && filler); + for (const [position, id] of [truths[0].id, truths[1].id, filler.id, karama.id].entries()) { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + game = advance(game, state => beforeIxSpiceDraw(state)); + assert.ok(player(game, asker).hand.some(card => card.id === truths[0].id)); + assert.ok(player(game, owner).hand.some(card => card.id === karama.id)); + assert.deepEqual(inventory(game), originalCards); + const opportunity = windowCardOpportunity(game, 'karamaFremenSpecial'); assert.ok(opportunity); + const territory = 'hagga_basin'; + assert.ok(game.players.every(seat => Object.entries(seat.forces).every(([key, amount]) => !key.startsWith(`${territory}:`) || !amount))); + assert.ok(Object.entries(game.spice).every(([key, amount]) => !key.startsWith(`${territory}:`) || !amount)); + return { game, asker, owner, observer, truth: truths[0].id, karama: karama.id, + territory, source: opportunity.source, originalCards }; +} +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function declared(f: ReturnType) { + let game = act(f.game, f.asker, { type: 'card', card: f.truth }); + while (game.truthtrance?.stage === 'priority') { + const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return game; +} +function question(f: ReturnType, target = f.owner) { + return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: { + kind: 'windowCardPlay', effect: 'karamaFremenSpecial', source: f.source, + } } }; +} +function asked(f: ReturnType) { + return act(declared(f), f.asker, { type: 'truthAsk', question: question(f) }); +} +const special = (f: ReturnType): Action => + ({ type: 'card', mode: 'special', card: f.karama, territory: f.territory }); + +void test('Advanced Fremen special Yes spends the real Karama and summons the original empty-territory worm', () => { + const f = fixture(), questionState = asked(f); + assert.ok(viewGame(questionState, f.owner).truthTurnActionAnswers?.includes('yes')); + assert.equal(viewGame(questionState, f.observer).truthTurnActionAnswers, null); + let game = act(questionState, 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 view = viewGame(game, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty; + assert.deepEqual(botActions(view)[0], guide.action); + } + game = act(game, f.owner, special(f)); + assert.equal(player(game, f.owner).specialKaramaUsed, true); + assert.ok(game.turnActionPromises!.find(row => row.player === f.owner)?.fulfilled); + assert.equal(game.turnActionPromises!.find(row => row.player === f.owner)?.windowCards!.karamaFremenSpecial!.played, true); + assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); + assert.ok(game.summonedBeforeBlow || game.summonedWorm, 'The native summoned-worm sequence must actually begin'); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('Advanced Fremen special No blocks that role while retaining the actual physical card', () => { + const f = fixture(), state = asked(f); + assert.ok(viewGame(state, f.owner).truthTurnActionAnswers?.includes('no')); + const game = act(state, f.owner, { type: 'truthAnswer', answer: 'no' }); + rejected(game, f.owner, special(f)); + 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.originalCards); +}); + +void test('Fremen special source cannot bind another respondent or a forged turn', () => { + const f = fixture(), game = declared(f); + rejected(game, f.asker, { type: 'truthAsk', question: question(f, f.observer) }); + const forged = question(f); + forged.mixed.action.source = JSON.stringify(['karamaFremenSpecial', game.turn + 1, f.owner]); + rejected(game, f.asker, { type: 'truthAsk', question: forged }); +}); + +void test('a special summon cannot promise a future card that is not actually held', () => { + const f = fixture(), game = declared(f); + assert.ok(!player(game, f.owner).hand.some(card => card.effect === 'hajr')); + const mixed = { kind: 'and', terms: [question(f).mixed, + { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } }] }; + const state = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'turnActions', target: f.owner, turn: game.turn, mixed, + } }); + assert.ok(!viewGame(state, f.owner).truthTurnActionAnswers?.includes('yes')); + assert.ok(viewGame(state, f.owner).truthTurnActionAnswers?.includes('no')); + rejected(state, f.owner, { type: 'truthAnswer', answer: 'yes' }); +}); + +void test('a sole special-use Yes permits a populated sand territory and resolves the native worm protection', () => { + const original = fixture(), game = structuredClone(original.game), key = `${original.territory}:12`; + // Controlled physical positioning supplement: move one Fremen from its real + // Sietch Tabr starting group and one Atreides reserve counter into Hagga. + // This conserves both force pools but is not presented as an earned move. + // Swap a second physical Karama into Atreides' hand so its native response + // stays open instead of automatically passing when no counter is possible. + const fremen = player(game, original.owner), opponent = player(game, original.observer); + const counter = game.deck.findIndex(card => card.effect === 'karama' && card.id !== original.karama); + assert.ok(counter >= 0 && opponent.hand.length > 0); + [game.deck[counter], opponent.hand[0]] = [opponent.hand[0], game.deck[counter]]; + assert.deepEqual(inventory(game), original.originalCards); + assert.ok((fremen.forces['sietch_tabr:14'] ?? 0) >= 1 && opponent.reserves >= 1); + fremen.forces['sietch_tabr:14'] -= 1; + fremen.forces[key] = (fremen.forces[key] ?? 0) + 1; + opponent.reserves -= 1; + opponent.forces[key] = (opponent.forces[key] ?? 0) + 1; + const f = { ...original, game }; + let state = act(asked(f), f.owner, { type: 'truthAnswer', answer: 'yes' }); + const opponentTanks = player(state, f.observer).tanks; + state = act(state, f.owner, special(f)); + assert.equal(state.response?.kind, 'wormSurvival'); + assert.equal(state.response?.owner, f.owner); + assert.equal(state.response?.location, f.territory); + assert.equal(player(state, f.owner).forces[key], 1); + assert.equal(player(state, f.observer).forces[key], 1); + assert.ok(state.turnActionPromises!.find(row => row.player === f.owner)?.fulfilled); + while (state.response?.kind === 'wormSurvival') { + const actor = state.players.find(p => !state.response!.passed.includes(p.id)); assert.ok(actor); + state = act(state, actor.id, { type: 'passResponse' }); + } + assert.equal(player(state, f.owner).forces[key], 1, 'Fremen survive the allowed worm'); + assert.equal(player(state, f.observer).forces[key] ?? 0, 0, 'The enemy force is devoured'); + assert.equal(player(state, f.observer).tanks, opponentTanks + 1); + assert.equal(state.discard.filter(card => card.id === f.karama).length, 1); + assert.ok(state.summonedBeforeBlow || state.summonedWorm || state.nexus); + assert.deepEqual(inventory(state), f.originalCards); +}); diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 25d0c325..2b9cceb0 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -2230,3 +2230,43 @@ void test('Atreides special Karama guide forwards only the live target battle pa 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); }); + +void test('Fremen special Karama authoring uses only its public Spice Blow opportunity', () => { + const game = projected(1); + game.advanced = true; game.spiceWindow = null; game.nexus = false; + const fremen = game.players.find(player => player.id === 'p')!; + fremen.faction = 'fremen'; fremen.specialKaramaUsed = false; + const source = JSON.stringify(['karamaFremenSpecial', game.turn, 'p']); + const initial = windowClaim('karamaFremenSpecial', source), e = editor(initial, false, game); + Object.defineProperty(fremen, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true }); + assert.equal(e.error(), null); + assert.match(e.html(), /Use Fremen’s special Karama during Spice Blow/); + assert.match(e.html(), /Turn 2 · Fremen special Karama during Spice Blow/); + assert.match(e.html(), /guide uses empty sand without spice/); + assert.ok(turnActionInputError(initial, game, 'r')); + fremen.specialKaramaUsed = true; assert.ok(e.error()); + fremen.specialKaramaUsed = false; game.phase = 2; assert.ok(e.error()); +}); + +void test('Fremen special Karama guide forwards only a current sand summon packet', () => { + const game = projected(1, { type:'card', mode:'special', card:'controlled-printed-karama', territory:'hagga_basin' }); + game.advanced = true; game.spiceWindow = null; game.nexus = false; + const owner = game.players.find(player => player.id === 'p')!; + owner.faction = 'fremen'; owner.specialKaramaUsed = false; + const source = JSON.stringify(['karamaFremenSpecial', game.turn, 'p']); + game.turnActionPromises[0].mixed = windowClaim('karamaFremenSpecial', 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), /Fremen’s special Karama/); + press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]); + game.spice['hagga_basin:5'] = 1; press(button); assert.equal(sent.length, 1); + delete game.spice['hagga_basin:5']; + owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1); + owner.specialKaramaUsed = false; game.phase = 2; press(button); assert.equal(sent.length, 1); + const invalid = projected(1, { type:'card', mode:'special', card:'controlled-printed-karama', territory:'arrakeen' }); + invalid.advanced = true; invalid.spiceWindow = null; invalid.nexus = false; + const invalidOwner = invalid.players.find(player => player.id === 'p')!; + invalidOwner.faction = 'fremen'; invalidOwner.specialKaramaUsed = false; + invalid.turnActionPromises[0].mixed = windowClaim('karamaFremenSpecial', source); + assert.equal(guide({ game:invalid, busy:false, act() { assert.fail('Invalid territory'); } }).props.disabled, true); +}); diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts index 9dc456b3..11c6a6b8 100644 --- a/tests/turn-action-promises.test.ts +++ b/tests/turn-action-promises.test.ts @@ -64,7 +64,8 @@ const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment' ? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial' ? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial' ? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial' - ? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' : + ? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : effect === 'karamaFremenSpecial' + ? JSON.stringify(['karamaFremenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' : effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' : effect === 'harvester' ? 'spice-blow:1' : 'storm:1'; const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression => @@ -1936,6 +1937,41 @@ void test('Emperor special No and source binding retain exact responder, turn an }); +void test('Fremen special Karama window records only the original owner and phase after the answer', () => { + const source = windowSource('karamaFremenSpecial'); + const question = windowed(group('and', windowCard('karamaFremenSpecial'), pay())); + const signed = { kind: 'windowCardPlay', effect: 'karamaFremenSpecial', source, answer: true }; + assert.deepEqual(branches(question), [[signed, signedPay()]]); + const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaFremenSpecial', source); + assert.equal(otherOwner[0], question); + const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaFremenSpecial', source); + assert.deepEqual(branches(actual[0]), [[signedPay()]]); + assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true); + assert.equal(question.windowCards!.karamaFremenSpecial!.played, false); + validateTurnActionPromises(actual, seats); + const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaFremenSpecial', source); + assert.equal(closed[0].windowCards!.karamaFremenSpecial!.closed, true); + assert.deepEqual(branches(closed[0]), []); +}); + +void test('Fremen special No and source binding retain exact responder, turn and old receipts', () => { + const source = windowSource('karamaFremenSpecial'); + const negative = windowed(windowCard('karamaFremenSpecial'), false); + assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaFremenSpecial', source), TurnActionError); + const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaFremenSpecial', source); + assert.equal(passed[0].fulfilled, true); + for (const bad of ['', 'storm:1', JSON.stringify(['karamaFremenSpecial', 0, 'respondent']), + JSON.stringify(['karamaFremenSpecial', 1, '']), JSON.stringify(['karamaFremenSpecial', 1, 'respondent', 'extra'])]) + assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaFremenSpecial', source: bad }), TurnActionError); + for (const bad of [JSON.stringify(['karamaFremenSpecial', 2, 'respondent']), + JSON.stringify(['karamaFremenSpecial', 1, 'other'])]) + assert.throws(() => turnActionValue(windowed(windowCard('karamaFremenSpecial', bad))), TurnActionError); + const old = windowed(windowCard('karamaAuction')); + 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())); diff --git a/tests/window-card-fremen-special.test.ts b/tests/window-card-fremen-special.test.ts new file mode 100644 index 00000000..d0b1197d --- /dev/null +++ b/tests/window-card-fremen-special.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseKaramaFremenSpecialSource as parse, windowCardNativeEffect, + windowCardOpportunity, type WindowCardGame } from '../game/window-card-play'; + +const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 1, turn: 2, + shieldWallDestroyed: false, players: [{ id: 'h', faction: 'fremen', specialKaramaUsed: false }, + { id: 'a', faction: 'atreides', specialKaramaUsed: false }] }); +const event = JSON.stringify(['karamaFremenSpecial', 2, 'h']); + +void test('Fremen special window is public, turn-bound and independent of private custody', () => { + const game = source(), before = structuredClone(game); + assert.deepEqual(windowCardOpportunity(game, 'karamaFremenSpecial'), + { effect: 'karamaFremenSpecial', source: event, turn: 2 }); + assert.deepEqual(parse(event), { turn: 2, owner: 'h' }); + assert.equal(windowCardNativeEffect('karamaFremenSpecial'), 'karama'); + assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } }, + 'karamaFremenSpecial')?.source, event); + assert.equal(windowCardOpportunity(game, 'karamaFremenSpecial', 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: 4 }, { turn: 0 }, + { players: [{ id: 'h', faction: 'fremen', specialKaramaUsed: true }] }, + { players: [{ id: 'h', faction: 'atreides' }] }, + { players: [{ id: 'h', faction: 'fremen' }, { id: 'h', faction: 'atreides' }] }, + { players: [{ id: 'h', faction: 'fremen' }, { id: 'h2', faction: 'fremen' }] }]) + assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaFremenSpecial'), null); + for (const value of ['', '[]', '["karamaFremenSpecial",2," h"]', + '["karamaFremenSpecial", 2,"h"]', '["karamaFremenSpecial",0,"h"]', + '["karamaFremenSpecial",2,"h","extra"]']) assert.equal(parse(value), null); + assert.equal(windowCardOpportunity(game, 'karamaFremenSpecial', JSON.stringify(['karamaFremenSpecial', 2, 'a'])), null); + assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaFremenSpecial', event), null); + assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'fremen' }] }, 'karamaFremenSpecial')); +}); diff --git a/tests/window-card-play.test.ts b/tests/window-card-play.test.ts index 8db25e09..9362ee8a 100644 --- a/tests/window-card-play.test.ts +++ b/tests/window-card-play.test.ts @@ -23,8 +23,8 @@ function shipments(): WindowCardGame { } void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => { - assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'thumper', 'amal']); - assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']); + assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'thumper', 'amal']); + assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']); }); void test('Amal names each original phase opening with only its public turn and phase identity', () => {