Make Fremen allied revival grants cancelable

Bind the original Fremen/ally/turn grant before Karama allowance or prevention.
A prevented grant leaves intrinsic free rates and prior payments intact. Permit
late grants under the explicit provisional Recruits current-rate policy, with
shared controls, minimal bot support and native public chronology evidence.
Complete rules and browser/deployed acceptance remain open.
This commit is contained in:
Quintine committed 2026-10-24 18:16:03 +11:00
1 parent d63efccb55
commit 0e3d6a250e
23 files changed
+1183 -44

No files matched your search

+40 -20
View File
@@ -532,6 +532,7 @@ import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homewo
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
import { resolveBattleWeapons } from './effective-weapons';
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
import {
charityAmount,
charityQuote,
@@ -1060,6 +1061,7 @@ type SpiceBankerIncomePayment = {
};
export type ResponseWindow = {
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
bureaucratPayment?: BureaucratPaymentSource;
bureaucratPaymentEvent?: string;
@@ -1113,6 +1115,7 @@ export type ResponseWindow = {
| 'guildTransportPermission'
| 'emperorGift'
| 'emperorRevival'
| 'fremenRevivalGrant'
| 'stormPeek'
| 'stormProtection'
| 'wormPlacement'
@@ -14452,11 +14455,24 @@ function recruitsPreview(g: Game, viewer: Player): RecruitsPreview | null {
const allowance = normalRevivalAllowance(g, target);
return { player, freeRate: allowance.freeRate, limit: allowance.limit };
}),
grantRevivalBlocked: active
? 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.'
: null,
grantRevivalBlocked: projectFremenRevivalGrant(g, viewer.id)?.blocked ?? null,
};
}
function fremenRevivalGrantRule<T>(quote: () => T): T {
try { return quote(); }
catch (error) {
if (error instanceof FremenRevivalGrantError) throw new RuleError(error.message);
throw error;
}
}
function currentFremenRevivalGrant(g: Game, response: ResponseWindow): FremenRevivalGrantSource {
requireRule(response.kind === 'fremenRevivalGrant' && response.fremenGrant &&
response.owner === response.fremenGrant.owner && response.recipient === response.fremenGrant.recipient,
'The Fremen revival grant lost its original owner and ally.');
requireRule(!g.pendingRevival, 'Finish the existing force return before resolving the Fremen grant.');
fremenRevivalGrantRule(() => validateFremenRevivalGrant(g, response.fremenGrant!));
return response.fremenGrant;
}
/** Pure normal-lot prerequisites; free payment deliberately does not require spice. */
function normalKaramaAuction(
g: Game,
@@ -14694,6 +14710,7 @@ function validateKaramaUse(
'Karama cancels another faction’s power.',
);
karamaCanceledSource(g, use.response);
if (use.response.kind === 'fremenRevivalGrant') currentFremenRevivalGrant(g, use.response);
validateAuctionContinuationCancellation(
g,
use.response,
@@ -27119,6 +27136,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
@@ -27196,6 +27214,18 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
return;
}
if (offerOccupiedBonus(g, response)) return;
if (response.kind === 'fremenRevivalGrant') {
requireRule(fremenGrant, 'The original Fremen revival grant is missing.');
if (canceled) {
g.revivalRules ??= newRevivalRules();
(g.revivalRules.fremenGrantBlocked ??= []).push(fremenGrant.recipient);
log(g, `${getPlayer(g, fremenGrant.owner).name}'s allied revival grant was prevented for this Revival. The ally keeps its intrinsic free rate and completed returns.`);
} else {
g.freeRevival.push(fremenGrant.recipient);
log(g, `${getPlayer(g, fremenGrant.owner).name} granted ${getPlayer(g, fremenGrant.recipient).name} the Fremen allied free-revival rate for this phase. Current Recruits rates, prior usage and total limits still apply.`, { faction: 'fremen', name: 'Allied free revival' });
}
return;
}
if (response.kind === 'ecazOccupy') {
const b = g.battle, occupy = b?.ecazOccupy;
requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed &&
@@ -34783,23 +34813,12 @@ function applyActionInner(
return g;
}
if (t === 'grantRevival') {
requireRule(
g.phase === 4 && p.faction === 'fremen' && p.ally,
'Only Fremen may grant their ally free revival during revival.',
);
requireRule(
!g.freeRevival.includes(p.ally),
'Free revival has already been granted.',
);
requireRule(
g.recruits?.turn !== g.turn,
'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.',
);
g.freeRevival.push(p.ally);
log(
g,
`${p.name} granted their ally up to three free force revivals this phase.`,
);
requireRule(g.phase === 4 && p.faction === 'fremen' && p.ally,
'Only Fremen may grant their ally free revival during revival.');
requireRule(!recruitsInteractionPending(g), 'Finish the current revival transaction or priority window before granting allied revival.');
const source = fremenRevivalGrantRule(() => quoteFremenRevivalGrant(g, id));
g.response = { kind: 'fremenRevivalGrant', owner: source.owner,
recipient: source.recipient, fremenGrant: source, passed: [] };
return g;
}
if (t === 'tleilaxuRevivalLimit' || t === 'tleilaxuAllyDiscount') {
@@ -36803,6 +36822,7 @@ export function viewGame(state: Game, id: string) {
emperorExtra: g.emperorExtra ?? {},
freeRevival: g.freeRevival ?? [],
recruitsPreview: recruitsPreview(g, me),
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
revivalRules: {
...(g.revivalRules ?? newRevivalRules()),
earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(