From ff2826b980fdeefe20a9e760d12ce19a22f57fe8 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 09:22:27 +1100
Subject: [PATCH] Allow individual revived Fedaykin placement
---
components/homeworld-revival-deployment.tsx | 69 ++++--
docs/CURRENT_STATUS.md | 35 +++
docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md | 64 +++++
docs/IMPLEMENTATION_STATUS.md | 33 +++
docs/RULE_DECISIONS.md | 16 +-
game/ambassador-phase.ts | 4 +-
game/arrival-overlap.ts | 3 +-
game/engine.ts | 123 +++++++---
game/homeworld-cards.ts | 2 +-
game/homeworld-revival-deployment-options.ts | 51 +++-
game/homeworld-revival-destinations.ts | 9 +-
game/homeworld-revival-return.ts | 202 +++++++++++++--
game/karama-movement-cancellation.ts | 8 +-
game/reference.ts | 14 +-
game/revival-terror-entry.ts | 19 +-
game/spiritual-advisor.ts | 3 +-
tests/fixture-revival-arrival-reactions.ts | 7 +-
tests/fixture-southern-allocation.ts | 191 +++++++++++++++
...omeworld-revival-ambassador-engine.test.ts | 18 +-
...eworld-revival-deployment-controls.test.ts | 111 ++++++++-
...omeworld-revival-deployment-engine.test.ts | 21 +-
tests/homeworld-revival-destinations.test.ts | 35 +++
tests/homeworld-revival-return.test.ts | 124 ++++++++++
tests/revival-terror-entry.test.ts | 32 ++-
tests/southern-allocation-runtime.test.ts | 231 ++++++++++++++++++
25 files changed, 1301 insertions(+), 124 deletions(-)
create mode 100644 tests/fixture-southern-allocation.ts
create mode 100644 tests/southern-allocation-runtime.test.ts
diff --git a/components/homeworld-revival-deployment.tsx b/components/homeworld-revival-deployment.tsx
index 8b4b4596..006b5b9d 100644
--- a/components/homeworld-revival-deployment.tsx
+++ b/components/homeworld-revival-deployment.tsx
@@ -5,6 +5,7 @@ import type { Action, GameView } from '@/game/engine';
import {
homeworldRevivalDeploymentCanAct,
homeworldRevivalDeploymentChoice,
+ homeworldRevivalDeploymentQuantities,
} from '@/game/homeworld-revival-deployment-options';
import { Button } from './ui/button';
@@ -37,9 +38,11 @@ function DeploymentChoice({ game, act, busy }: Props) {
const [destination, setDestination] = useState(
offer.destinations.find((target) => !target.blocked)?.id ?? '',
);
+ const [amount, setAmount] = useState(offer.allocation !== undefined ? 1 : offer.normal + offer.elite);
const canAct = homeworldRevivalDeploymentCanAct(game);
- const choice = homeworldRevivalDeploymentChoice(game, destination);
- const amount = offer.normal + offer.elite;
+ const quantities = homeworldRevivalDeploymentQuantities(game);
+ const choice = homeworldRevivalDeploymentChoice(game, destination, amount);
+ const allocation = offer.allocation;
return (
@@ -47,12 +50,21 @@ function DeploymentChoice({ game, act, busy }: Props) {
? 'Place revived Fedaykin'
: 'Place revived forces'}
-
+ {allocation !== undefined ? <>
+
Choose how many remaining starred forces to place now. After this placement's native arrival effects finish,
+ you may place another portion at a different legal destination, or leave all remaining stars in reserves.
+ The eligible batch is fixed by this one original revival.
+ Provisional per-star interpretation: Southern's face says “forces”;
+ the individual allocation has no publisher clarification.
+ {allocation &&
+ {allocation.remaining} starred remaining of {allocation.total} · {allocation.placed} placed · {allocation.kept} kept in reserves
+
}
+ > :
Place the entire eligible revived group in one destination, or leave it
in reserves.
-
+
}
- {offer.normal} ordinary · {offer.elite} starred · {amount} total
+ {offer.normal} ordinary · {offer.elite} starred · {offer.normal + offer.elite} total
{offer.kind === 'fedaykin'
@@ -98,32 +110,57 @@ function DeploymentChoice({ game, act, busy }: Props) {
))}
+ {allocation !== undefined &&
+ Starred forces to place now
+ {
+ const next = Number(event.target.value);
+ if (quantities.includes(next)) setAmount(next);
+ }}>
+ {quantities.length === 0 && No current placement quantity }
+ {quantities.map(quantity => {quantity} )}
+
+ }
{choice.blocked && (
{choice.blocked}
)}
-
+
+
+ );
+}
+
+/** Submit the current native choice only; the engine owns arrival and allocation. */
+export function HomeworldRevivalDeploymentButtons(props: Props & { destination: string; amount: number }) {
+ const { game, destination, amount } = props;
+ const offer = game.homeworldRevivalDeployment, event = offer?.event, actor = game.me, act = props.act;
+ const signature = JSON.stringify(offer);
+ const current = () => !props.busy && props.game === game && props.act === act && game.me === actor &&
+ !!offer && game.homeworldRevivalDeployment === offer && offer.event === event && JSON.stringify(offer) === signature &&
+ homeworldRevivalDeploymentCanAct(game);
+ const choice = homeworldRevivalDeploymentChoice(game, destination, amount);
+ return
{
- if (choice.action) act(choice.action);
+ if (!current()) return;
+ const fresh = homeworldRevivalDeploymentChoice(game, destination, amount);
+ if (fresh.action) props.act(fresh.action);
}}
>
Place {amount} revived {amount === 1 ? 'force' : 'forces'}
- act({ type: 'decision', event: offer.event, decline: true })
- }
+ disabled={!current()}
+ onClick={() => { if (current()) props.act({ type: 'decision', event, decline: true }); }}
>
- Leave in reserves
+ {offer?.allocation !== undefined ? 'Leave all remaining in reserves' : 'Leave in reserves'}
-
-
- );
+
;
}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 519ff62e..d2dddbdb 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,41 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Southern individual revival allocation — 27 October. Prototyped / Partial.**
+Fresh Basic multi-Fedaykin returns permit separate portions/destinations or
+keeping the remainder in reserves. One original grant/payment/income stays
+fixed; each portion finishes its native arrival before the next choice.
+Controls show original/remaining/placed/kept stars. The per-star interpretation
+is visibly provisional under the 14 October instruction, not publisher
+clarification or a claimed answer to the earlier splitting question.
+
+Frozen **24 affected files, 256/256**, types/lint/build and seven genuine
+native programmes pass. Actual Basic Homeworld setup, a paid Emperor invasion,
+real battle/starred casualties and the next Storm earn the original revival.
+Runtime includes 21 GameTable SSR renders, 12 button-group SSR renders and
+12 actual quantity/destination/reserves callbacks. Source/further counts
+(414/20, aggregate across programmes) derive from recorded action arrays.
+No played state, Tank, phase or upcoming revival receipt is installed.
+
+Twelve new native cases cover split, partial/whole/declined placement, actual
+two-star entitlement, stale/overdraw/other-owner rejection, pending ownership,
+one deferred Tleilax payment, Ecaz interruption and four legal profiles.
+Seven helper and four control cases cover exact subsets, fixed grants, current
+children and callback boundaries. Early feedback repairs new fixture shipment/
+leader selection and the one-spice normalized income oracle. Related feedback
+updates whole-only expectations and native Advanced source-order drivers.
+
+The existing local server returned HTTP200; no restart was needed. No HTTP/storage boundary,
+saved-game/database or deployed operation changes. This is local native/SSR/
+callback evidence, not browser hydration, complete modes or final acceptance.
+Wider combinations and final rules/recovery/privacy/deployed proof remain open.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-southern-allocation-proof-v2/`;
+journeys/HTML: adjacent `20261027-southern-allocation-runtime/run/`.
+[Contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#individual-southern-allocation--27-october-2026).
+The bounded classic/expansion scoping pass found usable independent first
+versions in sampled functions; it does not certify all Basic or Advanced rules.
+
**Truthtrance / printed flight and pre-leader cards — 27 October. Prototyped / Partial.**
Ornithopter counts its accepted initial physical declaration, with actual
Movement-end closure and no credit for later groups/disposal. Residual Poison
diff --git a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md
index 09ae4132..49de8ff2 100644
--- a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md
+++ b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md
@@ -2,6 +2,70 @@
Primary-source audit, 10 September 2026. This is a source and implementation-boundary contract, not a runtime completion claim. The coordinator has already asked about threshold restoration and splitting several revived Fedaykin; those answers remain pending.
+## Individual Southern allocation — 27 October 2026
+
+**Prototyped / Partial.** Under the 14 October recommended-option instruction,
+new Basic returns of two or three eligible Fedaykin use a visibly provisional
+per-star interpretation. The photographed publisher Southern high face uses
+plural forces; neither that face nor the supplied revised rules explicitly
+settles splitting or a strict partial placement. This policy is not a publisher
+clarification or an answer to the earlier user question. The supplied revision,
+physical22, delegates Homeworld abilities to their cards; physical32 retains
+the Advanced one-Fedaykin revival limit. The original component provenance is
+recorded [below](#printed-effects-and-provenance).
+
+Complete the original revival into reserves once, then fix its eligible new
+stars and resulting-population entitlement. Choose one through all remaining
+stars and a currently legal territory already containing Fremen forces. Finish
+that portion's native arrival before offering a fresh portion, which may use
+another destination. Leaving the remainder in reserves ends the allocation.
+Destinations are quoted against the current board; old reserves, ordinary
+forces and a new free allowance cannot join the original batch. No second
+revival, payment or shipment/movement allowance is introduced. Restore the
+original deferred income once, after every portion and child finishes.
+
+The version2 return retains its immutable original root and grant. Each portion
+has its own event, exact starred subset, destination and ordinary arrival
+history. Ambassador and Terror consumers bind the current child rather than
+the aggregate root. The next choice cannot precede an unfinished arrival.
+Public controls show the original total, remaining, placed and kept counts;
+all four profiles have the same legal fallback without strategic tuning.
+Single-star/Advanced returns, Tleilax's whole free group and existing
+unversioned/version1 returns keep their prior procedure. The selected storm/HMS
+routes and existing source-specific arrival policies below remain applicable.
+
+Focused evidence paths: [return](../game/homeworld-revival-return.ts),
+[destinations](../game/homeworld-revival-destinations.ts),
+[controls](../components/homeworld-revival-deployment.tsx),
+[native source](../tests/fixture-southern-allocation.ts),
+[native cases](../tests/southern-allocation-runtime.test.ts).
+Frozen **24 affected files, 256/256**, types/lint/build and seven genuine
+native programmes pass. Actual Basic Homeworld setup, a paid Emperor invasion,
+real battle/starred casualties and the next Storm earn the original revival.
+Runtime includes 21 GameTable SSR renders, 12 button-group SSR renders and
+12 actual quantity/destination/reserves callbacks. Source/further counts
+(414/20, aggregate across programmes) derive from recorded action arrays.
+No played state, Tank, phase or upcoming revival receipt is installed.
+
+Twelve new native cases cover split, partial/whole/declined placement, actual
+two-star entitlement, stale/overdraw/other-owner rejection, pending ownership,
+one deferred Tleilax payment, Ecaz interruption and four legal profiles.
+Seven helper and four control cases cover exact subsets, fixed grants, current
+children and callback boundaries. Early feedback repairs new fixture shipment/
+leader selection and the one-spice normalized income oracle. Related feedback
+updates whole-only expectations and native Advanced source-order drivers.
+
+The existing local server returned HTTP200; no restart was needed. No HTTP/storage boundary,
+saved-game/database or deployed operation changes. This is local native/SSR/
+callback evidence, not browser hydration, complete modes or final acceptance.
+Wider combinations and final rules/recovery/privacy/deployed proof remain open.
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-southern-allocation-proof-v2/`;
+journeys/HTML: adjacent `20261027-southern-allocation-runtime/run/`.
+
+The dated audit's whole-group boundary below records the earlier common
+subset; this explicit provisional policy supersedes it only for fresh Basic
+multi-Fedaykin allocation. It does not resolve unrelated pending questions.
+
## Selected storm and HMS route policy — 25 October 2026
Use the existing out-of-storm destinations and native HMS entry route, under
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 839b0823..80a9fb29 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,38 @@
# Dune implementation status
+## 27 October 2026 — Individual Southern revival allocation
+
+Prototyped / Partial. Fresh Basic multi-Fedaykin roots retain one exact revival
+and income while offering sequential starred portions, current destinations
+and remaining-reserves disposition. Native arrival ownership binds each child;
+Advanced, Tleilax and existing whole-group returns retain their procedure.
+The per-star interpretation is explicit provisional composition, not a located
+publisher clarification or an inferred user answer. No strategic AI tuning.
+
+Frozen **24 affected files, 256/256**, types/lint/build and seven genuine
+native programmes pass. Actual Basic Homeworld setup, a paid Emperor invasion,
+real battle/starred casualties and the next Storm earn the original revival.
+Runtime includes 21 GameTable SSR renders, 12 button-group SSR renders and
+12 actual quantity/destination/reserves callbacks. Source/further counts
+(414/20, aggregate across programmes) derive from recorded action arrays.
+No played state, Tank, phase or upcoming revival receipt is installed.
+
+Twelve new native cases cover split, partial/whole/declined placement, actual
+two-star entitlement, stale/overdraw/other-owner rejection, pending ownership,
+one deferred Tleilax payment, Ecaz interruption and four legal profiles.
+Seven helper and four control cases cover exact subsets, fixed grants, current
+children and callback boundaries. Early feedback repairs new fixture shipment/
+leader selection and the one-spice normalized income oracle. Related feedback
+updates whole-only expectations and native Advanced source-order drivers.
+
+The existing local server returned HTTP200; no restart was needed. No HTTP/storage boundary,
+saved-game/database or deployed operation changes. This is local native/SSR/
+callback evidence, not browser hydration, complete modes or final acceptance.
+Wider combinations and final rules/recovery/privacy/deployed proof remain open.
+
+[Contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#individual-southern-allocation--27-october-2026).
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-southern-allocation-proof-v2/`.
+
## 27 October 2026 — Printed flight and pre-leader commitments
Prototyped / Partial. First native Ornithopter declaration and actual Residual
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index cea4f59e..fc96cd78 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,19 @@
# Rule decision index
+## Individual Southern revival allocation — 27 October 2026
+
+Under the 14 October recommended-option authority, fresh Basic multi-Fedaykin
+returns provisionally permit each newly revived star to remain in reserves or
+join a current legal own-force territory. Hold one original resulting-population
+grant and revival/income; each exact subset completes its native arrival before
+the next fresh choice. The publisher face uses plural forces but does not
+explicitly settle partial/split placement. Supplied physical22 refers abilities
+to the cards and physical32 retains the Advanced one-star revival limit.
+This is selected provisional composition, not publisher clarification or a
+claimed answer to the earlier user question. Tleilax, Advanced and existing
+unversioned/version1 returns retain their previous procedure.
+[Contract and provenance](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#individual-southern-allocation--27-october-2026).
+
## Truthtrance printed flight/pre-leader use — 27 October 2026
Prototyped / Partial. Ornithopter counts accepted initial physical placement in
@@ -2136,7 +2150,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Repeat Karama on a canceled compulsory cache lot | The earlier runtime observation is superseded by current source: cache cancellation requires an uncanceled declaration with no dealt pool; completion marks canceled and advances to normal. A second original cache cancellation is unavailable. A later Karama may target a different response, including Atreides inspection; no blanket second-card prohibition or publisher repeat ruling is inferred. The frozen before-cache count and independent sale reduction remain. | [Richese auction source](RICHESE_AUCTION_RULES.md), [current cancellation-cost source](SEMUTA_SOURCE_UPDATE_20260907.md#richese-cancellation-costs--25-october-2026) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) |
-| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
+| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged. Fresh Basic multi-Fedaykin now uses the separately selected provisional per-star allocation above, without claiming a publisher or user answer to splitting. Tleilax whole-group, storm/HMS routes, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. The later Terror continuation retains the original return, ordered Ambassador opportunity and actual Sneak children; complete combinations remain unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents |
diff --git a/game/ambassador-phase.ts b/game/ambassador-phase.ts
index 7b98e96b..1da1551c 100644
--- a/game/ambassador-phase.ts
+++ b/game/ambassador-phase.ts
@@ -1,7 +1,7 @@
import type { Game } from './engine';
import { location } from './board';
import { validAmbassadorResume } from './ambassador-resume';
-import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
+import { currentHomeworldRevivalArrival, validateHomeworldRevivalReturn } from './homeworld-revival-return';
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
import {
validateHomeworldArrival,
@@ -29,7 +29,7 @@ export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
tag: HomeworldArrivalTag;
frame: HomeworldArrivalFrame | undefined;
}[] = [
- { tag: 'revivalEvent', frame: g.homeworldRevivalReturn },
+ { tag: 'revivalEvent', frame: currentHomeworldRevivalArrival(g.homeworldRevivalReturn) ?? undefined },
{ tag: 'victoryEvent', frame: g.homeworldVictoryReinforcement },
];
const matching = candidates.filter(
diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts
index 7afac31e..b055c175 100644
--- a/game/arrival-overlap.ts
+++ b/game/arrival-overlap.ts
@@ -1,4 +1,5 @@
import type { Game } from './engine';
+import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
@@ -47,7 +48,7 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador');
- const arrival = g.homeworldRevivalReturn?.ambassadors?.find(record => record.event === entry.event);
+ const arrival = currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.find(record => record.event === entry.event);
return !!overlap && entry.stage === 'cards' && entry.resume === 'none' &&
entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz &&
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
diff --git a/game/engine.ts b/game/engine.ts
index ff90a15b..35669b9f 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -196,6 +196,14 @@ import {
} from './homeworld-revival-deployment';
import {
makeHomeworldRevivalReturn,
+ makeSouthernRevivalAllocation,
+ southernRevivalRemaining,
+ southernRevivalSummary,
+ currentHomeworldRevivalArrival,
+ currentHomeworldRevivalGroup,
+ openSouthernRevivalChoice,
+ commitSouthernRevivalChoice,
+ completeSouthernRevivalChild,
makeHomeworldRevivalProgress,
type HomeworldRevivalProgress,
validateHomeworldRevivalReturn,
@@ -21701,7 +21709,7 @@ function openAmbassadorEntry(
};
g.decision = { kind: 'ecazAmbassador', player: owner.id };
if (revivalArrival) {
- const frame = g.homeworldRevivalReturn;
+ const frame = currentHomeworldRevivalArrival(g.homeworldRevivalReturn);
requireRule(frame?.stage === 'arrival' && frame.event === revivalArrival.event,
'This entry has lost its original revived group.');
g.pendingAmbassador.revivalEvent = frame.event;
@@ -21807,7 +21815,7 @@ function openTerritoryEntry(
function finishAmbassador(g: Game, deferResume = false) {
const pending = g.pendingAmbassador!;
if (pending.revivalEvent !== undefined || pending.victoryEvent !== undefined) {
- const arrival = pending.revivalEvent !== undefined ? g.homeworldRevivalReturn : g.homeworldVictoryReinforcement;
+ const arrival = pending.revivalEvent !== undefined ? currentHomeworldRevivalArrival(g.homeworldRevivalReturn) : g.homeworldVictoryReinforcement;
requireRule(arrival && ambassadorPhaseAllowed(g),
'This Ambassador has lost its original Homeworld arrival completion.');
completeHomeworldArrivalAmbassador(arrival, pending.event);
@@ -21884,7 +21892,7 @@ function currentFremenAmbassador(g: Game, event?: string, completed = false) {
function ambassadorRelocationIntegrity(g: Game) {
const entry = g.pendingAmbassador;
if (entry && (entry.revivalEvent !== undefined || entry.victoryEvent !== undefined ||
- g.homeworldRevivalReturn?.ambassadors?.some((record) => record.event === entry.event) ||
+ currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.some((record) => record.event === entry.event) ||
g.homeworldVictoryReinforcement?.ambassadors?.some((record) => record.event === entry.event)))
requireRule(ambassadorPhaseAllowed(g),
'This Ambassador has lost its original Homeworld revival arrival.');
@@ -22754,7 +22762,7 @@ function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
const parent = g.pendingAmbassador!;
const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' :
parent.victoryEvent !== undefined ? 'victoryEvent' : undefined;
- const arrival = arrivalTag === 'revivalEvent' ? g.homeworldRevivalReturn :
+ const arrival = arrivalTag === 'revivalEvent' ? currentHomeworldRevivalArrival(g.homeworldRevivalReturn) :
arrivalTag === 'victoryEvent' ? g.homeworldVictoryReinforcement : undefined;
if (arrivalTag !== undefined)
requireRule(arrival && ambassadorPhaseAllowed(g),
@@ -32441,7 +32449,9 @@ function stageHomeworldRevivalReturn(
grant: ReturnType, card?: string,
) {
if (!grant) return;
- g.homeworldRevivalReturn = homeworldRule(() => makeHomeworldRevivalReturn({
+ const makeReturn = !g.advanced && grant.kind === 'fedaykin' && grant.elite > 1
+ ? makeSouthernRevivalAllocation : makeHomeworldRevivalReturn;
+ g.homeworldRevivalReturn = homeworldRule(() => makeReturn({
event: crypto.randomUUID(), turn: g.turn, phase: g.phase, player: p.id,
source, group, quote: grant, ...(card ? { card } : {}),
}));
@@ -32449,7 +32459,7 @@ function stageHomeworldRevivalReturn(
}
function stampHomeworldRevivalProgress(g: Game) {
const frame = g.homeworldRevivalReturn!;
- if (frame.progressVersion === 1)
+ if (frame.progressVersion === 1 || frame.progressVersion === 2)
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
}
function homeworldRevivalReturnIntegrity(g: Game) {
@@ -32461,20 +32471,49 @@ function homeworldRevivalReturnIntegrity(g: Game) {
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'homeworldRevivalDeployment' ? [context.decision] : []);
+ const arrival = currentHomeworldRevivalArrival(frame);
requireRule(decisions.every((decision) => frame?.stage === 'choice' &&
- decision.player === frame.player && decision.event === frame.event),
+ decision.player === frame.player && decision.event === arrival?.event),
'The revival placement decision has lost its original returned group.');
if (frame?.stage === 'choice')
requireRule(decisions.length > 0, 'The returned group has lost its owned placement choice.');
}
function completeHomeworldRevivalReturn(g: Game) {
const frame = g.homeworldRevivalReturn!;
- requireRule(!frame.ambassadors?.some((entry) => !entry.completed),
+ const arrival = currentHomeworldRevivalArrival(frame);
+ requireRule(arrival && !arrival.ambassadors?.some((entry) => !entry.completed),
'Finish the revived group’s Ambassador opportunity before its original revival income.');
- frame.stage = 'complete';
+ if (frame.progressVersion === 2) {
+ homeworldRule(() => completeSouthernRevivalChild(frame, arrival.event,
+ southernRevivalRemaining(frame) > 0 ? crypto.randomUUID() : undefined));
+ stampHomeworldRevivalProgress(g);
+ if (frame.stage === 'choice') {
+ openHomeworldRevivalDecision(g);
+ return;
+ }
+ } else frame.stage = 'complete';
stampHomeworldRevivalProgress(g);
g.response = structuredClone(frame.resumeResponse ?? null);
}
+/** Every portion waits for its genuine arrival children before a fresh choice.
+ * The original revival grant and deferred income are never issued again. */
+function openHomeworldRevivalDecision(g: Game) {
+ const frame = g.homeworldRevivalReturn!, arrival = currentHomeworldRevivalArrival(frame);
+ requireRule(frame.stage === 'choice' && arrival, 'Retain the current revival placement choice.');
+ const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote));
+ if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) {
+ if (frame.progressVersion === 2) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
+ { amount: southernRevivalRemaining(frame), destination: 'decline' }));
+ else {
+ frame.destination = 'decline'; frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ }
+ completeHomeworldRevivalReturn(g);
+ log(g, `${getPlayer(g, frame.player).name} left the remaining revived group in reserves because no placement destination is available.`);
+ return;
+ }
+ g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: arrival.event };
+}
function resumeHomeworldRevivalReturn(g: Game) {
const frame = g.homeworldRevivalReturn;
if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
@@ -32493,57 +32532,61 @@ function resumeHomeworldRevivalReturn(g: Game) {
frame.resumeResponse = structuredClone(g.response);
frame.resumeSignature = homeworldRevivalResumeSignature(frame.resumeResponse);
g.response = null;
- frame.stage = 'choice';
+ if (frame.progressVersion === 2) homeworldRule(() => openSouthernRevivalChoice(frame, crypto.randomUUID()));
+ else frame.stage = 'choice';
stampHomeworldRevivalProgress(g);
- const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote));
- if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) {
- frame.destination = 'decline';
- frame.ambassadors = [];
- frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
- completeHomeworldRevivalReturn(g);
- log(g, `${getPlayer(g, frame.player).name} left the revived group in reserves because no placement destination is available.`);
- return;
- }
- g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: frame.event };
+ openHomeworldRevivalDecision(g);
}
function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
- const frame = g.homeworldRevivalReturn;
- requireRule(frame?.stage === 'choice' && frame.player === p.id && action.event === frame.event,
+ const frame = g.homeworldRevivalReturn, arrival = currentHomeworldRevivalArrival(frame);
+ requireRule(frame?.stage === 'choice' && arrival && frame.player === p.id && action.event === arrival.event,
'Choose the current returned group’s placement.');
+ const allocation = frame.progressVersion === 2;
+ if (allocation) requireRule(Object.keys(action).every(key => ['type', 'event', 'decline', 'destination', 'amount'].includes(key)),
+ 'Choose only the current returned stars, destination or remaining-reserve option.');
if (action.decline === true) {
requireRule(action.destination === undefined && action.amount === undefined,
'Leaving the group in reserves does not select a destination or quantity.');
- frame.destination = 'decline';
- frame.ambassadors = [];
- frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ if (allocation) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
+ { amount: southernRevivalRemaining(frame), destination: 'decline' }));
+ else {
+ frame.destination = 'decline'; frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ }
completeHomeworldRevivalReturn(g);
log(g, `${p.name} left the newly revived group in reserves.`);
return;
}
- const amount = frame.quote.normal + frame.quote.elite;
- requireRule((action.amount === undefined || action.amount === amount) && typeof action.destination === 'string',
- 'Place the entire eligible revived group in one destination.');
- const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string));
+ const amount = allocation ? action.amount === undefined ? 1 : integer(action.amount, 1, southernRevivalRemaining(frame), 'Revived Fedaykin')
+ : frame.quote.normal + frame.quote.elite;
+ requireRule((allocation || action.amount === undefined || action.amount === amount) && typeof action.destination === 'string',
+ allocation ? 'Choose a legal destination for the selected revived stars.' : 'Place the entire eligible revived group in one destination.');
+ const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string,
+ allocation ? amount : undefined));
+ if (allocation) homeworldRule(() => commitSouthernRevivalChoice(frame, arrival.event,
+ { amount, destination: quote.selected.id }));
const seat = quote.transfer.players.find((seat) => seat.id === p.id)!;
p.reserves = seat.reserves;
if (p.elites) p.elites.reserves = seat.eliteReserves;
g.homeworlds!.custody = quote.transfer.state;
observeOccupation(g);
- frame.destination = quote.selected.id;
- frame.stage = 'arrival';
+ if (!allocation) {
+ frame.destination = quote.selected.id; frame.stage = 'arrival';
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ }
stampHomeworldRevivalProgress(g);
- frame.ambassadors = [];
- frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ const group = currentHomeworldRevivalGroup(frame)!;
if (quote.selected.territory) {
- place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
+ place(p, quote.selected.territory, quote.selected.sector!, amount, group.elite);
const bg = byFaction(g, 'beneGesserit');
const followup = frame.quote.kind === 'tleilax' && bg && bg.id !== p.id &&
spiritualAdvisorMaximum(g, bg.id) > 0
- ? {shipment:p.id,destination:quote.selected.id,revivalEvent:frame.event} : undefined;
- if (!intrusion(g, p, quote.selected.territory, {revivalEvent:frame.event,...(followup ? {followup} : {})}) && followup)
+ ? {shipment:p.id,destination:quote.selected.id,revivalEvent:arrival.event} : undefined;
+ if (!intrusion(g, p, quote.selected.territory, {revivalEvent:arrival.event,...(followup ? {followup} : {})}) && followup)
g.decision = {kind:'advisor',player:bg!.id,...followup};
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount,
- frame.quote.elite, 'homeworldRevival', 'none', {event:frame.event});
+ group.elite, 'homeworldRevival', 'none', {event:arrival.event});
}
log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
{ faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
@@ -32551,8 +32594,10 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
function projectedHomeworldRevivalReturn(g: Game, player: string) {
const frame = g.homeworldRevivalReturn;
if (frame?.stage !== 'choice') return null;
- return { event: frame.event, player: frame.player, kind: frame.quote.kind,
- normal: frame.quote.normal, elite: frame.quote.elite, blocked: frame.quote.blocked,
+ const allocation = frame.progressVersion === 2 ? southernRevivalSummary(frame) : undefined;
+ return { event: currentHomeworldRevivalArrival(frame)!.event, player: frame.player, kind: frame.quote.kind,
+ normal: frame.quote.normal, elite: allocation?.remaining ?? frame.quote.elite, blocked: frame.quote.blocked,
+ ...(allocation ? { allocation } : {}),
destinations: player === frame.player
? homeworldRule(() => homeworldRevivalDestinations(g, player, frame.quote)) : [],
};
diff --git a/game/homeworld-cards.ts b/game/homeworld-cards.ts
index 5c62984e..308aa29c 100644
--- a/game/homeworld-cards.ts
+++ b/game/homeworld-cards.ts
@@ -124,7 +124,7 @@ export const HOMEWORLD_CARDS: readonly HomeworldCard[] = Object.freeze([
2,
2,
[
- 'When a starred Fedaykin force is revived, you may immediately place it in any territory where you already have forces.',
+ 'Newly revived starred Fedaykin forces may immediately be placed in a territory where you already have forces.',
],
['No additional penalty.'],
[
diff --git a/game/homeworld-revival-deployment-options.ts b/game/homeworld-revival-deployment-options.ts
index 95db8e08..5e58b190 100644
--- a/game/homeworld-revival-deployment-options.ts
+++ b/game/homeworld-revival-deployment-options.ts
@@ -37,29 +37,48 @@ export function homeworldRevivalDeploymentCanAct(g: GameView): boolean {
);
}
+function deploymentQuantities(g: GameView): { amounts: number[]; blocked: string | null } {
+ const offer = g.homeworldRevivalDeployment;
+ if (!offer || !Number.isSafeInteger(offer.normal) || !Number.isSafeInteger(offer.elite) ||
+ offer.normal < 0 || offer.elite < 0 || offer.normal + offer.elite <= 0 || offer.normal + offer.elite > 20)
+ return { amounts: [], blocked: 'The revived group is no longer available.' };
+ const allocation = offer.allocation;
+ if (allocation === undefined) return { amounts: [offer.normal + offer.elite], blocked: null };
+ if (!allocation || typeof allocation !== 'object' || Array.isArray(allocation) ||
+ Object.keys(allocation).sort().join(',') !== 'kept,placed,remaining,total' || g.advanced || offer.kind !== 'fedaykin' || offer.normal !== 0 ||
+ ![allocation.total, allocation.remaining, allocation.placed, allocation.kept].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 20) ||
+ allocation.remaining <= 0 || allocation.total <= 0 || allocation.total !== allocation.remaining + allocation.placed + allocation.kept ||
+ offer.elite !== allocation.remaining)
+ return { amounts: [], blocked: 'The Southern revival allocation is no longer available.' };
+ return { amounts: Array.from({ length: allocation.remaining }, (_, index) => index + 1), blocked: null };
+}
+
+/** Only the stamped native Basic allocation allows a partial placement. */
+export function homeworldRevivalDeploymentQuantities(g: GameView): number[] {
+ if (!homeworldRevivalDeploymentCanAct(g) || g.homeworldRevivalDeployment?.blocked) return [];
+ return deploymentQuantities(g).amounts;
+}
+
export function homeworldRevivalDeploymentChoice(
g: GameView,
destination: string,
+ amount?: number,
): { blocked: string | null; action: Action | null } {
const offer = g.homeworldRevivalDeployment;
const target = offer?.destinations.find(
(option) => option.id === destination,
);
+ const quantities = deploymentQuantities(g);
+ const selected = amount === undefined ? quantities.amounts[0] : amount;
let blocked: string | null = null;
if (!homeworldRevivalDeploymentCanAct(g))
blocked = 'Wait for the current choice to finish.';
else if (offer!.blocked) blocked = offer!.blocked;
else if (!target) blocked = 'Choose a current revival destination.';
else if (target.blocked) blocked = target.blocked;
- else if (
- !Number.isSafeInteger(offer!.normal) ||
- !Number.isSafeInteger(offer!.elite) ||
- offer!.normal < 0 ||
- offer!.elite < 0 ||
- offer!.normal + offer!.elite <= 0 ||
- offer!.normal + offer!.elite > 20
- )
- blocked = 'The revived group is no longer available.';
+ else if (quantities.blocked) blocked = quantities.blocked;
+ else if (selected === undefined || !quantities.amounts.includes(selected))
+ blocked = 'Choose a current revived quantity.';
return {
blocked,
action: blocked
@@ -68,7 +87,7 @@ export function homeworldRevivalDeploymentChoice(
type: 'decision',
event: offer!.event,
destination,
- amount: offer!.normal + offer!.elite,
+ amount: selected,
},
};
}
@@ -89,11 +108,12 @@ export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
.filter(([, count]) => count > 0)
.map(([key]) => splitLocation(key).territory),
);
+ const quantities = homeworldRevivalDeploymentQuantities(g);
const choices = offer.destinations
- .map((target) => ({
+ .flatMap((target) => quantities.map(amount => ({
target,
- action: homeworldRevivalDeploymentChoice(g, target.id).action,
- }))
+ action: homeworldRevivalDeploymentChoice(g, target.id, amount).action,
+ })))
.filter((choice) => choice.action);
const preferred = choices.find(
({ target }) =>
@@ -102,5 +122,10 @@ export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
occupied.has(target.territory),
);
const action = preferred?.action ?? choices[0]?.action;
+ if (offer.allocation !== undefined) {
+ const ordered = preferred ? [preferred, ...choices.filter(choice => choice !== preferred && choice.target.id === preferred.target.id),
+ ...choices.filter(choice => choice.target.id !== preferred.target.id)] : choices;
+ return [...ordered.flatMap(choice => choice.action ? [choice.action] : []), { type: 'decision', event: offer.event, decline: true }];
+ }
return [action ?? { type: 'decision', event: offer.event, decline: true }];
}
diff --git a/game/homeworld-revival-destinations.ts b/game/homeworld-revival-destinations.ts
index 1ce97c45..bcca1783 100644
--- a/game/homeworld-revival-destinations.ts
+++ b/game/homeworld-revival-destinations.ts
@@ -18,7 +18,8 @@ export type HomeworldRevivalDestination = {
blocked: string | null;
};
-/** Only the complete earned group to one destination is admitted here.
+/** Legacy calls place the complete earned group in one destination. New Southern
+ * allocations may quote an exact subset of the original eligible stars.
* The engine owns provisional SEND-entry reactions and their continuation. No token face,
* opposing hand, spice balance or concealed denomination influences these lists. */
export function homeworldRevivalDestinations(
@@ -68,11 +69,15 @@ export function quoteHomeworldRevivalDestination(
player: string,
grant: HomeworldRevivalDeploymentQuote,
destination: string,
+ fedaykinAmount?: number,
) {
+ if (fedaykinAmount !== undefined && (grant.kind !== 'fedaykin' || grant.normal !== 0 ||
+ !Number.isSafeInteger(fedaykinAmount) || fedaykinAmount < 1 || fedaykinAmount > grant.elite))
+ throw new HomeworldCustodyError('Choose only a positive subset of the original revived Fedaykin grant.');
const selected = homeworldRevivalDestinations(g, player, grant).find((d) => d.id === destination);
if (!selected || selected.blocked)
throw new HomeworldCustodyError(selected?.blocked ?? 'Choose a currently eligible revival destination.');
- const forces = { normal: grant.normal, elite: grant.elite };
+ const forces = { normal: grant.normal, elite: fedaykinAmount ?? grant.elite };
const context = homeworldContext(g);
if (selected.homeworld) {
const native = homeworldForceGroups(context, g.homeworlds!.custody!)
diff --git a/game/homeworld-revival-return.ts b/game/homeworld-revival-return.ts
index 5a3adc61..f05448d1 100644
--- a/game/homeworld-revival-return.ts
+++ b/game/homeworld-revival-return.ts
@@ -5,6 +5,7 @@ import {
completeHomeworldArrivalAmbassador,
validateHomeworldArrival,
type HomeworldAmbassadorArrival,
+ type HomeworldArrivalFrame,
} from './homeworld-arrival';
import { HomeworldCustodyError } from './homeworld-custody';
import type {
@@ -14,7 +15,7 @@ import type {
} from './homeworld-revival-deployment';
export type HomeworldRevivalReturn = {
- progressVersion?: 1;
+ progressVersion?: 1 | 2;
event: string;
turn: number;
phase: number;
@@ -30,8 +31,18 @@ export type HomeworldRevivalReturn = {
resumeSignature?: string;
ambassadors?: HomeworldAmbassadorArrival[];
arrivalSignature?: string;
+ allocation?: SouthernRevivalAllocation;
};
+export type SouthernRevivalChild = Omit & {
+ rootEvent: string;
+ index: number;
+ amount: number | null;
+ elite: number | null;
+ stage: 'choice' | 'arrival' | 'complete';
+};
+export type SouthernRevivalAllocation = { version: 1; children: SouthernRevivalChild[] };
+
export type HomeworldRevivalProgress = {
event: string;
stage: HomeworldRevivalReturn['stage'];
@@ -50,6 +61,10 @@ type Original = Pick<
| 'group'
| 'quote'
>;
+type ArrivalIdentity = Pick & {
+ progressVersion?: 1 | 2;
+ allocation?: SouthernRevivalAllocation;
+};
type Context = Pick<
Game,
| 'turn'
@@ -58,7 +73,19 @@ type Context = Pick<
| 'homeworlds'
| 'pendingAmbassador'
| 'homeworldRevivalProgress'
->;
+> & { advanced?: boolean };
+
+/** Selection only: minimal saved parent identities remain valid legacy inputs. */
+export function currentHomeworldRevivalArrival(frame: T | null | undefined): T | SouthernRevivalChild | null {
+ return !frame ? null : frame.progressVersion === 2 ? frame.allocation?.children.at(-1) ?? null : frame;
+}
+
+/** Actual entry packet only; an unchosen allocation has not moved any forces. */
+export function currentHomeworldRevivalGroup(frame: HomeworldRevivalReturn): { amount: number; elite: number } | null {
+ if (frame.progressVersion !== 2) return { amount: frame.quote.normal + frame.quote.elite, elite: frame.quote.elite };
+ const child = frame.allocation?.children.at(-1);
+ return child && child.amount !== null && child.elite !== null ? { amount: child.amount, elite: child.elite } : null;
+}
function fail(): never {
throw new HomeworldCustodyError(
@@ -103,6 +130,7 @@ function originalSignature(frame: Original): string {
],
// Preserve the exact signature bytes of legacy unversioned saves.
...(frame.progressVersion === 1 ? ['progressVersion', 1] : []),
+ ...(frame.progressVersion === 2 ? ['progressVersion', 2, 'allocation', 1] : []),
]);
}
@@ -110,7 +138,9 @@ function originalSignature(frame: Original): string {
export function homeworldRevivalArrivalSignature(
frame: HomeworldRevivalReturn,
): string {
- return homeworldArrivalSignature(frame);
+ const arrival = currentHomeworldRevivalArrival(frame);
+ if (!arrival) fail();
+ return homeworldArrivalSignature(arrival);
}
export function appendHomeworldRevivalAmbassador(
frame: HomeworldRevivalReturn,
@@ -119,7 +149,10 @@ export function appendHomeworldRevivalAmbassador(
): void {
validateOriginal(frame);
if (frame.signature !== originalSignature(frame)) fail();
- appendHomeworldArrivalAmbassador(frame, entry, parent);
+ validateAllocation(frame);
+ const arrival = currentHomeworldRevivalArrival(frame);
+ if (!arrival) fail();
+ appendHomeworldArrivalAmbassador(arrival, entry, parent);
}
export function completeHomeworldRevivalAmbassador(
frame: HomeworldRevivalReturn,
@@ -127,11 +160,14 @@ export function completeHomeworldRevivalAmbassador(
): void {
validateOriginal(frame);
if (frame.signature !== originalSignature(frame)) fail();
- completeHomeworldArrivalAmbassador(frame, event);
+ validateAllocation(frame);
+ const arrival = currentHomeworldRevivalArrival(frame);
+ if (!arrival) fail();
+ completeHomeworldArrivalAmbassador(arrival, event);
}
function validateOriginal(frame: Original): void {
if (
- (frame.progressVersion !== undefined && frame.progressVersion !== 1) ||
+ (frame.progressVersion !== undefined && frame.progressVersion !== 1 && frame.progressVersion !== 2) ||
!text(frame.event) ||
!text(frame.player) ||
!count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
@@ -163,6 +199,7 @@ function validateOriginal(frame: Original): void {
)
fail();
const quote = frame.quote;
+ if (frame.progressVersion === 2 && (quote.kind !== 'fedaykin' || quote.elite < 2)) fail();
// Provisional composition policy: the population resulting from this return
// decides the ability, so the crossing return's frame is valid even though
// the pre-return population was below the high threshold.
@@ -210,6 +247,129 @@ export function homeworldRevivalResumeSignature(
);
}
+function southernChildSignature(root: HomeworldRevivalReturn, child: SouthernRevivalChild): string {
+ return JSON.stringify(['southernRevivalChild', root.signature, root.event, child.index, child.event, child.amount, child.elite]);
+}
+
+function validateAllocation(root: HomeworldRevivalReturn): void {
+ if (root.progressVersion !== 2) {
+ if (Object.hasOwn(root, 'allocation')) fail();
+ return;
+ }
+ const allocation = root.allocation;
+ if (!keys(allocation, ['version', 'children']) || allocation?.version !== 1 || !Array.isArray(allocation.children) ||
+ allocation.children.length > root.quote.elite || root.destination !== undefined || root.ambassadors !== undefined ||
+ root.arrivalSignature !== undefined) fail();
+ const seen = new Set([root.event]);
+ let selected = 0;
+ for (const [index, child] of allocation.children.entries()) {
+ if (!keys(child, ['event', 'turn', 'phase', 'player', 'stage', 'signature', 'destination', 'ambassadors',
+ 'arrivalSignature', 'rootEvent', 'index', 'amount', 'elite']) || !text(child.event) || seen.has(child.event) ||
+ child.rootEvent !== root.event || child.index !== index || child.turn !== root.turn || child.phase !== root.phase ||
+ child.player !== root.player || !['choice', 'arrival', 'complete'].includes(child.stage) ||
+ (index < allocation.children.length - 1 && child.stage !== 'complete')) fail();
+ seen.add(child.event);
+ if (child.stage === 'choice') {
+ if (child.amount !== null || child.elite !== null || child.destination !== undefined ||
+ child.ambassadors !== undefined || child.arrivalSignature !== undefined || selected >= root.quote.elite) fail();
+ } else {
+ if (!count(child.amount, 1, root.quote.elite) || child.elite !== child.amount || !text(child.destination) ||
+ child.arrivalSignature !== homeworldArrivalSignature(child)) fail();
+ selected += child.amount;
+ }
+ if (child.signature !== southernChildSignature(root, child) || selected > root.quote.elite) fail();
+ }
+ const current = allocation.children.at(-1);
+ if (root.stage === 'waiting' ? allocation.children.length !== 0 : !current || root.stage !== current.stage) fail();
+ if (root.stage === 'complete' && selected !== root.quote.elite) fail();
+}
+
+function requireSouthern(root: HomeworldRevivalReturn): void {
+ validateOriginal(root);
+ if (root.progressVersion !== 2 || root.signature !== originalSignature(root)) fail();
+ validateAllocation(root);
+ if (root.stage !== 'waiting' && (root.resumeResponse === undefined ||
+ root.resumeSignature !== homeworldRevivalResumeSignature(root.resumeResponse))) fail();
+}
+
+export function southernRevivalSummary(root: HomeworldRevivalReturn): { total: number; remaining: number; placed: number; kept: number } {
+ requireSouthern(root);
+ let placed = 0, kept = 0;
+ for (const child of root.allocation!.children) {
+ if (child.amount === null) continue;
+ if (child.destination === 'decline') kept += child.amount;
+ else placed += child.amount;
+ }
+ return { total: root.quote.elite, remaining: root.quote.elite - placed - kept, placed, kept };
+}
+
+export function southernRevivalRemaining(root: HomeworldRevivalReturn): number {
+ return southernRevivalSummary(root).remaining;
+}
+
+/** Fresh Basic multi-star roots only. The engine owns the actual one-time return. */
+export function makeSouthernRevivalAllocation(original: Omit): HomeworldRevivalReturn {
+ const base = makeHomeworldRevivalReturn(original);
+ const root: HomeworldRevivalReturn = { ...base, progressVersion: 2, allocation: { version: 1, children: [] } };
+ validateOriginal(root);
+ root.signature = originalSignature(root);
+ validateAllocation(root);
+ return root;
+}
+
+function makeSouthernChoice(root: HomeworldRevivalReturn, event: string): SouthernRevivalChild {
+ if (!text(event) || event !== event.trim() || event === root.event || root.allocation!.children.some(child => child.event === event)) fail();
+ const child: SouthernRevivalChild = { event, turn: root.turn, phase: root.phase, player: root.player,
+ stage: 'choice', signature: '', rootEvent: root.event, index: root.allocation!.children.length, amount: null, elite: null };
+ child.signature = southernChildSignature(root, child);
+ return child;
+}
+
+/** Capture the original response once before opening the first owned child. */
+export function openSouthernRevivalChoice(root: HomeworldRevivalReturn, newEvent: string): void {
+ requireSouthern(root);
+ if (root.stage !== 'waiting' || root.allocation!.children.length || root.resumeResponse === undefined ||
+ root.resumeSignature !== homeworldRevivalResumeSignature(root.resumeResponse)) fail();
+ const child = makeSouthernChoice(root, newEvent);
+ root.allocation!.children.push(child);
+ root.stage = 'choice';
+}
+
+/** Called at the same atomic boundary as the native subset debit/placement. */
+export function commitSouthernRevivalChoice(root: HomeworldRevivalReturn, event: string,
+ selection: { amount: number; destination: string }): void {
+ requireSouthern(root);
+ const child = root.allocation!.children.at(-1);
+ if (root.stage !== 'choice' || !child || child.stage !== 'choice' || child.event !== event ||
+ !keys(selection, ['amount', 'destination']) || !count(selection.amount, 1, southernRevivalRemaining(root)) ||
+ !text(selection.destination) || selection.destination !== selection.destination.trim()) fail();
+ child.amount = selection.amount;
+ child.elite = selection.amount;
+ child.destination = selection.destination;
+ child.stage = 'arrival';
+ child.signature = southernChildSignature(root, child);
+ child.ambassadors = [];
+ child.arrivalSignature = homeworldArrivalSignature(child);
+ root.stage = 'arrival';
+}
+
+/** Retire one complete native arrival, then open the next fresh choice or finish.
+ * The original response remains held until the whole root is complete. */
+export function completeSouthernRevivalChild(root: HomeworldRevivalReturn, event: string, nextEvent?: string): void {
+ requireSouthern(root);
+ const child = root.allocation!.children.at(-1);
+ if (root.stage !== 'arrival' || !child || child.stage !== 'arrival' || child.event !== event ||
+ child.ambassadors!.some(entry => !entry.completed)) fail();
+ const remaining = southernRevivalRemaining(root);
+ if (remaining === 0 && nextEvent !== undefined || remaining > 0 && nextEvent === undefined) fail();
+ const next = remaining > 0 ? makeSouthernChoice(root, nextEvent!) : null;
+ child.stage = 'complete';
+ if (next) {
+ root.allocation!.children.push(next);
+ root.stage = 'choice';
+ } else root.stage = 'complete';
+}
+
/** Construct only after the quoted deposit actually returned its exact group. */
export function makeHomeworldRevivalReturn(
original: Omit,
@@ -245,11 +405,12 @@ export function makeHomeworldRevivalProgress(
): HomeworldRevivalProgress {
validateOriginal(frame);
if (
- frame.progressVersion !== 1 ||
+ (frame.progressVersion !== 1 && frame.progressVersion !== 2) ||
frame.signature !== originalSignature(frame) ||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage)
)
fail();
+ validateAllocation(frame);
return {
event: frame.event,
stage: frame.stage,
@@ -258,6 +419,9 @@ export function makeHomeworldRevivalProgress(
frame.signature,
frame.event,
frame.stage,
+ ...(frame.progressVersion === 2 ? ['allocation', frame.allocation!.children.map(child => [
+ child.event, child.index, child.amount, child.elite, child.stage, child.destination ?? null, child.signature,
+ ])] : []),
]),
};
}
@@ -289,20 +453,23 @@ export function validateHomeworldRevivalReturn(
'resumeSignature',
'ambassadors',
'arrivalSignature',
+ ...(frame.progressVersion === 2 ? ['allocation'] : []),
])
)
fail();
validateOriginal(frame);
+ validateAllocation(frame);
if (
frame.signature !== originalSignature(frame) ||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
!g.homeworlds?.custody ||
(frame.stage !== 'complete' &&
(g.turn !== frame.turn || g.phase !== frame.phase)) ||
- (frame.stage === 'complete' && g.turn < frame.turn)
+ (frame.stage === 'complete' && g.turn < frame.turn) ||
+ (frame.progressVersion === 2 && g.advanced !== false)
)
fail();
- if (frame.progressVersion === 1) {
+ if (frame.progressVersion === 1 || frame.progressVersion === 2) {
const expected = makeHomeworldRevivalProgress(frame);
const progress = g.homeworldRevivalProgress;
if (
@@ -323,16 +490,23 @@ export function validateHomeworldRevivalReturn(
if (frame.stage === 'waiting' || frame.stage === 'choice') {
if (frame.ambassadors !== undefined || frame.arrivalSignature !== undefined)
fail();
+ }
+ if (frame.stage === 'waiting' || frame.stage === 'choice' || frame.progressVersion === 2 && frame.stage !== 'complete') {
const elites = owner.elites?.reserves ?? 0;
+ const elite = frame.progressVersion === 2 ? southernRevivalRemaining(frame) : frame.quote.elite;
+ const normal = frame.progressVersion === 2 ? 0 : frame.quote.normal;
if (
!count(owner.reserves) ||
!count(elites, 0, owner.reserves) ||
- elites < frame.quote.elite ||
- owner.reserves - elites < frame.quote.normal
+ elites < elite ||
+ owner.reserves - elites < normal
)
fail();
}
- if (frame.stage === 'arrival' || frame.stage === 'complete')
+ if (frame.progressVersion === 2) {
+ for (const child of frame.allocation!.children)
+ if (child.stage !== 'choice') validateHomeworldArrival(g, child, 'revivalEvent');
+ } else if (frame.stage === 'arrival' || frame.stage === 'complete')
validateHomeworldArrival(g, frame, 'revivalEvent');
if (frame.stage === 'waiting') {
if (
@@ -349,11 +523,11 @@ export function validateHomeworldRevivalReturn(
homeworldRevivalResumeSignature(frame.resumeResponse)
)
fail();
- if (
+ if (frame.progressVersion !== 2 && (
frame.stage === 'choice'
? frame.destination !== undefined
: !text(frame.destination)
- )
+ ))
fail();
const response = frame.resumeResponse;
if (
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index 4724e515..b42638cc 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -1,7 +1,7 @@
import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
-import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
+import { currentHomeworldRevivalArrival, validateHomeworldRevivalReturn } from './homeworld-revival-return';
import { validateRevivalArrivalSource } from './revival-terror-entry';
import { HomeworldCustodyError } from './homeworld-custody';
import {
@@ -516,10 +516,10 @@ export function quoteMovementCancellation(
);
if (response.advisorResume === 'wormRide') return null;
if (response.advisorRevivalEvent !== undefined) {
- const parent = g.homeworldRevivalReturn;
+ const root = g.homeworldRevivalReturn, parent = currentHomeworldRevivalArrival(root);
const terror = response.advisorRevivalTerror;
try {
- validateHomeworldRevivalReturn(g, parent);
+ validateHomeworldRevivalReturn(g, root);
if (terror) validateRevivalArrivalSource(g, {event:response.advisorRevivalEvent,terror},
{entrant:terror.owner,territory:terror.territory,sector:terror.sector,
amount:terror.amount,elite:terror.elite,cause:'shipment'});
@@ -536,7 +536,7 @@ export function quoteMovementCancellation(
response.advisorResume === undefined && response.advisorRemaining === undefined,
'The canceled Intrusion lost its original committed revived-force arrival.');
const followup = response.advisorFollowup;
- requireContext(followup === undefined || ((terror || parent.quote.kind === 'tleilax') &&
+ requireContext(followup === undefined || ((terror || root?.quote.kind === 'tleilax') &&
followup.shipment === (terror?.owner ?? parent.player) &&
followup.destination === (terror ? location(terror.territory,terror.sector) : parent.destination) &&
followup.revivalEvent === parent.event &&
diff --git a/game/reference.ts b/game/reference.ts
index 6ed51518..06cef8bb 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -5337,9 +5337,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
- 'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
+ 'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place newly revived starred forces in territories already containing their forces, or leave them in reserves. Fresh Basic multi-star returns provisionally permit separate portions and destinations: finish each arrival before choosing the next portion, or leave all remaining stars in reserves. The original revived batch and its entitlement stay fixed. Existing whole-group returns keep their earlier procedure. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
'Under the same provisional resulting-population policy, an ordinary Free Revival that leaves Tleilax at high population may send its newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. This includes the return that first reaches the high side. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
- 'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Partial groups, split destinations, Southern storm placement and Hidden Mobile Stronghold classification remain guarded; allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
+ 'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Fresh Basic Southern allocation shows exact remaining quantities and permits different current legal destinations under the provisional per-star interpretation. Southern uses sectors outside the storm and keeps the ordinary Hidden Mobile Stronghold entry route; Tleilax keeps its direct-entry restriction. Tleilax still sends the whole free group to one destination, and allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
@@ -5386,7 +5386,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
+ "Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/homeworld-module-profile.ts',
'game/faction-module-profile.ts',
@@ -5417,6 +5417,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'game/homeworld-revival-deployment.ts',
'game/homeworld-revival-destinations.ts',
'game/homeworld-revival-return.ts',
+ 'tests/fixture-southern-allocation.ts',
+ 'tests/southern-allocation-runtime.test.ts',
'game/homeworld-victory-reinforcement.ts',
'game/homeworld-victory-return.ts',
'game/grumman-collection.ts',
@@ -5462,7 +5464,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Player controls',
status: 'Partial',
detail:
- "Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Supported starts remain previews. Basic Tupile controls explicitly show provisional current-sole loss and the earlier normal-limit discard. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.",
+ "Southern allocation controls show original, remaining, placed and kept stars with current quantity/destination choices and an explicit provisional note. Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Supported starts remain previews. Basic Tupile controls explicitly show provisional current-sole loss and the earlier normal-limit discard. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.",
evidence: [
'components/homeworld-cards.tsx',
'components/homeworld-table.tsx',
@@ -5485,14 +5487,14 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'AI',
status: 'Partial',
detail:
- "Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
+ "All four profiles share legal Southern portion/destination choices and the remaining-reserves option. Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
- "All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover, original occupied economic/Bidding ownership and user-ruled whole-invoice rounding are explicit. Remaining occupation variants and interactions stay separate; full guidance remains in progress. Current Junction source choice distinguishes printed immunity from the selected unused-price/no-refund product composition; failed or deferred transport retains the grant.",
+ "The Basic Southern per-star interpretation, exact original-batch scope and legacy/Tleilax/Advanced boundaries are explicit. All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover, original occupied economic/Bidding ownership and user-ruled whole-invoice rounding are explicit. Remaining occupation variants and interactions stay separate; full guidance remains in progress. Current Junction source choice distinguishes printed immunity from the selected unused-price/no-refund product composition; failed or deferred transport retains the grant.",
evidence: [
'docs/HOMEWORLD_COMPONENT_AUDIT.md',
'docs/HOMEWORLD_RULES.md',
diff --git a/game/revival-terror-entry.ts b/game/revival-terror-entry.ts
index 2c5c2886..82c23c73 100644
--- a/game/revival-terror-entry.ts
+++ b/game/revival-terror-entry.ts
@@ -1,7 +1,7 @@
import type { Game } from './engine';
import { gameTerritories, location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
-import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
+import { currentHomeworldRevivalArrival, currentHomeworldRevivalGroup, validateHomeworldRevivalReturn } from './homeworld-revival-return';
import type { SpiritualAdvisorSource } from './spiritual-advisor';
export type RevivalTerrorPacket = {
@@ -19,7 +19,7 @@ export type RevivalEntryParent = {
};
type TerrorEntry = NonNullable;
export type RevivalArrivalDetails = Pick;
-export type RevivalArrivalContext = Pick;
type HistoricalContext = Pick & {
@@ -138,18 +138,21 @@ export function revivalEntryParentTuple(parent: RevivalEntryParent): unknown[] {
export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: RevivalEntryParent, details: RevivalArrivalDetails): void {
if (!validRevivalEntryParent(parent)) fail();
const frame = g.homeworldRevivalReturn;
- if (g.status !== 'playing' || !frame || frame.stage !== 'arrival' || frame.event !== parent.event ||
- frame.turn !== g.turn || frame.phase !== g.phase || !text(frame.destination)) fail();
+ const arrival = frame ? currentHomeworldRevivalArrival(frame) : null;
+ const group = frame ? currentHomeworldRevivalGroup(frame) : null;
+ if (g.status !== 'playing' || !frame || !arrival || !group || frame.stage !== 'arrival' ||
+ arrival.stage !== 'arrival' || arrival.event !== parent.event || frame.turn !== g.turn || frame.phase !== g.phase ||
+ !text(arrival.destination)) fail();
const continuation = g.pendingTreacheryDiscard?.continuation;
const context = !g.pendingAmbassador && continuation?.kind === 'ambassador'
? { ...g, pendingAmbassador: continuation.entry } : g;
validateHomeworldRevivalReturn(context, frame);
- const original = splitLocation(frame.destination);
- if (frame.destination !== location(original.territory, original.sector) ||
+ const original = splitLocation(arrival.destination);
+ if (arrival.destination !== location(original.territory, original.sector) ||
!gameTerritories(g).some(place => place.id === original.territory && place.sectors.includes(original.sector)) ||
!gameTerritories(g).some(place => place.id === details.territory && place.sectors.includes(details.sector)) ||
!count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail();
- if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, frame.destination, frame.event);
+ if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, arrival.destination, arrival.event);
if (parent.spiritual) {
const source = parent.spiritual;
const origin = parent.terror?.territory ?? original.territory;
@@ -165,7 +168,7 @@ export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: R
details.sector !== packet.sector || details.amount !== packet.amount || details.elite !== packet.elite) fail();
} else if (details.entrant !== frame.player || details.cause !== 'homeworldRevival' ||
details.territory !== original.territory || details.sector !== original.sector ||
- details.amount !== frame.quote.normal + frame.quote.elite || details.elite !== frame.quote.elite) fail();
+ details.amount !== group.amount || details.elite !== group.elite) fail();
}
export function validateRevivalTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): void {
diff --git a/game/spiritual-advisor.ts b/game/spiritual-advisor.ts
index 32d21253..fdc5cb88 100644
--- a/game/spiritual-advisor.ts
+++ b/game/spiritual-advisor.ts
@@ -1,6 +1,7 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
import type { Game } from './engine';
+import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
import { presenceAt, type ForcePresence } from './force-presence';
export type SpiritualAdvisorSource = {
@@ -157,7 +158,7 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
'Victory Spiritual Advisors need the original Guild shipment territory or Polar Sink.');
}
if (source.phase === 4) {
- const parent = ctx.homeworldRevivalReturn;
+ const parent = currentHomeworldRevivalArrival(ctx.homeworldRevivalReturn);
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
parent.event === source.revivalEvent && parent.turn === ctx.turn && parent.phase === 4 &&
parent.stage === 'arrival' && identifier(parent.destination),
diff --git a/tests/fixture-revival-arrival-reactions.ts b/tests/fixture-revival-arrival-reactions.ts
index ef7a9dce..e6033d2f 100644
--- a/tests/fixture-revival-arrival-reactions.ts
+++ b/tests/fixture-revival-arrival-reactions.ts
@@ -66,6 +66,8 @@ export function passRevivalArrivalReactions(game: Game, actions: RevivalArrivalR
/** Earn the original returned-group decision; no destination or BG reaction is chosen. */
export function openRevivalArrivalReactions(game: Game, fixture: { owner: string; revivalAction: Action },
actions: RevivalArrivalReactionsStep[] = []): Game {
+ game = advanceRevivalArrivalReactions(game, state => state.phase === 4 && revivalArrivalReactionsClean(state) &&
+ (!state.revivalOrder || state.revivalOrder.remaining[0] === fixture.owner), actions);
game = actRevivalArrivalReactions(game, fixture.owner, fixture.revivalAction, actions);
game = advanceRevivalArrivalReactions(game, state =>
state.decision?.kind === 'homeworldRevivalDeployment' && !state.response && !state.phaseOpening, actions);
@@ -169,7 +171,8 @@ export function createRevivalArrivalReactionsFixture(options: { advanced?: boole
assert.equal(game.storm, 13);
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
- reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state));
+ reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state) &&
+ (!state.revivalOrder || state.revivalOrder.remaining[0] === owner));
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, resident ? 18 : 19);
@@ -194,7 +197,7 @@ export function createRevivalArrivalReactionsFixture(options: { advanced?: boole
dealOrder, spiceOrder, originalSpice, originalInventory, originalCards: originalInventory,
steps: actions, actions, sourceActionCount: actions.length,
staging: [
- 'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
+ 'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG, finishing preceding Advanced seats through their actual Ready controls until Tleilaxu owns the opportunity. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
],
};
}
diff --git a/tests/fixture-southern-allocation.ts b/tests/fixture-southern-allocation.ts
new file mode 100644
index 00000000..4c895694
--- /dev/null
+++ b/tests/fixture-southern-allocation.ts
@@ -0,0 +1,191 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type SouthernAllocationStep = TruthMovementStep;
+export const southernPlayer = (game: Game, actor = 'fremen') => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const southernReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
+export const actSouthernAllocation = actTruthMovement;
+export const southernAllocationClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
+
+/** Count physical live slots, never historical references to those cards. */
+export function southernAllocationInventory(game: Game) {
+ homeworldGameIntegrity(game);
+ const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
+ const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0);
+ for (const player of game.players) {
+ const visitors = worlds.filter(world => world.native !== player.id)
+ .map(world => world.forces[player.id] ?? { normal: 0, elite: 0 });
+ assert.equal(player.reserves + player.tanks + sum(player.forces) +
+ visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20, player.faction);
+ if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
+ visitors.reduce((total, group) => total + group.elite, 0), player.faction === 'emperor' ? 5 : 3);
+ }
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
+ .map(card => card.id).sort();
+ const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
+ [...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)])].sort();
+ const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
+ assert.equal(new Set(cards).size, cards.length);
+ assert.equal(new Set(traitors).size, traitors.length);
+ return { cards, traitors, spice };
+}
+
+/** Native controls only. Stop at an owned allocation or unsealed human plan. */
+export function nextSouthernAllocationStep(game: Game): SouthernAllocationStep | null {
+ if (game.decision?.kind === 'homeworldRevivalDeployment') return null;
+ if (!game.response && !game.phaseOpening) {
+ if (game.status === 'setup' && game.setupStage === 'forces' && southernPlayer(game).reserves === 20)
+ return { actor: 'fremen', action: { type: 'fremenSetup',
+ placements: { 'sietch_tabr:14': 4, 'false_wall_south:5': 3, 'false_wall_west:18': 3 },
+ elitePlacements: { 'sietch_tabr:14': 3 } } };
+ if (game.decision?.kind === 'ecazPlacement')
+ return { actor: game.decision.player, action: { type: 'decision', decline: true } };
+ if (game.decision?.kind === 'ecazAmbassador')
+ return { actor: game.decision.player, action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
+ if (game.decision?.kind === 'revivalStop')
+ return { actor: game.decision.player, action: { type: 'decision', decline: true } };
+ if (game.decision?.kind === 'faceDance')
+ return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
+ if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
+ return { actor: game.decision.player, action: { type: 'decision', accept: false } };
+ }
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceSouthernAllocation(game: Game, until: (state: Game) => boolean,
+ actions: SouthernAllocationStep[] = []): Game {
+ for (let count = 0; count < 1000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Southern chronology ended before its requested choice');
+ const step = nextSouthernAllocationStep(game);
+ assert.ok(step, `Choose the original allocation or Battle Plans at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actSouthernAllocation(game, step.actor, step.action, actions);
+ }
+ throw Error('The finite original Southern chronology did not reach its requested boundary');
+}
+
+/** Genuine bare Basic Homeworld source. Original public setup distributes the
+ * three actual stars; a paid Emperor invasion and chosen native casualties put
+ * two or three in the Tanks. The next ordinary free revival is still unplayed.
+ * Only original scalar lotteries and unopened non-worm Spice order are chosen.
+ * No played hand, purse, army, Tank, phase, population or receipt is installed.
+ */
+export function createSouthernAllocationFixture(options: { stars?: 2 | 3; third?: 'tleilaxu' | 'ecaz' } = {}) {
+ const stars = options.stars ?? 3;
+ let game = createGame('SOUTHERNALLOCATION', newPlayer('fremen', 'Fremen', 'fremen'), false,
+ options.third === 'tleilaxu' ? ['ix'] : options.third === 'ecaz' ? ['ecaz'] : []);
+ joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
+ if (options.third) joinGame(game, newPlayer(options.third, options.third, options.third));
+ const initial = structuredClone(game), actions: SouthernAllocationStep[] = [];
+ const act = (actor: string, action: Action) => { game = actSouthernAllocation(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceSouthernAllocation(game, until, actions); };
+ act(game.host, { type: 'homeworlds', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(game.host, { type: 'start' });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
+ assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), setupSteps = structuredClone(actions);
+ const original = southernAllocationInventory(game);
+ assert.equal(southernPlayer(game).reserves, 10);
+ assert.deepEqual(southernPlayer(game).elites?.forces, { 'sietch_tabr:14': 3 });
+ assert.equal(game.storm, 1);
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
+ !card.discovery && !['sietch_tabr', 'false_wall_south', 'false_wall_west'].includes(card.territory));
+ assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1);
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ // This optional original token will first be declined on the Emperor's entry,
+ // then provide a genuine phase-four arrival interruption after revival.
+ if (options.third === 'ecaz') {
+ reach(state => state.turn === 1 && state.decision?.kind === 'ecazPlacement');
+ const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply');
+ assert.ok(token);
+ act('ecaz', { type: 'decision', token: token.id, territory: 'sietch_tabr' });
+ }
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === 'emperor' && southernAllocationClean(state));
+ const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, 'emperor'), 1, 0);
+ assert.ok(sources);
+ // Homeworld shipment quotes govern World destinations. Tabr uses the
+ // ordinary Arrakis shipment with the exact original native reserve sources.
+ const shipmentStep: SouthernAllocationStep = { actor: 'emperor', action: {
+ type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0,
+ allyPayment: 0, homeworldSources: sources,
+ } };
+ act(shipmentStep.actor, shipmentStep.action);
+ const shipmentCost = southernPlayer(beforeShipment, 'emperor').spice - southernPlayer(game, 'emperor').spice;
+ assert.equal(shipmentCost, 1);
+ reach(state => state.turn === 1 && state.phase === 6 && !!state.active && southernAllocationClean(state));
+ const beforeBattle = structuredClone(game);
+ const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'sietch_tabr'); assert.ok(choice);
+ act(choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr',
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
+ reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && southernAllocationClean(state));
+ const fremenLeader = southernPlayer(game).leaders.filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
+ const emperorLeader = southernPlayer(game, 'emperor').leaders.filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
+ assert.ok(fremenLeader && emperorLeader, 'Both original factions retain an eligible native leader');
+ assert.ok(stars + fremenLeader.strength > emperorLeader.strength,
+ 'The actual no-card native plans leave Fremen victorious before its chosen starred casualties');
+ const plans: SouthernAllocationStep[] = [
+ { actor: 'fremen', action: { type: 'battlePlan', dial: stars, support: 0,
+ leader: fremenLeader.id } },
+ { actor: 'emperor', action: { type: 'battlePlan', dial: 0, support: 0,
+ leader: emperorLeader.id } },
+ ];
+ for (const step of plans) act(step.actor, step.action);
+ reach(state => state.decision?.kind === 'battleLosses');
+ const losses = game.decision;
+ assert.ok(losses?.kind === 'battleLosses');
+ const lossChoice = losses.options.findIndex(loss => loss.normal === 0 && loss.elite === stars); assert.ok(lossChoice >= 0);
+ act('fremen', { type: 'decision', choice: lossChoice });
+ reach(state => !state.battle && southernAllocationClean(state));
+ const afterBattle = structuredClone(game), battleSteps = structuredClone(actions.slice(setupSteps.length));
+ assert.equal(southernPlayer(game).tanks, stars); assert.equal(southernPlayer(game).elites?.tanks, stars);
+ assert.equal(southernPlayer(game).forces['sietch_tabr:14'], 4 - stars);
+ assert.equal(southernPlayer(game, 'emperor').tanks, 1);
+ reach(state => state.turn === 2 && state.phase === 4 && southernAllocationClean(state));
+ assert.equal(game.storm, 3, 'Actual Basic second Storm is one plus one, outside every original Fremen group');
+ assert.equal(southernPlayer(game).tanks, stars);
+ assert.equal(southernPlayer(game).elites?.tanks, stars);
+ assert.equal(southernPlayer(game).reserves, 10);
+ assert.equal(southernPlayer(game).forces['false_wall_south:5'], 3);
+ assert.equal(southernPlayer(game).forces['false_wall_west:18'], 3);
+ assert.deepEqual(southernAllocationInventory(game), original);
+ const revivalAction: Action = { type: 'revive', amount: stars, elite: stars };
+ return { initial, lobby, setup, afterSetup, beforeShipment, shipmentStep, shipmentCost,
+ beforeBattle, plans, afterBattle, game, stars, third: options.third, actions, setupSteps, battleSteps,
+ spiceOrder, original, revivalAction,
+ sourceCounts: { setup: setupSteps.length, throughBattle: setupSteps.length + battleSteps.length, source: actions.length },
+ sourceDisclosure: 'Actual Basic Fremen/Emperor Homeworld setup; paid one-force Emperor invasion; native no-card battle and exact starred casualties; actual next Storm1+1 and phase-four return opportunity. Only original scalar lotteries and unopened Spice order selected.' };
+}
+
+export function openSouthernAllocation(fixture: ReturnType,
+ actions: SouthernAllocationStep[] = []): Game {
+ let game = actSouthernAllocation(southernReload(fixture.game), 'fremen', fixture.revivalAction, actions);
+ game = advanceSouthernAllocation(game, state => state.decision?.kind === 'homeworldRevivalDeployment', actions);
+ assert.equal(viewGame(game, 'fremen').homeworldRevivalDeployment?.elite, fixture.stars);
+ return game;
+}
+export function southernAllocationAction(game: Game, destination: string, amount: number): Action {
+ const offer = viewGame(game, 'fremen').homeworldRevivalDeployment; assert.ok(offer);
+ assert.ok(offer.destinations.some(option => option.id === destination && !option.blocked));
+ return { type: 'decision', event: offer.event, destination, amount };
+}
+export function settleSouthernAllocation(game: Game, actions: SouthernAllocationStep[] = []): Game {
+ return advanceSouthernAllocation(game, state => state.decision?.kind === 'homeworldRevivalDeployment' ||
+ (state.homeworldRevivalReturn?.stage === 'complete' && !state.response && !state.pendingAmbassador), actions);
+}
diff --git a/tests/homeworld-revival-ambassador-engine.test.ts b/tests/homeworld-revival-ambassador-engine.test.ts
index 7ce16390..21a6e0fe 100644
--- a/tests/homeworld-revival-ambassador-engine.test.ts
+++ b/tests/homeworld-revival-ambassador-engine.test.ts
@@ -14,8 +14,20 @@ import {
import { homeworldRevivalAmbassadorFixture as fixture } from './fixture-homeworld-revival-ambassador';
+function revivalOpportunity(g: Game, actor: string) {
+ for (let count = 0; g.revivalOrder && g.revivalOrder.remaining[0] !== actor && count < g.players.length; count++) {
+ const preceding = g.revivalOrder.remaining[0]; assert.ok(preceding);
+ assert.equal(g.phase, 4);
+ assert.equal(g.response ?? null, null); assert.equal(g.decision ?? null, null);
+ g = applyAction(g, preceding, { type: 'ready' });
+ }
+ assert.equal(g.phase, 4);
+ if (g.revivalOrder) assert.equal(g.revivalOrder.remaining[0], actor);
+ return g;
+}
+
function open() {
- let g = fixture();
+ let g = revivalOpportunity(fixture(), 'f');
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
if (g.decision?.kind === 'revivalStop')
g = applyAction(g, 't', { type: 'decision', decline: true });
@@ -89,7 +101,7 @@ void test('actual phase-four revived arrival rejects fighter accompaniment and h
});
void test('a controlled native World source completes revived Tleilax Intrusion and Spiritual declines before its Fremen Ambassador relocation', () => {
- let g = fixture();
+ let g = revivalOpportunity(fixture(), 't');
// This existing fixture conserves staged native armies/cards. The return,
// Intrusion, Spiritual choice and Ambassador continuations are real actions.
g = applyAction(g, 't', { type: 'revive', amount: 1, elite: 0 });
@@ -119,7 +131,7 @@ void test('a controlled native World source completes revived Tleilax Intrusion
});
void test('a controlled conserved Southern return permits Intrusion Karama without inventing Spiritual accompaniment', () => {
- let g = fixture();
+ let g = revivalOpportunity(fixture(), 'f');
// Move existing Fremen/BG counters to a non-stronghold in this controlled
// fixture. No revival, entry, response or continuation is installed.
p(g, 'f').forces['arrakeen:10']--;
diff --git a/tests/homeworld-revival-deployment-controls.test.ts b/tests/homeworld-revival-deployment-controls.test.ts
index 0aeec9b8..b7e41651 100644
--- a/tests/homeworld-revival-deployment-controls.test.ts
+++ b/tests/homeworld-revival-deployment-controls.test.ts
@@ -1,8 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { createElement } from 'react';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import { HomeworldRevivalDeployment } from '../components/homeworld-revival-deployment';
+import { HomeworldRevivalDeployment, HomeworldRevivalDeploymentButtons } from '../components/homeworld-revival-deployment';
import { baseDeck } from '../game/cards';
import {
createGame,
@@ -15,6 +15,7 @@ import {
homeworldRevivalDeploymentActions,
homeworldRevivalDeploymentCanAct,
homeworldRevivalDeploymentChoice,
+ homeworldRevivalDeploymentQuantities,
homeworldRevivalActionBlock,
} from '../game/homeworld-revival-deployment-options';
@@ -82,11 +83,31 @@ function markup(game: GameView, busy = false) {
);
}
+function allocated(remaining = 3, placed = 0): GameView {
+ const view = fixture(), offer = view.homeworldRevivalDeployment!;
+ offer.elite = remaining;
+ offer.allocation = { total: remaining + placed, remaining, placed, kept: 0 };
+ return view;
+}
+type ButtonProps = { children?: ReactNode; [key: string]: unknown };
+function elements(node: ReactNode): ReactElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function deploymentButton(props: Parameters[0], action: 'place' | 'reserves') {
+ const button = elements(HomeworldRevivalDeploymentButtons(props)).find(element => element.props['data-revival-deployment-action'] === action);
+ assert.ok(button); return button;
+}
+const click = (button: ReactElement) => (button.props.onClick as () => void)();
+
void test('revival controls commit the entire eligible typed group to one current destination', () => {
for (const kind of ['fedaykin', 'tleilax'] as const) {
const view = fixture(kind);
const offer = view.homeworldRevivalDeployment!;
const before = structuredClone(view);
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [offer.normal + offer.elite]);
+ assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null);
assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'arrakeen:9'), {
blocked: null,
action: {
@@ -109,6 +130,92 @@ void test('revival controls commit the entire eligible typed group to one curren
assert.doesNotMatch(html, /type="number"|type="range"/);
assert.match(html, /Leave in reserves/);
}
+ const advanced = fixture(); advanced.advanced = true; advanced.homeworldRevivalDeployment!.elite = 1;
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(advanced), [1]);
+ assert.equal(homeworldRevivalDeploymentChoice(advanced, 'arrakeen:9').action!.amount, 1);
+ assert.doesNotMatch(markup(advanced), /Starred forces to place now|Provisional per-star interpretation/);
+});
+
+void test('new Basic Southern allocation defaults to one and enumerates native destinations and quantities for every profile', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = allocated(), offer = view.homeworldRevivalDeployment!; view.players[0].bot = profile;
+ for (const key of ['hand', 'spice', 'traitors', 'forces'])
+ Object.defineProperty(view.players[1], key, { get() { throw Error(`No foreign ${key} allocation inspection`); } });
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [1, 2, 3]);
+ assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'hagga_basin:8').action,
+ { type: 'decision', event: offer.event, destination: 'hagga_basin:8', amount: 1 });
+ const actions = homeworldRevivalDeploymentActions(view);
+ assert.deepEqual(actions.slice(0, 3).map(action => [action.destination, action.amount]),
+ [['arrakeen:9', 1], ['arrakeen:9', 2], ['arrakeen:9', 3]]);
+ assert.deepEqual(actions.slice(3, 6).map(action => [action.destination, action.amount]),
+ [['hagga_basin:8', 1], ['hagga_basin:8', 2], ['hagga_basin:8', 3]]);
+ assert.deepEqual(actions[6], { type: 'decision', event: offer.event, decline: true });
+ const html = markup(view); assert.match(html, /3 starred remaining of 3/);
+ assert.match(html, /0 placed · 0 kept in reserves/); assert.match(html, /eligible batch is fixed by this one original revival/);
+ assert.match(html, /Provisional per-star interpretation/); assert.match(html, /no publisher clarification/);
+ assert.match(html, /Starred forces to place now/); assert.match(html, /1<\/option>/);
+ assert.match(html, /Place 1 revived force/); assert.match(html, /Leave all remaining in reserves/);
+ }
+});
+
+void test('allocation metadata and amount bounds fail closed instead of reverting to a whole-group action', () => {
+ for (const allocation of [null,
+ { total: 3, remaining: 2, placed: 0, kept: 0 },
+ { total: 3, remaining: 3, placed: -1, kept: 1 },
+ { total: 3, remaining: 1.5, placed: 1.5, kept: 0 },
+ { total: 21, remaining: 3, placed: 18, kept: 0 },
+ { total: 3, remaining: 3, placed: 0, kept: 0, quantity: 1 },
+ ]) {
+ const view = allocated(); Object.assign(view.homeworldRevivalDeployment!, { allocation });
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
+ assert.match(homeworldRevivalDeploymentChoice(view, 'arrakeen:9').blocked!, /allocation is no longer available/);
+ assert.deepEqual(homeworldRevivalDeploymentActions(view), [{ type: 'decision', event: 'revival-deployment-1', decline: true }]);
+ }
+ for (const amount of [0, -1, 1.5, NaN, Infinity, 4])
+ assert.equal(homeworldRevivalDeploymentChoice(allocated(), 'arrakeen:9', amount).action, null);
+ for (const change of [
+ (view: GameView) => { view.advanced = true; },
+ (view: GameView) => { view.homeworldRevivalDeployment!.kind = 'tleilax'; },
+ (view: GameView) => { view.homeworldRevivalDeployment!.normal = 1; },
+ ]) {
+ const view = allocated(); change(view); assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
+ assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null);
+ }
+});
+
+void test('allocation callbacks submit only the selected native amount or all-remaining decline and honor busy and changed choices', () => {
+ const game = allocated(), sent: unknown[] = [];
+ const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
+ const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
+ assert.equal(place.props.disabled, false); click(place); click(reserves);
+ assert.deepEqual(sent, [{ type: 'decision', event: 'revival-deployment-1', destination: 'arrakeen:9', amount: 2 },
+ { type: 'decision', event: 'revival-deployment-1', decline: true }]);
+ props.busy = true; click(place); click(reserves); assert.equal(sent.length, 2);
+ assert.equal(deploymentButton(props, 'place').props.disabled, true);
+ props.busy = false; game.homeworldRevivalDeployment!.destinations[2].blocked = 'The current arrival must finish.';
+ click(place); click(reserves); assert.equal(sent.length, 2);
+ assert.match(markup(game, true), /]*aria-label="Starred forces to place now"[^>]*disabled/);
+});
+
+void test('each fresh child event resets the quantity and rejects old callbacks until original arrival children finish', () => {
+ const game = allocated(), sent: unknown[] = [];
+ const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
+ const oldKey = HomeworldRevivalDeployment(props)!.key;
+ const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
+ game.response = { kind: 'revivalIncome', owner: 'a', passed: [] };
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(game), []); click(place); click(reserves); assert.equal(sent.length, 0);
+ game.response = null;
+ game.homeworldRevivalDeployment = { ...game.homeworldRevivalDeployment!, event: 'revival-deployment-2', elite: 1,
+ allocation: { total: 3, remaining: 1, placed: 2, kept: 0 } };
+ assert.equal(homeworldRevivalDeploymentCanAct(game), false);
+ game.decision = { kind: 'homeworldRevivalDeployment', player: 'f', event: 'revival-deployment-2' };
+ click(place); click(reserves); assert.equal(sent.length, 0);
+ assert.notEqual(HomeworldRevivalDeployment(props)!.key, oldKey);
+ assert.deepEqual(homeworldRevivalDeploymentQuantities(game), [1]);
+ const html = markup(game); assert.match(html, /1 starred remaining of 3 · 2 placed/);
+ assert.match(html, /1<\/option>/); assert.match(html, /Place 1 revived force/);
+ assert.deepEqual(homeworldRevivalDeploymentChoice(game, 'hagga_basin:8').action,
+ { type: 'decision', event: 'revival-deployment-2', destination: 'hagga_basin:8', amount: 1 });
});
void test('current projected restrictions invalidate stale selections and preserve a decline path', () => {
diff --git a/tests/homeworld-revival-deployment-engine.test.ts b/tests/homeworld-revival-deployment-engine.test.ts
index ff045c3d..9edd1e34 100644
--- a/tests/homeworld-revival-deployment-engine.test.ts
+++ b/tests/homeworld-revival-deployment-engine.test.ts
@@ -75,8 +75,8 @@ void test('actual high Southern revival moves every newly revived Basic star onc
);
stable(g);
const action = choice(g, 'f');
- for (const amount of [0, 1, 3])
- reject(g, 'f', { ...action, amount }, /entire/);
+ for (const amount of [0, 3])
+ reject(g, 'f', { ...action, amount }, /Fedaykin must be an integer/);
g = applyAction(reload(g), 'f', action);
assert.equal(p(g, 'f').reserves, old.reserves + 1);
assert.equal(p(g, 'f').elites!.reserves, old.elites!.reserves);
@@ -346,13 +346,18 @@ void test('real deployment projections expose destinations only to their owner a
assert.deepEqual(homeworldRevivalDeploymentActions(observer), []);
assert.equal(observer.decision?.kind, 'homeworldRevivalDeployment');
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
- const view = viewGame(reload(original), 'f');
- view.players.find((player) => player.id === 'f')!.bot = profile;
- const proposed = homeworldRevivalDeploymentActions(view);
- assert.equal(proposed.length, 1);
- assert.deepEqual(botActions(view), proposed);
- const done = applyAction(reload(original), 'f', proposed[0]);
+ let done = reload(original), choices = 0;
+ while (done.homeworldRevivalReturn!.stage !== 'complete' && choices < 3) {
+ const view = viewGame(reload(done), 'f');
+ view.players.find((player) => player.id === 'f')!.bot = profile;
+ const proposed = homeworldRevivalDeploymentActions(view);
+ assert.ok(proposed.length > 0);
+ assert.deepEqual(botActions(view), proposed);
+ done = applyAction(reload(done), 'f', proposed[0]);
+ choices++;
+ }
assert.equal(done.homeworldRevivalReturn!.stage, 'complete');
+ assert.ok(choices > 0 && choices <= 2);
assert.equal(viewGame(done, 'f').homeworldRevivalDeployment, null);
stable(done);
}
diff --git a/tests/homeworld-revival-destinations.test.ts b/tests/homeworld-revival-destinations.test.ts
index b3c90ffd..53a26df6 100644
--- a/tests/homeworld-revival-destinations.test.ts
+++ b/tests/homeworld-revival-destinations.test.ts
@@ -27,6 +27,41 @@ function fixture(faction: 'fremen' | 'tleilaxu', advanced = true) {
return { g, grant };
}
+void test('Southern subset quotes debit only selected original stars while omitted quantities retain whole-group behavior', () => {
+ const { g, grant } = fixture('fremen', false), before = structuredClone(g);
+ const one = quoteHomeworldRevivalDestination(g, 'p', grant, 'arrakeen:10', 1);
+ assert.equal(one.transfer.players.find(player => player.id === 'p')!.reserves, 9);
+ assert.equal(one.transfer.players.find(player => player.id === 'p')!.eliteReserves, 1);
+ const full = quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0');
+ assert.equal(full.transfer.players.find(player => player.id === 'p')!.reserves, 8);
+ assert.equal(full.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
+ for (const amount of [0, -1, 1.5, 3, NaN, Infinity])
+ assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0', amount));
+ const t = fixture('tleilaxu', false);
+ assert.throws(() => quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0', 1));
+ assert.equal(quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0').transfer.players
+ .find(player => player.id === 'p')!.reserves, 9);
+ assert.deepEqual(g, before);
+});
+
+void test('later Southern groups retain the fixed earned grant below threshold but quote current destination presence', () => {
+ const { g, grant } = fixture('fremen', false);
+ g.players[0].reserves = 6;
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ const originalGrant = { ...grant, beforePopulation: 3, afterPopulation: 6 };
+ const first = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1);
+ const owner = first.transfer.players.find(player => player.id === 'p')!;
+ g.players[0].reserves = owner.reserves; g.players[0].elites!.reserves = owner.eliteReserves;
+ g.homeworlds.custody = first.transfer.state;
+ assert.equal(g.players[0].reserves, 5);
+ // A completed first child may change the board; never reuse its destination quote.
+ g.players[0].forces = { 'polar_sink:0': 1 };
+ assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1));
+ const second = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'polar_sink:0', 1);
+ assert.equal(second.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
+ assert.deepEqual(originalGrant, { ...grant, beforePopulation: 3, afterPopulation: 6 });
+});
+
void test('Southern lists existing territories only and withdraws the entire typed star group without mutation', () => {
const { g, grant } = fixture('fremen', false);
const before = structuredClone(g);
diff --git a/tests/homeworld-revival-return.test.ts b/tests/homeworld-revival-return.test.ts
index a461cb14..97881fba 100644
--- a/tests/homeworld-revival-return.test.ts
+++ b/tests/homeworld-revival-return.test.ts
@@ -10,9 +10,133 @@ import {
validateHomeworldRevivalReturn,
homeworldRevivalResumeSignature,
homeworldRevivalArrivalSignature,
+ makeSouthernRevivalAllocation,
+ southernRevivalRemaining,
+ southernRevivalSummary,
+ currentHomeworldRevivalArrival,
+ currentHomeworldRevivalGroup,
+ openSouthernRevivalChoice,
+ commitSouthernRevivalChoice,
+ completeSouthernRevivalChild,
+ appendHomeworldRevivalAmbassador,
+ completeHomeworldRevivalAmbassador,
type HomeworldRevivalReturn,
} from '../game/homeworld-revival-return';
+function southernFixture(stars = 3) {
+ const { g, original } = fixture();
+ g.advanced = false;
+ original.group.elite = stars;
+ original.quote.elite = stars;
+ g.players[0].elites!.reserves = stars;
+ const root = makeSouthernRevivalAllocation(original);
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ return { g, root, original };
+}
+function openSouthern(root: HomeworldRevivalReturn, event: string, response: Game['response'] = null) {
+ root.resumeResponse = structuredClone(response);
+ root.resumeSignature = homeworldRevivalResumeSignature(response);
+ openSouthernRevivalChoice(root, event);
+}
+
+void test('Southern v2 allocates three original stars to distinct current groups and reserves while holding one original income', () => {
+ const { g, root } = southernFixture();
+ validateHomeworldRevivalReturn(g, root);
+ assert.equal(currentHomeworldRevivalArrival(root), null);
+ assert.equal(currentHomeworldRevivalGroup(root), null);
+ const response: Game['response'] = { kind: 'revivalIncome', owner: 'v', recipient: 'p', amount: 1, passed: [] };
+ openSouthern(root, 'child-1', response);
+ const original = structuredClone({ event: root.event, source: root.source, group: root.group, quote: root.quote,
+ signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature });
+ const destinations = ['arrakeen:10', 'polar_sink:0', 'decline'];
+ for (const [index, destination] of destinations.entries()) {
+ const event = `child-${index + 1}`;
+ assert.equal(currentHomeworldRevivalArrival(root)!.event, event);
+ assert.equal(currentHomeworldRevivalGroup(root), null);
+ commitSouthernRevivalChoice(root, event, { amount: 1, destination });
+ assert.deepEqual(currentHomeworldRevivalGroup(root), { amount: 1, elite: 1 });
+ if (destination !== 'decline') { g.players[0].reserves--; g.players[0].elites!.reserves--; }
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ validateHomeworldRevivalReturn(g, root);
+ completeSouthernRevivalChild(root, event, index < 2 ? `child-${index + 2}` : undefined);
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ validateHomeworldRevivalReturn(g, root);
+ }
+ assert.equal(root.stage, 'complete');
+ assert.deepEqual(southernRevivalSummary(root), { total: 3, remaining: 0, placed: 2, kept: 1 });
+ assert.equal(g.players[0].elites!.reserves, 1);
+ assert.equal(root.allocation!.children.length, 3);
+ assert.deepEqual({ event: root.event, source: root.source, group: root.group, quote: root.quote,
+ signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature }, original);
+ validateHomeworldRevivalReturn(g, JSON.parse(JSON.stringify(root)) as HomeworldRevivalReturn);
+});
+
+void test('Southern v2 is a fresh Basic multi-star grant while minimal legacy identities and original whole frames remain unchanged', () => {
+ const legacy = fixture(), before = JSON.stringify(legacy.frame);
+ assert.equal(currentHomeworldRevivalArrival(legacy.frame), legacy.frame);
+ assert.deepEqual(currentHomeworldRevivalGroup(legacy.frame), { amount: 2, elite: 2 });
+ const minimal = { event: 'legacy', turn: 1, phase: 4, stage: 'arrival' as const, destination: 'arrakeen:10' };
+ assert.equal(currentHomeworldRevivalArrival(minimal), minimal);
+ assert.equal(currentHomeworldRevivalArrival(null), null);
+ assert.equal(currentHomeworldRevivalArrival(undefined), null);
+ assert.equal(JSON.stringify(legacy.frame), before);
+ assert.equal('allocation' in legacy.frame, false);
+ assert.throws(() => makeSouthernRevivalAllocation(fixture('tleilax').original));
+ const one = fixture().original; one.group.elite = 1; one.quote.elite = 1;
+ assert.throws(() => makeSouthernRevivalAllocation(one));
+ const { g, root } = southernFixture(2);
+ assert.throws(() => validateHomeworldRevivalReturn({ ...g, advanced: true }, root));
+ validateHomeworldRevivalReturn(g, root);
+});
+
+void test('Southern child selection rejects stale events, duplicate decisions and over-allocation before changing the root', () => {
+ const { g, root } = southernFixture(2);
+ openSouthern(root, 'child-1');
+ for (const selection of [{ amount: 0, destination: 'decline' }, { amount: 3, destination: 'arrakeen:10' },
+ { amount: 1.5, destination: 'polar_sink:0' }, { amount: 1, destination: '' }]) {
+ const before = structuredClone(root);
+ assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', selection));
+ assert.deepEqual(root, before);
+ }
+ commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' });
+ const before = structuredClone(root);
+ for (const next of [undefined, 'child-1', root.event]) {
+ assert.throws(() => completeSouthernRevivalChild(root, 'child-1', next));
+ assert.deepEqual(root, before);
+ }
+ completeSouthernRevivalChild(root, 'child-1', 'child-2');
+ assert.equal(southernRevivalRemaining(root), 1);
+ assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' }));
+ assert.throws(() => commitSouthernRevivalChoice(root, 'child-2', { amount: 2, destination: 'decline' }));
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ validateHomeworldRevivalReturn(g, root);
+ const changed = structuredClone(root); changed.allocation!.children[0].amount = 2;
+ assert.throws(() => makeHomeworldRevivalProgress(changed));
+ const regranted = structuredClone(root); regranted.quote.afterPopulation++;
+ assert.throws(() => makeHomeworldRevivalProgress(regranted));
+});
+
+void test('Southern children retain their own Ambassador completion before the next unique decision', () => {
+ const { g, root } = southernFixture(2);
+ openSouthern(root, 'child-1');
+ commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'arrakeen:10' });
+ g.players[0].reserves--; g.players[0].elites!.reserves--;
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ const ambassador = { event: 'child-1-ambassador', entrant: 'p', territory: 'arrakeen', sector: 10, turn: 2, phase: 4 };
+ appendHomeworldRevivalAmbassador(root, ambassador);
+ const pending = { ...ambassador, revivalEvent: 'child-1' } as NonNullable;
+ validateHomeworldRevivalReturn({ ...g, pendingAmbassador: pending }, root);
+ assert.throws(() => completeSouthernRevivalChild(root, 'child-1', 'child-2'));
+ completeHomeworldRevivalAmbassador(root, ambassador.event);
+ validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
+ completeSouthernRevivalChild(root, 'child-1', 'child-2');
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ assert.equal(root.allocation!.children[0].ambassadors![0].completed, true);
+ assert.equal(currentHomeworldRevivalArrival(root)!.event, 'child-2');
+ assert.throws(() => completeHomeworldRevivalAmbassador(root, ambassador.event));
+ validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
+});
+
function fixture(
kind: 'fedaykin' | 'tleilax' = 'fedaykin',
source: 'normal' | 'emperorExtra' | 'ghola' | 'nexusTleilaxu' = 'normal',
diff --git a/tests/revival-terror-entry.test.ts b/tests/revival-terror-entry.test.ts
index 99df5eae..d7da4d24 100644
--- a/tests/revival-terror-entry.test.ts
+++ b/tests/revival-terror-entry.test.ts
@@ -4,7 +4,9 @@ import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { appendHomeworldArrivalAmbassador, homeworldArrivalSignature } from '../game/homeworld-arrival';
-import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress } from '../game/homeworld-revival-return';
+import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress, makeSouthernRevivalAllocation,
+ currentHomeworldRevivalArrival, openSouthernRevivalChoice, commitSouthernRevivalChoice,
+ completeSouthernRevivalChild } from '../game/homeworld-revival-return';
import { terrorEntrySignature, validateTerrorEntrySignature } from '../game/terror-entry-receipt';
import {
revivalOwnsTerrorEntry, validateRevivalTerrorEntry, validateRevivalArrivalSource,
@@ -37,6 +39,34 @@ function fixture(fremen = false) {
entry.entrySignature = terrorEntrySignature(entry);
return { g, frame, entry };
}
+
+void test('Southern live Terror source binds each actual child subset and never the whole root or a retired child', () => {
+ const { g, entry } = fixture(true);
+ g.players[0].reserves = 3; g.players[0].elites!.reserves = 2;
+ const root = makeSouthernRevivalAllocation({ event: 'southern-root', turn: 2, phase: 4, player: 'native', source: 'normal',
+ group: { amount: 2, elite: 2, free: 2 },
+ quote: { kind: 'fedaykin', normal: 0, elite: 2, beforePopulation: 1, afterPopulation: 3, blocked: null } });
+ root.resumeResponse = null; root.resumeSignature = 'null';
+ openSouthernRevivalChoice(root, 'southern-child-1');
+ commitSouthernRevivalChoice(root, 'southern-child-1', { amount: 1, destination: 'arrakeen:10' });
+ g.homeworldRevivalReturn = root; g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ Object.assign(entry, { amount: 1, elite: 1, revivalArrival: { event: 'southern-child-1' } });
+ entry.entrySignature = terrorEntrySignature(entry);
+ validateRevivalTerrorEntry(g, entry);
+ assert.throws(() => validateRevivalArrivalSource(g, { event: root.event }, entry), HomeworldCustodyError);
+ assert.throws(() => validateRevivalArrivalSource(g, entry.revivalArrival!, { ...entry, amount: 2, elite: 2 }), HomeworldCustodyError);
+ const historical = packet(entry);
+ validateRevivalTerrorPacket({ ...g, revivalPlayer: root.player }, historical, 'arrakeen:10', 'southern-child-1');
+ completeSouthernRevivalChild(root, 'southern-child-1', 'southern-child-2');
+ commitSouthernRevivalChoice(root, 'southern-child-2', { amount: 1, destination: 'polar_sink:0' });
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
+ assert.equal(currentHomeworldRevivalArrival(root)!.event, 'southern-child-2');
+ assert.throws(() => validateRevivalTerrorEntry(g, entry), HomeworldCustodyError);
+ // The old captured history remains historical without reopening its live source.
+ validateRevivalTerrorPacket({ ...g, revivalPlayer: root.player }, historical, 'arrakeen:10', 'southern-child-1');
+ validateRevivalArrivalSource(g, { event: 'southern-child-2' },
+ { entrant: 'native', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, cause: 'homeworldRevival' });
+});
function spiritual(parent: RevivalEntryParent, territory = 'arrakeen', sector = 10): RevivalEntryParent {
return { ...structuredClone(parent), spiritual: { event: 'original-spiritual-packet', owner: 'bg', turn: 2, phase: 4,
territory, sector, amount: 1, revivalEvent: parent.event } };
diff --git a/tests/southern-allocation-runtime.test.ts b/tests/southern-allocation-runtime.test.ts
new file mode 100644
index 00000000..fe099acc
--- /dev/null
+++ b/tests/southern-allocation-runtime.test.ts
@@ -0,0 +1,231 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import type { Difficulty } from '../game/bot-profiles';
+import { homeworldRevivalDeploymentActions } from '../game/homeworld-revival-deployment-options';
+import {
+ actSouthernAllocation as act, advanceSouthernAllocation as advance,
+ createSouthernAllocationFixture as fixture, openSouthernAllocation as open,
+ settleSouthernAllocation as settle, southernAllocationAction as choice,
+ southernAllocationClean as clean, southernAllocationInventory as inventory,
+ southernPlayer as p, southernReload as reload, type SouthernAllocationStep,
+} from './fixture-southern-allocation';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = reload(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before, 'Rejected allocation leaves the native return and all physical custody unchanged');
+}
+function offer(game: Game) {
+ const current = viewGame(game, 'fremen').homeworldRevivalDeployment; assert.ok(current); return current;
+}
+function allocation(game: Game) {
+ const current = offer(game).allocation; assert.ok(current); return current;
+}
+function finish(game: Game, actions: SouthernAllocationStep[] = []) {
+ game = settle(game, actions);
+ assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
+ assert.equal(game.decision ?? null, null);
+ inventory(game);
+ return game;
+}
+function unchangedUses(before: Game, after: Game) {
+ for (const key of ['spice', 'shipped', 'moved', 'revived', 'freeForcesRevived'] as const)
+ assert.equal(p(after)[key], p(before)[key], key);
+ assert.deepEqual(after.homeworldRevivalReturn?.group, before.homeworldRevivalReturn?.group);
+}
+
+void test('genuine Southern source earns three starred Tanks through original setup paid invasion and battle', () => {
+ const f = fixture();
+ assert.equal(f.sourceCounts.setup, f.setupSteps.length);
+ assert.equal(f.sourceCounts.throughBattle, f.setupSteps.length + f.battleSteps.length);
+ assert.equal(f.sourceCounts.source, f.actions.length);
+ assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [0, 0, 1, 1]);
+ assert.equal(f.shipmentCost, 1);
+ assert.ok(f.actions.some(step => step.actor === 'emperor' && step.action.type === 'ship' && step.action.amount === 1));
+ assert.equal(p(f.afterBattle).tanks, 3); assert.equal(p(f.afterBattle).elites?.tanks, 3);
+ assert.equal(p(f.afterBattle).forces['sietch_tabr:14'], 1);
+ assert.equal(p(f.game).reserves, 10); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
+ assert.equal(f.game.advanced, false); assert.equal(f.game.storm, 3);
+ assert.deepEqual(inventory(f.game), f.original);
+ assert.equal(f.original.cards.length, f.setup.deck.length);
+ assert.ok(f.original.traitors.length > 0 && f.original.spice.length > 0);
+ assert.equal(f.game.homeworldRevivalReturn ?? null, null, 'The source has not installed or played its upcoming revival');
+});
+
+void test('one genuine free Basic return opens its original three-star allocation without spending shipment or movement', () => {
+ const f = fixture(), game = open(f);
+ assert.equal(p(game).spice, p(f.game).spice);
+ assert.equal(p(game).tanks, 0); assert.equal(p(game).reserves, 13);
+ assert.equal(p(game).elites?.reserves, 3); assert.equal(p(game).elites?.revived, 3);
+ assert.equal(p(game).revived, 3); assert.equal(p(game).freeForcesRevived, 3);
+ assert.deepEqual(game.homeworldRevivalReturn?.group, { amount: 3, elite: 3, free: 3 });
+ assert.equal(offer(game).normal, 0); assert.equal(offer(game).elite, 3);
+ assert.deepEqual(allocation(game), { total: 3, remaining: 3, placed: 0, kept: 0 });
+ assert.equal(p(game).shipped, p(f.game).shipped); assert.equal(p(game).moved, p(f.game).moved);
+ assert.ok(offer(game).destinations.filter(destination => !destination.blocked).length >= 2);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('three returned Southern stars split one plus two between distinct native occupied territories', () => {
+ const f = fixture(); let game = open(f);
+ const returned = reload(game), first = choice(game, 'false_wall_south:5', 1);
+ game = settle(act(reload(game), 'fremen', first));
+ assert.equal(p(game).forces['false_wall_south:5'], 4);
+ assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
+ assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
+ assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
+ assert.notEqual(offer(game).event, first.event);
+ assert.equal(offer(game).elite, 2);
+ game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2)));
+ assert.equal(p(game).forces['false_wall_west:18'], 5);
+ assert.equal(p(game).elites?.forces['false_wall_west:18'], 2);
+ assert.equal(p(game).reserves, 10); assert.equal(p(game).elites?.reserves, 0);
+ unchangedUses(returned, game);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('one star may be placed and every remaining returned star kept at Southern Hemisphere', () => {
+ const f = fixture(); let game = open(f);
+ const returned = reload(game);
+ game = settle(act(game, 'fremen', choice(game, 'sietch_tabr:14', 1)));
+ const last = offer(game).event;
+ game = finish(act(reload(game), 'fremen', { type: 'decision', event: last, decline: true }));
+ assert.equal(p(game).forces['sietch_tabr:14'], 2);
+ assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
+ assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
+ assert.equal(p(game).tanks, 0);
+ unchangedUses(returned, game);
+ reject(game, 'fremen', { type: 'decision', event: last, decline: true });
+});
+
+void test('legacy whole-group packet still places all three stars at one native destination', () => {
+ const f = fixture(); let game = open(f);
+ const returned = reload(game), packet = choice(game, 'sietch_tabr:14', 3);
+ game = finish(act(reload(game), 'fremen', packet));
+ assert.equal(p(game).forces['sietch_tabr:14'], 4);
+ assert.equal(p(game).elites?.forces['sietch_tabr:14'], 3);
+ assert.equal(p(game).reserves, 10);
+ unchangedUses(returned, game);
+ reject(game, 'fremen', packet);
+});
+
+void test('declining the first child keeps the exact complete returned group without undoing revival', () => {
+ const f = fixture(); let game = open(f);
+ const returned = reload(game), event = offer(game).event;
+ game = finish(act(reload(game), 'fremen', { type: 'decision', event, decline: true }));
+ assert.equal(p(game).reserves, 13); assert.equal(p(game).elites?.reserves, 3);
+ assert.equal(p(game).tanks, 0); assert.equal(p(game).elites?.tanks, 0);
+ assert.deepEqual(p(game).forces, p(f.game).forces);
+ assert.deepEqual(p(game).elites?.forces, p(f.game).elites?.forces);
+ unchangedUses(returned, game);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('fresh child event fences stale packets overdraw wrong owner and an unearned destination', () => {
+ let game = open(fixture());
+ const old = choice(game, 'false_wall_south:5', 1);
+ game = settle(act(game, 'fremen', old));
+ const current = choice(game, 'false_wall_west:18', 1);
+ reject(game, 'fremen', old);
+ reject(game, 'emperor', current);
+ for (const amount of [0, -1, 3, 1.5]) reject(game, 'fremen', { ...current, amount });
+ reject(game, 'fremen', { ...current, destination: 'arrakeen:10' });
+ reject(game, 'fremen', { ...current, destination: 'homeworld:fremen' });
+ reject(game, 'fremen', { type: 'decision', event: current.event, decline: true, amount: 1 });
+ assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
+});
+
+void test('pending allocations keep the original Revival control owned until all stars are disposed', () => {
+ let game = open(fixture());
+ reject(game, 'fremen', { type: 'ready' });
+ reject(game, 'fremen', { type: 'revive', amount: 1, elite: 0 });
+ reject(game, 'emperor', { type: 'revive', amount: 1, elite: 0 });
+ game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
+ reject(game, 'fremen', { type: 'ready' });
+ game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
+ game = advance(game, state => state.phase === 5 && clean(state));
+ assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
+});
+
+void test('two actual starred casualties produce only their exact two-star native allocation', () => {
+ const f = fixture({ stars: 2 }); let game = open(f);
+ assert.equal(p(f.game).elites?.forces['sietch_tabr:14'], 1);
+ assert.deepEqual(allocation(game), { total: 2, remaining: 2, placed: 0, kept: 0 });
+ game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
+ assert.equal(offer(game).elite, 1);
+ game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 1)));
+ assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
+ assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
+ assert.equal(p(game).elites?.forces['false_wall_west:18'], 1);
+ assert.equal(p(game).revived, 2);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('original one-spice Tleilax free-revival income waits across every child and settles once after final disposition', () => {
+ const f = fixture({ third: 'tleilaxu' }); let game = open(f);
+ const provider = p(game, 'tleilaxu').spice;
+ const returned = reload(game);
+ assert.equal(game.homeworldRevivalReturn?.resumeResponse?.kind, 'revivalIncome');
+ const income = game.homeworldRevivalReturn!.resumeResponse!.amount;
+ assert.equal(income, 1, 'Normal free revival grants one native bank payment per faction and turn, regardless of returned group size');
+ assert.equal(game.revivalFreeIncome?.fremen, game.turn);
+ game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
+ assert.equal(p(game, 'tleilaxu').spice, provider);
+ assert.equal(game.response ?? null, null);
+ game = act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2));
+ assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
+ assert.equal(game.response ?? null, null, 'Native normalization settles the restored income when nobody owns a cancellation choice');
+ assert.equal(p(game, 'tleilaxu').spice, provider + income!);
+ assert.equal(game.revivalFreeIncome?.fremen, game.turn);
+ game = finish(game);
+ assert.equal(p(game, 'tleilaxu').spice, provider + income!);
+ unchangedUses(returned, game);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('actual native Ecaz arrival finishes before the next fresh Southern child becomes available', () => {
+ const f = fixture({ third: 'ecaz' }); let game = open(f);
+ assert.ok(f.actions.some(step => step.actor === 'ecaz' && step.action.type === 'decision' && step.action.territory === 'sietch_tabr'));
+ const first = choice(game, 'sietch_tabr:14', 1);
+ game = act(game, 'fremen', first);
+ assert.equal(game.homeworldRevivalReturn?.stage, 'arrival');
+ assert.equal(game.pendingAmbassador?.stage, 'offer');
+ assert.equal(game.pendingAmbassador?.entrant, 'fremen');
+ assert.equal(game.pendingAmbassador?.phase, 4);
+ assert.equal(viewGame(game, 'fremen').homeworldRevivalDeployment ?? null, null);
+ assert.deepEqual(homeworldRevivalDeploymentActions(viewGame(game, 'fremen')), []);
+ reject(game, 'fremen', { ...first, amount: 2 });
+ const parent = game.pendingAmbassador!.event;
+ game = act(reload(game), 'ecaz', { type: 'decision', event: parent, decline: true });
+ game = settle(game);
+ assert.equal(game.pendingAmbassador ?? null, null);
+ assert.notEqual(offer(game).event, first.event);
+ assert.equal(offer(game).elite, 2);
+ assert.ok(offer(game).destinations.some(destination => destination.id === 'false_wall_west:18' && !destination.blocked));
+ game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
+ assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
+ assert.equal(p(game).elites?.reserves, 2);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('all four minimal profiles consume only exact current native Southern choices', () => {
+ const f = fixture();
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
+ let game = open(f), decisions = 0;
+ while (game.homeworldRevivalReturn?.stage !== 'complete' && decisions < 4) {
+ const view = viewGame(reload(game), 'fremen');
+ view.players.find(player => player.id === 'fremen')!.bot = difficulty;
+ const offered = homeworldRevivalDeploymentActions(view); assert.ok(offered.length);
+ const action = botActions(view).find(action => action.type === 'decision' && action.event === offer(game).event);
+ assert.ok(action, `${difficulty} consumes its current owner-view allocation`);
+ assert.ok(offered.some(legal => JSON.stringify(legal) === JSON.stringify(action)));
+ game = settle(act(game, 'fremen', action)); decisions++;
+ }
+ game = finish(game);
+ assert.ok(decisions > 0 && decisions <= 3);
+ assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
+ assert.deepEqual(inventory(game), f.original);
+ }
+});