From fd0b2f0fa518a21656e221b45143af8436e6b2b1 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 03:07:33 +1100
Subject: [PATCH] Bind Truthtrance Karama promises to original auction lots
---
components/truthtrance.tsx | 1 +
components/turn-action-fields.tsx | 26 ++-
components/turn-action-promises.tsx | 78 +++++--
docs/CURRENT_STATUS.md | 38 +++
docs/IMPLEMENTATION_STATUS.md | 40 ++++
docs/RULE_DECISIONS.md | 13 ++
docs/TRUTHTRANCE_TURN_ACTIONS.md | 70 ++++++
game/engine.ts | 140 +++++++++--
game/reference.ts | 9 +-
game/turn-action-promises.ts | 10 +-
game/window-card-play.ts | 35 ++-
tests/fixture-truth-bid.ts | 3 +-
tests/fixture-truth-turn-karama.ts | 151 ++++++++++++
tests/truth-turn-action-controls.test.tsx | 116 +++++++++-
tests/truth-turn-karama-forecast.test.ts | 52 +++++
tests/truth-turn-karama-runtime.test.ts | 270 ++++++++++++++++++++++
tests/turn-action-promises.test.ts | 80 ++++++-
tests/window-card-play.test.ts | 64 ++++-
18 files changed, 1128 insertions(+), 68 deletions(-)
create mode 100644 tests/fixture-truth-turn-karama.ts
create mode 100644 tests/truth-turn-karama-forecast.test.ts
create mode 100644 tests/truth-turn-karama-runtime.test.ts
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index ad10bf1c..9e7860e0 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -604,6 +604,7 @@ export function Truthtrance({
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
{!g.truthTurnActionTargets.includes(target) &&
Choose a player with supported native Voice or Prescience, printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn.
Weather Control, Family Atomics and Harvester refer only to their original current Storm or fresh spice-blow window.
+ Printed Karama auction use refers only to the current ordinary lot, through immediate purchase or its winning invoice.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.
}
{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}
>
diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx
index 0bda85c4..3f1bc315 100644
--- a/components/turn-action-fields.tsx
+++ b/components/turn-action-fields.tsx
@@ -6,7 +6,7 @@ import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
-import { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
+import { 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';
@@ -47,8 +47,8 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
nativeBattlePowerOpportunity(game, target, 'voice') ? { kind: 'voice', use: true }
: nativeBattlePowerOpportunity(game, target, 'prescience') ? { kind: 'prescience', use: true }
: { kind: 'voice', use: true };
-const windowEffects: WindowCardEffect[] = ['weather', 'atomics', 'harvester'];
-const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' };
+const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
+ karamaAuction: 'Spend a printed Karama for this current ordinary lot' };
const config: MixedActionConfig = {
prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -80,6 +80,9 @@ const config: MixedActionConfig = {
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after this answer
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
They do not ask for a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
+ A current-lot Karama condition requires spending a printed Karama for this original ordinary lot, through immediate purchase
+ or its winning invoice. Other Karama roles, Worthless conversion and a successful bid alone do not count.
+ Recovery alone does not count. This condition closes with the original ordinary lot.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
@@ -115,7 +118,7 @@ const config: MixedActionConfig = {
if (action.kind === 'windowCardPlay') {
const opportunity = windowCardOpportunity(game, action.effect);
if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source)
- throw new Error('Choose the original currently open Storm or fresh spice-blow window for this printed role.');
+ throw new Error('Choose the original currently open Storm, fresh spice-blow or ordinary-lot window for this printed role.');
}
return action;
},
@@ -131,7 +134,7 @@ const config: MixedActionConfig = {
const minimum = 'minimum' in value ? value.minimum : 1;
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
if (kind === 'windowCardPlay') {
- const effect = windowEffects.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
+ const effect = WINDOW_CARD_EFFECTS.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}
if (kind === 'battlePower') {
@@ -150,7 +153,7 @@ const config: MixedActionConfig = {
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
Play Hajr or Tleilaxu Ghola in its printed role
- Play Weather Control, Family Atomics or Harvester in this window
+ Use a printed card in this original Storm, spice-blow or ordinary-lot window
Declare native Voice or Prescience in this battle
Use an actual current battle-plan choice
Make the current Traitor declaration
@@ -174,14 +177,14 @@ const config: MixedActionConfig = {
> : value.kind === 'windowCardPlay' ? <>
Printed role in the original window
- {
+ {
const effect = event.currentTarget.value;
- if (effect === 'weather' || effect === 'atomics' || effect === 'harvester')
+ if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction')
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}}>
Choose a printed role
- {windowEffects.map(effect => {windowRoleNames[effect]} )}
+ {WINDOW_CARD_EFFECTS.map(effect => {windowRoleNames[effect]} )}
Original native window
= {
Choose the original open window
{currentWindow && {value.effect === 'harvester'
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}`
- : `Turn ${currentWindow.turn} · Before this Storm movement`} }
+ : value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
+ : `Turn ${currentWindow.turn} · Before this Storm movement`}}
{!currentWindow && This printed role has no current original open native window.
}
> : value.kind === 'battlePower' ? <>
diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx
index 1116e975..9826f542 100644
--- a/components/turn-action-promises.tsx
+++ b/components/turn-action-promises.tsx
@@ -9,7 +9,7 @@ import { ordinaryCardAvailability } from '@/game/card-availability';
import { gholaForcePreparations } from '@/game/ghola-force-preparation';
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
-import { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
+import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
@@ -26,10 +26,11 @@ const publicBattleContext = (game: GameView) => game.battle && ({
preparation: game.battle.preparation,
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
-const isWindowEffect = (effect: unknown): effect is WindowCardEffect => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
-const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' };
-const namesWindow = (mixed: MixedActionExpression, effect: WindowCardEffect, source: string): boolean =>
- mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && mixed.action.source === source
+const isWindowEffect = (effect: unknown): effect is Exclude => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
+const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
+ karamaAuction: 'printed Karama for this ordinary lot' };
+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));
/** One projected native packet; no client branch selection or completion solver. */
@@ -61,27 +62,38 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const nexus = game.alliancePromiseNexus, nexusOpen = game.nexus, nexusSpiceWindow = game.spiceWindow;
const nexusSignature = JSON.stringify({ ...publicAllianceContext(game), ready: game.ready });
const box = game.nullentropy, search = box?.search;
- const cardOwner = action.type === 'card' ? game.players.find(player => player.id === actor) : undefined;
+ const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
+ const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
+ const currentAuctionWindow = windowCardOpportunity(game, 'karamaAuction');
+ const auctionRoleNamed = !!windowPromise && !!currentAuctionWindow && namesWindow(windowPromise.mixed, 'karamaAuction', currentAuctionWindow.source);
+ const auctionPurchaseAction = action.type === 'card' && action.mode === 'purchase';
+ 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);
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
- const windowEffect = isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
+ const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
+ ? 'karamaAuction' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
- const windowPromise = action.type === 'card' || earlyWindowReadyAction
- ? game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn) : null;
- const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
const publicWindowState = (current: GameView) => JSON.stringify({
opportunity: windowEffect ? windowCardOpportunity(current, windowEffect)
- : (['weather', 'atomics', 'harvester'] as const).map(effect => windowCardOpportunity(current, effect)),
+ : WINDOW_CARD_EFFECTS.map(effect => windowCardOpportunity(current, effect)),
stormOpen: current.stormCardWindowOpen,
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
blow: current.spiceWindow,
+ ...(current.phase === 3 ? { auction: auctionSource(current) } : {}),
...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces,
advisors: current.players.find(player => player.id === actor)?.advisors } : {}),
});
const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction ? publicWindowState(game) : null;
const originalBlow = windowEffect === 'harvester' || earlyWindowReadyAction && phase === 1 ? game.spiceWindow : null;
const earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event });
+ const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
+ const auctionHandSource = (current: GameView) => {
+ const player = current.players.find(player => player.id === actor);
+ return JSON.stringify({ hand: player?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })), limit: player?.handLimit });
+ };
+ const auctionHandSignature = windowEffect === 'karamaAuction' ? auctionHandSource(game) : null;
const ghola = heldCard?.effect === 'ghola' ? game.ghola : null, gholaSignature = JSON.stringify(ghola);
const hajrSource = (current: GameView) => {
const player = current.players.find(player => player.id === actor);
@@ -91,9 +103,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const hajrSignature = heldCard?.effect === 'hajr' ? hajrSource(game) : null;
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
- const ownCard = action.type === 'card' && !!heldCard && !!me && me === cardOwner &&
+ const ownPhysicalCard = (action.type === 'card' || printedInvoiceAction) && !!heldCard && !!me && me === cardOwner &&
me.hand?.find(card => card.id === action.card) === heldCard &&
JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect }) === cardSignature;
+ const ownCard = action.type === 'card' && ownPhysicalCard;
+ const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
+ const ownInvoiceSource = phase === 3 && current.decision === decision && current.decision?.kind === 'auctionPayment' &&
+ current.decision.player === actor && !current.richeseAuction && currentAuction && current.auction?.bidder === actor;
const printedHajr = ownCard && !!heldCard && !!me && heldCard.kind === 'special' && heldCard.effect === 'hajr' && action.mode === undefined &&
phase === 5 && current.active === actor && me.hajrAvailable === true && hajrSource(current) === hajrSignature &&
ordinaryCardAvailability(current, heldCard.id)?.available === true;
@@ -110,22 +126,31 @@ 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'] : [])].includes(key)) &&
+ ...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' ? ['mode'] : [])].includes(key)) &&
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
- const printedWindow = ownCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
- action.mode === undefined && windowPacket && !!windowPromise &&
+ const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
+ heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
namesWindow(windowPromise.mixed, windowEffect, cardWindow.source) &&
- (windowEffect !== 'harvester' || current.spiceWindow === originalBlow) && publicWindowState(current) === windowSignature &&
- ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true;
- const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
- const earlyWindowBox = (phase === 0 || phase === 1) && genericCard && heldCard?.effect === 'nullentropyBox' &&
+ (windowEffect !== 'harvester' || current.spiceWindow === originalBlow) && publicWindowState(current) === windowSignature;
+ const nativeAuctionCard = windowEffect === 'karamaAuction' && !!heldCard && currentAuction &&
+ Array.isArray(current.karamaAuctionCards) &&
+ current.karamaAuctionCards === auctionCards && JSON.stringify(current.karamaAuctionCards) === auctionCardsSignature &&
+ current.karamaAuctionCards.includes(heldCard.id) && auctionHandSource(current) === auctionHandSignature;
+ const printedWindow = action.type === 'card' && printedWindowSource && !!heldCard && windowPacket &&
+ (windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
+ Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
+ : action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
+ const printedAuctionInvoice = printedInvoiceAction && printedWindowSource && nativeAuctionCard &&
+ Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
+ const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
+ const earlyWindowBox = (phase === 0 || phase === 1 || phase === 3) && genericCard && heldCard?.effect === 'nullentropyBox' &&
action.mode === undefined && Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
!!box && current.nullentropy === box &&
JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature &&
!box.blocked && !box.search && box.card.id === heldCard.id &&
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
- (['weather', 'atomics', 'harvester'] as const).some(effect => {
+ WINDOW_CARD_EFFECTS.some(effect => {
const opportunity = windowCardOpportunity(current, effect);
return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source);
}) && publicWindowState(current) === windowSignature;
@@ -155,11 +180,9 @@ export function TurnActionGuideButton(props: TurnActionProps) {
current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
- const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
const invoice = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' &&
(!action.karama || typeof action.card === 'string') &&
- current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor &&
- !current.richeseAuction && currentAuction && current.auction?.bidder === actor;
+ ownInvoiceSource && (!printedInvoiceAction || printedAuctionInvoice);
const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native &&
nativeSource(current) === nativeSignature && native && native.event === action.event && !native.outcome &&
native.eligible.includes(actor) && (native.method === 'silent'
@@ -213,7 +236,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
- !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice) &&
+ !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice || earlyWindowBox && ownInvoiceSource) &&
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
@@ -229,6 +252,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.type === 'card' && heldCard?.effect === 'ghola' ? action.leader !== undefined
? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer`
: `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer`
+ : action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer'
: action.type === 'card' && windowEffect ? `Play ${windowRoleNames[windowEffect]} in its original native window${windowEffect === 'weather' ? ` with storm distance ${amount}` : ''} while honoring your whole answer`
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
: action.type === 'voice' ? `Declare native Voice: opponent ${action.must ? 'must' : 'cannot'} play ${battleCardLabel(String(action.kind))}`
@@ -256,7 +280,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.type === 'move' ? `Move ${amount} physical forces to ${destination}${sector} while honoring your whole answer`
: action.type === 'endMovement' ? 'Finish movement while preserving any later payment conditions'
: action.type === 'decision' ? game.decision?.kind === 'auctionPayment'
- ? action.karama === true ? 'Use the quoted Karama for the current auction payment while preserving your whole answer'
+ ? action.karama === true ? printedInvoiceAction ? 'Spend the quoted printed Karama on this ordinary-lot winning invoice while preserving your whole answer'
+ : 'Use the quoted Karama for the current auction payment while preserving your whole answer'
: 'Pay the current auction price while preserving your whole answer'
: 'Complete your paid Box search while preserving your whole answer'
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} while preserving your whole answer`
@@ -314,6 +339,9 @@ export function TurnActionPromises(props: TurnActionProps) {
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after the answer
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
They do not promise a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
+ A current-lot Karama condition requires spending a printed Karama for this original ordinary lot, through immediate purchase
+ or its winning invoice. Other Karama roles, Worthless conversion and a successful bid alone do not count.
+ Recovery alone does not count. This condition closes with the original ordinary lot.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 30cb9b1c..096b9bad 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,44 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Truthtrance / current-lot printed Karama — 27 October. Prototyped / Partial.**
+A whole turn answer can now name the actual printed Karama cost for the current
+ordinary lot: immediate free purchase or its winning invoice. Its public source
+stays tied to that lot. A real bid must precede purchase when both are required;
+the guide keeps native bidder priority and out-of-turn purchase rights. Whole
+No shares actual bid/payment funding without assuming a forbidden free invoice.
+Other roles and later lots do not credit the original interval.
+
+Original H/A/E sources deal both real Truthtrances and Emperor's printed Karama,
+with33 Treachery/15 Traitors. Holding that Karama adds three actual Atreides
+inspection response passes: Basic waiting23/active25, Advanced26/28. The actual
+Hbid1/Apass source25 supports Ebid3 then purchase or both renewed opponents'
+passes before invoice. Counts derive from action arrays. An unopened Hajr lot
+supplement checks that unseen acquired cards do not fund a future-card witness;
+actual acquisition restores owned knowledge while prior answers remain binding.
+
+The final frozen batch passes **16 files,267/267**, types/lint/build. Thirty-one
+new cases comprise6 source,4 aggregate,5 controls,14 native and2 forecast/closure
+cases. Five journeys pass with22 GameTable SSR renders,5 actual answer callbacks
+and9 exact guide callbacks: frozen fact/pay10/free purchase, bid-before-purchase,
+bid-before-invoice, whole No's cumulative cap and the native spice alternative.
+Four profiles consume legal guides without strategic refinement.
+
+Feedback repairs purpose-specific overbid funding and an overbroad pre-effect
+guard that rejected existing Revival invoices. Type guards and fixture oracles
+are corrected: bids reopen both opponents, No retains its payment deadline and
+Karama shipment is half price rather than free. Bounded review moves required
+bidding after any quoted Box recovery, so its fee participates in the same quote;
+that Box composition has no native acceptance claim in this batch. No broad
+assurance or calibration campaign is run.
+
+Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-proof-v3/`;
+journeys/HTML: adjacent `20261027-truth-karama-runtime-v3/`.
+No HTTP/storage boundary, saved-room/database, reset/deletion, restart or deployed
+operation changes. Wider Karama/card/action families, optional compositions and
+final browser/deployed/recovery/privacy/custody acceptance remain open.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#current-lot-printed-karama--27-october-2026).
+
**Truthtrance / original-window card plays — 27 October. Prototyped / Partial.**
Weather Control, Family Atomics and Harvester now have source-bound future
printed-play conditions in whole turn answers. Real Storm movement closes its
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 814caa7b..fc639267 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,45 @@
# Dune implementation status
+## 27 October 2026 — Current-lot printed Karama promises
+
+**Truthtrance / current-lot printed Karama — 27 October. Prototyped / Partial.**
+A whole turn answer can now name the actual printed Karama cost for the current
+ordinary lot: immediate free purchase or its winning invoice. Its public source
+stays tied to that lot. A real bid must precede purchase when both are required;
+the guide keeps native bidder priority and out-of-turn purchase rights. Whole
+No shares actual bid/payment funding without assuming a forbidden free invoice.
+Other roles and later lots do not credit the original interval.
+
+Original H/A/E sources deal both real Truthtrances and Emperor's printed Karama,
+with33 Treachery/15 Traitors. Holding that Karama adds three actual Atreides
+inspection response passes: Basic waiting23/active25, Advanced26/28. The actual
+Hbid1/Apass source25 supports Ebid3 then purchase or both renewed opponents'
+passes before invoice. Counts derive from action arrays. An unopened Hajr lot
+supplement checks that unseen acquired cards do not fund a future-card witness;
+actual acquisition restores owned knowledge while prior answers remain binding.
+
+The final frozen batch passes **16 files,267/267**, types/lint/build. Thirty-one
+new cases comprise6 source,4 aggregate,5 controls,14 native and2 forecast/closure
+cases. Five journeys pass with22 GameTable SSR renders,5 actual answer callbacks
+and9 exact guide callbacks: frozen fact/pay10/free purchase, bid-before-purchase,
+bid-before-invoice, whole No's cumulative cap and the native spice alternative.
+Four profiles consume legal guides without strategic refinement.
+
+Feedback repairs purpose-specific overbid funding and an overbroad pre-effect
+guard that rejected existing Revival invoices. Type guards and fixture oracles
+are corrected: bids reopen both opponents, No retains its payment deadline and
+Karama shipment is half price rather than free. Bounded review moves required
+bidding after any quoted Box recovery, so its fee participates in the same quote;
+that Box composition has no native acceptance claim in this batch. No broad
+assurance or calibration campaign is run.
+
+Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-proof-v3/`;
+journeys/HTML: adjacent `20261027-truth-karama-runtime-v3/`.
+No HTTP/storage boundary, saved-room/database, reset/deletion, restart or deployed
+operation changes. Wider Karama/card/action families, optional compositions and
+final browser/deployed/recovery/privacy/custody acceptance remain open.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#current-lot-printed-karama--27-october-2026).
+
## 27 October 2026 — Original Storm and fresh-blow card plays
**Truthtrance / original-window card plays — 27 October. Prototyped / Partial.**
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 998466c4..1a08b2c6 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,18 @@
# Rule decision index
+## Truthtrance current-lot printed Karama — 27 October 2026
+
+Prototyped / Partial. Reuse the current-turn AND/OR authority below for a
+future physical printed Karama spent on the original ordinary lot, either
+immediate purchase or the winning invoice. Bids and card costs stay distinct;
+the native bid must precede a purchase when both are required. Whole No keeps
+its current-lot restriction and residual payment cap. Other roles, converted
+costs, later lots and recovery alone do not count. Unseen acquired cards do not
+fund a detached future-card witness; actual owned knowledge remains usable.
+This extends the existing bounded promise composition, not printed exhaustive
+future-action coverage. Kull, conversion and wider combination acceptance stay
+separate. [Contract](TRUTHTRANCE_TURN_ACTIONS.md#current-lot-printed-karama--27-october-2026).
+
## Truthtrance original-window ordinary card plays — 27 October 2026
Compose the existing FAQp8/current-turn AND/OR authority and supplied Advanced
diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md
index cfaab206..19192bfb 100644
--- a/docs/TRUTHTRANCE_TURN_ACTIONS.md
+++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md
@@ -1,5 +1,75 @@
# Truthtrance combined current-turn actions
+## Current-lot printed Karama — 27 October 2026
+
+**Prototyped / Partial.** `windowCardPlay` now accepts `karamaAuction` with
+public `ordinary-auction:turn:lot` identity. It describes a future physical
+printed Karama cost for this original ordinary lot: immediate purchase or its
+winning invoice. A bid alone, another Karama role, converted Worthless/Shrine
+cost, earlier play, recovery alone or a later lot does not satisfy it. A modal
+response preserves the original source; another player's winning invoice ends
+the respondent's opportunity. Original pricing, physical card delivery and
+hand capacity stay native.
+
+Whole AND/OR and No share the existing per-answer played/closed receipt. Actual
+cost records only after pre-cost interception has allowed spending. The original
+native bid must precede closing a lot when both are required. A detached quote
+can prepare that bid and then purchase; the guide waits for the real bidder
+turn. An actual invoice instead uses its own Karama/spice decision. Own closing
+actions must retain the whole answer before a later foreign response can mask
+that loss. Foreign actions retain their rights and can release impossibility.
+
+The false branch forbids a printed free invoice in its detached native bid
+funding quote. The remaining purse cannot simultaneously fund an unfunded bid
+and a payment by silently assuming that forbidden cost. Conversely, a real
+printed free purchase and a funded bribe share one purse without inventing a
+spice payment. Unknown acquired lot/bonus cards remain physical hand occupants
+but cannot fund another future-card witness before their actual acquisition;
+known Atreides inspection retains its original authority. This keeps the
+existing bounded current-funding interpretation, not exhaustive future draws.
+
+The editor uses only public lot identity. Owner-only physical cost quotations
+feed exact purchase and current-source invoice controls. Purchase keeps its
+native out-of-turn availability. Current source/raw row/held cost, auction and
+callback snapshots reject stale guides. A later lot's unrelated invoice keeps
+its existing controls. Four profiles consume the shared guide without strategy
+or difficulty calibration. Wider Karama roles, Kull/conversion/Box compositions,
+complete modes and final browser/deployed/recovery/privacy/custody acceptance
+remain open.
+
+Native evidence uses the original H/A/E base33 and fifteen Traitors, both actual
+Harkonnen Truthtrances and Emperor's actual printed Karama. Holding that Karama
+adds three actual Atreides inspection response passes. Recorded Basic source
+prefixes are waiting23/active25; Advanced26/28. The live-opponent source uses
+the original waiting23, a real H bid1 and A pass for25 actions. It then earns
+the required E bid3; the new bid reopens both H and A before E's invoice.
+All counts derive from original action arrays. The unseen-Hajr supplement
+selects its original unopened lot position, with no played hand or promise
+installation. No saved-room, database, reset/deletion, restart or deployment
+operation belongs to this batch.
+
+
+Frozen evidence: **16 files,267/267**, types/lint/build;31 new checks include
+6 public-source,4 aggregate,5 controls,14 original native and2 forecast/closure
+cases. Five actual engine/control-callback journeys use22 GameTable SSR renders,
+5 answer callbacks and9 exact guide callbacks. They prove the real free purchase,
+pay10/frozen fact, native bid-before-purchase/invoice, whole No cap and paid spice
+alternative. Four profiles use the shared guide. Local SSR/callback evidence
+does not certify hydration, browser/mobile or deployed play.
+
+Targeted feedback corrects purpose-specific bid funding and limits the pre-cost
+guard so original Revival/free-Karama invoices retain their post-purchase funds.
+Fixture feedback retains real extra inspection passes, reopened bidder order,
+full-turn No deadlines and half-price shipment. A bounded review moves required
+bidding inside the shared post-Box quote; no combined native Box recovery proof
+is claimed. Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261027-truth-karama-proof-v3/`;
+journeys/HTML: adjacent `20261027-truth-karama-runtime-v3/`.
+Evidence: [original sources](../tests/fixture-truth-turn-karama.ts),
+[native cases](../tests/truth-turn-karama-runtime.test.ts),
+[forecast and own closure](../tests/truth-turn-karama-forecast.test.ts),
+[controls](../tests/truth-turn-action-controls.test.tsx).
+
## Native current-turn action prototype
**Prototyped / Partial.** New raw question:
diff --git a/game/engine.ts b/game/engine.ts
index 27aaa169..4d70d86f 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -4,7 +4,7 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
-import { windowCardOpportunity, type WindowCardEffect } from './window-card-play';
+import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from './window-card-play';
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
@@ -5028,6 +5028,8 @@ function savedTransferResponses(g: Game) {
];
}
function transferCardBlock(g: Game, owner: Player, card: Card) {
+ if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
+ return 'An unseen future auction card cannot fund this completion.';
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
@@ -15710,7 +15712,8 @@ function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
else recoverAuctionPayment(g, getPlayer(g, source.winner));
}
function normalBidMaximum(g: Game, p: Player): number {
- return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
+ return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card))
+ : !!karamaCard(g, p, undefined, 'auction-free-purchase'))
? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0);
}
/** Same native quote for ordinary controls and the detached current-lot guide. */
@@ -16240,6 +16243,13 @@ function kullKaramaBlock(g: Game, p: Player, purpose?: KullPurpose): string | nu
}
/** Physical custody plus the intended effect; promise feasibility is checked at commit. */
function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpose): string | null {
+ if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
+ return 'An unseen future auction card cannot fund this completion.';
+ const policy = (g as TurnActionCardProbe)._turnActionWindowCardPolicy;
+ if (purpose === 'auction-free-purchase' && card.kind === 'special' && card.effect === 'karama' &&
+ policy?.player === p.id && policy.forbidden.some(required => required.effect === 'karamaAuction' &&
+ windowCardOpportunity(g, required.effect)?.source === required.source))
+ return 'Retain this lot without a forbidden printed Karama purchase.';
const kullBlock = kullKaramaBlock(g, p, purpose);
if (kullBlock) return kullBlock;
if (g.pendingKull?.stage === 'counter' &&
@@ -17434,7 +17444,22 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
'Kull preview cannot accept this unproven Truthtrance counter continuation.');
if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return;
if (use.kind === 'shipment') use = { ...use, card: card.id };
+ const auctionRows = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn);
+ let auctionWindow: string | undefined;
+ if (card.kind === 'special' && card.effect === 'karama' &&
+ (use.kind === 'purchase' || use.kind === 'auctionPayment')) {
+ const window = windowCardOpportunity(g, 'karamaAuction');
+ requireRule(window, 'Retain the original ordinary Karama auction lot.');
+ auctionWindow = window.source;
+ recordNativeTurnActionWindowCard(g, p.id, 'karamaAuction', window.source);
+ }
const used = discard(g, p, card.id);
+ if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
+ guardTurnActionAuctionClosure(g, p.id, auctionRows);
+ if (!auctionWindow && card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
+ const stage = row.windowCards?.karamaAuction;
+ return stage && !stage.closed && windowCardOpportunity(g, 'karamaAuction')?.source === stage.source;
+ })) guardTurnActionPrintedCardCost(g, p, used);
const semutaRoleCost = g.semutaPreview && semutaKaramaRoleCost(g, p.id, card);
const guildShipmentDiscard = semutaRoleCost &&
use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response);
@@ -18849,7 +18874,11 @@ function turnActionRule(operation: () => T): T {
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
/** This field exists only on detached completion clones and is never returned
* as a real game or installed as a selected whole-answer branch. */
-type TurnActionCardProbe = Game & { _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] } };
+type TurnActionCardProbe = Game & {
+ _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] };
+ _turnActionWindowCardPolicy?: { player: string; forbidden: { effect: WindowCardEffect; source: string }[] };
+ _turnActionUnknownCards?: string[];
+};
function printedCardRule(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof PrintedCardPlayError) throw new RuleError(error.message); throw error; }
@@ -18919,11 +18948,30 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
}
type TurnActionWindowCardRequirement = Extract;
const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect =>
- effect === 'weather' || effect === 'atomics' || effect === 'harvester';
+ WINDOW_CARD_EFFECTS.some(candidate => candidate === effect);
+/** Own physical costs only. Whole-answer completion remains the guide's job. */
+function quotedAuctionKaramaCards(g: Game, player: Player): string[] {
+ const invoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
+ if (!windowCardOpportunity(g, 'karamaAuction') || g.truthtrance || g.response || g.phaseOpening ||
+ g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull || g.decision && !invoice) return [];
+ return player.hand.filter(card => {
+ if (card.kind !== 'special' || card.effect !== 'karama' ||
+ karamaSpendingBlock(g, player, card, 'auction-free-purchase')) return false;
+ try {
+ validateKaramaUse(g, player, { kind: invoice ? 'auctionPayment' : 'purchase' }, card);
+ return true;
+ } catch (error) { if (error instanceof RuleError) return false; throw error; }
+ }).map(card => card.id);
+}
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator {
if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor);
- for (const card of player.hand.filter(card => card.effect === effect && !transferCardBlock(g, player, card)))
+ if (effect === 'karamaAuction') {
+ for (const card of quotedAuctionKaramaCards(g, player)) yield g.decision?.kind === 'auctionPayment'
+ ? { type: 'decision', karama: true, card } : { type: 'card', card, mode: 'purchase' };
+ return;
+ }
+ for (const card of player.hand.filter(card => card.effect === windowCardNativeEffect(effect) && !transferCardBlock(g, player, card)))
for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) {
const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn,
active: g.active, me: actor, shieldWallDestroyed: g.shieldWallDestroyed,
@@ -18947,9 +18995,32 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
for (const required of pending) {
if (windowCardOpportunity(game, required.effect)?.source !== required.source) continue;
const play = function* (state: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
+ const lot = required.effect === 'karamaAuction' ? bidOpportunity(state) : null;
+ if (lot && bidRequired(state.bidPromises ?? [], actor, lot)) {
+ // Quote after any owned recovery, so its actual two-spice cost and
+ // recovered physical Karama participate in this same native bid.
+ const bid = bidPromiseCompletion(state, getPlayer(state, actor));
+ if (!bid || bid.type !== 'bid') return;
+ const accepted = structuredClone(state);
+ accepted.auction!.bid = bid.amount as number;
+ accepted.auction!.bidder = actor;
+ accepted.auction!.allyPayment = bid.allyPayment as number;
+ accepted.bidPromises = recordPromisedBid(accepted.bidPromises ?? [], actor, lot, bid.amount as number);
+ yield* play(accepted, [...prefix, bid]);
+ return;
+ }
for (const action of nativeWindowCardActions(state, actor, required.effect)) {
try {
const next = applyActionInner(state, actor, action, 'movementPreparation');
+ if (required.effect === 'karamaAuction') {
+ const before = getPlayer(state, actor), after = getPlayer(next, actor);
+ const known = before.faction === 'atreides' && state.auction?.peekKnown
+ ? state.auction.cards[state.auction.index]?.id : null;
+ (next as TurnActionCardProbe)._turnActionUnknownCards = [...new Set([
+ ...((state as TurnActionCardProbe)._turnActionUnknownCards ?? []),
+ ...after.hand.filter(card => card.id !== known && !before.hand.some(held => held.id === card.id)).map(card => card.id),
+ ])];
+ }
yield* search(next, [...actions, ...prefix, action], new Set([...played, key(required)]));
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
@@ -18957,12 +19028,12 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
yield* play(game, []);
const paid = game.pendingNullentropy;
if (paid?.player === actor) {
- if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === required.effect)) {
+ if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === windowCardNativeEffect(required.effect))) {
const recovered = projectMovementBoxRecovery(game, actor, paid.box, card.id, true);
if (recovered) yield* play(recovered, [{ type: 'decision', event: paid.event, card: card.id }]);
}
} else for (const box of getPlayer(game, actor).hand.filter(card => card.effect === 'nullentropyBox'))
- for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === required.effect)) {
+ for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === windowCardNativeEffect(required.effect))) {
const recovered = projectMovementBoxRecovery(game, actor, box.id, known.id, false);
if (recovered) yield* play(recovered, [{ type: 'card', card: box.id }]);
}
@@ -18989,9 +19060,11 @@ function reconcileTurnActionWindowCards(g: Game) {
if (!g.turnActionPromises) return;
for (const promise of g.turnActionPromises) {
if (promise.fulfilled || promise.released) continue;
- for (const effect of ['weather', 'atomics', 'harvester'] as const) {
+ for (const effect of WINDOW_CARD_EFFECTS) {
const stage = promise.windowCards?.[effect];
- if (!stage || stage.closed || promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
+ const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
+ g.decision.player !== promise.player;
+ if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
promise.player, promise.turn, effect, stage.source));
}
@@ -19002,7 +19075,7 @@ function reconcileTurnActionWindowCards(g: Game) {
function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[] {
let closed = g.turnActionPromises ?? [];
for (const promise of liveTurnActionPromises(closed, actor, g.turn))
- for (const effect of ['weather', 'atomics', 'harvester'] as const) {
+ for (const effect of WINDOW_CARD_EFFECTS) {
const stage = promise.windowCards?.[effect];
if (stage && !stage.closed) closed = turnActionRule(() =>
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source));
@@ -19011,7 +19084,7 @@ function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[]
}
function reopenTurnActionWindowReady(g: Game, actor: string) {
if (g.ready.includes(actor) && liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
- (['weather', 'atomics', 'harvester'] as const).some(effect => {
+ WINDOW_CARD_EFFECTS.some(effect => {
const stage = promise.windowCards?.[effect];
return stage && !stage.closed && windowCardOpportunity(g, effect)?.source === stage.source;
}))) g.ready = g.ready.filter(player => player !== actor);
@@ -19030,6 +19103,28 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
requireRule(!!turnActionCompletion(trial, player.id),
'This printed card play would remove the last legal completion of your whole Truthtrance answer.');
}
+/** The owned purchase irrevocably ends bidding before its foreign income or
+ * discard reactions. Quote that boundary without completing those reactions. */
+function guardTurnActionAuctionClosure(g: Game, actor: string, original: readonly TurnActionPromise[]) {
+ const relevant = original.filter(row => row.windowCards?.karamaAuction && !row.fulfilled && !row.released);
+ if (!relevant.length) return;
+ const trial = structuredClone(g);
+ trial.truthtrance = null; trial.response = null; trial.decision = null;
+ trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingTreacheryDiscard = null;
+ trial.auction = null; trial.currentAuctionSale = null;
+ for (const row of relevant) {
+ const stage = row.windowCards!.karamaAuction!;
+ trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(trial.turnActionPromises ?? [],
+ actor, row.turn, 'karamaAuction', stage.source));
+ const bid = row.preparation?.bid;
+ if (bid) trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding(trial.turnActionPromises!,
+ row.turn, bid.source, actor));
+ }
+ requireRule(!relevant.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released),
+ 'Finish your required bid or alternative before closing this Karama lot.');
+ reconcileBidPromises(trial, { actor, action: { type: 'card' } });
+ requireRule(!!turnActionCompletion(trial, actor), 'Retain a whole Truthtrance completion after this auction closes.');
+}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
}
@@ -19280,6 +19375,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
};
+ const forbiddenWindows = leaves.flatMap(leaf => leaf.kind === 'windowCardPlay' && !leaf.answer
+ ? [{ effect: leaf.effect, source: leaf.source }] : []);
+ if (forbiddenWindows.length) (trial as TurnActionCardProbe)._turnActionWindowCardPolicy = {
+ player: actor, forbidden: forbiddenWindows,
+ };
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
@@ -19602,7 +19702,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
- if (!action) action = completion.cardPreparations?.[0];
+ if (!action) {
+ const preparation = completion.cardPreparations?.[0];
+ if (preparation?.type !== 'bid' || g.auction?.active === player.id) action = preparation;
+ }
if (!action && g.phase <= 5) {
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
if (lot && g.auction?.active === player.id) action = completion.bid ??
@@ -19652,9 +19755,11 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
// The joint quotation can prepare a later answer while an earlier unrelated
// answer remains live. Window controls must bind the row naming that source.
const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined;
- const printedWindow = isWindowCardEffect(held?.effect) ? held.effect : null;
+ const printedWindow = held?.kind === 'special' && held.effect === 'karama' && action.mode === 'purchase'
+ ? 'karamaAuction' : isWindowCardEffect(held?.effect) ? held.effect : null;
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
- : action.type === 'ready' || held?.effect === 'nullentropyBox' ? ['weather', 'atomics', 'harvester'] : [];
+ : action.type === 'decision' && action.karama === true ? ['karamaAuction']
+ : action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
windows.some(effect => {
const stage = row.windowCards?.[effect];
@@ -27769,6 +27874,7 @@ function gholaOptions(g: Game, p: Player) {
.filter(
(c) =>
c.effect === 'ghola' &&
+ !(g as TurnActionCardProbe)._turnActionUnknownCards?.includes(c.id) &&
!(
g.richeseAuction?.source === 'blackMarket' &&
!g.currentAuctionSale &&
@@ -37512,6 +37618,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
'This revival commitment guide is stale or belongs to another native opportunity.');
}
const g = applyActionInner(state, id, action);
+ if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
+ const stage = row.windowCards?.karamaAuction;
+ return stage && !stage.closed && (windowCardOpportunity(g, 'karamaAuction')?.source !== stage.source ||
+ g.decision?.kind === 'auctionPayment' && g.decision.player !== id);
+ })) guardTurnActionAuctionClosure(g, id, liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn));
reconcileTravelPromises(g, id, action, state.phase);
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
@@ -42138,6 +42249,7 @@ export function viewGame(state: Game, id: string, now?: number) {
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
: null,
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
+ karamaAuctionCards: quotedAuctionKaramaCards(g, me),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
diff --git a/game/reference.ts b/game/reference.ts
index c11793a3..6d3cabc6 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3212,7 +3212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined card plays, battle and turn actions',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
- summary: 'Combine original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
+ summary: 'Combine current-lot printed Karama costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
@@ -3230,6 +3230,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
+ 'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
@@ -3237,11 +3238,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse current-lot printed Karama, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts'] },
+ { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts'] },
],
},
{
diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts
index 145c7051..51f1ce77 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 type { PrintedCardEffect } from './printed-card-play';
-import type { WindowCardEffect } from './window-card-play';
+import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play';
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
import {
@@ -119,7 +119,7 @@ const positive = (value: unknown): value is number => whole(value) && value > 0;
const physical = (value: unknown): value is number => whole(value) && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
-const windowEffect = (value: unknown): value is WindowCardEffect => value === 'weather' || value === 'atomics' || value === 'harvester';
+const windowEffect = (value: unknown): value is WindowCardEffect => WINDOW_CARD_EFFECTS.some(effect => effect === value);
/** Identity membership is decoded by Truthtrance; here retain one strict atom. */
function parseCombatClaim(value: unknown): MixedBattleActionClaim {
@@ -145,7 +145,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
try {
if (value.kind === 'windowCardPlay') {
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
- 'Choose one original Storm or fresh-blow printed-card play after this answer.');
+ 'Choose one original Storm, fresh-blow or ordinary-lot printed-card play after this answer.');
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
}
if (value.kind === 'battlePower') {
@@ -958,7 +958,7 @@ export function finishTurnActionTurn(promises: read
if (!live.has(promise)) return promise;
readPromise(promise);
const windowCards: TurnActionWindowCards = {};
- for (const effect of ['weather', 'atomics', 'harvester'] as const) {
+ for (const effect of WINDOW_CARD_EFFECTS) {
const stage = promise.windowCards?.[effect];
if (stage) windowCards[effect] = { ...stage, closed: true };
}
@@ -993,7 +993,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
- (['weather', 'atomics', 'harvester'] as const).every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
+ WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
const recipientsValid = (node: MixedActionExpression): boolean => node.kind === 'action'
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
diff --git a/game/window-card-play.ts b/game/window-card-play.ts
index 8831224d..5542a9b0 100644
--- a/game/window-card-play.ts
+++ b/game/window-card-play.ts
@@ -1,6 +1,10 @@
import { harvesterAvailable } from './cards';
-export type WindowCardEffect = 'weather' | 'atomics' | 'harvester';
+export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction'] as const;
+export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
+export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' {
+ return effect === 'karamaAuction' ? 'karama' : effect;
+}
export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number };
export type WindowCardGame = {
status: string;
@@ -21,8 +25,16 @@ export type WindowCardGame = {
decision?: unknown;
phaseOpening?: unknown;
truthtrance?: unknown;
+ /** Engine rows retain the physical array/index; public views expose only the lot. */
+ auction?: unknown;
+ bidPromiseLot?: { turn: number; lot: number } | null;
+ richeseAuction?: unknown;
+ currentAuctionSale?: unknown;
+ ixAuction?: unknown;
};
+const object = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const whole = (value: unknown): value is number =>
@@ -32,6 +44,27 @@ const whole = (value: unknown): value is number =>
* force conditions and chosen effect parameters remain native engine rules. */
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
+ if (effect === 'karamaAuction') {
+ if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null ||
+ !object(game.auction)) return null;
+ const auction = game.auction;
+ let lot: number | undefined;
+ if ('index' in auction || 'cards' in auction) {
+ if (!whole(auction.index) || !Array.isArray(auction.cards) || auction.index >= auction.cards.length ||
+ !object(auction.cards[auction.index])) return null;
+ lot = auction.index + 1;
+ if (!Number.isSafeInteger(lot)) return null;
+ }
+ if ('bidPromiseLot' in game) {
+ const publicLot = game.bidPromiseLot;
+ if (!object(publicLot) || Object.keys(publicLot).sort().join(',') !== 'lot,turn' ||
+ publicLot.turn !== game.turn || !whole(publicLot.lot) || publicLot.lot < 1 ||
+ lot !== undefined && lot !== publicLot.lot) return null;
+ lot = publicLot.lot;
+ }
+ if (lot === undefined) return null;
+ return { effect, source: `ordinary-auction:${game.turn}:${lot}`, turn: game.turn };
+ }
if (effect === 'weather' || effect === 'atomics') {
if (game.phase !== 0 || game.stormResolution || game.stormCardWindowOpen === false ||
game.stormCardWindowOpen !== undefined && typeof game.stormCardWindowOpen !== 'boolean' ||
diff --git a/tests/fixture-truth-bid.ts b/tests/fixture-truth-bid.ts
index 3100e166..6476c39e 100644
--- a/tests/fixture-truth-bid.ts
+++ b/tests/fixture-truth-bid.ts
@@ -53,6 +53,7 @@ export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
* auction, question, answer or promise is installed. */
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
+ firstLotEffect?: 'hajr';
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
@@ -71,7 +72,7 @@ export function createTruthBidFixture(options: {
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
- const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
+ const lotCards = [game.deck.find(card => options.firstLotEffect ? card.effect === options.firstLotEffect : card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
game.deck.find(card => card.kind === 'snooper')];
assert.ok(filler && targetCard && lotCards.every(Boolean));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
diff --git a/tests/fixture-truth-turn-karama.ts b/tests/fixture-truth-turn-karama.ts
new file mode 100644
index 00000000..5ec5a076
--- /dev/null
+++ b/tests/fixture-truth-turn-karama.ts
@@ -0,0 +1,151 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { TruthFact, TruthQuestion } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer,
+ type TruthBidStep } from './fixture-truth-bid';
+import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
+import type { TruthTurnWindowCardLeaf } from './fixture-truth-turn-window-card';
+
+export type TruthTurnKaramaLeaf = TruthTurnWindowCardLeaf |
+ { kind: 'windowCardPlay'; effect: 'karamaAuction'; source: string };
+export type TruthTurnKaramaExpression = MixedActionExpression;
+export type TruthTurnKaramaStep = TruthBidStep;
+export const actTruthTurnKarama = actTruthBid;
+export const truthTurnKaramaPlayer = truthBidPlayer;
+export const truthTurnKaramaInventory = truthBidInventory;
+export const truthTurnKaramaTraitors = truthTraitorDecisionTraitors;
+export const truthTurnKaramaClean = (game: Game) => truthBidClean(game) && !game.pendingRevival && !game.pendingShipment;
+export const truthTurnKaramaFact = (fact: TruthFact): TruthTurnKaramaExpression => ({ kind: 'fact', fact });
+export const truthTurnKaramaPlay = (source: string): TruthTurnKaramaExpression =>
+ ({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaAuction', source } });
+export const truthTurnKaramaPayment = (recipient: string, minimum = 2): TruthTurnKaramaExpression =>
+ ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
+export const truthTurnKaramaBid = (lot = 1, minimum = 3): TruthTurnKaramaExpression =>
+ ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
+export const truthTurnKaramaGroup = (kind: 'and' | 'or', ...terms: TruthTurnKaramaExpression[]): TruthTurnKaramaExpression =>
+ ({ kind, terms });
+
+/** Thin genuine original H/A/E base33 adapter. The live-opponent variant
+ * follows original Basic waiting23 by a real H bid1 and A pass: E can really bid,
+ * then buy immediately while H still owns the next native bidder turn. */
+export function createTruthTurnKaramaFixture(options: {
+ window?: 'active' | 'waiting'; advanced?: boolean; liveOpponent?: boolean;
+} = {}) {
+ const liveOpponent = options.liveOpponent ?? false;
+ const base = createTruthBidFixture({ targetKarama: true, advanced: options.advanced,
+ window: liveOpponent ? 'waiting' : options.window ?? 'active' });
+ let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
+ if (liveOpponent) {
+ game = actTruthTurnKarama(game, base.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
+ game = actTruthTurnKarama(game, base.opponent, { type: 'passBid' }, actions);
+ assert.equal(game.auction?.active, base.target); assert.equal(game.auction?.bidder, base.asker);
+ assert.equal(game.auction?.passed.includes(base.asker), false);
+ }
+ assert.ok(base.karama && game.auction);
+ const opportunity = windowCardOpportunity(game, 'karamaAuction'); assert.ok(opportunity);
+ const source = opportunity.source, lot = game.auction.index + 1, asker = base.asker, target = base.target, recipient = asker;
+ assert.equal(source, `ordinary-auction:${game.turn}:${lot}`);
+ const mixed = truthTurnKaramaGroup('and', truthTurnKaramaPlay(source), truthTurnKaramaPayment(recipient));
+ const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
+ const originalTraitors = truthTurnKaramaTraitors(game);
+ assert.equal(originalTraitors.length, 15); assert.deepEqual(truthTurnKaramaInventory(game), base.originalCards);
+ assert.deepEqual(truthTurnKaramaPlayer(game, asker).hand.map(card => card.id), base.truths);
+ assert.deepEqual(truthTurnKaramaPlayer(game, target).hand.map(card => card.id), [base.karama]);
+ const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnKaramaPlayer(game, target).spice };
+ return { game, base, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
+ beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer: base.opponent,
+ truths: structuredClone(base.truths), truth: base.truth, karama: base.karama, card: base.karama,
+ effect: 'karamaAuction' as const, source, opportunity, lot, lotCard: base.lotCard, lotCards: base.lotCards,
+ advanced: base.advanced, window: base.window, liveOpponent, mixed, question, spiceFact,
+ purchase: { type: 'card', card: base.karama, mode: 'purchase' } satisfies Action,
+ karamaPayment: { type: 'decision', karama: true, card: base.karama } satisfies Action,
+ spicePayment: { type: 'decision', karama: false } satisfies Action,
+ bid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
+ payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
+ originalCards: base.originalCards, originalTraitors, originalSpice: base.originalSpice,
+ actions, sourceActions: actions, sourceActionCount: actions.length,
+ sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length },
+ sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw printed-Karama/current-lot question. ` +
+ 'The original held Karama opens the native Atreides auction-inspection response; all three real response passes remain in the recorded source prefix. ' +
+ (liveOpponent ? 'Actually bid1 with original Harkonnen and pass with Atreides, leaving the original H bidder live when Emperor acts. ' : '') +
+ 'Both original H Truthtrances and the original Emperor Karama retain physical custody. No hand, purse, army, lot, bid, invoice, cancellation response, cost receipt or promise is installed.',
+ };
+}
+export type TruthTurnKaramaFixture = ReturnType;
+
+export function declareTruthTurnKarama(f: TruthTurnKaramaFixture, source = f.game, copy: 0 | 1 = 0,
+ actions: TruthTurnKaramaStep[] = []) {
+ let game = actTruthTurnKarama(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
+ while (game.truthtrance?.stage === 'priority') {
+ const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
+ game = actTruthTurnKarama(game, actor.id, { type: 'truthPass' }, actions);
+ }
+ assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
+}
+export function askTruthTurnKarama(game: Game, asker: string, target: string, mixed: TruthTurnKaramaExpression,
+ actions: TruthTurnKaramaStep[] = []): Game {
+ return actTruthTurnKarama(game, asker, { type: 'truthAsk', question: {
+ kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
+ } }, actions);
+}
+export function answerTruthTurnKarama(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
+ actions: TruthTurnKaramaStep[] = []): Game {
+ return actTruthTurnKarama(game, target, { type: 'truthAnswer', answer }, actions);
+}
+export function bindTruthTurnKarama(f: TruthTurnKaramaFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
+ source = f.game, copy: 0 | 1 = 0, actions: TruthTurnKaramaStep[] = []) {
+ const declared = declareTruthTurnKarama(f, source, copy, actions);
+ const asked = askTruthTurnKarama(declared.game, f.asker, f.target, mixed, actions);
+ return { game: answerTruthTurnKarama(asked, f.target, answer, actions), declared, asked, actions };
+}
+export function truthTurnKaramaPromise(game: Game, target = game.turnActionPromises![0].player) {
+ const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
+}
+export function nextTruthTurnKaramaNative(game: Game): TruthTurnKaramaStep | null {
+ return nextTruthBid(game);
+}
+export function nextTruthTurnKarama(game: Game): TruthTurnKaramaStep | null {
+ if (game.truthtrance) return null;
+ for (const player of game.players) {
+ const guide = viewGame(game, player.id).turnActionGuide;
+ if (guide) return { actor: player.id, action: guide.action };
+ }
+ return nextTruthTurnKaramaNative(game);
+}
+export function advanceTruthTurnKarama(game: Game, until: (state: Game) => boolean,
+ actions: TruthTurnKaramaStep[] = [], nativeOnly = false): Game {
+ for (let count = 0; count < 1400; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Karama-auction source ended before its native boundary');
+ const step = nativeOnly ? nextTruthTurnKaramaNative(game) : nextTruthTurnKarama(game);
+ assert.ok(step, 'Supply the actual Truthtrance or original unfunded Karama invoice choice');
+ game = actTruthTurnKarama(game, step.actor, step.action, actions);
+ }
+ throw Error('The original Karama-auction source did not reach its finite boundary');
+}
+export function settleTruthTurnKarama(game: Game, actions: TruthTurnKaramaStep[] = []): Game {
+ return advanceTruthTurnKarama(game, truthTurnKaramaClean, actions, true);
+}
+export function winTruthTurnKaramaBid(game: Game, f: TruthTurnKaramaFixture, amount = 3,
+ actions: TruthTurnKaramaStep[] = []): Game {
+ game = advanceTruthTurnKarama(game, state => state.auction?.active === f.target && truthTurnKaramaClean(state), actions, true);
+ game = actTruthTurnKarama(game, f.target, { type: 'bid', amount, allyPayment: 0 }, actions);
+ return reachTruthTurnKaramaInvoice(game, f, actions);
+}
+/** A new bid reopens both other original bidders. Actually pass each remaining
+ * native turn before stopping at the winning Emperor's printed-card invoice. */
+export function reachTruthTurnKaramaInvoice(game: Game, f: TruthTurnKaramaFixture,
+ actions: TruthTurnKaramaStep[] = []): Game {
+ return advanceTruthTurnKarama(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard &&
+ state.decision?.kind === 'auctionPayment' && state.decision.player === f.target, actions, true);
+}
+export function closeTruthTurnKaramaLot(game: Game, f: TruthTurnKaramaFixture, actions: TruthTurnKaramaStep[] = []): Game {
+ return advanceTruthTurnKarama(game, state => truthTurnKaramaClean(state) &&
+ windowCardOpportunity(state, 'karamaAuction')?.source !== f.source, actions, true);
+}
+export function finishTruthTurnKarama(game: Game, actions: TruthTurnKaramaStep[] = []): Game {
+ const turn = game.turn;
+ return advanceTruthTurnKarama(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnKaramaClean(state), actions);
+}
diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx
index 89b8f3f7..e88b5315 100644
--- a/tests/truth-turn-action-controls.test.tsx
+++ b/tests/truth-turn-action-controls.test.tsx
@@ -32,7 +32,8 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
-const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow' : 'storm:2'): Expression =>
+const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
+ : effect === 'karamaAuction' ? 'ordinary-auction:2:2' : 'storm:2'): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
@@ -102,6 +103,16 @@ function auction(next: Action = { type: 'passBid' }): GameView {
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
return game;
}
+function auctionKarama(invoice = false): GameView {
+ const card = baseDeck().find(card => card.kind === 'special' && card.effect === 'karama'); assert.ok(card);
+ const id = 'controlled-printed-karama';
+ const game = auction(invoice ? { type: 'decision', karama: true, card: id } : { type: 'card', card: id, mode: 'purchase' });
+ const me = game.players.find(player => player.id === 'p')!; me.hand = [{ ...card, id }]; me.handLimit = 4;
+ game.karamaAuctionCards = [id];
+ game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaAuction'), payment()] };
+ if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
+ return game;
+}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -805,6 +816,109 @@ void test('window Ready guides retain the exact live raw source, known Storm dis
replaced.spiceWindow = { ...replaced.spiceWindow! }; press(saved);
});
+void test('current ordinary-lot Karama picker is public, source-bound and repairable within the shared logical budget', () => {
+ const game = auctionKarama(), e = editor(physical(), false, game);
+ for (const player of game.players) for (const key of ['hand', 'spice', 'leaders', 'traitors', 'forces'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private Karama authoring ${key} read`); }, configurable: true });
+ Object.defineProperty(game, 'karamaAuctionCards', { get() { throw Error('The public question must not inspect the owner’s native card quote'); } });
+ select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaAuction')); assert.equal(e.error(), null);
+ assert.match(e.html(), /Spend a printed Karama for this current ordinary lot/); assert.match(e.html(), /Ordinary lot 2/);
+ select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
+ select(e.tree(), '1', 'window-source', 'ordinary-auction:2:2'); assert.equal(e.error(), null);
+ game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error());
+ select(e.tree(), '1', 'window-source', 'ordinary-auction:2:3'); assert.equal(e.error(), null);
+ const originalAuction = game.auction;
+ game.auction = null; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/); game.auction = originalAuction;
+ for (const extra of [{ card: 'private-karama' }, { mode: 'purchase' }, { amount: 0 }])
+ assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaAuction', source: 'ordinary-auction:2:3', ...extra } }, game, 'p'));
+ const fullGame = auctionKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaAuction'), fact()] }, false, fullGame);
+ for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
+ assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
+ const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaAuction')] }, false, fullGame);
+ click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
+ assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
+});
+
+void test('printed current-lot Karama guides forward exact purchase and winning-invoice packets using native owner quotes', () => {
+ for (const invoice of [false, true]) {
+ const game = auctionKarama(invoice), sent: Action[] = [];
+ for (const key of ['windowCards', 'compiled', 'cardPlays'])
+ Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} Karama guide read`); }, configurable: true });
+ const button = guide({ game, busy: false, act(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
+ assert.equal(sent[0].card, 'controlled-printed-karama'); assert.equal(sent[0].turnActionPromisePhase, 3);
+ assert.equal(sent[0].turnActionPromiseEvent, event);
+ if (invoice) assert.match(renderToStaticMarkup(button), /printed Karama on this ordinary-lot winning invoice/);
+ else { assert.equal(sent[0].mode, 'purchase'); assert.equal(game.auction!.active, 'a', 'The native purchase remains possible outside the bidder’s turn.'); }
+ game.turnActionGuide = null;
+ const html = markup(game); assert.match(html, /printed Karama/); assert.match(html, /immediate purchase/);
+ assert.match(html, /Worthless conversion and a successful bid alone do not count/); assert.match(html, /closes with the original ordinary lot/);
+ assert.doesNotMatch(html, /windowCards|controlled-printed-karama/);
+ }
+});
+
+void test('saved printed-Karama auction callbacks expire with lot, native quote, physical card, owner and callback changes', () => {
+ for (const invoice of [false, true]) for (const change of [
+ (game: GameView) => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
+ (game: GameView) => { game.auction = { ...game.auction! }; },
+ (game: GameView) => { game.auction!.active = 'r'; },
+ (game: GameView) => { game.karamaAuctionCards = []; },
+ (game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
+ (game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaAuction', 'ordinary-auction:2:1'); },
+ (game: GameView) => { game.me = 'a'; },
+ ]) {
+ const game = auctionKarama(invoice), button = guide({ game, busy: false, act() { assert.fail('A stale ordinary-lot printed Karama cannot send'); } });
+ assert.equal(button.props.disabled, false); change(game); press(button);
+ }
+ const game = auctionKarama(), props: Props = { game, busy: false, act() { assert.fail('A saved Karama callback cannot send after replacement'); } };
+ const saved = guide(props); props.act = () => assert.fail('The replacement callback cannot receive an old purchase'); press(saved);
+ const full = auctionKarama(), fullButton = guide({ game: full, busy: false, act() { assert.fail('A newly full hand cannot purchase the lot'); } });
+ full.players.find(player => player.id === 'p')!.handLimit = 1; press(fullButton);
+ assert.equal(guide({ game: full, busy: false, act() { assert.fail('An initially full hand cannot purchase the lot'); } }).props.disabled, true);
+});
+
+void test('printed auction-role guides refuse conversion, unsupported packets and generic purchase fallback while retaining unrelated invoices', () => {
+ for (const invoice of [false, true]) for (const extra of [{ amount: 0 }, { target: 'r' }, { leader: 'foreign-leader' },
+ ...(invoice ? [{ mode: 'purchase' }] : [{ mode: 'shipment' }])]) {
+ const game = auctionKarama(invoice); Object.assign(game.turnActionGuide!.action, extra);
+ const button = guide({ game, busy: false, act() { assert.fail('An unsupported auction-role packet cannot send'); } });
+ assert.equal(button.props.disabled, true); press(button);
+ }
+ for (const invoice of [false, true]) {
+ const game = auctionKarama(invoice), card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
+ card.kind = 'worthless'; delete card.effect;
+ assert.equal(guide({ game, busy: false, act() { assert.fail('Worthless conversion is not printed Karama use'); } }).props.disabled, true);
+ const missingQuote = auctionKarama(invoice); missingQuote.karamaAuctionCards = [];
+ assert.equal(guide({ game: missingQuote, busy: false, act() { assert.fail('A native card quote is required'); } }).props.disabled, true);
+ }
+ for (const phase of [2, 4, 5, 6]) {
+ const game = auctionKarama(); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase;
+ assert.equal(guide({ game, busy: false, act() { assert.fail('A purchase cannot become generic preparation'); } }).props.disabled, true);
+ }
+ const foreign = auctionKarama(true); foreign.decision = { kind: 'auctionPayment', player: 'a' };
+ assert.equal(guide({ game: foreign, busy: false, act() { assert.fail('A foreign invoice cannot consume this printed Karama'); } }).props.disabled, true);
+ const prior = auctionKarama(true); prior.turnActionPromises[0].mixed = payment(); prior.karamaAuctionCards = [];
+ for (const player of prior.players) Object.defineProperty(player, 'hand', { get() { throw Error('Existing unrelated invoice guides keep their original native authority'); } });
+ const sent: Action[] = [], oldInvoice = guide({ game: prior, busy: false, act(action) { sent.push(action); } });
+ assert.equal(oldInvoice.props.disabled, false); press(oldInvoice); assert.equal(sent[0], prior.turnActionGuide!.action);
+ const later = auctionKarama(true); later.bidPromiseLot = { turn: 2, lot: 3 }; later.karamaAuctionCards = [];
+ for (const player of later.players) Object.defineProperty(player, 'hand', { get() { throw Error('An older raw window cannot impose printed-role eligibility on an unrelated later invoice'); } });
+ const laterSent: Action[] = [], laterInvoice = guide({ game: later, busy: false, act(action) { laterSent.push(action); } });
+ assert.equal(laterInvoice.props.disabled, false); press(laterInvoice); assert.equal(laterSent[0], later.turnActionGuide!.action);
+});
+
+void test('current ordinary-lot printed-role questions retain quoted owned Box routes before purchase and inside their own invoice', () => {
+ for (const invoice of [false, true]) {
+ const game = auctionKarama(invoice), me = game.players.find(player => player.id === 'p')!;
+ me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }];
+ game.nullentropy = { card: me.hand[0], blocked: null, search: null };
+ game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: 3 };
+ const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
+ game.bidPromiseLot = { turn: 2, lot: 3 }; press(button); assert.equal(sent.length, 1);
+ }
+});
+
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
diff --git a/tests/truth-turn-karama-forecast.test.ts b/tests/truth-turn-karama-forecast.test.ts
new file mode 100644
index 00000000..a4002d81
--- /dev/null
+++ b/tests/truth-turn-karama-forecast.test.ts
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { createTruthBidFixture, declareTruthBid, actTruthBid, truthBidInventory } from './fixture-truth-bid';
+import { askTruthTurnKarama, settleTruthTurnKarama, truthTurnKaramaGroup, truthTurnKaramaPlay,
+ createTruthTurnKaramaFixture, bindTruthTurnKarama } from './fixture-truth-turn-karama';
+
+void test('an unseen auction Hajr cannot fund a future card promise before its actual Karama acquisition', () => {
+ const f = createTruthBidFixture({ targetKarama: true, firstLotEffect: 'hajr', window: 'active' });
+ assert.equal(f.game.auction?.cards[0].effect, 'hajr');
+ assert.equal(viewGame(f.game, f.target).auction?.card, null);
+ const source = windowCardOpportunity(f.game, 'karamaAuction')!;
+ const mixed = truthTurnKaramaGroup('and', truthTurnKaramaPlay(source.source),
+ { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
+ let game = declareTruthBid(f).game;
+ game = askTruthTurnKarama(game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
+ game = actTruthBid(game, f.target, { type: 'truthAnswer', answer: 'no' });
+ game = settleTruthTurnKarama(actTruthBid(game, f.target, { type: 'card', card: f.karama!, mode: 'purchase' }));
+ assert.ok(game.players.find(player => player.id === f.target)!.hand.some(card => card.effect === 'hajr'));
+ assert.deepEqual(truthBidInventory(game), f.originalCards);
+ // The actual acquisition makes the printed card owned knowledge. A second
+ // original Truth can now ask about the known remaining Movement play.
+ game = actTruthBid(game, f.asker, { type: 'card', card: f.truths[1] });
+ while (game.truthtrance?.stage === 'priority') {
+ const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!;
+ game = actTruthBid(game, actor.id, { type: 'truthPass' });
+ }
+ game = askTruthTurnKarama(game, f.asker, f.target,
+ { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
+ // The earlier No still forbids fulfilling the AND by that Hajr. It is the
+ // original binding, rather than lack of owned card knowledge, that applies.
+ assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
+ let owned = settleTruthTurnKarama(actTruthBid(f.game, f.target, { type: 'card', card: f.karama!, mode: 'purchase' }));
+ owned = declareTruthBid(f, owned).game;
+ owned = askTruthTurnKarama(owned, f.asker, f.target,
+ { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
+ assert.ok(viewGame(owned, f.target).truthTurnActionAnswers?.includes('yes'),
+ 'Actual ownership permits the native future play when no earlier answer forbids it');
+});
+
+void test('the last native pass cannot close an own unfulfilled printed Karama promise', () => {
+ const f = createTruthTurnKaramaFixture();
+ const game = bindTruthTurnKarama(f, 'yes', truthTurnKaramaPlay(f.source)).game;
+ assert.equal(game.auction?.active, f.target);
+ const before = structuredClone(game);
+ assert.throws(() => actTruthBid(game, f.target, { type: 'passBid' }), /Karama lot|Truthtrance/);
+ assert.deepEqual(game, before);
+ const played = settleTruthTurnKarama(actTruthBid(game, f.target, f.purchase));
+ assert.equal(played.turnActionPromises![0].fulfilled, true);
+});
diff --git a/tests/truth-turn-karama-runtime.test.ts b/tests/truth-turn-karama-runtime.test.ts
new file mode 100644
index 00000000..20d3b8de
--- /dev/null
+++ b/tests/truth-turn-karama-runtime.test.ts
@@ -0,0 +1,270 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import {
+ actTruthTurnKarama as act, advanceTruthTurnKarama as advance, answerTruthTurnKarama as answer,
+ askTruthTurnKarama as ask, bindTruthTurnKarama as bind, closeTruthTurnKaramaLot as close,
+ createTruthTurnKaramaFixture as fixture, declareTruthTurnKarama as declare, finishTruthTurnKarama as finish,
+ nextTruthTurnKaramaNative as nativeStep, reachTruthTurnKaramaInvoice as reachInvoice, settleTruthTurnKarama as settle,
+ truthTurnKaramaBid as bid, truthTurnKaramaClean as clean, truthTurnKaramaFact as fact,
+ truthTurnKaramaGroup as group, truthTurnKaramaInventory as inventory, truthTurnKaramaPayment as payment,
+ truthTurnKaramaPlay as play, truthTurnKaramaPlayer as player, truthTurnKaramaPromise as promise,
+ truthTurnKaramaTraitors as traitors, winTruthTurnKaramaBid as invoice,
+ type TruthTurnKaramaFixture, type TruthTurnKaramaStep,
+} from './fixture-truth-turn-karama';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function physical(game: Game, f: TruthTurnKaramaFixture, copies: 1 | 2 = 1) {
+ for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
+}
+function receipt(game: Game, index = 0) {
+ const stage = game.turnActionPromises![index].windowCards?.karamaAuction; assert.ok(stage); return stage;
+}
+
+void test('native Karama sources preserve actual opening two Truths, original printed card and recorded bidder controls', () => {
+ for (const advanced of [false, true]) for (const window of ['active', 'waiting'] as const) {
+ const f = fixture({ advanced, window }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15);
+ assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
+ assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
+ assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.karama]);
+ assert.equal(player(f.game, f.target).spice, 10); assert.equal(f.game.advanced, advanced);
+ assert.equal(f.source, 'ordinary-auction:1:1'); assert.deepEqual(windowCardOpportunity(f.game, 'karamaAuction'), f.opportunity);
+ assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.inherited, f.base.sourceActions.length);
+ assert.equal(f.sourceCounts.nativeContinuation, 0); assert.equal(f.sourceCounts.selected, f.base.sourceActionCount);
+ assert.equal(f.sourceActions.filter(step => step.action.type === 'passBid').length, window === 'active' ? 2 : 0);
+ // The held Karama creates three genuine Atreides-inspection response passes.
+ assert.equal(f.sourceActionCount, (advanced ? 26 : 23) + (window === 'active' ? 2 : 0));
+ assert.equal(f.game.auction?.active, window === 'active' ? f.target : f.asker);
+ assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
+ assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
+ }
+ const f = fixture({ liveOpponent: true }); assert.equal(f.sourceCounts.inherited, 23);
+ assert.equal(f.sourceCounts.nativeContinuation, 2); assert.equal(f.sourceActionCount, f.sourceActions.length);
+ assert.equal(f.sourceActionCount, 25);
+ assert.equal(f.game.auction?.bidder, f.asker); assert.equal(f.game.auction?.bid, 1);
+ assert.equal(f.game.auction?.active, f.target); assert.equal(f.game.auction?.passed.includes(f.asker), false);
+});
+
+void test('a printed Karama purchase Yes grants the exact original lot for free and records one future physical cost', () => {
+ for (const window of ['active', 'waiting'] as const) {
+ const f = fixture({ window }); let game = bind(f, 'yes', play(f.source)).game;
+ assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
+ const purse = player(game, f.target).spice; game = settle(act(game, f.target, f.purchase));
+ assert.equal(player(game, f.target).spice, purse); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.notEqual(windowCardOpportunity(game, 'karamaAuction')?.source, f.source); physical(game, f);
+ }
+});
+
+void test('a real winning invoice can spend the original printed Karama without spice even for a native overbid', () => {
+ const f = fixture(); let game = bind(f, 'yes', play(f.source)).game;
+ game = invoice(game, f, 11); assert.equal(game.auction?.bid, 11); assert.equal(game.decision?.kind, 'auctionPayment');
+ assert.equal(player(game, f.target).spice, 10); assert.equal(receipt(game).played, false);
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'decision'); assert.equal(guide.action.karama, true); assert.equal(guide.action.card, f.karama);
+ game = settle(act(game, f.target, guide.action));
+ assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); physical(game, f);
+});
+
+void test('No to this printed auction cost blocks immediate and invoice Karama while preserving the native spice payment', () => {
+ const f = fixture(); let game = bind(f, 'no', play(f.source)).game;
+ rejected(game, f.target, f.purchase); game = invoice(game, f);
+ rejected(game, f.target, f.karamaPayment); game = settle(act(game, f.target, f.spicePayment));
+ assert.equal(player(game, f.target).spice, 7); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
+ assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
+ assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('whole printed-auction OR payment Yes preserves either actual free purchase or ordinary paid spice route', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ const bound = bind(f, 'yes', mixed).game;
+ const bought = settle(act(bound, f.target, f.purchase));
+ assert.equal(promise(bought, f.target).fulfilled, true); assert.equal(player(bought, f.recipient).bribes, 0);
+ let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true);
+ paid = invoice(paid, f); paid = settle(act(paid, f.target, f.spicePayment));
+ assert.equal(player(paid, f.target).spice, 5); assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama));
+ assert.equal(receipt(paid).played, false); physical(bought, f); physical(paid, f);
+});
+
+void test('whole printed-auction AND payment No caps only completion and survives a free purchase until actual turn end', () => {
+ const f = fixture();
+ let paid = bind(f, 'no').game; paid = act(paid, f.target, f.payment);
+ rejected(paid, f.target, f.purchase); paid = invoice(paid, f); rejected(paid, f.target, f.karamaPayment);
+ paid = settle(act(paid, f.target, f.spicePayment));
+ assert.deepEqual(receipt(paid), { source: f.source, played: false, closed: true });
+ assert.ok(!promise(paid, f.target).released); assert.ok(!promise(paid, f.target).fulfilled);
+ assert.equal(paid.turnActionPromises![0].paymentClosed, false);
+ paid = finish(paid); assert.equal(promise(paid, f.target).fulfilled, true); physical(paid, f);
+ let bought = settle(act(bind(f, 'no').game, f.target, f.purchase));
+ assert.equal(receipt(bought).played, true); rejected(bought, f.target, f.payment);
+ bought = act(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ rejected(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ assert.ok(!promise(bought, f.target).fulfilled); bought = finish(bought);
+ assert.equal(promise(bought, f.target).fulfilled, true); physical(bought, f);
+});
+
+void test('whole printed-auction OR payment No keeps its cumulative payment cap after the original lot closes', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.purchase); rejected(game, f.target, f.payment);
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
+ assert.equal(receipt(game).closed, true); assert.equal(receipt(game).played, false);
+ rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('one funded current purse can pay all ten and still fulfill printed Karama with a free original purchase', () => {
+ const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient, 10));
+ let game = bind(f, 'yes', mixed).game;
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 10 });
+ assert.equal(player(game, f.target).spice, 0); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase');
+ game = settle(act(game, f.target, guide.action));
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 10);
+ assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ physical(game, f);
+});
+
+void test('a required future bid precedes printed purchase and still permits either immediate or winning-invoice native cost', () => {
+ const f = fixture({ liveOpponent: true }), mixed = group('and', bid(f.lot), play(f.source), payment(f.recipient));
+ for (const route of ['purchase', 'invoice'] as const) {
+ const actions: TruthTurnKaramaStep[] = [];
+ let game = bind(f, 'yes', mixed, f.game, 0, actions).game;
+ rejected(game, f.target, f.purchase);
+ game = act(game, f.target, f.payment, actions);
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 3);
+ game = act(game, f.target, guide.action, actions);
+ assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 3);
+ assert.equal(game.auction?.active, f.asker); assert.equal(receipt(game).played, false);
+ if (route === 'invoice') {
+ game = act(game, f.asker, { type: 'passBid' }, actions); assert.equal(game.auction?.active, f.observer);
+ game = reachInvoice(game, f, actions); assert.equal(game.auction?.bid, 3);
+ assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision?.player, f.target);
+ const paying = viewGame(game, f.target).turnActionGuide; assert.ok(paying);
+ assert.equal(paying.action.type, 'decision'); assert.equal(paying.action.karama, true);
+ assert.equal(paying.action.card, f.karama); game = act(game, f.target, paying.action, actions);
+ } else game = act(game, f.target, f.purchase, actions);
+ game = settle(game, actions); assert.equal(player(game, f.target).spice, 8);
+ assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ const bidAt = actions.findIndex(step => step.actor === f.target && step.action.type === 'bid');
+ const costAt = actions.findIndex(step => step.actor === f.target && (step.action.type === 'card' && step.action.card === f.karama ||
+ step.action.type === 'decision' && step.action.karama === true));
+ assert.ok(bidAt >= 0 && costAt > bidAt); physical(game, f);
+ }
+});
+
+void test('two actual Truths reject bid ten plus pay ten when the earlier No forbids the only free Karama invoice', () => {
+ const f = fixture(); let game = bind(f, 'no', play(f.source)).game;
+ const earlier = structuredClone(game.turnActionPromises![0]), history = structuredClone(game.truthHistory![0]);
+ const second = group('and', bid(f.lot, 10), payment(f.recipient, 10));
+ const declared = declare(f, game, 1), asked = ask(declared.game, f.asker, f.target, second);
+ assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = answer(asked, f.target, 'no'); assert.deepEqual(game.turnActionPromises![0], earlier);
+ assert.deepEqual(game.truthHistory![0], history); assert.equal(player(game, f.target).spice, 10);
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.karama)); physical(game, f, 2);
+});
+
+void test('an original first-lot No permits the actual printed Karama in a later lot without crediting the earlier receipt', () => {
+ const f = fixture({ window: 'waiting' }); const actions: TruthTurnKaramaStep[] = [];
+ let game = bind(f, 'no', play(f.source), f.game, 0, actions).game;
+ const bidderPurse = player(game, f.asker).spice, emperorPurse = player(game, f.target).spice;
+ game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
+ game = act(game, f.observer, { type: 'passBid' }, actions); game = act(game, f.target, { type: 'passBid' }, actions);
+ // H holds no Karama: its native one-spice purchase is automatic. The named
+ // boundary helper really completes Emperor income, H bonus and next-lot peek.
+ game = close(game, f, actions); assert.equal(game.auction?.index, 1);
+ assert.equal(player(game, f.asker).spice, bidderPurse - 1);
+ assert.equal(player(game, f.target).spice, emperorPurse + 1);
+ assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
+ assert.equal(promise(game, f.target).fulfilled, true); const evidence = structuredClone(receipt(game));
+ assert.equal(evidence.played, false); assert.equal(evidence.closed, true);
+ const next = windowCardOpportunity(game, 'karamaAuction'); assert.ok(next); assert.notEqual(next.source, f.source);
+ const nextCard = game.auction!.cards[game.auction!.index].id, purse = player(game, f.target).spice;
+ game = settle(act(game, f.target, f.purchase, actions), actions);
+ assert.ok(player(game, f.target).hand.some(card => card.id === nextCard)); assert.equal(player(game, f.target).spice, purse);
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(receipt(game), evidence);
+ assert.ok(actions.some(step => step.actor === f.asker && step.action.type === 'bid'));
+ physical(game, f);
+});
+
+void test('actual later Karama shipment is a different role and cannot fulfill an expired auction alternative', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ let game = close(bind(f, 'yes', mixed).game, f);
+ assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
+ game = act(game, f.target, { type: 'card', card: f.karama, mode: 'shipment', target: f.target });
+ assert.ok(game.karamaShipping); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(receipt(game).played, false); assert.ok(!promise(game, f.target).fulfilled);
+ const purse = player(game, f.target).spice;
+ game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
+ assert.equal(player(game, f.target).spice, purse - 1, 'Native Karama halves the original two-spice desert tariff');
+ assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
+ game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.equal(receipt(game).played, false); physical(game, f);
+});
+
+void test('Karama guide packets retain original lot and physical card identity and reject stale sources or owners', () => {
+ const f = fixture(), declared = declare(f);
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target,
+ turn: f.game.turn, mixed: play(`${f.source}:stale`) } });
+ rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
+ let game = bind(f).game; game = act(game, f.target, f.payment);
+ const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase');
+ assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
+ assert.equal(guide.action.turnActionPromisePhase, 3);
+ assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
+ assert.deepEqual(viewGame(game, f.observer).turnActionPromises, []); assert.equal(viewGame(game, f.observer).turnActionGuide, null);
+ rejected(game, f.observer, guide.action);
+ rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-event' });
+ rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
+ const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
+ rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true);
+ const second = declare(f, game, 1);
+ rejected(second.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(f.source) } }); physical(game, f);
+});
+
+void test('all four minimal profiles execute funded native printed Karama guides including a required first bid', () => {
+ for (const joint of [false, true]) {
+ const f = fixture({ liveOpponent: joint }), mixed = joint ? group('and', bid(f.lot), play(f.source), payment(f.recipient)) : f.mixed;
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(asked); const actions: TruthTurnKaramaStep[] = [];
+ const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
+ assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
+ for (let count = 0; count < 26 && !promise(game, f.target).fulfilled; count++) {
+ const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ const guide = view.turnActionGuide;
+ if (guide) {
+ const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
+ assert.ok(action, `${joint}/${difficulty}`); assert.deepEqual(action, guide.action);
+ game = act(game, f.target, action, actions);
+ } else {
+ const next = nativeStep(game); assert.ok(next, `${joint}/${difficulty}`);
+ game = act(game, next.actor, next.action, actions);
+ }
+ }
+ assert.equal(promise(game, f.target).fulfilled, true, `${joint}/${difficulty}`);
+ assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
+ assert.ok(actions.some(step => step.actor === f.target && (step.action.type === 'card' && step.action.card === f.karama ||
+ step.action.type === 'decision' && step.action.karama === true)));
+ if (joint) assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bid'));
+ game = settle(game); physical(game, f);
+ }
+ }
+});
diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts
index e1fe4672..c5324708 100644
--- a/tests/turn-action-promises.test.ts
+++ b/tests/turn-action-promises.test.ts
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
-import type { WindowCardEffect } from '../game/window-card-play';
+import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from '../game/window-card-play';
import {
compileMixedActionExpression,
type MixedActionExpression,
@@ -59,7 +59,9 @@ const call = (call = true, battle = 'battle:1'): Expression =>
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
-const windowCard = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'spice-blow:1' : 'storm:1'): Expression =>
+const windowSource = (effect: WindowCardEffect) => effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
+ effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
+const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
@@ -1268,8 +1270,8 @@ void test('power predicates deduplicate only exact original clauses and retain t
});
void test('window-card raw leaves name only a printed role and exact original source, including folded clauses', () => {
- for (const effect of ['weather', 'atomics', 'harvester'] as const) {
- const original = { kind: 'windowCardPlay', effect, source: effect === 'harvester' ? 'spice-blow:1' : 'storm:1' };
+ for (const effect of WINDOW_CARD_EFFECTS) {
+ const original = { kind: 'windowCardPlay', effect, source: windowSource(effect) };
assert.deepEqual(parseTurnActionLeaf(original), original);
assert.notEqual(parseTurnActionLeaf(original), original);
for (const invalid of [{ ...original, source: '' }, { ...original, card: 'physical-id' }, { ...original, amount: 12 },
@@ -1278,7 +1280,7 @@ void test('window-card raw leaves name only a printed role and exact original so
assert.throws(() => turnActionValue(promise(windowCard(effect))), TurnActionError);
assert.throws(() => turnActionValue(promise(group('or', fact(), windowCard(effect)))), TurnActionError);
}
- for (const effect of ['hajr', 'ghola', 'Weather Control', null])
+ for (const effect of ['hajr', 'ghola', 'karama', 'Weather Control', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect, source: 'storm:1' }), TurnActionError);
});
@@ -1396,3 +1398,71 @@ void test('window-card clauses deduplicate only their exact original source and
} } };
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
});
+
+void test('printed auction Karama receipt is separate from the bid and preserves their joint payment remainder', () => {
+ const original = { ...windowed(group('and', windowCard('karamaAuction'), bid(5), pay())), preparation: {
+ bid: { lot: 1, source: 'private-original-lot:1', maximum: 0, closed: false }, revival: null,
+ } };
+ const signed = { kind: 'windowCardPlay', effect: 'karamaAuction', source: 'ordinary-auction:1:1', answer: true };
+ const bidPlaced = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'private-original-lot:1', amount: 5 });
+ assert.deepEqual(branches(bidPlaced[0]), [[signed, signedPay()]]);
+ assert.equal(bidPlaced[0].windowCards!.karamaAuction!.played, false);
+ const rolePlayed = recordTurnActionWindowCardPlay(bidPlaced, 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
+ assert.deepEqual(branches(rolePlayed[0]), [[signedPay()]]);
+ const complete = recordTurnActionPayment(rolePlayed, 'respondent', 1, payment());
+ assert.equal(complete[0].fulfilled, true);
+ assert.equal(original.windowCards!.karamaAuction!.played, false);
+ validateTurnActionPromises(complete, seats);
+});
+
+void test('auction Karama replay receipts affect only future answers for the exact original player turn and lot', () => {
+ const original = windowed(group('and', windowCard('karamaAuction'), pay()));
+ for (const [player, turn, source] of [['other', 1, 'ordinary-auction:1:1'],
+ ['respondent', 2, 'ordinary-auction:1:1'], ['respondent', 1, 'ordinary-auction:1:2']] as const) {
+ assert.equal(recordTurnActionWindowCardPlay([original], player, turn, 'karamaAuction', source)[0], original);
+ assert.equal(closeTurnActionWindowCardPlay([original], player, turn, 'karamaAuction', source)[0], original);
+ }
+ const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
+ const fresh = windowed(windowCard('karamaAuction'), true, 'turn-action:2');
+ const replay = recordTurnActionWindowCardPlay([played, fresh], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
+ assert.equal(replay[0], played);
+ assert.equal(replay[1].fulfilled, true);
+ assert.equal(fresh.windowCards!.karamaAuction!.played, false);
+ const closed = closeTurnActionWindowCardPlay([original], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
+ assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], closed);
+});
+
+void test('whole No retains its auction Karama cap until source close and rejects a later correlated payment atomically', () => {
+ const no = windowed(windowCard('karamaAuction'), false);
+ assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1'), TurnActionError);
+ assert.equal(no.windowCards!.karamaAuction!.played, false);
+ assert.equal(closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:2')[0], no);
+ assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].fulfilled, false);
+ const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
+ assert.equal(closed.fulfilled, true);
+ assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], closed);
+ const correlated = windowed(group('and', windowCard('karamaAuction'), pay()), false, 'turn-action:2');
+ const played = recordTurnActionWindowCardPlay([correlated], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
+ assert.equal(played[0].fulfilled, false);
+ assert.equal(recordTurnActionWindowCardPlay(played, 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], played[0]);
+ assert.throws(() => recordTurnActionPayment(played, 'respondent', 1, payment()), TurnActionError);
+ assert.deepEqual(played[0].payments, []);
+ validateTurnActionPromises([closed], seats);
+});
+
+void test('closing the auction role preserves a later payment alternative and full expiry includes the new stage', () => {
+ const alternative = windowed(group('or', windowCard('karamaAuction'), pay()));
+ const closed = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
+ assert.deepEqual(branches(closed[0]), [[signedPay()]]);
+ assert.equal(closed[0].paymentClosed, false);
+ assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
+ const no = windowed(windowCard('karamaAuction'), false, 'turn-action:2');
+ const finished = finishTurnActionTurn([no], 'respondent', 1)[0];
+ assert.deepEqual(finished.windowCards!.karamaAuction, { source: 'ordinary-auction:1:1', played: false, closed: true });
+ assert.equal(finished.fulfilled, true);
+ validateTurnActionPromises([finished], seats);
+ const wrongStage = { ...alternative, windowCards: { karamaAuction: { source: 'ordinary-auction:1:2', played: false, closed: false } } };
+ assert.throws(() => turnActionValue(wrongStage), TurnActionError);
+ assert.throws(() => turnActionValue(windowed(group('or', windowCard('karamaAuction'),
+ windowCard('karamaAuction', 'ordinary-auction:1:2')))), TurnActionError);
+});
diff --git a/tests/window-card-play.test.ts b/tests/window-card-play.test.ts
index 74d6478e..524cb204 100644
--- a/tests/window-card-play.test.ts
+++ b/tests/window-card-play.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
+import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
function storm(): WindowCardGame {
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
@@ -8,6 +8,68 @@ function storm(): WindowCardGame {
function blow(): WindowCardGame {
return { ...storm(), phase: 1, spiceWindow: { event: 'spice-blow:1', territory: 'hagga_basin', sector: 5, harvested: false } };
}
+function auction(): WindowCardGame {
+ return { ...storm(), phase: 3, auction: { cards: [{ id: 'private-card:1' }, { id: 'private-card:2' }], index: 0 } };
+}
+function publicAuction(): WindowCardGame {
+ return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } };
+}
+
+void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
+ assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction']);
+ assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama']);
+});
+
+void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => {
+ const expected = { effect: 'karamaAuction', source: 'ordinary-auction:3:1', turn: 3 };
+ assert.deepEqual(windowCardOpportunity(auction(), 'karamaAuction'), expected);
+ assert.deepEqual(windowCardOpportunity(publicAuction(), 'karamaAuction'), expected);
+ assert.deepEqual(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 1 } }, 'karamaAuction'), expected);
+ const otherFace = { ...auction(), auction: { cards: [{ id: 'different-hidden-card' }], index: 0 } };
+ assert.deepEqual(windowCardOpportunity(otherFace, 'karamaAuction'), expected);
+});
+
+void test('ordinary lot responses and a winning unpaid invoice retain Karama source until actual lot close', () => {
+ for (const source of [auction(), publicAuction()]) {
+ for (const modal of [{ response: { kind: 'karama' } }, { decision: { kind: 'auctionPayment', player: 'respondent' } },
+ { truthtrance: {} }, { phaseOpening: {} }])
+ assert.equal(windowCardOpportunity({ ...source, ...modal }, 'karamaAuction')!.source, 'ordinary-auction:3:1');
+ for (const exclusion of [{ richeseAuction: {} }, { currentAuctionSale: {} }, { ixAuction: {} },
+ { phase: 4 }, { status: 'setup' }, { auction: null }])
+ assert.equal(windowCardOpportunity({ ...source, ...exclusion }, 'karamaAuction'), null);
+ }
+});
+
+void test('Karama lot progression changes only the public source and never mutates physical auction metadata', () => {
+ const source = auction(), before = structuredClone(source);
+ assert.equal(windowCardOpportunity(source, 'karamaAuction')!.source, 'ordinary-auction:3:1');
+ assert.equal(windowCardOpportunity({ ...source, auction: { cards: [{}, {}], index: 1 } }, 'karamaAuction')!.source,
+ 'ordinary-auction:3:2');
+ assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction')!.source,
+ 'ordinary-auction:3:2');
+ assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaAuction')!.source, 'ordinary-auction:4:1');
+ assert.deepEqual(source, before);
+});
+
+void test('malformed or exhausted native auction arrays and indices cannot fall back to a public lot', () => {
+ for (const native of [{ cards: [], index: 0 }, { cards: [{}], index: 1 }, { cards: [{}], index: -1 },
+ { cards: [{}], index: 0.5 }, { cards: [{}], index: Number.MAX_SAFE_INTEGER + 1 },
+ { cards: [null], index: 0 }, { cards: [undefined], index: 0 }, { cards: ['private-card'], index: 0 },
+ { cards: {}, index: 0 }, { cards: [{}] }, { index: 0 }, { cards: [{}], index: undefined }]) {
+ assert.equal(windowCardOpportunity({ ...auction(), auction: native }, 'karamaAuction'), null);
+ assert.equal(windowCardOpportunity({ ...publicAuction(), auction: native }, 'karamaAuction'), null);
+ }
+});
+
+void test('projected lot metadata is positive safe coherent and cannot invent an absent ordinary auction', () => {
+ for (const bidPromiseLot of [null, undefined, { turn: 3, lot: 0 }, { turn: 3, lot: -1 }, { turn: 3, lot: 1.5 },
+ { turn: 3, lot: Number.MAX_SAFE_INTEGER + 1 }, { turn: 4, lot: 1 }, { turn: 3, lot: '1' },
+ { turn: 3, lot: 1, card: 'private-card' }, { lot: 1 }])
+ assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot } as WindowCardGame, 'karamaAuction'), null);
+ assert.equal(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction'), null);
+ assert.equal(windowCardOpportunity({ ...publicAuction(), auction: [] }, 'karamaAuction'), null);
+ assert.equal(windowCardOpportunity({ ...storm(), phase: 3, auction: {} }, 'karamaAuction'), null);
+});
void test('Weather and Family Atomics share the exact original current Storm source before movement', () => {
assert.deepEqual(windowCardOpportunity(storm(), 'weather'), { effect: 'weather', source: 'storm:3', turn: 3 });