diff --git a/components/game-table.tsx b/components/game-table.tsx index 63b0dae9..c0eb0ed1 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -176,6 +176,7 @@ import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/ne import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options'; import { guildTransportQuote } from '@/game/transport-quote'; import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility'; +import { isBasicAlliedShipmentVisit } from '@/game/allied-separation'; import { location as boardLocation } from '@/game/board'; import { fighterCount, isAdvisor } from '@/game/advisors'; import { useState } from 'react'; @@ -4392,6 +4393,14 @@ export function GameTable({ )} {destination} + {isBasicAlliedShipmentVisit(g, me, selected) && ( +

+ Basic allied transit: ship ordinary forces here, then move the visiting group out + before finishing your turn. Storm and stronghold capacity still apply. + Provisional fallback: new visitors left behind go to Tanks, even if departure is prevented. + This does not waive the older allied co-occupation deadline. +

+ )} {amountInput} {g.karamaShipping?.player === me.id && (

@@ -4733,6 +4742,13 @@ export function GameTable({ )} )} + {g.basicAlliedShipment && ( +

+ Move your new visitors out of {g.basicAlliedShipment.territory} before finishing. + Provisional Basic policy: finishing now sends {g.basicAlliedShipment.normal} normal + {' '}and {g.basicAlliedShipment.elite} special visiting forces to Tanks, not your ally’s forces. +

+ )} {g.nexusGuildCunning?.offer && (
diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md index 2f1c863e..6e07c7a8 100644 --- a/docs/ALLIED_TRANSIT_RULES.md +++ b/docs/ALLIED_TRANSIT_RULES.md @@ -2,6 +2,45 @@ Audit: 7 September 2026. Read-only source and code audit, retained by the coordinating agent. Scope is classic GF9 base rules and the applicable November 2020 FAQ, not tournament or movie-game rules. +## Basic ordinary allied shipment prototype — 8 October 2026 + +The content-first Basic pass connects the FAQ's ordinary physical +reserve-shipment-then-departure sequence. It is available only before the +shipper's movement, on its own turn, with its ordinary shipment unused. +Storm, stronghold capacity, reserve/payment rules and movement range still +apply. Normal ground-movement endpoints retain the ally guard; Polar Sink +and native Ecaz coexistence need no temporary permission. + +`Game.basicAlliedShipment` records the current actor, reciprocal ally, +turn, territory and pre-arrival normal/special physical baseline. The +own-seat `GameView.basicAlliedShipment` warning quotes excess physical +visitors still present there. Normal ending consumes the receipt once. +If the ally has left or the original reciprocal alliance no longer holds, +there is no visitor consequence. Physical counters are fungible: mixed +old/new groups use the aggregate baseline, not invented counter identities. + +**Provisional policy, not a publisher ruling:** excess visiting forces +left behind at the end of the shipper's turn go to Tanks, including when +an interruption prevented departure. This implements a non-stalling first +version under the user's Basic-first instruction; it does not adjudicate +the no-exit source gap below. The pre-shipment notice and ending warning +name that policy. Losses do not count as Battle losses. Existing Basic +co-occupation deadlines still resolve separately. AI prioritizes available +native departures and does not retain a stronghold sentinel from a +visiting group. + +Concealed and allied No-Field arrivals, mobile/homeworld transports, +optional arrival compositions and Advanced entry permission are not +expanded by this prototype. Refinement of interrupted and mixed-cohort +interactions remains later work. + +**Brief smoke evidence:** paid Basic allied arrival followed by Medium's +native departure and clean ending; ending without departure removes two +new visitors while preserving five old counters and the ally's three; +ally-plus-rival stronghold capacity remains enforced. Actual `GameTable` +SSR markup was observed in Chromium with the provisional count warning +and enabled finish control. This is not deployed browser acceptance. + ## Authorized Advanced source cutover — 1 October 2026 The user-supplied root `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 04480864..9d9c2440 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -46,6 +46,13 @@ One direct Basic alliance-and-loan smoke passes; no broad suite was run. **Guild Secret Ally return control corrected.** The human transport preview no longer rejects the already-ruled own-native-reserve return. One Basic quote smoke offers three physical counters for two spice; no broad suite was run. +**Basic allied shipment transit prototype.** Ordinary physical reserve forces +can ship into an ally's territory and then depart through native movement. +Owned warnings and minimal legal AI are connected. A visibly provisional +no-exit policy sends only excess new visitors to Tanks when the turn ends; +old co-occupation timing remains separate. Three brief engine scenarios pass, +including Medium departure, old/ally force preservation and stronghold capacity. +Advanced entry and concealed/optional arrivals remain unchanged. diff --git a/game/allied-separation.ts b/game/allied-separation.ts index 2dff5d39..37764578 100644 --- a/game/allied-separation.ts +++ b/game/allied-separation.ts @@ -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> } }, + 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; diff --git a/game/bot-mobility.ts b/game/bot-mobility.ts index a5dda500..56829a9a 100644 --- a/game/bot-mobility.ts +++ b/game/bot-mobility.ts @@ -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; diff --git a/game/bots.ts b/game/bots.ts index 8eb3ec5a..7e521bb7 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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 ( diff --git a/game/engine.ts b/game/engine.ts index 57eea07a..04759038 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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: diff --git a/game/occupancy.ts b/game/occupancy.ts index 8ecf546b..1cf7b00e 100644 --- a/game/occupancy.ts +++ b/game/occupancy.ts @@ -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 && diff --git a/game/reference.ts b/game/reference.ts index bc2c2907..19426a80 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', ], [