Support Fremen special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m22s

This commit is contained in:
Quintine committed 2026-10-29 18:26:33 +11:00
1 parent 5079ab556f
commit 5b8d510470
14 files changed
+418 -18

No files matched your search

+43 -3
View File
@@ -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<Action> {
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(),
+1
View File
@@ -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.',
+9 -1
View File
@@ -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`;
+8 -1
View File
@@ -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<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('karamaFremenSpecial') ?? []) {
const parsed = parseKaramaFremenSpecialSource(source);
check(parsed && parsed.turn === promise.turn && parsed.owner === promise.player,
'Retain the Fremen special-Karama owner and turn.');
}
for (const source of windowSources.get('karamaAtreidesSpecial') ?? []) {
const binding = parseKaramaAtreidesSpecialSource(source);
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
+24 -2
View File
@@ -1,12 +1,13 @@
import { harvesterAvailable } from './cards';
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'thumper', 'amal'] as const;
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'thumper', 'amal'] as const;
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' ? 'karama' : effect;
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' ? 'karama' : effect;
}
export type KaramaHarkonnenSpecialSource = { turn: number; owner: string };
export type KaramaEmperorSpecialSource = { turn: number; owner: string };
export type KaramaFremenSpecialSource = { turn: number; owner: string };
export type KaramaAtreidesSpecialSource = { turn: number; owner: string; battle: string; territory: string };
export type KaramaBattleCancelSource = {
turn: number;
@@ -89,6 +90,16 @@ export function parseKaramaEmperorSpecialSource(source: unknown): KaramaEmperorS
return { turn: tuple[1], owner: tuple[2] };
}
/** Decode the public phase window even after the special power is spent. */
export function parseKaramaFremenSpecialSource(source: unknown): KaramaFremenSpecialSource | null {
if (!identifier(source)) return null;
let tuple: unknown;
try { tuple = JSON.parse(source); } catch { return null; }
if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaFremenSpecial' ||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
return { turn: tuple[1], owner: tuple[2] };
}
/** Decode the public battle window even after the inspection closes. */
export function parseKaramaAtreidesSpecialSource(source: unknown): KaramaAtreidesSpecialSource | null {
if (!identifier(source)) return null;
@@ -214,6 +225,17 @@ function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEff
if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null;
return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) };
}
if (effect === 'karamaFremenSpecial') {
const players = game.players;
if (game.advanced !== true || game.phase !== 1 || !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 === '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 ||