Connect phase-opening Amal promises and native payment order
This commit is contained in:
1 parent
00741a37d0
commit
b81823bbec
20 files changed
+954
-51
No files matched your search
@@ -1565,6 +1565,7 @@ function policyActions(g: GameView): Action[] {
|
||||
// Rival spice is private. Low own losses inform this choice.
|
||||
if (
|
||||
amal &&
|
||||
g.amalCards?.includes(amal.id) === true &&
|
||||
g.turn > 1 &&
|
||||
(me.spice ?? 0) <= (level === 0 ? 4 : 2) &&
|
||||
(level > 0 || variation(g, 'amal') > 0.4)
|
||||
|
||||
+136
-27
@@ -2507,6 +2507,8 @@ export type Game = {
|
||||
stormCardWindowOpen?: boolean;
|
||||
/** Original first Spice reveal; temporary summoned-worm controls do not retire it. */
|
||||
thumperWindowOpen?: boolean;
|
||||
/** Original phase opening; a discard response can temporarily suspend its controls. */
|
||||
amalWindowOpen?: boolean;
|
||||
stormCard: number | null;
|
||||
stormCardKnown: boolean;
|
||||
stormResolution: {
|
||||
@@ -19017,9 +19019,23 @@ function quotedThumperCards(g: Game, player: Player): string[] {
|
||||
return player.hand.filter(card => card.kind === 'special' && card.effect === 'thumper' &&
|
||||
!transferCardBlock(g, player, card)).map(card => card.id);
|
||||
}
|
||||
function quotedAmalCards(g: Game, player: Player): string[] {
|
||||
if (!windowCardOpportunity(g, 'amal') || !g.phaseOpening || g.phaseOpening.passed.includes(player.id) ||
|
||||
g.truthtrance || g.pendingTreacheryDiscard) return [];
|
||||
return player.hand.filter(card => card.kind === 'special' && card.effect === 'amal' &&
|
||||
!transferCardBlock(g, player, card)).map(card => card.id);
|
||||
}
|
||||
function quotedAmalReady(g: Game, actor: string): boolean {
|
||||
return !!windowCardOpportunity(g, 'amal') && !!g.phaseOpening && !g.phaseOpening.passed.includes(actor) &&
|
||||
!g.truthtrance && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||
if (!windowCardOpportunity(g, effect)) return;
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'amal') {
|
||||
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
|
||||
return;
|
||||
}
|
||||
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' };
|
||||
@@ -19155,10 +19171,10 @@ function reconcileTurnActionWindowCards(g: Game) {
|
||||
}
|
||||
/** 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[] {
|
||||
function ownTurnActionWindowClosure(g: Game, actor: string, effects: readonly WindowCardEffect[] = WINDOW_CARD_EFFECTS): TurnActionPromise[] {
|
||||
let closed = g.turnActionPromises ?? [];
|
||||
for (const promise of liveTurnActionPromises(closed, actor, g.turn))
|
||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||
for (const effect of effects) {
|
||||
const stage = promise.windowCards?.[effect];
|
||||
if (stage && !stage.closed) closed = turnActionRule(() =>
|
||||
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source));
|
||||
@@ -19283,10 +19299,10 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
|
||||
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
|
||||
}
|
||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
|
||||
return bribeQuestionWindow(g.phaseOpening ? { ...g, phaseOpening: null } : g) && movementPromiseModeSupported(g);
|
||||
}
|
||||
function turnActionTransportOpen(g: Game, actor: string): boolean {
|
||||
return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
|
||||
return g.phase < 5 || g.phase === 5 && (!!g.phaseOpening?.initialize || !!g.movementRemaining?.includes(actor));
|
||||
}
|
||||
/** Read only the original predicates while the native sealed cards are still
|
||||
* held. Receipts contain booleans, never copies of private hands or plans. */
|
||||
@@ -19504,6 +19520,66 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
|
||||
/** Forecast deterministic opening work only. Auction/deck draws and foreign
|
||||
* choices stay at the real table; fixed Mentat receipts arrive after Amal. */
|
||||
function projectTurnActionOpeningEnd(g: Game) {
|
||||
const initialize = g.phaseOpening?.initialize;
|
||||
g.phaseOpening = null; g.amalWindowOpen = false;
|
||||
if (!initialize) return;
|
||||
if ([4, 5, 7].includes(g.phase)) beginPhase(g);
|
||||
if (g.phase === 8) {
|
||||
advanceMentatInflation(g);
|
||||
for (const player of g.players) { player.spice += player.bribes; player.bribes = 0; }
|
||||
}
|
||||
}
|
||||
/** Amal must precede payments in this opening. Other native card windows may
|
||||
* still be used after its confirmations, with the resulting physical purse. */
|
||||
function prepareTurnActionOpening(source: Game, actor: string, requirements: readonly TurnActionWindowCardRequirement[]):
|
||||
{ game: Game; actions: Action[] } | null {
|
||||
if (!source.phaseOpening) return { game: source, actions: [] };
|
||||
const opening = requirements.filter(required => required.effect === 'amal');
|
||||
let game = source;
|
||||
const actions: Action[] = [];
|
||||
if (opening.some(required => required.answer)) {
|
||||
const opportunity = windowCardOpportunity(source, 'amal');
|
||||
if (!opportunity || opening.some(required => !required.answer || required.source !== opportunity.source)) return null;
|
||||
const action = nativeWindowCardActions(source, actor, 'amal').next().value;
|
||||
if (!action) return null;
|
||||
try { game = applyActionInner(source, actor, action, 'movementPreparation'); }
|
||||
catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
actions.push(action);
|
||||
}
|
||||
projectTurnActionOpeningEnd(game);
|
||||
return { game, actions };
|
||||
}
|
||||
/** Check the owner before a discard/pass hands the opening away. Propagate
|
||||
* only genuine foreign releases, so the later last passer cannot inherit
|
||||
* responsibility for another player's already completed Amal reduction. */
|
||||
function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
|
||||
const keys = ['turnActionPromises', 'travelPromises', 'shipmentPromises', 'movementPromises',
|
||||
'movementExpressionPromises', 'bidPromises', 'revivalPromises', 'leaderRevivalPromises',
|
||||
'revivalExpressionPromises', 'bribePromises', 'bribeExpressionPromises'] as const;
|
||||
if (!keys.some(key => g[key]?.some(row => !row.fulfilled && !row.released))) return;
|
||||
const trial = structuredClone(g);
|
||||
if (pass && trial.turnActionPromises) trial.turnActionPromises = ownTurnActionWindowClosure(trial, actor, ['amal']);
|
||||
projectTurnActionOpeningEnd(trial);
|
||||
const action: Action = { type: 'card' };
|
||||
reconcileTravelPromises(trial, actor, action, g.phase);
|
||||
reconcileBidPromises(trial, { actor, action });
|
||||
reconcileRevivalPromises(trial, { actor, action });
|
||||
reconcileBribePromises(trial, { actor, action });
|
||||
reconcileGroundMovementPromises(trial, actor, action, g.phase);
|
||||
if (pass) return;
|
||||
for (const key of keys) {
|
||||
const quoted = trial[key];
|
||||
g[key]?.forEach((row, index) => {
|
||||
if (row.player !== actor && !row.fulfilled && !row.released && quoted?.[index]?.released) {
|
||||
row.released = true;
|
||||
log(g, `${getPlayer(g, row.player).name} can no longer fulfill a Truthtrance answer after another player's Amal; that answer is no longer binding.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||
* route, sharing actual interval progress and the remaining physical purse. */
|
||||
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
@@ -19521,12 +19597,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const trial = structuredClone(source);
|
||||
let trial = structuredClone(source);
|
||||
trial.truthtrance = null;
|
||||
delete trial.turnActionPromises;
|
||||
delete trial.travelPromises;
|
||||
delete trial.travelPromiseOrder;
|
||||
const owner = getPlayer(trial, actor);
|
||||
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
|
||||
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
|
||||
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
|
||||
@@ -19541,6 +19616,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
...forbiddenWindows,
|
||||
],
|
||||
};
|
||||
const windowRequirements = leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay');
|
||||
const opening = prepareTurnActionOpening(trial, actor, windowRequirements);
|
||||
if (!opening) return null;
|
||||
trial = opening.game;
|
||||
const owner = getPlayer(trial, actor);
|
||||
trial.bribePromises ??= [];
|
||||
trial.shipmentPromises ??= [];
|
||||
trial.movementPromises ??= [];
|
||||
@@ -19624,7 +19704,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
||||
for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements,
|
||||
leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay'))) {
|
||||
source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements)) {
|
||||
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||
try {
|
||||
reconcileBattlePromises(funded, voluntary);
|
||||
@@ -19668,7 +19748,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
: !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null;
|
||||
if (completion) {
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
const cardPreparations = [...opening.actions, ...cardSeed.actions];
|
||||
return { ...completion, ...(cardPreparations.length ? { cardPreparations } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(power.action ? { battlePower: power.action } : {}),
|
||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||
@@ -19739,7 +19820,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
...(combat ? { combat } : {}),
|
||||
...(preparation ? { preparation } : {}),
|
||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||
shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
|
||||
shipment: { pending: open && (g.phase < 5 || !!g.phaseOpening?.initialize || shipmentAvailable(g, p)), event: null },
|
||||
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
|
||||
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
|
||||
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
|
||||
@@ -19747,9 +19828,11 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
return promise;
|
||||
}
|
||||
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 ||
|
||||
const source = 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;
|
||||
return source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening,
|
||||
passed: source.phaseOpening.passed.filter(id => id !== player) } } : source;
|
||||
}
|
||||
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
|
||||
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
|
||||
@@ -19770,7 +19853,8 @@ 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, !!promise.windowCards).ready;
|
||||
const reopened = turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards);
|
||||
g.ready = reopened.ready; g.phaseOpening = reopened.phaseOpening;
|
||||
g.turnActionPromises ??= [];
|
||||
initializeTurnActionOrder(g);
|
||||
g.turnActionPromises.push(promise);
|
||||
@@ -19858,10 +19942,25 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
|
||||
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
|
||||
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
|
||||
if (!promise || g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
|
||||
g.decision && !ownedBox && !ownedInvoice) return null;
|
||||
const completion = turnActionCompletion(g, player.id);
|
||||
if (!completion) return null;
|
||||
if (g.phaseOpening) {
|
||||
const opportunity = windowCardOpportunity(g, 'amal');
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
row.windowCards?.amal && !row.windowCards.amal.closed && row.windowCards.amal.source === opportunity?.source);
|
||||
if (!row) return null;
|
||||
const preparation = completion.cardPreparations?.[0];
|
||||
let action = preparation?.type === 'card' && quotedAmalCards(g, player).includes(preparation.card as string)
|
||||
? preparation : undefined;
|
||||
if (!action && quotedAmalReady(g, player.id)) {
|
||||
const closed = ownTurnActionWindowClosure(g, player.id, ['amal']);
|
||||
if (turnActionCompletion({ ...g, turnActionPromises: closed }, player.id)) action = { type: 'ready' };
|
||||
}
|
||||
return action ? { event: row.event, action: { ...action,
|
||||
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
|
||||
}
|
||||
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
||||
if (!action) {
|
||||
const preparation = completion.cardPreparations?.[0];
|
||||
@@ -26133,6 +26232,7 @@ function completePhase(g: Game) {
|
||||
/** Open the same public window for every Ix table, independently of who holds Amal. */
|
||||
function openPhase(g: Game, initialize = true) {
|
||||
if (g.phase === 1) g.thumperWindowOpen = true;
|
||||
g.amalWindowOpen = g.expansions.includes('ix');
|
||||
if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
|
||||
g.nexusCards.phase = createNexusCardPhase(g.turn);
|
||||
if (initialize && g.phase === 4 && g.homeworlds) {
|
||||
@@ -26170,6 +26270,18 @@ function startNativeRevivalOrder(g: Game, player: string) {
|
||||
if (g.revivalOrder)
|
||||
g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player));
|
||||
}
|
||||
function advanceMentatInflation(g: Game) {
|
||||
if (!g.inflation || g.inflation.updatedTurn >= g.turn) return;
|
||||
if (g.inflation.flipped) {
|
||||
g.inflation = null;
|
||||
log(g, 'The Inflation token was removed from the game.');
|
||||
} else {
|
||||
g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double';
|
||||
g.inflation.flipped = true;
|
||||
g.inflation.updatedTurn = g.turn;
|
||||
log(g, `Inflation flipped to ${g.inflation.side} for next turn’s charity.`);
|
||||
}
|
||||
}
|
||||
function beginPhase(g: Game) {
|
||||
if (g.phase === 0) {
|
||||
g.auditorInsight = null;
|
||||
@@ -26227,20 +26339,7 @@ function beginPhase(g: Game) {
|
||||
collectBankerIncome(g);
|
||||
drawHarkonnenNexusBetrayalReplacement(g);
|
||||
replaceMoritaniAssassinationTraitors(g);
|
||||
if (g.inflation && g.inflation.updatedTurn < g.turn) {
|
||||
if (g.inflation.flipped) {
|
||||
g.inflation = null;
|
||||
log(g, 'The Inflation token was removed from the game.');
|
||||
} else {
|
||||
g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double';
|
||||
g.inflation.flipped = true;
|
||||
g.inflation.updatedTurn = g.turn;
|
||||
log(
|
||||
g,
|
||||
`Inflation flipped to ${g.inflation.side} for next turn’s charity.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
advanceMentatInflation(g);
|
||||
for (const p of g.players) {
|
||||
p.spice += p.bribes;
|
||||
p.bribes = 0;
|
||||
@@ -38776,10 +38875,12 @@ function applyActionInner(
|
||||
'You have already passed the phase opening.',
|
||||
);
|
||||
if (t === 'ready') {
|
||||
if (execution === 'live') guardTurnActionOpening(g, id, true);
|
||||
g.phaseOpening.passed.push(id);
|
||||
if (g.phaseOpening.passed.length === g.players.length) {
|
||||
const initialize = g.phaseOpening.initialize;
|
||||
g.phaseOpening = null;
|
||||
g.amalWindowOpen = false;
|
||||
if (initialize) beginPhase(g);
|
||||
}
|
||||
} else {
|
||||
@@ -38790,13 +38891,18 @@ function applyActionInner(
|
||||
t === 'card' && card && !action.mode,
|
||||
'Play Amal or pass the phase opening before taking other actions.',
|
||||
);
|
||||
const window = windowCardOpportunity(g, 'amal');
|
||||
requireRule(window && quotedAmalCards(g, p).includes(card.id), 'Retain your original native Amal opening and physical card.');
|
||||
recordNativeTurnActionWindowCard(g, id, 'amal', window.source);
|
||||
const before = g.semutaPreview
|
||||
? g.players.map(player => ({ player: player.id, spice: player.spice }))
|
||||
: null;
|
||||
const used = discard(g, p, card.id);
|
||||
rememberMovementDiscards(g, [{ card: used, discardedBy: id, publicFace: true }]);
|
||||
for (const player of g.players)
|
||||
player.spice = Math.floor(player.spice / 2);
|
||||
g.phaseOpening.passed = [];
|
||||
if (execution === 'live') guardTurnActionOpening(g, id, false);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`,
|
||||
@@ -42123,7 +42229,7 @@ function applyActionInner(
|
||||
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.');
|
||||
requireRule(source, 'Retain this card’s original native timing window.');
|
||||
recordNativeTurnActionWindowCard(g, id, c.effect, source.source);
|
||||
}
|
||||
if (c.effect === 'hajr') {
|
||||
@@ -42439,6 +42545,9 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
thumperCards: quotedThumperCards(g, me),
|
||||
thumperReady: thumperReadyStartsDraw(g),
|
||||
thumperWindowOpen: !!windowCardOpportunity(g, 'thumper'),
|
||||
amalCards: quotedAmalCards(g, me),
|
||||
amalReady: quotedAmalReady(g, id),
|
||||
amalWindowOpen: !!windowCardOpportunity(g, 'amal'),
|
||||
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;
|
||||
|
||||
+3
-2
@@ -3233,6 +3233,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
|
||||
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
|
||||
'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
|
||||
'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
|
||||
'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.',
|
||||
@@ -3240,11 +3241,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-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', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -381,7 +381,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (v.kind === 'turnActions') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
|
||||
bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
!g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Supply the current turn, complete turn-action question and a supported quiet native window.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
@@ -741,6 +741,7 @@ export function truthQuestionText(
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => {
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
if (action.effect === 'amal') return 'you play Amal after this answer during this original phase opening, halving available spice before ordinary payments';
|
||||
if (action.effect === 'karamaAuction') return 'you spend a printed Karama after this answer on this original ordinary auction lot, by immediate purchase or its winning invoice';
|
||||
if (action.effect === 'karamaShipment') {
|
||||
const recipient = parseKaramaShipmentSource(action.source)?.recipient;
|
||||
|
||||
@@ -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, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
|
||||
'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal'] as const;
|
||||
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' {
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
|
||||
@@ -18,6 +18,7 @@ export type WindowCardGame = {
|
||||
stormCardWindowOpen?: boolean;
|
||||
stormResolution?: unknown;
|
||||
thumperWindowOpen?: boolean;
|
||||
amalWindowOpen?: boolean;
|
||||
spiceSequence?: unknown;
|
||||
spiceResolution?: unknown;
|
||||
nexus?: unknown;
|
||||
@@ -123,6 +124,16 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
|
||||
* force conditions and chosen effect parameters remain native engine rules. */
|
||||
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
|
||||
if (effect === 'amal') {
|
||||
if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
|
||||
game.amalWindowOpen === false) return null;
|
||||
if (game.amalWindowOpen === undefined) {
|
||||
const opening = game.phaseOpening;
|
||||
if (!object(opening) || !Array.isArray(opening.passed) || !opening.passed.every(identifier) ||
|
||||
new Set(opening.passed).size !== opening.passed.length) return null;
|
||||
}
|
||||
return { effect, source: `phase-opening:${game.turn}:${game.phase}`, turn: game.turn };
|
||||
}
|
||||
if (effect === 'thumper') {
|
||||
if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' ||
|
||||
game.thumperWindowOpen === false || game.thumperWindowOpen === undefined &&
|
||||
|
||||
Reference in new issue
Block a user