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

+1
View File
@@ -121,6 +121,7 @@ const responses = {
harkonnenBonus: true,
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
+4 -4
View File
@@ -3110,10 +3110,10 @@ function policyActions(g: GameView): Action[] {
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
const targets = destinations(g);
const halfRate =
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
g.karamaShipping?.player === me.id;
const halfRate = g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally)));
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
const guildSecret = nexusGuildSecretAllyCanAct(g);
const noField = g.richeseNoField;
+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,
+1
View File
@@ -116,6 +116,7 @@ export const FACTION_RULES: Record<
'Start with five forces in Tuek’s Sietch, fifteen in reserve and five spice. One force revival is free each turn.',
'Choose one shipment: reserves to Dune, one on-planet territory to another, or one on-planet territory back to reserves. Movement follows shipment.',
'Pay half the normal shipment rate, rounding the total cost up. Returning forces cost one spice per two tokens, rounded up.',
'In classic-faction games without optional modules, a Guild or allied ordinary reserve shipment at this rate waits for a Karama response before paying. A canceled discount charges the full tariff for the same forces if the original approved payment split can fund it. Otherwise the declaration is withdrawn without consuming shipment, and a replacement this turn uses full price. This safe withdrawal timing is a table interpretation; cross-shipment, return and optional-module rate cancellation still need integration.',
'Receive spice for other factions’ ordinary off-planet shipments and allied cross-shipments. Karama shipping and a canceled Guild payment go to the bank. Fremen’s normal reinforcements provide no income. Any spice the Guild contributes toward its ally’s shipment goes to the bank.',
'Allies can ship from off-planet or cross-ship on Dune at half price. They cannot ship back to reserves. Allied Fremen may pay to cross-ship from southern reserves to any legal destination. Allied cross-shipments pay the Guild; any Guild-funded share goes to the bank.',
'If the game reaches its end without another qualifying victory, Guild and its ally win through the Guild special victory.',
+6
View File
@@ -132,6 +132,12 @@ const cancellationSources = {
calls: g.battle?.traitorCalls ?? null,
}),
guildIncome: () => null,
guildRate: (g) => ({
pending: g.pendingShipment ?? null,
active: g.active,
discountedRateBlocked: g.guildRateBlocked ?? null,
purchasedRate: g.karamaShipping ?? null,
}),
emperorGift: () => null,
emperorRevival: () => null,
stormPeek: (g) => ({ card: g.stormCard, dialers: g.stormDialers }),
+2
View File
@@ -140,6 +140,7 @@ const phaseDetails = [
[
'At the start of this phase, resolve the Atreides spice foresight response. If allowed, Atreides privately inspects the next card, including when using double spice blow.',
'Select a destination and sector. Ship from reserves before making a force move.',
'A classic Guild or reciprocal ally’s discounted reserve shipment waits for a Karama response before paying. Cancellation uses the full normal rate for the same forces and approved payment split; if that split cannot pay, the declaration returns unused and a replacement this turn uses full price. This withdrawal timing is an implementation interpretation, not a printed FAQ procedure. Independent Karama-purchased rates, Guild cross-shipment and return have separate boundaries.',
'For movement, select the source forces. Enable Combine sectors to include several groups from the same territory.',
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
@@ -2407,6 +2408,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'If eligible to bid, use the card to take the current auction card without paying spice. A full hand cannot bypass the bidding limit.',
'Holding Karama also permits bids beyond your available spice. A winner can choose to spend it; an outbid player keeps it.',
'Use Cancel with Karama during a power response. Supported responses include Voice, prescience, Atreides auction and spice peeks, spiritual advisors, Emperor gifts and revival, faction income, Harkonnen bonus cards and allied traitors, plus Fremen storm and worm powers, elite combat strength and Fremen free spice support.',
'In classic-faction games without optional modules, cancel Guild’s half-price rate on Guild’s or its ally’s declared ordinary reserve shipment before any spice or forces move. Full price applies if the original approved payment split can afford it; otherwise the declaration returns unused and its replacement is full price this turn. Guild income cancellation and the independent rate purchased by Karama remain distinct. The unaffordable withdrawal is a documented table policy, not a printed refund instruction.',
'During shipment, use Guild rates for the active player before they ship. They choose and pay for their shipment at the rounded half rate, paid to the bank. You may provide this benefit for another player; it expires if they move or finish without shipping.',
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
+1
View File
@@ -107,6 +107,7 @@ const RESPONSES = {
harkonnenBonus: true,
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,