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
+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;
|
||||
|
||||
Reference in new issue
Block a user