Extend Guild rate Karama to cross and return transport
This commit is contained in:
1 parent
8a23b5ccd2
commit
8ddd138e87
19 files changed
+898
-109
No files matched your search
@@ -140,9 +140,10 @@ export function fremenReserveEntry(to: string) {
|
||||
);
|
||||
}
|
||||
/** Guild cross-shipping is paid transport, including allied Fremen reserves. */
|
||||
export function guildTransportCost(to: string, amount: number) {
|
||||
export function guildTransportCost(to: string, amount: number, halfRate = true) {
|
||||
return guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type,
|
||||
amount,
|
||||
halfRate,
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -3302,6 +3302,7 @@ function policyActions(g: GameView): Action[] {
|
||||
guildTransportCost(
|
||||
to.t,
|
||||
Math.min(me.reserves, level === 0 ? 1 : 4),
|
||||
halfRate,
|
||||
) <= shipmentBudget
|
||||
)
|
||||
actions.push({
|
||||
@@ -3327,7 +3328,7 @@ function policyActions(g: GameView): Action[] {
|
||||
origin.id !== to.t &&
|
||||
splitLocation(from).sector !== g.storm &&
|
||||
botEntryAllowed(g, me, to.t, to.s, 'guildShip', origin.id) &&
|
||||
guildTransportCost(to.t, amount) <= shipmentBudget &&
|
||||
guildTransportCost(to.t, amount, halfRate || guildSecret) <= shipmentBudget &&
|
||||
gameDistance(g, from, to.key) > 3
|
||||
)
|
||||
actions.push({
|
||||
|
||||
+248
-58
@@ -1041,6 +1041,24 @@ type PendingShipment = {
|
||||
noField?: { tokenId: string; event: string };
|
||||
alliedNoField?: RicheseAllyOffer;
|
||||
};
|
||||
type PendingGuildTransport = {
|
||||
event: string;
|
||||
signature: string;
|
||||
turn: number;
|
||||
player: string;
|
||||
fromReserves: boolean;
|
||||
group: [string, number][];
|
||||
eliteGroup: Record<string, number>;
|
||||
origin: string;
|
||||
to: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
cost: number;
|
||||
allyPayment: number;
|
||||
advisors: boolean;
|
||||
};
|
||||
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves'>;
|
||||
type RicheseAllyOffer = {
|
||||
event: string;
|
||||
owner: string;
|
||||
@@ -1569,6 +1587,7 @@ export type Game = {
|
||||
>;
|
||||
} | null;
|
||||
pendingShipment?: PendingShipment | null;
|
||||
pendingGuildTransport?: PendingGuildTransport | null;
|
||||
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
|
||||
guildRateBlocked?: { turn: number; player: string };
|
||||
pendingHomeworldShipment?: PendingHomeworldShipment | null;
|
||||
@@ -18867,7 +18886,10 @@ 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 === 'guildRate') {
|
||||
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response);
|
||||
else 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
|
||||
@@ -18909,7 +18931,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
|
||||
g.response = null;
|
||||
if (response.kind === 'guildRate') {
|
||||
finishGuildRateResponse(g, response, canceled);
|
||||
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
|
||||
else finishGuildRateResponse(g, response, canceled);
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'nexusEcazBetrayal') {
|
||||
@@ -20370,13 +20393,22 @@ function validateGuildShipmentDecision(
|
||||
validatePhysicalShipment(g, shipment);
|
||||
return shipment;
|
||||
}
|
||||
const GUILD_RATE_CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
atreides: true, harkonnen: true, emperor: true,
|
||||
fremen: true, guild: true, beneGesserit: true,
|
||||
};
|
||||
|
||||
/** The independent Karama rate is a card benefit, not Guild's cancelable rate. */
|
||||
function guildRateModeSupported(g: Game): boolean {
|
||||
return !g.expansions.length && !g.homeworlds && !g.leaderSkills &&
|
||||
!g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
|
||||
g.players.every(player => GUILD_RATE_CLASSIC_FACTIONS[player.faction] === true);
|
||||
}
|
||||
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 ||
|
||||
!guildRateModeSupported(g) ||
|
||||
g.karamaShipping?.player === p.id ||
|
||||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) ||
|
||||
shipment.source || shipment.noField || shipment.alliedNoField ||
|
||||
@@ -20422,11 +20454,15 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) {
|
||||
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),
|
||||
const expected = Number(!!g.pendingShipment?.guildRateEvent) +
|
||||
Number(!!g.pendingGuildTransport);
|
||||
requireRule(expected <= 1 && responses.length === expected,
|
||||
'The saved Guild-rate shipment lost its single Karama response.');
|
||||
if (responses.length) validateGuildRateResponse(g, responses[0]);
|
||||
if (responses.length) {
|
||||
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, responses[0]);
|
||||
else validateGuildRateResponse(g, responses[0]);
|
||||
}
|
||||
const blocked = g.guildRateBlocked;
|
||||
if (blocked)
|
||||
requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player),
|
||||
@@ -20454,6 +20490,201 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
|
||||
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,
|
||||
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
|
||||
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]);
|
||||
}
|
||||
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
|
||||
const guild = byFaction(g, 'guild');
|
||||
if (!guild || !guildRateModeSupported(g) ||
|
||||
(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))
|
||||
return null;
|
||||
const fullCost = guildShipmentCost(
|
||||
frame.to === 'reserves' ? 'reserves' : territory(frame.to).type,
|
||||
frame.amount, false);
|
||||
return frame.turn === g.turn && frame.cost < fullCost ? fullCost : null;
|
||||
}
|
||||
function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean): boolean {
|
||||
const fullCost = ordinaryGuildTransportFullCost(g, p, frame);
|
||||
if (fullCost === null) return false;
|
||||
const intent: PendingGuildTransport = {
|
||||
...frame, fromReserves, event: crypto.randomUUID(), signature: '',
|
||||
};
|
||||
intent.signature = guildTransportSignature(intent);
|
||||
g.pendingGuildTransport = intent;
|
||||
g.response = {
|
||||
kind: 'guildRate',
|
||||
owner: byFaction(g, 'guild')!.id,
|
||||
passed: [],
|
||||
guildRateEvent: intent.event,
|
||||
intent: `${p.name} declared ${frame.amount} forces from ${fromReserves ? 'southern reserves' : territory(frame.origin).name} to ${frame.to === 'reserves' ? 'reserves' : territory(frame.to).name} at the Guild transport rate of ${frame.cost} spice. Karama can remove only the discount before payment or force transfer. The same physical transport would cost ${fullCost} at full price; if its original approved payment split cannot cover that, it returns unused and a replacement this turn uses full price.`,
|
||||
};
|
||||
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) {
|
||||
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 &&
|
||||
g.status === 'playing' && g.phase === 5 && g.active === shipper.id &&
|
||||
!shipper.shipped && intent.turn === g.turn &&
|
||||
typeof intent.event === 'string' && intent.event.length > 0 &&
|
||||
response.owner === guild.id && response.guildRateEvent === intent.event &&
|
||||
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' &&
|
||||
!Array.isArray(intent.eliteGroup) &&
|
||||
typeof intent.origin === 'string' && intent.origin.length > 0 &&
|
||||
typeof intent.to === 'string' && intent.to.length > 0 &&
|
||||
Number.isSafeInteger(intent.sector) &&
|
||||
Number.isSafeInteger(intent.amount) && intent.amount > 0 &&
|
||||
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
|
||||
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
|
||||
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
|
||||
(intent.fromReserves
|
||||
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
|
||||
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
|
||||
intent.to !== 'reserves'
|
||||
: intent.group.length > 0 && intent.group.every(([key]) =>
|
||||
splitLocation(key).territory === intent.origin) &&
|
||||
intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount &&
|
||||
Object.keys(intent.eliteGroup).length === intent.group.length &&
|
||||
intent.group.every(([key, n]) =>
|
||||
Number.isSafeInteger(intent.eliteGroup[key]) &&
|
||||
intent.eliteGroup[key] >= 0 && intent.eliteGroup[key] <= n) &&
|
||||
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.cost === guildShipmentCost(
|
||||
intent.to === 'reserves' ? 'reserves' : territory(intent.to).type,
|
||||
intent.amount),
|
||||
'The saved Guild transport lost its original physical rate response.');
|
||||
return { intent, shipper, fullCost };
|
||||
}
|
||||
function validateGuildTransportForCommit(
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
|
||||
) {
|
||||
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
|
||||
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
|
||||
'This Guild transport no longer belongs to an unused shipment opportunity.');
|
||||
if (fromReserves) {
|
||||
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
|
||||
group.length === 0 && Object.keys(eliteGroup).length === 0,
|
||||
'The southern-reserve transport lost its original source.');
|
||||
integer(amount, 1, p.reserves, 'Forces');
|
||||
requireRule(eliteChoice(amount, p.reserves, p.elites?.reserves ?? 0, elite) === elite,
|
||||
'The original southern-reserve elite allocation is unavailable.');
|
||||
} else {
|
||||
const quoted = forceGroup(g, p, { type: 'guildShip',
|
||||
forces: Object.fromEntries(group), eliteForces: eliteGroup });
|
||||
requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) &&
|
||||
JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) &&
|
||||
quoted.origin === origin && quoted.elite === elite && quoted.total === amount &&
|
||||
group.every(([key]) => splitLocation(key).sector !== g.storm),
|
||||
'The original Guild transport force group changed or entered the storm.');
|
||||
}
|
||||
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
||||
(to !== 'reserves' || p.faction === 'guild') &&
|
||||
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
|
||||
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)),
|
||||
'The original Guild transport destination or advisor stance is unavailable.');
|
||||
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
||||
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = g.karamaShipping?.player === p.id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
|
||||
requireRule(cost === guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate),
|
||||
'The declared Guild transport price no longer matches its rate.');
|
||||
contribution(g, p, cost, allyPayment);
|
||||
checkShipmentIncomeRounding(g, p, cost, allyPayment);
|
||||
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
|
||||
}
|
||||
function commitGuildTransport(
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
|
||||
guildSecretEvent?: string,
|
||||
) {
|
||||
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
|
||||
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
||||
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
|
||||
{ ...frame, guildSecretEvent });
|
||||
bindGuildCunningShipment(g, p, 'guild', frame);
|
||||
payWithAlly(g, p, cost, allyPayment);
|
||||
const guild = byFaction(g, 'guild');
|
||||
const guildPayment = guildShipmentIncome({
|
||||
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
|
||||
bankOnly: g.karamaShipping?.player === p.id,
|
||||
});
|
||||
if (guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id,
|
||||
shipmentIncomeContributions(g, p, cost, allyPayment),
|
||||
allyPayment ? undefined : p.id);
|
||||
if (fromReserves) {
|
||||
p.reserves -= amount;
|
||||
if (p.elites) p.elites.reserves -= elite;
|
||||
} else removeGroup(p, group, eliteGroup);
|
||||
if (to === 'reserves') {
|
||||
p.reserves += amount;
|
||||
if (p.elites) p.elites.reserves += elite;
|
||||
} else {
|
||||
place(p, to, sector, amount, elite);
|
||||
if (advisors)
|
||||
(p.advisors ??= {})[to] = {
|
||||
lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined,
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[to];
|
||||
}
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
|
||||
finishGuildCunningShipment(g, p.id, 'guild', frame);
|
||||
p.shipped = true;
|
||||
g.karamaShipping = null;
|
||||
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
guildSecretEvent
|
||||
? { faction: 'guild', name: 'Secret Ally transport' }
|
||||
: { faction: p.faction, name: 'Guild transport' });
|
||||
if (to !== 'reserves') {
|
||||
intrusion(g, p, to);
|
||||
if (origin !== to)
|
||||
openTerritoryEntry(g, p, to, sector, amount, elite, 'guildTransport');
|
||||
}
|
||||
}
|
||||
function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
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,
|
||||
};
|
||||
try {
|
||||
validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
log(g, `${shipper.name}'s Guild transport declaration returned without a payment or force transfer; its exact original source, destination or approved funding can no longer complete at ${canceled ? 'the full canceled rate' : 'the Guild rate'}.${canceled ? ' The spent Karama leaves a replacement at full price for this turn.' : ''}`);
|
||||
return;
|
||||
}
|
||||
commitGuildTransport(g, shipper, frame, intent.fromReserves);
|
||||
if (canceled) {
|
||||
delete g.guildRateBlocked;
|
||||
log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`);
|
||||
}
|
||||
}
|
||||
function offerShipment(g: Game, shipment: PendingShipment) {
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
@@ -25688,64 +25919,23 @@ function applyActionInner(
|
||||
'Only Ixians may ship directly into the mobile stronghold.',
|
||||
);
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = g.karamaShipping?.player === id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id);
|
||||
const cost = guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type,
|
||||
n,
|
||||
);
|
||||
to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate);
|
||||
requireRule(
|
||||
p.spice + (aidFor(g, p)?.amount ?? 0) >= cost,
|
||||
'Not enough spice.',
|
||||
);
|
||||
const allyPayment = contribution(g, p, cost, action.allyPayment);
|
||||
checkShipmentIncomeRounding(g, p, cost, allyPayment);
|
||||
const cunningFrame = {turn:g.turn,player:id,origin,group,eliteGroup,to,sector,amount:n,elite,cost,allyPayment,advisors};
|
||||
if (guildSecretEvent) recordGuildSecretShipment(g,id,guildSecretEvent,'cross',{...cunningFrame,guildSecretEvent});
|
||||
bindGuildCunningShipment(g,p,'guild',cunningFrame);
|
||||
payWithAlly(g, p, cost, allyPayment);
|
||||
const guild = byFaction(g, 'guild');
|
||||
const guildPayment = guildShipmentIncome({
|
||||
guild: guild?.id,
|
||||
shipper: id,
|
||||
ally: p.ally,
|
||||
cost,
|
||||
allyPayment,
|
||||
bankOnly: g.karamaShipping?.player === id,
|
||||
});
|
||||
if (guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
|
||||
if (fromReserves) {
|
||||
p.reserves -= n;
|
||||
if (p.elites) p.elites.reserves -= elite;
|
||||
} else removeGroup(p, group, eliteGroup);
|
||||
if (to === 'reserves') {
|
||||
p.reserves += n;
|
||||
if (p.elites) p.elites.reserves += elite;
|
||||
} else {
|
||||
place(p, to, sector, n, elite);
|
||||
if (advisors)
|
||||
(p.advisors ??= {})[to] = {
|
||||
lockedTurn:
|
||||
Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
|
||||
undefined,
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[to];
|
||||
}
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(
|
||||
g,
|
||||
p,
|
||||
fromReserves ? { territory: to, amount: n } : null,
|
||||
);
|
||||
finishGuildCunningShipment(g,id,'guild',cunningFrame);
|
||||
p.shipped = true;
|
||||
g.karamaShipping = null;
|
||||
log(g, `${p.name} used Guild transport for ${n} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : { faction: p.faction, name: 'Guild transport' });
|
||||
if (to !== 'reserves') {
|
||||
intrusion(g, p, to);
|
||||
if (origin !== to)
|
||||
openTerritoryEntry(g, p, to, sector, n, elite, 'guildTransport');
|
||||
}
|
||||
const frame: GuildTransportOrder = {
|
||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||
amount: n, elite, cost, allyPayment, advisors,
|
||||
};
|
||||
if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves))
|
||||
return g;
|
||||
commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent);
|
||||
return g;
|
||||
}
|
||||
if (t === 'emperorHomeworldMove') {
|
||||
|
||||
@@ -116,7 +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.',
|
||||
'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; optional modules and separate transport-permission 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.',
|
||||
|
||||
@@ -134,6 +134,7 @@ const cancellationSources = {
|
||||
guildIncome: () => null,
|
||||
guildRate: (g) => ({
|
||||
pending: g.pendingShipment ?? null,
|
||||
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
|
||||
active: g.active,
|
||||
discountedRateBlocked: g.guildRateBlocked ?? null,
|
||||
purchasedRate: g.karamaShipping ?? null,
|
||||
|
||||
+3
-3
@@ -140,10 +140,10 @@ 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.',
|
||||
'A classic Guild or reciprocal ally’s discounted ordinary reserve shipment or eligible cross-planet transport waits for a Karama response before paying. Guild’s own return to reserves waits too. Canceling the rate uses normal destination pricing for the same physical forces and approved payment split; a return costs one spice per force. If the split or exact group cannot complete, the declaration returns unused and a replacement this turn uses full price. Full-price return and unaffordable withdrawal are documented table interpretations, not separate publisher examples. Guild income, purchased Karama rates, transport permission and optional modules remain distinct.',
|
||||
'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.',
|
||||
'Guild transport can move one exact physical group across the planet or return it to reserves. In classic games its normal half-price rate can be canceled separately from its transport permission. Ordinary movement is checked against range, storm and stronghold occupancy.',
|
||||
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
|
||||
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory and shared desert allocation are supported. Allied Ecaz and Fremen together in Sietch Tabr do not block the Fremen final-turn victory; its other conditions still apply. Combined combat and the Habbanya extension remain unfinished.',
|
||||
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
|
||||
@@ -2408,7 +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.',
|
||||
'In classic-faction games without optional modules, cancel Guild’s half-price rate before an ordinary reserve shipment, eligible cross-planet transport or Guild return moves any spice or forces. Full destination price applies to the same physical group if the original approved payment split can afford it; otherwise the declaration returns unused and its replacement is full price this turn. The full-price return tariff and unaffordable withdrawal are documented table policies, not printed refund examples. Guild income, permission to cross/return and the independent rate purchased by Karama remain separate.',
|
||||
'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.',
|
||||
|
||||
@@ -14,14 +14,14 @@ export function reserveShipmentCost(
|
||||
/** Guild cross-planet transport and returns; Fremen southern reserves pay this
|
||||
* tariff too. Pass 'reserves' for a return, not a board territory type.
|
||||
*/
|
||||
export function guildShipmentCost(destinationType: string, amount: number) {
|
||||
return Math.ceil(
|
||||
(amount *
|
||||
(destinationType === 'reserves' || destinationType === 'stronghold'
|
||||
? 1
|
||||
: 2)) /
|
||||
2,
|
||||
);
|
||||
export function guildShipmentCost(
|
||||
destinationType: string,
|
||||
amount: number,
|
||||
halfRate = true,
|
||||
) {
|
||||
const normal = amount *
|
||||
(destinationType === 'reserves' || destinationType === 'stronghold' ? 1 : 2);
|
||||
return halfRate ? Math.ceil(normal / 2) : normal;
|
||||
}
|
||||
|
||||
/** A pledge is already escrowed. Selecting a share never spends a donor's
|
||||
|
||||
@@ -158,7 +158,11 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
);
|
||||
const cost =
|
||||
validForces && total > 0 && validDestination
|
||||
? guildTransportCost(to, total)
|
||||
? guildTransportCost(
|
||||
to,
|
||||
total,
|
||||
g.karamaShipping?.player === g.me || !g.guildRateCanceled,
|
||||
)
|
||||
: null;
|
||||
const ownSpice = p.spice ?? 0;
|
||||
const pledgedSpice = g.aid.available;
|
||||
|
||||
Reference in new issue
Block a user