Bind Truthtrance Karama promises to original auction lots
This commit is contained in:
1 parent
e88f871385
commit
fd0b2f0fa5
18 files changed
+1128
-68
No files matched your search
+126
-14
@@ -4,7 +4,7 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { windowCardOpportunity, type WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from './window-card-play';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
@@ -5028,6 +5028,8 @@ function savedTransferResponses(g: Game) {
|
||||
];
|
||||
}
|
||||
function transferCardBlock(g: Game, owner: Player, card: Card) {
|
||||
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
|
||||
return 'An unseen future auction card cannot fund this completion.';
|
||||
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
|
||||
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
|
||||
|
||||
@@ -15710,7 +15712,8 @@ function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
|
||||
else recoverAuctionPayment(g, getPlayer(g, source.winner));
|
||||
}
|
||||
function normalBidMaximum(g: Game, p: Player): number {
|
||||
return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
|
||||
return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card))
|
||||
: !!karamaCard(g, p, undefined, 'auction-free-purchase'))
|
||||
? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0);
|
||||
}
|
||||
/** Same native quote for ordinary controls and the detached current-lot guide. */
|
||||
@@ -16240,6 +16243,13 @@ function kullKaramaBlock(g: Game, p: Player, purpose?: KullPurpose): string | nu
|
||||
}
|
||||
/** Physical custody plus the intended effect; promise feasibility is checked at commit. */
|
||||
function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpose): string | null {
|
||||
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
|
||||
return 'An unseen future auction card cannot fund this completion.';
|
||||
const policy = (g as TurnActionCardProbe)._turnActionWindowCardPolicy;
|
||||
if (purpose === 'auction-free-purchase' && card.kind === 'special' && card.effect === 'karama' &&
|
||||
policy?.player === p.id && policy.forbidden.some(required => required.effect === 'karamaAuction' &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this lot without a forbidden printed Karama purchase.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
if (kullBlock) return kullBlock;
|
||||
if (g.pendingKull?.stage === 'counter' &&
|
||||
@@ -17434,7 +17444,22 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
'Kull preview cannot accept this unproven Truthtrance counter continuation.');
|
||||
if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return;
|
||||
if (use.kind === 'shipment') use = { ...use, card: card.id };
|
||||
const auctionRows = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn);
|
||||
let auctionWindow: string | undefined;
|
||||
if (card.kind === 'special' && card.effect === 'karama' &&
|
||||
(use.kind === 'purchase' || use.kind === 'auctionPayment')) {
|
||||
const window = windowCardOpportunity(g, 'karamaAuction');
|
||||
requireRule(window, 'Retain the original ordinary Karama auction lot.');
|
||||
auctionWindow = window.source;
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaAuction', window.source);
|
||||
}
|
||||
const used = discard(g, p, card.id);
|
||||
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
||||
guardTurnActionAuctionClosure(g, p.id, auctionRows);
|
||||
if (!auctionWindow && card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
|
||||
const stage = row.windowCards?.karamaAuction;
|
||||
return stage && !stage.closed && windowCardOpportunity(g, 'karamaAuction')?.source === stage.source;
|
||||
})) guardTurnActionPrintedCardCost(g, p, used);
|
||||
const semutaRoleCost = g.semutaPreview && semutaKaramaRoleCost(g, p.id, card);
|
||||
const guildShipmentDiscard = semutaRoleCost &&
|
||||
use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response);
|
||||
@@ -18849,7 +18874,11 @@ function turnActionRule<T>(operation: () => T): T {
|
||||
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
|
||||
/** This field exists only on detached completion clones and is never returned
|
||||
* as a real game or installed as a selected whole-answer branch. */
|
||||
type TurnActionCardProbe = Game & { _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] } };
|
||||
type TurnActionCardProbe = Game & {
|
||||
_turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] };
|
||||
_turnActionWindowCardPolicy?: { player: string; forbidden: { effect: WindowCardEffect; source: string }[] };
|
||||
_turnActionUnknownCards?: string[];
|
||||
};
|
||||
function printedCardRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof PrintedCardPlayError) throw new RuleError(error.message); throw error; }
|
||||
@@ -18919,11 +18948,30 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
|
||||
}
|
||||
type TurnActionWindowCardRequirement = Extract<TurnActionBranchLeaf, { kind: 'windowCardPlay' }>;
|
||||
const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect =>
|
||||
effect === 'weather' || effect === 'atomics' || effect === 'harvester';
|
||||
WINDOW_CARD_EFFECTS.some(candidate => candidate === effect);
|
||||
/** Own physical costs only. Whole-answer completion remains the guide's job. */
|
||||
function quotedAuctionKaramaCards(g: Game, player: Player): string[] {
|
||||
const invoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
|
||||
if (!windowCardOpportunity(g, 'karamaAuction') || g.truthtrance || g.response || g.phaseOpening ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull || g.decision && !invoice) return [];
|
||||
return player.hand.filter(card => {
|
||||
if (card.kind !== 'special' || card.effect !== 'karama' ||
|
||||
karamaSpendingBlock(g, player, card, 'auction-free-purchase')) return false;
|
||||
try {
|
||||
validateKaramaUse(g, player, { kind: invoice ? 'auctionPayment' : 'purchase' }, card);
|
||||
return true;
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}).map(card => card.id);
|
||||
}
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<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)))
|
||||
if (effect === 'karamaAuction') {
|
||||
for (const card of quotedAuctionKaramaCards(g, player)) yield g.decision?.kind === 'auctionPayment'
|
||||
? { type: 'decision', karama: true, card } : { type: 'card', card, mode: 'purchase' };
|
||||
return;
|
||||
}
|
||||
for (const card of player.hand.filter(card => card.effect === windowCardNativeEffect(effect) && !transferCardBlock(g, player, card)))
|
||||
for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) {
|
||||
const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn,
|
||||
active: g.active, me: actor, shieldWallDestroyed: g.shieldWallDestroyed,
|
||||
@@ -18947,9 +18995,32 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
|
||||
for (const required of pending) {
|
||||
if (windowCardOpportunity(game, required.effect)?.source !== required.source) continue;
|
||||
const play = function* (state: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
const lot = required.effect === 'karamaAuction' ? bidOpportunity(state) : null;
|
||||
if (lot && bidRequired(state.bidPromises ?? [], actor, lot)) {
|
||||
// Quote after any owned recovery, so its actual two-spice cost and
|
||||
// recovered physical Karama participate in this same native bid.
|
||||
const bid = bidPromiseCompletion(state, getPlayer(state, actor));
|
||||
if (!bid || bid.type !== 'bid') return;
|
||||
const accepted = structuredClone(state);
|
||||
accepted.auction!.bid = bid.amount as number;
|
||||
accepted.auction!.bidder = actor;
|
||||
accepted.auction!.allyPayment = bid.allyPayment as number;
|
||||
accepted.bidPromises = recordPromisedBid(accepted.bidPromises ?? [], actor, lot, bid.amount as number);
|
||||
yield* play(accepted, [...prefix, bid]);
|
||||
return;
|
||||
}
|
||||
for (const action of nativeWindowCardActions(state, actor, required.effect)) {
|
||||
try {
|
||||
const next = applyActionInner(state, actor, action, 'movementPreparation');
|
||||
if (required.effect === 'karamaAuction') {
|
||||
const before = getPlayer(state, actor), after = getPlayer(next, actor);
|
||||
const known = before.faction === 'atreides' && state.auction?.peekKnown
|
||||
? state.auction.cards[state.auction.index]?.id : null;
|
||||
(next as TurnActionCardProbe)._turnActionUnknownCards = [...new Set([
|
||||
...((state as TurnActionCardProbe)._turnActionUnknownCards ?? []),
|
||||
...after.hand.filter(card => card.id !== known && !before.hand.some(held => held.id === card.id)).map(card => card.id),
|
||||
])];
|
||||
}
|
||||
yield* search(next, [...actions, ...prefix, action], new Set([...played, key(required)]));
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
@@ -18957,12 +19028,12 @@ function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: r
|
||||
yield* play(game, []);
|
||||
const paid = game.pendingNullentropy;
|
||||
if (paid?.player === actor) {
|
||||
if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === required.effect)) {
|
||||
if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === windowCardNativeEffect(required.effect))) {
|
||||
const recovered = projectMovementBoxRecovery(game, actor, paid.box, card.id, true);
|
||||
if (recovered) yield* play(recovered, [{ type: 'decision', event: paid.event, card: card.id }]);
|
||||
}
|
||||
} else for (const box of getPlayer(game, actor).hand.filter(card => card.effect === 'nullentropyBox'))
|
||||
for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === required.effect)) {
|
||||
for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === windowCardNativeEffect(required.effect))) {
|
||||
const recovered = projectMovementBoxRecovery(game, actor, box.id, known.id, false);
|
||||
if (recovered) yield* play(recovered, [{ type: 'card', card: box.id }]);
|
||||
}
|
||||
@@ -18989,9 +19060,11 @@ function reconcileTurnActionWindowCards(g: Game) {
|
||||
if (!g.turnActionPromises) return;
|
||||
for (const promise of g.turnActionPromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (!stage || stage.closed || promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
|
||||
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
|
||||
g.decision.player !== promise.player;
|
||||
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue;
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
|
||||
promise.player, promise.turn, effect, stage.source));
|
||||
}
|
||||
@@ -19002,7 +19075,7 @@ function reconcileTurnActionWindowCards(g: Game) {
|
||||
function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[] {
|
||||
let closed = g.turnActionPromises ?? [];
|
||||
for (const promise of liveTurnActionPromises(closed, actor, g.turn))
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (stage && !stage.closed) closed = turnActionRule(() =>
|
||||
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source));
|
||||
@@ -19011,7 +19084,7 @@ function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[]
|
||||
}
|
||||
function reopenTurnActionWindowReady(g: Game, actor: string) {
|
||||
if (g.ready.includes(actor) && liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
|
||||
(['weather', 'atomics', 'harvester'] as const).some(effect => {
|
||||
WINDOW_CARD_EFFECTS.some(effect => {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect)?.source === stage.source;
|
||||
}))) g.ready = g.ready.filter(player => player !== actor);
|
||||
@@ -19030,6 +19103,28 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||
requireRule(!!turnActionCompletion(trial, player.id),
|
||||
'This printed card play would remove the last legal completion of your whole Truthtrance answer.');
|
||||
}
|
||||
/** The owned purchase irrevocably ends bidding before its foreign income or
|
||||
* discard reactions. Quote that boundary without completing those reactions. */
|
||||
function guardTurnActionAuctionClosure(g: Game, actor: string, original: readonly TurnActionPromise[]) {
|
||||
const relevant = original.filter(row => row.windowCards?.karamaAuction && !row.fulfilled && !row.released);
|
||||
if (!relevant.length) return;
|
||||
const trial = structuredClone(g);
|
||||
trial.truthtrance = null; trial.response = null; trial.decision = null;
|
||||
trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingTreacheryDiscard = null;
|
||||
trial.auction = null; trial.currentAuctionSale = null;
|
||||
for (const row of relevant) {
|
||||
const stage = row.windowCards!.karamaAuction!;
|
||||
trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(trial.turnActionPromises ?? [],
|
||||
actor, row.turn, 'karamaAuction', stage.source));
|
||||
const bid = row.preparation?.bid;
|
||||
if (bid) trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding(trial.turnActionPromises!,
|
||||
row.turn, bid.source, actor));
|
||||
}
|
||||
requireRule(!relevant.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released),
|
||||
'Finish your required bid or alternative before closing this Karama lot.');
|
||||
reconcileBidPromises(trial, { actor, action: { type: 'card' } });
|
||||
requireRule(!!turnActionCompletion(trial, actor), 'Retain a whole Truthtrance completion after this auction closes.');
|
||||
}
|
||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
|
||||
}
|
||||
@@ -19280,6 +19375,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
|
||||
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
|
||||
};
|
||||
const forbiddenWindows = leaves.flatMap(leaf => leaf.kind === 'windowCardPlay' && !leaf.answer
|
||||
? [{ effect: leaf.effect, source: leaf.source }] : []);
|
||||
if (forbiddenWindows.length) (trial as TurnActionCardProbe)._turnActionWindowCardPolicy = {
|
||||
player: actor, forbidden: forbiddenWindows,
|
||||
};
|
||||
trial.bribePromises ??= [];
|
||||
trial.shipmentPromises ??= [];
|
||||
trial.movementPromises ??= [];
|
||||
@@ -19602,7 +19702,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const completion = turnActionCompletion(g, player.id);
|
||||
if (!completion) return null;
|
||||
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
||||
if (!action) action = completion.cardPreparations?.[0];
|
||||
if (!action) {
|
||||
const preparation = completion.cardPreparations?.[0];
|
||||
if (preparation?.type !== 'bid' || g.auction?.active === player.id) action = preparation;
|
||||
}
|
||||
if (!action && g.phase <= 5) {
|
||||
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
||||
@@ -19652,9 +19755,11 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
// The joint quotation can prepare a later answer while an earlier unrelated
|
||||
// answer remains live. Window controls must bind the row naming that source.
|
||||
const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined;
|
||||
const printedWindow = isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
const printedWindow = held?.kind === 'special' && held.effect === 'karama' && action.mode === 'purchase'
|
||||
? 'karamaAuction' : isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
|
||||
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? ['weather', 'atomics', 'harvester'] : [];
|
||||
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
|
||||
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
|
||||
const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
windows.some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
@@ -27769,6 +27874,7 @@ function gholaOptions(g: Game, p: Player) {
|
||||
.filter(
|
||||
(c) =>
|
||||
c.effect === 'ghola' &&
|
||||
!(g as TurnActionCardProbe)._turnActionUnknownCards?.includes(c.id) &&
|
||||
!(
|
||||
g.richeseAuction?.source === 'blackMarket' &&
|
||||
!g.currentAuctionSale &&
|
||||
@@ -37512,6 +37618,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
'This revival commitment guide is stale or belongs to another native opportunity.');
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
|
||||
const stage = row.windowCards?.karamaAuction;
|
||||
return stage && !stage.closed && (windowCardOpportunity(g, 'karamaAuction')?.source !== stage.source ||
|
||||
g.decision?.kind === 'auctionPayment' && g.decision.player !== id);
|
||||
})) guardTurnActionAuctionClosure(g, id, liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn));
|
||||
reconcileTravelPromises(g, id, action, state.phase);
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
@@ -42138,6 +42249,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
|
||||
: null,
|
||||
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
|
||||
karamaAuctionCards: quotedAuctionKaramaCards(g, me),
|
||||
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
|
||||
const lot = bidOpportunity(g)!;
|
||||
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
|
||||
|
||||
+5
-4
@@ -3212,7 +3212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
summary: 'Combine current-lot printed Karama costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||
@@ -3230,6 +3230,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
|
||||
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
|
||||
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
|
||||
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
||||
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
|
||||
@@ -3237,11 +3238,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse current-lot printed Karama, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import type { WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
import {
|
||||
@@ -119,7 +119,7 @@ const positive = (value: unknown): value is number => whole(value) && value > 0;
|
||||
const physical = (value: unknown): value is number => whole(value) && value <= 20;
|
||||
const printedTerritory = (value: unknown): value is string =>
|
||||
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
|
||||
const windowEffect = (value: unknown): value is WindowCardEffect => value === 'weather' || value === 'atomics' || value === 'harvester';
|
||||
const windowEffect = (value: unknown): value is WindowCardEffect => WINDOW_CARD_EFFECTS.some(effect => effect === value);
|
||||
|
||||
/** Identity membership is decoded by Truthtrance; here retain one strict atom. */
|
||||
function parseCombatClaim(value: unknown): MixedBattleActionClaim {
|
||||
@@ -145,7 +145,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
try {
|
||||
if (value.kind === 'windowCardPlay') {
|
||||
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
|
||||
'Choose one original Storm or fresh-blow printed-card play after this answer.');
|
||||
'Choose one original Storm, fresh-blow or ordinary-lot printed-card play after this answer.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
@@ -958,7 +958,7 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (stage) windowCards[effect] = { ...stage, closed: true };
|
||||
}
|
||||
@@ -993,7 +993,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
(['weather', 'atomics', 'harvester'] as const).every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
|
||||
WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
|
||||
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export type WindowCardEffect = 'weather' | 'atomics' | 'harvester';
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction'] as const;
|
||||
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' {
|
||||
return effect === 'karamaAuction' ? 'karama' : effect;
|
||||
}
|
||||
export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number };
|
||||
export type WindowCardGame = {
|
||||
status: string;
|
||||
@@ -21,8 +25,16 @@ export type WindowCardGame = {
|
||||
decision?: unknown;
|
||||
phaseOpening?: unknown;
|
||||
truthtrance?: unknown;
|
||||
/** Engine rows retain the physical array/index; public views expose only the lot. */
|
||||
auction?: unknown;
|
||||
bidPromiseLot?: { turn: number; lot: number } | null;
|
||||
richeseAuction?: unknown;
|
||||
currentAuctionSale?: unknown;
|
||||
ixAuction?: unknown;
|
||||
};
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const whole = (value: unknown): value is number =>
|
||||
@@ -32,6 +44,27 @@ const whole = (value: unknown): value is number =>
|
||||
* force conditions and chosen effect parameters remain native engine rules. */
|
||||
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
|
||||
if (effect === 'karamaAuction') {
|
||||
if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null ||
|
||||
!object(game.auction)) return null;
|
||||
const auction = game.auction;
|
||||
let lot: number | undefined;
|
||||
if ('index' in auction || 'cards' in auction) {
|
||||
if (!whole(auction.index) || !Array.isArray(auction.cards) || auction.index >= auction.cards.length ||
|
||||
!object(auction.cards[auction.index])) return null;
|
||||
lot = auction.index + 1;
|
||||
if (!Number.isSafeInteger(lot)) return null;
|
||||
}
|
||||
if ('bidPromiseLot' in game) {
|
||||
const publicLot = game.bidPromiseLot;
|
||||
if (!object(publicLot) || Object.keys(publicLot).sort().join(',') !== 'lot,turn' ||
|
||||
publicLot.turn !== game.turn || !whole(publicLot.lot) || publicLot.lot < 1 ||
|
||||
lot !== undefined && lot !== publicLot.lot) return null;
|
||||
lot = publicLot.lot;
|
||||
}
|
||||
if (lot === undefined) return null;
|
||||
return { effect, source: `ordinary-auction:${game.turn}:${lot}`, turn: game.turn };
|
||||
}
|
||||
if (effect === 'weather' || effect === 'atomics') {
|
||||
if (game.phase !== 0 || game.stormResolution || game.stormCardWindowOpen === false ||
|
||||
game.stormCardWindowOpen !== undefined && typeof game.stormCardWindowOpen !== 'boolean' ||
|
||||
|
||||
Reference in new issue
Block a user