Add prepayment Guild rate Karama response

This commit is contained in:
Quintine committed 2026-09-29 15:32:49 +10:00
1 parent 745e4fa8ab
commit 8a23b5ccd2
17 files changed
+567 -21

No files matched your search

+111 -4
View File
@@ -917,6 +917,8 @@ export type ResponseWindow = {
source?: 'ambassador';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string;
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
@@ -946,6 +948,7 @@ export type ResponseWindow = {
| 'harkonnenBonus'
| 'harkonnenTraitor'
| 'guildIncome'
| 'guildRate'
| 'emperorGift'
| 'emperorRevival'
| 'stormPeek'
@@ -1018,6 +1021,7 @@ type PendingShipment = {
smugglerCompanion?: SmugglerNoFieldCompanion;
noFieldSkillProof?: string;
guildSecretEvent?: string;
guildRateEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
richesePair?: { event: string; revealedTokenId: string };
@@ -1565,6 +1569,8 @@ export type Game = {
>;
} | null;
pendingShipment?: PendingShipment | null;
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
guildRateBlocked?: { turn: number; player: string };
pendingHomeworldShipment?: PendingHomeworldShipment | null;
junctionOffer?: JunctionOffer | null;
pendingExchange?: {
@@ -14059,6 +14065,7 @@ function completePhase(g: Game) {
observeOccupation(g, 'turnEnd');
g.turn++;
g.recruits = null;
delete g.guildRateBlocked;
g.phase = 0;
observeOccupation(g, 'turnStart');
}
@@ -18860,6 +18867,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
if (response.kind === 'guildRate') validateGuildRateResponse(g, response);
if (response.kind === 'moritaniPlacement') {
// Both outcomes need the same physical inventory and declared source.
// This detached denial quote is only validation here; allowance still
@@ -18900,6 +18908,10 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
if (response.kind === 'guildRate') {
finishGuildRateResponse(g, response, canceled);
return;
}
if (response.kind === 'nexusEcazBetrayal') {
finishEcazBetrayalResponse(g, response, canceled);
return;
@@ -20358,6 +20370,90 @@ function validateGuildShipmentDecision(
validatePhysicalShipment(g, shipment);
return shipment;
}
/** The independent Karama rate is a card benefit, not Guild's cancelable rate. */
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
const guild = byFaction(g, 'guild');
const p = getPlayer(g, shipment.player);
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
g.expansions.length || g.homeworlds || g.leaderSkills || g.nexusCards ||
g.discoveryEnabled || g.techTokens || g.strongholdCards ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
shipment.source || shipment.noField || shipment.alliedNoField ||
shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent ||
shipment.guildNexusEvent || shipment.nexusEvent || shipment.richesePair)
return null;
const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false },
territory(shipment.territory).type, shipment.amount);
return shipment.cost < fullCost ? fullCost : null;
}
function openGuildRate(g: Game, shipment: PendingShipment): boolean {
const fullCost = ordinaryGuildRateFullCost(g, shipment);
if (fullCost === null) return false;
// A validated older Guild-stop declaration may lack this newer turn stamp.
shipment.turn ??= g.turn;
const event = crypto.randomUUID();
shipment.guildRateEvent = event;
g.pendingShipment = shipment;
g.response = {
kind: 'guildRate',
owner: byFaction(g, 'guild')!.id,
passed: [],
guildRateEvent: event,
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} reserve forces to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival. The original forces would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`,
};
log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`);
return true;
}
function validateGuildRateResponse(g: Game, response: ResponseWindow) {
const shipment = g.pendingShipment;
const guild = byFaction(g, 'guild');
const p = shipment && getPlayer(g, shipment.player);
const fullCost = shipment ? ordinaryGuildRateFullCost(g, shipment) : null;
requireRule(response.kind === 'guildRate' && guild && p && shipment &&
g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped &&
response.owner === guild.id && typeof shipment.guildRateEvent === 'string' &&
shipment.guildRateEvent.length > 0 &&
response.guildRateEvent === shipment.guildRateEvent &&
shipment.turn === g.turn && fullCost !== null &&
shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true },
territory(shipment.territory).type, shipment.amount),
'The saved Guild-rate response lost its original physical shipment.');
return { shipment, fullCost };
}
function guildRateIntegrity(g: Game) {
const pending = g.pendingShipment;
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
requireRule(responses.length === Number(!!pending?.guildRateEvent),
'The saved Guild-rate shipment lost its single Karama response.');
if (responses.length) validateGuildRateResponse(g, responses[0]);
const blocked = g.guildRateBlocked;
if (blocked)
requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player),
'The canceled Guild discount belongs to another turn or player.');
}
function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) {
const { shipment, fullCost } = validateGuildRateResponse(g, response);
const shipper = getPlayer(g, shipment.player);
g.pendingShipment = null;
delete shipment.guildRateEvent;
if (!canceled) {
commitShipment(g, shipment);
return;
}
g.guildRateBlocked = { turn: g.turn, player: shipper.id };
const bounds = shipmentPaymentBounds(fullCost, shipper.spice,
aidFor(g, shipper)?.amount ?? 0);
if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`);
return;
}
shipment.cost = fullCost;
checkShipmentIncomeRounding(g, shipper, fullCost, shipment.allyPayment);
commitShipment(g, shipment);
delete g.guildRateBlocked;
log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`);
}
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
@@ -20390,7 +20486,7 @@ function offerShipment(g: Game, shipment: PendingShipment) {
g,
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else commitShipment(g, shipment);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
function commitShipment(g: Game, shipment: PendingShipment) {
if (shipment.source === 'ambassador') {
@@ -20992,7 +21088,9 @@ function richesePairIntegrity(g: Game) {
}
}
function shipmentHalfRate(g: Game, p: Player) {
return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id;
return g.karamaShipping?.player === p.id ||
((p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally) &&
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id));
}
function nexusRicheseSignature(record: NonNullable<Game['nexusRicheseHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]);
@@ -22179,6 +22277,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22361,6 +22460,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -22469,6 +22569,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22523,6 +22624,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
return g;
}
@@ -23823,8 +23925,10 @@ function applyActionInner(
);
return g;
}
g.pendingShipment = null;
commitShipment(g, shipment);
if (!openGuildRate(g, shipment)) {
g.pendingShipment = null;
commitShipment(g, shipment);
}
} else if (decision.kind === 'nullentropy') {
throw new RuleError(
'No matching paid search was saved; restore the saved search before continuing.',
@@ -26331,6 +26435,7 @@ export function viewGame(state: Game, id: string) {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
guildRateIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -26791,6 +26896,8 @@ export function viewGame(state: Game, id: string) {
karamaShipping: g.karamaShipping
? { player: g.karamaShipping.player, owner: g.karamaShipping.owner }
: null,
guildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
g.guildRateBlocked.player === id,
stormPending: g.stormPending ?? null,
stormForecast:
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,