Bind Thumper promises before the original hidden Spice draw
This commit is contained in:
1 parent
fd38c51ffd
commit
00741a37d0
19 files changed
+985
-34
No files matched your search
@@ -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 &&
|
||||
|
||||
+87
-9
@@ -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<Game, string>();
|
||||
const thumperForcedDraws = new WeakSet<Game>();
|
||||
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<T>(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<Action> {
|
||||
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 &&
|
||||
|
||||
+4
-3
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+12
-4
@@ -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)}`;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user