Separate Guild transport permission, purchase price and loan approval
This commit is contained in:
1 parent
a12250320c
commit
11adb7dbf3
39 files changed
+1576
-121
No files matched your search
+242
-56
@@ -1024,6 +1024,7 @@ export type ResponseWindow = {
|
||||
advisors?: boolean;
|
||||
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
|
||||
guildRateEvent?: string;
|
||||
guildTransportEvent?: string;
|
||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
@@ -1055,6 +1056,7 @@ export type ResponseWindow = {
|
||||
| 'harkonnenTraitor'
|
||||
| 'guildIncome'
|
||||
| 'guildRate'
|
||||
| 'guildTransportPermission'
|
||||
| 'emperorGift'
|
||||
| 'emperorRevival'
|
||||
| 'stormPeek'
|
||||
@@ -1112,6 +1114,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
|
||||
guildSecretEvent?: string;
|
||||
guildNexusEvent?: string;
|
||||
guildRateEvent?: string;
|
||||
guildTransportEvent?: string;
|
||||
/** Absent in older world-to-world declarations. */
|
||||
route?: 'arrakis';
|
||||
event: string;
|
||||
@@ -1151,6 +1154,8 @@ type PendingShipment = {
|
||||
type PendingGuildTransport = {
|
||||
event: string;
|
||||
signature: string;
|
||||
/** Absent in original rate-only continuations. */
|
||||
permission?: true;
|
||||
turn: number;
|
||||
player: string;
|
||||
fromReserves: boolean;
|
||||
@@ -1165,7 +1170,7 @@ type PendingGuildTransport = {
|
||||
allyPayment: number;
|
||||
advisors: boolean;
|
||||
};
|
||||
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves'>;
|
||||
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves' | 'permission'>;
|
||||
type JunctionTransportDeclaration = {
|
||||
player: string;
|
||||
turn: number;
|
||||
@@ -2031,6 +2036,8 @@ export type Game = {
|
||||
pendingGuildTransport?: PendingGuildTransport | null;
|
||||
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
|
||||
guildRateBlocked?: { turn: number; player: string };
|
||||
/** Denied cross/return privilege for this original shipment opportunity only. */
|
||||
guildTransportBlocked?: { turn: number; player: string; opportunity: string };
|
||||
pendingHomeworldShipment?: PendingHomeworldShipment | null;
|
||||
junctionOffer?: JunctionOffer | null;
|
||||
pendingExchange?: {
|
||||
@@ -4183,6 +4190,13 @@ function noFieldAllyOfferBlock(
|
||||
return 'Karama prevented No-Field use for this allied shipment opportunity.';
|
||||
return null;
|
||||
}
|
||||
function alliedNoFieldPricing(g: Game, recipient: Pick<Player, 'id' | 'faction'>) {
|
||||
return alliedNoFieldShipmentPricing({
|
||||
purchasedGuildRate: g.karamaShipping?.player === recipient.id,
|
||||
nativeGuildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
|
||||
g.guildRateBlocked.player === recipient.id,
|
||||
}, recipient);
|
||||
}
|
||||
function alliedNoFieldQuote(
|
||||
g: Game,
|
||||
offer: RicheseAllyOffer,
|
||||
@@ -4233,7 +4247,7 @@ function alliedNoFieldQuote(
|
||||
false,
|
||||
advisors,
|
||||
);
|
||||
const pricing = alliedNoFieldShipmentPricing(g, recipient);
|
||||
const pricing = alliedNoFieldPricing(g, recipient);
|
||||
const cost = territory(offer.territory).type === 'stronghold' ? pricing.stronghold : pricing.other;
|
||||
requireRule(
|
||||
offer.payer !== 'both' || cost === 2,
|
||||
@@ -14452,7 +14466,7 @@ function finishAutomaticDecision(g: Game): boolean {
|
||||
if (homeworldShipmentAutomatic(g)) {
|
||||
const shipment = g.pendingHomeworldShipment!;
|
||||
g.decision = null;
|
||||
if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
|
||||
continueHomeworldShipment(g, shipment);
|
||||
return true;
|
||||
}
|
||||
const market = g.choamMarket;
|
||||
@@ -19267,6 +19281,7 @@ function completePhase(g: Game) {
|
||||
g.turn++;
|
||||
g.recruits = null;
|
||||
delete g.guildRateBlocked;
|
||||
delete g.guildTransportBlocked;
|
||||
g.phase = 0;
|
||||
observeOccupation(g, 'turnStart');
|
||||
}
|
||||
@@ -24819,6 +24834,10 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
|
||||
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
|
||||
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
|
||||
if (response.kind === 'guildTransportPermission') {
|
||||
if (g.pendingGuildTransport?.permission) validateGuildTransportPermissionResponse(g, response);
|
||||
else validateHomeworldTransportPermission(g, response);
|
||||
}
|
||||
if (response.kind === 'guildRate') {
|
||||
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response);
|
||||
else if (g.pendingHomeworldShipment?.guildRateEvent) validateHomeworldGuildRateResponse(g, response);
|
||||
@@ -24894,6 +24913,11 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' });
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'guildTransportPermission') {
|
||||
if (g.pendingGuildTransport?.permission) finishGuildTransportPermission(g, response, canceled);
|
||||
else finishHomeworldTransportPermission(g, response, canceled);
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'guildRate') {
|
||||
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
|
||||
else if (g.pendingHomeworldShipment?.guildRateEvent) finishHomeworldGuildRateResponse(g, response, canceled);
|
||||
@@ -25999,7 +26023,8 @@ function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string {
|
||||
return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped,
|
||||
homeworldContext(g), g.homeworlds?.custody,
|
||||
g.players.map((seat) => [seat.id, seat.ally]),
|
||||
...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]);
|
||||
...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : []),
|
||||
...(g.karamaShipping?.player === p.id ? [g.karamaShipping] : [])]);
|
||||
}
|
||||
function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null {
|
||||
if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.';
|
||||
@@ -26009,8 +26034,6 @@ function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): strin
|
||||
g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment ||
|
||||
g.pendingHomeworldShipment || g.pendingExchange || g.pendingRicheseGift || g.battle)
|
||||
return 'Finish the current interaction before another Homeworld shipment.';
|
||||
if (g.karamaShipping?.player === p.id)
|
||||
return 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.';
|
||||
const richese = byFaction(g, 'richese');
|
||||
if (richese && !noFieldAllyOfferBlock(g, richese) &&
|
||||
!(g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id))
|
||||
@@ -26021,6 +26044,8 @@ function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): strin
|
||||
}
|
||||
function guildHomeworldShipmentBlock(g: Game, p: Player): string | null {
|
||||
if (p.faction !== 'guild') return 'Only Guild has this Arrakis-to-Homeworld shipment permission.';
|
||||
if (guildTransportCanceled(g, p.id))
|
||||
return 'Karama prevents native Guild cross/return transport for this shipment opportunity.';
|
||||
return homeworldShipmentBlock(g, p);
|
||||
}
|
||||
function junctionTransportWindow(g: Game) {
|
||||
@@ -26029,7 +26054,9 @@ function junctionTransportWindow(g: Game) {
|
||||
if (!owner || !recipient || owner === recipient.id || g.phase !== 5 ||
|
||||
g.status !== 'playing' || recipient.shipped) return null;
|
||||
const offer = currentJunctionOffer(g);
|
||||
const blocked = homeworldShipmentBlock(g, recipient, false);
|
||||
const blocked = g.karamaShipping?.player === recipient.id
|
||||
? 'Junction transport with a purchased Karama price awaits its separate source composition.'
|
||||
: homeworldShipmentBlock(g, recipient, false);
|
||||
return {owner, recipient: recipient.id, offer, blocked,
|
||||
canOffer: !blocked, offerEvent: junctionOfferEvent(g),
|
||||
event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])};
|
||||
@@ -26149,10 +26176,15 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
}
|
||||
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
|
||||
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
|
||||
requireRule(!intent.guildSecretEvent || g.karamaShipping?.player !== intent.player,
|
||||
'Choose the purchased Karama price or Guild Secret Ally, not both.');
|
||||
requireRule(!homeworldUsesGuildTransport(g, intent) || !guildTransportCanceled(g, intent.player),
|
||||
'Karama prevents native Guild cross/return transport for this shipment opportunity.');
|
||||
const context = {
|
||||
...homeworldContext(g),
|
||||
nativeGuildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
|
||||
g.guildRateBlocked.player === intent.player,
|
||||
purchasedGuildRate: g.karamaShipping?.player === intent.player,
|
||||
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
|
||||
};
|
||||
const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
|
||||
@@ -26179,8 +26211,6 @@ function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment)
|
||||
shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') &&
|
||||
shipment.event === homeworldShipmentEvent(g, p, shipment.route),
|
||||
'This Homeworld declaration no longer matches its unused shipment and physical custody.');
|
||||
requireRule(g.karamaShipping?.player !== p.id,
|
||||
'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.');
|
||||
const quote = homeworldShipmentQuote(g, shipment);
|
||||
requireRule(shipment.amount === quote.amount && shipment.elite === quote.elite &&
|
||||
shipment.cost === quote.cost && JSON.stringify(shipment.pools) === JSON.stringify(quote.sources),
|
||||
@@ -26196,6 +26226,13 @@ function homeworldShipmentIntegrity(g: Game) {
|
||||
const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild');
|
||||
const shipment = g.pendingHomeworldShipment;
|
||||
if (!shipment && !decisions.length) return;
|
||||
if (shipment?.guildTransportEvent) {
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
|
||||
requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
|
||||
'The saved Homeworld transport declaration lost its single permission response.');
|
||||
validateHomeworldTransportPermission(g, responses[0]);
|
||||
return;
|
||||
}
|
||||
if (shipment?.guildRateEvent) {
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
|
||||
requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
|
||||
@@ -26220,6 +26257,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
|
||||
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
|
||||
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
|
||||
const p = getPlayer(g, shipment.player);
|
||||
const purchasedRate = g.karamaShipping?.player === p.id;
|
||||
payWithAlly(g, p, quote.cost, shipment.allyPayment);
|
||||
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
|
||||
g.homeworlds!.custody = quote.state;
|
||||
@@ -26240,13 +26278,14 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
|
||||
// Off-planet destinations do not create the Guild's ordinary onto-Dune income.
|
||||
// November FAQ contributor routing remains independent of that trigger.
|
||||
const guild = byFaction(g, 'guild');
|
||||
const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
|
||||
const income = !purchasedRate && guild && p.ally !== guild.id ? shipment.allyPayment : 0;
|
||||
if (!settlement?.recipient && guild && income > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, [income], undefined,
|
||||
[{ payer: p.ally!, amount: income }]);
|
||||
if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, shipment.allyPayment,
|
||||
{ own: false, ally: !!guild && p.ally !== guild.id });
|
||||
{ own: false, ally: !purchasedRate && !!guild && p.ally !== guild.id });
|
||||
finishShipmentPromises(g, p, null);
|
||||
if (purchasedRate) g.karamaShipping = null;
|
||||
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
|
||||
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
|
||||
}
|
||||
@@ -26262,7 +26301,68 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
|
||||
g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id,
|
||||
destination: intent.destination, amount: quote.amount, event: shipment.event};
|
||||
log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
|
||||
} else if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
|
||||
} else continueHomeworldShipment(g, shipment);
|
||||
}
|
||||
function homeworldUsesGuildTransport(g: Game,
|
||||
shipment: HomeworldShipmentIntent & {route?: 'arrakis'}): boolean {
|
||||
return getPlayer(g, shipment.player).faction === 'guild' &&
|
||||
(shipment.route === 'arrakis' || shipment.destination === 'homeworld:guild');
|
||||
}
|
||||
function continueHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
|
||||
if (!openHomeworldTransportPermission(g, shipment) && !openHomeworldGuildRate(g, shipment))
|
||||
commitHomeworldShipment(g, shipment);
|
||||
}
|
||||
function openHomeworldTransportPermission(g: Game, shipment: PendingHomeworldShipment): boolean {
|
||||
if (!homeworldUsesGuildTransport(g, shipment)) return false;
|
||||
validateHomeworldShipment(g, shipment);
|
||||
// The existing validator has proved this signed pending source.
|
||||
const cunning = shipment.guildNexusEvent
|
||||
? currentGuildCunning(g, shipment.player) as ReturnType<typeof validateGuildCunningShipment>
|
||||
: null;
|
||||
const event = crypto.randomUUID();
|
||||
shipment.guildTransportEvent = event;
|
||||
if (cunning) {
|
||||
cunning.shipment!.frame = JSON.stringify(shipment);
|
||||
saveGuildCunning(g, cunning);
|
||||
}
|
||||
g.pendingHomeworldShipment = shipment;
|
||||
g.response = { kind: 'guildTransportPermission', owner: shipment.player, passed: [],
|
||||
guildTransportEvent: event,
|
||||
intent: `${getPlayer(g, shipment.player).name} declared native Guild cross/return transport to ${combatLocationName(g, shipment.destination)}. Karama may prevent only this faction transport privilege before payment or typed departure. The original shipment opportunity remains for a legal different route; an independent purchased price remains but grants no transport permission. Junction-sponsored transport is a separate immune Homeworld source.` };
|
||||
return true;
|
||||
}
|
||||
function validateHomeworldTransportPermission(g: Game, response: ResponseWindow) {
|
||||
const shipment = g.pendingHomeworldShipment;
|
||||
requireRule(response.kind === 'guildTransportPermission' && shipment &&
|
||||
shipment.guildRateEvent === undefined && homeworldUsesGuildTransport(g, shipment) &&
|
||||
response.owner === shipment.player && typeof shipment.guildTransportEvent === 'string' &&
|
||||
shipment.guildTransportEvent.length > 0 && response.guildTransportEvent === shipment.guildTransportEvent,
|
||||
'The saved Homeworld permission lost its original native transport.');
|
||||
validateHomeworldShipment(g, shipment);
|
||||
return shipment;
|
||||
}
|
||||
function finishHomeworldTransportPermission(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
const shipment = validateHomeworldTransportPermission(g, response);
|
||||
const cunning = shipment.guildNexusEvent
|
||||
? currentGuildCunning(g, shipment.player) as ReturnType<typeof validateGuildCunningShipment>
|
||||
: null;
|
||||
g.pendingHomeworldShipment = null;
|
||||
delete shipment.guildTransportEvent;
|
||||
if (canceled) {
|
||||
g.guildTransportBlocked = { turn: g.turn, player: shipment.player,
|
||||
opportunity: guildTransportOpportunity(g, shipment.player) };
|
||||
if (cunning) {
|
||||
delete cunning.shipment;
|
||||
saveGuildCunning(g, cunning);
|
||||
}
|
||||
log(g, `Karama prevented the native Guild cross/return privilege. No typed forces or spice moved; ${getPlayer(g, shipment.player).name} retains a legal different shipment or movement.`);
|
||||
return;
|
||||
}
|
||||
if (cunning) {
|
||||
cunning.shipment!.frame = JSON.stringify(shipment);
|
||||
saveGuildCunning(g, cunning);
|
||||
}
|
||||
if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
|
||||
}
|
||||
function homeworldGuildRateFullCost(g: Game, shipment: PendingHomeworldShipment): number | null {
|
||||
const p = getPlayer(g, shipment.player);
|
||||
@@ -26401,11 +26501,13 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
'Allied No-Field skill composition retains its original ordinary shipment only.');
|
||||
const initial = responses.filter(r => r.kind === 'richeseNoField');
|
||||
const guild = decisions.filter(d => d.kind === 'guildShipment');
|
||||
requireRule(initial.length + guild.length === 1,
|
||||
const rates = responses.filter(r => r.kind === 'guildRate');
|
||||
requireRule(initial.length + guild.length + rates.length === 1,
|
||||
'The allied No-Field must retain its original cancellation or Guild continuation.');
|
||||
for (const response of initial) requireRule(noFieldCancellationQuote(g, response)?.kind === 'allied',
|
||||
'The allied No-Field lost its original owner, recipient or token declaration.');
|
||||
for (const decision of guild) validateGuildShipmentDecision(g, decision);
|
||||
for (const rate of rates) validateGuildRateResponse(g, rate);
|
||||
return true;
|
||||
}
|
||||
const pair = !!g.leaderSkills && !!shipment?.richesePair;
|
||||
@@ -26579,16 +26681,18 @@ const CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
|
||||
const guild = byFaction(g, 'guild');
|
||||
const p = getPlayer(g, shipment.player);
|
||||
const pricedAmount = shipment.noField || shipment.alliedNoField ? 1
|
||||
: shipment.amount - (shipment.smuggler ? 1 : 0);
|
||||
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
|
||||
g.karamaShipping?.player === p.id ||
|
||||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
|
||||
shipment.source || shipment.alliedNoField || shipment.guildSecretEvent ||
|
||||
shipment.source || shipment.guildSecretEvent ||
|
||||
shipment.nexusEvent || shipment.richesePair)
|
||||
return null;
|
||||
const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false },
|
||||
territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0));
|
||||
territory(shipment.territory).type, pricedAmount);
|
||||
const halfCost = reserveShipmentCost({ faction: p.faction, halfRate: true },
|
||||
territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0));
|
||||
territory(shipment.territory).type, pricedAmount);
|
||||
return shipment.cost === halfCost && shipment.cost < fullCost ? fullCost : null;
|
||||
}
|
||||
function openGuildRate(g: Game, shipment: PendingShipment): boolean {
|
||||
@@ -26613,7 +26717,7 @@ function openGuildRate(g: Game, shipment: PendingShipment): boolean {
|
||||
owner: byFaction(g, 'guild')!.id,
|
||||
passed: [],
|
||||
guildRateEvent: event,
|
||||
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.noField ? 'one concealed No-Field' : `${shipment.amount} reserve forces`}${shipment.smugglerCompanion ? ' with one free Smuggler companion' : shipment.smuggler ? ' including one free Smuggler companion' : ''} to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival, but does not remove the independent free companion. The same paid units 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.`,
|
||||
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.alliedNoField ? 'one allied No-Field shipment' : shipment.noField ? 'one concealed No-Field' : `${shipment.amount} reserve forces`}${shipment.smugglerCompanion ? ' with one free Smuggler companion' : shipment.smuggler ? ' including one free Smuggler companion' : ''} to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival.${shipment.smuggler || shipment.smugglerCompanion ? ' The independent free companion remains free.' : ''} The same paid units 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;
|
||||
@@ -26628,16 +26732,35 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) {
|
||||
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 - (shipment.smuggler ? 1 : 0)),
|
||||
shipment.turn === g.turn && fullCost !== null,
|
||||
'The saved Guild-rate response lost its original physical shipment.');
|
||||
return { shipment, fullCost };
|
||||
}
|
||||
function guildTransportPermissionIntegrity(g: Game) {
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
|
||||
const transport = g.pendingGuildTransport?.permission === true;
|
||||
const homeworld = !!g.pendingHomeworldShipment?.guildTransportEvent;
|
||||
const expected = Number(transport) + Number(homeworld);
|
||||
requireRule(expected <= 1 && responses.length === expected,
|
||||
'The native Guild transport lost its unique permission response.');
|
||||
for (const response of responses) {
|
||||
if (transport) validateGuildTransportPermissionResponse(g, response);
|
||||
else validateHomeworldTransportPermission(g, response);
|
||||
}
|
||||
const blocked = g.guildTransportBlocked;
|
||||
if (blocked) requireRule(blocked.turn === g.turn &&
|
||||
g.players.some(player => player.id === blocked.player) &&
|
||||
(blocked.opportunity === 'ordinary' ||
|
||||
g.nexusGuildCunningHistory?.some(record => record.receipt.event === blocked.opportunity &&
|
||||
record.receipt.turn === g.turn && record.receipt.owner === blocked.player)),
|
||||
'The denied Guild transport belongs to another shipment opportunity.');
|
||||
}
|
||||
function guildRateIntegrity(g: Game) {
|
||||
guildTransportPermissionIntegrity(g);
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
|
||||
const expected = Number(!!g.pendingShipment?.guildRateEvent) +
|
||||
Number(!!g.pendingGuildTransport) + Number(!!g.pendingHomeworldShipment?.guildRateEvent);
|
||||
Number(!!g.pendingGuildTransport && !g.pendingGuildTransport.permission) +
|
||||
Number(!!g.pendingHomeworldShipment?.guildRateEvent);
|
||||
requireRule(expected <= 1 && responses.length === expected,
|
||||
'The saved Guild-rate shipment lost its single Karama response.');
|
||||
if (responses.length) {
|
||||
@@ -26662,7 +26785,10 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
|
||||
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) {
|
||||
// A loan supplier approved the original whole price, not an increased gift.
|
||||
const supplierApprovalChanges = shipment.alliedNoField &&
|
||||
shipment.alliedNoField.payer === shipment.alliedNoField.owner;
|
||||
if (supplierApprovalChanges || shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
|
||||
if (cunning) {
|
||||
delete cunning.shipment;
|
||||
saveGuildCunning(g, cunning);
|
||||
@@ -26680,15 +26806,17 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
|
||||
saveGuildCunning(g, cunning);
|
||||
}
|
||||
commitShipment(g, shipment);
|
||||
if (canceled && !g.pendingGuildBetrayal) {
|
||||
if (canceled && shipper.shipped && !g.pendingGuildBetrayal) {
|
||||
delete g.guildRateBlocked;
|
||||
log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`);
|
||||
}
|
||||
}
|
||||
function guildTransportSignature(intent: PendingGuildTransport): string {
|
||||
return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves,
|
||||
const fields = [intent.event, intent.turn, intent.player, intent.fromReserves,
|
||||
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
|
||||
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]);
|
||||
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
|
||||
if (intent.permission !== undefined) fields.push(intent.permission);
|
||||
return JSON.stringify(fields);
|
||||
}
|
||||
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
|
||||
const guild = byFaction(g, 'guild');
|
||||
@@ -26720,18 +26848,19 @@ function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder,
|
||||
log(g, `${p.name} declared Guild-rate transport; payment and physical transfer wait for the Karama response.`);
|
||||
return true;
|
||||
}
|
||||
function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
|
||||
function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
|
||||
const intent = g.pendingGuildTransport;
|
||||
const guild = byFaction(g, 'guild');
|
||||
const shipper = intent && g.players.find(p => p.id === intent.player);
|
||||
const fullCost = intent && shipper
|
||||
? ordinaryGuildTransportFullCost(g, shipper, intent) : null;
|
||||
requireRule(response.kind === 'guildRate' && intent && guild && shipper &&
|
||||
const permission = response.kind === 'guildTransportPermission';
|
||||
requireRule(intent && guild && shipper &&
|
||||
(permission ? intent.permission === true && response.guildTransportEvent === intent.event
|
||||
: response.kind === 'guildRate' && intent.permission === undefined &&
|
||||
response.guildRateEvent === intent.event) &&
|
||||
g.status === 'playing' && g.phase === 5 && g.active === shipper.id &&
|
||||
shipmentAvailable(g, shipper) && intent.turn === g.turn &&
|
||||
typeof intent.event === 'string' && intent.event.length > 0 &&
|
||||
response.owner === guild.id && response.guildRateEvent === intent.event &&
|
||||
typeof intent.fromReserves === 'boolean' &&
|
||||
response.owner === guild.id && typeof intent.fromReserves === 'boolean' &&
|
||||
Array.isArray(intent.group) && intent.group.every(([key, n]) =>
|
||||
typeof key === 'string' && key.length > 0 && Number.isSafeInteger(n) && n > 0) &&
|
||||
intent.eliteGroup && typeof intent.eliteGroup === 'object' &&
|
||||
@@ -26757,15 +26886,68 @@ function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
|
||||
intent.group.reduce((sum, [key]) => sum + intent.eliteGroup[key], 0) === intent.elite) &&
|
||||
(intent.to !== 'reserves' || shipper.faction === 'guild') &&
|
||||
typeof intent.signature === 'string' &&
|
||||
intent.signature === guildTransportSignature(intent) &&
|
||||
fullCost !== null &&
|
||||
intent.signature === guildTransportSignature(intent),
|
||||
'The saved Guild transport lost its original physical response.');
|
||||
return { intent, shipper };
|
||||
}
|
||||
function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
|
||||
const { intent, shipper } = validateGuildTransportWindow(g, response);
|
||||
const fullCost = ordinaryGuildTransportFullCost(g, shipper, intent);
|
||||
requireRule(response.kind === 'guildRate' && fullCost !== null &&
|
||||
intent.cost === guildShipmentCost(
|
||||
intent.to === 'reserves' ? 'reserves' : territory(intent.to).type,
|
||||
intent.amount),
|
||||
'The saved Guild transport lost its original physical rate response.');
|
||||
intent.to === 'reserves' ? 'reserves' : territory(intent.to).type, intent.amount),
|
||||
'The saved Guild transport lost its original rate.');
|
||||
return { intent, shipper, fullCost };
|
||||
}
|
||||
function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost): GuildTransportOrder {
|
||||
return {
|
||||
turn: intent.turn, player: intent.player, origin: intent.origin,
|
||||
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
|
||||
sector: intent.sector, amount: intent.amount, elite: intent.elite,
|
||||
cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
|
||||
};
|
||||
}
|
||||
function guildTransportOpportunity(g: Game, player: string): string {
|
||||
return currentGuildCunning(g, player)?.receipt.event ?? 'ordinary';
|
||||
}
|
||||
function guildTransportCanceled(g: Game, player: string): boolean {
|
||||
const blocked = g.guildTransportBlocked;
|
||||
return !!blocked && blocked.turn === g.turn && blocked.player === player &&
|
||||
blocked.opportunity === guildTransportOpportunity(g, player);
|
||||
}
|
||||
function openGuildTransportPermission(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean) {
|
||||
validateGuildTransportForCommit(g, p, frame, fromReserves);
|
||||
const intent: PendingGuildTransport = {
|
||||
...frame, fromReserves, event: crypto.randomUUID(), signature: '', permission: true,
|
||||
};
|
||||
intent.signature = guildTransportSignature(intent);
|
||||
g.pendingGuildTransport = intent;
|
||||
g.response = { kind: 'guildTransportPermission', owner: byFaction(g, 'guild')!.id,
|
||||
passed: [], guildTransportEvent: intent.event,
|
||||
intent: `${p.name} declared native Guild ${frame.to === 'reserves' ? 'return' : 'cross-planet'} transport of ${frame.amount} physical forces. Karama may prevent this transport privilege, independently of its price. Denial moves no forces or spice and leaves this shipment opportunity available for a legal different route. Native cross/return stays unavailable for that opportunity; an independent purchased price is retained, not a new transport permission.` };
|
||||
}
|
||||
function validateGuildTransportPermissionResponse(g: Game, response: ResponseWindow) {
|
||||
const result = validateGuildTransportWindow(g, response);
|
||||
requireRule(response.kind === 'guildTransportPermission',
|
||||
'Use the original Guild transport permission response.');
|
||||
validateGuildTransportForCommit(g, result.shipper, result.intent, result.intent.fromReserves);
|
||||
return result;
|
||||
}
|
||||
function finishGuildTransportPermission(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
const { intent, shipper } = validateGuildTransportPermissionResponse(g, response);
|
||||
g.pendingGuildTransport = null;
|
||||
if (canceled) {
|
||||
g.guildTransportBlocked = { turn: g.turn, player: shipper.id,
|
||||
opportunity: guildTransportOpportunity(g, shipper.id) };
|
||||
log(g, `Karama prevented ${shipper.name}'s native Guild cross/return transport. No spice or forces moved; choose a legal different shipment route or continue movement.`);
|
||||
return;
|
||||
}
|
||||
const frame = guildTransportOrder(intent);
|
||||
if (!openGuildTransportRate(g, shipper, frame, intent.fromReserves))
|
||||
commitGuildTransport(g, shipper, frame, intent.fromReserves);
|
||||
}
|
||||
function nativeGuildTransportPermission(g: Game, p: Player): boolean {
|
||||
if (guildTransportCanceled(g, p.id)) return false;
|
||||
if (p.faction === 'guild') return true;
|
||||
const guild = byFaction(g, 'guild');
|
||||
return !!guild && p.ally === guild.id && guild.ally === p.id;
|
||||
@@ -26874,13 +27056,7 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
|
||||
const { intent, shipper, fullCost } = validateGuildTransportRateResponse(g, response);
|
||||
g.pendingGuildTransport = null;
|
||||
if (canceled) g.guildRateBlocked = { turn: g.turn, player: shipper.id };
|
||||
const frame: GuildTransportOrder = {
|
||||
turn: intent.turn, player: intent.player, origin: intent.origin,
|
||||
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
|
||||
sector: intent.sector, amount: intent.amount, elite: intent.elite,
|
||||
cost: canceled ? fullCost : intent.cost,
|
||||
allyPayment: intent.allyPayment, advisors: intent.advisors,
|
||||
};
|
||||
const frame = guildTransportOrder(intent, canceled ? fullCost : intent.cost);
|
||||
try {
|
||||
validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves);
|
||||
} catch (error) {
|
||||
@@ -27064,7 +27240,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
cost,
|
||||
allyPayment,
|
||||
bankOnly: g.karamaShipping?.player === p.id ||
|
||||
!!(shipment.alliedNoField && alliedNoFieldShipmentPricing(g, p).bankOnly),
|
||||
!!(shipment.alliedNoField && alliedNoFieldPricing(g, p).bankOnly),
|
||||
});
|
||||
if (!settlement?.recipient && guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
|
||||
@@ -28519,6 +28695,18 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action {
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing provisional special-stop policy: return only the original purchased card. */
|
||||
function refundStoppedShipmentKarama(g: Game, shipper: Player) {
|
||||
const benefit = g.karamaShipping;
|
||||
if (benefit?.player !== shipper.id) return;
|
||||
if (benefit.card) {
|
||||
const index = g.discard.findIndex(card => card.id === benefit.card);
|
||||
if (index >= 0)
|
||||
getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
|
||||
}
|
||||
g.karamaShipping = null;
|
||||
}
|
||||
|
||||
/** Revalidates exact custody and the original window before any mutation.
|
||||
* A future paused-intent caller must restore its original decision/response first;
|
||||
* this executor does not open, pass, or recreate a reaction window.
|
||||
@@ -28741,6 +28929,7 @@ export function executeSpecialKaramaIntent(
|
||||
p.specialKaramaUsed = true;
|
||||
if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped');
|
||||
shipper.shipped = true;
|
||||
refundStoppedShipmentKarama(g, shipper);
|
||||
g.pendingHomeworldShipment = null;
|
||||
g.decision = null;
|
||||
finishShipmentPromises(g, shipper, null);
|
||||
@@ -28764,15 +28953,7 @@ export function executeSpecialKaramaIntent(
|
||||
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
|
||||
if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped');
|
||||
shipper.shipped = true;
|
||||
// Provisional canceled-shipment settlement pending a primary-source
|
||||
// clarification: retain the attempted shipment's spice and rate card.
|
||||
const benefit = g.karamaShipping;
|
||||
if (benefit?.player === shipper.id && benefit.card) {
|
||||
const index = g.discard.findIndex((c) => c.id === benefit.card);
|
||||
if (index >= 0)
|
||||
getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
|
||||
}
|
||||
g.karamaShipping = null;
|
||||
refundStoppedShipmentKarama(g, shipper);
|
||||
g.pendingShipment = null;
|
||||
g.decision = null;
|
||||
log(
|
||||
@@ -30928,7 +31109,7 @@ function applyActionInner(
|
||||
decision.event === shipment.event && decision.shipper === shipment.player &&
|
||||
decision.destination === shipment.destination && decision.amount === shipment.amount,
|
||||
'The Guild decision no longer matches this Homeworld shipment.');
|
||||
if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
|
||||
continueHomeworldShipment(g, shipment);
|
||||
} else if (decision.kind === 'guildShipment') {
|
||||
requireRule(
|
||||
action.allow === true,
|
||||
@@ -32758,8 +32939,10 @@ function applyActionInner(
|
||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||
amount: n, elite, cost, allyPayment, advisors,
|
||||
};
|
||||
if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves))
|
||||
if (!guildSecretEvent) {
|
||||
openGuildTransportPermission(g, p, frame, fromReserves);
|
||||
return g;
|
||||
}
|
||||
commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent);
|
||||
return g;
|
||||
}
|
||||
@@ -34031,6 +34214,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
guildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
|
||||
g.guildRateBlocked.player === id,
|
||||
...(guildTransportCanceled(g, id) ? { guildTransportCanceled: true as const } : {}),
|
||||
stormPending: g.stormPending ?? null,
|
||||
advancedAllySeparation: advancedAllySeparationView(g, me),
|
||||
basicAlliedShipment: basicAlliedShipmentView(g, me),
|
||||
@@ -34386,6 +34570,7 @@ export function viewGame(state: Game, id: string) {
|
||||
const owner = byFaction(g, 'richese');
|
||||
if (!owner?.noField || !owner.noFieldEvent) return null;
|
||||
const mine = owner.id === me.id;
|
||||
const ally = owner.ally ? getPlayer(g, owner.ally) : null;
|
||||
const shipBlock = mine
|
||||
? noFieldShipBlock(g, owner)
|
||||
: 'Only Richese controls this token inventory.';
|
||||
@@ -34407,6 +34592,7 @@ export function viewGame(state: Game, id: string) {
|
||||
allyOfferBlock: mine
|
||||
? noFieldAllyOfferBlock(g, owner)
|
||||
: 'Only Richese may offer these tokens.',
|
||||
allyPricing: ally && (mine || ally.id === me.id) ? alliedNoFieldPricing(g, ally) : null,
|
||||
allyDeclined:
|
||||
g.richeseAllyDeclined?.turn === g.turn &&
|
||||
g.richeseAllyDeclined.recipient === owner.ally,
|
||||
@@ -34416,7 +34602,7 @@ export function viewGame(state: Game, id: string) {
|
||||
return null;
|
||||
const { event, owner, recipient, territory, sector, payer } = offer;
|
||||
const recipientSeat = g.players.find(player => player.id === recipient);
|
||||
const pricingPolicy = recipientSeat ? alliedNoFieldShipmentPricing(g, recipientSeat).policy : null;
|
||||
const pricingPolicy = recipientSeat ? alliedNoFieldPricing(g, recipientSeat).policy : null;
|
||||
try {
|
||||
const {
|
||||
cost,
|
||||
|
||||
Reference in new issue
Block a user