Support Advanced native Homeworld return
Verify and publish checkpoint container / container (push) Successful in 11m10s

This commit is contained in:
Quintine committed 2026-10-29 04:48:47 +11:00
1 parent 1cf90f1328
commit 89569e92a1
14 files changed
+429 -39

No files matched your search

+11 -8
View File
@@ -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<HomeworldShipmentSources>({});
const [allyPayment, setAllyPayment] = useState(0);
@@ -143,11 +144,13 @@ export function HomeworldShipment({
<p className="fine">
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.
</p>
<p className="fine">
+11
View File
@@ -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.
+21 -1
View File
@@ -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. |
+9
View File
@@ -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
+11
View File
@@ -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
+11 -3
View File
@@ -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<Plan> = { ...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))
+5 -9
View File
@@ -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,
+6 -5
View File
@@ -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),
+2 -1
View File
@@ -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.',
@@ -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 = /<select aria-label="Destination Homeworld"[^>]*>([\s\S]*?)<\/select>/.exec(html);
assert.ok(options, 'The native Homeworld shipment destination selector is present');
return options[1];
}
void test('Advanced personal shipment exposes the own Homeworld, source distinction and price', () => {
const game = fixture(true), before = structuredClone(game), html = markup(game);
const options = destinationOptions(html);
assert.match(options, /value="homeworld:atreides"[^>]*>Caladan · return to your Homeworld/);
assert.match(options, /value="homeworld:guild"/);
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
assert.match(html, /Native reserves and foreign garrisons have separate source pools/);
assert.match(html, /Pay one spice per force/);
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
{ 'homeworld:guild': { normal: 2, elite: 0 } });
assert.equal(quote.blocked, null);
assert.equal(quote.cost, 2);
assert.equal(quote.action?.destination, 'homeworld:atreides');
assert.deepEqual(game, before);
});
void test('Basic personal shipment retains the explicit native destination restriction', () => {
const game = fixture(false), html = markup(game);
const options = destinationOptions(html);
assert.doesNotMatch(options, /value="homeworld:atreides"/);
assert.match(options, /value="homeworld:guild"/);
assert.match(html, /Your own Homeworld cannot be a destination/);
const quote = homeworldShipmentChoice(game, 'homeworld:atreides',
{ 'homeworld:guild': { normal: 2, elite: 0 } });
assert.equal(quote.action, null);
assert.match(quote.blocked ?? '', /Basic play.*own Homeworld/);
});
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldShipmentActions, homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
const owner = (g: Game) => g.players.find(p => p.id === 'emperor')!;
const groups = (g: Game) => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
const forces = (g: Game, id: string) => groups(g).find(world => world.id === id)!.forces.emperor ?? {normal:0, elite:0};
const total = (g: Game) => {
const p = owner(g);
return p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) +
groups(g).filter(world => world.native !== p.id).reduce((sum, world) => {
const f = world.forces[p.id]; return sum + (f ? f.normal + f.elite : 0);
}, 0);
};
/** Advance genuine Homeworld setup; only the Shipment and Movement opportunity
* is staged. The foreign garrison is created through an actual shipment. */
function setup(advanced: boolean, actor: 'emperor' | 'guild' = 'emperor') {
let game = createGame('RETURNWORLD', newPlayer(actor, actor, actor), advanced);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
game = applyAction(game, actor, {type:'homeworlds', enabled:true});
for (const seat of game.players) game = applyAction(game, seat.id, {type:'ready'});
game = initializeHomeworldGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 80; step++) {
let next: Game | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(p => p.id === seat.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(game, seat.id, action); break; }
}
assert.ok(next, 'Native setup needs a legal next action'); game = next;
}
assert.equal(game.status, 'playing');
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const stageOpportunity = (state: Game) => {
Object.assign(state, {phase:5, phaseOpening:null, response:null, decision:null,
active:actor, order:state.players.map(p=>p.id),
movementRemaining:state.players.map(p=>p.id), ready:[], storm:18});
for (const p of state.players) {p.shipped=false; p.moved=0;}
};
stageOpportunity(game);
const initialReserves = game.players.find(p => p.id === actor)!.reserves;
const first = homeworldShipmentChoice(viewGame(game,actor),'homeworld:atreides',
{[`homeworld:${actor}`]:{normal:4,elite:0}});
assert.ok(first.action, first.blocked ?? 'The initial invasion must be legal');
game = applyAction(game,actor,first.action);
assert.deepEqual(groups(game).find(w => w.id === 'homeworld:atreides')!.forces[actor],{normal:4,elite:0});
assert.equal(game.players.find(p => p.id === actor)!.reserves,initialReserves - 4);
if (actor === 'emperor') assert.equal(total(game),20);
game.turn++;
stageOpportunity(game);
homeworldGameIntegrity(game);
return game;
}
for (const advanced of [false,true]) void test(`${advanced ? 'Advanced' : 'Basic'} foreign Homeworld return to native reserves`, () => {
const before = setup(advanced);
const source = {'homeworld:atreides':{normal:2,elite:0}};
const raw: Action = {type:'homeworldShip', event:viewGame(before,'emperor').homeworldShipment!.event,
destination:'homeworld:emperor', sources:source};
const offer = homeworldShipmentChoice(viewGame(before,'emperor'), 'homeworld:emperor', source);
if (!advanced) {
assert.equal(offer.action,null);
const snapshot = structuredClone(before);
assert.throws(() => applyAction(before,'emperor',raw));
assert.deepEqual(before,snapshot);
return;
}
assert.ok(offer.action, offer.blocked ?? 'Advanced return should be offered');
assert.equal(offer.cost,2);
const original = structuredClone(before);
let after = applyAction(before,'emperor',offer.action);
assert.deepEqual(before,original);
assert.equal(owner(after).spice,owner(before).spice-2);
assert.equal(owner(after).reserves,owner(before).reserves+2);
assert.deepEqual(forces(after,'homeworld:atreides'),{normal:2,elite:0});
assert.deepEqual(forces(after,'homeworld:emperor'),{normal:13,elite:0});
assert.equal(owner(after).shipped,true);
assert.equal(owner(after).moved,0);
assert.equal(total(after),20);
homeworldGameIntegrity(after);
assert.throws(() => applyAction(after,'emperor',raw), 'The used/stale shipment cannot be replayed');
after = applyAction(after,'emperor',{type:'endMovement'});
assert.notEqual(after.active,'emperor');
homeworldGameIntegrity(after);
});
void test('all bot profiles retain legal movement participation in the Advanced return opportunity', () => {
const game = setup(true);
for (const [level, difficulty] of DIFFICULTIES.entries()) {
const view = viewGame(game,'emperor'); view.players.find(p=>p.id==='emperor')!.bot=difficulty;
const action = botActions(view)[0]; assert.ok(action, `${difficulty} must have a legal action`);
assert.doesNotThrow(() => applyAction(game,'emperor',action));
const returned = homeworldShipmentActions(view,level).find(candidate =>
candidate.type === 'homeworldShip' && candidate.destination === 'homeworld:emperor');
assert.ok(returned, `${difficulty} must offer a legal native return`);
assert.doesNotThrow(() => applyAction(game,'emperor',returned));
}
});
for (const advanced of [false,true]) for (const canceled of [false,true])
void test(`${advanced ? 'Advanced' : 'Basic'} Guild native return ${canceled ? 'with canceled transport' : 'with native transport'}`, () => {
const game = setup(advanced,'guild');
if (canceled) game.guildTransportBlocked = {turn:game.turn,player:'guild',opportunity:'ordinary'};
const view = viewGame(game,'guild');
const source = {'homeworld:atreides':{normal:2,elite:0}};
const offer = homeworldShipmentChoice(view, 'homeworld:guild', source);
const raw: Action = {type:'homeworldShip',event:view.homeworldShipment!.event,
destination:'homeworld:guild',sources:source};
if (canceled && !advanced) {
assert.equal(offer.action,null);
const snapshot=structuredClone(game);
assert.throws(() => applyAction(game,'guild',raw));
assert.deepEqual(game,snapshot);
return;
}
assert.ok(offer.action,offer.blocked ?? 'Guild return should be offered');
assert.equal(offer.cost,1, 'Native Guild rate remains distinct from transport permission');
const before = structuredClone(game);
const result = applyAction(game,'guild',offer.action);
assert.deepEqual(game,before);
const guild = result.players.find(p=>p.id==='guild')!;
assert.equal(guild.reserves,game.players.find(p=>p.id==='guild')!.reserves+2);
assert.equal(guild.spice,game.players.find(p=>p.id==='guild')!.spice-1);
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
assert.equal(guild.shipped,true);
homeworldGameIntegrity(result);
});
void test('an already-open native Guild Homeworld return response survives the Advanced cutover', () => {
// The response is obtained through the native Basic declaration, then staged
// into an otherwise real Advanced game. Rebind only its source event to the
// Advanced context; no counters, spice or physical cards are manufactured.
const withKarama = (g: Game) => {
const card = g.deck.find(c => c.effect === 'karama'); assert.ok(card);
g.deck.splice(g.deck.indexOf(card),1);
g.players.find(p => p.id === 'atreides')!.hand.push(card);
return card.id;
};
const basic = setup(false,'guild');
withKarama(basic);
const offer = homeworldShipmentChoice(viewGame(basic,'guild'), 'homeworld:guild',
{'homeworld:atreides':{normal:2,elite:0}});
assert.ok(offer.action,offer.blocked ?? 'The original native return must be available');
const declared = applyAction(basic,'guild',offer.action);
assert.equal(declared.response?.kind,'guildTransportPermission');
assert.ok(declared.pendingHomeworldShipment?.guildTransportEvent);
const advanced = setup(true,'guild');
withKarama(advanced);
const event = viewGame(advanced,'guild').homeworldShipment!.event;
advanced.pendingHomeworldShipment = structuredClone(declared.pendingHomeworldShipment!);
advanced.pendingHomeworldShipment.event = event;
advanced.response = structuredClone(declared.response!);
let result = JSON.parse(JSON.stringify(advanced)) as Game;
for (let n=0; result.response && n<20; n++) {
const seat = result.players.find(p => !result.response!.passed.includes(p.id));
assert.ok(seat, 'The saved native response must be passable');
result = applyAction(result,seat.id,{type:'passResponse'});
}
assert.equal(result.response,null);
assert.equal(result.pendingHomeworldShipment,null);
assert.equal(result.players.find(p=>p.id==='guild')!.reserves,
advanced.players.find(p=>p.id==='guild')!.reserves+2);
assert.equal(result.players.find(p=>p.id==='guild')!.spice,
advanced.players.find(p=>p.id==='guild')!.spice-1);
assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0});
homeworldGameIntegrity(result);
});
+6 -6
View File
@@ -105,13 +105,13 @@ void test('Homeworld public shipment quote preserves exact native and foreign so
assert.deepEqual(view, before);
});
void test('public shipment options reject own, allied, depleted, mixed foreign, stale and unfunded orders', () => {
void test('public shipment options allow Advanced native return and reject allied, depleted, mixed foreign, stale and unfunded orders', () => {
const { view } = fixture();
const good = { 'homeworld:emperor': { normal: 2, elite: 0 } };
assert.equal(
homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good).action,
null,
);
const nativeReturn = homeworldShipmentChoice(view, 'homeworld:emperor:salusa', good);
assert.equal(nativeReturn.action?.destination, 'homeworld:emperor:salusa');
assert.equal(nativeReturn.cost, 2);
assert.equal(homeworldShipmentChoice(view, 'homeworld:emperor', good).action, null);
assert.equal(
homeworldShipmentChoice(view, 'homeworld:atreides', {
'homeworld:guild': { normal: 5, elite: 0 },
@@ -439,7 +439,7 @@ void test('Guild return controls and policy preserve foreign source custody and
busy: false,
}),
);
assert.match(html, /Guild may also return a foreign garrison to Junction/);
assert.match(html, /You may return a foreign garrison to your own Homeworld/);
assert.match(html, /Junction · return to your Homeworld/);
assert.match(html, /Guild pays one spice per two forces, rounded up/);
assert.doesNotMatch(html, /Your own Homeworld cannot/);
+26 -6
View File
@@ -248,14 +248,34 @@ void test('Advanced Emperor may combine its two native origins and retains preci
);
});
void test('own and mutual ally destinations are rejected, including either Emperor world', () => {
void test('Advanced may return an actual foreign garrison to either native Emperor World at the normal rate', () => {
const { context, custody } = fixture();
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
rejected(context, custody, {
...invasion,
destination,
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa']) {
const before = structuredClone({ context, custody });
const result = quote(context, custody, {
player: 'e', destination,
sources: { 'homeworld:atreides': { normal: 4, elite: 1 } },
});
assert.equal(result.amount, 5);
assert.equal(result.cost, 5);
assert.equal(result.players[0].reserves, 20);
assert.equal(result.players[0].eliteReserves, 5);
assert.equal(result.state.visitors['homeworld:atreides'], undefined);
const next = { ...context, players: result.players.map(p => ({ ...p, ally: null })) };
assert.deepEqual(totals(next, result.state), totals(context, custody));
assert.deepEqual({ context, custody }, before);
}
});
void test('Basic preserves the native return restriction and all modes reject a mutual ally destination', () => {
const { context, custody } = fixture();
const basic = { ...context, advanced: false };
const basicCustody = { ...custody, salusa: null };
rejected(basic, basicCustody, {
...invasion,
destination: 'homeworld:emperor',
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
});
context.players[0].ally = 'a';
context.players[1].ally = 'e';
rejected(context, custody, {
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
createReinforcementsHomeworldFixture,
reinforcementsHomeworldPool as pool,
reinforcementsHomeworldPlayer as player,
REINFORCEMENTS_KAITAIN as K,
REINFORCEMENTS_SALUSA as S,
REINFORCEMENTS_CARD,
} from './fixture-reinforcements-homeworld';
/** The original native setup moves one actual ordinary counter to Salusa.
* Its remaining native troops, the real invading force and all cards are
* conserved; choosing the special reserve fee leaves two extra normal
* counters on the native battlefield compared with the legacy fee. */
void test('a named Reinforcements promise finds a necessary special-reserve Homeworld fee', () => {
const f = createReinforcementsHomeworldFixture({
advanced: true, role: 'native', nativeTransfer: 1, slot: 'weapon',
});
const owner = player(f.game, f.owner);
assert.deepEqual(pool(f.game, f.owner, K), { normal: 14, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, S), { normal: 1, elite: 5 });
const support = Math.min(owner.spice, 14);
const wantedDial = (14 + support) / 2;
const legacyMax = (12 + Math.min(owner.spice, 12)) / 2;
assert.ok(wantedDial > legacyMax, 'The legal plan must require preserving normal counters at Kaitain');
const mixed = {
[K]: { normal: 0, elite: 0 },
[S]: { normal: 1, elite: 2 },
};
const manual = { ...f.planActions[0].action, type: 'battlePlan',
weapon: REINFORCEMENTS_CARD, defense: null, dial: wantedDial, support,
reinforcementsSources: mixed, reinforcementsElite: 2 };
assert.doesNotThrow(() => applyAction(f.game, f.owner, manual),
'The actual native plan establishes the chosen physical fee is legal');
let game = structuredClone(f.game);
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
takeHarassCard(game, f.observer, truth.id);
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
while (game.truthtrance!.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'truthPass' });
}
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
{ kind: 'weapon', name: 'Reinforcements' },
{ kind: 'dial', compare: 'eq', value: wantedDial },
] },
} });
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'),
'The real special-reserve plan supports a truthful Yes');
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
assert.equal(witness.dial, wantedDial);
assert.ok((witness.reinforcementsElite ?? 0) >= 2);
assert.equal(Object.values(witness.reinforcementsSources!).reduce((sum, group) => sum + group.elite, 0),
witness.reinforcementsElite);
const sealed = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
assert.equal(sealed.battle!.plans[f.owner].dial, wantedDial);
assert.equal(sealed.battle!.plans[f.owner].reinforcementsElite, witness.reinforcementsElite);
});