Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s

This commit is contained in:
Quintine committed 2026-10-28 23:52:21 +11:00
1 parent 478b38f5de
commit 2fe97ddd1f
10 files changed
+424 -23

No files matched your search

+1 -1
View File
@@ -67,7 +67,7 @@ export function botEntryAllowed(
return false;
try {
return !territoryEntryBlock(g.players, p.id, to, advisors,
kind === 'ship' && isAlliedShipmentVisit(g, p, to));
kind === 'ship' && isAlliedShipmentVisit(g, p, to), g.coalitionState);
} catch (error) {
if (error instanceof EcazOccupancyError) return false;
throw error;
+1
View File
@@ -3743,6 +3743,7 @@ function policyActions(g: GameView): Action[] {
const guildTransport =
guildSecret ||
me.faction === 'guild' ||
!!g.coalitionFunding && groupGuildRate ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
if (shipmentAvailable && guildTransport)
for (const to of targets
+47 -15
View File
@@ -1407,6 +1407,7 @@ type PendingGuildTransport = {
elite: number;
cost: number;
allyPayment: number;
coalitionContributions?: CoalitionContribution[];
advisors: boolean;
/** Fresh board transports retain their actual source stance at foreign arrivals.
* Absent on declarations recorded before this source cutover. */
@@ -38198,12 +38199,14 @@ function guildTransportSignature(intent: PendingGuildTransport): string {
if (intent.permission !== undefined) fields.push(intent.permission);
if (intent.advisorArrivalSource !== undefined) fields.push(intent.advisorArrivalSource);
if (intent.noField !== undefined) fields.push(intent.noField);
if (intent.coalitionContributions !== undefined) fields.push(intent.coalitionContributions);
return JSON.stringify(fields);
}
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
const guild = byFaction(g, 'guild');
if (!guild ||
(p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
(p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id) &&
!(coalitionFundingAvailable(g, p) && guildGroupAlly(g, p))) ||
g.karamaShipping?.player === p.id ||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id))
return null;
@@ -38256,6 +38259,8 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount - Number(!!marker) &&
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
(intent.coalitionContributions === undefined || Array.isArray(intent.coalitionContributions) &&
intent.allyPayment === 0 && !marker) &&
(intent.advisorArrivalSource === undefined || g.advanced && shipper.faction === 'beneGesserit' &&
!intent.fromReserves && intent.to !== 'reserves' && intent.advisorArrivalSource === intent.origin) &&
(marker === undefined || typeof marker === 'object' && marker !== null &&
@@ -38299,6 +38304,7 @@ function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost):
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,
...(intent.coalitionContributions !== undefined ? {coalitionContributions:intent.coalitionContributions.map(share => ({...share}))} : {}),
...(intent.noField ? {noField:{...intent.noField}} : {}),
...(intent.advisorArrivalSource !== undefined ? {advisorArrivalSource:intent.advisorArrivalSource} : {}),
};
@@ -38361,7 +38367,8 @@ 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;
return !!guild && (p.ally === guild.id && guild.ally === p.id ||
coalitionFundingAvailable(g, p) && guildGroupAlly(g, p));
}
function validateGuildTransportForCommit(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?:string,
@@ -38410,8 +38417,14 @@ function validateGuildTransportForCommit(
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);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
if (frame.coalitionContributions !== undefined) {
requireRule(allyPayment === 0 && !frame.noField && !guildSecretEvent &&
Array.isArray(frame.coalitionContributions), 'Group funding requires a physical native Guild transport.');
coalitionShipmentQuote(g, p, cost, frame.coalitionContributions);
} else {
contribution(g, p, cost, allyPayment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
}
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount, sector } : null);
}
function commitGuildTransport(
@@ -38433,18 +38446,32 @@ function commitGuildTransport(
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
{ ...frame, guildSecretEvent });
bindGuildCunningShipment(g, p, 'guild', frame);
payWithAlly(g, p, cost, allyPayment);
if (frame.coalitionContributions !== undefined) {
const result = coalitionRule(() => settleCoalitionFunding({seats:g.players, coalitionState:g.coalitionState,
authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges,
purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p),
contributions:frame.coalitionContributions!}));
p.spice -= result.quote.own;
g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges};
} else payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
if (!movementPreparation) {
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
const groupShares = frame.coalitionContributions;
const groupTotal = groupShares?.reduce((sum, share) => sum + share.amount, 0) ?? 0;
const groupContributors = groupShares ? [{payer:p.id, amount:cost-groupTotal},
...groupShares.map(share => ({payer:share.donor, amount:share.amount}))]
.filter(share => share.payer !== guild?.id && share.amount > 0) : null;
const guildPayment = groupContributors ? groupContributors.reduce((sum, share) => sum + share.amount, 0) : guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
g.response = guildPaymentResponse(g, guild.id,
groupContributors ? groupContributors.map(share => share.amount) : shipmentIncomeContributions(g, p, cost, allyPayment),
groupShares || allyPayment ? undefined : p.id, groupContributors ??
[{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
settleBankerShipmentIncome(g, p, cost, allyPayment);
}
if (fromReserves) {
@@ -45278,15 +45305,20 @@ function applyActionInner(
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id);
const cost = guildShipmentCost(
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);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
let coalitionContributions: CoalitionContribution[] | undefined;
if (action.coalitionContributions !== undefined) {
requireRule(execution === 'live' && Array.isArray(action.coalitionContributions) &&
!noField && !guildSecretEvent && (action.allyPayment === undefined || action.allyPayment === 0),
'Group funding requires a physical native Guild transport.');
coalitionContributions = action.coalitionContributions as CoalitionContribution[];
coalitionShipmentQuote(g, p, cost, coalitionContributions);
} else requireRule(p.spice + (aidFor(g, p)?.amount ?? 0) >= cost, 'Not enough spice.');
const allyPayment = coalitionContributions === undefined ? contribution(g, p, cost, action.allyPayment) : 0;
if (coalitionContributions === undefined) checkShipmentIncomeFunding(g, p, cost, allyPayment);
const frame: GuildTransportOrder = {
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
amount: n, elite, cost, allyPayment, advisors,
...(coalitionContributions !== undefined ? {coalitionContributions:coalitionContributions.map(share => ({...share}))} : {}),
...(noField ? {noField:{...noField}} : {}),
...(advisorArrivalSource !== undefined ? {advisorArrivalSource} : {}),
};
+2 -2
View File
@@ -4500,11 +4500,11 @@ export const RULE_TOPICS: RuleTopic[] = [
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, native Guild transport, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Guild transport, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',
+22 -4
View File
@@ -3,7 +3,7 @@ import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { splitLocation, validGameLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
import { shipmentPaymentBounds } from './shipment-price';
import { groupGuildShipmentRate, shipmentPaymentBounds } from './shipment-price';
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
import { guildNoFieldSelection } from './guild-no-field-selection';
import { isBoardNoFieldLocation } from './richese-no-field';
@@ -176,6 +176,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
!(
nexusAllowed ||
p.faction === 'guild' ||
!!g.coalitionFunding && groupGuildShipmentRate(g.players, p.id, g.coalitionState) ||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
)
)
@@ -192,15 +193,32 @@ export function guildTransportQuote(g: GameView, action: Action) {
)
: null;
const ownSpice = p.spice ?? 0;
const pledgedSpice = g.aid.available;
const groupShares = action.coalitionContributions;
const incoming = g.coalitionFunding?.incoming ?? [];
const pledgedSpice = groupShares === undefined ? g.aid.available : incoming.reduce((sum, entry) => sum + entry.amount, 0);
const { minimum } = shipmentPaymentBounds(cost ?? 0, ownSpice, pledgedSpice);
const share = action.allyPayment ?? minimum;
let groupShareValid = groupShares === undefined;
let groupShareTotal = 0;
if (groupShares !== undefined && Array.isArray(groupShares) && g.coalitionFunding) {
const seen = new Set<string>();
groupShareValid = groupShares.every((entry: unknown) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
const share = entry as {donor?: unknown; amount?: unknown};
const pledged = incoming.find(item => item.donor === share.donor);
if (typeof share.donor !== 'string' || seen.has(share.donor) || !pledged ||
typeof share.amount !== 'number' || !Number.isSafeInteger(share.amount) ||
share.amount <= 0 || share.amount > pledged.amount) return false;
seen.add(share.donor); groupShareTotal += share.amount; return Number.isSafeInteger(groupShareTotal);
});
}
const share = groupShares === undefined ? action.allyPayment ?? minimum : groupShareTotal;
const validShare =
cost !== null &&
typeof share === 'number' &&
Number.isSafeInteger(share) &&
share >= 0 &&
share <= cost;
share <= cost && groupShareValid && (groupShares === undefined || !nexus && !namesNoField &&
(action.allyPayment === undefined || action.allyPayment === 0));
if (cost !== null) {
if (!validShare)
unavailableReasons.push(