From 89569e92a134345fb88fb0af1f85267bea5dc868 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:48:47 +1100 Subject: [PATCH] Support Advanced native Homeworld return --- components/homeworld-shipment.tsx | 19 +- docs/CURRENT_STATUS.md | 11 ++ docs/HOMEWORLD_RULES.md | 22 ++- docs/REINFORCEMENTS_RUNTIME.md | 9 + docs/RULE_DECISIONS.md | 11 ++ game/engine.ts | 14 +- game/homeworld-shipment-options.ts | 14 +- game/homeworld-shipment.ts | 11 +- game/reference.ts | 3 +- .../homeworld-return-native-controls.test.tsx | 70 +++++++ .../homeworld-return-native-shipment.test.ts | 176 ++++++++++++++++++ tests/homeworld-shipment-controls.test.ts | 12 +- tests/homeworld-shipment.test.ts | 32 +++- ...nforcements-homeworld-guide-choice.test.ts | 64 +++++++ 14 files changed, 429 insertions(+), 39 deletions(-) create mode 100644 tests/homeworld-return-native-controls.test.tsx create mode 100644 tests/homeworld-return-native-shipment.test.ts create mode 100644 tests/reinforcements-homeworld-guide-choice.test.ts diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx index ec0f95d8..0ab8ecd9 100644 --- a/components/homeworld-shipment.tsx +++ b/components/homeworld-shipment.tsx @@ -98,13 +98,14 @@ export function HomeworldShipment({ }); const targets = worlds.filter( (world) => - (world.native !== g.me || me.faction === 'guild') && - !(g.guildTransportCanceled && me.faction === 'guild' && world.native === me.id) && + (g.advanced || world.native !== g.me || me.faction === 'guild') && + !(!g.advanced && g.guildTransportCanceled && me.faction === 'guild' && world.native === me.id) && world.native !== me.ally, ); const [origin, setOrigin] = useState(available[0]?.id ?? ''); const [destination, setDestination] = useState( - targets.find((world) => world.id !== available[0]?.id)?.id ?? '', + (targets.find((world) => world.id !== available[0]?.id && world.native !== g.me) ?? + targets.find((world) => world.id !== available[0]?.id))?.id ?? '', ); const [sources, setSources] = useState({}); const [allyPayment, setAllyPayment] = useState(0); @@ -143,11 +144,13 @@ export function HomeworldShipment({

Use your shipment to send forces from one Homeworld to another. Native reserves and foreign garrisons have separate source pools.{' '} - {me.faction === 'guild' - ? g.guildTransportCanceled - ? 'The native Guild return permission is canceled: Junction cannot be a destination, even with a purchased price.' - : 'Guild may also return a foreign garrison to Junction.' - : 'Your own Homeworld cannot be a destination.'}{' '} + {g.advanced + ? 'You may return a foreign garrison to your own Homeworld.' + : me.faction === 'guild' + ? g.guildTransportCanceled + ? 'The native Guild return permission is canceled: Junction cannot be a destination, even with a purchased price.' + : 'Guild may also return a foreign garrison to Junction.' + : 'Your own Homeworld cannot be a destination.'}{' '} Allied Homeworlds cannot be destinations.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a755558e..99ba7f9b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Advanced native Homeworld return — 29 October. Prototyped / Partial.** +Physical forces may ship from another Homeworld back to their own under the +user-authorized Advanced rulebook; Basic retains its original guard. Guild uses +the common Advanced route even if its native transport is canceled, while +already-open permission responses retain their continuation. A Homeworld battle +plan guide also carries the selected Reinforcements reserve mix. Ninety-four +related cases, quick type/lint checks and the production build pass. Concealed +No-Field return, wider +interactions and deployed acceptance remain open. See [return scope](HOMEWORLD_RULES.md#advanced-native-homeworld-return--29-october-2026) +and [guide scope](REINFORCEMENTS_RUNTIME.md#homeworld-plan-guide-with-chosen-fees--29-october-2026). + **Reinforcements reserve choice — 29 October. Prototyped / Partial.** The card holder can choose any available mix of three normal and special reserve counters when sealing a plan, including native Homeworld sources. diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md index de21a7b8..01b339c9 100644 --- a/docs/HOMEWORLD_RULES.md +++ b/docs/HOMEWORLD_RULES.md @@ -365,6 +365,26 @@ Low population adds one to the native faction's free-revival rate and adds one b Advanced Emperor starts with Sardaukar on Salusa and normal reserves on Kaitain. Revival returns these types to their respective native locations. During Shipment and Movement, Emperor can spend its movement transferring forces between the two. One outbound shipment can combine forces from both Homeworlds. The initial/revival placement restriction is not a permanent ban on the other type arriving through that expressly permitted transfer. Salusa's printed threshold nevertheless counts Sardaukar, so transferred normal defenders do not substitute for them. Basic uses only Kaitain and treats Sardaukar as ordinary battle forces. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10) +## Advanced native Homeworld return — 29 October 2026 + +**Prototyped / Partial.** The supplied user-authorized revised rulebook physical +p. 22 (printed p. 20) makes World-to-World shipment available to all factions. +Advanced physical forces may return from a distinct foreign Homeworld to their +own using a normal paid shipment, including either Emperor World. Guild's +World-to-World return is this general route rather than its cancelable native +cross/return privilege; the Guild rate and its own cancellation remain separate. +Previously opened native permission windows retain their saved continuation. +Basic retains its existing E3 own-World restriction and Guild exception. This +cutover does not permit an ordinary Arrakis-to-own-World shipment, allied World +entry or concealed No-Field return. + +The [real-action tests](../tests/homeworld-return-native-shipment.test.ts) stage +only the shipment opportunity after native setup and create the foreign garrison +through play, then exercise Advanced and Basic, payments and bot candidates. +The [control tests](../tests/homeworld-return-native-controls.test.tsx) verify +that the destination and explanation match the selected rules. Broader module +interactions, full games and deployed acceptance remain open. + ## Transport and arrival | Route or effect | Source contract | @@ -372,7 +392,7 @@ Advanced Emperor starts with Sardaukar on Salusa and normal reserves on Kaitain. | Native reserves to Arrakis | Existing faction shipment rules apply unless the actual Homeworld face modifies them. Keep free Fremen placement, Guild rates and ordinary restrictions distinct. | | Homeworld to another Homeworld | Permitted; printed price is one spice per physical force, half price for Guild. Check owner/ally restrictions and the particular card's transport advantage. | | Arrakis to a Homeworld | Guild cross-shipment only, except a permission actually conferred by the Guild Homeworld face. Do not infer permission from ordinary allied discounts. | -| Adding forces to one's own Homeworld | Revival is the general route; Guild/cross-shipping exceptions are explicit. No generic free board-to-reserve return follows merely from introducing the module. Specific existing card returns require their own composition. | +| Adding forces to one's own Homeworld | Revival is the general route. Advanced permits a paid return from another Homeworld; Basic retains its existing Guild/cross-shipping exceptions. No generic free board-to-reserve return follows merely from introducing the module. | | Emperor inter-Homeworld transfer | Uses movement, not another ordinary shipment. Preserve typed quantities and the combined-shipment exception. | | Allied Homeworld | Shipping there and occupying it are forbidden, including for the Ecaz pair. | | BG | No spiritual advisors may be sent to Homeworlds. BG fighters on a foreign Homeworld cannot flip to advisors. | diff --git a/docs/REINFORCEMENTS_RUNTIME.md b/docs/REINFORCEMENTS_RUNTIME.md index 0d820b75..15587256 100644 --- a/docs/REINFORCEMENTS_RUNTIME.md +++ b/docs/REINFORCEMENTS_RUNTIME.md @@ -16,6 +16,15 @@ fixes the exact card. Harass & Withdraw may share the plan under the Original or copied Stone Burner composes in clean selected Ix/CHOAM families and the existing native Advanced Occupy envelope. Other profile boundaries remain. +## Homeworld plan guide with chosen fees — 29 October 2026 + +**Prototyped / Partial.** Native Homeworld battle-plan completion now carries +its selected Reinforcements special-reserve count with the exact source allocation. +The [focused journey](../tests/reinforcements-homeworld-guide-choice.test.ts) +asks a real named-card and dial Truthtrance and seals a compliant plan whose +legal dial requires the nondefault fee. A Planetologist alternate role clears +both fee fields. Other promises and full battle acceptance remain open. + ## Chosen reserve mix — 29 October 2026 **Prototyped / Partial.** When sealing Reinforcements, its owner can select any diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a62a7ccd..89f2643a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,16 @@ # Rule decision index +## Advanced native Homeworld return — 29 October 2026 + +The user-authorized revised rulebook, physical p. 22 (printed p. 20), permits +all factions to ship physical forces between distinct Homeworlds. In Advanced, +this includes return from a foreign World to the faction's own; it is a general +route even if native Guild transport is canceled. Basic retains the existing +printed E3 own-World restriction and Guild exception. Separately priced rates, +physical custody, allied restrictions and already-open Guild permission windows +remain. Concealed No-Field return remains outside this physical-force slice. +[Scope](HOMEWORLD_RULES.md#advanced-native-homeworld-return--29-october-2026). + ## Great Maker reserve ride through Aftermath — 29 October 2026 The Great Maker reserve ride is treated as movement, so it may enter an Atomics diff --git a/game/engine.ts b/game/engine.ts index 37e3dfb0..9013e0f0 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -31921,7 +31921,11 @@ function findLegalBattlePlan( for (let source = 0; source < (nativeFee ? nativeSources?.length ?? 0 : 1); source++) { for (const n of numerical) { const candidate: Partial = { ...partial, ...n }; - if (nativeFee) candidate.reinforcementsSources = nativeSources![source]; + if (nativeFee) { + candidate.reinforcementsSources = nativeSources![source]; + candidate.reinforcementsElite = Object.values(candidate.reinforcementsSources) + .reduce((sum, forces) => sum + forces.elite, 0); + } if (!accepts(candidate)) continue; try { return validatePlan(g, p, candidate, promises); @@ -31931,6 +31935,7 @@ function findLegalBattlePlan( if (alternateSpecial) { candidate.planetologistSpecial = true; delete candidate.reinforcementsSources; + delete candidate.reinforcementsElite; try { return validatePlan(g, p, candidate, promises); } catch (error) { @@ -38416,9 +38421,12 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI } else continueHomeworldShipment(g, shipment); } function homeworldUsesGuildTransport(g: Game, - shipment: HomeworldShipmentIntent & {route?: 'arrakis'}): boolean { + shipment: HomeworldShipmentIntent & {route?: 'arrakis'; guildTransportEvent?: string}): boolean { + // The revised Advanced rules grant World-to-World transport to every + // faction. Preserve already-open native Guild permission windows in saves. return getPlayer(g, shipment.player).faction === 'guild' && - (shipment.route === 'arrakis' || shipment.destination === 'homeworld:guild'); + (shipment.route === 'arrakis' || shipment.destination === 'homeworld:guild' && + (!g.advanced || !!shipment.guildTransportEvent)); } function continueHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) { if (!openHomeworldTransportPermission(g, shipment) && !openHomeworldGuildRate(g, shipment)) diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts index 68d8b349..ad06b49c 100644 --- a/game/homeworld-shipment-options.ts +++ b/game/homeworld-shipment-options.ts @@ -147,7 +147,7 @@ export function homeworldShipmentChoice( return result; } if ( - view.guildTransportCanceled && + !view.advanced && view.guildTransportCanceled && me.faction === 'guild' && destination === 'homeworld:guild' ) { @@ -266,9 +266,9 @@ export function homeworldShipmentActions( const targets = worlds .filter( (world) => - (world.native !== me.id || me.faction === 'guild') && + (view.advanced || world.native !== me.id || me.faction === 'guild') && !( - view.guildTransportCanceled && + !view.advanced && view.guildTransportCanceled && me.faction === 'guild' && world.native === me.id ) && @@ -306,12 +306,8 @@ export function homeworldShipmentActions( sourceGroups.push(imperial); const actions: Action[] = []; for (const target of targets) { - if ( - target.world.native === me.id && - target.world.side === 'high' && - target.strength === 0 - ) - continue; + if (!view.advanced && target.world.native === me.id && + target.world.side === 'high' && target.strength === 0) continue; const required = Math.max( 1, target.strength + [0, 1, 2, 3][difficulty] - target.existing, diff --git a/game/homeworld-shipment.ts b/game/homeworld-shipment.ts index b56a772c..5763597b 100644 --- a/game/homeworld-shipment.ts +++ b/game/homeworld-shipment.ts @@ -35,9 +35,10 @@ function requireShipment( } /** E3 world-to-world transport: use the actual native or foreign source pool. - * Guild may also return a foreign garrison to its own world; other own-world - * exceptions need separately established permission. The caller owns timing, usage, - * funding, bank payment, responses and arrival effects. A purchased ordinary + * Basic preserves the printed own-World return restriction; the user-authorized + * Advanced revision (physical p.22) permits travel between distinct Homeworlds, + * including an actual foreign garrison returning to its own World. The caller + * owns timing, usage, funding, bank payment, responses and arrival effects. A purchased ordinary * Karama price takes precedence over the native Guild half rate or its * cancellation; an alliance alone does not grant either price. Independent * Nexus and Junction prices belong to their own grants. */ @@ -74,8 +75,8 @@ export function quoteHomeworldShipment( ); const native = context.players.find((p) => p.id === destination.native)!; requireShipment( - native.id !== player.id || player.faction === 'guild', - 'Shipment to your own Homeworld needs its specific return permission.', + native.id !== player.id || context.advanced || player.faction === 'guild', + 'In Basic play, shipment to your own Homeworld needs its specific return permission.', ); requireShipment( !(player.ally === native.id && native.ally === player.id), diff --git a/game/reference.ts b/game/reference.ts index 3efa4c27..507c09f7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -147,7 +147,7 @@ const phaseDetails = [ 'Native Guild cross/return permission has a separate unpaid response before native price. Preventing it preserves legal ordinary shipment or movement and does not remove half price. The conservative provisional denied scope is that original shipment opportunity; signed Cunning second opportunity is distinct. Native rate cancellation keeps the same paid units and approved funding, or releases an unaffordable attempt for a full-price replacement. Free Smuggler counters stay free; own/allied No-Field bills one marker. Immune Junction and independent card prices remain separate sources.', '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 moves one exact physical group across the planet or returns it to reserves. Permission and price are separate counter opportunities; common world-to-foreign-world shipment does not require Guild cross/return privilege. Purchased half price grants no new route. Ordinary movement still checks range, storm and stronghold occupancy.', + 'Guild transport moves one exact physical group across the planet or returns it to reserves. Permission and price are separate counter opportunities; Common World-to-World shipment does not require Guild cross/return privilege, including a return to the native World in Advanced. Purchased half price grants no new route. Ordinary movement still checks range, storm and stronghold occupancy.', 'You may pass through an ally’s territory on a legal route that ends elsewhere. Ordinary physical reserve shipments in both modes may enter allied territory before movement, then depart during that turn. Storm and stronghold capacity remain binding. Basic visibly labels its provisional no-exit policy: excess new visitors still present go to Tanks. Advanced follows supplied p15: all the ending player’s shared forces go to Tanks, including old forces, subject to advisor/Polar/Ecaz exemptions. Neither grants an extra move. Concealed No-Fields and optional arrivals remain separate.', 'Ecaz and its reciprocal ally may share territories and count as one faction toward stronghold capacity, without extra movement or storm/Homeworld exceptions. Their three-joint-stronghold victory and desert allocation are connected. Fremen/Ecaz cooccupation permits the Fremen special win in both Sietch Tabr and Habbanya Sietch; the Basic Habbanya allowance is a provisional interpretation. Other victory conditions and exceptional combined interactions remain separate.', '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.', @@ -5508,6 +5508,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ).join(' '), steps: [ 'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.', + 'Advanced permits a paid physical shipment from a different Homeworld back to your own, including either Emperor World. Guild may use this common route even if native cross/return transport is canceled; the Guild rate is separate. Basic keeps its existing Guild exception and own-World restriction. Arrakis and concealed No-Field return remain separate.', 'Clean World starts admit registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Skills or Nexus are not incidental prerequisites. Fresh setup includes original occupation qualification, bank/percentage benefits, native Tupile and Ecaz victory state alongside physical locations; existing played games are not retrofitted. Original module-specific profiles and complete-mode gates stay separate.', 'Fresh World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced, including mixed Ecaz/Moritani with E1/E2 natives. Neither Nexus, independent cards, one exact family nor a seated native from every selected deck is required. Original Tech3+/Advanced Strongholds2+ may remain. Existing no-card Nexus/Discovery and Semuta keep their separate limits. Actual hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Native Suk saves stay at that home; external Emperor returns retain original typed destinations. Nested locations gain no ordinary Stronghold effect or invented saved history.', 'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.', diff --git a/tests/homeworld-return-native-controls.test.tsx b/tests/homeworld-return-native-controls.test.tsx new file mode 100644 index 00000000..6088fd30 --- /dev/null +++ b/tests/homeworld-return-native-controls.test.tsx @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { GameView } from '../game/engine'; +import { HomeworldShipment } from '../components/homeworld-shipment'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody'; +import { homeworldPopulations } from '../game/homeworld-population'; + +/** Personalized public view: the Atreides player has an actual physical + * foreign garrison. Native and foreign pools are projected using the same + * Homeworld helpers as the existing shipment-control fixture. */ +function fixture(advanced: boolean): GameView { + const context = { advanced, players: [ + { id: 'a', faction: 'atreides' as const, reserves: 17, eliteReserves: 0, ally: null }, + { id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null }, + ] }; + const custody: HomeworldCustody = { salusa: null, + visitors: { 'homeworld:guild': { a: { normal: 3, elite: 0 } } } }; + const populations = homeworldPopulations(context, custody); + return { + me: 'a', active: 'a', advanced, status: 'playing', phase: 5, + homeworldShipment: { event: 'shipment:1:a', blocked: null }, + aid: { available: 0, pledged: 0 }, + players: context.players.map(player => ({ ...player, name: player.faction, + spice: player.id === 'a' ? 10 : undefined, shipped: false, + elites: { reserves: player.eliteReserves } })), + homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ + ...world, ...populations.find(row => row.location === world.id)!, + })) }, + } as unknown as GameView; +} + +function markup(game: GameView): string { + return renderToStaticMarkup(createElement(HomeworldShipment, { game, busy: false, act() {} })); +} +function destinationOptions(html: string): string { + const options = /