diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index f6943f5f..76f25e97 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -606,6 +606,7 @@ export function Truthtrance({
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.
Printed Karama shipment use grants the named active recipient’s current native shipment price; it does not require an actual force shipment.
+ Thumper refers only to this turn’s original first spice reveal. On turn 1 its card is still discarded, but the worm encounter is ignored.
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 ecfb5b9e..048f0e5f 100644
--- a/components/turn-action-fields.tsx
+++ b/components/turn-action-fields.tsx
@@ -49,7 +49,8 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
: { kind: 'voice', use: true };
const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
- karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity' };
+ karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
+ thumper: 'Play Thumper before this turn’s first spice reveal' };
const config: MixedActionConfig = {
prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -88,6 +89,9 @@ const config: MixedActionConfig = {
unused native shipment price. The giver may be another player. The price grant counts separately from an actual force shipment;
it does not require the recipient to ship forces. Other roles, Worthless conversion and recovery alone do not count.
This condition closes when that original shipment-price opportunity ends.
+ Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
+ Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
+ This condition closes when the actual first spice draw begins.
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.
@@ -123,7 +127,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 current Storm, fresh spice-blow, ordinary-lot or active shipment-price opportunity for this printed role.');
+ throw new Error('Choose the original current native window for this printed role, including the first spice reveal or active shipment price.');
}
return action;
},
@@ -185,7 +189,7 @@ const config: MixedActionConfig = {
{
const effect = event.currentTarget.value;
- if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment')
+ if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'thumper')
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}}>
Choose a printed role
@@ -202,10 +206,13 @@ const config: MixedActionConfig = {
{currentWindow && {value.effect === 'harvester'
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}`
: value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
+ : value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal`
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
: `Turn ${currentWindow.turn} · Before this Storm movement`} }
{!currentWindow && This printed role has no current original open native window.
}
+ {value.effect === 'thumper' && game.turn === 1 && On turn 1, playing Thumper still discards the card,
+ but its worm encounter is ignored and the normal Spice Card draw continues.
}
> : value.kind === 'battlePower' ? <>
Original native declaration
{
diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx
index f93aae0f..b5426c79 100644
--- a/components/turn-action-promises.tsx
+++ b/components/turn-action-promises.tsx
@@ -26,9 +26,10 @@ const publicBattleContext = (game: GameView) => game.battle && ({
preparation: game.battle.preparation,
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
-const isWindowEffect = (effect: unknown): effect is Exclude => effect === 'weather' || effect === 'atomics' || effect === 'harvester';
+const isWindowEffect = (effect: unknown): effect is Exclude =>
+ effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper';
const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
- karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price' };
+ karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price', thumper: 'Thumper' };
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));
@@ -84,6 +85,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
opportunity: windowEffect ? windowCardOpportunity(current, windowEffect)
: WINDOW_CARD_EFFECTS.map(effect => windowCardOpportunity(current, effect)),
stormOpen: current.stormCardWindowOpen,
+ thumperOpen: current.thumperWindowOpen, thumperReady: current.thumperReady, nexus: current.nexus,
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
blow: current.spiceWindow,
...(current.phase === 3 ? { auction: auctionSource(current) } : {}),
@@ -97,6 +99,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
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 shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
+ const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
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 });
@@ -149,11 +152,15 @@ export function TurnActionGuideButton(props: TurnActionProps) {
phase === 5 && current.active === cardWindow.recipient && action.target === cardWindow.recipient &&
Array.isArray(current.karamaShipmentCards) && current.karamaShipmentCards === shipmentCards &&
JSON.stringify(current.karamaShipmentCards) === shipmentCardsSignature && current.karamaShipmentCards.includes(heldCard.id);
+ const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
+ Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
+ JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
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
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
- : action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
+ : windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
+ : 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 quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box &&
@@ -179,8 +186,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
(phase === 0 ? current.stormPending !== null && (['weather', 'atomics'] as const).some(effect => {
const opportunity = windowCardOpportunity(current, effect);
return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source);
- }) : current.spiceWindow === originalBlow && (() => {
- const opportunity = windowCardOpportunity(current, 'harvester');
+ }) : (() => {
+ const thumper = current.thumperReady && windowCardOpportunity(current, 'thumper');
+ if (thumper && namesWindow(windowPromise.mixed, 'thumper', thumper.source)) return true;
+ const opportunity = current.spiceWindow === originalBlow && windowCardOpportunity(current, 'harvester');
return !!opportunity && namesWindow(windowPromise.mixed, 'harvester', opportunity.source);
})());
const options = current.bribeOptions;
@@ -285,6 +294,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.type === 'charity' ? 'Claim Charity while preserving your whole answer'
: action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer'
: earlyWindowReadyAction && (phase === 0 || !!game.spiceWindow) ? `Accept this ${phase === 0 ? 'Storm' : 'fresh spice-blow'} window while preserving your whole answer`
+ : earlyWindowReadyAction && game.thumperReady && !!windowPromise && namesWindow(windowPromise.mixed, 'thumper', `first-spice-reveal:${turn}`)
+ ? 'Ready for the original first spice reveal while preserving your whole answer'
: phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer'
: 'Finish this preparation phase while preserving your whole answer'
: action.type === 'passBid' ? 'Pass this bid while preserving your whole answer'
@@ -365,12 +376,17 @@ export function TurnActionPromises(props: TurnActionProps) {
unused native shipment price. The giver may be another player. The price grant counts separately from an actual force shipment;
it does not require the recipient to ship forces. Other roles, Worthless conversion and recovery alone do not count.
This condition closes when that original shipment-price opportunity ends.
+ Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
+ Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
+ This condition closes when the actual first spice draw begins.
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.
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
A known Inflation Double opening can close payments earlier without closing Ghola.
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.
+ {own.some(promise => promise.turn === 1 && namesWindow(promise.mixed, 'thumper')) &&
+ On turn 1, playing Thumper still discards the card, but its worm encounter is ignored and the normal Spice Card draw continues.
}
{live && {game.phase === 8 ? 'Honor remaining Ghola or payment conditions before this turn ends. Late payment recipients collect next turn.'
: game.phase < 5 ? 'Your Movement turn is still ahead. Preserve the needed forces, spice and cards and complete any offered native preparation.'
: 'Use the current native controls to honor your whole answer. Payment conditions can remain after your Movement turn ends.'}
}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index ad86c981..5dbc7f28 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,30 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Truthtrance / printed Thumper — 27 October. Prototyped / Partial.**
+Original-first-reveal promises now connect real printed costs, persistent
+first-turn warning, exact native play/Ready controls and four legal profiles.
+Actual ignored/suppressed encounters still count. Known injected casualties
+enter the shared quote; the next hidden Spice Card does not. Voluntary loss is
+checked before that draw, while unavoidable later loss can release impossibility.
+
+Frozen **14 files, 299/299**, types/lint/build and seven native journeys
+pass, with 40 GameTable SSR renders, 7 actual answer callbacks and 12 exact guide
+callbacks. Genuine selected-Ix H/A/E sources use 17 first-turn or 74 earned-body
+native actions, retaining 47 Treachery and complete original Traitor/Spice
+inventories. Three-counter comparisons pay 6, collect 6, retain 10, then really
+move 3 under suppression or lose 3 to a later worm. A two-counter negative oracle
+was corrected: native Charity 2 can legally replace two counters through a city.
+The three-counter case exposes and fixes the hidden-draw forecast boundary.
+Question/history text also names the actual Karama role/beneficiary.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-thumper-proof-v2/`;
+journeys/HTML: adjacent `20261027-truth-thumper-runtime/run-v2/`.
+No HTTP/storage boundary, saved-game/database, restart or deployed operation
+changes. Foreign worm decisions, Semuta/Box and wider combinations, complete
+modes and final assurance remain open.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-thumper--27-october-2026).
+
**Truthtrance / printed Karama shipment pricing — 27 October. Prototyped / Partial.**
Whole answers can name a future printed price cost for the original beneficiary.
Own and foreign givers share actual fees correctly: own pay9 plus shipment1 is
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index bdfbf1d9..fbf23f4a 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,17 @@
# Dune implementation status
+## 27 October 2026 — Printed Thumper promises
+
+Prototyped / Partial. Original first-reveal sources, actual printed costs and
+usable native controls/guides are connected. Quotation stops after the known
+injected encounter before any hidden draw; actual voluntary casualties are
+checked separately from forced later draws. Genuine three-counter comparisons
+prove the boundary after correcting a valid two-counter Charity replacement.
+Frozen checks pass: 14 files, 299/299 tests, types/lint/build and seven journeys
+(40 SSR renders, 7 answer callbacks and 12 guide callbacks). No deployment or saved-game operation occurs. Wider compositions
+and full acceptance remain. [Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-thumper--27-october-2026).
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-thumper-proof-v2/`.
+
## 27 October 2026 — Printed Karama shipment-price promises
Prototyped / Partial. Printed grant costs, beneficiary-specific original sources,
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 320257b9..5c9054ae 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,19 @@
# Rule decision index
+## Truthtrance printed Thumper — 27 October 2026
+
+Prototyped / Partial. Extend existing whole current-turn answers to an actual
+future printed Thumper cost before this turn's original first Spice reveal.
+Temporary opening/Truthtrance windows retain the interval; the actual first
+draw closes it. Counting the cost does not promise a worm outcome: existing
+first-turn ignoring and waiting-Sandtrout suppression stay provisional under
+the [original card policy](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026).
+Quote the known injected encounter without reading or reshuffling hidden Spice
+cards. Own voluntary cost/casualties must retain accepted answers before the
+next normal draw; that draw can independently release actual impossibility.
+Foreign worm decisions and wider compositions are not certified by this slice.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-thumper--27-october-2026).
+
## Truthtrance printed Karama shipment pricing — 27 October 2026
Prototyped / Partial. Compose the existing current-turn AND/OR authority with
diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md
index b68da790..2f32da91 100644
--- a/docs/TRUTHTRANCE_TURN_ACTIONS.md
+++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md
@@ -1,5 +1,43 @@
# Truthtrance combined current-turn actions
+## Printed Thumper — 27 October 2026
+
+**Prototyped / Partial.** `windowCardPlay/thumper` binds
+`first-spice-reveal:turn`. It counts the actual printed card spent after the
+answer before this original first reveal, independently of ignored or suppressed
+worm outcomes. Earlier plays, other roles, recovery alone and a later turn do
+not count. Temporary Amal/Truthtrance openings preserve the source. Actual first
+draw retires it permanently; own Ready must preserve a completion, while a whole
+No's payment cap lasts through its proper deadline. Current facts freeze and
+whole AND/OR alternatives retain their existing semantics.
+
+Detached quotation executes the known injected encounter and its real physical
+casualties/Sandtrout consumption, stopping before the next hidden draw or shuffle.
+It then quotes owned future preparation without invented income, cards or foreign
+decisions. Actual live play checks voluntary cost/casualties before that draw;
+independently forced hidden casualties can release genuine impossibility.
+No selected branch or causal guard is persisted or published.
+
+The shared source/owner/card quotes feed human controls, persistent first-turn
+warning, exact native play/Ready guides and four minimally legal profiles.
+Question/history text names the actual Thumper or Karama role and shipment
+beneficiary. Original selected-Ix H/A/E sources deal both real Truthtrances and
+the physical Thumper, preserve 47 Treachery and the actual Traitor/Spice inventory,
+and earn later bodies by prior native shipment/collection. Three-body comparisons
+use actual cost 6 and collection 6, retain purse 10, and include future Charity's
+legitimate two-counter replacement route when assessing impossibility.
+
+Foreign worm reactions, Semuta/Box and other optional combinations, full modes,
+comprehensive assurance and deployed acceptance remain open. Source/effect
+precedence remains [provisional](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026).
+Frozen evidence: 14 related files, 299/299, types/lint/build and seven
+native/SSR/control journeys pass (40 GameTable renders, 7 answer callbacks,
+12 guide callbacks). First-turn sources use 17 native actions; later genuine
+body sources use 74. Private proof is
+`/home/quintine/.local/share/dune/checkpoints/20261027-truth-thumper-proof-v2/`;
+journeys/HTML are in adjacent `20261027-truth-thumper-runtime/run-v2/`.
+This is local native/SSR evidence, not browser hydration or deployed acceptance.
+
## Printed Karama shipment pricing — 27 October 2026
**Prototyped / Partial.** `windowCardPlay/karamaShipment` names the current
diff --git a/game/bots.ts b/game/bots.ts
index 530e2895..cf6000e1 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3157,6 +3157,8 @@ function policyActions(g: GameView): Action[] {
const prior = g.spiceDiscardTop[0];
if (
thumper &&
+ !g.ready.includes(me.id) &&
+ g.thumperCards?.includes(thumper.id) === true &&
g.beforeSpiceDraw &&
g.turn > 1 &&
prior &&
diff --git a/game/engine.ts b/game/engine.ts
index ff39a3c3..62740cdd 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -2505,6 +2505,8 @@ export type Game = {
stormPending: number | null;
/** Actual original Storm card window; absent older games use native phase/resolution. */
stormCardWindowOpen?: boolean;
+ /** Original first Spice reveal; temporary summoned-worm controls do not retire it. */
+ thumperWindowOpen?: boolean;
stormCard: number | null;
stormCardKnown: boolean;
stormResolution: {
@@ -12779,7 +12781,20 @@ function refillSpice(g: Game) {
g.spiceDiscard = [[], []];
}
}
+class ThumperForecastBoundary extends Error {}
+// Live-action causality stays outside persisted games and signed responses.
+const thumperDrawOwners = new WeakMap();
+const thumperForcedDraws = new WeakSet();
function drawSpice(g: Game) {
+ // A promise may quote the known injected encounter, never the next hidden
+ // card or a hidden discard reshuffle.
+ if ((g as TurnActionCardProbe)._turnActionThumperForecast) throw new ThumperForecastBoundary();
+ const thumperOwner = thumperDrawOwners.get(g);
+ if (thumperOwner !== undefined) {
+ thumperDrawOwners.delete(g);
+ guardThumperKnownEncounter(g, thumperOwner);
+ thumperForcedDraws.add(g);
+ }
refillSpice(g);
return g.spiceDeck.shift();
}
@@ -13415,6 +13430,7 @@ function decideGreatMakerRide(g: Game, p: Player, action: Action) {
completeGreatMaker(g);
}
function blowSpice(g: Game, thumper = false) {
+ g.thumperWindowOpen = false;
g.nexus = !!g.summonedBeforeBlow;
if (!g.summonedBeforeBlow) g.wormRides = [];
g.summonedBeforeBlow = false;
@@ -18893,6 +18909,7 @@ type TurnActionCardProbe = Game & {
_turnActionUnknownCards?: string[];
_turnActionPriceCursor?: { order: string[]; index: number };
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null };
+ _turnActionThumperForecast?: true;
};
function printedCardRule(operation: () => T): T {
try { return operation(); }
@@ -18990,6 +19007,16 @@ function quotedShipmentKaramaCards(g: Game, player: Player): string[] {
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}).map(card => card.id);
}
+function thumperReadyStartsDraw(g: Game): boolean {
+ return !!windowCardOpportunity(g, 'thumper') && !g.truthtrance && !g.phaseOpening && !g.response &&
+ !g.decision && !g.pendingTreacheryDiscard && !g.spiceWindow && (!g.nexus || !!g.summonedBeforeBlow);
+}
+function quotedThumperCards(g: Game, player: Player): string[] {
+ if (!windowCardOpportunity(g, 'thumper') || g.truthtrance || g.phaseOpening || g.response || g.decision ||
+ g.pendingTreacheryDiscard || g.spiceSequence || g.spiceResolution || g.spiceWindow || g.nexus) return [];
+ return player.hand.filter(card => card.kind === 'special' && card.effect === 'thumper' &&
+ !transferCardBlock(g, player, card)).map(card => card.id);
+}
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator {
if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor);
@@ -19004,6 +19031,10 @@ function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffe
yield { type: 'card', card, mode: 'shipment', target: opportunity.recipient };
return;
}
+ if (effect === 'thumper') {
+ for (const card of quotedThumperCards(g, player)) yield { type: 'card', card };
+ 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,
@@ -19155,6 +19186,37 @@ 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.');
}
+/** This detached owned forecast starts after the known encounter, before any
+ * normal hidden draw. It grants no spice, cards or foreign decisions. */
+function projectAfterKnownThumper(g: Game) {
+ g.phase = 2; g.active = null; g.ready = [];
+ g.spiceSequence = null; g.spiceResolution = null; g.spiceWindow = null;
+ g.nexus = false;
+}
+function forecastThumperEncounter(g: Game) {
+ let reachedDraw = false;
+ (g as TurnActionCardProbe)._turnActionThumperForecast = true;
+ try { blowSpice(g, true); }
+ catch (error) {
+ if (!(error instanceof ThumperForecastBoundary)) throw error;
+ reachedDraw = true;
+ } finally { delete (g as TurnActionCardProbe)._turnActionThumperForecast; }
+ requireRule(reachedDraw,
+ 'This owned Thumper forecast cannot resolve a foreign worm reaction.');
+ projectAfterKnownThumper(g);
+}
+/** Reject voluntary loss from the printed cost and known injected worm before
+ * a normal hidden draw can cause an independently involuntary loss. */
+function guardThumperKnownEncounter(g: Game, actor: string) {
+ const trial = structuredClone(g);
+ projectAfterKnownThumper(trial);
+ const action: Action = { type: 'card' };
+ reconcileTravelPromises(trial, actor, action, 1);
+ reconcileBidPromises(trial, { actor, action });
+ reconcileRevivalPromises(trial, { actor, action });
+ reconcileBribePromises(trial, { actor, action });
+ reconcileGroundMovementPromises(trial, actor, action, 1);
+}
/** 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[]) {
@@ -19847,7 +19909,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
if (!action && g.phase === 1 && allianceQuestionWindow(g) && !g.ready.includes(player.id))
action = completion.alliance?.[0] ?? { type: 'ready' };
if (!action && !g.ready.includes(player.id) &&
- (g.phase === 0 && g.stormPending !== null || g.phase === 1 && g.spiceWindow)) {
+ (g.phase === 0 && g.stormPending !== null || g.phase === 1 && (g.spiceWindow || thumperReadyStartsDraw(g)))) {
const closed = ownTurnActionWindowClosure(g, player.id);
if (closed !== g.turnActionPromises && turnActionCompletion({ ...g, turnActionPromises: closed }, player.id))
action = { type: 'ready' };
@@ -26070,6 +26132,7 @@ function completePhase(g: Game) {
}
/** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) {
+ if (g.phase === 1) g.thumperWindowOpen = true;
if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
g.nexusCards.phase = createNexusCardPhase(g.turn);
if (initialize && g.phase === 4 && g.homeworlds) {
@@ -37732,11 +37795,14 @@ export function applyAction(state: Game, id: string, action: Action): Game {
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 });
- reconcileBribePromises(g, { actor: id, action });
- reconcileGroundMovementPromises(g, id, action, state.phase);
+ const forcedThumperDraw = thumperForcedDraws.delete(g);
+ const promiseActor = forcedThumperDraw ? undefined : id;
+ const voluntary = forcedThumperDraw ? undefined : { actor: id, action };
+ reconcileTravelPromises(g, promiseActor, action, state.phase);
+ reconcileBidPromises(g, voluntary);
+ reconcileRevivalPromises(g, voluntary);
+ reconcileBribePromises(g, voluntary);
+ reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
if (g.pendingNexusHarkonnenBetrayal) return g;
@@ -41988,7 +42054,11 @@ function applyActionInner(
!g.nexus,
'Use Thumper at the beginning of Spice Blow, before the first draw.',
);
+ const window = windowCardOpportunity(g, 'thumper');
+ requireRule(window, 'Retain the original unused first Spice reveal.');
+ recordNativeTurnActionWindowCard(g, id, 'thumper', window.source);
const used = discard(g, p, c.id);
+ rememberMovementDiscards(g, [{ card: used, discardedBy: id, publicFace: true }]);
log(
g,
`${p.name} played Thumper in place of the next spice-card reveal.`,
@@ -42007,7 +42077,12 @@ function applyActionInner(
return g;
}
}
- blowSpice(g, true);
+ if (execution === 'movementPreparation') forecastThumperEncounter(g);
+ else {
+ thumperDrawOwners.set(g, id);
+ try { blowSpice(g, true); }
+ finally { thumperDrawOwners.delete(g); }
+ }
return g;
}
const availability = ordinaryCardAvailability(
@@ -42109,7 +42184,7 @@ function applyActionInner(
}
if (t === 'ready') {
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
- if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && g.spiceWindow)) {
+ if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && (g.spiceWindow || thumperReadyStartsDraw(g)))) {
const closed = ownTurnActionWindowClosure(g, p.id);
requireRule(closed === g.turnActionPromises || !!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id),
'Play your required window card or retain another legal whole-answer alternative before accepting this window.');
@@ -42361,6 +42436,9 @@ export function viewGame(state: Game, id: string, now?: number) {
karamaAuctionCards: quotedAuctionKaramaCards(g, me),
karamaShipmentCards: quotedShipmentKaramaCards(g, me),
karamaShipmentWindows: karamaShipmentOpportunities(g),
+ thumperCards: quotedThumperCards(g, me),
+ thumperReady: thumperReadyStartsDraw(g),
+ thumperWindowOpen: !!windowCardOpportunity(g, 'thumper'),
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;
@@ -42840,7 +42918,7 @@ export function viewGame(state: Game, id: string, now?: number) {
spiceWindow: g.spiceWindow ?? null,
spicePile: g.spiceSequence?.pile ?? 0,
beforeSpiceDraw:
- g.phase === 1 &&
+ !!windowCardOpportunity(g, 'thumper') &&
g.nexusCards?.phase?.stage !== 'drawing' &&
!g.spiceSequence &&
!g.spiceResolution &&
diff --git a/game/reference.ts b/game/reference.ts
index aa69f658..5dc05135 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3232,6 +3232,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
+ 'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
'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.',
@@ -3239,11 +3240,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 printed Karama shipment pricing and current-lot costs, 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 printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, 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', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-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', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-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', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.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', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
],
},
{
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 8b4a36eb..faa86a7f 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -2,7 +2,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
type TurnActionClaim } from './turn-action-promises';
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
-import { windowCardOpportunity } from './window-card-play';
+import { parseKaramaShipmentSource, windowCardOpportunity } from './window-card-play';
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
@@ -740,9 +740,17 @@ export function truthQuestionText(
};
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => {
- if (action.kind === 'windowCardPlay') return `you play ${action.effect === 'weather' ? 'Weather Control' :
- action.effect === 'atomics' ? 'Family Atomics' : 'Harvester'} in its printed role after this answer in this original ${
- action.effect === 'harvester' ? 'fresh Spice Blow' : 'Storm window'} this turn`;
+ if (action.kind === 'windowCardPlay') {
+ if (action.effect === 'karamaAuction') return 'you spend a printed Karama after this answer on this original ordinary auction lot, by immediate purchase or its winning invoice';
+ if (action.effect === 'karamaShipment') {
+ const recipient = parseKaramaShipmentSource(action.source)?.recipient;
+ return `you spend a printed Karama after this answer on ${recipient ? playerName(recipient) : 'the named beneficiary'}’s original unused shipment price`;
+ }
+ if (action.effect === 'thumper') return 'you play Thumper after this answer before this turn’s original first Spice reveal, even if its worm encounter is ignored or suppressed';
+ return `you play ${action.effect === 'weather' ? 'Weather Control' :
+ action.effect === 'atomics' ? 'Family Atomics' : 'Harvester'} in its printed role after this answer in this original ${
+ action.effect === 'harvester' ? 'fresh Spice Blow' : 'Storm window'} this turn`;
+ }
if (action.kind === 'cardPlay') return `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`;
if (action.kind === 'battlePower') {
const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`;
diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts
index e4758721..686e5dce 100644
--- a/game/turn-action-promises.ts
+++ b/game/turn-action-promises.ts
@@ -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, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
+ 'Choose one original Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
}
if (value.kind === 'battlePower') {
diff --git a/game/window-card-play.ts b/game/window-card-play.ts
index 8faf90fd..74fe47b2 100644
--- a/game/window-card-play.ts
+++ b/game/window-card-play.ts
@@ -1,8 +1,8 @@
import { harvesterAvailable } from './cards';
-export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment'] as const;
+export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper'] as const;
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
-export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' {
+export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' {
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
}
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
@@ -17,6 +17,10 @@ export type WindowCardGame = {
shieldWallDestroyed: boolean;
stormCardWindowOpen?: boolean;
stormResolution?: unknown;
+ thumperWindowOpen?: boolean;
+ spiceSequence?: unknown;
+ spiceResolution?: unknown;
+ nexus?: unknown;
spiceWindow?: {
event?: string;
territory: string;
@@ -119,6 +123,12 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
* force conditions and chosen effect parameters remain native engine rules. */
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
+ if (effect === 'thumper') {
+ if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' ||
+ game.thumperWindowOpen === false || game.thumperWindowOpen === undefined &&
+ (game.spiceSequence || game.spiceResolution || game.spiceWindow || game.nexus)) return null;
+ return { effect, source: `first-spice-reveal:${game.turn}`, turn: game.turn };
+ }
if (effect === 'karamaAuction') {
if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null ||
!object(game.auction)) return null;
diff --git a/tests/fixture-truth-turn-thumper.ts b/tests/fixture-truth-turn-thumper.ts
new file mode 100644
index 00000000..ebc41f78
--- /dev/null
+++ b/tests/fixture-truth-turn-thumper.ts
@@ -0,0 +1,211 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { type SpiceCard, treacheryDeck } from '../game/cards';
+import { stormSectorAfter } from '../game/disaster-rules';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import { traitorDeck } from '../game/traitors';
+import type { TruthFact, TruthQuestion } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
+import { actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer,
+ type TruthTurnActionStep } from './fixture-truth-turn-action';
+import type { TruthTurnKaramaShipmentLeaf } from './fixture-truth-turn-karama-shipment';
+
+export type TruthTurnThumperLeaf = TruthTurnKaramaShipmentLeaf |
+ { kind: 'windowCardPlay'; effect: 'thumper'; source: string };
+export type TruthTurnThumperExpression = MixedActionExpression;
+export type TruthTurnThumperStep = TruthTurnActionStep;
+export const actTruthTurnThumper = actTruthTurnAction;
+export const truthTurnThumperPlayer = truthTurnActionPlayer;
+export const truthTurnThumperInventory = truthTurnActionInventory;
+export const truthTurnThumperTraitors = truthTraitorDecisionTraitors;
+export const truthTurnThumperClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
+ !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
+export const truthTurnThumperFact = (fact: TruthFact): TruthTurnThumperExpression => ({ kind: 'fact', fact });
+export const truthTurnThumperPlay = (source: string): TruthTurnThumperExpression =>
+ ({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'thumper', source } });
+export const truthTurnThumperPayment = (recipient: string, minimum = 2): TruthTurnThumperExpression =>
+ ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
+export const truthTurnThumperMove = (territory = 'polar_sink', minimum = 2): TruthTurnThumperExpression =>
+ ({ kind: 'action', action: { kind: 'move', territory, minimum } });
+export const truthTurnThumperGroup = (kind: 'and' | 'or', ...terms: TruthTurnThumperExpression[]): TruthTurnThumperExpression =>
+ ({ kind, terms });
+
+/** Sandtrout's waiting flag holds its original one physical card. Generated
+ * Thumper worms are encounters, and never join the physical Spice inventory. */
+export function truthTurnThumperSpice(game: Game) {
+ const physical: SpiceCard[] = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])]
+ .filter(card => !('worm' in card && card.thumper));
+ if (game.sandtrout) physical.push({ sandtrout: true });
+ return physical.map(card => 'territory' in card
+ ? JSON.stringify({ territory: card.territory, amount: card.amount, sector: card.sector })
+ : 'sandtrout' in card ? 'sandtrout' : 'worm').sort();
+}
+/** Native Basic dials are zero in the original first Storm, then one each in
+ * the later Storm. Its real two-sector traversal misses the earned bodies. */
+export function nextTruthTurnThumperNative(game: Game): TruthTurnThumperStep | null {
+ if (game.truthtrance) return null;
+ if (!game.response && !game.phaseOpening && game.decision &&
+ ['wormRide', 'wormPlacement'].includes(game.decision.kind))
+ return { actor: game.decision.player, action: { type: 'decision', accept: false } };
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function nextTruthTurnThumper(game: Game): TruthTurnThumperStep | 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 nextTruthTurnThumperNative(game);
+}
+export function advanceTruthTurnThumper(game: Game, until: (state: Game) => boolean,
+ actions: TruthTurnThumperStep[] = [], nativeOnly = false): Game {
+ for (let count = 0; count < 1900; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The genuine Thumper source ended before its requested native boundary');
+ const step = nativeOnly ? nextTruthTurnThumperNative(game) : nextTruthTurnThumper(game);
+ assert.ok(step, 'Supply the original Truthtrance question or actual private Battle Plan');
+ game = actTruthTurnThumper(game, step.actor, step.action, actions);
+ }
+ throw Error('The original Thumper chronology did not reach its finite boundary');
+}
+export function settleTruthTurnThumper(game: Game, actions: TruthTurnThumperStep[] = []): Game {
+ return advanceTruthTurnThumper(game, truthTurnThumperClean, actions, true);
+}
+
+/** Genuine public selected-Ix H/A/E. H really receives both original Truths,
+ * A Baliset and E printed Thumper from the unopened original deal. The later
+ * Basic source earns its selected potential casualties by shipment/collection
+ * and two actual one-sector later Storm dials; no played state is installed. */
+export function createTruthTurnThumperFixture(options: {
+ advanced?: boolean; turn?: 1 | 2; waitingSandtrout?: boolean; bodyCount?: 2 | 3;
+} = {}) {
+ const advanced = options.advanced ?? false, turn = options.turn ?? 1, waitingSandtrout = options.waitingSandtrout ?? false;
+ const bodyCount = options.bodyCount ?? 2;
+ assert.ok(turn === 1 || !advanced, 'The bounded earned-body second-turn source uses actual Basic second Storm dials');
+ assert.ok(!waitingSandtrout || turn === 2, 'Waiting Sandtrout is earned during the original preceding turn');
+ const asker = 'harkonnen', observer = 'atreides', target = 'emperor', recipient = asker;
+ let game = createGame('TRUTHTURNTHUMPER', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']);
+ joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
+ joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: TruthTurnThumperStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthTurnThumper(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnThumper(game, until, actions, true); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game); assert.equal(game.status, 'setup');
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ const originalCards = truthTurnThumperInventory(game), originalTraitors = truthTurnThumperTraitors(game);
+ assert.deepEqual(originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
+ assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
+ const originalSpice = truthTurnThumperSpice(game);
+ const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
+ const thumper = game.deck.find(card => card.effect === 'thumper'), filler = game.deck.find(card => card.name === 'Baliset');
+ assert.ok(thumper && filler);
+ const deal = [...truths.map(card => card.id), filler.id, thumper.id];
+ const dealOrder = deal.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: id };
+ });
+ const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(truthTurnThumperPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
+ assert.deepEqual(truthTurnThumperPlayer(game, target).hand.map(card => card.id), [thumper.id]);
+ reach(state => state.phase === 1 && !!state.phaseOpening);
+ const firstOpening = structuredClone(game); let sourceOpening = firstOpening, openingAt = actions.length;
+ reach(beforeIxSpiceDraw); const firstBeforeReveal = structuredClone(game), firstRevealAt = actions.length;
+ let priorOrder: ReturnType = [];
+ let priorShipment: { before: Game; steps: TruthTurnThumperStep[]; after: Game } | null = null;
+ let secondStormSteps: TruthTurnThumperStep[] = [];
+ const previousTerritory = 'hagga_basin', previousSector = 12, previousLocation = `${previousTerritory}:${previousSector}`;
+ let priorBlow: Game['spiceWindow'] = null;
+ if (turn === 2) {
+ priorOrder = orderIxSpiceCards(game, [...(waitingSandtrout ? ['sandtrout' as const] : []), { territory: previousTerritory }]);
+ reach(state => !!state.spiceWindow && truthTurnThumperClean(state)); priorBlow = structuredClone(game.spiceWindow);
+ assert.equal(priorBlow?.territory, previousTerritory); assert.equal(priorBlow?.sector, previousSector);
+ reach(state => state.phase === 5 && state.active === target && truthTurnThumperClean(state));
+ const before = structuredClone(game), from = actions.length;
+ act(target, { type: 'ship', territory: previousTerritory, sector: previousSector, amount: bodyCount, elite: 0, allyPayment: 0 });
+ reach(truthTurnThumperClean);
+ priorShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
+ assert.equal(truthTurnThumperPlayer(before, target).spice - truthTurnThumperPlayer(game, target).spice, bodyCount * 2);
+ assert.equal(truthTurnThumperPlayer(game, target).forces[previousLocation], bodyCount);
+ act(target, { type: 'endMovement' });
+ reach(state => state.turn === 2 && state.phase === 1 && !!state.phaseOpening);
+ sourceOpening = structuredClone(game); openingAt = actions.length; reach(beforeIxSpiceDraw);
+ secondStormSteps = structuredClone(actions.slice(firstRevealAt).filter(step => step.action.type === 'stormDial'));
+ assert.deepEqual(secondStormSteps.map(step => step.action.amount), [1, 1]);
+ assert.equal(game.storm, stormSectorAfter(firstBeforeReveal.storm, 2));
+ assert.equal(truthTurnThumperPlayer(game, target).forces[previousLocation], bodyCount);
+ assert.equal(truthTurnThumperPlayer(game, target).tanks, 0);
+ assert.equal(game.sandtrout, waitingSandtrout);
+ }
+ const sourceOpeningSteps = structuredClone(actions.slice(openingAt));
+ const revealOrder = orderIxSpiceCards(game, [{ territory: turn === 2 ? 'broken_land' : previousTerritory },
+ ...(advanced ? [{ territory: 'broken_land' }] : [])]);
+ const opportunity = windowCardOpportunity(game, 'thumper'); assert.ok(opportunity);
+ assert.equal(opportunity.source, `first-spice-reveal:${turn}`); assert.equal(game.turn, turn);
+ assert.equal(game.thumperWindowOpen, true); assert.deepEqual(game.ready, []);
+ assert.equal(viewGame(game, target).beforeSpiceDraw, true);
+ assert.ok(truthTurnThumperPlayer(game, target).hand.some(card => card.id === thumper.id));
+ assert.deepEqual(truthTurnThumperInventory(game), originalCards); assert.deepEqual(truthTurnThumperTraitors(game), originalTraitors);
+ assert.deepEqual(truthTurnThumperSpice(game), originalSpice);
+ const source = opportunity.source, mixed = truthTurnThumperGroup('and', truthTurnThumperPlay(source), truthTurnThumperPayment(recipient));
+ const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
+ const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnThumperPlayer(game, target).spice };
+ return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, firstOpening, sourceOpening,
+ sourceOpeningSteps, firstBeforeReveal, asker, target, recipient, observer, advanced, turn, waitingSandtrout, bodyCount,
+ truths: truths.map(card => card.id), truth: truths[0].id, card: thumper.id, thumper: thumper.id,
+ effect: 'thumper' as const, source, opportunity, mixed, question, spiceFact,
+ nativeAction: { type: 'card', card: thumper.id } satisfies Action,
+ payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
+ priorOrder, priorShipment, priorBlow, secondStormSteps, previousTerritory, previousSector, previousLocation, revealOrder, dealOrder,
+ originalCards, originalTraitors, originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
+ sourceCounts: { selected: actions.length, sourceOpening: sourceOpeningSteps.length,
+ priorShipment: priorShipment?.steps.length ?? 0, secondStormDials: secondStormSteps.length },
+ sourceDisclosure: 'Actual public selected-Ix Harkonnen/Atreides/Emperor setup and original card inventories, including the original Cheap Hero Traitor. ' +
+ 'Only the original unopened four-card deal, specified unopened Spice prefixes and scalar lotteries are selected. All native Amal phase-opening passes execute. ' +
+ (turn === 2 ? `Actually reveal original Hagga Basin Spice, pay ${bodyCount * 2} spice to ship ${bodyCount} original Emperor reserves there, collect ${bodyCount * 2} spice through the real first turn and submit two real second-turn one-sector Storm dials. The actual two-sector traversal leaves all ${bodyCount} counters alive. ` +
+ (waitingSandtrout ? 'Original first-turn Sandtrout really enters its waiting state before that original land blow. ' : '')
+ : 'Stop before the actual first-turn first reveal; existing provisional first-turn ignoring of the Thumper encounter remains unchanged. ') +
+ 'No hand, purse, force, phase, worm, waiting flag, casualty, revealed card, printed play or promise is installed. Action counts derive from actual recorded arrays.',
+ };
+}
+export type TruthTurnThumperFixture = ReturnType;
+export function declareTruthTurnThumper(f: TruthTurnThumperFixture, source = f.game, copy: 0 | 1 = 0,
+ actions: TruthTurnThumperStep[] = []) {
+ let game = actTruthTurnThumper(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 = actTruthTurnThumper(game, actor.id, { type: 'truthPass' }, actions);
+ }
+ assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
+}
+export function askTruthTurnThumper(game: Game, asker: string, target: string, mixed: TruthTurnThumperExpression,
+ actions: TruthTurnThumperStep[] = []): Game {
+ return actTruthTurnThumper(game, asker, { type: 'truthAsk', question: {
+ kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
+ } }, actions);
+}
+export function answerTruthTurnThumper(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
+ actions: TruthTurnThumperStep[] = []): Game {
+ return actTruthTurnThumper(game, target, { type: 'truthAnswer', answer }, actions);
+}
+export function bindTruthTurnThumper(f: TruthTurnThumperFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
+ source = f.game, copy: 0 | 1 = 0, actions: TruthTurnThumperStep[] = []) {
+ const declared = declareTruthTurnThumper(f, source, copy, actions), asked = askTruthTurnThumper(declared.game, f.asker, f.target, mixed, actions);
+ return { game: answerTruthTurnThumper(asked, f.target, answer, actions), declared, asked, actions };
+}
+export function truthTurnThumperPromise(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 closeTruthTurnThumper(game: Game, f: TruthTurnThumperFixture, actions: TruthTurnThumperStep[] = []): Game {
+ return advanceTruthTurnThumper(game, state => truthTurnThumperClean(state) && !windowCardOpportunity(state, 'thumper', f.source), actions, true);
+}
+export function finishTruthTurnThumper(game: Game, actions: TruthTurnThumperStep[] = []): Game {
+ const turn = game.turn;
+ return advanceTruthTurnThumper(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnThumperClean(state), actions);
+}
diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx
index 950cb1f2..1911e829 100644
--- a/tests/truth-turn-action-controls.test.tsx
+++ b/tests/truth-turn-action-controls.test.tsx
@@ -6,7 +6,7 @@ import { TurnActionFields, turnActionInputError } from '../components/turn-actio
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BattleClaimFields } from '../components/battle-promises';
-import { baseDeck } from '../game/cards';
+import { baseDeck, ixSpecialCards } from '../game/cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
@@ -35,7 +35,8 @@ const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind:
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
- : effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 'storm:2'): Expression =>
+ : effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource()
+ : effect === 'thumper' ? 'first-spice-reveal: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 } });
@@ -85,9 +86,9 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
if (next.type === 'card' && typeof next.card === 'string') {
- const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester'] as const)
+ const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper'] as const)
.find(effect => cardId.includes(effect)) ?? 'karama';
- const face = baseDeck().find(card => card.effect === effect); assert.ok(face);
+ const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
const me = game.players.find(player => player.id === 'p')!;
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
if (effect === 'ghola') {
@@ -124,6 +125,12 @@ function shipmentKarama(recipient = 'p', cunning?: string): GameView {
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
return game;
}
+function firstReveal(next: Action = { type: 'card', card: 'controlled-thumper' }): GameView {
+ const game = projected(1, next); game.thumperWindowOpen = true; game.thumperReady = true; game.spiceWindow = null; game.nexus = false;
+ game.thumperCards = ['controlled-thumper']; game.active = 'a';
+ game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), payment()] };
+ 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';
@@ -1020,6 +1027,114 @@ void test('a current shipment-price question retains a quoted Box route for an o
}
});
+void test('Thumper authoring uses the original public first-reveal source and preserves repairable shared logical drafts', () => {
+ const game = firstReveal(), e = editor(physical(), false, game);
+ for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private Thumper authoring ${key} read`); }, configurable: true });
+ for (const key of ['thumperCards', 'spiceSequence', 'spiceResolution'])
+ Object.defineProperty(game, key, { get() { throw Error(`No native Thumper authoring ${key} read`); }, configurable: true });
+ select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('thumper')); assert.equal(e.error(), null);
+ assert.match(e.html(), /Before the original first spice reveal/);
+ game.nexus = true; game.response = { kind: 'voice', owner: 'a', passed: [] };
+ assert.equal(e.error(), null, 'A temporary native control does not change the original public first-reveal source.');
+ game.nexus = false; game.response = null;
+ select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
+ select(e.tree(), '1', 'window-source', 'first-spice-reveal:2'); assert.equal(e.error(), null);
+ game.thumperWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
+ game.thumperWindowOpen = true;
+ for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }])
+ assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'thumper', source: 'first-spice-reveal:2', ...extra } }, game, 'p'));
+ const fullGame = firstReveal(), full = editor({ kind: 'or', terms: [windowClaim('thumper'), 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('thumper')] }, 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('Thumper play and same-source Ready guides forward exact native packets while first-turn history retains its printed warning', () => {
+ const played = firstReveal(), sent: Action[] = [];
+ Object.defineProperty(played.turnActionPromises[0], 'windowCards', { get() { throw Error('No private Thumper receipt read'); } });
+ const button = guide({ game: played, busy: false, act(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], played.turnActionGuide!.action);
+ assert.equal(sent[0].type, 'card'); assert.equal(sent[0].card, 'controlled-thumper'); assert.equal(sent[0].turnActionPromisePhase, 1);
+ const alreadyReady = firstReveal(); alreadyReady.ready.push('p'); alreadyReady.thumperReady = false;
+ let latePlay = 0; const lateThumper = guide({ game: alreadyReady, busy: false, act() { latePlay++; } });
+ assert.equal(lateThumper.props.disabled, false); press(lateThumper); assert.equal(latePlay, 1, 'The native card quote can permit a held Thumper after own readiness.');
+ const keeper = firstReveal({ type: 'ready' }), readied: Action[] = [];
+ keeper.thumperCards = []; keeper.players.find(player => player.id === 'p')!.hand = [];
+ keeper.turnActionPromises[0].answer = false; keeper.turnActionPromises[0].mixed = windowClaim('thumper');
+ const ready = guide({ game: keeper, busy: false, act(action) { readied.push(action); } });
+ assert.equal(ready.props.disabled, false); press(ready); assert.equal(readied[0], keeper.turnActionGuide!.action);
+ assert.match(renderToStaticMarkup(ready), /Ready for the original first spice reveal/);
+ keeper.ready.push('p'); press(ready); assert.equal(readied.length, 1);
+ const summoned = firstReveal({ type: 'ready' }); summoned.nexus = true; summoned.thumperCards = [];
+ Object.defineProperty(summoned, 'summonedBeforeBlow', { get() { throw Error('Use the native public Ready quote, not private summoned timing'); } });
+ let summonedReady = 0; const quotedReady = guide({ game: summoned, busy: false, act() { summonedReady++; } });
+ assert.equal(quotedReady.props.disabled, false); press(quotedReady); assert.equal(summonedReady, 1);
+ const first = firstReveal(); first.turn = 1; first.turnActionGuide = null;
+ first.turnActionPromises[0].turn = 1; first.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1');
+ const html = markup(first); assert.match(html, /On turn 1, playing Thumper still discards the card/);
+ assert.match(html, /worm encounter is ignored and the normal Spice Card draw continues/);
+ assert.match(html, /closes when the actual first spice draw begins/); assert.doesNotMatch(html, /windowCards|controlled-thumper/);
+ assert.match(editor(windowClaim('thumper', 'first-spice-reveal:1'), false, first).html(), /On turn 1, playing Thumper still discards the card/);
+});
+
+void test('saved Thumper play and Ready callbacks reject original-source expiry and native modal changes', () => {
+ for (const action of [{ type: 'card', card: 'controlled-thumper' }, { type: 'ready' }]) for (const change of [
+ (game: GameView) => { game.thumperWindowOpen = false; },
+ (game: GameView) => { game.thumperReady = false; },
+ (game: GameView) => { game.nexus = true; },
+ (game: GameView) => { game.spiceWindow = { event: 'later-native-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false }; },
+ (game: GameView) => { game.phaseOpening = { passed: [] }; },
+ (game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
+ (game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ (game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1'); },
+ ]) {
+ const game = firstReveal(action), saved = guide({ game, busy: false, act() { assert.fail('A stale first-reveal native action cannot send'); } });
+ assert.equal(saved.props.disabled, false); change(game); press(saved);
+ }
+ for (const change of [
+ (game: GameView) => { game.thumperCards = []; },
+ (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
+ (game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; },
+ ]) {
+ const game = firstReveal(), saved = guide({ game, busy: false, act() { assert.fail('A changed Thumper quote or physical card cannot send'); } });
+ assert.equal(saved.props.disabled, false); change(game); press(saved);
+ }
+});
+
+void test('Thumper guides reject unsupported native packets, unavailable quotes and generic preparation fallback', () => {
+ for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { leader: 'foreign-leader' }]) {
+ const game = firstReveal(); Object.assign(game.turnActionGuide!.action, extra);
+ const button = guide({ game, busy: false, act() { assert.fail('Thumper accepts only its exact native printed-play packet'); } });
+ assert.equal(button.props.disabled, true); press(button);
+ }
+ for (const phase of [0, 2, 4, 5, 6, 8]) {
+ const game = firstReveal(); game.phase = phase; game.active = 'p'; game.turnActionGuide!.action.turnActionPromisePhase = phase;
+ const button = guide({ game, busy: false, act() { assert.fail('A held Thumper cannot become generic native preparation'); } });
+ assert.equal(button.props.disabled, true); press(button);
+ }
+ const unquoted = firstReveal(); unquoted.thumperCards = [];
+ assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The root native quote must make Thumper available'); } }).props.disabled, true);
+ const unrelated = firstReveal(); unrelated.turnActionPromises[0].mixed = payment();
+ assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Thumper requires its original own raw first-reveal source'); } }).props.disabled, true);
+ const extraReady = firstReveal({ type: 'ready', amount: 0 });
+ assert.equal(guide({ game: extraReady, busy: false, act() { assert.fail('First-reveal Ready accepts no extra native fields'); } }).props.disabled, true);
+ const unavailableReady = firstReveal({ type: 'ready' }); unavailableReady.thumperReady = false;
+ assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('An open source alone cannot invent a native Ready stage'); } }).props.disabled, true);
+});
+
+void test('Thumper first-reveal questions retain the quoted phase-one Box route before the actual first draw', () => {
+ const game = firstReveal(), me = game.players.find(player => player.id === 'p')!;
+ const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
+ me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
+ game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 1 };
+ 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.thumperWindowOpen = false; 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-thumper-runtime.test.ts b/tests/truth-turn-thumper-runtime.test.ts
new file mode 100644
index 00000000..f13a503f
--- /dev/null
+++ b/tests/truth-turn-thumper-runtime.test.ts
@@ -0,0 +1,315 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { spiceDeck, treacheryDeck } from '../game/cards';
+import { stormSectorAfter } from '../game/disaster-rules';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
+import { truthFactAnswer } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
+import {
+ actTruthTurnThumper as act, advanceTruthTurnThumper as advance, answerTruthTurnThumper as answer,
+ askTruthTurnThumper as ask, bindTruthTurnThumper as bind, closeTruthTurnThumper as close,
+ createTruthTurnThumperFixture as fixture, declareTruthTurnThumper as declare, finishTruthTurnThumper as finish,
+ nextTruthTurnThumperNative as nativeStep, settleTruthTurnThumper as settle,
+ truthTurnThumperClean as clean, truthTurnThumperFact as fact, truthTurnThumperGroup as group,
+ truthTurnThumperInventory as inventory, truthTurnThumperMove as move, truthTurnThumperPayment as payment,
+ truthTurnThumperPlay as play, truthTurnThumperPlayer as player, truthTurnThumperPromise as promise,
+ truthTurnThumperSpice as spice, truthTurnThumperTraitors as traitors,
+ type TruthTurnThumperFixture, type TruthTurnThumperStep,
+} from './fixture-truth-turn-thumper';
+
+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: TruthTurnThumperFixture, 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);
+ assert.deepEqual(spice(game), f.originalSpice);
+}
+function receipt(game: Game, index = 0) {
+ const stage = game.turnActionPromises![index].windowCards?.thumper; assert.ok(stage); return stage;
+}
+
+void test('genuine Thumper sources keep the complete selected-Ix inventories and actual opening or earned-body chronology', () => {
+ for (const options of [{}, { advanced: true }, { turn: 2 as const }, { turn: 2 as const, waitingSandtrout: true }]) {
+ const f = fixture(options); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
+ assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
+ assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
+ assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
+ 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.thumper]);
+ assert.equal(f.game.phase, 1); assert.equal(f.game.turn, f.turn); assert.equal(f.game.thumperWindowOpen, true);
+ assert.deepEqual(f.game.ready, []); assert.equal(viewGame(f.game, f.target).beforeSpiceDraw, true);
+ assert.deepEqual(windowCardOpportunity(f.game, 'thumper'), f.opportunity);
+ assert.equal(f.source, `first-spice-reveal:${f.turn}`); assert.ok(f.sourceOpening.phaseOpening);
+ assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
+ assert.equal(f.sourceCounts.sourceOpening, f.sourceOpeningSteps.length);
+ assert.ok(f.sourceOpeningSteps.filter(step => step.action.type === 'ready').length >= f.game.players.length);
+ assert.deepEqual(viewGame(f.game, f.target).thumperCards, [f.thumper]);
+ assert.deepEqual(viewGame(f.game, f.asker).thumperCards, []);
+ if (f.turn === 2) {
+ assert.ok(f.priorShipment && f.priorBlow); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
+ assert.equal(player(f.game, f.target).forces[f.previousLocation], 2); assert.equal(player(f.game, f.target).tanks, 0);
+ assert.equal(player(f.game, f.target).spice, 10, 'Real shipment4 and native collection4 retain the original ten');
+ assert.deepEqual(f.secondStormSteps.map(step => step.action.amount), [1, 1]);
+ assert.equal(f.sourceCounts.secondStormDials, f.secondStormSteps.length);
+ assert.equal(f.game.storm, stormSectorAfter(f.firstBeforeReveal.storm, 2));
+ assert.equal(f.game.sandtrout, f.waitingSandtrout);
+ }
+ assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
+ assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
+ assert.deepEqual(spice(f.game), f.originalSpice);
+ }
+});
+
+void test('printed first-turn Thumper AND payment freezes facts and counts the real cost even when the encounter is ignored', () => {
+ for (const advanced of [false, true]) {
+ const f = fixture({ advanced }), mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient));
+ let game = bind(f, 'yes', mixed).game; assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
+ game = act(game, f.target, f.payment); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
+ assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).bribes, 2);
+ assert.equal(game.nexus, false); assert.equal(player(game, f.target).tanks, 0); assert.equal(game.thumperWindowOpen, false);
+ assert.ok(game.log.some(entry => /first-turn worm rule ignores this Thumper/.test(entry.text)));
+ assert.equal(game.spiceDiscard.flat().some(card => 'worm' in card && card.thumper), false);
+ assert.equal(game.spiceWindow?.territory, f.previousTerritory); assert.equal(game.spiceWindow?.amount, 6);
+ physical(game, f);
+ }
+});
+
+void test('a real later Thumper kills the two genuinely shipped previous-pile counters before the next original land blow', () => {
+ const f = fixture({ turn: 2 }); let game = bind(f).game;
+ game = act(game, f.target, f.payment); const reserves = player(game, f.target).reserves;
+ game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(player(game, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(game, f.target).tanks, 2);
+ assert.equal(player(game, f.target).reserves, reserves); assert.equal(game.spice[f.previousLocation] ?? 0, 0);
+ assert.equal(game.nexus, true); assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 8);
+ assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('earned waiting Sandtrout can suppress the real later Thumper while its printed cost still fulfills', () => {
+ const f = fixture({ turn: 2, waitingSandtrout: true }); assert.equal(f.game.sandtrout, true);
+ let game = bind(f, 'yes', play(f.source)).game; game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(game.sandtrout, false); assert.equal(game.nexus, false);
+ assert.equal(player(game, f.target).forces[f.previousLocation], 2); assert.equal(player(game, f.target).tanks, 0);
+ assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 16);
+ assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1); physical(game, f);
+});
+
+void test('whole Thumper OR payment Yes preserves the actual printed route and the manual payment route through normal reveal', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ const bound = bind(f, 'yes', mixed).game;
+ const played = settle(act(bound, f.target, f.nativeAction));
+ assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
+ let paid = act(bound, f.target, f.payment); paid = close(paid, f);
+ assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(receipt(paid).played, false);
+ assert.ok(player(paid, f.target).hand.some(card => card.id === f.thumper)); physical(played, f); physical(paid, f);
+});
+
+void test('whole Thumper AND payment No accepts either first action and guards only the actual completed predicate', () => {
+ const f = fixture();
+ for (const first of ['payment', 'card'] as const) {
+ let game = bind(f, 'no').game;
+ if (first === 'payment') { game = act(game, f.target, f.payment); rejected(game, f.target, f.nativeAction); }
+ else {
+ game = settle(act(game, f.target, f.nativeAction)); rejected(game, f.target, f.payment);
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ }
+ game = close(game, f); assert.ok(!promise(game, f.target).released);
+ game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+ }
+});
+
+void test('whole Thumper OR payment No retains its real cumulative cap after the original first draw', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.nativeAction); rejected(game, f.target, f.payment);
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
+ assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
+ 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('a skipped first-reveal Thumper OR remains payable through actual late Mentat', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ let game = close(bind(f, 'yes', mixed).game, f);
+ const evidence = structuredClone(receipt(game)); assert.deepEqual(evidence, { source: f.source, played: false, closed: true });
+ assert.ok(!promise(game, f.target).fulfilled); assert.ok(player(game, f.target).hand.some(card => card.id === f.thumper));
+ game = advance(game, state => state.phase === 8 && clean(state), [], true);
+ rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
+ assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game), evidence); physical(game, f);
+});
+
+void test('definite Thumper answers reopen actual Ready and own acceptance cannot skip a required cost', () => {
+ const f = fixture(), ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
+ let game = bind(f, 'yes', play(f.source), ready).game;
+ assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
+ game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' });
+ assert.equal(game.thumperWindowOpen, true); rejected(game, f.target, { type: 'ready' });
+ game = settle(act(game, f.target, f.nativeAction)); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+ const declined = bind(f, 'no', play(f.source), ready).game; assert.equal(declined.ready.includes(f.target), false);
+ const completed = close(act(declined, f.target, { type: 'ready' }), f);
+ assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
+ for (const [selected, mixed] of [
+ ['yes', group('or', fact(f.spiceFact), play(f.source))],
+ ['no', group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(f.source))],
+ ] as const) {
+ const answered = bind(f, selected, mixed, ready).game; assert.equal(answered.ready.includes(f.target), false); physical(answered, f);
+ }
+});
+
+void test('real Amal opening and Truthtrance modal passes preserve the original first-reveal source', () => {
+ const f = fixture(); let game = structuredClone(f.sourceOpening);
+ while (game.phaseOpening || game.response) {
+ assert.equal(windowCardOpportunity(game, 'thumper')?.source, f.source); assert.equal(game.thumperWindowOpen, true);
+ const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action);
+ }
+ assert.equal(beforeIxSpiceDraw(game), true); assert.deepEqual(game.ready, []);
+ const declared = declare(f, game); assert.equal(windowCardOpportunity(declared.game, 'thumper')?.source, f.source);
+ const asked = ask(declared.game, f.asker, f.target, play(f.source));
+ assert.equal(windowCardOpportunity(asked, 'thumper')?.source, f.source);
+ game = answer(asked, f.target, 'yes'); game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('exact Thumper guides reject stale source, owner and event while a real first reveal permanently closes that source', () => {
+ const f = fixture(), declared = declare(f);
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play('first-spice-reveal:2') } });
+ rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
+ kind: 'windowCardPlay', effect: 'thumper', source: f.source, worm: true,
+ } } } });
+ let game = bind(f).game; game = act(game, f.target, f.payment);
+ const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
+ assert.deepEqual(Object.keys(guide.action).sort(), ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].sort());
+ assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.thumper);
+ assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
+ assert.equal(guide.action.turnActionPromisePhase, 1); assert.deepEqual(own.thumperCards, [f.thumper]);
+ assert.deepEqual(viewGame(game, f.asker).thumperCards, []);
+ assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
+ rejected(game, f.asker, guide.action);
+ rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-thumper-event' });
+ rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 0 });
+ 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); physical(game, f);
+ const closed = close(f.game, f); assert.equal(closed.thumperWindowOpen, false);
+ assert.equal(windowCardOpportunity(closed, 'thumper'), null); rejected(closed, f.target, f.nativeAction);
+ const after = declare(f, closed);
+ rejected(after.game, f.asker, { type: 'truthAsk', question: f.question });
+});
+
+void test('an original first-turn No does not forbid a later printed Thumper and second actual Truth in the next reveal window', () => {
+ const f = fixture(), actions: TruthTurnThumperStep[] = [];
+ let game = close(bind(f, 'no', play(f.source), f.game, 0, actions).game, f, actions);
+ const earlier = structuredClone(receipt(game)); assert.equal(promise(game, f.target).fulfilled, true);
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions, true);
+ game = settle(act(game, f.target, { type: 'ship', territory: f.previousTerritory, sector: f.previousSector,
+ amount: 2, elite: 0, allyPayment: 0 }, actions), actions);
+ game = act(game, f.target, { type: 'endMovement' }, actions);
+ game = advance(game, state => state.turn === 2 && beforeIxSpiceDraw(state), actions, true);
+ assert.deepEqual(actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [1, 1]);
+ assert.equal(player(game, f.target).forces[f.previousLocation], 2); assert.equal(player(game, f.target).tanks, 0);
+ orderIxSpiceCards(game, [{ territory: 'broken_land' }]);
+ const next = windowCardOpportunity(game, 'thumper'); assert.ok(next); assert.notEqual(next.source, f.source);
+ game = bind(f, 'yes', play(next.source), game, 1, actions).game;
+ game = settle(act(game, f.target, f.nativeAction, actions), actions);
+ assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
+ assert.deepEqual(receipt(game), earlier); assert.equal(receipt(game).played, false);
+ assert.equal(player(game, f.target).tanks, 2); assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
+ assert.ok(actions.length > f.sourceActionCount); physical(game, f, 2);
+});
+
+void test('the earned three-counter move baseline is real but mandatory later Thumper cannot preserve it after paying the current purse', () => {
+ const f = fixture({ turn: 2, bodyCount: 3 }), amount = player(f.game, f.target).spice;
+ assert.ok(f.priorShipment); assert.equal(f.bodyCount, 3);
+ assert.equal(player(f.priorShipment.before, f.target).spice - player(f.priorShipment.after, f.target).spice, 6);
+ assert.equal(player(f.game, f.target).forces[f.previousLocation], 3); assert.equal(player(f.game, f.target).tanks, 0);
+ assert.equal(amount, 10); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
+ const baseline = group('and', move('polar_sink', 3), payment(f.recipient, amount));
+ let game = bind(f, 'yes', baseline).game; game = act(game, f.target, { type: 'bribe', target: f.recipient, amount });
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
+ assert.equal(player(game, f.target).forces[f.previousLocation], 3);
+ game = settle(act(game, f.target, { type: 'move', from: f.previousLocation, territory: 'polar_sink', sector: 0, amount: 3 }));
+ assert.equal(player(game, f.target).forces['polar_sink:0'], 3); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+ let promised = bind(f, 'yes', baseline).game;
+ promised = act(promised, f.target, { type: 'bribe', target: f.recipient, amount });
+ rejected(promised, f.target, f.nativeAction);
+ assert.equal(player(promised, f.target).forces[f.previousLocation], 3);
+ const mixed = group('and', play(f.source), move('polar_sink', 3), payment(f.recipient, amount));
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
+ let declined = answer(asked, f.target, 'no'); assert.equal(receipt(declined).played, false);
+ declined = act(declined, f.target, { type: 'bribe', target: f.recipient, amount });
+ declined = settle(act(declined, f.target, f.nativeAction));
+ assert.equal(player(declined, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(declined, f.target).tanks, 3);
+ assert.equal(receipt(declined).played, true); assert.ok(!promise(declined, f.target).released); physical(declined, f);
+});
+
+void test('waiting Sandtrout three-counter answers ignore the hidden next Spice Card while an actual later Worm releases the impossible remainder', () => {
+ const f = fixture({ turn: 2, waitingSandtrout: true, bodyCount: 3 }), amount = player(f.game, f.target).spice;
+ assert.ok(f.priorShipment); assert.equal(f.bodyCount, 3);
+ assert.equal(player(f.priorShipment.before, f.target).spice - player(f.priorShipment.after, f.target).spice, 6);
+ assert.equal(amount, 10); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
+ const mixed = group('and', play(f.source), move('polar_sink', 3), payment(f.recipient, amount));
+ for (const next of ['land', 'worm'] as const) {
+ const source = structuredClone(f.game);
+ orderIxSpiceCards(source, [...(next === 'worm' ? ['worm' as const] : []), { territory: 'broken_land' }]);
+ assert.deepEqual(spice(source), f.originalSpice); assert.equal(source.sandtrout, true);
+ assert.equal(player(source, f.target).forces[f.previousLocation], 3); assert.equal(player(source, f.target).tanks, 0);
+ const asked = ask(declare(f, source).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no'], next);
+ let game = answer(asked, f.target, 'yes');
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount });
+ assert.equal(player(game, f.target).spice, 0);
+ const paid = structuredClone(game.turnActionPromises![0].payments), history = structuredClone(game.truthHistory!.at(-1));
+ game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(receipt(game).played, true); assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
+ assert.equal(game.sandtrout, false); assert.deepEqual(game.turnActionPromises![0].payments, paid);
+ assert.deepEqual(game.truthHistory!.at(-1), history);
+ if (next === 'worm') {
+ assert.equal(player(game, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(game, f.target).tanks, 3);
+ assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled);
+ } else {
+ assert.equal(player(game, f.target).forces[f.previousLocation], 3); assert.equal(player(game, f.target).tanks, 0);
+ assert.ok(!promise(game, f.target).released); assert.ok(!promise(game, f.target).fulfilled);
+ assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 16);
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
+ game = settle(act(game, f.target, { type: 'move', from: f.previousLocation, territory: 'polar_sink', sector: 0, amount: 3 }));
+ assert.equal(player(game, f.target).forces['polar_sink:0'], 3); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.ok(!promise(game, f.target).released); assert.deepEqual(game.turnActionPromises![0].payments, paid);
+ assert.deepEqual(game.truthHistory!.at(-1), history);
+ }
+ physical(game, f);
+ }
+});
+
+void test('all four minimal profiles play actual first-turn Thumper guides and accept No with native first-reveal Ready', () => {
+ const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(asked); const actions: TruthTurnThumperStep[] = [];
+ 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 < 18 && !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; assert.ok(guide, difficulty);
+ const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
+ assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
+ }
+ assert.equal(promise(game, f.target).fulfilled, true, difficulty);
+ assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.thumper)); physical(game, f);
+ const no = bind(f, 'no', play(f.source)).game, view = viewGame(no, f.target);
+ view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ const ready = botActions(view)[0]; assert.equal(ready.type, 'ready'); assert.deepEqual(ready, view.turnActionGuide?.action);
+ const completed = close(act(no, f.target, ready), f);
+ assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
+ }
+});
diff --git a/tests/truthtrance.test.ts b/tests/truthtrance.test.ts
index 1b2413f6..2098057a 100644
--- a/tests/truthtrance.test.ts
+++ b/tests/truthtrance.test.ts
@@ -14,11 +14,22 @@ import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
truthFactAnswer,
+ truthQuestionText,
type TruthFact,
type TruthQuestion,
} from '../game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+void test('whole card questions name Thumper and the actual printed Karama role and shipment beneficiary', () => {
+ const question = (effect: 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
+ truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
+ kind: 'windowCardPlay', effect, source,
+ } } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id);
+ assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/);
+ assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/);
+ assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
+});
+
function fixture() {
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(
diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts
index 76f8d413..dd542ef3 100644
--- a/tests/turn-action-promises.test.ts
+++ b/tests/turn-action-promises.test.ts
@@ -61,7 +61,7 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
- effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
+ effect === 'thumper' ? 'first-spice-reveal: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 } });
@@ -1520,3 +1520,55 @@ void test('closing a shipment-price opportunity retains actual play and later wh
assert.deepEqual(expired.windowCards!.karamaShipment, { source, played: false, closed: true });
validateTurnActionPromises([expired], seats);
});
+
+void test('Thumper receipt means accepted printed play after this answer and carries no worm result or chosen destination', () => {
+ const original = windowed(windowCard('thumper'));
+ const leaf = { kind: 'windowCardPlay', effect: 'thumper', source: 'first-spice-reveal:1' };
+ for (const extra of [{ worm: true }, { territory: 'carthag' }, { suppressed: true }, { card: 'physical-thumper' }])
+ assert.throws(() => parseTurnActionLeaf({ ...leaf, ...extra }), TurnActionError);
+ const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0];
+ assert.equal(played.fulfilled, true, 'No worm effect or successful outcome is part of the receipt');
+ assert.equal(original.windowCards!.thumper!.played, false);
+ const fresh = windowed(windowCard('thumper'), true, 'turn-action:2');
+ assert.equal(fresh.windowCards!.thumper!.played, false);
+ assert.equal(closeTurnActionWindowCardPlay([fresh], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, false);
+ assert.equal(recordTurnActionWindowCardPlay([played], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0], played);
+ validateTurnActionPromises([played], seats);
+});
+
+void test('Thumper No binds only the exact actor turn and original first reveal until actual source closure', () => {
+ const no = windowed(windowCard('thumper'), false);
+ for (const [player, turn, source] of [['other', 1, 'first-spice-reveal:1'],
+ ['respondent', 2, 'first-spice-reveal:1'], ['respondent', 1, 'first-spice-reveal:2']] as const) {
+ assert.equal(recordTurnActionWindowCardPlay([no], player, turn, 'thumper', source)[0], no);
+ assert.equal(closeTurnActionWindowCardPlay([no], player, turn, 'thumper', source)[0], no);
+ }
+ assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].fulfilled, false);
+ assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1'), TurnActionError);
+ assert.equal(no.windowCards!.thumper!.played, false);
+ const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0];
+ assert.equal(closed.fulfilled, true);
+ assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0], closed);
+ validateTurnActionPromises([closed], seats);
+});
+
+void test('Thumper whole AND No and OR Yes retain their later payment correlation after first-reveal closure', () => {
+ const no = windowed(group('and', windowCard('thumper'), pay()), false);
+ const granted = recordTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1');
+ const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'thumper', 'first-spice-reveal:1');
+ assert.equal(closed[0].fulfilled, false);
+ assert.deepEqual(branches(closed[0]), [[signedPay('recipient', 3, 0, false)]]);
+ assert.throws(() => recordTurnActionPayment(closed, 'respondent', 1, payment()), TurnActionError);
+ assert.deepEqual(closed[0].payments, []);
+ const expiry = finishTurnActionTurn(closed, 'respondent', 1)[0];
+ assert.equal(expiry.fulfilled, true);
+ const alternative = windowed(group('or', windowCard('thumper'), pay()), true, 'turn-action:2');
+ const absent = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'thumper', 'first-spice-reveal:1');
+ assert.deepEqual(branches(absent[0]), [[signedPay()]]);
+ assert.equal(recordTurnActionPayment(absent, 'respondent', 1, payment())[0].fulfilled, true);
+ const and = windowed(group('and', windowCard('thumper'), pay()), true, 'turn-action:3');
+ const paid = recordTurnActionPayment([and], 'respondent', 1, payment());
+ assert.equal(paid[0].fulfilled, false);
+ assert.equal(recordTurnActionWindowCardPlay(paid, 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, true);
+ validateTurnActionPromises([expiry], seats);
+});
diff --git a/tests/window-card-play.test.ts b/tests/window-card-play.test.ts
index c79ec176..ae86034c 100644
--- a/tests/window-card-play.test.ts
+++ b/tests/window-card-play.test.ts
@@ -23,8 +23,44 @@ function shipments(): WindowCardGame {
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
- assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment']);
- assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama']);
+ assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper']);
+ assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper']);
+});
+
+void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => {
+ const source = { ...storm(), phase: 1, thumperWindowOpen: true }, before = structuredClone(source);
+ assert.deepEqual(windowCardOpportunity(source, 'thumper'), { effect: 'thumper', source: 'first-spice-reveal:3', turn: 3 });
+ assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:3')!.source, 'first-spice-reveal:3');
+ assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:4'), null);
+ assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'thumper')!.source, 'first-spice-reveal:4');
+ assert.deepEqual(source, before);
+});
+
+void test('an explicitly open first-reveal entitlement survives temporary controls and summoned-worm or Nexus state', () => {
+ const source = { ...storm(), phase: 1, thumperWindowOpen: true };
+ for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} },
+ { nexus: true }, { spiceResolution: { kind: 'summonedWorm' } }, { spiceSequence: { skipped: [] } },
+ { spiceWindow: blow().spiceWindow }])
+ assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3');
+});
+
+void test('first real reveal or printed Thumper closes the first-reveal source through later Nexus and second-blow windows', () => {
+ const source = { ...storm(), phase: 1, thumperWindowOpen: false };
+ for (const later of [{}, { nexus: true }, { spiceSequence: {} }, { spiceResolution: {} },
+ { spiceWindow: blow().spiceWindow }, { response: {} }])
+ assert.equal(windowCardOpportunity({ ...source, ...later }, 'thumper'), null);
+ for (const invalid of [{ phase: 0 }, { phase: 2 }, { status: 'setup' }, { turn: 0 }, { turn: 1.5 },
+ { turn: Number.MAX_SAFE_INTEGER + 1 }, { thumperWindowOpen: 'yes' }, { thumperWindowOpen: null }])
+ assert.equal(windowCardOpportunity({ ...source, thumperWindowOpen: true, ...invalid } as WindowCardGame, 'thumper'), null);
+});
+
+void test('legacy absent Thumper flag retains the original native clean first-reveal fallback', () => {
+ const source = { ...storm(), phase: 1 };
+ assert.equal(windowCardOpportunity(source, 'thumper')!.source, 'first-spice-reveal:3');
+ for (const started of [{ spiceSequence: {} }, { spiceResolution: {} }, { spiceWindow: blow().spiceWindow }, { nexus: true }])
+ assert.equal(windowCardOpportunity({ ...source, ...started }, 'thumper'), null);
+ for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }])
+ assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3');
});
void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => {