Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s
Verify and publish checkpoint container / container (push) Successful in 12m5s
This commit is contained in:
1 parent
478b38f5de
commit
2fe97ddd1f
10 files changed
+424
-23
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user