Connect Basic allied reserve shipment and provisional departure policy

This commit is contained in:
Quintine committed 2026-10-08 08:53:44 +11:00
1 parent fbbfabb404
commit d3a09a95fe
9 files changed
+180 -6

No files matched your search

+50
View File
@@ -1,3 +1,53 @@
import { MOBILE_STRONGHOLD, splitLocation } from './board';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { presenceAt, type ForcePresence } from './force-presence';
import type { OccupancySeat } from './occupancy';
/** Physical baseline distinguishes this turn's visitors from old co-occupation. */
export type BasicAlliedShipment = Readonly<{
player: string;
ally: string;
turn: number;
territory: string;
baseline: Readonly<{ normal: number; elite: number }>;
}>;
/** Ordinary Basic reserve arrivals only; movement endpoints keep their guard. */
export function isBasicAlliedShipmentVisit(
context: Readonly<{
advanced: boolean;
phase: number;
active: string | null;
players: readonly OccupancySeat[];
}>,
player: OccupancySeat & Readonly<{ shipped?: boolean; moved?: number }>,
to: string,
): boolean {
if (context.advanced || context.phase !== 5 || context.active !== player.id ||
player.shipped !== false || player.moved !== 0 || to === 'polar_sink' ||
to === MOBILE_STRONGHOLD) return false;
const ally = context.players.find(seat => seat.id === player.ally);
return !!ally && ally.ally === player.id && presenceAt(ally, to) > 0 &&
ecazOccupancyRelation(context.players, player.id, ally.id, {
kind: 'territory', id: to,
}) !== 'ecazAlliance';
}
export function alliedShipmentPhysicalPool(
player: ForcePresence & { elites?: { forces: Readonly<Record<string, number>> } },
to: string,
): { normal: number; elite: number } {
let normal = 0;
let elite = 0;
for (const key in player.forces) {
if (splitLocation(key).territory !== to) continue;
const special = player.elites?.forces[key] ?? 0;
elite += special;
normal += player.forces[key] - special;
}
return { normal, elite };
}
export type AlliedSeparationQuoteContext = Readonly<{
advanced: boolean;
turn: number;
+3 -1
View File
@@ -9,6 +9,7 @@ import {
type PlanetologistMoveMode,
} from './planetologist-movement';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
import { isBasicAlliedShipmentVisit } from './allied-separation';
import {
distance,
gameDistance,
@@ -65,7 +66,8 @@ export function botEntryAllowed(
)
return false;
try {
return !territoryEntryBlock(g.players, p.id, to, advisors);
return !territoryEntryBlock(g.players, p.id, to, advisors,
kind === 'ship' && isBasicAlliedShipmentVisit(g, p, to));
} catch (error) {
if (error instanceof EcazOccupancyError) return false;
throw error;
+10
View File
@@ -3481,6 +3481,7 @@ function policyActions(g: GameView): Action[] {
const origin = splitLocation(from).territory;
const retained =
level > 0 &&
g.basicAlliedShipment?.territory !== territory(origin).name &&
territory(origin).type === 'stronghold' &&
!isAdvisor(me, origin)
? 1
@@ -4118,6 +4119,15 @@ export function botActions(g: GameView): Action[] {
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) ? [sourced] : [];
});
const me = g.players.find((p) => p.id === g.me)!;
if (g.basicAlliedShipment && g.phase === 5 && g.active === me.id &&
me.shipped && me.moved === 0) {
// Do not choose an unrelated move or retain a city sentinel while visitors
// owe departure. Native response/decision branches do not offer these moves.
const departures = actions.filter(action => action.type === 'move' &&
typeof action.from === 'string' &&
territory(splitLocation(action.from).territory).name === g.basicAlliedShipment!.territory);
if (departures.length) return departures;
}
const ghola = standaloneGholaAction(g, actions);
if (ghola && !homeworldRevivalActionBlock(g, ghola)) return [ghola];
if (
+51 -3
View File
@@ -562,7 +562,8 @@ import {
settleAdvisors,
arrivalAsAdvisor,
} from './advisors';
import { quoteAlliedSeparation } from './allied-separation';
import { quoteAlliedSeparation, isBasicAlliedShipmentVisit, alliedShipmentPhysicalPool,
type BasicAlliedShipment } from './allied-separation';
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
import { FACTIONS, faction, type FactionId } from './catalog';
import { quoteLobbyBotConfiguration } from './lobby-bot-configuration';
@@ -1696,6 +1697,8 @@ export type Game = {
resume: 'none' | 'wormRide' | 'ambassador';
ambassadorEvent?: string;
} | null;
/** Current ordinary Basic shipment's physical departure obligation. */
basicAlliedShipment?: BasicAlliedShipment | null;
/** Committed entries whose optional reactions must wait for the arrival's own
* pending interaction (payment, advisor choice, storm protection). */
pendingArrivalReaction?: {
@@ -13741,6 +13744,7 @@ function allowedEntry(
s: number,
stormShipment = false,
advisors = false,
temporaryAlliedShipment = false,
) {
requireRule(
t !== MOBILE_STRONGHOLD || g.mobileStronghold?.location,
@@ -13755,7 +13759,7 @@ function allowedEntry(
'That sector is in storm.',
);
const blocked = occupancyRule(() =>
territoryEntryBlock(g.players, p.id, t, advisors),
territoryEntryBlock(g.players, p.id, t, advisors, temporaryAlliedShipment),
);
requireRule(!blocked, blocked ?? 'This territory cannot be entered.');
}
@@ -14636,12 +14640,48 @@ function advancedAllySeparationView(g: Game, p: Player): {territories: string[]}
return territories.length ? {territories} : null;
}
function basicAlliedShipmentView(g: Game, p: Player) {
const visit = g.basicAlliedShipment;
if (g.advanced || g.phase !== 5 || !visit || visit.player !== p.id ||
visit.turn !== g.turn || p.ally !== visit.ally) return null;
const ally = getPlayer(g, visit.ally);
if (ally.ally !== p.id || !at(ally, visit.territory) ||
sharesEcazOccupation(g, p, visit.territory)) return null;
const pool = alliedShipmentPhysicalPool(p, visit.territory);
const normal = Math.max(0, pool.normal - visit.baseline.normal);
const elite = Math.max(0, pool.elite - visit.baseline.elite);
return normal + elite ? { territory: territory(visit.territory).name, normal, elite } : null;
}
/** Provisional no-exit policy: only the excess physical visitors go to Tanks. */
function finishBasicAlliedShipment(g: Game, p: Player) {
const visit = g.basicAlliedShipment;
if (!visit || visit.player !== p.id) return;
const quote = basicAlliedShipmentView(g, p);
g.basicAlliedShipment = null;
if (!quote) return;
let normal = quote.normal;
let elite = quote.elite;
for (const key in p.forces) {
if (splitLocation(key).territory !== visit.territory) continue;
const special = p.elites?.forces[key] ?? 0;
const lostElite = Math.min(elite, special);
const lostNormal = Math.min(normal, p.forces[key] - special);
if (lostElite + lostNormal) kill(g, p, key, lostElite + lostNormal, false, lostElite);
elite -= lostElite;
normal -= lostNormal;
if (!normal && !elite) break;
}
log(g, `${p.name}'s ${quote.normal + quote.elite} new allied-shipment visitors left in ${quote.territory} went to Tanks. This is the provisional Basic no-departure policy, not a publisher ruling.`);
}
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
requireAlliedSeparationCustody(g, p);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
finishBasicAlliedShipment(g, p);
g.karamaShipping = null;
g.movementRemaining ??= g.order.slice(g.order.indexOf(id));
const shared = p.ally
@@ -25086,6 +25126,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
shipment.sector,
!shipment.guildSecretEvent && g.advanced && p.faction === 'fremen',
shipment.advisors,
!shipment.noField && !shipment.alliedNoField && isBasicAlliedShipmentVisit(g, p, shipment.territory),
);
if (p.faction === 'fremen' && !shipment.guildSecretEvent)
requireRule(
@@ -25584,6 +25625,11 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
let homeworldOrigins = '';
if (!shipment.noField) {
if (!shipment.alliedNoField && n > 0 && isBasicAlliedShipmentVisit(g, p, to))
g.basicAlliedShipment = {
player: p.id, ally: p.ally!, turn: g.turn, territory: to,
baseline: alliedShipmentPhysicalPool(p, to),
};
const receipts = withdrawNativeReserves(
g,
p,
@@ -30829,7 +30875,8 @@ function applyActionInner(
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
'Only Ixians may ship directly into the mobile stronghold.',
);
allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors);
allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors,
action.noField === undefined && isBasicAlliedShipmentVisit(g, p, to));
let noField: PendingShipment['noField'];
if (action.noField !== undefined) {
requireRule(
@@ -32327,6 +32374,7 @@ export function viewGame(state: Game, id: string) {
g.guildRateBlocked.player === id,
stormPending: g.stormPending ?? null,
advancedAllySeparation: advancedAllySeparationView(g, me),
basicAlliedShipment: basicAlliedShipmentView(g, me),
stormForecast:
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
stormRevealed:
+2
View File
@@ -37,6 +37,7 @@ export function territoryEntryBlock(
playerId: string,
to: string,
advisors = false,
temporaryAlliedShipment = false,
): string | null {
// Validate the complete identity roster even for an empty destination.
ecazOccupancyIdentity(players, playerId, { kind: 'territory', id: to });
@@ -44,6 +45,7 @@ export function territoryEntryBlock(
const player = players.find((p) => p.id === playerId)!;
const ally = players.find((p) => p.id === player.ally);
if (
!temporaryAlliedShipment &&
to !== 'polar_sink' &&
ally &&
presenceAt(ally, to) > 0 &&
+2 -2
View File
@@ -145,12 +145,12 @@ const phaseDetails = [
'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 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.',
'You may pass through an ally’s territory on a legal route that ends elsewhere. Basic ordinary physical reserve shipments may also enter allied territory before movement, provided the visiting group departs during that turn. Storm and stronghold capacity remain binding. The table visibly labels its provisional no-exit policy: excess new visitors still present when the turn ends go to Tanks, without destroying the ally or granting an extra move. Concealed No-Fields, optional arrivals and Advanced entry remain separate.',
'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.',
'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.',
'In Advanced, finishing your turn sends your own fighters in territories shared with your ally to Tanks, even if your ally acts later or the alliance formed this turn. Polar Sink, Bene Gesserit advisor coexistence in either direction and native Ecaz peaceful coexistence are exempt. The finish warning names affected territories without revealing concealed forces. Loss is mandatory, not a separate confirmation; finishing remains legal if you cannot escape.',
'Basic keeps its existing later-player separation deadline and same-turn alliance exemption; advisors have no new Basic exemption. Homeworlds keep their separate allied-entry restrictions.',
'Basic keeps its existing later-player separation deadline and same-turn alliance exemption for old co-occupation; these do not waive a new temporary shipment’s departure obligation. Advisors have no new Basic exemption. Homeworlds keep their separate allied-entry restrictions.',
'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.',
],
[