Integrate Caladan victory placement and entry reactions

This commit is contained in:
Quintine committed 2026-10-27 14:39:27 +11:00
1 parent 97edc3eac8
commit 2e4aba9630
18 files changed
+789 -76

No files matched your search

+14 -4
View File
@@ -40,6 +40,17 @@ function ReinforcementChoice({ game, act, busy }: Props) {
<p>
Add one reserve force at the battle location, or leave it in reserves.
</p>
<p className="fine" aria-label="Caladan arrival rules">
This force joins the original surviving winner at the original battle location, including inside
the Hidden Mobile Stronghold. Its direct-shipment ban does not block this placement. No spice,
shipment, movement or revival allowance is spent, and no Spiritual Advisor opportunity is created.
HMS entry is provisional. Storm-sector entry remains unavailable.
</p>
<p className="fine" aria-label="Provisional Caladan arrival reactions">
{game.advanced && 'Advanced Bene Gesserit Intrusion resolves first. '}
Provisional arrival classification: eligible Ambassador and Terror reactions then follow their
original arrival order.
</p>
<p className="fine" aria-label="Provisional Face Dance order">
Provisional order: this optional force joins the original victorious
army before any later Face Dance. If replacement occurs, it returns with
@@ -80,7 +91,7 @@ function ReinforcementChoice({ game, act, busy }: Props) {
className="game-action"
disabled={busy || !choice.action}
onClick={() => {
if (choice.action) act(choice.action);
if (!busy && choice.action) act(choice.action);
}}
>
Add one reserve force
@@ -89,9 +100,8 @@ function ReinforcementChoice({ game, act, busy }: Props) {
className="game-action"
variant="outline"
disabled={busy || !canAct}
onClick={() =>
act({ type: 'decision', event: offer.event, decline: true })
}
onClick={() => { if (!busy && canAct)
act({ type: 'decision', event: offer.event, decline: true }); }}
>
Leave in reserves
</Button>
+31
View File
@@ -20,6 +20,37 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Caladan victory arrival — 27 October. Prototyped / Partial.**
The original one-counter reserve addition now reaches a won HMS provisionally,
resolves Advanced BG Intrusion first, then opens the original Ambassador/Terror
entry. All six original Terror effects and both owner orders retain the victory
event; queued reactions finish before the battle suffix resumes. Storm-sector
entry remains unavailable conservatively. No spice/normal action or Spiritual
Advisor shipment source is added. HMS/Terror composition remains provisional.
[Source and contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026).
Frozen **12-file union97/97**, types/lint/build and twelve controlled runtime
journeys pass:39 GameTable SSR,12 actual chooser SSR and12 shared-adapter packets;
recorded battle/further actions91/32. These are controlled audit setups with
staged later armies, purses, original cards, token supply/order and a separate
contest retaining Battle phase. Actual battle/plan/call packets create each
victory; no reinforcement/entry/reaction receipt is installed. HMS uses its actual
Ix setup placement. The stateful chooser keeps its real initial target; no
React handler, browser/navigation/mobile, native acquisition or deployed claim.
Feedback fixed the storm test's actual sector13, kept a real Karama for the
preventable BG flip, supplied the actual optional Sabotage gift card and finished
Ix placement after playing starts. Six old World Face Dance failures exposed an
invalid controlled source under existing Tleilax occupation immunity. Its repaired
A3/Guild2 visitor battle retains noncombatant nativeT2, both production guards,
3/4 replacement from Polar and the later A–T battle on decline. Failed v1 stays
preserved; v2 verifies the frozen source plus a final three-document overlay.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-arrival-proof-v2/`;
journeys/HTML: adjacent `20261027-caladan-arrival-runtime/run-v1/`.
Existing server HTTP200 without restart or saved-game/database operation.
Nested Sneak/Skills/Semuta compositions, full modes and final assurance/deployed
acceptance remain open; full AI refinement stays deferred.
**Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.**
Native Kaitain/Salusa transfers may now be first/second groups alongside ground
movement or another distinct World group. Actual normal/Sardaukar pools and
+54 -8
View File
@@ -66,15 +66,54 @@ Existing Intrusion and fighter/advisor arrival rules still apply. The source
retains its existing payload shape, allowing an already-open exact child to
continue without rewriting its parent or creating another reinforcement.
## Unresolved ordering and entry combinations
## Victory placement and arrival reactions — 27 October 2026
The selected Face Dance order above supersedes the earlier decline-only guard.
Storm-sector placement, Hidden Mobile Stronghold classification, and otherwise
relevant Intrusion/Terror reactions remain explicit source boundaries.
Destination guards use public physical presence and token placement, never a
secret Terror identity. Low Grumman's entering-force minimum still determines
whether its Terror reaction would be relevant to the one arriving counter.
The original Caladan face supplies a separate one-counter addition, not an
ordinary shipment, revival or Force Movement. Under standing recommended-option
authority, provisionally let this specific grant add to an already-surviving
victorious army inside HMS. E1p9/supplied Advanced physical34/printed32 bans other
factions' direct shipments and normally requires Force Movement through the
pointer territory; this specific card composition is not a publisher exception
example or a general relaxation of those routes. The sheltered interior0 does
not inherit the pointer's storm sector. Normal occupancy still applies.
Supplied physical10/printed8 forbids ordinary shipment/movement into storm.
With no retrieved Caladan exception, retain that destination-sector prohibition
conservatively. This is an explicit composition, not a quoted Caladan storm rule.
Advanced Intrusion says another faction **enters** (supplied physical28/printed26).
Place the counter once, offer the relevant BG fighter/advisor decision, then
process the original Ambassador/Terror arrival. Caladan itself creates no free
Spiritual Advisor shipment opportunity. Homeworlds remain separate from Arrakis
territories. Terror's ships/moves trigger (physical35/printed33) requires a
provisional classification of this addition; its native token effects and
original public one-counter minimum remain unchanged. The source lane found no
specific publisher/designer combined-effect ruling. Native Homeworld powers
retain E3p10 Karama immunity; canceling an independent BG reaction does not
cancel or refund the reinforcement.
The original victory event binds queued entry, Intrusion and the signed Terror
receipt. Original Ambassador/Terror overlap uses storm order, and an Ambassador
opened second retains the same victory history. The reinforcement waits for
its arrival queue/overlap before resuming battle cleanup. Original ordinary
receipt/signature bytes remain unchanged when the new event field is absent.
Frozen twelve-file97/97, types/lint/build and twelve controlled engine/GameTable
SSR programmes pass. Fifteen new engine cases and four chooser cases accompany
related native/Face Dance/Ambassador/discard regressions. Runtime records39 table
and12 chooser renders,12 shared-adapter one-force packets and91/32 recorded
battle/further actions. Controlled setup/positions/cards/token order are explicit;
actual battles create every original grant. A separate controlled contest prevents
unrelated Collection income from obscuring arrival outcomes. No stateful React
handler, browser/navigation/mobile, native acquisition or deployed proof is claimed.
The first failed batch's new fixtures were repaired; the older native-World
Face Dance source correction is explained below without weakening production.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-arrival-proof-v2/`;
journeys: adjacent `20261027-caladan-arrival-runtime/run-v1/`.
Nested Sneak/Skill/Semuta compositions, complete rules and final assurance/browser/
deployed acceptance remain open. No saved-room/database/restart operation.
The [integration audit](HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md) maps
battle cleanup and persistence. The
@@ -88,8 +127,15 @@ Frozen15-file189/189, types/lint/build and sixteen engine/GameTable SSR journeys
pass. Eight genuine public Basic59/Advanced124-action setup/shipment/battle
sources keep all47 Treachery and16 original Traitor/Face Dancer identities;
all give/leave and later reveal/decline combinations use the actual10/11 cap.
Six separately controlled native-Homeworld sources prove3/4 replacement and
Caladan9 return with existing occupation-profile bounds intact. Two controlled
The historical six controlled native-World sources used A-versus-native-T
combat; current occupied-Tleilax immunity now prohibits their positive reveal.
The 27 October corrected source instead stages A3/Guild2 visitors with native
T2 as a third noncombatant. Guild takes the losses; A never earns sole Tleilax
occupation, and the native-only Face Dance restriction stays intact. Accepted
replacement takes3/4 original Polar counters, adds them to native T2 and returns
A to Caladan9. Decline retains the later A–T battle; original-event completion
does not claim that battle finished. These remain controlled fixture sources,
not native invasion or acquisition journeys. Two controlled
Ambassador journeys prove both legal accompaniment after Intrusion and automatic
Polar conversion, preserving the original victory and leaving the Fremen token
untouched by advisors. Controlled seams are not original public acquisition proof.
@@ -1,7 +1,8 @@
# Caladan victory reinforcement runtime
The 10 September checkpoint below is historical. The [26 October before-Face-Dance composition](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026)
supersedes its decline-only overlap. Use [current status](CURRENT_STATUS.md)
supersedes its decline-only overlap; the [27 October arrival contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026)
supersedes the HMS/Intrusion/Terror destination guards. Use [current status](CURRENT_STATUS.md)
for present preview admission and proof; this old checkpoint does not certify it.
Development checkpoint, 10 September 2026. This implements the bounded behavior
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 27 October 2026 — Caladan victory arrival
One actual reserve counter now reaches a won HMS provisionally and resolves
Advanced Intrusion before original Ambassador/Terror reactions. The original
victory event survives both owner orders and all six direct Terror effects;
queued entry completes before battle cleanup resumes. Storm stays unavailable
conservatively, with no ordinary action, fee or Spiritual shipment source.
Frozen12-file97/97, types/lint/build and twelve controlled runtime programmes
pass (39 table/12 chooser SSR,12 shared packets;91/32 recorded battle/further
actions). Actual battles create the grants from explicitly staged sources;
stateful React handlers/browser/deployed journeys are unclaimed. Six old World
Face Dance failures were corrected by preserving nativeT2 while A/Guild visitors
fight, retaining both printed production guards. Failed v1 remains; v2 binds the
frozen source and final three-document overlay. No saved-game/database/restart
operation. [Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work)
and [contract/proof](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026).
Full modes, nested compositions, final assurance and deployed acceptance remain
open; this does not certify the full goal.
## 27 October 2026 — Imperial Ornithopter / Homeworld movement
**Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.**
+14
View File
@@ -1,5 +1,19 @@
# Rule decision index
## Caladan victory placement and entry — 27 October 2026
Compose the original Caladan high face's one-counter addition with Advanced
Intrusion's "enters" trigger (supplied physical28/printed26), before the original
Ambassador/Terror reactions. It spends no ordinary action or spice and grants
no Spiritual Advisor shipment source. Under standing recommended-option
authority, HMS placement is a provisional specific-card exception to E1p9/
supplied physical34's normal entry route; Terror's ships/moves trigger at
physical35 requires provisional classification. Keep the ordinary storm-sector
ban conservatively (physical10); sheltered HMS interior0 remains separate from
its pointer. Retain population, survivors, occupancy and Homeworld Karama
immunity. No specific combined publisher ruling was found.
[Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026).
## Physical Ornithopter and Imperial Homeworld movement — 27 October 2026
E3printed10/supplied Advanced physical22/printed20 grant Emperor native Force
+9 -2
View File
@@ -1,4 +1,5 @@
import type { Game } from './engine';
import { caladanOwnsTerrorEntry } from './caladan-arrival';
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
@@ -6,7 +7,8 @@ import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntry
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
type AmbassadorEntry = NonNullable<Game['pendingAmbassador']>;
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order'>;
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order' |
'lastBattleContext' | 'homeworldVictoryReinforcement'>;
function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalEntryParent | undefined) {
return validRevivalEntryParent(left) && validRevivalEntryParent(right) &&
@@ -19,7 +21,8 @@ export function arrivalOverlapSignature(record: Overlap): string {
record.territory, record.sector, record.amount, record.elite, record.cause,
record.resume, record.ecaz, record.moritani, record.first,
record.ambassadorToken, record.terrorTokens,
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)])]);
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]),
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]])]);
}
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
@@ -34,6 +37,8 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
try {
validateRevivalArrivalSource(g, overlap.revivalArrival, overlap);
} catch { return null; }
} else if (overlap.cause === 'caladanReinforcement' || overlap.victoryEvent !== undefined) {
if (!caladanOwnsTerrorEntry(g, overlap)) return null;
} else if (g.phase !== 5 ||
!['shipment', 'movement'].includes(overlap.cause)) return null;
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
@@ -54,6 +59,7 @@ export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: Ambassado
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
entry.territory === overlap.territory && entry.sector === overlap.sector &&
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
(overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event &&
!!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event,
...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }),
@@ -67,6 +73,7 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry,
entry.territory === overlap.territory && entry.sector === overlap.sector &&
entry.amount === overlap.amount && entry.elite === overlap.elite &&
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
(overlap.revivalArrival === undefined ||
sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry));
}
+37
View File
@@ -0,0 +1,37 @@
import type { Game } from './engine';
import { location, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
export type CaladanArrivalContext = Pick<Game, 'status' | 'turn' | 'phase' | 'players' |
'homeworlds' | 'lastBattleContext' | 'homeworldVictoryReinforcement'>;
export type CaladanArrivalDetails = {
victoryEvent?: string;
entrant: string;
territory: string;
sector: number;
amount: number;
elite: number;
cause: string;
};
/** Validate the committed original addition, not the army after a reaction.
* Atomics, alliances and other accepted effects may already have changed it. */
export function validateCaladanArrivalSource(g: CaladanArrivalContext, entry: CaladanArrivalDetails): void {
validateHomeworldVictoryReturn(g);
const frame = g.homeworldVictoryReinforcement;
if (g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' ||
frame.turn !== g.turn || frame.phase !== g.phase || entry.victoryEvent !== frame.event ||
entry.entrant !== frame.player || entry.cause !== 'caladanReinforcement' ||
entry.amount !== 1 || entry.elite !== 0 || !validLocation(entry.territory, entry.sector) ||
frame.destination !== location(entry.territory, entry.sector))
throw new HomeworldCustodyError('The Caladan arrival has lost its original committed victory reinforcement.');
}
export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanArrivalDetails): boolean {
try { validateCaladanArrivalSource(g, entry); return true; }
catch (error) {
if (error instanceof HomeworldCustodyError) return false;
throw error;
}
}
+52 -6
View File
@@ -191,6 +191,7 @@ import { ambassadorPhaseAllowed } from './ambassador-phase';
import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement';
import { validateCaladanArrivalSource } from './caladan-arrival';
import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival';
import {
quoteHomeworldRevivalDeployment,
@@ -1109,6 +1110,7 @@ export type Decision =
}
| {
kind: 'intrusion';
victoryEvent?: string;
player: string;
territory: string;
wormRide?: boolean;
@@ -1193,6 +1195,7 @@ export type ResponseWindow = {
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorVictoryEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
@@ -2046,6 +2049,7 @@ export type Game = {
dukeVidal?: DukeState;
dukeAcquisitionTurn?: number;
pendingTerrorEntry?: {
victoryEvent?: string;
revivalArrival?: RevivalEntryParent;
/** Original public arrival, absent only in legacy saves. */
entrySignature?: string;
@@ -2093,6 +2097,7 @@ export type Game = {
/** Committed entries whose optional reactions must wait for the arrival's own
* pending interaction (payment, advisor choice, storm protection). */
pendingArrivalReaction?: {
victoryEvent?: string;
entrant: string;
territory: string;
sector: number;
@@ -2108,6 +2113,7 @@ export type Game = {
}[] | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
victoryEvent?: string;
revivalArrival?: RevivalEntryParent;
event: string;
signature: string;
@@ -8265,6 +8271,7 @@ function treacheryDiscardIntegrity(g: Game) {
'advisor',
'wormRide',
'homeworldRevival',
'caladanReinforcement',
'ambassador',
].includes(entry.cause) &&
['none', 'wormRide', 'ambassador'].includes(entry.resume) &&
@@ -8311,7 +8318,7 @@ function treacheryDiscardIntegrity(g: Game) {
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'ambassador'].includes(entry.cause) &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'caladanReinforcement', 'ambassador'].includes(entry.cause) &&
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
token?.kind === 'atomics' && token.status === 'removed' &&
@@ -21762,6 +21769,8 @@ function arrivalOverlapIntegrity(g: Game) {
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
if (record.revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record));
if (record.victoryEvent !== undefined || record.cause === 'caladanReinforcement')
homeworldRule(() => validateCaladanArrivalSource(g, record));
const active = record.stage === 'first' ? record.first :
record.first === 'ambassador' ? 'terror' : 'ambassador';
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g, record.revivalArrival) &&
@@ -21838,6 +21847,12 @@ function openAmbassadorEntry(
g.pendingAmbassador.revivalEvent = frame.event;
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
revivalArrival.spiritual, revivalArrival.terror));
} else if (arrivalCause === 'caladanReinforcement') {
const frame = g.homeworldVictoryReinforcement!;
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:frame?.event, entrant:entrant.id,
territory:to, sector, amount:1, elite:0, cause:arrivalCause}));
g.pendingAmbassador.victoryEvent = frame.event;
appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
}
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
return true;
@@ -21864,6 +21879,9 @@ function openTerritoryEntry(
if (revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
const victoryEvent = cause === 'caladanReinforcement' ? g.homeworldVictoryReinforcement?.event : undefined;
if (cause === 'caladanReinforcement')
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent, entrant:entrant.id, territory:to, sector, amount, elite, cause}));
// The arrival's own interaction (guild payment, advisor choice, storm
// protection) settles first; the entry then opens from the automatic pass.
if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador ||
@@ -21871,6 +21889,7 @@ function openTerritoryEntry(
(g.pendingArrivalReaction ??= []).push({
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
turn: g.turn, phase: g.phase,
...(victoryEvent === undefined ? {} : {victoryEvent}),
...(revivalArrival ? { revivalArrival } : {}),
});
return true;
@@ -21924,6 +21943,7 @@ function openTerritoryEntry(
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
ecaz: owner.id, moritani: moritani.id, first, stage: 'first',
ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id),
...(victoryEvent === undefined ? {} : {victoryEvent}),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
overlap.signature = arrivalOverlapSignature(overlap);
@@ -24210,6 +24230,8 @@ function terrorEntryIntegrity(g: Game) {
if (!entry) continue;
homeworldRule(() => validateTerrorEntrySignature(entry));
if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
if (entry.victoryEvent !== undefined || entry.cause === 'caladanReinforcement')
homeworldRule(() => validateCaladanArrivalSource(g, entry));
if (entry.entrySignature === undefined) continue;
requireRule(entry.turn === g.turn && entry.phase === g.phase &&
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
@@ -24288,6 +24310,7 @@ function openTerrorEntry(
stage: tokens.length > 1 ? 'select' : 'offer',
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
...(cause === 'caladanReinforcement' ? {victoryEvent:g.homeworldVictoryReinforcement?.event} : {}),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
if (!TERROR_STRONGHOLDS.includes(to)) {
@@ -26472,6 +26495,7 @@ function finishAdvisorReaction(
advisorResume?: ResponseWindow['advisorResume'];
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorVictoryEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
@@ -26500,6 +26524,7 @@ function intrusion(
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
victoryEvent?: string;
revivalEvent?: string;
revivalTerror?: RevivalEntryParent['terror'];
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
@@ -32170,6 +32195,26 @@ function homeworldVictoryReturnIntegrity(g: Game) {
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []);
const arrivals = g.pendingArrivalReaction?.filter(entry => entry.cause === 'caladanReinforcement' || entry.victoryEvent !== undefined) ?? [];
for (const entry of arrivals) homeworldRule(() => validateCaladanArrivalSource(g, entry));
const intrusionChoices = contexts.flatMap(context => context?.decision?.kind === 'intrusion' &&
context.decision.victoryEvent !== undefined ? [context.decision] : []);
const intrusionResponses = [...contexts.map(context => context?.response),
g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : undefined]
.filter((response): response is ResponseWindow => !!response && response.advisorVictoryEvent !== undefined);
for (const choice of intrusionChoices) {
requireRule(choice.player === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion belongs to Bene Gesserit.');
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:choice.victoryEvent,
entrant:frame?.player ?? '', territory:choice.territory, sector:splitLocation(frame?.destination ?? '').sector,
amount:1, elite:0, cause:'caladanReinforcement'}));
}
for (const response of intrusionResponses) {
requireRule(response.kind === 'advisorFlip' && response.advisors === true &&
response.owner === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion has lost its advisor response.');
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:response.advisorVictoryEvent,
entrant:frame?.player ?? '', territory:response.location ?? '', sector:splitLocation(frame?.destination ?? '').sector,
amount:1, elite:0, cause:'caladanReinforcement'}));
}
requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event),
'The Caladan choice has lost its original victory.');
if (frame?.stage === 'choice')
@@ -32255,11 +32300,8 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) {
stampHomeworldVictoryStage(g);
if (quote.selected.territory) {
place(p, quote.selected.territory, quote.selected.sector!, 1);
intrusion(g, p, quote.selected.territory, {victoryEvent:frame.event});
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement');
if (g.pendingAmbassador) {
g.pendingAmbassador.victoryEvent = frame.event;
appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
}
}
log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`,
{ faction: p.faction, name: 'Caladan victory reinforcement' });
@@ -32268,7 +32310,8 @@ function resumeHomeworldVictoryReturn(g: Game) {
const frame = g.homeworldVictoryReinforcement;
if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return;
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingKarama || g.decision || g.response) return;
completeHomeworldVictoryReturn(g);
finishBattle(g);
}
@@ -38903,6 +38946,8 @@ function settleAutomaticContinuations(g: Game) {
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) {
const deferred = g.pendingArrivalReaction.shift()!;
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
if (deferred.victoryEvent !== undefined || deferred.cause === 'caladanReinforcement')
homeworldRule(() => validateCaladanArrivalSource(g, deferred));
const entrant = g.players.find(p => p.id === deferred.entrant);
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
if (deferred.revivalSneakAdvisor) {
@@ -40872,6 +40917,7 @@ function applyActionInner(
? ('wormRide' as const)
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
advisorVictoryEvent: decision.victoryEvent,
advisorRevivalEvent: decision.revivalEvent,
advisorRevivalTerror: decision.revivalTerror,
discoveryEntry: decision.discoveryEntry,
+2 -24
View File
@@ -2,11 +2,9 @@ import type { Game } from './engine';
import {
gameTerritories,
location,
MOBILE_STRONGHOLD,
splitLocation,
validLocation,
} from './board';
import { fighterCount } from './advisors';
import { territoryEntryBlock } from './occupancy';
import { homeworldContext } from './homeworld-game';
import {
@@ -17,7 +15,6 @@ import {
import { homeworldPopulations } from './homeworld-population';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
export type HomeworldVictoryReinforcementContext = Pick<
Game,
@@ -168,31 +165,12 @@ export function quoteHomeworldVictoryReinforcement(
}
if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18)
invalid('Caladan reinforcement requires the current storm sector.');
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
const moritani = g.players.find((seat) => seat.faction === 'moritani');
quote.destinations = board!.sectors.map((sector) => {
let blocked: string | null = null;
if (board!.id === MOBILE_STRONGHOLD)
if (sector === g.storm)
blocked =
'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.';
if (!blocked && sector === g.storm)
blocked =
'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.';
'You cannot add the Caladan reinforcement to a sector in storm.';
blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0)
blocked =
'Intrusion after Caladan reinforcement awaits an entry-classification ruling.';
if (
!blocked &&
moritani &&
owner.id !== moritani.ally &&
!lowGrummanRevealBlock(g, moritani.id, 1) &&
g.moritaniTerror?.tokens.some(
(token) => token.status === 'placed' && token.location === board!.id,
)
)
blocked =
'Terror after Caladan reinforcement awaits an entry-classification ruling.';
return {
id: location(board!.id, sector),
name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`,
+7 -1
View File
@@ -5410,7 +5410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Storm, mobile stronghold and unresolved entry restrictions remain.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Provisional HMS placement adds to the surviving won interior without an ordinary shipment. Advanced Intrusion resolves before the original Ambassador/Terror entry; Terror classification is provisional. Storm-sector entry stays unavailable, and nested module interactions remain open.',
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
@@ -5452,6 +5452,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'components/reinforcements-sources.tsx',
'tests/nexus-world-independent-cards.test.ts',
'tests/nexus-world-independent-battles.test.ts',
'game/caladan-arrival.ts',
'game/homeworld-victory-reinforcement.ts',
'components/caladan-reinforcement.tsx',
'tests/caladan-arrival-engine.test.ts',
'tests/caladan-arrival-controls.test.tsx',
'docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md',
'game/homeworld-cards.ts',
'game/richese-homeworld-shipment.ts',
'game/homeworld-payment-options.ts',
+8 -3
View File
@@ -23,6 +23,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]),
]);
}
@@ -31,13 +32,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string {
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)])]);
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]])]);
}
/** Older saves without a receipt remain explicit legacy state. Never invent
* their original entry count from the destination's present occupants. */
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.stage !== 'select') return;
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return;
if (
typeof entry.entrySignature !== 'string' ||
typeof entry.token !== 'string' ||
@@ -72,6 +74,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
entry.phase < 0 ||
entry.phase > 8 ||
(entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) ||
(entry.victoryEvent !== undefined && (typeof entry.victoryEvent !== 'string' || !entry.victoryEvent ||
entry.cause !== 'caladanReinforcement' || entry.phase !== 6 || entry.revivalArrival !== undefined)) ||
(![
'shipment',
'movement',
@@ -79,7 +83,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
'advisor',
'wormRide',
'ambassador',
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined)) ||
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined) &&
!(entry.cause === 'caladanReinforcement' && entry.victoryEvent !== undefined)) ||
!['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
(entry.ambassadorEvent !== undefined &&
(typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game } from '../game/engine';
import { CaladanReinforcement } from '../components/caladan-reinforcement';
import { caladanReinforcementCanAct, caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import {
caladanVictoryFixture, prepareVictoryBattle, commitVictoryPlans, finishVictoryCalls,
victoryPlayer,
} from './fixture-caladan-victory';
// Existing controlled victory source: genuine Homeworld setup/deal, staged
// later phase/armies/wallets and storm position, then real battle packets yield
// the choice. This is not original-game progression or an HMS entry programme.
// SSR and shared-adapter checks do not operate the stateful React chooser.
const sources = new Map<boolean, Game>();
function choiceGame(advanced = true): Game {
let source = sources.get(advanced);
if (!source) {
const initial = caladanVictoryFixture({ advanced });
initial.storm = 13;
source = finishVictoryCalls(commitVictoryPlans(prepareVictoryBattle(initial)));
assert.equal(source.decision?.kind, 'caladanReinforcement');
assert.equal(source.lastBattleContext?.winner, 'a');
sources.set(advanced, source);
}
return structuredClone(source);
}
function html(game: Game, owner = 'a', busy = false) {
return renderToStaticMarkup(createElement(CaladanReinforcement, { game: viewGame(game, owner), busy,
act() { assert.fail('Rendering the chooser must not submit a decision.'); } }));
}
void test('the engine-owned Caladan choice keeps its default target and original exact one-force adapter packet', () => {
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
assert.ok(offer);
const target = offer.destinations.find(destination => !destination.blocked); assert.ok(target);
assert.ok(target.id.startsWith('hagga_basin:'));
const markup = html(game);
assert.ok(markup.includes(`<option value="${target.id}" selected="">`));
const choice = caladanReinforcementChoice(view, target.id);
assert.deepEqual(choice, { blocked: null, action: { type: 'decision', event: offer.event,
destination: target.id, amount: 1 } });
assert.ok(choice.action);
const before = victoryPlayer(game, 'a');
const after = applyAction(game, 'a', choice.action), placed = victoryPlayer(after, 'a');
assert.equal(placed.reserves, before.reserves - 1);
assert.equal(placed.forces[target.id], (before.forces[target.id] ?? 0) + 1);
});
void test('arrival guidance distinguishes placement from shipment and preserves the original Face Dance explanation', () => {
const game = choiceGame(), before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const markup = html(game);
assert.match(markup, /original surviving winner at the original battle location/);
assert.match(markup, /including inside the Hidden Mobile Stronghold/);
assert.match(markup, /direct-shipment ban does not block this placement/);
assert.match(markup, /No spice, shipment, movement or revival allowance is spent/);
assert.match(markup, /no Spiritual Advisor opportunity is created/);
assert.match(markup, /Storm-sector entry remains unavailable/);
assert.match(markup, /Advanced Bene Gesserit Intrusion resolves first/);
assert.match(markup, /Provisional arrival classification: eligible Ambassador and Terror reactions then follow their original arrival order/);
assert.match(markup, /aria-label="Provisional Face Dance order"/);
assert.match(markup, /joins the original victorious army before any later Face Dance/);
assert.match(markup, /does not create another victory or grant another Caladan reinforcement/);
}
assert.deepEqual(game, before);
});
void test('actual storm and foreign targets stay unavailable and another viewer receives no owned chooser', () => {
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
assert.ok(offer);
const storm = offer.destinations.find(destination => destination.id === 'hagga_basin:13'); assert.ok(storm);
assert.match(storm.blocked ?? '', /storm/i);
assert.equal(caladanReinforcementChoice(view, storm.id).action, null);
assert.equal(caladanReinforcementChoice(view, 'carthag:11').action, null);
assert.match(html(game), /<option[^>]*value="hagga_basin:13"[^>]*disabled=""/);
const rival = viewGame(game, 'g');
assert.equal(caladanReinforcementCanAct(rival), false);
assert.equal(caladanReinforcementChoice(rival, 'hagga_basin:12').action, null);
assert.equal(html(game, 'g'), '');
});
void test('busy disables the existing chooser while Basic keeps the placement guidance without an Advanced Intrusion claim', () => {
const game = choiceGame(), busy = html(game, 'a', true);
assert.match(busy, /<select[^>]*disabled=""/);
assert.equal((busy.match(/<button[^>]*disabled=""/g) ?? []).length, 2);
assert.match(busy, /Provisional arrival classification/);
const basic = choiceGame(false), markup = html(basic);
assert.match(markup, /no Spiritual Advisor opportunity is created/);
assert.match(markup, /Provisional arrival classification/);
assert.doesNotMatch(markup, /Advanced Bene Gesserit Intrusion resolves first/);
const view = viewGame(basic, 'a');
assert.ok(caladanReinforcementChoice(view, 'hagga_basin:12').action);
});
+215
View File
@@ -0,0 +1,215 @@
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 { fighterCount } from '../game/advisors';
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import { TERROR_KINDS } from '../game/moritani-terror';
import {
actCaladanArrival as act, advanceCaladanArrival as advance,
allowCaladanArrivalResponses as allow, caladanArrivalInventory as inventory,
caladanArrivalPlayer as p, caladanArrivalReload as reload,
createCaladanArrivalFixture as fixture,
} from './fixture-caladan-arrival';
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function completed(g: Game, event: string) {
assert.equal(g.homeworldVictoryReinforcement!.event, event);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(p(g,'a').reserves, 5);
inventory(g);
}
void test('controlled Caladan battle sources record actual plans, calls and the original free addition', () => {
const f = fixture({bg:true});
assert.match(f.disclosure, /Controlled.*not public acquisition/);
assert.equal(f.sourceCount, f.actions.length);
assert.equal(f.actions.filter(s => s.action.type === 'chooseBattle').length,1);
assert.equal(f.actions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(f.actions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(f.source.homeworldVictoryReinforcement,undefined);
const before = structuredClone(p(f.game,'a'));
const g = act(reload(f.game),'a',f.action);
assert.equal(p(g,'a').reserves,before.reserves-1);
assert.equal(p(g,'a').forces[f.destination],before.forces[f.destination]+1);
for (const field of ['spice','shipped','moved','battleLosses'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.equal(g.homeworldRevivalReturn,undefined);
inventory(g);
});
for (const flip of [false,true])
void test(`Caladan Advanced Intrusion ${flip ? 'flip' : 'keep'} waits for the original arrival`, () => {
const f = fixture({bg:true});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.decision?.kind,'intrusion');
assert.equal(g.decision?.player,'bg');
assert.equal(g.decision?.kind === 'intrusion' && g.decision.victoryEvent,f.event);
assert.equal(fighterCount(p(g,'bg'),f.territory),1);
reject(g,'a',f.action);
g = act(g,'bg',{type:'decision',accept:flip});
if (flip) {
assert.equal(g.response?.kind,'advisorFlip');
assert.equal(g.response?.advisorVictoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = allow(g);
}
g = advance(g);
assert.equal(fighterCount(p(g,'bg'),f.territory),flip ? 0 : 1);
assert.equal(p(g,'bg').forces[f.destination],1);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan Terror decline leaves the original hidden token and completes once', () => {
const f = fixture({terror:'robbery'});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingTerrorEntry?.cause,'caladanReinforcement');
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.pendingTerrorEntry?.entrant,'a');
assert.equal(g.pendingTerrorEntry?.amount,1);
assert.equal(g.pendingTerrorEntry?.elite,0);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
reject(g,'a',{type:'decision',decline:true});
g = act(g,'m',{type:'decision',decline:true});
g = advance(g);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'placed');
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});
for (const kind of TERROR_KINDS)
void test(`Caladan original Terror ${kind} resolves before its reinforcement completes`, () => {
const f = fixture({terror:kind});
let g = act(reload(f.game),'a',f.action);
const before = reload(g), entry = g.pendingTerrorEntry!;
assert.equal(entry.stage,'offer');
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',reveal:true});
if (kind === 'robbery') {
assert.equal(g.pendingTerrorEntry?.stage,'robbery');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',choice:'spice'});
const stolen = Math.ceil(p(before,'a').spice/2);
assert.equal(p(g,'a').spice,p(before,'a').spice-stolen);
assert.equal(p(g,'m').spice,p(before,'m').spice+stolen);
} else if (kind === 'sneakAttack') {
assert.equal(g.pendingTerrorEntry?.stage,'sneakAttack');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',amount:1});
assert.equal(p(g,'m').reserves,p(before,'m').reserves-1);
assert.equal(p(g,'m').forces[f.destination],1);
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else if (kind === 'sabotage') {
g = advance(g);
assert.equal(g.pendingTerrorEntry?.stage,'gift');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.ok(g.discard.some(c => c.id === f.heldCard));
assert.equal(p(g,'a').hand.length,0);
g = act(g,'m',{type:'decision',decline:true});
} else if (kind === 'assassination') {
const deadBefore = p(before,'a').leaders.filter(l => l.dead).length;
assert.equal(p(g,'a').leaders.filter(l => l.dead).length,deadBefore+1);
const victim = p(g,'a').leaders.find(l => l.dead && !p(before,'a').leaders.find(old => old.id === l.id)!.dead)!;
assert.equal(p(g,'m').spice,p(before,'m').spice+victim.strength);
} else if (kind === 'extortion') {
assert.ok(g.moritaniExtortion);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'extortion');
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else {
assert.equal(p(g,'a').forces[f.destination] ?? 0,0);
assert.equal(p(g,'a').tanks,p(before,'a').tanks+3);
}
g = advance(g);
assert.equal(g.pendingTerrorEntry,null);
if (kind !== 'extortion')
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan adds directly to a surviving army aboard the actual Ix HMS without shipping', () => {
const f = fixture({hms:true});
assert.ok(f.source.mobileStronghold?.location);
assert.equal(f.territory,'hidden_mobile_stronghold');
const choice = caladanReinforcementChoice(viewGame(f.game,'a'),f.destination);
assert.equal(choice.blocked,null);
assert.deepEqual(choice.action,f.action);
const before = structuredClone(p(f.game,'a'));
const g = advance(act(reload(f.game),'a',choice.action!));
assert.equal(p(g,'a').forces[f.destination],3);
for (const field of ['spice','shipped','moved'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.deepEqual(g.mobileStronghold,f.game.mobileStronghold);
completed(g,f.event);
});
void test('Caladan original event and owner reject stale, foreign and oversized entry before cost', () => {
const f = fixture({bg:true});
reject(f.game,'g',f.action);
reject(f.game,'a',{...f.action,event:`${f.event}:stale`});
reject(f.game,'a',{...f.action,amount:2});
reject(f.game,'a',{...f.action,destination:'arrakeen:10'});
assert.equal(p(f.game,'a').reserves,6);
assert.equal(p(f.game,'a').forces[f.destination],2);
inventory(f.game);
});
void test('all four minimal profiles use the actual Caladan and Intrusion controls legally', () => {
const f = fixture({bg:true});
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
let g = reload(f.game);
const own = viewGame(g,'a');
own.players.find(seat => seat.id === 'a')!.bot = profile;
const action = botActions(own).find(a => a.type === 'decision' && a.event === f.event);
assert.ok(action,`${profile} needs a current original reinforcement action.`);
g = act(g,'a',action);
if (g.decision?.kind === 'intrusion') {
const view = viewGame(g,'bg');
view.players.find(seat => seat.id === 'bg')!.bot = profile;
const reaction = botActions(view)[0];
assert.ok(reaction,`${profile} needs a native Intrusion choice.`);
g = allow(act(g,'bg',reaction));
}
g = advance(g);
assert.equal(g.homeworldVictoryReinforcement!.stage,'complete');
assert.equal(p(g,'a').reserves,action.decline ? 6 : 5);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed,true);
inventory(g);
}
});
for (const first of ['first','second'] as const)
void test(`Caladan Ambassador ${first} in storm order retains its original victory through Terror`, () => {
const f = fixture({terror:'robbery',ambassador:first});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingArrivalOverlap?.first,first === 'first' ? 'ambassador' : 'terror');
assert.equal(g.pendingArrivalOverlap?.victoryEvent,f.event);
const declineAmbassador = () => {
const entry = g.pendingAmbassador; assert.ok(entry);
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'ec',{type:'decision',event:entry.event,decline:true});
};
const declineTerror = () => {
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',decline:true});
};
if (first === 'first') { declineAmbassador(); declineTerror(); }
else { declineTerror(); declineAmbassador(); }
g = advance(g);
assert.equal(g.pendingArrivalOverlap,null);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors!.length,1);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors![0].completed,true);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});
+11 -9
View File
@@ -29,8 +29,9 @@ function source(f: Fixture) {
assert.equal(player(f.controlled, f.tleilaxu).reserves, 2);
assert.equal(player(f.controlled, f.tleilaxu).forces['polar_sink:0'], 18);
assert.equal(player(f.game, f.atreides).reserves, 6); assert.equal(army(f.game, f.atreides), 3);
assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 2);
assert.equal(player(f.game, f.tleilaxu).reserves, 0); assert.equal(army(f.game, f.tleilaxu), 0);
assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 0);
assert.equal(player(f.game, f.defender).tanks, 2);
assert.equal(player(f.game, f.tleilaxu).reserves, 2); assert.equal(army(f.game, f.tleilaxu), 2);
assert.equal(f.game.decision?.kind, 'caladanReinforcement'); assert.equal(f.game.battle ?? null, null);
assert.equal(f.game.pendingFaceDance!.player, f.tleilaxu); assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader);
assert.equal(player(f.game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
@@ -56,7 +57,8 @@ function give(f: Fixture, actions: Step[] = []) {
}
function completed(game: Game, f: Fixture, actions: Step[] = []) {
const done = finish(game, actions);
assert.equal(done.phase, 7); assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
assert.equal(done.phase, army(done, f.atreides) > 0 ? 6 : 7);
assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
assert.equal(done.pendingFaceDance ?? null, null); assert.equal(viewGame(done, f.atreides).caladanReinforcement, null);
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(done.lastBattleContext!.event, f.event); assert.equal(done.lastBattleContext!.winner, f.atreides);
@@ -82,8 +84,8 @@ for (const advanced of [false, true]) {
game = act(game, f.tleilaxu, f.faceDanceAction, actions);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
assert.equal(player(game, f.tleilaxu).reserves, 4); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
assert.equal(player(game, f.tleilaxu).tanks, 2);
assert.equal(player(game, f.tleilaxu).reserves, 6); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).tanks, 2);
const dead = player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!;
assert.equal(dead.dead, true); assert.equal(dead.deaths, beforeLeader.deaths + 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
@@ -91,7 +93,7 @@ for (const advanced of [false, true]) {
game = completed(game, f, actions);
assert.equal(actions.filter(step => step.action.reveal === true).length, 1);
assert.equal(actions.filter(step => step.action.destination === f.destination).length, 1);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 4);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 6);
});
void test(`${band} controlled native Homeworld victory keeps its fourth visitor when Tleilaxu declines Face Dance`, () => {
@@ -101,7 +103,7 @@ for (const advanced of [false, true]) {
game = act(game, f.tleilaxu, f.faceDanceDecline, actions); game = completed(game, f, actions);
assert.deepEqual(game.players, before); assert.deepEqual(game.homeworlds!.custody, custody);
assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
assert.equal(player(game, f.tleilaxu).reserves, 0); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
assert.equal(player(game, f.tleilaxu).reserves, 2); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
assert.equal(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
});
@@ -115,8 +117,8 @@ for (const advanced of [false, true]) {
game = act(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 3 } });
game = completed(game, f);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
assert.equal(player(game, f.tleilaxu).reserves, 3); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
assert.equal(player(game, f.tleilaxu).tanks, 2);
assert.equal(player(game, f.tleilaxu).reserves, 5); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).tanks, 2);
});
void test(`${band} controlled Homeworld reinforcement and subsequent Face Dance reject stale, foreign, quantity and live-source over-cap actions`, () => {
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldGameForAudit, joinGame,
newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { location, territory as boardTerritory } from '../game/board';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
import {
caladanVictoryFixture, holdVictoryCard, positionVictoryArmy,
victoryPlayer, victoryReload,
} from './fixture-caladan-victory';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export const caladanArrivalPlayer = victoryPlayer;
export const caladanArrivalReload = victoryReload;
export type CaladanArrivalStep = { actor: string; action: Action };
/** Controlled, conserved battle source. The inherited Homeworld preview
* initializer is not a public start journey. Wallets, later turn/phase and
* positions, selected held original cards, and the placed original Terror token are
* scenario inputs. Only the recorded battle and arrival actions are native
* chronology; no victory, entry, reaction or reinforcement receipt is installed. */
export const caladanArrivalDisclosure =
'Controlled Homeworld preview setup and later battle positions/purses/cards/token placement; recorded native battle and arrival actions, not public acquisition history.';
export function actCaladanArrival(
game: Game, actor: string, action: Action, actions: CaladanArrivalStep[] = [],
): Game {
const result = normalizeAutomaticGame(applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) });
return result;
}
/** Resolve only actual neutral native controls, preserving the decisions under
* test. It never seals a plan, chooses an entry reaction or advances a new phase. */
export function advanceCaladanArrival(
game: Game, actions: CaladanArrivalStep[] = [],
): Game {
let g = game;
for (let n = 0; n < 100; n++) {
if (g.decision && ['caladanReinforcement', 'intrusion', 'moritaniTerror',
'ambassador', 'arrivalOverlap'].includes(g.decision.kind)) return g;
if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
const next = nextStrongholdFactionsNativeStep(g);
if (!next) return g;
g = actCaladanArrival(g, next.actor, next.action, actions);
}
assert.fail('The bounded native Caladan continuation did not stop.');
}
export function allowCaladanArrivalResponses(
game: Game, actions: CaladanArrivalStep[] = [],
): Game {
let g = game;
for (let n = 0; g.response && n < 40; n++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(actor);
g = actCaladanArrival(g, actor.id, { type: 'passResponse' }, actions);
}
assert.equal(g.response, null);
return advanceCaladanArrival(g, actions);
}
export function caladanArrivalInventory(g: Game) {
homeworldGameIntegrity(g);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
assert.equal(cards.length, g.expansions.includes('ix') ? 47 : 33);
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
for (const p of g.players) {
const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
(sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0);
const stars = p.elites ? p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0) : 0;
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) + visitors, 20);
assert.ok(stars >= 0 && p.spice >= 0);
}
return { cards: cards.length, leaders: g.players.flatMap(p => p.leaders).length };
}
function hmsBattleSource(): Game {
let g = createGame('CALADANHMSARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; (g.status === 'setup' || !g.mobileStronghold?.location) && n < 140; n++) {
const next = nextStrongholdFactionsNativeStep(g);
if (next) g = applyAction(g, next.actor, next.action);
else {
const choice = g.players.map(p => ({actor:p.id, action:botActions(viewGame(g,p.id))[0]})).find(c => c.action);
assert.ok(choice);
g = applyAction(g, choice.actor, choice.action);
}
}
assert.equal(g.status, 'playing');
assert.ok(g.mobileStronghold?.location, 'The original Ix setup must place its actual HMS.');
// Conserved later scenario positions, not a claimed native movement journey.
const ix = victoryPlayer(g, 'i');
ix.reserves += Object.values(ix.forces).reduce((sum, count) => sum + count, 0);
ix.forces = {};
ix.elites!.reserves += Object.values(ix.elites!.forces).reduce((sum, count) => sum + count, 0);
ix.elites!.forces = {};
for (const p of g.players) { g.deck.push(...p.hand.splice(0)); p.spice = 20; }
Object.assign(g, { phase:6, turn:2, storm:18, phaseOpening:null, response:null,
decision:null, active:'a', ready:[], order:g.players.map(p => p.id) });
positionVictoryArmy(g, 'a', 6, 3);
positionVictoryArmy(g, 'g', 17, 3);
for (const id of ['a', 'g']) {
const p = victoryPlayer(g, id);
delete p.forces['hagga_basin:12'];
p.forces['hidden_mobile_stronghold:0'] = 3;
}
caladanArrivalInventory(g);
return g;
}
export function createCaladanArrivalFixture(options: {
bg?: boolean; terror?: TerrorKind; hms?: boolean; ambassador?: 'first' | 'second';
} = {}) {
assert.ok(!options.hms || (!options.bg && !options.terror));
let g = options.hms ? hmsBattleSource() : caladanVictoryFixture({
advanced:true,
extraSeats: options.bg ? [{id:'bg',faction:'beneGesserit'}] :
options.terror ? [{id:'m',faction:'moritani'}, ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : [])] : [],
});
const territory = options.hms ? 'hidden_mobile_stronghold' : options.terror ? 'arrakeen' : 'hagga_basin';
const destination = `${territory}:${options.hms ? 0 : options.terror ? 10 : 12}`;
let token: string | undefined;
let heldCard: string | undefined;
if (options.bg) {
const bg = victoryPlayer(g, 'bg');
Object.assign(bg, {reserves:19,forces:{'hagga_basin:12':1},advisors:{}});
holdVictoryCard(g,'g','karama'); // Keep an actual preventable flip response open.
}
if (options.terror) {
// Ecaz/Moritani native roster preview; no extra Treachery module is added.
g.expansions.push('ecaz');
for (const id of ['a','g']) {
const p = victoryPlayer(g,id);
delete p.forces['hagga_basin:12'];
p.forces['arrakeen:10'] = 3;
}
const original = g.moritaniTerror!.tokens.find(t => t.kind === options.terror)!;
token = original.id;
g.moritaniTerror = placeTerror(g.moritaniTerror!, token, territory, 1);
if (options.terror === 'sabotage') {
heldCard = holdVictoryCard(g,'a','shield');
holdVictoryCard(g,'m','karama'); // An actual held card supplies the optional gift.
}
}
if (options.ambassador) {
assert.ok(options.terror);
// Controlled original-token supply and storm order; neither reaction has
// been accepted and no later arrival/continuation receipt is installed.
g.ecazAmbassadors = createAmbassadors(() => 0);
const ambassador = g.ecazAmbassadors.tokens.find(token => token.zone === 'supply')!;
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, ambassador.id, {turn:1,availableSpice:20,
destination:{id:territory,stronghold:true,inStorm:false,allowed:true}}).state;
g.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
}
// A separately staged one-counter contest retains Battle phase after this
// victory, so unrelated automatic Collection income is not part of entry proof.
const other = location('shield_wall', boardTerritory('shield_wall').sectors[0]);
const atreides = victoryPlayer(g,'a'), guildArmy = victoryPlayer(g,'g');
assert.ok(atreides.forces['polar_sink:0'] > 0 && guildArmy.reserves > 0);
atreides.forces['polar_sink:0']--; atreides.forces[other] = 1;
guildArmy.reserves--; guildArmy.forces[other] = 1;
caladanArrivalInventory(g);
const source = victoryReload(g), actions: CaladanArrivalStep[] = [];
g = actCaladanArrival(g,'a',{type:'chooseBattle',territory,target:'g'},actions);
g = advanceCaladanArrival(g,actions);
assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
const a = victoryPlayer(g,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
const guild = victoryPlayer(g,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
g = actCaladanArrival(g,'a',{type:'battlePlan',leader:a.id,dial:1,support:1},actions);
g = actCaladanArrival(g,'g',{type:'battlePlan',leader:guild.id,dial:0,support:0},actions);
g = advanceCaladanArrival(g,actions);
assert.equal(g.decision?.kind,'caladanReinforcement');
assert.equal(g.lastBattleContext?.winner,'a');
assert.equal(victoryPlayer(g,'a').forces[destination],2);
const event = g.homeworldVictoryReinforcement!.event;
const offer = viewGame(g,'a').caladanReinforcement!;
caladanArrivalInventory(g);
return {game:g,source,actions,sourceActions:actions,sourceCount:actions.length,
disclosure:caladanArrivalDisclosure,territory,destination,event,token,heldCard,offer,
action:{type:'decision',event,destination,amount:1} as Action,
decline:{type:'decision',event,decline:true} as Action};
}
+19 -10
View File
@@ -85,10 +85,14 @@ export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game
}
throw Error('The original Homeworld battle did not reach its bounded continuation');
}
/** Finish only this battle; retain the original Collection opening. */
/** Finish this original aftermath only. A declined Face Dance can leave a
* further mandatory battle against the noncombatant native Tleilaxu army. */
export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
const event = game.lastBattleContext?.event; assert.ok(event);
return advanceCaladanFaceDanceHomeworld(game,
state => state.phase === 7 && !state.battle && !state.response && !state.decision, actions);
state => state.lastBattleContext?.event === event && !state.pendingFaceDance &&
state.lastBattleContext.caladanReinforcement?.completed === true &&
!state.battle && !state.response && !state.decision && !state.phaseOpening, actions);
}
/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
@@ -126,10 +130,13 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
positionVictoryArmy(game, observer, 20, 0);
positionVictoryArmy(game, tleilaxu, 2, 0);
positionVictoryInvader(game, atreides, territory, 3);
positionVictoryInvader(game, observer, territory, 2);
const defender = observer;
const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
const opposing = caladanFaceDanceHomeworldPlayer(game, defender);
const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const loserDisc = [...native.leaders].filter(leader => !leader.dead && !leader.usedAt)
const loserDisc = [...opposing.leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerDisc && loserDisc && winnerDisc.strength > loserDisc.strength);
const winnerLeader = winnerDisc.id, loserLeader = loserDisc.id;
let exchange: { from: string; returned: string; selected: string } | null = null;
@@ -148,14 +155,14 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
const controlled = structuredClone(game);
assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
const battleAction: Action = { type: 'chooseBattle', territory, target: tleilaxu };
const battleAction: Action = { type: 'chooseBattle', territory, target: defender };
act(atreides, battleAction); const beforePreparation = structuredClone(game);
game = advanceCaladanFaceDanceHomeworld(game, state => !!state.battle && !state.response && !state.decision &&
!state.phaseOpening && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
const beforePlans = structuredClone(game);
const planSteps: CaladanFaceDanceHomeworldStep[] = [
{ actor: atreides, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
{ actor: defender, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
];
for (const step of planSteps) act(step.actor, step.action);
const revealed = structuredClone(game);
@@ -165,7 +172,9 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).reserves, 6);
assert.equal(caladanFaceDanceHomeworldArmy(game, atreides), 3);
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).tanks, 0);
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 2);
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 0);
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).reserves, 2);
assert.equal(caladanFaceDanceHomeworldPlayer(game, defender).tanks, 2);
assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
const event = game.homeworldVictoryReinforcement!.event;
const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
@@ -173,17 +182,17 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 } };
const faceDanceDecline: Action = { type: 'decision', reveal: false };
return { game, source: structuredClone(game), advanced, atreides, winner: atreides, owner: atreides, tleilaxu,
observer, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
observer, defender, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
action: reinforcementAction, reinforcementAction, reinforcementDecline, faceDanceAction, faceDanceDecline,
battleAction, planSteps, lobby, setup, afterSetup, controlled, exchange, beforePreparation, beforePlans, revealed,
originalInventory, originalTraitors, originalLeaders, originalPendingFaceDance: structuredClone(game.pendingFaceDance),
originalPhysical: caladanFaceDanceHomeworldPhysical(game), originalContext: structuredClone(game.lastBattleContext),
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. This unchanged fixture-caladan-victory profile does not enable occupation-preview benefits or a new public start gate.',
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native20. The exact original custody quote moves A3 from Caladan9 to the visitor pool. No extra counter, card or disc is created.',
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. Original occupation state/preview flags remain unchanged; no occupied benefit or native-only Face Dance restriction is disabled.',
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native18 and two visitors on Tleilax. Exact original custody quotes move A3 from Caladan9 and Guild2 from Junction20 into their visitor pools. Tleilaxu stays a noncombatant native, so A never solely occupies Tleilax. No extra counter, card or disc is created.',
'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors/discs stay unique; no replacement or reveal status is installed.',
'Actual chooseBattle, preparation/response passes, zero-dial unarmed native-leader plans, outcome readiness and declined Traitor votes create a normal A victory, no A ordinary casualties and Tleilaxu2 Tanks. Stop at the original Caladan choice before native Face Dance. Optional transfer and reveal remain caller-owned.',
'Actual A-versus-Guild chooseBattle, preparation/response passes, zero-dial unarmed faction-leader plans and outcome readiness create a normal A victory, no A ordinary casualties and Guild2 Tanks; two visiting combatants have no Homeworld Traitor voters. Native Tleilaxu2 remains, permitting its native Face Dance without occupied-Tleilax immunity. Stop at the original Caladan choice. Declining Face Dance leaves the later A–T battle unresolved rather than claiming Collection.',
],
};
}
@@ -116,7 +116,7 @@ void test('victory on native Caladan is a physical no-op, never a newly created
assert.deepEqual(g, before);
});
void test('permission is territorial; sector choices stay at the original battle and storm/HMS remain explicitly blocked', () => {
void test('permission is territorial; storm stays unavailable while the separate addition can reach a won HMS', () => {
const g = fixture();
const sectors = territory('imperial_basin').sectors;
g.players[0].forces = { [location('imperial_basin', sectors[0])]: 1 };
@@ -130,19 +130,17 @@ void test('permission is territorial; sector choices stay at the original battle
HomeworldVictoryReinforcementContext['mobileStronghold']
>;
g.players[0].forces = { [location(MOBILE_STRONGHOLD, 0)]: 1 };
assert.match(
quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked!,
/Hidden Mobile Stronghold/,
);
assert.equal(quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked, null);
assert.equal(transfer(g, victory(MOBILE_STRONGHOLD), `${MOBILE_STRONGHOLD}:0`).transfer.players[0].reserves, 5);
});
void test('actual entry boundaries retain occupancy, BG Intrusion and public low-Grumman count distinctions', () => {
void test('arrival reactions do not prohibit the original addition; ordinary allied occupancy still does', () => {
const g = fixture();
const bg = newPlayer('b', 'BG', 'beneGesserit');
bg.forces = { 'arrakeen:10': 1 };
bg.reserves = 19;
g.players.push(bg);
assert.match(quote(g, victory()).destinations[0].blocked!, /Intrusion/);
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players.pop();
const moritani = newPlayer('m', 'Moritani', 'moritani');
moritani.reserves = 8;
@@ -151,7 +149,7 @@ void test('actual entry boundaries retain occupancy, BG Intrusion and public low
const token = state.tokens[0];
state = placeTerror(state, token.id, 'arrakeen', 1);
g.moritaniTerror = state;
assert.match(quote(g, victory()).destinations[0].blocked!, /Terror/);
assert.equal(quote(g, victory()).destinations[0].blocked, null);
moritani.reserves = 7;
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players[0].ally = 'h';