Bind Truthtrance card plays to original Storm and spice windows
This commit is contained in:
1 parent
09a81a9b65
commit
e88f871385
20 files changed
+1519
-26
No files matched your search
@@ -3178,6 +3178,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (
|
||||
g.spiceWindow &&
|
||||
!g.ready.includes(me.id) &&
|
||||
harvesterAvailable(g.spiceWindow) &&
|
||||
countAt(me, g.spiceWindow.territory)
|
||||
) {
|
||||
|
||||
@@ -38,6 +38,8 @@ export type OrdinaryCardState = {
|
||||
decision?: object | null;
|
||||
choamMarket?: { owner: string } | null;
|
||||
shieldWallDestroyed: boolean;
|
||||
stormCardWindowOpen?: boolean;
|
||||
stormResolution?: unknown;
|
||||
spiceWindow?: {
|
||||
harvested: boolean;
|
||||
harvesters?: number;
|
||||
@@ -128,7 +130,7 @@ export function ordinaryCardAvailability(
|
||||
);
|
||||
break;
|
||||
case 'weather':
|
||||
if (state.phase !== 0)
|
||||
if (state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
|
||||
return blocked('Play Weather Control before the storm moves.');
|
||||
if (
|
||||
selection &&
|
||||
@@ -140,7 +142,7 @@ export function ordinaryCardAvailability(
|
||||
return blocked('Storm movement must be an integer from 0 to 10.');
|
||||
break;
|
||||
case 'atomics':
|
||||
if (state.shieldWallDestroyed || state.phase !== 0)
|
||||
if (state.shieldWallDestroyed || state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
|
||||
return blocked('Play Family Atomics before storm movement.');
|
||||
if (
|
||||
!TERRITORIES.some(
|
||||
|
||||
+157
-9
@@ -4,12 +4,14 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { windowCardOpportunity, type WindowCardEffect } from './window-card-play';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
recordTurnActionBattlePower, closeTurnActionBattlePower,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||
recordTurnActionWindowCardPlay, closeTurnActionWindowCardPlay,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
@@ -2500,6 +2502,8 @@ export type Game = {
|
||||
}[];
|
||||
stormDials: Record<string, number>;
|
||||
stormPending: number | null;
|
||||
/** Actual original Storm card window; absent older games use native phase/resolution. */
|
||||
stormCardWindowOpen?: boolean;
|
||||
stormCard: number | null;
|
||||
stormCardKnown: boolean;
|
||||
stormResolution: {
|
||||
@@ -2563,6 +2567,8 @@ export type Game = {
|
||||
winner: string[];
|
||||
shieldWallDestroyed: boolean;
|
||||
spiceWindow: {
|
||||
/** Stable identity for a genuinely newly drawn fresh blow. */
|
||||
event?: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
@@ -4947,6 +4953,7 @@ function finishNullentropy(
|
||||
}
|
||||
function beginNullentropy(g: Game, owner: Player, cardId: string) {
|
||||
const { candidates } = validateNullentropyBegin(g, owner, cardId);
|
||||
reopenTurnActionWindowReady(g, owner.id);
|
||||
owner.spice -= 2;
|
||||
const event = crypto.randomUUID();
|
||||
g.pendingNullentropy = {
|
||||
@@ -12530,6 +12537,7 @@ function finishSetup(g: Game) {
|
||||
.map((leader) => ({ leader, revealed: false }));
|
||||
}
|
||||
g.status = 'playing';
|
||||
g.stormCardWindowOpen = true;
|
||||
if (g.homeworlds?.custody) {
|
||||
g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID()));
|
||||
g.homeworlds.historyVersion = 1;
|
||||
@@ -12588,6 +12596,7 @@ function moveStorm(g: Game, n: number) {
|
||||
offerChoamStorm(g);
|
||||
}
|
||||
function startStormMovement(g: Game, n: number) {
|
||||
g.stormCardWindowOpen = false;
|
||||
const offer = nexusRule(() => discoveryStormOffer(g));
|
||||
if (offer && !offer.blocked) {
|
||||
g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID()));
|
||||
@@ -13488,7 +13497,7 @@ function continueSpice(g: Game, injected?: SpiceCard) {
|
||||
if (card.discovery) resolveDiscoveryBlow(g, card);
|
||||
g.spiceDiscard[sequence.pile].push(card);
|
||||
const amount = card.amount * (doubleNext ? 2 : 1);
|
||||
g.spiceWindow = { ...card, amount, harvested: false };
|
||||
g.spiceWindow = { ...card, event: crypto.randomUUID(), amount, harvested: false };
|
||||
const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount });
|
||||
if (!placement.blocked) {
|
||||
g.spice[placement.key] = placement.total;
|
||||
@@ -18908,6 +18917,105 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
|
||||
if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]);
|
||||
}
|
||||
}
|
||||
type TurnActionWindowCardRequirement = Extract<TurnActionBranchLeaf, { kind: 'windowCardPlay' }>;
|
||||
const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect =>
|
||||
effect === 'weather' || effect === 'atomics' || effect === 'harvester';
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||
if (!windowCardOpportunity(g, effect)) return;
|
||||
const player = getPlayer(g, actor);
|
||||
for (const card of player.hand.filter(card => card.effect === effect && !transferCardBlock(g, player, card)))
|
||||
for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) {
|
||||
const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn,
|
||||
active: g.active, me: actor, shieldWallDestroyed: g.shieldWallDestroyed,
|
||||
stormCardWindowOpen: g.stormCardWindowOpen, stormResolution: g.stormResolution,
|
||||
spiceWindow: g.spiceWindow, truthtrance: g.truthtrance, response: g.response,
|
||||
decision: g.decision, phaseOpening: g.phaseOpening, players: [player] }, card.id, { amount });
|
||||
if (availability?.available) yield { type: 'card', card: card.id, ...(amount === undefined ? {} : { amount }) };
|
||||
}
|
||||
}
|
||||
/** Execute real printed effects on detached states before sharing their
|
||||
* resulting terrain, pieces and recovery costs with the other action families. */
|
||||
function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: readonly TurnActionWindowCardRequirement[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
const key = (required: TurnActionWindowCardRequirement) => JSON.stringify([required.effect, required.source]);
|
||||
const search = function* (game: Game, actions: Action[], played: ReadonlySet<string>): Generator<{ game: Game; actions: Action[] }> {
|
||||
const pending = requirements.filter(required => required.answer && !played.has(key(required)));
|
||||
if (!pending.length) {
|
||||
if (requirements.every(required => played.has(key(required)) === required.answer)) yield { game, actions };
|
||||
return;
|
||||
}
|
||||
for (const required of pending) {
|
||||
if (windowCardOpportunity(game, required.effect)?.source !== required.source) continue;
|
||||
const play = function* (state: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
for (const action of nativeWindowCardActions(state, actor, required.effect)) {
|
||||
try {
|
||||
const next = applyActionInner(state, actor, action, 'movementPreparation');
|
||||
yield* search(next, [...actions, ...prefix, action], new Set([...played, key(required)]));
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
};
|
||||
yield* play(game, []);
|
||||
const paid = game.pendingNullentropy;
|
||||
if (paid?.player === actor) {
|
||||
if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === required.effect)) {
|
||||
const recovered = projectMovementBoxRecovery(game, actor, paid.box, card.id, true);
|
||||
if (recovered) yield* play(recovered, [{ type: 'decision', event: paid.event, card: card.id }]);
|
||||
}
|
||||
} else for (const box of getPlayer(game, actor).hand.filter(card => card.effect === 'nullentropyBox'))
|
||||
for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === required.effect)) {
|
||||
const recovered = projectMovementBoxRecovery(game, actor, box.id, known.id, false);
|
||||
if (recovered) yield* play(recovered, [{ type: 'card', card: box.id }]);
|
||||
}
|
||||
}
|
||||
};
|
||||
yield* search(source, [], new Set());
|
||||
}
|
||||
function* turnActionPrintedPreparationSeeds(source: Game, actor: string, cards: readonly TurnActionCardRequirement[],
|
||||
windows: readonly TurnActionWindowCardRequirement[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
if (!windows.length) { yield* turnActionCardSeeds(source, actor, cards); return; }
|
||||
for (const window of turnActionWindowCardSeeds(structuredClone(source), actor, windows))
|
||||
for (const card of turnActionCardSeeds(window.game, actor, cards))
|
||||
yield { game: card.game, actions: [...window.actions, ...card.actions] };
|
||||
// An owned Ghola can supply its real allowed income before a paid Box route.
|
||||
if (cards.length) for (const card of turnActionCardSeeds(structuredClone(source), actor, cards))
|
||||
for (const window of turnActionWindowCardSeeds(card.game, actor, windows))
|
||||
yield { game: window.game, actions: [...card.actions, ...window.actions] };
|
||||
}
|
||||
function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: WindowCardEffect, source: string) {
|
||||
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
|
||||
recordTurnActionWindowCardPlay(g.turnActionPromises!, actor, g.turn, effect, source));
|
||||
}
|
||||
function reconcileTurnActionWindowCards(g: Game) {
|
||||
if (!g.turnActionPromises) return;
|
||||
for (const promise of g.turnActionPromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (!stage || stage.closed || promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
|
||||
promise.player, promise.turn, effect, stage.source));
|
||||
}
|
||||
}
|
||||
}
|
||||
/** A concurrent Ready grants permission to close this window. Quote that
|
||||
* closure without installing it before the real global continuation. */
|
||||
function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[] {
|
||||
let closed = g.turnActionPromises ?? [];
|
||||
for (const promise of liveTurnActionPromises(closed, actor, g.turn))
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (stage && !stage.closed) closed = turnActionRule(() =>
|
||||
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source));
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
function reopenTurnActionWindowReady(g: Game, actor: string) {
|
||||
if (g.ready.includes(actor) && liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
|
||||
(['weather', 'atomics', 'harvester'] as const).some(effect => {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect)?.source === stage.source;
|
||||
}))) g.ready = g.ready.filter(player => player !== actor);
|
||||
}
|
||||
/** Check the actual spent card and returned pieces before a later foreign
|
||||
* income/discard response can hide an own voluntary loss of feasibility. */
|
||||
function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||
@@ -19254,7 +19362,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
const voluntary = { actor, action: { type: 'bribe' } };
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
||||
for (const cardSeed of turnActionCardSeeds(paid, actor, cardRequirements)) {
|
||||
for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements,
|
||||
leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay'))) {
|
||||
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||
try {
|
||||
reconcileBattlePromises(funded, voluntary);
|
||||
@@ -19320,6 +19429,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
const open = turnActionTransportOpen(g, p.id);
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false;
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid') bid = true;
|
||||
@@ -19327,6 +19437,9 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
if (node.action.kind === 'battlePlan') plan = true;
|
||||
if (node.action.kind === 'traitorCall') traitor = true;
|
||||
if (node.action.kind === 'battlePower') powers.add(battlePowerOf(node.action.claim));
|
||||
if (node.action.kind === 'windowCardPlay') windowCards[node.action.effect] = {
|
||||
source: node.action.source, played: false, closed: false,
|
||||
};
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true;
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true;
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
|
||||
@@ -19359,6 +19472,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
|
||||
closed: false, call: null } : null } : undefined;
|
||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
...(Object.keys(windowCards).length ? { windowCards } : {}),
|
||||
...(cardHajr || cardGhola ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||
ghola: cardGhola ? { played: false, closed: false } : null } } : {}),
|
||||
...(combat ? { combat } : {}),
|
||||
@@ -19371,8 +19485,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
|
||||
return promise;
|
||||
}
|
||||
function turnActionAnswerSource(g: Game, player: string, alliance = false): Game {
|
||||
return g.phase === 1 && alliance || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
|
||||
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game {
|
||||
return g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
|
||||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
|
||||
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
|
||||
}
|
||||
@@ -19384,7 +19498,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
|
||||
try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); }
|
||||
catch (error) { if (error instanceof RuleError) continue; throw error; }
|
||||
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
|
||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance),
|
||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards),
|
||||
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
|
||||
if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
@@ -19395,7 +19509,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
|
||||
requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a whole turn-action answer compatible with current funding and native actions.');
|
||||
const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker);
|
||||
g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance).ready;
|
||||
g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards).ready;
|
||||
g.turnActionPromises ??= [];
|
||||
initializeTurnActionOrder(g);
|
||||
g.turnActionPromises.push(promise);
|
||||
@@ -19403,6 +19517,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
|
||||
}
|
||||
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
reconcileTurnActionCombat(g);
|
||||
reconcileTurnActionWindowCards(g);
|
||||
turnActionRule(() => validateTurnActionPromises(g.turnActionPromises!, g.players.map(player => player.id)));
|
||||
for (const p of g.players) {
|
||||
for (const turn of new Set(g.turnActionPromises!.filter(promise => promise.player === p.id &&
|
||||
@@ -19526,11 +19641,29 @@ 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)) {
|
||||
const closed = ownTurnActionWindowClosure(g, player.id);
|
||||
if (closed !== g.turnActionPromises && turnActionCompletion({ ...g, turnActionPromises: closed }, player.id))
|
||||
action = { type: 'ready' };
|
||||
}
|
||||
if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' };
|
||||
if (!action) return null;
|
||||
// The joint quotation can prepare a later answer while an earlier unrelated
|
||||
// answer remains live. Window controls must bind the row naming that source.
|
||||
const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined;
|
||||
const printedWindow = isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
|
||||
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? ['weather', 'atomics', 'harvester'] : [];
|
||||
const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
windows.some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
return stage && !stage.closed && (!printedWindow || !stage.played) &&
|
||||
windowCardOpportunity(g, effect)?.source === stage.source;
|
||||
})) ?? promise : promise;
|
||||
const packet = { ...structuredClone(action) };
|
||||
for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key];
|
||||
return { event: promise.event, action: { ...packet, turnActionPromiseEvent: promise.event, turnActionPromisePhase: g.phase } };
|
||||
return { event: guidePromise.event, action: { ...packet, turnActionPromiseEvent: guidePromise.event, turnActionPromisePhase: g.phase } };
|
||||
}
|
||||
function travelRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
@@ -25909,6 +26042,7 @@ function payCharity(g: Game, player: Player, amount: number, homeworld = 0) {
|
||||
techIncome(g, 'production', player);
|
||||
}
|
||||
function beginStormTurn(g: Game) {
|
||||
g.stormCardWindowOpen = true;
|
||||
if (g.dukeVidal) delete g.dukeVidal.leader.usedAt;
|
||||
for (const p of g.players) if (p.elites) p.elites.revived = 0;
|
||||
g.stormDialers = g.lastBattle;
|
||||
@@ -41670,6 +41804,8 @@ function applyActionInner(
|
||||
response: g.response,
|
||||
decision: g.decision,
|
||||
shieldWallDestroyed: g.shieldWallDestroyed,
|
||||
stormCardWindowOpen: g.stormCardWindowOpen,
|
||||
stormResolution: g.stormResolution,
|
||||
spiceWindow: g.spiceWindow,
|
||||
players: [
|
||||
{
|
||||
@@ -41690,6 +41826,11 @@ function applyActionInner(
|
||||
);
|
||||
if (availability && !availability.available)
|
||||
throw new RuleError(availability.reason);
|
||||
if (isWindowCardEffect(c.effect)) {
|
||||
const source = windowCardOpportunity(g, c.effect);
|
||||
requireRule(source, 'Retain the original open Storm or fresh spice-blow window.');
|
||||
recordNativeTurnActionWindowCard(g, id, c.effect, source.source);
|
||||
}
|
||||
if (c.effect === 'hajr') {
|
||||
p.hajrPlays = hajrPlayCount(g,p) + 1;
|
||||
recordNativePrintedCardPlay(g, p.id, 'hajr');
|
||||
@@ -41732,7 +41873,7 @@ function applyActionInner(
|
||||
'This card’s timing and effect are still being implemented.',
|
||||
);
|
||||
const used = discard(g, p, c.id);
|
||||
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola'))
|
||||
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola' || isWindowCardEffect(c.effect)))
|
||||
guardTurnActionPrintedCardCost(g, p, used);
|
||||
log(
|
||||
g,
|
||||
@@ -41748,6 +41889,11 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||
if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && g.spiceWindow)) {
|
||||
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.');
|
||||
}
|
||||
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
|
||||
if (bribeWindowCloses && g.turnActionPromises) {
|
||||
// Known Double closes payments at the next opening, while Ghola keeps
|
||||
@@ -42503,6 +42649,8 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
fremenVictory,
|
||||
ecazHomeworldVictory,
|
||||
shieldWallDestroyed: g.shieldWallDestroyed,
|
||||
stormCardWindowOpen: g.status === 'playing' && g.phase === 0 &&
|
||||
g.stormCardWindowOpen !== false && !g.stormResolution,
|
||||
stormDialers: g.stormDialers,
|
||||
stormSubmitted: Object.keys(g.stormDials),
|
||||
karamaShipping: g.karamaShipping
|
||||
|
||||
+7
-5
@@ -3212,9 +3212,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
summary: 'Combine original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'Choose Combine native battle powers, Hajr/Ghola plays and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
|
||||
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
|
||||
@@ -3228,6 +3228,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
|
||||
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
|
||||
'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.',
|
||||
@@ -3235,11 +3237,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 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/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 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'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts'] },
|
||||
{ area: '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, shared Revival resources and original power declarations; broader composition and final acceptance remain.', evidence: ['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'] },
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +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 { 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';
|
||||
@@ -385,6 +386,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
const opportunity = windowCardOpportunity(g, action.effect);
|
||||
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
|
||||
'Choose the exact current original Storm or fresh-blow printed-card window.');
|
||||
}
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 &&
|
||||
@@ -734,6 +740,9 @@ 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 === '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)}`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import type { WindowCardEffect } from './window-card-play';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
import {
|
||||
@@ -32,7 +33,8 @@ export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: s
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim };
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim } |
|
||||
{ kind: 'windowCardPlay'; effect: WindowCardEffect; source: string };
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
@@ -46,7 +48,8 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
beneficiary: string; target: string; leader: string | null } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim;
|
||||
owner: string; beneficiary: string; target: string; answer: boolean };
|
||||
owner: string; beneficiary: string; target: string; answer: boolean } |
|
||||
{ kind: 'windowCardPlay'; effect: WindowCardEffect; source: string; answer: boolean };
|
||||
export type TurnActionPreparation = {
|
||||
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
||||
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
||||
@@ -66,6 +69,7 @@ export type TurnActionCombat = {
|
||||
powers?: Record<NativeBattlePower, TurnActionPowerStage | null>;
|
||||
};
|
||||
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||
export type TurnActionWindowCards = Partial<Record<WindowCardEffect, { source: string; played: boolean; closed: boolean }>>;
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
@@ -89,6 +93,7 @@ export type TurnActionPromise = TurnActionClaim & {
|
||||
combat?: TurnActionCombat;
|
||||
/** Accepted printed-role plays after this answer only, never earlier counts. */
|
||||
cardPlays?: TurnActionCardPlays;
|
||||
windowCards?: TurnActionWindowCards;
|
||||
};
|
||||
|
||||
export class TurnActionError extends Error {
|
||||
@@ -114,6 +119,7 @@ const positive = (value: unknown): value is number => whole(value) && value > 0;
|
||||
const physical = (value: unknown): value is number => whole(value) && value <= 20;
|
||||
const printedTerritory = (value: unknown): value is string =>
|
||||
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
|
||||
const windowEffect = (value: unknown): value is WindowCardEffect => value === 'weather' || value === 'atomics' || value === 'harvester';
|
||||
|
||||
/** Identity membership is decoded by Truthtrance; here retain one strict atom. */
|
||||
function parseCombatClaim(value: unknown): MixedBattleActionClaim {
|
||||
@@ -137,6 +143,11 @@ function powerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclarati
|
||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.');
|
||||
try {
|
||||
if (value.kind === 'windowCardPlay') {
|
||||
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
|
||||
'Choose one original Storm or fresh-blow printed-card play after this answer.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
check(keys(value, 'battle,claim,kind,territory') && identifier(value.battle) && identifier(value.territory),
|
||||
'Retain the original battle and native Voice or Prescience declaration clause.');
|
||||
@@ -193,6 +204,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
if (action.kind === 'windowCardPlay') return JSON.stringify([action.kind, action.effect, action.source]);
|
||||
if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]);
|
||||
if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]);
|
||||
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
|
||||
@@ -243,6 +255,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if ('alliance' in promise) fields.push('alliance');
|
||||
if ('combat' in promise) fields.push('combat');
|
||||
if ('cardPlays' in promise) fields.push('cardPlays');
|
||||
if ('windowCards' in promise) fields.push('windowCards');
|
||||
check(keys(promise, fields.sort().join(',')) &&
|
||||
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
||||
'Retain the complete original turn-action commitment and its metadata.');
|
||||
@@ -266,6 +279,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
const powerClaims: Record<NativeBattlePower, Set<string>> = { voice: new Set(), prescience: new Set() };
|
||||
let hasTraitor = false;
|
||||
const cardEffects = new Set<PrintedCardEffect>();
|
||||
const windowSources = new Map<WindowCardEffect, Set<string>>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
@@ -280,6 +294,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if (node.action.kind === 'traitorCall') hasTraitor = true;
|
||||
if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect);
|
||||
if (node.action.kind === 'battlePower') powerClaims[battlePowerOf(node.action.claim)].add(powerKey(node.action.claim));
|
||||
if (node.action.kind === 'windowCardPlay') {
|
||||
const sources = windowSources.get(node.action.effect) ?? new Set<string>();
|
||||
sources.add(node.action.source);
|
||||
windowSources.set(node.action.effect, sources);
|
||||
}
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
@@ -291,6 +310,18 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
for (const power of ['voice', 'prescience'] as const) check(!powerClaims[power].size || !!promise.combat?.powers?.[power],
|
||||
'Retain the original private native battle-power stage, including folded original clauses.');
|
||||
for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.');
|
||||
for (const effect of windowSources.keys()) check(!!promise.windowCards?.[effect],
|
||||
'Retain the original native window for every printed-card window clause, including folded ones.');
|
||||
if ('windowCards' in promise) {
|
||||
check(object(promise.windowCards) && keys(promise.windowCards, [...windowSources.keys()].sort().join(',')),
|
||||
'Retain exactly the original named window-card effects.');
|
||||
for (const [effect, sources] of windowSources) {
|
||||
const stage = promise.windowCards[effect];
|
||||
check(object(stage) && keys(stage, 'closed,played,source') && identifier(stage.source) && sources.size === 1 &&
|
||||
sources.has(stage.source) && typeof stage.played === 'boolean' && typeof stage.closed === 'boolean',
|
||||
'Retain the exact original window source, actual printed play and closure.');
|
||||
}
|
||||
}
|
||||
if ('cardPlays' in promise) {
|
||||
check(object(promise.cardPlays) && keys(promise.cardPlays, 'ghola,hajr'), 'Retain only the native printed-card play stages.');
|
||||
for (const effect of ['hajr', 'ghola'] as const) {
|
||||
@@ -404,6 +435,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'windowCardPlay') return promise.windowCards?.[action.effect]?.played ?? false;
|
||||
if (action.kind === 'battlePower') return promise.combat?.powers?.[battlePowerOf(action.claim)]?.values?.find(entry =>
|
||||
powerKey(entry.claim) === powerKey(action.claim))?.value ?? false;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false;
|
||||
@@ -430,6 +462,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'windowCardPlay') return promise.windowCards![action.effect]!.closed;
|
||||
if (action.kind === 'battlePower') return promise.combat!.powers![battlePowerOf(action.claim)]!.closed;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed;
|
||||
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
|
||||
@@ -471,6 +504,7 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
||||
|
||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === 'windowCardPlay') return right.kind === 'windowCardPlay' && left.effect === right.effect && left.source === right.source;
|
||||
if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle &&
|
||||
left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary &&
|
||||
left.target === right.target && powerKey(left.claim) === powerKey(right.claim);
|
||||
@@ -499,7 +533,8 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || leaf.kind === 'battlePower') {
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' ||
|
||||
leaf.kind === 'battlePower' || leaf.kind === 'windowCardPlay') {
|
||||
const prior = merged.find(other => sameAction(other, leaf));
|
||||
if (prior && prior.answer !== leaf.answer) return null;
|
||||
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
|
||||
@@ -571,6 +606,37 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** Only an accepted printed-role play in this exact original native window
|
||||
* counts. Effect amounts, earlier plays, disposal and recovery do not call it. */
|
||||
export function recordTurnActionWindowCardPlay<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, effect: WindowCardEffect, source: string): T[] {
|
||||
check(windowEffect(effect) && identifier(source), 'Record one accepted original-window printed-card play.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (!stage || stage.closed || stage.played || stage.source !== source) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!,
|
||||
[effect]: { ...stage, played: true } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Close only this original effect/window, preserving every later alternative. */
|
||||
export function closeTurnActionWindowCardPlay<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, effect: WindowCardEffect, source: string): T[] {
|
||||
check(windowEffect(effect) && identifier(source), 'Close one original native printed-card window.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (!stage || stage.closed || stage.source !== source) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!,
|
||||
[effect]: { ...stage, closed: true } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Record the actual owned declaration before any later Karama prevention.
|
||||
* Its exact predicates stay true/false even if the power's effect is denied. */
|
||||
export function recordTurnActionBattlePower<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
@@ -891,6 +957,11 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
return promises.map(promise => {
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (stage) windowCards[effect] = { ...stage, closed: true };
|
||||
}
|
||||
const closed = { ...promise, transportClosed: true, paymentClosed: true,
|
||||
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
||||
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
||||
@@ -898,7 +969,7 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
} } : {}), ...(promise.cardPlays ? { cardPlays: {
|
||||
hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null,
|
||||
ghola: promise.cardPlays.ghola ? { ...promise.cardPlays.ghola, closed: true } : null,
|
||||
} } : {}) };
|
||||
} } : {}), ...(promise.windowCards ? { windowCards } : {}) };
|
||||
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
|
||||
promise.combat.traitor && !promise.combat.traitor.closed || promise.combat.powers?.voice && !promise.combat.powers.voice.closed ||
|
||||
promise.combat.powers?.prescience && !promise.combat.powers.prescience.closed);
|
||||
@@ -922,6 +993,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
(['weather', 'atomics', 'harvester'] as const).every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
|
||||
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export type WindowCardEffect = 'weather' | 'atomics' | 'harvester';
|
||||
export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number };
|
||||
export type WindowCardGame = {
|
||||
status: string;
|
||||
phase: number;
|
||||
turn: number;
|
||||
shieldWallDestroyed: boolean;
|
||||
stormCardWindowOpen?: boolean;
|
||||
stormResolution?: unknown;
|
||||
spiceWindow?: {
|
||||
event?: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
harvested: boolean;
|
||||
harvesters?: number;
|
||||
harvesterClosed?: boolean;
|
||||
} | null;
|
||||
response?: unknown;
|
||||
decision?: unknown;
|
||||
phaseOpening?: unknown;
|
||||
truthtrance?: unknown;
|
||||
};
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** Original global window only. Temporary modal priority, actual card custody,
|
||||
* force conditions and chosen effect parameters remain native engine rules. */
|
||||
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
|
||||
if (effect === 'weather' || effect === 'atomics') {
|
||||
if (game.phase !== 0 || game.stormResolution || game.stormCardWindowOpen === false ||
|
||||
game.stormCardWindowOpen !== undefined && typeof game.stormCardWindowOpen !== 'boolean' ||
|
||||
effect === 'atomics' && game.shieldWallDestroyed !== false) return null;
|
||||
return { effect, source: `storm:${game.turn}`, turn: game.turn };
|
||||
}
|
||||
if (effect !== 'harvester' || game.phase !== 1) return null;
|
||||
const blow = game.spiceWindow;
|
||||
if (!blow || !identifier(blow.territory) || !whole(blow.sector) || typeof blow.harvested !== 'boolean' ||
|
||||
blow.harvesters !== undefined && !whole(blow.harvesters) ||
|
||||
blow.harvesterClosed !== undefined && typeof blow.harvesterClosed !== 'boolean' || !harvesterAvailable(blow)) return null;
|
||||
if (blow.event !== undefined && !identifier(blow.event)) return null;
|
||||
return { effect, source: blow.event ?? `legacy-spice-blow:${game.turn}:${blow.territory}:${blow.sector}`, turn: game.turn };
|
||||
}
|
||||
Reference in new issue
Block a user