diff --git a/components/game-table.tsx b/components/game-table.tsx
index bcb9ce20..46d73c71 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -226,6 +226,7 @@ import { guildTransportQuote } from '@/game/transport-quote';
import { fremenReserveEntry, botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility';
import { isAlliedShipmentVisit } from '@/game/allied-separation';
import { location as boardLocation } from '@/game/board';
+import { isBoardNoFieldLocation } from '@/game/richese-no-field';
import { fighterCount, isAdvisor } from '@/game/advisors';
import { useState } from 'react';
import {
@@ -825,10 +826,13 @@ export function GameTable({
),
}
: { from: source, amount, elite: eliteAmount };
- const ownNoField =
+ const ownedDeployedNoField =
g.richeseNoField?.owner === me.id
? g.richeseNoField.private?.deployed
: null;
+ const ownNoField = ownedDeployedNoField && isBoardNoFieldLocation(ownedDeployedNoField.location)
+ ? { ...ownedDeployedNoField, location: ownedDeployedNoField.location }
+ : null;
const markerAtSource =
!!ownNoField &&
!!source &&
@@ -1958,19 +1962,18 @@ export function GameTable({
)),
)}
- {g.players
- .filter(
- (p) => p.noField?.deployed?.location.territory === selected,
- )
- .map((p) => (
-
- {faction(p.faction).name}: concealed No-Field in sector{' '}
- {p.noField!.deployed!.location.sector} · counts as one force
-
- ))}
+ {g.players.flatMap((p) => {
+ const marker = p.noField?.deployed;
+ if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected)
+ return [];
+ return [
+ {faction(p.faction).name}: concealed No-Field in sector{' '}
+ {marker.location.sector} · counts as one force
+ ];
+ })}
{g.status !== 'lobby' && (
void; busy: boolean;
+}) {
+ const me = game.players.find(player => player.id === game.me);
+ const info = game.richeseNoField;
+ const [selectedDestination, setSelectedDestination] = useState('');
+ const [selectedToken, setSelectedToken] = useState('');
+ const [allyPayment, setAllyPayment] = useState(0);
+ if (!me || me.faction !== 'richese' || info?.owner !== me.id || !info.private) return null;
+ const targets = (game.homeworlds?.worlds ?? []).filter(world =>
+ world.native !== me.id && world.native !== me.ally);
+ const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
+ const tokens = info.private.tokens.filter(token => token.id !== info.private!.lastShipped);
+ const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
+ const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment);
+ return
+ Ship a concealed No-Field
+ Provisional Homeworld rule: ship one concealed marker from your supply to
+ another non-allied Homeworld for one spice. Its value stays private and its physical forces
+ remain in your reserves until the marker is revealed. This uses your normal shipment.
+ Destination Homeworld for No-Field
+ setSelectedDestination(event.target.value)}>
+ {!targets.length && No eligible destination }
+ {targets.map(world =>
+ {homeworldName(game, world.id)} · {game.players.find(player => player.id === world.native)?.name}
+ )}
+
+
+ Private No-Field token
+ setSelectedToken(event.target.value)}>
+ {!tokens.length && No different token available }
+ {tokens.map(candidate => No-Field {candidate.value} )}
+
+
+ {me.ally && Allied spice contribution · {game.aid.available} pledged
+ setAllyPayment(Number(event.target.value))} />
+ }
+
+ One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
+ {quote.blocked && ` ${quote.blocked}`}
+
+ { if (quote.action) act(quote.action); }}>
+ Ship No-Field to {homeworldName(game, destination?.id ?? '')}
+
+ ;
+}
+
export function HomeworldShipment({
game: g,
act,
@@ -265,6 +320,7 @@ export function HomeworldShipment({
Ship to {homeworldName(g, destination)}
+
);
}
diff --git a/components/homeworld-table.tsx b/components/homeworld-table.tsx
index 1aa00556..4e431233 100644
--- a/components/homeworld-table.tsx
+++ b/components/homeworld-table.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
+import { isHomeworldNoFieldLocation } from '@/game/richese-no-field';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import { emperorHomeworldMoveAction } from '@/game/homeworld-options';
import type { EmperorHomeworldMove } from '@/game/homeworld-emperor-move';
@@ -54,6 +55,14 @@ export function HomeworldTable({ game: g }: { game: GameView }) {
: ''}
))}
+ {g.players.flatMap(player => {
+ const marker = player.noField?.deployed;
+ if (!marker || !isHomeworldNoFieldLocation(marker.location) || marker.location.homeworld !== world.id)
+ return [];
+ return [
+ {player.name}: concealed No-Field · counts as one force
+
];
+ })}
{card.name} card
diff --git a/components/mobile-stronghold.tsx b/components/mobile-stronghold.tsx
index 4a927404..297cf8ce 100644
--- a/components/mobile-stronghold.tsx
+++ b/components/mobile-stronghold.tsx
@@ -13,6 +13,7 @@ import {
import type { Action, GameView } from '@/game/engine';
import { quoteSandmasterStronghold } from '@/game/sandmaster-movement';
import { mobileStrongholdCollectionCapacity } from '@/game/mobile-stronghold-collection';
+import { isBoardNoFieldLocation } from '@/game/richese-no-field';
const label = (key: string) => {
const loc = splitLocation(key);
return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`;
@@ -56,7 +57,12 @@ export function MobileStronghold({
return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]];
}) : [];
const remaining: Record = {};
- const capacity = mobileStrongholdCollectionCapacity(g.players);
+ const capacity = mobileStrongholdCollectionCapacity(g.players.map(player => {
+ const marker = player.noField?.deployed;
+ return { ...player, noField: marker && isBoardNoFieldLocation(marker.location)
+ ? { deployed: { controller: marker.controller, location: marker.location } }
+ : null };
+ }));
const collections = route.map((key, index) => {
const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key)));
const maximum = Math.min(before, capacity);
diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx
index cc3b94f7..a0c99f62 100644
--- a/components/ornithopter-movement.tsx
+++ b/components/ornithopter-movement.tsx
@@ -7,6 +7,7 @@ import {
type OrnithopterMode,
} from '@/game/ornithopter';
import { location, splitLocation, gameTerritories } from '@/game/board';
+import { isBoardNoFieldLocation } from '@/game/richese-no-field';
import { botGroundMoveAllowed } from '@/game/bot-mobility';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
@@ -82,7 +83,7 @@ export function OrnithopterMovement({
me.elites?.forces ?? {},
forces,
elites,
- typeof move.noField === 'string' && marker
+ typeof move.noField === 'string' && marker && isBoardNoFieldLocation(marker.location)
? {
tokenId: move.noField,
event: typeof move.event === 'string' ? move.event : '',
diff --git a/components/planetologist-movement-action.tsx b/components/planetologist-movement-action.tsx
index b213f2d9..75467add 100644
--- a/components/planetologist-movement-action.tsx
+++ b/components/planetologist-movement-action.tsx
@@ -12,6 +12,7 @@ import { botGroundMoveAllowed } from '@/game/bot-mobility';
import { isAdvisor } from '@/game/advisors';
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
import { location, splitLocation, validGameLocation } from '@/game/board';
+import { isBoardNoFieldLocation } from '@/game/richese-no-field';
import { nexusGuildMovementAvailable } from '@/game/nexus-guild-cunning-options';
import { planetologistFlightSelection, type PlanetologistFlight } from '@/game/planetologist-flight';
import { validateCohortSelection } from '@/game/ornithopter';
@@ -30,8 +31,9 @@ export function planetologistNoFieldMarker(game: GameView) {
const state = info.private;
const marker = state?.deployed;
const publicMarker = info.public.deployed;
- if (!state || !marker || marker.controller !== me.id ||
+ if (!state || !marker || !isBoardNoFieldLocation(marker.location) || marker.controller !== me.id ||
!publicMarker || publicMarker.controller !== me.id ||
+ !isBoardNoFieldLocation(publicMarker.location) ||
marker.location.territory !== publicMarker.location.territory ||
marker.location.sector !== publicMarker.location.sector ||
marker.tokenId !== state.lastShipped ||
diff --git a/components/richese-no-field.tsx b/components/richese-no-field.tsx
index a91a95c4..9379915e 100644
--- a/components/richese-no-field.tsx
+++ b/components/richese-no-field.tsx
@@ -6,7 +6,8 @@ import { faction } from '@/game/catalog';
import { territory, MOBILE_STRONGHOLD } from '@/game/board';
import { fighterCount } from '@/game/advisors';
import { reserveShipmentCost } from '@/game/shipment-price';
-import type { RicheseNoFieldView } from '@/game/richese-no-field';
+import { isBoardNoFieldLocation, isHomeworldNoFieldLocation, type RicheseNoFieldView } from '@/game/richese-no-field';
+import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
import { quoteSmugglerNoField } from '@/game/smuggler-no-field';
import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from '@/game/richese-homeworld-shipment';
import { RicheseHomeworldForces } from './richese-homeworld-forces';
@@ -58,7 +59,7 @@ export function NoFieldBoardMarkers({
}) {
return players.flatMap((player) => {
const marker = player.noField?.deployed;
- if (!marker) return [];
+ if (!marker || !isBoardNoFieldLocation(marker.location)) return [];
const t = territory(marker.location.territory);
const label = `${player.name}: concealed No-Field in ${t.name}, sector ${marker.location.sector}. Counts as one force until revealed.`;
return [
@@ -135,6 +136,10 @@ export function RicheseNoFieldControls({
) ?? available.find((token) => token.id !== pairConcealed?.id);
const nexus = game.nexusRicheseCunning;
const deployed = state.deployed;
+ const deployedHomeworldId = deployed && isHomeworldNoFieldLocation(deployed.location)
+ ? deployed.location.homeworld : null;
+ const deployedHomeworld = game.homeworlds?.worlds?.find(world => world.id === deployedHomeworldId);
+ const deployedHomeworldName = HOMEWORLD_CARDS.find(card => card.id === deployedHomeworld?.card)?.name ?? 'a Homeworld';
const selectedTerritory = territory(destination);
const rate = {
faction: me.faction,
@@ -225,7 +230,7 @@ export function RicheseNoFieldControls({
No-Field {token.value}
{deployed?.tokenId === token.id
- ? 'Concealed on the board'
+ ? 'Concealed in play'
: state.lastShipped === token.id
? 'Last used · cannot repeat'
: 'Available'}
@@ -235,11 +240,14 @@ export function RicheseNoFieldControls({
{deployed && (
<>
-
+ {isBoardNoFieldLocation(deployed.location) ?
Concealed at {territory(deployed.location.territory).name}, sector{' '}
{deployed.location.sector}. Use the movement controls to move the
marker alone or with physical forces from that territory.
-
+ :
+ Concealed at {deployedHomeworldName}.
+ The marker counts as one force until it is revealed.
+
}
card.id === 'tupile')!.high.reserves.min;
type Group = [string, number, number];
- const eligible = (groups: readonly Group[]): string | null => {
+ const eligible = (groups: readonly Group[], presence: readonly string[]): string | null => {
const nativeGroup = groups.find(([id]) => id === native.id);
if (!nativeGroup || nativeGroup[1] + nativeGroup[2] >= high) return null;
- const foreign = groups.filter(([id, normal, elite]) => id !== native.id && normal + elite > 0);
- return foreign.length === 1 ? foreign[0][0] : null;
+ const foreign = presence.filter(id => id !== native.id);
+ return foreign.length === 1 ? foreign[0] : null;
};
const qualifications = new Map();
for (const fact of history.qualifications) if (fact.world === 'homeworld:choam') {
@@ -41,10 +42,10 @@ export function basicTupileActivations(game: StableHomeworldOccupationContext):
let activeIndex: number | null = null;
for (const observation of history.sources) {
for (const player of qualifications.get(observation.event) ?? []) qualified.add(player);
- const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, Group[]][];
- const groups = snapshot.find(([world]) => world === 'homeworld:choam');
+ const snapshot = readHomeworldOccupationSnapshot(history.snapshots[observation.snapshot], homeworldContext(game));
+ const groups = snapshot.groups.find(([world]) => world === 'homeworld:choam');
if (!groups) throw new HomeworldCustodyError('The original Basic Tupile observation lost its native world.');
- const candidate = eligible(groups[1]);
+ const candidate = eligible(groups[1], homeworldOccupationSnapshotPresence(snapshot, 'homeworld:choam'));
if (activeIndex !== null && epochs[activeIndex].occupier !== candidate) activeIndex = null;
if (activeIndex === null && candidate && qualified.has(candidate) && observation.cause !== 'setup') {
activeIndex = epochs.length;
@@ -54,7 +55,8 @@ export function basicTupileActivations(game: StableHomeworldOccupationContext):
const world = homeworldForceGroups(homeworldContext(game), game.homeworlds.custody)
.find(world => world.id === 'homeworld:choam')!;
const current = activeIndex === null ? null : epochs[activeIndex];
- const actual = eligible(Object.entries(world.forces).map(([id, forces]): Group => [id, forces.normal, forces.elite]));
+ const actual = eligible(Object.entries(world.forces).map(([id, forces]): Group => [id, forces.normal, forces.elite]),
+ liveHomeworldOccupationPresence(game, 'homeworld:choam'));
if (actual !== current?.occupier) {
return { epochs, current: null, blocked: actual && qualified.has(actual)
? 'The current Basic Tupile activation must be observed before its benefit settles.' : null };
diff --git a/game/board-resolution-quote.ts b/game/board-resolution-quote.ts
index 92365442..b417906e 100644
--- a/game/board-resolution-quote.ts
+++ b/game/board-resolution-quote.ts
@@ -2,6 +2,7 @@ import type { Player } from './engine';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import type { ForcePresence } from './force-presence';
import { presenceByLocation } from './force-presence';
+import { isHomeworldNoFieldLocation } from './richese-no-field';
import { fighterCount, isAdvisor } from './advisors';
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
@@ -74,7 +75,7 @@ export function settledBoard(g: BoardContext) {
const marker = p.noField?.deployed?.location;
if (marker)
requireBoard(
- validGameLocation(g, marker.territory, marker.sector),
+ isHomeworldNoFieldLocation(marker) || validGameLocation(g, marker.territory, marker.sector),
'The No-Field needs a valid board location.',
);
}
diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts
index 91419eb8..e0d9b47c 100644
--- a/game/bot-arrival.ts
+++ b/game/bot-arrival.ts
@@ -1,6 +1,7 @@
import type { Action, GameView } from './engine';
import { arrivalAsAdvisor } from './advisors';
import { splitLocation, territory, validLocation } from './board';
+import { isBoardNoFieldLocation } from './richese-no-field';
import {
MovementArrivalError,
quoteCompletedMovementArrival,
@@ -159,7 +160,7 @@ export function botArrivalBlock(g: GameView, action: Action): string | null {
const origins = new Set();
if (action.noField !== undefined) {
const marker = me.noField?.deployed;
- if (me.faction !== 'richese' || !marker) return null;
+ if (me.faction !== 'richese' || !marker || !isBoardNoFieldLocation(marker.location)) return null;
origins.add(marker.location.territory);
}
for (const [key, count] of entries) {
diff --git a/game/bot-guild-no-field-transport.ts b/game/bot-guild-no-field-transport.ts
index 9ff89c62..0ab754c4 100644
--- a/game/bot-guild-no-field-transport.ts
+++ b/game/bot-guild-no-field-transport.ts
@@ -3,6 +3,7 @@ import type { Action, GameView } from './engine';
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
import { guildTransportQuote } from './transport-quote';
+import { isBoardNoFieldLocation } from './richese-no-field';
/** Minimal legal choices for the owned concealed marker, before auto-revelation.
* The shared quotes own timing, entry, pricing and original source validation.
@@ -12,14 +13,16 @@ export function guildNoFieldTransportActions(g: GameView): Action[] {
const inventory = g.richeseNoField;
const marker = inventory?.owner === g.me ? inventory.private?.deployed : null;
const publicMarker = inventory?.public.deployed;
- if (!own || own.shipped || !marker || marker.controller !== own.id || !publicMarker ||
+ if (!own || own.shipped || !marker || !isBoardNoFieldLocation(marker.location) || marker.controller !== own.id || !publicMarker ||
+ !isBoardNoFieldLocation(publicMarker.location) ||
publicMarker.controller !== own.id || !inventory?.event ||
!nexusGuildShipmentAvailable(g)) return [];
const guild = g.players.find(player => player.faction === 'guild');
const native = !!guild && own.ally === guild.id && guild.ally === own.id;
const secret = nexusGuildSecretAllyCanAct(g);
if (!native && !secret) return [];
- const destinations = gameTerritories(g).flatMap(territory => territory.id === publicMarker.location.territory
+ const origin = publicMarker.location.territory;
+ const destinations = gameTerritories(g).flatMap(territory => territory.id === origin
? [] : territory.sectors.map(sector => ({ territory: territory.id, sector })));
if (secret) destinations.push({ territory: 'reserves', sector: 0 });
const actions: Action[] = [];
diff --git a/game/bot-planetologist-flight.ts b/game/bot-planetologist-flight.ts
index aa58a403..a87dcdbb 100644
--- a/game/bot-planetologist-flight.ts
+++ b/game/bot-planetologist-flight.ts
@@ -1,5 +1,6 @@
import type { Action, GameView } from './engine';
import { location, splitLocation } from './board';
+import { isBoardNoFieldLocation } from './richese-no-field';
import { botGroundMoveAllowed } from './bot-mobility';
import { planetologistFlightSelection, type PlanetologistFlight } from './planetologist-flight';
import { planetologistLeader, planetologistMovementModeSupported,
@@ -21,7 +22,9 @@ export function botPlanetologistFlightActions(game: GameView,
!planetologistLeader(game, me.id) || !planetologistMovementModeSupported(game) ||
(!game.ornithopter?.active && !nexusGuildMovementAvailable(game))) return [];
const info = game.richeseNoField?.owner === me.id ? game.richeseNoField : null;
- const marker = info?.private?.deployed;
+ const deployed = info?.private?.deployed;
+ const marker = deployed && isBoardNoFieldLocation(deployed.location)
+ ? { ...deployed, location: deployed.location } : null;
const result: Action[] = [];
for (const transport of transports) {
const flight = planetologistFlightSelection(game, transport);
diff --git a/game/bots.ts b/game/bots.ts
index d5c94cfa..a41b8846 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -47,6 +47,7 @@ import { choamSaleGholaTiming } from './choam-market-ghola';
import { junctionTransportActions } from './junction-transport-options';
import { currentJunctionOffer, junctionSponsor } from './junction-offer';
import { homeworldShipmentActions } from './homeworld-shipment-options';
+import { isBoardNoFieldLocation, isHomeworldNoFieldLocation } from './richese-no-field';
import {
emperorHomeworldMoveActions,
withNativeShipmentSources,
@@ -461,7 +462,8 @@ function ornithopterMoves(g: GameView): Action[] {
active = flight.active,
cohort = active?.cohort;
const noField = g.richeseNoField?.owner === me.id ? g.richeseNoField : null;
- const marker = noField?.private?.deployed;
+ const deployed = noField?.private?.deployed;
+ const marker = deployed && isBoardNoFieldLocation(deployed.location) ? { ...deployed, location: deployed.location } : null;
const markerSource = marker
? location(marker.location.territory, marker.location.sector)
: null;
@@ -2103,7 +2105,7 @@ function policyActions(g: GameView): Action[] {
splitLocation(key).sector !== g.storm),
) ||
!!(
- me.noField?.deployed &&
+ me.noField?.deployed && isBoardNoFieldLocation(me.noField.deployed.location) &&
(me.noField.deployed.location.sector === 0 ||
me.noField.deployed.location.sector !== g.storm)
);
@@ -3531,19 +3533,21 @@ function policyActions(g: GameView): Action[] {
const guildSecret = nexusGuildSecretAllyCanAct(g);
const noField = g.richeseNoField;
const ownNoField = noField?.owner === me.id ? noField.private : null;
- const marker = ownNoField?.deployed;
+ const deployed = ownNoField?.deployed;
+ const marker = deployed && isBoardNoFieldLocation(deployed.location) ? { ...deployed, location: deployed.location } : null;
+ const deployedWorld = deployed && isHomeworldNoFieldLocation(deployed.location) ? deployed.location.homeworld : null;
const deployedToken = ownNoField?.tokens.find(
- (token) => token.id === marker?.tokenId,
+ (token) => token.id === deployed?.tokenId,
);
const materialized = Math.min(deployedToken?.value ?? 0, me.reserves);
const physicalAlongsideMarker = marker
? countAt(me, marker.location.territory) > 0
- : false;
+ : !!(deployedWorld && g.homeworlds?.worlds?.find(world => world.id === deployedWorld)?.forces[me.id]);
const reveal =
noField?.canReveal &&
- marker &&
+ deployed &&
(materialized > 0 || physicalAlongsideMarker)
- ? { type: 'revealNoField', token: marker.tokenId, event: noField.event }
+ ? { type: 'revealNoField', token: deployed.tokenId, event: noField.event }
: null;
if (
reveal &&
@@ -3557,7 +3561,7 @@ function policyActions(g: GameView): Action[] {
to.t !== MOBILE_STRONGHOLD &&
!territoryEntryBlock(g.players, me.id, to.t),
);
- if (noField?.canShip && ownNoField && !marker && !me.shipped) {
+ if (noField?.canShip && ownNoField && !deployed && !me.shipped) {
const tokens = ownNoField.tokens.filter(
(token) => token.id !== ownNoField.lastShipped,
);
diff --git a/game/combat-location.ts b/game/combat-location.ts
index 0b46dfcb..6aff09fd 100644
--- a/game/combat-location.ts
+++ b/game/combat-location.ts
@@ -3,8 +3,9 @@ import { gameTerritories } from './board';
import { homeworldContext } from './homeworld-game';
import {
homeworldCombatLocation,
- quoteHomeworldBattles,
} from './homeworld-combat';
+import { quoteHomeworldBattlesWithNoField } from './richese-homeworld-marker-combat';
+import { isHomeworldNoFieldLocation } from './richese-no-field';
import { homeworldLocations, type HomeworldForces } from './homeworld-custody';
import {
quoteBattleBoard,
@@ -86,7 +87,10 @@ export function quoteCombatBoard(g: Game) {
const board = quoteBattleBoard(g);
if (!g.homeworlds?.custody) return board;
const context = homeworldContext(g);
- const worlds = quoteHomeworldBattles(
+ const deployed = g.players.find(p => p.faction === 'richese')?.noField?.deployed;
+ const marker = deployed && isHomeworldNoFieldLocation(deployed.location)
+ ? {controller:deployed.controller,location:deployed.location} : null;
+ const worlds = quoteHomeworldBattlesWithNoField(
{
...context,
order: g.order,
@@ -96,6 +100,7 @@ export function quoteCombatBoard(g: Game) {
})),
},
g.homeworlds.custody,
+ marker,
);
return {
...board,
diff --git a/game/disaster-preflight.ts b/game/disaster-preflight.ts
index a1eacb8f..f0e1f0aa 100644
--- a/game/disaster-preflight.ts
+++ b/game/disaster-preflight.ts
@@ -2,7 +2,7 @@ import { stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './d
import type { Game, Player, ResponseWindow } from './engine';
import { splitLocation, territory, validLocation } from './board';
import { SPICE_CARDS } from './cards';
-import { revealRicheseNoField } from './richese-no-field';
+import { isBoardNoFieldLocation, revealRicheseNoField } from './richese-no-field';
import { DISCOVERY_SPICE_CARDS } from './discoveries';
export class DisasterPreflightError extends Error {}
@@ -52,7 +52,7 @@ function casualtyCustody(
}
const marker = p.noField?.deployed;
if (
- marker &&
+ marker && isBoardNoFieldLocation(marker.location) &&
affected(`${marker.location.territory}:${marker.location.sector}`)
) {
requireDisaster(
diff --git a/game/discovery-entry.ts b/game/discovery-entry.ts
index e227c64f..95fca870 100644
--- a/game/discovery-entry.ts
+++ b/game/discovery-entry.ts
@@ -10,7 +10,7 @@ import {
} from './discoveries';
import { splitLocation, validGameLocation, type MobileBoard } from './board';
import { territoryEntryBlock, type OccupancySeat } from './occupancy';
-import type { RicheseNoField } from './richese-no-field';
+import { isBoardNoFieldLocation, type RicheseNoField } from './richese-no-field';
export class DiscoveryEntryError extends Error {}
@@ -375,7 +375,7 @@ function sourceOffer(
* do not supply physical counters or alter this optional relocation. */
function noFieldOffer(g: DiscoveryEntryContext, player: DiscoveryEntrySeat, parent: string): DiscoveryEntryNoField | undefined {
const marker = player.noField?.deployed;
- if (player.faction !== 'richese' || !marker || marker.controller !== player.id ||
+ if (player.faction !== 'richese' || !marker || !isBoardNoFieldLocation(marker.location) || marker.controller !== player.id ||
!identifier(marker.tokenId) || !identifier(player.noFieldEvent) ||
marker.location.territory !== parent || marker.location.sector === g.storm ||
!validGameLocation(g, marker.location.territory, marker.location.sector)) return undefined;
diff --git a/game/discovery-flight-options.ts b/game/discovery-flight-options.ts
index 624c3363..93bade19 100644
--- a/game/discovery-flight-options.ts
+++ b/game/discovery-flight-options.ts
@@ -9,6 +9,7 @@ import { planetologistLeader, planetologistMovementModeSupported, planetologistG
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
import { validateCohortSelection } from './ornithopter';
+import { isBoardNoFieldLocation } from './richese-no-field';
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
@@ -67,7 +68,7 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
}
const deployed = game.richeseNoField?.owner === me.id
? game.richeseNoField.private?.deployed : undefined;
- const marker = move.noField === undefined ? undefined : deployed &&
+ const marker = move.noField === undefined ? undefined : deployed && isBoardNoFieldLocation(deployed.location) &&
deployed.tokenId === move.noField && game.richeseNoField?.event === move.event
? { tokenId: deployed.tokenId, event: String(move.event),
from: location(deployed.location.territory, deployed.location.sector) } : null;
diff --git a/game/engine.ts b/game/engine.ts
index 71dcc68d..5270db79 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -185,7 +185,7 @@ import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusCh
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
-import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
+import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, homeworldOccupationMarker, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
@@ -274,6 +274,7 @@ import {
import {
createHomeworldCustody,
HomeworldCustodyError,
+ quoteHomeworldCustody,
type HomeworldCustody,
} from './homeworld-custody';
import {
@@ -546,6 +547,7 @@ import {
type RicheseGiftIntent,
} from './richese-gift';
import { richeseCards, richeseCardDefinition } from './richese-cards';
+import { quoteRicheseHomeworldMarkerShipment } from './richese-homeworld-marker-shipment';
import {
createRicheseNoField,
deployRicheseNoField,
@@ -556,6 +558,8 @@ import {
recallRicheseNoField,
shipAlliedRicheseNoField,
projectRicheseNoField,
+ isHomeworldNoFieldLocation,
+ isBoardNoFieldLocation,
type RicheseNoField,
type NoFieldRevealCause,
} from './richese-no-field';
@@ -1223,7 +1227,7 @@ export type ResponseWindow = {
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
- source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome';
+ source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome' | 'homeworldNoField';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
@@ -1325,6 +1329,8 @@ type RichesePreCostDeclaration = { round: string; lot: string } & (
| { kind: 'special'; intent: Extract }
);
type PendingHomeworldShipment = HomeworldShipmentIntent & {
+ /** The selected physical marker and its original private inventory event. */
+ noField?: { tokenId: string; event: string };
guildSecretEvent?: string;
guildNexusEvent?: string;
guildRateEvent?: string;
@@ -4715,7 +4721,12 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
p.faction === 'richese' && p.noField?.deployed,
'No concealed No-Field is deployed.',
);
- requireRule(validGameLocation(g, p.noField!.deployed!.location.territory, p.noField!.deployed!.location.sector),
+ const deployedLocation = p.noField.deployed.location;
+ if (isHomeworldNoFieldLocation(deployedLocation)) {
+ requireRule(g.homeworlds?.custody &&
+ homeworldForceGroups(homeworldContext(g), g.homeworlds.custody).some(world => world.id === deployedLocation.homeworld),
+ 'A No-Field can reveal only on an active Homeworld.');
+ } else requireRule(validGameLocation(g, deployedLocation.territory, deployedLocation.sector),
'A No-Field can reveal only in a current board location.');
const result = noFieldRule(() =>
revealRicheseNoField(p.noField!, {
@@ -4734,19 +4745,35 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
cohort?.noField?.tokenId === p.noField!.deployed!.tokenId &&
cohort.noField.event === p.noFieldEvent
) {
+ requireRule(isBoardNoFieldLocation(result.location), 'A Homeworld No-Field cannot belong to a board movement cohort.');
const key = location(result.location.territory, result.location.sector);
cohort.forces[key] = (cohort.forces[key] ?? 0) + result.forces;
delete cohort.noField;
}
p.noField = result.state;
p.noFieldEvent = crypto.randomUUID();
- p.reserves -= result.forces;
- if (result.forces)
- place(p, result.location.territory, result.location.sector, result.forces);
+ if (isHomeworldNoFieldLocation(result.location)) {
+ const destination = result.location.homeworld;
+ if (result.forces) {
+ const transfer = homeworldRule(() => quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
+ { homeworld: 'homeworld:richese', player: p.id, withdraw: { normal: result.forces, elite: 0 }, deposit: { normal: 0, elite: 0 } },
+ { homeworld: destination, player: p.id, withdraw: { normal: 0, elite: 0 }, deposit: { normal: result.forces, elite: 0 } },
+ ]));
+ g.homeworlds!.custody = transfer.state;
+ for (const seat of transfer.players) {
+ const owner = getPlayer(g, seat.id);
+ owner.reserves = seat.reserves;
+ if (owner.elites) owner.elites.reserves = seat.eliteReserves;
+ }
+ }
+ } else {
+ p.reserves -= result.forces;
+ if (result.forces) place(p, result.location.territory, result.location.sector, result.forces);
+ }
observeOccupation(g);
log(
g,
- `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
+ `${p.name} revealed the ${result.value} No-Field in ${isHomeworldNoFieldLocation(result.location) ? combatLocationName(g, result.location.homeworld) : territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
{ faction: 'richese', name: 'No-Field revealed' },
);
return result;
@@ -5546,10 +5573,10 @@ function currentAtomicsQuote(
requireRule(terrorEntryLocationAllowed(g,entry), 'Atomics lost its original physical placement source.');
// Provisional explosion composition: quote the same capped physical reveal
// that allowance will perform. Forecasts never change or expose a live marker.
- const forecast = g.players.some(player => player.noField?.deployed?.location.territory === entry.territory)
+ const forecast = g.players.some(player => noFieldAtCombatLocation(player,entry.territory))
? structuredClone(g) : g;
if (forecast !== g) for (const player of forecast.players)
- if (player.noField?.deployed?.location.territory === entry.territory)
+ if (noFieldAtCombatLocation(player,entry.territory))
revealPlayerNoField(forecast, player, 'atomics');
return atomicsRule(() => quoteMoritaniAtomics({
token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id,
@@ -5567,7 +5594,7 @@ function resolveAtomics(
quote: AtomicsQuote,
) {
for (const player of g.players)
- if (player.noField?.deployed?.location.territory === entry.territory)
+ if (noFieldAtCombatLocation(player,entry.territory))
revealPlayerNoField(g, player, 'atomics');
g.moritaniAtomics = quote.aftermath;
for (const casualty of quote.casualties) {
@@ -8694,8 +8721,8 @@ function treacheryDiscardIntegrity(g: Game) {
m.total - (m.noField ? 1 : 0) &&
(owner.elites?.forces[location(m.to, m.sector)] ?? 0) >= m.elite &&
(!m.noField ||
- (owner.noField?.deployed?.location.territory === m.to &&
- owner.noField.deployed.location.sector === m.sector)),
+ (boardNoFieldLocation(owner)?.territory === m.to &&
+ boardNoFieldLocation(owner)?.sector === m.sector)),
'The retired Ornithopter arrival differs from its committed movement.',
);
} else
@@ -10302,6 +10329,14 @@ function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: b
g.homeworldBattleLoss = null;
finishBattle(g);
}
+function boardNoFieldLocation(p: Player) {
+ const loc = p.noField?.deployed?.location;
+ return loc && isBoardNoFieldLocation(loc) ? loc : null;
+}
+function noFieldAtCombatLocation(p: Player, id: string): boolean {
+ const loc = p.noField?.deployed?.location;
+ return !!loc && (isHomeworldNoFieldLocation(loc) ? loc.homeworld === id : loc.territory === id);
+}
function killTerritory(
g: Game,
p: Player,
@@ -10312,10 +10347,14 @@ function killTerritory(
) {
if (t.startsWith('homeworld:')) {
requireRule(battle && n === Infinity, 'Homeworld force removal requires an exact combat casualty allocation.');
+ if (noFieldAtCombatLocation(p,t)) {
+ requireRule(noFieldCause, 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.');
+ revealPlayerNoField(g,p,noFieldCause);
+ }
commitHomeworldLoss(g, p.id, t, combatArmy(g, p.id, t));
return;
}
- if (p.noField?.deployed?.location.territory === t) {
+ if (noFieldAtCombatLocation(p,t)) {
requireRule(
noFieldCause,
'This removal of a concealed No-Field awaits its specific reveal and casualty rules.',
@@ -10333,7 +10372,7 @@ function killTerritory(
* before requesting a pool. Actual reveal remains the native component action. */
function prospectiveNoFieldForces(p: Player, territory: string): number {
const marker = p.noField?.deployed;
- return marker?.location.territory === territory
+ return marker && noFieldAtCombatLocation(p,territory)
? Math.min(p.reserves, p.noField!.tokens.find(token => token.id === marker.tokenId)!.value)
: 0;
}
@@ -10379,7 +10418,7 @@ function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string):
for (const y in ally.forces)
if (ally.forces[y] > 0 && splitLocation(y).territory === t &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0) return true;
- if (marker?.location.territory === t &&
+ if (marker && isBoardNoFieldLocation(marker.location) && marker.location.territory === t &&
gameDistance(g, x, location(t, marker.location.sector), key => splitLocation(key).sector === g.storm) === 0) return true;
}
return false;
@@ -10395,7 +10434,7 @@ function battleNoFieldPlan(g: Game, id: string): boolean {
}
function battleConcealedForces(g: Game, id: string): boolean {
const b = g.battle;
- return !!b && getPlayer(g, battlePhysicalForceOwner(g, id)).noField?.deployed?.location.territory === b.territory;
+ return !!b && noFieldAtCombatLocation(getPlayer(g, battlePhysicalForceOwner(g, id)),b.territory);
}
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
const b = g.battle, occupy = b?.ecazOccupy;
@@ -13421,7 +13460,7 @@ function continueStorm(g: Game) {
returnAmbassadorsIn(g, city, 'the storm crossed the exposed city');
for (const p of g.players) {
const marker = p.noField?.deployed?.location;
- if (marker && marker.sector === s) {
+ if (marker && isBoardNoFieldLocation(marker) && marker.sector === s) {
const t = territory(marker.territory);
if (
stormExposesTerritory(t.id, g.shieldWallDestroyed)
@@ -13895,8 +13934,8 @@ function discoveryEntryIntegrity(g: Game) {
(owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
'The Discovery arrival has lost its transferred ordinary or elite force custody.');
if (marker) requireRule(owner.faction === 'richese' && owner.noField?.deployed?.tokenId === marker.tokenId &&
- owner.noField.deployed.controller === owner.id && owner.noField.deployed.location.territory === arrival.destination &&
- owner.noField.deployed.location.sector === 0 && !!owner.noFieldEvent && owner.noFieldEvent !== marker.event,
+ owner.noField.deployed.controller === owner.id && boardNoFieldLocation(owner)?.territory === arrival.destination &&
+ boardNoFieldLocation(owner)?.sector === 0 && !!owner.noFieldEvent && owner.noFieldEvent !== marker.event,
'The Discovery arrival has lost its transferred concealed No-Field.');
}
const automatic = g.pendingAutomaticAdvisorRelocation;
@@ -13964,7 +14003,8 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
return;
}
const marker = action.noField === undefined && action.noFieldEvent === undefined ? undefined : (() => {
- requireRule(p.noField?.deployed, 'Choose your currently deployed concealed No-Field.');
+ requireRule(p.noField?.deployed && isBoardNoFieldLocation(p.noField.deployed.location),
+ 'Choose your currently deployed Arrakis No-Field.');
return {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent),
source:location(p.noField.deployed.location.territory,p.noField.deployed.location.sector)};
})();
@@ -18060,7 +18100,7 @@ function reinforcementsPreview(g: Game, p: Player) {
return { blocked, normal, elite,
...(!blocked && g.homeworlds?.custody ? { homeworldSources: homeworldRule(() =>
reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id)) } : {}),
- ...(p.noField?.deployed?.location.territory === battle.territory
+ ...(noFieldAtCombatLocation(p,battle.territory)
? { noField: { materializes, reservesAfterReveal } } : {}) };
}
@@ -18338,6 +18378,13 @@ function choamSaleCancellationQuote(g: Game, response: ResponseWindow) {
}
function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
if (response.kind !== 'richeseNoField') return null;
+ if (response.source === 'homeworldNoField') {
+ const shipment = g.pendingHomeworldShipment;
+ requireRule(shipment?.noField && shipment.player === response.owner,
+ 'The Homeworld No-Field response lost its original shipment.');
+ validateHomeworldShipment(g, shipment);
+ return {kind:'own' as const,player:shipment.player,blockedTurn:g.turn,pendingShipment:null};
+ }
if (response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response);
return {kind:'own' as const,player:shipment.ambassadorMarker!.owner,blockedTurn:g.turn,pendingShipment:null};
@@ -19606,6 +19653,7 @@ function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) {
requireRule(
p.faction === 'richese' &&
marker &&
+ isBoardNoFieldLocation(marker.location) &&
marker.tokenId === action.noField &&
p.noFieldEvent === action.event,
'This concealed No-Field movement selection is stale.',
@@ -22367,7 +22415,7 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
// the actual declaration still validates exact speed/groups/custody.
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const sources = [...Object.keys(player.forces).filter(key => player.forces[key] > 0),
- ...(marker ? [location(marker.location.territory, marker.location.sector)] : [])];
+ ...(marker && isBoardNoFieldLocation(marker.location) ? [location(marker.location.territory, marker.location.sector)] : [])];
return sources.some(key => splitLocation(key).territory === origin &&
gameDistance(state, key, location(destination, sector), point => pathBlocked(state, player, point, isAdvisor(player, origin))) <= Math.max(3, movementRange(state, player, 1)));
},
@@ -22802,7 +22850,7 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
pools.elites,
Object.fromEntries(group),
eliteGroup,
- marker && p.noFieldEvent
+ marker && isBoardNoFieldLocation(marker.location) && p.noFieldEvent
? {
tokenId: marker.tokenId,
event: p.noFieldEvent,
@@ -22955,7 +23003,7 @@ function alliedSeparationView(g: Game, p: Player): {territories: string[]; noFie
const territories = places.map(id => territory(id).name);
const marker = p.noField?.deployed;
return territories.length ? {territories,
- ...(marker?.controller === p.id && places.includes(marker.location.territory) ? {noField:true as const} : {})} : null;
+ ...(marker?.controller === p.id && isBoardNoFieldLocation(marker.location) && places.includes(marker.location.territory) ? {noField:true as const} : {})} : null;
}
function basicAlliedShipmentView(g: Game, p: Player) {
@@ -28880,7 +28928,7 @@ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' =
g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause,
cause === 'change'
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
- : `homeworld-turn-${g.turn}-${cause}`));
+ : `homeworld-turn-${g.turn}-${cause}`, homeworldOccupationMarker(g.players)));
syncTupileSlots(g);
}
/** A new Duke grant may precede the first sealed plan, but never changes a
@@ -29661,7 +29709,7 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const materializes = prospectiveNoFieldForces(p, b.territory);
if (materializes) {
const marker = p.noField!.deployed!;
- const key = location(marker.location.territory, marker.location.sector);
+ const key = isHomeworldNoFieldLocation(marker.location) ? marker.location.homeworld : location(marker.location.territory, marker.location.sector);
if (locations[key]) locations[key].normal += materializes;
else locations[key] = { normal: materializes, elite: 0 };
}
@@ -29702,7 +29750,7 @@ function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null
const materializes = prospectiveNoFieldForces(p, b.territory);
return { ...context, card: HARASS_WITHDRAW_CARD, allowAllocation: b.harassAllocationVersion === 1,
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null),
- ...(p.noField?.deployed?.location.territory === b.territory
+ ...(noFieldAtCombatLocation(p,b.territory)
? { noField: { materializes, reservesAfterReveal: p.reserves - materializes } } : {}) };
}
function validateResidualPoison(
@@ -30003,7 +30051,7 @@ function stonePublicPools(
const b = g.battle!, t = b.territory, occupy = b.ecazOccupy;
const owner = occupy?.lead === p.id && !occupy.canceled ? getPlayer(g, occupy.ally) : p;
const enemy = occupy?.lead === opponent.id && !occupy.canceled ? getPlayer(g, occupy.ally) : opponent;
- const hiddenMarker = owner.noField?.deployed?.location.territory === t;
+ const hiddenMarker = noFieldAtCombatLocation(owner,t);
const pool = combatForces(g, hiddenMarker ? { ...owner, noField: undefined } : owner, t, enemy);
return hiddenMarker
? Array.from({ length: 6 }, (_, n) => ({
@@ -32434,7 +32482,7 @@ function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleRe
faction: p.faction,
ally: p.ally,
noFieldAtTerritory:
- p.noField?.deployed?.location.territory === b.territory,
+ noFieldAtCombatLocation(p,b.territory),
})),
physicalCards: physicalTreacheryCards(g),
pendingAuditorPresent: !!g.pendingAuditor,
@@ -32529,7 +32577,7 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
const leader = controlledLeaders(g, p).find(candidate => candidate.id === plan.leader && !candidate.dead);
if (!leader || (player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender) ||
!battleLeaderSkills(g, p).some(skill => skill.skill === 'diplomat' && skill.leader === leader.id &&
- (!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null;
+ (!skill.faceUp || skill.captured)) || noFieldAtCombatLocation(p,b.territory)) return null;
const opponent = getPlayer(g, quote.winner);
const forces = combatForces(g, p, b.territory, opponent);
const occupy = ecazBattleProfile(g);
@@ -35819,6 +35867,23 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
g.decision = resume.decision;
g.pendingKarama = resume.pendingKarama;
g.phaseOpening = resume.phaseOpening;
+ } else if (response.kind === 'richeseNoField' && response.source === 'homeworldNoField') {
+ const shipment = g.pendingHomeworldShipment;
+ requireRule(shipment?.noField && shipment.player === response.owner,
+ 'The Homeworld No-Field response lost its original shipment.');
+ validateHomeworldShipment(g, shipment);
+ g.pendingHomeworldShipment = null;
+ if (canceled) {
+ getPlayer(g,shipment.player).noFieldBlockedTurn = g.turn;
+ log(g, 'Karama prevented this Homeworld No-Field shipment. No spice, forces or token history changed; an ordinary shipment remains available.');
+ } else {
+ const guild = byFaction(g, 'guild');
+ if (g.advanced && guild && !guild.specialKaramaUsed) {
+ g.pendingHomeworldShipment = shipment;
+ g.decision = {kind:'homeworldShipmentGuild',player:guild.id,shipper:shipment.player,
+ destination:shipment.destination,amount:shipment.amount,event:shipment.event};
+ } else continueHomeworldShipment(g,shipment);
+ }
} else if (response.kind === 'richeseNoField' && response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response), entry = g.pendingAmbassador!;
g.pendingShipment = null;
@@ -36862,7 +36927,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
function homeworldAllianceReason(g: Game, first: string, second: string) {
return g.homeworlds?.custody
- ? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second)) : null;
+ ? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second, homeworldOccupationMarker(g.players))) : null;
}
function homeworldShipmentAutomatic(g: Game): boolean {
const decision = g.decision;
@@ -37056,7 +37121,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
contribution(g, p, quote.quote.cost, action.allyPayment);
if(!openAdvisorTransportMatching(g,{kind:'junction',declaration}))commitJunctionTransport(g,p,declaration);
}
-function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
+function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string}}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
requireRule(!intent.guildSecretEvent || g.karamaShipping?.player !== intent.player,
'Choose the purchased Karama price or Guild Secret Ally, not both.');
@@ -37069,6 +37134,20 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
purchasedGuildRate: g.karamaShipping?.player === intent.player,
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
};
+ if (intent.noField) {
+ const p = getPlayer(g, intent.player);
+ requireRule(!g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
+ !g.techTokens && !g.strongholdCards,
+ 'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
+ requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
+ !Array.isArray(intent.sources) && Object.keys(intent.sources).length === 0 &&
+ p.noField && p.noFieldEvent === intent.noField.event && p.noFieldBlockedTurn !== g.turn,
+ 'The original concealed Homeworld shipment is no longer available.');
+ const marker = homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
+ {player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!));
+ return {...marker, state:g.homeworlds.custody, players:context.players, sources:[],
+ boardForces:undefined,boardEliteForces:undefined,sourceNames:[combatLocationName(g,marker.source)]};
+ }
const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
if (intent.route === 'arrakis') {
const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries,
@@ -37108,6 +37187,14 @@ function homeworldShipmentIntegrity(g: Game) {
const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild');
const shipment = g.pendingHomeworldShipment;
if (!shipment && !decisions.length) return;
+ const markerResponses = shipment?.noField
+ ? savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField' && response.source === 'homeworldNoField') : [];
+ if (shipment?.noField && markerResponses.length) {
+ requireRule(!decisions.length && markerResponses.length === 1 && markerResponses[0].owner === shipment.player && !g.pendingShipment,
+ 'The saved Homeworld No-Field declaration lost its original response.');
+ validateHomeworldShipment(g,shipment);
+ return;
+ }
if (shipment?.guildTransportEvent) {
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
@@ -37143,6 +37230,11 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
payWithAlly(g, p, quote.cost, shipment.allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
+ if (shipment.noField) {
+ requireRule('noField' in quote && quote.noField, 'The Homeworld shipment lost its physical No-Field.');
+ p.noField = quote.noField;
+ p.noFieldEvent = crypto.randomUUID();
+ }
if (shipment.route === 'arrakis') {
p.forces = quote.boardForces!;
if (p.elites) p.elites.forces = quote.boardEliteForces!;
@@ -37174,16 +37266,22 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
{ own: false, ally: !purchasedRate && !!guild && p.ally !== guild.id });
finishShipmentPromises(g, p, null);
if (purchasedRate) g.karamaShipping = null;
- log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
+ log(g, `${p.name} shipped ${shipment.noField ? 'one concealed No-Field' : `${quote.amount} physical forces (${quote.elite} special)`} from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
if (heldConversion) openAutomaticAdvisorFlip(g);
}
-function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) {
+function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string}}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources),
event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite,
cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources};
bindGuildCunningShipment(g,p,'homeworld',shipment);
+ if (shipment.noField) {
+ g.pendingHomeworldShipment = shipment;
+ g.response = {kind:'richeseNoField',source:'homeworldNoField',owner:p.id,passed:[]};
+ log(g, `${p.name} declared a concealed No-Field shipment to ${combatLocationName(g,intent.destination)}. Payment and placement await the response.`);
+ return;
+ }
const guild = byFaction(g, 'guild');
if (g.advanced && guild && !guild.specialKaramaUsed) {
g.pendingHomeworldShipment = shipment;
@@ -37809,7 +37907,7 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
shipper.faction === 'richese' && !intent.fromReserves && !intent.advisors &&
typeof marker.tokenId === 'string' && typeof marker.event === 'string' &&
typeof marker.from === 'string' && deployed?.tokenId === marker.tokenId &&
- deployed.controller === shipper.id && shipper.noFieldEvent === marker.event &&
+ deployed.controller === shipper.id && shipper.noFieldEvent === marker.event && isBoardNoFieldLocation(deployed.location) &&
location(deployed.location.territory,deployed.location.sector) === marker.from &&
deployed.location.territory === intent.origin) &&
(intent.fromReserves
@@ -39176,12 +39274,14 @@ function gamontAvailable(
return (
!!marker &&
p.noFieldEvent === noFieldEvent &&
+ isBoardNoFieldLocation(marker.location) &&
location(marker.location.territory, marker.location.sector) === key &&
elite === 0
);
}
if (
p.noField?.deployed &&
+ isBoardNoFieldLocation(p.noField.deployed.location) &&
location(
p.noField.deployed.location.territory,
p.noField.deployed.location.sector,
@@ -39297,6 +39397,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action, movementPreparat
const marker = other.noField?.deployed;
const concealed =
!!marker &&
+ isBoardNoFieldLocation(marker.location) &&
location(marker.location.territory, marker.location.sector) === key;
requireRule(
validGameLocation(g, loc.territory, loc.sector) &&
@@ -44392,11 +44493,22 @@ function applyActionInner(
const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
- requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)),
- 'Choose explicit typed source groups and a Homeworld destination without concealed tokens.');
+ requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment',
+ ...(guildSecretEvent ? ['nexus'] : []), ...(action.noField !== undefined ? ['noField','noFieldEvent'] : [])].includes(key)),
+ 'Choose explicit typed source groups or a concealed No-Field and a Homeworld destination.');
requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.');
+ let noField: {tokenId:string;event:string} | undefined;
+ if (action.noField !== undefined) {
+ requireRule(t === 'homeworldShip' && !guildSecretEvent && !currentGuildCunning(g,id),
+ 'This provisional No-Field route uses a native Homeworld shipment.');
+ const markerBlocked = noFieldShipBlock(g,p);
+ requireRule(!markerBlocked,markerBlocked ?? 'The No-Field shipment is unavailable.');
+ requireRule(action.noFieldEvent === p.noFieldEvent, 'This No-Field selection is stale.');
+ noField = {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent)};
+ } else requireRule(action.noFieldEvent === undefined, 'Choose a physical No-Field for its inventory event.');
declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination),
- sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment);
+ sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}),
+ ...(guildSecretEvent ? {guildSecretEvent} : {}), ...(noField ? {noField} : {})}, action.allyPayment);
return g;
}
if (t === 'ship') {
@@ -44812,15 +44924,14 @@ function applyActionInner(
ally?.ally === ecaz.id && connected && !isAdvisor(ally, choice.territory) &&
[choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id);
const noFieldPlayers = g.players.filter(player =>
- player.noField?.deployed?.location.territory === choice.territory &&
+ noFieldAtCombatLocation(player,choice.territory) &&
(player.id === choice.attacker || player.id === choice.defender ||
(combined && ecaz && player.id === ally?.id &&
(choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id);
let mixedNoFieldPlayers: string[] | undefined;
for (const playerId of noFieldPlayers) {
- const mixed = Object.entries(getPlayer(g, playerId).forces).some(
- ([key, n]) => n > 0 && splitLocation(key).territory === choice.territory,
- );
+ const army = combatArmy(g,playerId,choice.territory);
+ const mixed = army.normal + army.elite > 0;
if (!mixed) continue;
requireRule(!combined || ecazOccupyCompositionSupported(g),
'Combined mixed No-Field dialing requires an admitted native Occupy composition.');
@@ -45007,7 +45118,7 @@ function applyActionInner(
if (b.plans[b.attacker] && b.plans[b.defender]) {
for (const playerId of b.noFieldPlayers ?? []) {
const owner = getPlayer(g, playerId);
- if (owner.noField?.deployed?.location.territory === b.territory)
+ if (noFieldAtCombatLocation(owner,b.territory))
revealPlayerNoField(g, owner, 'battle');
}
b.revealed = true;
@@ -46177,6 +46288,7 @@ export function viewGame(state: Game, id: string, now?: number) {
const marker = p.noField?.deployed;
const noField =
!!marker &&
+ isBoardNoFieldLocation(marker.location) &&
location(
marker.location.territory,
marker.location.sector,
diff --git a/game/family-atomics.ts b/game/family-atomics.ts
index 4f58b2ea..fadebac9 100644
--- a/game/family-atomics.ts
+++ b/game/family-atomics.ts
@@ -2,6 +2,7 @@ import { fighterCount, isAdvisor } from './advisors';
import { splitLocation, TERRITORIES } from './board';
import type { OrdinaryCardPlayer } from './card-availability';
import { presenceByLocation } from './force-presence';
+import { isBoardNoFieldLocation } from './richese-no-field';
export type FamilyAtomicsState = {
advanced?: boolean;
@@ -29,10 +30,11 @@ export function familyAtomicsBlock(state: FamilyAtomicsState, player: FamilyAtom
// including denomination0. Its public location suffices; never read private
// value, reserve capacity or token inventory merely to qualify this card.
const marker = player.noField?.deployed;
- const markerHere = marker?.controller === player.id && nearby.some(territory =>
- territory.id === marker.location.territory && territory.sectors.includes(marker.location.sector));
+ const markerLocation = marker && isBoardNoFieldLocation(marker.location) ? marker.location : null;
+ const markerHere = marker?.controller === player.id && markerLocation && nearby.some(territory =>
+ territory.id === markerLocation.territory && territory.sectors.includes(markerLocation.sector));
const forces = presenceByLocation({ forces: player.forces,
- noField: markerHere ? { deployed: { location: marker!.location } } : null });
+ noField: markerHere ? { deployed: { location: markerLocation! } } : null });
const effective = { faction: player.faction, advisors: player.advisors, forces };
if (!state.advanced)
return nearby.some(territory => fighterCount(effective, territory.id) > 0)
diff --git a/game/force-inventory.ts b/game/force-inventory.ts
index b5b13903..c0b41d01 100644
--- a/game/force-inventory.ts
+++ b/game/force-inventory.ts
@@ -3,6 +3,7 @@ import type { FactionId } from './catalog';
import { specialForceName } from './combat';
import { isAdvisor } from './advisors';
import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { isBoardNoFieldLocation, type NoFieldLocation } from './richese-no-field';
/** Public physical custody only. No private cards, commitments or token faces. */
export type PublicForcePlayer = {
@@ -14,7 +15,7 @@ export type PublicForcePlayer = {
forces: Readonly>;
elites?: { reserves: number; tanks: number; forces: Readonly> };
advisors?: Record;
- noField?: { deployed: { location: { territory: string; sector: number } } | null } | null;
+ noField?: { deployed: { location: NoFieldLocation } | null } | null;
};
export type PublicForceWorld = {
id: string;
@@ -43,6 +44,14 @@ function locationName(id: string, sector: number) {
return sector > 0 ? `${name} · sector ${sector}` : name;
}
+function markerLocationName(marker: NoFieldLocation) {
+ if (isBoardNoFieldLocation(marker)) return locationName(marker.territory, marker.sector);
+ const card = HOMEWORLD_CARDS.find(candidate => marker.homeworld ===
+ `homeworld:${candidate.faction}${candidate.id === 'salusa_secundus' ? ':salusa' : ''}`);
+ if (!card) throw new Error('Unknown No-Field Homeworld.');
+ return card.name;
+}
+
function pool(id: string, name: string, total: number, elite = 0, advisors = false): ForcePool {
if (![total, elite].every(n => Number.isSafeInteger(n) && n >= 0) || elite > total)
throw new Error('Invalid public force count.');
@@ -86,7 +95,7 @@ export function forceInventory(player: PublicForcePlayer, worlds: readonly Publi
elite: rows.reduce((n, r) => n + r.elite, 0),
advisors: rows.reduce((n, r) => n + (r.advisors ? r.total : 0), 0),
split: !!player.elites,
- marker: marker ? { name: locationName(marker.territory, marker.sector) } : null,
+ marker: marker ? { name: markerLocationName(marker) } : null,
};
} catch {
return null;
diff --git a/game/force-presence.ts b/game/force-presence.ts
index 58b2cd12..fb4bd946 100644
--- a/game/force-presence.ts
+++ b/game/force-presence.ts
@@ -1,11 +1,12 @@
import { location, splitLocation } from './board';
+import { isBoardNoFieldLocation, type NoFieldLocation } from './richese-no-field';
/** Structural public input: no token identity, value, inventory or reserves. */
export type ForcePresence = {
forces: Readonly>;
noField?: {
deployed: {
- location: { territory: string; sector: number };
+ location: NoFieldLocation;
} | null;
} | null;
};
@@ -17,10 +18,8 @@ export function presenceAt(player: ForcePresence, territory: string): number {
total + (splitLocation(key).territory === territory ? count : 0),
0,
);
- return (
- actual +
- (player.noField?.deployed?.location.territory === territory ? 1 : 0)
- );
+ const marker = player.noField?.deployed?.location;
+ return actual + (marker && isBoardNoFieldLocation(marker) && marker.territory === territory ? 1 : 0);
}
/** A fresh location map, with the single hidden marker represented as one. */
@@ -29,7 +28,7 @@ export function presenceByLocation(
): Record {
const counts = { ...player.forces };
const marker = player.noField?.deployed?.location;
- if (marker) {
+ if (marker && isBoardNoFieldLocation(marker)) {
const key = location(marker.territory, marker.sector);
counts[key] = (counts[key] ?? 0) + 1;
}
diff --git a/game/fremen-ambassador.ts b/game/fremen-ambassador.ts
index 720a7ff5..8f08491a 100644
--- a/game/fremen-ambassador.ts
+++ b/game/fremen-ambassador.ts
@@ -13,6 +13,7 @@ import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { homeworldNoFieldMovementBlock } from './homeworld-mobility';
import { sandmasterDestinationCollection } from './sandmaster-movement';
+import { isBoardNoFieldLocation } from './richese-no-field';
export class FremenAmbassadorMoveError extends Error {
constructor(message: string) {
@@ -122,7 +123,7 @@ function playerFor(g: Game, id: string) {
);
const marker = p.noField?.deployed;
requireMove(
- !marker ||
+ !marker || !isBoardNoFieldLocation(marker.location) ||
usableLocation(
g,
location(marker.location.territory, marker.location.sector),
@@ -143,7 +144,7 @@ function playerFor(g: Game, id: string) {
}
function ownMarker(p: Player) {
const marker = p.noField?.deployed;
- if (!marker) return null;
+ if (!marker || !isBoardNoFieldLocation(marker.location)) return null;
requireMove(
p.faction === 'richese' &&
marker.controller === p.id &&
diff --git a/game/guild-ambassador-no-field.ts b/game/guild-ambassador-no-field.ts
index 612a4012..a216d88b 100644
--- a/game/guild-ambassador-no-field.ts
+++ b/game/guild-ambassador-no-field.ts
@@ -14,6 +14,8 @@ import { presenceAt, type ForcePresence } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import {
deployRicheseNoField,
+ isBoardNoFieldLocation,
+ isHomeworldNoFieldLocation,
revealRicheseNoField,
shipAlliedRicheseNoField,
validateRicheseNoField,
@@ -148,10 +150,10 @@ function context(g: Game, beneficiary: string): MarkerContext {
'Only Richese may hold a No-Field inventory.');
requireMarker(deployed === undefined || deployed === null ||
(record(deployed) && record(deployed.location) &&
- text(deployed.location.territory) && count(deployed.location.sector) &&
- usableLocation(g, location(deployed.location.territory,
- deployed.location.sector)) &&
- count((totals[deployed.location.territory] ?? 0) + 1)),
+ (isHomeworldNoFieldLocation(deployed.location) ||
+ isBoardNoFieldLocation(deployed.location) && text(deployed.location.territory) &&
+ count(deployed.location.sector) && usableLocation(g, location(deployed.location.territory,
+ deployed.location.sector)) && count((totals[deployed.location.territory] ?? 0) + 1))),
'The concealed marker has an invalid public location.');
requireMarker(!p.advisors || record(p.advisors),
'The advisor inventory is invalid.');
@@ -198,12 +200,15 @@ function forecastPriorReveal(c: MarkerContext) {
}
const previous = c.owner.noField!;
if (!previous.deployed) return previous;
+ requireMarker(isBoardNoFieldLocation(previous.deployed.location),
+ 'Reveal the existing Homeworld No-Field before using this board shipment.');
const revealed = noFieldRule(() => revealRicheseNoField(previous, {
tokenId: previous.deployed!.tokenId,
reserves: c.owner.reserves,
cause: 'beforeAlly',
}));
const owner = c.seats.find((seat) => seat.id === c.owner.id)!;
+ requireMarker(isBoardNoFieldLocation(revealed.location), 'The original marker must be on Arrakis.');
const key = location(revealed.location.territory, revealed.location.sector);
const total = (owner.forces[key] ?? 0) + revealed.forces;
requireMarker(count(total), 'The prior reveal produces an invalid force quantity.');
diff --git a/game/guild-ambassador.ts b/game/guild-ambassador.ts
index 56eb88f1..192dcd0d 100644
--- a/game/guild-ambassador.ts
+++ b/game/guild-ambassador.ts
@@ -13,6 +13,7 @@ import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
import type { GuildAmbassadorMarker } from './guild-ambassador-no-field';
+import { isBoardNoFieldLocation } from './richese-no-field';
export class GuildAmbassadorShipmentError extends Error {
constructor(message: string) {
@@ -112,7 +113,7 @@ function beneficiary(g: Game, id: string) {
);
const marker = p.noField?.deployed;
requireShipment(
- !marker ||
+ !marker || !isBoardNoFieldLocation(marker.location) ||
(usableLocation(
g,
location(marker.location.territory, marker.location.sector),
diff --git a/game/homeworld-alliance.ts b/game/homeworld-alliance.ts
index 638411d7..94fa0998 100644
--- a/game/homeworld-alliance.ts
+++ b/game/homeworld-alliance.ts
@@ -1,4 +1,7 @@
-import { homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext } from './homeworld-custody';
+import { HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext } from './homeworld-custody';
+
+/** Public marker presence only; token identity and denomination are private. */
+export type HomeworldAllianceMarker = { homeworld: string; controller: string };
/** E3 alliance restriction uses present foreign forces, independently of any
* saved turn entitlement to an occupied card's benefits. */
@@ -7,8 +10,19 @@ export function homeworldAllianceBlock(
custody: HomeworldCustody,
first: string,
second: string,
+ marker?: HomeworldAllianceMarker | null,
): string | null {
- const occupied = homeworldForceGroups(context, custody).some((world) => {
+ const worlds = homeworldForceGroups(context, custody);
+ if (marker) {
+ const world = worlds.find(row => row.id === marker.homeworld);
+ const controller = context.players.find(row => row.id === marker.controller);
+ if (!world || !controller || controller.faction !== 'richese' || world.native === controller.id)
+ throw new HomeworldCustodyError('Homeworld alliance marker needs its seated Richese controller and an active foreign world.');
+ if ((world.native === first && controller.id === second) ||
+ (world.native === second && controller.id === first))
+ return 'An alliance cannot form while either faction has forces on the other’s Homeworld.';
+ }
+ const occupied = worlds.some((world) => {
const visitor = world.native === first ? second : world.native === second ? first : null;
const forces = visitor ? world.forces[visitor] : undefined;
return !!forces && forces.normal + forces.elite > 0;
diff --git a/game/homeworld-ecaz-duke.ts b/game/homeworld-ecaz-duke.ts
index 28ec5521..66960f1c 100644
--- a/game/homeworld-ecaz-duke.ts
+++ b/game/homeworld-ecaz-duke.ts
@@ -1,6 +1,8 @@
import type { Game } from './engine';
import { acquireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, type DukeState } from './duke-vidal';
import { HomeworldCustodyError } from './homeworld-custody';
+import { homeworldContext } from './homeworld-game';
+import { readHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence } from './homeworld-occupation-history';
import {
quoteStableHomeworldOccupation, type StableHomeworldOccupationContext,
} from './homeworld-stable-occupation';
@@ -67,10 +69,8 @@ export function validateHomeworldEcazDukeGrants(g: HomeworldEcazDukeContext): vo
const source = g.homeworldOccupationHistory?.sources.find(row => row.event === receipt.qualification);
requireGrant(native && source && source.cause !== 'setup' && source.turn <= receipt.turn,
'Occupied Ecaz Duke lost its original qualification source.');
- const snapshot: unknown = JSON.parse(g.homeworldOccupationHistory!.snapshots[source.snapshot]);
- requireGrant(Array.isArray(snapshot) && snapshot.some(row => Array.isArray(row) &&
- row[0] === `homeworld:${native.id}` && Array.isArray(row[1]) && row[1].some(group =>
- Array.isArray(group) && group[0] === receipt.occupier && group[1] + group[2] > 0)),
+ const snapshot = readHomeworldOccupationSnapshot(g.homeworldOccupationHistory!.snapshots[source.snapshot], homeworldContext(g));
+ requireGrant(homeworldOccupationSnapshotPresence(snapshot, `homeworld:${native.faction}`).includes(receipt.occupier),
'Occupied Ecaz Duke qualification does not contain its original occupier.');
}
}
diff --git a/game/homeworld-game.ts b/game/homeworld-game.ts
index 5bd46d82..eeb39c7d 100644
--- a/game/homeworld-game.ts
+++ b/game/homeworld-game.ts
@@ -4,6 +4,7 @@ import {
HomeworldCustodyError,
} from './homeworld-custody';
import { homeworldAllianceBlock } from './homeworld-alliance';
+import { homeworldOccupationMarker } from './homeworld-occupation-history';
import { homeworldPopulations } from './homeworld-population';
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
import { charityQuote } from './charity';
@@ -95,7 +96,7 @@ export function homeworldGameIntegrity(g: Game): void {
const groups = homeworldForceGroups(homeworldContext(g), state.custody);
for (const p of g.players) {
if (p.ally && g.players.some((other) => other.id === p.ally && other.ally === p.id)) {
- const blocked = homeworldAllianceBlock(homeworldContext(g), state.custody, p.id, p.ally);
+ const blocked = homeworldAllianceBlock(homeworldContext(g), state.custody, p.id, p.ally, homeworldOccupationMarker(g.players));
if (blocked) throw new HomeworldCustodyError(blocked);
}
let away = 0,
diff --git a/game/homeworld-occupation-history.ts b/game/homeworld-occupation-history.ts
index bb208459..cb3c3ac1 100644
--- a/game/homeworld-occupation-history.ts
+++ b/game/homeworld-occupation-history.ts
@@ -6,6 +6,7 @@ import {
type HomeworldCustody,
type HomeworldCustodyContext,
} from './homeworld-custody';
+import { isHomeworldNoFieldLocation, type NoFieldLocation } from './richese-no-field';
type Cause = 'change' | 'turnStart' | 'turnEnd';
type Source = {
@@ -34,7 +35,12 @@ export type HomeworldOccupationHistory = {
qualifications: HomeworldOccupationQualification[];
signature: string;
};
-type Snapshot = [string, [string, number, number][]][];
+export type HomeworldOccupationGroups = [string, [string, number, number][]][];
+export type HomeworldOccupationMarker = { homeworld: string; controller: string };
+export type HomeworldOccupationSnapshot = {
+ groups: HomeworldOccupationGroups;
+ marker: HomeworldOccupationMarker | null;
+};
const validText = (s: unknown): s is string =>
typeof s === 'string' && s.trim().length > 0;
const whole = (n: unknown): n is number =>
@@ -64,8 +70,9 @@ function seats(context: HomeworldCustodyContext): [string, FactionId][] {
function sourceSnapshot(
context: HomeworldCustodyContext,
custody: HomeworldCustody,
+ marker?: HomeworldOccupationMarker | null,
): string {
- const snapshot: Snapshot = homeworldForceGroups(context, custody).map(
+ const snapshot: HomeworldOccupationGroups = homeworldForceGroups(context, custody).map(
(home) => [
home.id,
Object.entries(home.forces)
@@ -82,12 +89,37 @@ function sourceSnapshot(
],
);
snapshot.sort((a, b) => a[0].localeCompare(b[0]));
- return JSON.stringify(snapshot);
+ if (!marker) return JSON.stringify(snapshot);
+ validateMarker(context, marker);
+ return JSON.stringify(['homeworldOccupationMarker', snapshot, marker.homeworld, marker.controller]);
}
-function readSnapshot(
+function validateMarker(context: HomeworldCustodyContext, marker: HomeworldOccupationMarker): void {
+ requireHistory(keys(marker, ['homeworld', 'controller']) && validText(marker.homeworld) && validText(marker.controller),
+ 'a public No-Field marker needs its original world and controller.');
+ const home = homeworldLocations(context).find(row => row.id === marker.homeworld);
+ const player = context.players.find(row => row.id === marker.controller);
+ requireHistory(home && player && player.faction === 'richese' && home.native !== player.id,
+ 'a No-Field marker needs a seated Richese controller and an active foreign world.');
+}
+
+/** Extract only the public off-planet marker location. No token ID or hidden
+ * denomination becomes part of occupation history. */
+export function homeworldOccupationMarker(players: readonly {
+ id: string; faction: FactionId;
+ noField?: { deployed: { controller: string; location: NoFieldLocation } | null } | null;
+}[]): HomeworldOccupationMarker | null {
+ const owner = players.find(player => player.faction === 'richese');
+ if (!owner) return null;
+ const marker = owner.noField?.deployed;
+ if (!marker || !isHomeworldNoFieldLocation(marker.location)) return null;
+ requireHistory(marker.controller === owner.id, 'the Homeworld marker lost its native controller.');
+ return { homeworld: marker.location.homeworld, controller: marker.controller };
+}
+
+export function readHomeworldOccupationSnapshot(
value: string,
context: HomeworldCustodyContext,
-): Snapshot {
+): HomeworldOccupationSnapshot {
requireHistory(typeof value === 'string', 'a source snapshot is missing.');
let raw: unknown;
try {
@@ -95,6 +127,14 @@ function readSnapshot(
} catch {
requireHistory(false, 'a source snapshot is malformed.');
}
+ let marker: HomeworldOccupationMarker | null = null;
+ if (Array.isArray(raw) && raw[0] === 'homeworldOccupationMarker') {
+ requireHistory(raw.length === 4 && validText(raw[2]) && validText(raw[3]),
+ 'a public No-Field source snapshot is malformed.');
+ marker = { homeworld: raw[2], controller: raw[3] };
+ validateMarker(context, marker);
+ raw = raw[1];
+ }
const homes = homeworldLocations(context);
requireHistory(
Array.isArray(raw) && raw.length === homes.length,
@@ -158,10 +198,28 @@ function readSnapshot(
}),
};
requireHistory(
- sourceSnapshot(historical, custody) === value,
+ sourceSnapshot(historical, custody, marker) === value,
'source armies are not canonical or physically valid.',
);
- return raw as Snapshot;
+ return { groups: raw as HomeworldOccupationGroups, marker };
+}
+
+/** Public occupiers are the union of positive physical groups and the single
+ * concealed marker. The marker never enters or changes a typed physical pool. */
+export function homeworldOccupationSnapshotPresence(snapshot: HomeworldOccupationSnapshot, world: string): string[] {
+ const row = snapshot.groups.find(([id]) => id === world);
+ requireHistory(row, 'a requested source world is missing.');
+ const present = row[1].filter(([, normal, elite]) => normal + elite > 0).map(([id]) => id);
+ if (snapshot.marker?.homeworld === world && !present.includes(snapshot.marker.controller)) {
+ present.push(snapshot.marker.controller);
+ present.sort((a, b) => a.localeCompare(b));
+ }
+ return present;
+}
+
+export function currentHomeworldOccupationSnapshot(context: HomeworldCustodyContext,
+ custody: HomeworldCustody, marker?: HomeworldOccupationMarker | null): HomeworldOccupationSnapshot {
+ return readHomeworldOccupationSnapshot(sourceSnapshot(context, custody, marker), context);
}
function sourceSignature(
source: Omit,
@@ -189,22 +247,22 @@ function historySignature(history: HomeworldOccupationHistory): string {
}
function facts(
source: Source,
- snapshot: Snapshot,
+ snapshot: HomeworldOccupationSnapshot,
context: HomeworldCustodyContext,
): HomeworldOccupationQualification[] {
if (source.cause === 'setup') return [];
const homes = homeworldLocations(context);
const result: HomeworldOccupationQualification[] = [];
- for (const [world, groups] of snapshot) {
+ for (const [world] of snapshot.groups) {
const native = homes.find((home) => home.id === world)!.native;
- const present = groups.filter(([, normal, elite]) => normal + elite > 0);
+ const present = homeworldOccupationSnapshotPresence(snapshot, world);
const foreign =
source.cause === 'change'
- ? present.length === 1 && present[0][0] !== native
+ ? present.length === 1 && present[0] !== native
? present
: []
- : present.filter(([player]) => player !== native);
- for (const [player] of foreign)
+ : present.filter((player) => player !== native);
+ for (const player of foreign)
result.push({
event: source.event,
world,
@@ -258,7 +316,7 @@ export function validateHomeworldOccupationHistory(
'source snapshots must be interned.',
);
const snapshots = history.snapshots.map((value) =>
- readSnapshot(value, context),
+ readHomeworldOccupationSnapshot(value, context),
);
const events = new Set(),
used = new Set(),
@@ -292,7 +350,7 @@ export function validateHomeworldOccupationHistory(
if (index === 0) {
const homes = homeworldLocations(context);
requireHistory(
- snapshots[source.snapshot].every(([world, groups]) =>
+ !snapshots[source.snapshot].marker && snapshots[source.snapshot].groups.every(([world, groups]) =>
groups.every(
([player]) =>
player === homes.find((home) => home.id === world)!.native,
@@ -368,6 +426,7 @@ export function observeHomeworldOccupation(
turn: number,
cause: Cause,
event: string,
+ marker?: HomeworldOccupationMarker | null,
): HomeworldOccupationHistory {
validateHomeworldOccupationHistory(history, context, turn);
requireHistory(
@@ -377,7 +436,7 @@ export function observeHomeworldOccupation(
['change', 'turnStart', 'turnEnd'].includes(cause),
'invalid observation event.',
);
- const snapshot = sourceSnapshot(context, custody);
+ const snapshot = sourceSnapshot(context, custody, marker);
const prior = history.sources.find((source) => source.event === event);
if (prior) {
requireHistory(
@@ -409,7 +468,7 @@ export function observeHomeworldOccupation(
source.signature = sourceSignature(source, snapshot);
result.sources.push(source);
const existing = new Set(result.qualifications.map(factKey));
- for (const fact of facts(source, readSnapshot(snapshot, context), context)) {
+ for (const fact of facts(source, readHomeworldOccupationSnapshot(snapshot, context), context)) {
if (!existing.has(factKey(fact))) {
result.qualifications.push(fact);
existing.add(factKey(fact));
diff --git a/game/homeworld-occupied-tupile.ts b/game/homeworld-occupied-tupile.ts
index 48854886..74b7f5ed 100644
--- a/game/homeworld-occupied-tupile.ts
+++ b/game/homeworld-occupied-tupile.ts
@@ -1,7 +1,8 @@
import { nativeHandLimit } from './engine';
import { HomeworldCustodyError } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
-import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
+import { validateHomeworldOccupationHistory, readHomeworldOccupationSnapshot,
+ homeworldOccupationSnapshotPresence } from './homeworld-occupation-history';
import { basicTupileActivations } from './basic-tupile-occupation';
import {
quoteStableHomeworldOccupation,
@@ -169,13 +170,11 @@ function originalEpochs(game: OccupiedTupileContext): Map world === 'homeworld:choam');
- requireTupile(row, 'the original Tupile world is missing from history.');
- const present = row[1].filter(([, normal, elite]) => normal + elite > 0);
- if (retained && !present.some(([id]) => id === retained)) retained = null;
- const candidate = observation.cause !== 'setup' && present.length === 1 && present[0][0] !== native.id
- ? present[0][0] : null;
+ const snapshot = readHomeworldOccupationSnapshot(history.snapshots[observation.snapshot], homeworldContext(game));
+ const present = homeworldOccupationSnapshotPresence(snapshot, 'homeworld:choam');
+ if (retained && !present.includes(retained)) retained = null;
+ const candidate = observation.cause !== 'setup' && present.length === 1 && present[0] !== native.id
+ ? present[0] : null;
if (!retained && candidate !== null) {
const occupier = candidate;
epochs.set(observation.event, sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id,
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
index 9ee46c1b..e87739d2 100644
--- a/game/homeworld-shipment-options.ts
+++ b/game/homeworld-shipment-options.ts
@@ -11,6 +11,7 @@ import {
type HomeworldForces,
} from './homeworld-custody';
import { quoteHomeworldShipment } from './homeworld-shipment';
+import { quoteRicheseHomeworldMarkerShipment } from './richese-homeworld-marker-shipment';
export type HomeworldShipmentSources = Record;
export function homeworldName(view: GameView, id: string): string {
@@ -20,6 +21,72 @@ export function homeworldName(view: GameView, id: string): string {
);
}
+/** Owner-only marker quote. The face stays private; the amount and invoice are
+ * one regardless of denomination. Authoritative timing and payment remain in
+ * the normal Homeworld shipment action. */
+export function richeseHomeworldMarkerChoice(
+ view: GameView,
+ destination: string,
+ tokenId: string,
+ allyPayment = 0,
+) {
+ const result: { blocked: string | null; amount: number; cost: number;
+ ownPayment: number; allyPayment: number; action: Action | null } = {
+ blocked: null, amount: 1, cost: 1, ownPayment: 1 - allyPayment, allyPayment, action: null,
+ };
+ const option = view.homeworldShipment, worlds = view.homeworlds?.worlds;
+ const me = view.players.find(player => player.id === view.me);
+ const info = view.richeseNoField;
+ if (view.nexusCards || view.leaderSkills || view.discoveryEnabled || view.discoveries ||
+ view.techTokens || view.strongholdCards) {
+ result.blocked = 'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.';
+ return result;
+ }
+ if (!option || !worlds?.length || !me || me.faction !== 'richese' ||
+ info?.owner !== me.id || !info.private || !info.event ||
+ view.roomControl?.paused || view.roomControl?.closed || !nexusGuildShipmentAvailable(view)) {
+ result.blocked = 'Use your available Richese No-Field during your shipment turn.';
+ return result;
+ }
+ if (option.blocked || !info.canShip) {
+ result.blocked = option.blocked ?? info.shipBlock ?? 'No-Field shipment is not available now.';
+ return result;
+ }
+ if (!Number.isSafeInteger(allyPayment) || allyPayment < 0 || allyPayment > 1 ||
+ allyPayment > (me.ally ? view.aid.available : 0)) {
+ result.blocked = 'Choose an allied contribution within the current pledge and one-spice price.';
+ return result;
+ }
+ if (result.ownPayment > (me.spice ?? 0)) {
+ result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
+ return result;
+ }
+ const context = {
+ advanced: view.advanced,
+ nativeGuildRateCanceled: view.guildRateCanceled,
+ purchasedGuildRate: view.karamaShipping?.player === view.me,
+ players: view.players.map(player => ({ id: player.id, faction: player.faction,
+ reserves: player.reserves, eliteReserves: player.elites?.reserves ?? 0, ally: player.ally ?? null })),
+ };
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ for (const world of worlds) {
+ if (world.secondary) custody.salusa = { ...world.forces[world.native] };
+ const visitors = Object.entries(world.forces).filter(([owner]) => owner !== world.native);
+ if (visitors.length) custody.visitors[world.id] = Object.fromEntries(
+ visitors.map(([owner, forces]) => [owner, { ...forces }]));
+ }
+ try {
+ quoteRicheseHomeworldMarkerShipment(context, custody,
+ { player: me.id, destination, tokenId }, info.private);
+ result.action = { type: 'homeworldShip', event: option.event, destination, sources: {},
+ noField: tokenId, noFieldEvent: info.event, allyPayment };
+ } catch (error) {
+ if (!(error instanceof HomeworldCustodyError)) throw error;
+ result.blocked = error.message;
+ }
+ return result;
+}
+
/** Only public physical counters and the actor's own available funding enter
* the quote. Foreign garrisons are never relabelled as native reserves. */
export function homeworldShipmentChoice(
@@ -317,5 +384,16 @@ export function homeworldShipmentActions(
actions.push(quote.action);
}
}
+ // Minimal legal participation: offer one private marker route through the
+ // same quote used by the human control. Faction strategy remains deferred.
+ if (me.faction === 'richese' && view.richeseNoField?.owner === me.id && view.richeseNoField.private) {
+ const token = view.richeseNoField.private.tokens.find(candidate =>
+ candidate.id !== view.richeseNoField!.private!.lastShipped);
+ if (token) for (const target of targets) {
+ const quote = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
+ Math.max(0, 1 - (me.spice ?? 0)));
+ if (quote.action) { actions.push(quote.action); break; }
+ }
+ }
return actions;
}
diff --git a/game/homeworld-stable-occupation.ts b/game/homeworld-stable-occupation.ts
index b7d62530..e3ce4960 100644
--- a/game/homeworld-stable-occupation.ts
+++ b/game/homeworld-stable-occupation.ts
@@ -2,7 +2,8 @@ import type { Game } from './engine';
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
import { homeworldContext } from './homeworld-game';
import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody';
-import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
+import { currentHomeworldOccupationSnapshot, homeworldOccupationMarker, homeworldOccupationSnapshotPresence,
+ readHomeworldOccupationSnapshot, validateHomeworldOccupationHistory } from './homeworld-occupation-history';
export type StableHomeworldOccupationContext = Pick;
@@ -20,12 +21,24 @@ export type StableHomeworldOccupationQuote = {
entitlement: StableHomeworldOccupier | null;
blocked: string | null;
};
-type SourceWorld = [string, [string, number, number][]];
type HomeworldCard = (typeof HOMEWORLD_CARDS)[number];
type HomeworldWorld = ReturnType[number];
type History = NonNullable;
type Player = Game['players'][number];
+/** Public marker presence is one, separate from the actual typed counters and
+ * native population. The hidden token identity and value never enter history. */
+export function liveHomeworldOccupationMarker(game: StableHomeworldOccupationContext) {
+ return homeworldOccupationMarker(game.players);
+}
+
+export function liveHomeworldOccupationPresence(game: StableHomeworldOccupationContext, world: string): string[] {
+ const custody = game.homeworlds?.custody;
+ if (!custody) throw new HomeworldCustodyError('Homeworld occupation needs current physical custody.');
+ return homeworldOccupationSnapshotPresence(currentHomeworldOccupationSnapshot(
+ homeworldContext(game), custody, liveHomeworldOccupationMarker(game)), world);
+}
+
export type StableHomeworldOccupationOptions = {
/** Basic-only policy selector. `currentSole` is the provisional default used
* by occupied income, Bidding, defenses and percentage effects. `epoch` keeps
@@ -63,19 +76,18 @@ export function quoteStableHomeworldOccupation(
if (game.advanced) {
let retained: { player: string; event: string } | null = null;
for (const source of history.sources) {
- const snapshot = JSON.parse(history.snapshots[source.snapshot]) as SourceWorld[];
- const groups = snapshot.find(row => row[0] === world.id)![1]
- .filter(([, normal, elite]) => normal + elite > 0);
- if (retained && !groups.some(([id]) => id === retained!.player)) retained = null;
- if (!retained && source.cause !== 'setup' && groups.length === 1 && groups[0][0] !== native.id)
- retained = { player: groups[0][0], event: source.event };
+ const snapshot = readHomeworldOccupationSnapshot(history.snapshots[source.snapshot], context);
+ const present = homeworldOccupationSnapshotPresence(snapshot, world.id);
+ if (retained && !present.includes(retained.player)) retained = null;
+ if (!retained && source.cause !== 'setup' && present.length === 1 && present[0] !== native.id)
+ retained = { player: present[0], event: source.event };
}
- const present = Object.entries(world.forces).filter(([, forces]) => forces.normal + forces.elite > 0);
- if (retained && !present.some(([id]) => id === retained!.player)) retained = null;
+ const present = liveHomeworldOccupationPresence(game, world.id);
+ if (retained && !present.includes(retained.player)) retained = null;
if (!retained) {
// A producer may query between a physical write and its semantic
// observation; do not invent a new qualifying event from that write.
- return present.length === 1 && present[0][0] !== native.id
+ return present.length === 1 && present[0] !== native.id
? { entitlement: null, blocked: 'The original Advanced sole-occupation change must be observed before its benefit settles.' }
: none;
}
@@ -103,14 +115,14 @@ function basicCurrentSoleOccupation(
): StableHomeworldOccupationQuote {
if (!history.qualifications.some(f => f.world === world.id)) return { entitlement: null, blocked: null };
let nativeHigh = false;
- const foreign: string[] = [];
for (const [id, forces] of Object.entries(world.forces)) {
const amount = forces.normal + forces.elite;
if (id === native.id) {
const population = card.reserveType === 'sardaukar' ? forces.elite : amount;
if (population >= card.high.reserves.min) nativeHigh = true;
- } else if (amount > 0) foreign.push(id);
+ }
}
+ const foreign = liveHomeworldOccupationPresence(game, world.id).filter(id => id !== native.id);
if (nativeHigh || foreign.length !== 1) return { entitlement: null, blocked: null };
const qualified = history.qualifications.filter(f => f.world === world.id && f.player === foreign[0]).at(-1);
if (!qualified)
@@ -149,35 +161,33 @@ function basicEpochOccupation(
if (!qualifiedThisTurn) return blocked('entitlement/expiry');
const start = history.sources.findIndex(source => source.event === first.event);
if (start < 0) throw new HomeworldCustodyError('The occupier lost its original qualifying source.');
- const check = (groups: readonly [string, number, number][]): string | null => {
- let present = false;
+ const check = (groups: readonly [string, number, number][], presence: readonly string[]): string | null => {
for (const [id, normal, elite] of groups) {
const amount = normal + elite;
if (id === native.id) {
const population = card.reserveType === 'sardaukar' ? elite : amount;
if (population >= card.high.reserves.min) return 'native-repopulation';
- } else if (amount > 0) {
- if (id !== first.player) return 'contested-occupation';
- present = true;
}
}
- return present ? null : 'departure/expiry';
+ const foreign = presence.filter(id => id !== native.id);
+ if (foreign.some(id => id !== first.player)) return 'contested-occupation';
+ return foreign.includes(first.player) ? null : 'departure/expiry';
};
let previous = -1;
for (let i = start; i < history.sources.length; i++) {
const index = history.sources[i].snapshot;
if (index === previous) continue;
previous = index;
- const snapshot = JSON.parse(history.snapshots[index]) as SourceWorld[];
- const row = snapshot.find(source => source[0] === world.id)!;
- const reason = check(row[1]);
+ const snapshot = readHomeworldOccupationSnapshot(history.snapshots[index], homeworldContext(game));
+ const row = snapshot.groups.find(source => source[0] === world.id)!;
+ const reason = check(row[1], homeworldOccupationSnapshotPresence(snapshot, world.id));
if (reason) return blocked(reason);
}
// Actions can query an effect after a physical change but before its semantic
// observation. Never let the last recorded snapshot substitute for now.
const current = Object.entries(world.forces).map(([id, forces]): [string, number, number] =>
[id, forces.normal, forces.elite]);
- const reason = check(current);
+ const reason = check(current, liveHomeworldOccupationPresence(game, world.id));
if (reason) return blocked(reason);
const occupier = game.players.find(p => p.id === first.player)!;
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
diff --git a/game/mobile-stronghold-collection.ts b/game/mobile-stronghold-collection.ts
index d2cbd6bf..2e6592f2 100644
--- a/game/mobile-stronghold-collection.ts
+++ b/game/mobile-stronghold-collection.ts
@@ -1,6 +1,7 @@
import { isAdvisor } from './advisors';
import { MOBILE_STRONGHOLD, splitLocation } from './board';
import type { Player } from './engine';
+import { isBoardNoFieldLocation, type NoFieldLocation } from './richese-no-field';
/** Only public board presence is needed; concealed token values are never read. */
export type MobileCollectionPlayer = Pick & {
@@ -8,7 +9,7 @@ export type MobileCollectionPlayer = Pick
noField?: {
deployed: {
controller: string;
- location: { territory: string; sector: number };
+ location: NoFieldLocation;
} | null;
} | null;
};
@@ -31,7 +32,7 @@ export function mobileStrongholdCollectionCapacity(
);
}
const marker = player.noField?.deployed;
- if (marker?.location.territory === MOBILE_STRONGHOLD) {
+ if (marker && isBoardNoFieldLocation(marker.location) && marker.location.territory === MOBILE_STRONGHOLD) {
const controller = players.find((seat) => seat.id === marker.controller);
if (!controller || !isAdvisor(controller, MOBILE_STRONGHOLD)) fighters++;
}
diff --git a/game/moritani-atomics.ts b/game/moritani-atomics.ts
index 7881dfb8..2d3d59fa 100644
--- a/game/moritani-atomics.ts
+++ b/game/moritani-atomics.ts
@@ -2,6 +2,7 @@ import { TERRITORIES, splitLocation, validLocation } from './board';
import type { Player } from './engine';
import type { TerrorToken } from './moritani-terror';
import { TERROR_STRONGHOLDS } from './moritani-terror';
+import { isBoardNoFieldLocation } from './richese-no-field';
/** The sole Aftermath piece and the ally affected at activation belong in saved Game state. */
export type AtomicsAftermath = Readonly<{
@@ -78,7 +79,8 @@ export function quoteMoritaniAtomics(input: {
let total = 0, eliteTotal = 0;
// The engine materializes any caught No-Field in a detached forecast before
// quoting this physical effect. The pure calculator never invents counters.
- requireAtomics(p.noField?.deployed?.location.territory !== token.location,
+ const marker = p.noField?.deployed?.location;
+ requireAtomics(!marker || !isBoardNoFieldLocation(marker) || marker.territory !== token.location,
'Atomics with a concealed No-Field awaits its reveal and casualty rules.');
for (const key of new Set([...Object.keys(p.forces),
...Object.keys(p.elites?.forces ?? {})])) {
diff --git a/game/movement-promise-search.ts b/game/movement-promise-search.ts
index 3d4b9f11..05b10815 100644
--- a/game/movement-promise-search.ts
+++ b/game/movement-promise-search.ts
@@ -1,5 +1,6 @@
import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
+import { isBoardNoFieldLocation } from './richese-no-field';
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionSectors,
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
@@ -61,7 +62,9 @@ export function findMovementCompletion(source: Game, actor: string,
// or Box reuse. Tanks are only an optimistic bound; the adapter still
// validates every owned card and never inspects unknown Box faces here.
const recovery = player.hand.some(card => card.effect === 'ghola' || card.effect === 'nullentropyBox') ? player.tanks : 0;
- const marker = player.faction === 'richese' ? player.noField?.deployed : null;
+ const deployed = player.faction === 'richese' ? player.noField?.deployed : null;
+ const marker = deployed && isBoardNoFieldLocation(deployed.location)
+ ? { ...deployed, location: deployed.location } : null;
const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0;
const revealable = Math.min(markerValue, player.reserves + recovery);
const possiblePhysical = physical + (player.shipped ? revealable : player.reserves + recovery);
diff --git a/game/nexus-advisors.ts b/game/nexus-advisors.ts
index 7596f78c..b1c207b7 100644
--- a/game/nexus-advisors.ts
+++ b/game/nexus-advisors.ts
@@ -11,6 +11,7 @@ import {
type BoardSeat,
} from './board-resolution-quote';
import { presenceAt } from './force-presence';
+import { isBoardNoFieldLocation } from './richese-no-field';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyIdentity } from './ecaz-occupy';
@@ -109,7 +110,7 @@ function selection(player: BoardSeat, to: string): NexusAdvisorSelection {
.map(([key, count]) => ({ location: key, count }));
const marker = player.noField?.deployed?.location;
const noField =
- marker?.territory === to ? location(marker.territory, marker.sector) : null;
+ marker && isBoardNoFieldLocation(marker) && marker.territory === to ? location(marker.territory, marker.sector) : null;
const count =
locations.reduce((sum, group) => sum + group.count, 0) + (noField ? 1 : 0);
return {
diff --git a/game/nexus-ecaz-betrayal.ts b/game/nexus-ecaz-betrayal.ts
index c4927f7e..876f4060 100644
--- a/game/nexus-ecaz-betrayal.ts
+++ b/game/nexus-ecaz-betrayal.ts
@@ -4,7 +4,7 @@ import { nexusCoreProfile } from './faction-module-profile';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
import { nexusCardMode, validateNexusCards } from './nexus-cards';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
-import { validateRicheseNoField } from './richese-no-field';
+import { isBoardNoFieldLocation, validateRicheseNoField } from './richese-no-field';
export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
export type EcazBetrayalTerritory = Readonly<{
@@ -117,7 +117,7 @@ function locations(g: Game, player: Player): Map p.noField?.deployed?.controller === ally &&
- p.noField.deployed.location.territory === territory);
+ isBoardNoFieldLocation(p.noField.deployed.location) && p.noField.deployed.location.territory === territory);
if (suppliers.length > 1) throw new Error('Ecaz Betrayal has multiple concealed marker sources.');
return suppliers[0];
}
@@ -127,6 +127,7 @@ function markerSource(g: Game, ally: string, territory: string): EcazBetrayalNoF
if (!supplier) return undefined;
const marker = supplier.noField!.deployed!;
validateRicheseNoField(supplier.noField!);
+ if (!isBoardNoFieldLocation(marker.location)) return undefined;
if (supplier.faction !== 'richese' || typeof supplier.noFieldEvent !== 'string' || !supplier.noFieldEvent ||
!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
throw new Error('Ecaz Betrayal has invalid concealed marker custody.');
@@ -138,7 +139,7 @@ function allyLocations(g: Game, ally: Player): Map player.id === marker.controller);
+ if (!owner || owner.faction !== 'richese')
+ throw new Error('A Homeworld No-Field needs its seated Richese controller.');
+ const home = homeworldCombatLocation(context, custody, marker.location.homeworld);
+ if (home.native === owner.id)
+ throw new Error('A concealed No-Field on its own Homeworld is not supported.');
+ return { homeworld: home.id, controller: owner.id, effectiveForces: 1 };
+}
+
+/** Add the marker's real participant to native world battle pairing without
+ * adding a fictitious typed counter to custody or native population. A hidden
+ * zero still counts as a marker until the original reveal resolves. */
+export function quoteHomeworldBattlesWithNoField(
+ context: HomeworldCombatContext,
+ custody: HomeworldCustody,
+ marker: PublicHomeworldNoField | null | undefined,
+): HomeworldCombatPair[] {
+ const battles = quoteHomeworldBattles(context, custody);
+ const presence = homeworldNoFieldPresence(context, custody, marker);
+ if (!presence) return battles;
+ const home = homeworldCombatLocation(context, custody, presence.homeworld);
+ const owner = context.players.find(player => player.id === presence.controller)!;
+ const rank = new Map(context.order.map((id, index) => [id, index]));
+ for (const opponent of context.players) {
+ const forces = home.forces[opponent.id];
+ if (opponent.id === owner.id || opponent.id === owner.ally || !forces ||
+ forces.normal + forces.elite <= 0) continue;
+ const ownerFirst = rank.get(owner.id)! < rank.get(opponent.id)!;
+ const attacker = ownerFirst ? owner.id : opponent.id;
+ const defender = ownerFirst ? opponent.id : owner.id;
+ if (!battles.some(pair => pair.territory === home.id &&
+ pair.attacker === attacker && pair.defender === defender))
+ battles.push({ territory: home.id, attacker, defender });
+ }
+ // Preserve the native ordering: storm-order attacker, canonical Homeworld,
+ // then the native player-list order for defenders.
+ const worldOrder = new Map(homeworldForceGroups(context, custody).map((world, index) => [world.id, index]));
+ const playerOrder = new Map(context.players.map((player, index) => [player.id, index]));
+ return battles.sort((left, right) =>
+ rank.get(left.attacker)! - rank.get(right.attacker)! ||
+ worldOrder.get(left.territory)! - worldOrder.get(right.territory)! ||
+ playerOrder.get(left.defender)! - playerOrder.get(right.defender)!,
+ );
+}
diff --git a/game/richese-homeworld-marker-shipment.ts b/game/richese-homeworld-marker-shipment.ts
new file mode 100644
index 00000000..49c2de71
--- /dev/null
+++ b/game/richese-homeworld-marker-shipment.ts
@@ -0,0 +1,68 @@
+import {
+ HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody,
+} from './homeworld-custody';
+import type { HomeworldShipmentContext } from './homeworld-shipment';
+import { deployRicheseNoField, type RicheseNoField } from './richese-no-field';
+
+export type RicheseHomeworldMarkerShipmentIntent = {
+ player: string;
+ destination: string;
+ tokenId: string;
+};
+
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+function requireMarker(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new HomeworldCustodyError(message);
+}
+
+/** Quote one concealed Richese marker as a single shipment, not a physical
+ * counter. The engine owns timing, payment, response windows and commitment.
+ * Nothing in the input state is mutated, and the marker's denomination never
+ * changes its invoice or causes reserve counters to move. */
+export function quoteRicheseHomeworldMarkerShipment(
+ context: HomeworldShipmentContext,
+ custody: HomeworldCustody,
+ intent: RicheseHomeworldMarkerShipmentIntent,
+ noField: RicheseNoField,
+) {
+ // This validates all physical reserve and foreign garrison custody first.
+ const worlds = homeworldForceGroups(context, custody);
+ requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,tokenId' &&
+ typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
+ 'Choose a Richese player, one active Homeworld and one physical No-Field.');
+ requireMarker(context.players.every(seat => seat.ally === null ||
+ typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
+ 'Homeworld shipment needs valid seated alliance identities.');
+ const player = context.players.find(seat => seat.id === intent.player);
+ const source = worlds.find(world => world.id === 'homeworld:richese');
+ const destination = worlds.find(world => world.id === intent.destination);
+ requireMarker(player?.faction === 'richese' && source?.native === player.id,
+ 'Only native Richese can ship its concealed No-Field.');
+ requireMarker(destination && destination.native !== player.id,
+ 'Choose another seated faction’s Homeworld.');
+ const native = context.players.find(seat => seat.id === destination.native)!;
+ requireMarker(!(player.ally === native.id && native.ally === player.id),
+ 'You cannot ship to your ally’s Homeworld.');
+
+ let deployed: RicheseNoField;
+ try {
+ deployed = deployRicheseNoField(noField, {
+ tokenId: intent.tokenId,
+ controller: player.id,
+ location: { homeworld: destination.id },
+ });
+ } catch (error) {
+ throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
+ }
+ return {
+ player: player.id,
+ tokenId: intent.tokenId,
+ source: source.id,
+ destination: destination.id,
+ amount: 1,
+ elite: 0,
+ cost: 1,
+ noField: deployed,
+ };
+}
diff --git a/game/richese-no-field.ts b/game/richese-no-field.ts
index 31787d31..07872a66 100644
--- a/game/richese-no-field.ts
+++ b/game/richese-no-field.ts
@@ -1,8 +1,24 @@
import { MOBILE_STRONGHOLD } from './board';
import { isDiscoveryLocationId } from './discoveries';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
export type NoFieldValue = 0 | 3 | 5;
-export type NoFieldLocation = { territory: string; sector: number };
+/** Existing planet records retain their original JSON shape. A Homeworld is a
+ * distinct location, not a territory or a synthetic sector. */
+export type NoFieldTerritoryLocation = { territory: string; sector: number };
+export type NoFieldHomeworldLocation = { homeworld: string };
+export type NoFieldLocation = NoFieldTerritoryLocation | NoFieldHomeworldLocation;
+
+const HOMEWORLD_LOCATIONS = new Set(HOMEWORLD_CARDS.map(card =>
+ `homeworld:${card.faction}${card.id === 'salusa_secundus' ? ':salusa' : ''}`));
+
+export function isHomeworldNoFieldLocation(location: NoFieldLocation): location is NoFieldHomeworldLocation {
+ return !!location && typeof location === 'object' && 'homeworld' in location;
+}
+
+export function isBoardNoFieldLocation(location: NoFieldLocation): location is NoFieldTerritoryLocation {
+ return !!location && typeof location === 'object' && 'territory' in location;
+}
export type NoFieldToken = { id: string; value: NoFieldValue };
export type NoFieldDeployment = {
tokenId: string;
@@ -60,8 +76,15 @@ function validId(id: unknown): id is string {
return typeof id === 'string' && id.trim().length > 0;
}
function validateLocation(location: NoFieldLocation) {
+ requireNoField(location && typeof location === 'object' && !Array.isArray(location),
+ 'Choose a valid No-Field location.');
+ if (isHomeworldNoFieldLocation(location)) {
+ requireNoField(Object.keys(location).length === 1 && HOMEWORLD_LOCATIONS.has(location.homeworld),
+ 'Choose a valid Homeworld for the No-Field.');
+ return;
+ }
requireNoField(
- location &&
+ isBoardNoFieldLocation(location) &&
validId(location.territory) &&
Number.isSafeInteger(location.sector) &&
(location.sector >= 1 ||
diff --git a/game/sandmaster-movement.ts b/game/sandmaster-movement.ts
index efd93fd2..d2f5c78d 100644
--- a/game/sandmaster-movement.ts
+++ b/game/sandmaster-movement.ts
@@ -6,6 +6,7 @@ import { strongholdPathBlocked } from './occupancy';
import { botMovementRange } from './bot-mobility';
import { planetologistFlightSelection as movementFlightSelection } from './planetologist-flight';
import { validateCohortSelection, type MovementMarker, type OrnithopterMode } from './ornithopter';
+import { isBoardNoFieldLocation } from './richese-no-field';
export class SandmasterMovementError extends Error {}
export type SandmasterChoice = {
@@ -375,7 +376,7 @@ export function sandmasterActionOrder(
const info = g.richeseNoField?.owner === player ? g.richeseNoField : null;
const marker = info?.private?.deployed;
const noField: MovementMarker | undefined = action.noField === undefined ? undefined
- : info && marker?.controller === player && marker.tokenId === action.noField && info.event === action.event
+ : info && marker?.controller === player && isBoardNoFieldLocation(marker.location) && marker.tokenId === action.noField && info.event === action.event
? { tokenId: marker.tokenId, event: info.event,
from: location(marker.location.territory, marker.location.sector) } : undefined;
if (action.noField !== undefined && (!noField || g.homeworldMobility?.noFieldMovementBlocked)) return null;
diff --git a/game/stronghold-cards.ts b/game/stronghold-cards.ts
index c3d26a78..5918e717 100644
--- a/game/stronghold-cards.ts
+++ b/game/stronghold-cards.ts
@@ -2,6 +2,7 @@ import { MOBILE_STRONGHOLD, validLocation, splitLocation } from './board';
import type { FactionId } from './catalog';
import { fighterCount } from './advisors';
import type { ForcePresence } from './force-presence';
+import { isHomeworldNoFieldLocation } from './richese-no-field';
export type StrongholdId =
| 'arrakeen'
@@ -189,7 +190,7 @@ export function strongholdControllers(
}
const marker = p.noField?.deployed?.location;
requireValid(
- !marker || validLocation(marker.territory, marker.sector),
+ !marker || isHomeworldNoFieldLocation(marker) || validLocation(marker.territory, marker.sector),
'The No-Field must occupy a valid location.',
);
}
diff --git a/game/transport-quote.ts b/game/transport-quote.ts
index 1fbcd77e..a8f67ae4 100644
--- a/game/transport-quote.ts
+++ b/game/transport-quote.ts
@@ -6,6 +6,7 @@ import { botEntryAllowed, guildTransportCost } from './bot-mobility';
import { shipmentPaymentBounds } from './shipment-price';
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
import { guildNoFieldSelection } from './guild-no-field-selection';
+import { isBoardNoFieldLocation } from './richese-no-field';
/** Preview the submitted Guild transport using only this seat's entitled view.
* The authoritative action still revalidates the selection when it arrives.
@@ -24,7 +25,9 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push('Karama prevents native Guild cross/return transport for this shipment opportunity.');
const fromReserves = action.from === 'reserves';
const namesNoField = action.noField !== undefined;
- const noField = namesNoField ? guildNoFieldSelection(g, action) : null;
+ const selectedMarker = namesNoField ? guildNoFieldSelection(g, action) : null;
+ const noField = selectedMarker && isBoardNoFieldLocation(selectedMarker.location)
+ ? { ...selectedMarker, location: selectedMarker.location } : null;
let total = 0;
let origin = noField?.location.territory;
const matchingGroup:Record={};
diff --git a/game/victory-quote.ts b/game/victory-quote.ts
index aee09725..6a1750eb 100644
--- a/game/victory-quote.ts
+++ b/game/victory-quote.ts
@@ -24,6 +24,7 @@ import {
} from './stronghold-cards';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryContext, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
import type { TwoPlayerControllers } from './two-player-controllers';
+import { isHomeworldNoFieldLocation } from './richese-no-field';
export class VictoryQuoteError extends Error {}
function requireVictory(
@@ -108,7 +109,7 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto
if (marker)
requireVictory(
record(marker.location) &&
- validLocation(marker.location.territory, marker.location.sector),
+ (isHomeworldNoFieldLocation(marker.location) || validLocation(marker.location.territory, marker.location.sector)),
'Victory needs a valid deployed No-Field location.',
);
}
diff --git a/tests/battle-resolution-quote.test.ts b/tests/battle-resolution-quote.test.ts
index cb1fae54..edae47ae 100644
--- a/tests/battle-resolution-quote.test.ts
+++ b/tests/battle-resolution-quote.test.ts
@@ -645,7 +645,8 @@ function normalize(g: Game): BattleResolutionInput {
faction: p.faction,
ally: p.ally,
noFieldAtTerritory:
- p.noField?.deployed?.location.territory === b.territory,
+ !!p.noField?.deployed && 'territory' in p.noField.deployed.location &&
+ p.noField.deployed.location.territory === b.territory,
})),
physicalCards: [
...g.deck,
diff --git a/tests/fixture-discovery-paired-nexus-e2-skills.ts b/tests/fixture-discovery-paired-nexus-e2-skills.ts
index 70e58a78..5eca9a30 100644
--- a/tests/fixture-discovery-paired-nexus-e2-skills.ts
+++ b/tests/fixture-discovery-paired-nexus-e2-skills.ts
@@ -348,6 +348,7 @@ export function discoveryPairedNexusE2SkillsCunningAction(game: Game, richese: s
export function prepareDiscoveryPairedNexusE2SkillsBattle(f: DiscoveryPairedNexusE2SkillsFixture, shipped: Game,
trained = true): DiscoveryPairedNexusE2SkillsBattle {
const deployed = pairedSkillsPlayer(shipped, f.richese).noField!.deployed; assert.ok(deployed);
+ assert.ok('territory' in deployed.location);
const destination = deployed.location.territory, key = `${destination}:${deployed.location.sector}`;
let game = revealPairedSkillsRichese(shipped, f.richese, f.actions);
assert.equal(pairedSkillsPlayer(game, f.richese).noField!.deployed, null);
diff --git a/tests/fixture-guild-no-field-transport.ts b/tests/fixture-guild-no-field-transport.ts
index 7767e312..d34b2dd8 100644
--- a/tests/fixture-guild-no-field-transport.ts
+++ b/tests/fixture-guild-no-field-transport.ts
@@ -76,8 +76,10 @@ function selectSpice(game: Game, position: number, worm = false): SpiceSelection
}
export function guildNoFieldTransportAction(game: Game, route: 'cross' | 'return' = 'cross'): Action {
const own = guildNoFieldTransportPlayer(game, 'richese'), marker = own.noField?.deployed; assert.ok(marker && own.noFieldEvent);
+ const markerLocation = marker.location;
+ assert.ok('territory' in markerLocation);
const forces = Object.fromEntries(Object.entries(own.forces).filter(([key, amount]) =>
- amount > 0 && key.startsWith(`${marker.location.territory}:`)));
+ amount > 0 && key.startsWith(`${markerLocation.territory}:`)));
const nexus = viewGame(game, own.id).nexusGuildSecretAlly;
return { type: 'guildShip', noField: marker.tokenId, event: own.noFieldEvent, forces,
territory: route === 'return' ? 'reserves' : 'polar_sink', sector: 0, allyPayment: 0,
diff --git a/tests/fremen-ambassador-bots.test.ts b/tests/fremen-ambassador-bots.test.ts
index 13581b6c..53647639 100644
--- a/tests/fremen-ambassador-bots.test.ts
+++ b/tests/fremen-ambassador-bots.test.ts
@@ -218,6 +218,7 @@ for (const level of DIFFICULTIES) {
assert.deepEqual(done.players[2].forces, {});
assert.equal(done.players[2].reserves, 20);
assert.deepEqual(done.players[2].noField!.tokens, p.noField.tokens);
+ assert.ok('territory' in done.players[2].noField!.deployed!.location);
assert.equal(
done.players[2].noField!.deployed!.location.territory,
a.territory,
diff --git a/tests/homeworld-mobility-engine.test.ts b/tests/homeworld-mobility-engine.test.ts
index e4d63b37..4c691d5c 100644
--- a/tests/homeworld-mobility-engine.test.ts
+++ b/tests/homeworld-mobility-engine.test.ts
@@ -271,6 +271,7 @@ void test('high Richese moves the same physical token once and never borrows con
population(g, 10, 'imperial_basin:10');
const action = marker(g);
const next = applyAction(reload(g), 'owner', action);
+ assert.ok('territory' in next.players[0].noField!.deployed!.location);
assert.equal(
next.players[0].noField!.deployed!.location.territory,
'arrakeen',
diff --git a/tests/mobile-stronghold-homeworld-marker.test.ts b/tests/mobile-stronghold-homeworld-marker.test.ts
new file mode 100644
index 00000000..e67d722d
--- /dev/null
+++ b/tests/mobile-stronghold-homeworld-marker.test.ts
@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+import { mobileStrongholdCollectionCapacity, type MobileCollectionPlayer } from '../game/mobile-stronghold-collection';
+
+void test('a Homeworld No-Field contributes nothing to a Mobile Stronghold collection', () => {
+ const ix: MobileCollectionPlayer = { id: 'i', faction: 'ixians', advisors: {}, forces: { [MOBILE_LOCATION]: 2 } };
+ const richese: MobileCollectionPlayer = { id: 'r', faction: 'richese', advisors: {}, forces: {},
+ noField: { deployed: { controller: 'r', location: { homeworld: 'homeworld:atreides' } } } };
+ assert.equal(mobileStrongholdCollectionCapacity([ix, richese]), 4);
+ assert.equal(mobileStrongholdCollectionCapacity([richese]), 0);
+});
+
+void test('an aboard No-Field continues to contribute one without reading its hidden face', () => {
+ const hidden = { deployed: { controller: 'r', location: { territory: MOBILE_STRONGHOLD, sector: 0 } },
+ get tokens(): never { throw new Error('The concealed No-Field face was read.'); } };
+ const richese: MobileCollectionPlayer = { id: 'r', faction: 'richese', advisors: {}, forces: {}, noField: hidden };
+ assert.equal(mobileStrongholdCollectionCapacity([richese]), 2);
+});
diff --git a/tests/nexus-ecaz-betrayal-quotes.test.ts b/tests/nexus-ecaz-betrayal-quotes.test.ts
index e7320d6e..b70ea434 100644
--- a/tests/nexus-ecaz-betrayal-quotes.test.ts
+++ b/tests/nexus-ecaz-betrayal-quotes.test.ts
@@ -188,7 +188,8 @@ void test('pending marker recall binds exact private source while leaving denomi
const mutations: Array<(copy: Game) => void> = [
copy => { copy.players.find(p => p.id === ally)!.noFieldEvent = 'new-event'; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed = null; },
- copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.location.sector = 15; },
+ copy => { const location = copy.players.find(p => p.id === ally)!.noField!.deployed!.location;
+ assert.ok('territory' in location); location.sector = 15; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder; },
copy => {
const state = copy.players.find(p => p.id === ally)!.noField!;
diff --git a/tests/nexus-fremen-protection.test.ts b/tests/nexus-fremen-protection.test.ts
index ac313379..ee79f2ca 100644
--- a/tests/nexus-fremen-protection.test.ts
+++ b/tests/nexus-fremen-protection.test.ts
@@ -115,6 +115,7 @@ void test('irrelevant faces and hidden No-Field contents are not read at any pub
frame = answerNexusFremenProtection(frame, 'a', false);
assert.equal(frame.stage, 'complete');
validateNexusFremenProtection(input, frame);
+ assert.ok('territory' in marker.deployed!.location);
assert.equal(marker.deployed!.location.territory, 'hagga_basin');
});
diff --git a/tests/richese-homeworld-marker-alliance.test.ts b/tests/richese-homeworld-marker-alliance.test.ts
new file mode 100644
index 00000000..c83b0d18
--- /dev/null
+++ b/tests/richese-homeworld-marker-alliance.test.ts
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createHomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
+import { homeworldAllianceBlock } from '../game/homeworld-alliance';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+import { applyAction, viewGame } from '../game/engine';
+import { createRicheseHomeworldShipmentFixture, passRicheseHomeworldShipment } from './fixture-richese-homeworld-shipment';
+
+function fixture() {
+ const context: HomeworldCustodyContext = { advanced: false, players: [
+ { id: 'r-seat', faction: 'richese', reserves: 10, eliteReserves: 0 },
+ { id: 'c-seat', faction: 'choam', reserves: 6, eliteReserves: 0 },
+ { id: 'e-seat', faction: 'ecaz', reserves: 6, eliteReserves: 0 },
+ ] };
+ const custody = createHomeworldCustody(context);
+ return { context, custody, marker: { homeworld: 'homeworld:choam', controller: 'r-seat' } };
+}
+
+void test('marker-only presence forbids the native and Richese alliance in either order', () => {
+ const { context, custody, marker } = fixture(), before = structuredClone(custody);
+ assert.equal(homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat'), null);
+ assert.match(homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat', marker)!, /cannot form/);
+ assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'r-seat', marker)!, /cannot form/);
+ assert.equal(homeworldAllianceBlock(context, custody, 'r-seat', 'e-seat', marker), null);
+ assert.equal(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat', marker), null);
+ assert.deepEqual(custody, before);
+});
+
+void test('physical visitors retain their original rule and marker adds no physical counter', () => {
+ const { context, custody, marker } = fixture();
+ custody.visitors['homeworld:ecaz'] = { 'c-seat': { normal: 1, elite: 0 } };
+ const before = structuredClone(custody);
+ assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat')!, /cannot form/);
+ assert.match(homeworldAllianceBlock(context, custody, 'c-seat', 'e-seat', marker)!, /cannot form/);
+ assert.deepEqual(custody, before);
+});
+
+void test('only a seated Richese controller on an active foreign World is accepted', () => {
+ const { context, custody, marker } = fixture();
+ assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
+ { ...marker, controller: 'e-seat' }), /Richese controller/);
+ assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
+ { ...marker, homeworld: 'homeworld:richese' }), /foreign world/);
+ assert.throws(() => homeworldAllianceBlock(context, custody, 'r-seat', 'c-seat',
+ { ...marker, homeworld: 'homeworld:atreides' }), /active foreign world/);
+});
+
+void test('server Homeworld integrity rejects an alliance across an actual marker-only arrival', () => {
+ const fixture = createRicheseHomeworldShipmentFixture({ companions: 1, advanced: false });
+ let game = fixture.game;
+ const view = viewGame(game, fixture.owner), option = view.homeworldShipment;
+ assert.ok(option && view.richeseNoField?.private);
+ game = applyAction(game, fixture.owner, { type: 'homeworldShip', event: option.event,
+ destination: 'homeworld:emperor', sources: {}, noField: fixture.token,
+ noFieldEvent: view.richeseNoField.event, allyPayment: 0 });
+ game = passRicheseHomeworldShipment(game);
+ assert.deepEqual(game.players.find(player => player.id === fixture.owner)!.noField!.deployed!.location,
+ { homeworld: 'homeworld:emperor' });
+ assert.equal(game.homeworlds!.custody!.visitors['homeworld:emperor']?.[fixture.owner], undefined);
+ assert.doesNotThrow(() => homeworldGameIntegrity(game));
+ const illegal = structuredClone(game);
+ const richese = illegal.players.find(player => player.id === fixture.owner)!;
+ const emperor = illegal.players.find(player => player.id === fixture.holder)!;
+ richese.ally = emperor.id; emperor.ally = richese.id;
+ assert.throws(() => homeworldGameIntegrity(illegal), /alliance cannot form/);
+});
diff --git a/tests/richese-homeworld-marker-board.test.ts b/tests/richese-homeworld-marker-board.test.ts
new file mode 100644
index 00000000..f1b43527
--- /dev/null
+++ b/tests/richese-homeworld-marker-board.test.ts
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, newPlayer } from '../game/engine';
+import { presenceAt, presenceByLocation } from '../game/force-presence';
+import { forceInventory } from '../game/force-inventory';
+import { quoteBattleBoard } from '../game/board-resolution-quote';
+import { quoteVictory } from '../game/victory-quote';
+import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
+
+function game() {
+ const g = createGame('WORLDMARKER', newPlayer('r', 'Richese', 'richese'), true);
+ g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
+ Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18,
+ order: ['r', 'a', 'b'], ready: [], phaseOpening: null, active: null });
+ for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, hand: [], spice: 5 });
+ return g;
+}
+function marker(tokenId: string, location: { homeworld: string } | { territory: string; sector: number }) {
+ return deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
+ tokenId, controller: 'r', location,
+ });
+}
+
+void test('a Homeworld marker does not fabricate Arrakis presence, a board battle or a stronghold win', () => {
+ const g = game(), richese = g.players[0], atreides = g.players[1];
+ richese.forces = { 'carthag:11': 1, 'sietch_tabr:14': 1 };
+ richese.noField = marker('three', { homeworld: 'homeworld:atreides' });
+ assert.equal(presenceAt(richese, 'arrakeen'), 0);
+ assert.equal(presenceAt(richese, 'homeworld:atreides'), 0);
+ assert.deepEqual(presenceByLocation(richese), richese.forces);
+ assert.deepEqual(quoteVictory(g).winner, []);
+ atreides.forces = { 'arrakeen:10': 1 };
+ assert.deepEqual(quoteBattleBoard(g).battles, []);
+
+ // The same single marker on Arrakis continues to participate normally.
+ richese.noField = marker('three', { territory: 'arrakeen', sector: 10 });
+ assert.equal(presenceAt(richese, 'arrakeen'), 1);
+ assert.equal(presenceByLocation(richese)['arrakeen:10'], 1);
+ assert.equal(quoteBattleBoard(g).battles.length, 1);
+ atreides.forces = {};
+ assert.deepEqual(quoteVictory(g).winner, ['r']);
+});
+
+void test('the inspector names a Homeworld marker without adding a counter or exposing its value', () => {
+ const g = game(), richese = g.players[0];
+ const inventories = ['zero', 'three', 'five'].map(tokenId => {
+ const state = marker(tokenId, { homeworld: 'homeworld:atreides' });
+ const player = { ...richese, noField: projectRicheseNoField(state, false) };
+ const inventory = forceInventory(player);
+ assert.ok(inventory);
+ assert.deepEqual(inventory.marker, { name: 'Caladan' });
+ assert.equal(inventory.total, 20);
+ assert.doesNotMatch(JSON.stringify(inventory), /zero|three|five|tokenId|lastShipped/);
+ return inventory;
+ });
+ assert.deepEqual(inventories[0], inventories[1]);
+ assert.deepEqual(inventories[1], inventories[2]);
+});
diff --git a/tests/richese-homeworld-marker-combat.test.ts b/tests/richese-homeworld-marker-combat.test.ts
new file mode 100644
index 00000000..00b87852
--- /dev/null
+++ b/tests/richese-homeworld-marker-combat.test.ts
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { quoteHomeworldBattles, homeworldCombatArmy, homeworldCombatLocation,
+ type HomeworldCombatContext } from '../game/homeworld-combat';
+import { createHomeworldCustody, type HomeworldCustody } from '../game/homeworld-custody';
+import { homeworldNoFieldPresence, quoteHomeworldBattlesWithNoField,
+ type PublicHomeworldNoField } from '../game/richese-homeworld-marker-combat';
+
+function fixture(): { context: HomeworldCombatContext; custody: HomeworldCustody; marker: PublicHomeworldNoField } {
+ const context: HomeworldCombatContext = {
+ advanced: true,
+ order: ['r', 'e', 'a'],
+ players: [
+ { id: 'e', faction: 'emperor', reserves: 6, eliteReserves: 2, ally: null },
+ { id: 'a', faction: 'atreides', reserves: 5, eliteReserves: 0, ally: null },
+ { id: 'r', faction: 'richese', reserves: 7, eliteReserves: 0, ally: null },
+ ],
+ };
+ return { context, custody: createHomeworldCustody(context),
+ marker: { controller: 'r', location: { homeworld: 'homeworld:emperor' } } };
+}
+
+void test('a public marker alone adds Richese as a Homeworld combatant without creating a counter', () => {
+ const { context, custody, marker } = fixture();
+ const beforeContext = structuredClone(context), beforeCustody = structuredClone(custody);
+ assert.deepEqual(quoteHomeworldBattles(context, custody), []);
+ assert.deepEqual(homeworldNoFieldPresence(context, custody, marker), {
+ controller: 'r', homeworld: 'homeworld:emperor', effectiveForces: 1,
+ });
+ assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), [
+ { territory: 'homeworld:emperor', attacker: 'r', defender: 'e' },
+ ]);
+ assert.equal(homeworldCombatLocation(context, custody, marker.location.homeworld).population, 4);
+ assert.deepEqual(homeworldCombatArmy(context, custody, marker.location.homeworld, 'r'),
+ { player: 'r', normal: 0, elite: 0, native: false, nativeBattleStrength: 0 });
+ assert.deepEqual(context, beforeContext); assert.deepEqual(custody, beforeCustody);
+});
+
+void test('a marker and existing Richese garrison do not duplicate battles or physical forces', () => {
+ const { context, custody, marker } = fixture();
+ custody.visitors['homeworld:emperor'] = { r: { normal: 2, elite: 0 }, a: { normal: 1, elite: 0 } };
+ const before = structuredClone(custody), native = quoteHomeworldBattles(context, custody);
+ assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), native);
+ assert.equal(native.filter(pair => pair.attacker === 'r' && pair.defender === 'e').length, 1);
+ assert.deepEqual(homeworldCombatArmy(context, custody, marker.location.homeworld, 'r'),
+ { player: 'r', normal: 2, elite: 0, native: false, nativeBattleStrength: 0 });
+ assert.deepEqual(custody, before);
+});
+
+void test('zero native forces are not invented and a foreign army may oppose the marker', () => {
+ const { context, custody, marker } = fixture();
+ const emperor = context.players.find(player => player.id === 'e')!;
+ emperor.reserves = 0; emperor.eliteReserves = 0;
+ custody.salusa = { normal: 0, elite: 0 };
+ custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } };
+ assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, marker), [
+ { territory: 'homeworld:emperor', attacker: 'r', defender: 'a' },
+ ]);
+});
+
+void test('allies do not become opponents and public pairing is independent of the hidden value', () => {
+ const { context, custody, marker } = fixture();
+ context.players.find(player => player.id === 'r')!.ally = 'a';
+ context.players.find(player => player.id === 'a')!.ally = 'r';
+ custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } };
+ const expected = [
+ { territory: 'homeworld:emperor', attacker: 'r', defender: 'e' },
+ { territory: 'homeworld:emperor', attacker: 'e', defender: 'a' },
+ ];
+ for (const value of [0, 3, 5]) {
+ // Extra private fields must never influence this public quote.
+ const specimen = { ...marker, tokenId: `hidden-${value}`, value };
+ assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, specimen), expected);
+ }
+});
+
+void test('a marker needs its seated Richese controller and a foreign active Homeworld', () => {
+ const { context, custody, marker } = fixture();
+ assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, null), quoteHomeworldBattles(context, custody));
+ assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'e' }), /Richese/);
+ assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'missing' }), /Richese/);
+ assert.throws(() => homeworldNoFieldPresence(context, custody,
+ { ...marker, location: { homeworld: 'homeworld:richese' } }), /own Homeworld/);
+ assert.throws(() => homeworldNoFieldPresence(context, custody,
+ { ...marker, location: { homeworld: 'homeworld:fremen' } }), /canonical Homeworld/);
+});
diff --git a/tests/richese-homeworld-marker-controls.test.tsx b/tests/richese-homeworld-marker-controls.test.tsx
new file mode 100644
index 00000000..ddbb24cc
--- /dev/null
+++ b/tests/richese-homeworld-marker-controls.test.tsx
@@ -0,0 +1,153 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import * as jsxRuntime from 'react/jsx-runtime';
+import ts from 'typescript';
+import type { Action, GameView } from '../game/engine';
+import { createRicheseNoField } from '../game/richese-no-field';
+import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
+import { homeworldPopulations } from '../game/homeworld-population';
+import * as combat from '../game/combat';
+import * as options from '../game/homeworld-shipment-options';
+import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
+
+function fixture(): GameView {
+ const context = { advanced: true, players: [
+ { id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null },
+ { id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null },
+ { id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null },
+ ] };
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ const populations = homeworldPopulations(context, custody);
+ return {
+ me: 'r', active: 'r', advanced: true, status: 'playing', phase: 5,
+ homeworldShipment: { event: 'homeworld-event', blocked: null },
+ aid: { available: 0, pledged: 0 },
+ players: context.players.map(p => ({ ...p, name: p.faction, spice: p.id === 'r' ? 4 : undefined,
+ shipped: false, elites: { reserves: 0 } })),
+ homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ ...world,
+ ...populations.find(population => population.location === world.id)! })) },
+ richeseNoField: { owner: 'r', event: 'marker-event', private: createRicheseNoField(['zero', 'three', 'five']),
+ canShip: true, shipBlock: null },
+ } as unknown as GameView;
+}
+
+void test('private marker quote emits the native shipment action without withdrawing counters', () => {
+ const view = fixture(), before = structuredClone(view);
+ for (const tokenId of ['zero', 'three', 'five']) {
+ const quote = options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', tokenId);
+ assert.equal(quote.cost, 1); assert.equal(quote.ownPayment, 1);
+ assert.deepEqual(quote.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:atreides',
+ sources: {}, noField: tokenId, noFieldEvent: 'marker-event', allyPayment: 0 });
+ }
+ assert.deepEqual(view, before);
+});
+
+void test('marker quote respects eligibility, payment, privacy and current action windows', () => {
+ const view = fixture(), info = view.richeseNoField!;
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action, null);
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:unknown', 'zero').action, null);
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'missing').action, null);
+ info.private!.lastShipped = 'zero';
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'zero').action, null);
+ info.private!.lastShipped = null;
+ view.players[0].spice = 0;
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three').action, null);
+ view.players[0].ally = 'g'; view.players[2].ally = 'r'; view.aid.available = 1;
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'three', 1).action, null);
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 1).action?.allyPayment, 1);
+ for (const contribution of [-1, 0.5, 2, NaN])
+ assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', contribution).action, null);
+ for (const patch of [
+ { phase: 6 }, { active: 'a' }, { status: 'finished' },
+ { roomControl: { paused: true } }, { homeworldShipment: { event: 'homeworld-event', blocked: 'Wait.' } },
+ { richeseNoField: { ...info, owner: 'a', private: null } },
+ ]) assert.equal(options.richeseHomeworldMarkerChoice({ ...view, ...patch } as GameView,
+ 'homeworld:atreides', 'three', 1).action, null);
+});
+
+void test('only the owner sees the provisional marker controls and private token values', () => {
+ const view = fixture();
+ const owner = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
+ assert.match(owner, /Provisional Homeworld rule/);
+ assert.match(owner, /No-Field 0/); assert.match(owner, /No-Field 3/); assert.match(owner, /No-Field 5/);
+ assert.match(owner, /one spice total/);
+ const other = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
+ assert.equal(renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: other, act() {}, busy: false })), '');
+});
+
+void test('all existing bot profiles can offer a legal marker when physical reserves are empty', () => {
+ const view = fixture();
+ view.players[0].reserves = 0;
+ const own = view.homeworlds!.worlds!.find(world => world.id === 'homeworld:richese')!;
+ own.forces.r.normal = 0;
+ for (const level of [0, 1, 2, 3]) {
+ const action = options.homeworldShipmentActions(view, level).find(candidate => candidate.noField !== undefined);
+ assert.ok(action, `profile ${level} can offer its physical marker`);
+ assert.deepEqual(action, options.richeseHomeworldMarkerChoice(view, String(action.destination),
+ String(action.noField), Number(action.allyPayment)).action);
+ }
+});
+
+void test('optional modules block the marker quote and all bot marker candidates', () => {
+ const view = fixture();
+ for (const patch of [
+ { nexusCards: { waiting: [] } }, { leaderSkills: {} }, { discoveryEnabled: true },
+ { discoveries: {} }, { techTokens: {} }, { strongholdCards: {} },
+ ]) {
+ const withModule = { ...view, ...patch } as GameView;
+ const quote = options.richeseHomeworldMarkerChoice(withModule, 'homeworld:atreides', 'three');
+ assert.equal(quote.action, null);
+ assert.match(quote.blocked ?? '', /without optional modules/);
+ for (const level of [0, 1, 2, 3])
+ assert.equal(options.homeworldShipmentActions(withModule, level)
+ .some(candidate => candidate.noField !== undefined), false);
+ }
+});
+
+type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+
+void test('the actual marker controls submit the selected private token and destination', () => {
+ const view = fixture(), sent: Action[] = [], states: unknown[] = [];
+ let cursor = 0;
+ const exports: { HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment } = {};
+ runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
+ }).outputText, {
+ exports,
+ require(name: string) {
+ if (name === 'react/jsx-runtime') return jsxRuntime;
+ if (name === 'react') return { useState(initial: unknown) {
+ const index = cursor++; if (!(index in states)) states[index] = initial;
+ return [states[index], (next: unknown) => { states[index] = next; }];
+ } };
+ if (name === '@/game/combat') return combat;
+ if (name === '@/game/homeworld-shipment-options') return options;
+ if (name === './ui/button') return { Button: 'button' };
+ if (name === './ui/input') return { Input: 'input' };
+ throw new Error(`Unexpected import ${name}`);
+ },
+ });
+ assert.ok(exports.HomeworldNoFieldShipment);
+ const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({ game: view, busy: false,
+ act: action => sent.push(action) })); };
+ const select = (label: string, value: string) => {
+ const node = render().find(element => element.type === 'select' && element.props['aria-label'] === label);
+ assert.ok(node); (node.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
+ };
+ select('No-Field destination Homeworld', 'homeworld:guild');
+ select('Private No-Field token', 'five');
+ const button = render().find(element => element.type === 'button');
+ assert.ok(button); assert.equal(button.props.disabled, false);
+ (button.props.onClick as () => void)();
+ assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
+ destination: 'homeworld:guild', sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0 });
+});
diff --git a/tests/richese-homeworld-marker-http.test.ts b/tests/richese-homeworld-marker-http.test.ts
new file mode 100644
index 00000000..59195fae
--- /dev/null
+++ b/tests/richese-homeworld-marker-http.test.ts
@@ -0,0 +1,88 @@
+// @dune-suite integration
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
+import type { Action, GameView } from '../game/engine';
+
+const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
+async function request(path: string, cookie?: string, body?: unknown) {
+ const response = await fetch(base + path, {
+ method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
+ headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(cookie ? { cookie } : {}) },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
+ data: await response.json() as GameView & { error?: string } };
+}
+
+/** All state belongs to the new QA room. The existing bot adapter proposes
+ * legal actions from each participant's own authenticated view; no server
+ * state, physical tokens, phases, hands or balances are staged. */
+void test('authenticated Richese ships a concealed marker to a Homeworld without revealing its identity', async () => {
+ const host = await request('/api/rooms', undefined, {
+ name: 'Homeworld marker owner', faction: 'richese', advanced: false, expansions: ['choam'],
+ });
+ assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
+ const path = `/api/rooms/${host.data.code}`;
+ const peer = await request(path, undefined, { type: 'join', name: 'Homeworld marker opponent', faction: 'atreides' });
+ assert.equal(peer.status, 200, JSON.stringify(peer.data)); assert.ok(peer.cookie);
+ const seats = [{ cookie: host.cookie, id: host.data.me }, { cookie: peer.cookie, id: peer.data.me }];
+ let view = peer.data;
+ const act = async (cookie: string, action: Action) => {
+ const next = await request(path, cookie, { version: view.version, action });
+ assert.equal(next.status, 200, `Rejected ${action.type}: ${JSON.stringify(next.data)}`);
+ view = next.data; return next.data;
+ };
+ await act(host.cookie, { type: 'homeworlds', enabled: true });
+ for (const seat of seats) await act(seat.cookie, { type: 'ready' });
+ await act(host.cookie, { type: 'start' });
+
+ async function read(cookie: string): Promise {
+ const result = await request(path, cookie);
+ assert.equal(result.status, 200, JSON.stringify(result.data));
+ view = result.data; return result.data;
+ }
+ async function advance(until: (owner: GameView) => boolean) {
+ for (let count = 0; count < 300; count++) {
+ const owner = await read(host.cookie!);
+ if (until(owner)) return owner;
+ assert.notEqual(owner.status, 'finished', 'The QA game ended before its marker shipment.');
+ let proposed: { cookie: string; action: Action } | null = null;
+ for (const seat of seats) {
+ const current = await read(seat.cookie!);
+ const local = structuredClone(current);
+ const self = local.players.find(player => player.id === seat.id); assert.ok(self);
+ self.bot = 'Easy'; // Local action adapter only; the server seat remains human.
+ const actions = botActions(local);
+ if (actions.length) { proposed = { cookie: seat.cookie!, action: actions[0] }; break; }
+ }
+ assert.ok(proposed, `No native continuation at ${owner.status}/${owner.phase}/${owner.decision?.kind ?? 'no decision'}.`);
+ // During response windows pass rather than spending a physical Karama.
+ if (view.response && !view.response.passed.includes(view.me)) proposed.action = { type: 'passResponse' };
+ await act(proposed.cookie, proposed.action);
+ }
+ throw Error('The dedicated HTTP room did not reach its finite native marker boundary.');
+ }
+
+ const before = await advance(owner => owner.status === 'playing' && owner.phase === 5 && owner.active === owner.me &&
+ !!owner.homeworldShipment && !owner.homeworldShipment.blocked && !!owner.richeseNoField?.canShip);
+ const info = before.richeseNoField!; assert.ok(info.private);
+ const token = info.private.tokens.find(candidate => candidate.id !== info.private!.lastShipped); assert.ok(token);
+ const tokens = info.private.tokens.map(candidate => candidate.id);
+ const payer = before.players.find(candidate => candidate.id === before.me)!;
+ const originalSpice = payer.spice, originalReserves = payer.reserves;
+ const quote = richeseHomeworldMarkerChoice(before, 'homeworld:atreides', token.id);
+ assert.ok(quote.action, quote.blocked ?? 'The original private shipment should be available.');
+ await act(host.cookie, quote.action);
+ const settled = await advance(owner => owner.status === 'playing' && owner.phase === 5 &&
+ !!owner.richeseNoField?.private?.deployed && owner.players.find(player => player.id === owner.me)?.shipped === true);
+ const after = settled.players.find(candidate => candidate.id === settled.me)!;
+ assert.equal(after.spice, originalSpice! - 1);
+ assert.equal(after.reserves, originalReserves);
+ assert.deepEqual(settled.richeseNoField!.private!.deployed!.location, { homeworld: 'homeworld:atreides' });
+ const peerView = await read(peer.cookie);
+ assert.equal(peerView.richeseNoField!.private, null);
+ assert.deepEqual(peerView.richeseNoField!.public.deployed!.location, { homeworld: 'homeworld:atreides' });
+ for (const id of tokens) assert.equal(JSON.stringify(peerView).includes(id), false, 'A foreign seat cannot see token IDs.');
+});
diff --git a/tests/richese-homeworld-marker-model.test.ts b/tests/richese-homeworld-marker-model.test.ts
new file mode 100644
index 00000000..3c2122eb
--- /dev/null
+++ b/tests/richese-homeworld-marker-model.test.ts
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ createRicheseNoField, deployRicheseNoField, isBoardNoFieldLocation,
+ isHomeworldNoFieldLocation, moveRicheseNoField, projectRicheseNoField,
+ recallRicheseNoField, revealRicheseNoField, validateRicheseNoField,
+ type NoFieldLocation, type RicheseNoField,
+} from '../game/richese-no-field';
+
+const ids = ['zero', 'three', 'five'];
+const home = { homeworld: 'homeworld:atreides' } as const;
+const planet = { territory: 'polar_sink', sector: 0 } as const;
+function placed(location: NoFieldLocation = home, tokenId = 'three') {
+ return deployRicheseNoField(createRicheseNoField(ids), { tokenId, controller: 'richese', location });
+}
+
+void test('Homeworld marker preserves single physical token and hides its face in public projection', () => {
+ const state = placed();
+ assert.equal(state.lastShipped, 'three');
+ assert.deepEqual(projectRicheseNoField(state, false), {
+ deployed: { controller: 'richese', location: home, effectiveForces: 1 }, lastUsed: null,
+ });
+ assert.deepEqual(projectRicheseNoField(state, true).private, state);
+ assert.throws(() => deployRicheseNoField(state, { tokenId: 'five', controller: 'richese', location: planet }));
+ assert.equal(isHomeworldNoFieldLocation(state.deployed!.location), true);
+ assert.equal(isBoardNoFieldLocation(state.deployed!.location), false);
+});
+
+void test('Homeworld reveal returns only existing reserves and keeps the prior shipment history', () => {
+ for (const [tokenId, reserves, forces] of [['zero', 5, 0], ['three', 1, 1], ['five', 3, 3]] as const) {
+ const before = placed(home, tokenId);
+ const snapshot = structuredClone(before);
+ const result = revealRicheseNoField(before, { tokenId, reserves, cause: 'battle' });
+ assert.equal(result.forces, forces);
+ assert.deepEqual(result.location, home);
+ assert.equal(result.state.deployed, null);
+ assert.equal(result.state.lastShipped, tokenId);
+ assert.deepEqual(before, snapshot);
+ assert.throws(() => deployRicheseNoField(result.state, { tokenId, controller: 'richese', location: planet }));
+ }
+});
+
+void test('board and Homeworld location custody survives JSON and a face-down recall', () => {
+ const board = placed(planet);
+ assert.equal(isBoardNoFieldLocation(board.deployed!.location), true);
+ assert.equal(isHomeworldNoFieldLocation(board.deployed!.location), false);
+ assert.deepEqual(JSON.parse(JSON.stringify(board)), board);
+ validateRicheseNoField(JSON.parse(JSON.stringify(board)) as RicheseNoField);
+ const moved = moveRicheseNoField(board, 'three', home);
+ assert.deepEqual(board.deployed!.location, planet);
+ assert.deepEqual(moved.deployed!.location, home);
+ const loaded = JSON.parse(JSON.stringify(moved)) as RicheseNoField;
+ validateRicheseNoField(loaded);
+ const returned = recallRicheseNoField(loaded, 'three');
+ assert.equal(returned.deployed, null);
+ assert.equal(returned.hiddenReturn, true);
+ assert.equal(projectRicheseNoField(returned, false).lastUsed, null);
+ assert.throws(() => deployRicheseNoField(returned, { tokenId: 'three', controller: 'richese', location: home }));
+});
+
+void test('Homeworld locations require a canonical identity and cannot mix with territory shape', () => {
+ const fresh = createRicheseNoField(ids);
+ for (const location of [
+ { homeworld: 'homeworld:emperor:salusa' }, { homeworld: 'homeworld:choam' },
+ ]) assert.doesNotThrow(() => deployRicheseNoField(fresh, { tokenId: 'zero', controller: 'richese', location }));
+ for (const location of [
+ { homeworld: 'homeworld:unknown' }, { homeworld: '' },
+ { homeworld: 'homeworld:atreides', territory: 'polar_sink', sector: 0 },
+ { territory: 'homeworld:atreides', sector: 0 },
+ ]) assert.throws(() => deployRicheseNoField(fresh, {
+ tokenId: 'zero', controller: 'richese', location: location as NoFieldLocation,
+ }));
+});
diff --git a/tests/richese-homeworld-marker-occupation.test.ts b/tests/richese-homeworld-marker-occupation.test.ts
new file mode 100644
index 00000000..ea29e934
--- /dev/null
+++ b/tests/richese-homeworld-marker-occupation.test.ts
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createHomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
+import { createHomeworldOccupationHistory, observeHomeworldOccupation,
+ validateHomeworldOccupationHistory, readHomeworldOccupationSnapshot,
+ currentHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence,
+ homeworldOccupationMarker } from '../game/homeworld-occupation-history';
+import { validateHomeworldEcazDukeGrants, type HomeworldEcazDukeContext } from '../game/homeworld-ecaz-duke';
+
+function fixture() {
+ const context: HomeworldCustodyContext = { advanced: false, players: [
+ { id: 'richese-seat-17', faction: 'richese', reserves: 10, eliteReserves: 0 },
+ { id: 'choam-seat-42', faction: 'choam', reserves: 0, eliteReserves: 0 },
+ { id: 'ecaz-seat-99', faction: 'ecaz', reserves: 0, eliteReserves: 0 },
+ ] };
+ const custody = createHomeworldCustody(context);
+ const history = createHomeworldOccupationHistory(context, custody, 1, 'setup');
+ const marker = { homeworld: 'homeworld:choam', controller: 'richese-seat-17' };
+ return { context, custody, history, marker };
+}
+
+void test('marker-free history preserves the canonical original physical snapshot and source signature', () => {
+ const { context, custody, history } = fixture();
+ const groups = [
+ ['homeworld:choam', [['choam-seat-42', 0, 0]]],
+ ['homeworld:ecaz', [['ecaz-seat-99', 0, 0]]],
+ ['homeworld:richese', [['richese-seat-17', 10, 0]]],
+ ];
+ const physical = JSON.stringify(groups);
+ assert.equal(history.snapshots[0], physical);
+ assert.equal(history.sources[0].signature,
+ JSON.stringify(['homeworldOccupationSource', 'setup', 1, 'setup', physical]));
+ assert.deepEqual(readHomeworldOccupationSnapshot(physical, context), { groups, marker: null });
+ assert.deepEqual(currentHomeworldOccupationSnapshot(context, custody), { groups, marker: null });
+ validateHomeworldOccupationHistory(JSON.parse(JSON.stringify(history)), context, 1);
+});
+
+void test('marker-only arrival and departure create distinct observed facts without physical counters', () => {
+ const { context, custody, history, marker } = fixture();
+ const beforeCustody = structuredClone(custody), beforeContext = structuredClone(context);
+ const arrival = observeHomeworldOccupation(history, context, custody, 1, 'change', 'arrival', marker);
+ assert.notEqual(arrival.snapshots[arrival.sources.at(-1)!.snapshot], history.snapshots[0]);
+ const snapshot = readHomeworldOccupationSnapshot(arrival.snapshots[arrival.sources.at(-1)!.snapshot], context);
+ assert.deepEqual(snapshot.marker, marker);
+ assert.deepEqual(homeworldOccupationSnapshotPresence(snapshot, marker.homeworld), [marker.controller]);
+ assert.deepEqual(arrival.qualifications, [{ event: 'arrival', world: marker.homeworld,
+ player: marker.controller, faction: 'richese', turn: 1, cause: 'sole' }]);
+ assert.deepEqual(observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'arrival', marker), arrival);
+ assert.throws(() => observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'arrival'), /different physical facts/);
+ const departure = observeHomeworldOccupation(arrival, context, custody, 1, 'change', 'departure');
+ assert.equal(departure.sources.at(-1)!.event, 'departure');
+ assert.equal(departure.sources.at(-1)!.snapshot, history.sources[0].snapshot);
+ assert.deepEqual(departure.qualifications, arrival.qualifications);
+ assert.deepEqual(custody, beforeCustody); assert.deepEqual(context, beforeContext);
+ validateHomeworldOccupationHistory(JSON.parse(JSON.stringify(departure)), context, 1);
+});
+
+void test('marker and physical garrison of one controller qualify once; invalid World identities reject', () => {
+ const { context, custody, history, marker } = fixture();
+ custody.visitors[marker.homeworld] = { [marker.controller]: { normal: 1, elite: 0 } };
+ const frame = currentHomeworldOccupationSnapshot(context, custody, marker);
+ assert.deepEqual(homeworldOccupationSnapshotPresence(frame, marker.homeworld), [marker.controller]);
+ const observed = observeHomeworldOccupation(history, context, custody, 1, 'change', 'mixed', marker);
+ assert.equal(observed.qualifications.length, 1);
+ assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
+ { homeworld: 'homeworld:richese', controller: marker.controller }), /foreign world/);
+ assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
+ { homeworld: marker.homeworld, controller: 'choam-seat-42' }), /Richese controller/);
+ assert.throws(() => currentHomeworldOccupationSnapshot(context, custody,
+ { homeworld: 'homeworld:fremen', controller: marker.controller }), /active foreign world/);
+});
+
+void test('marker extraction is public only and ignores an Arrakis token', () => {
+ const { marker } = fixture();
+ const seats = [{ id: marker.controller, faction: 'richese' as const,
+ noField: { deployed: { controller: marker.controller, location: { homeworld: marker.homeworld } } } }];
+ assert.deepEqual(homeworldOccupationMarker(seats), marker);
+ assert.equal(homeworldOccupationMarker([{ ...seats[0], noField: { deployed: {
+ controller: marker.controller, location: { territory: 'arrakeen', sector: 10 },
+ } } }]), null);
+});
+
+for (const source of ['physical', 'marker'] as const) void test(`Ecaz Duke grant validates its canonical faction world with opaque seat IDs and ${source} presence`, () => {
+ const { context, custody, history } = fixture();
+ const marker = { homeworld: 'homeworld:ecaz', controller: 'richese-seat-17' };
+ if (source === 'physical') custody.visitors[marker.homeworld] = { [marker.controller]: { normal: 1, elite: 0 } };
+ const occupied = observeHomeworldOccupation(history, context, custody, 1, 'change', 'ecaz-occupation',
+ source === 'marker' ? marker : null);
+ const game = {
+ status: 'playing', advanced: false, turn: 1, homeworldOccupationPreview: true,
+ players: context.players.map(player => ({ id: player.id, faction: player.faction,
+ reserves: player.reserves, ally: null })),
+ homeworlds: { custody, historyVersion: 1 }, homeworldOccupationHistory: occupied,
+ homeworldEcazDukeGrants: [{ turn: 1, qualification: 'ecaz-occupation',
+ occupier: marker.controller, recipient: marker.controller }],
+ } as unknown as HomeworldEcazDukeContext;
+ assert.doesNotThrow(() => validateHomeworldEcazDukeGrants(game));
+});
diff --git a/tests/richese-homeworld-marker-runtime.test.ts b/tests/richese-homeworld-marker-runtime.test.ts
new file mode 100644
index 00000000..dca66c2a
--- /dev/null
+++ b/tests/richese-homeworld-marker-runtime.test.ts
@@ -0,0 +1,174 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, createGame, joinGame, newPlayer,
+ viewGame, type Action, type Game } from '../game/engine';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
+
+const richese = 'richese', opponent = 'atreides', world = 'homeworld:atreides';
+const player = (game: Game, id: string) => {
+ const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat;
+};
+const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
+ !game.truthtrance && !game.pendingTreacheryDiscard;
+
+/** Follow only the real native decisions and response queues. The Guild's
+ * actual Advanced stop opportunity is explicitly allowed and recorded. */
+function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
+ let state = game;
+ for (let count = 0; count < 2000; count++) {
+ if (until(state)) return state;
+ assert.notEqual(state.status, 'finished');
+ if (state.response) trace.push(`response:${state.response.kind}`);
+ if (state.decision?.kind === 'homeworldShipmentGuild') {
+ const d = state.decision; trace.push(`decision:${d.kind}`);
+ state = applyAction(state, d.player, { type: 'decision', event: d.event, allow: true });
+ continue;
+ }
+ const step = nextIndependentCardModulesStep(state);
+ assert.ok(step, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}`);
+ state = applyAction(state, step.actor, step.action);
+ }
+ throw Error('The native Homeworld marker programme did not reach its boundary.');
+}
+
+function setup(advanced = true) {
+ let game = createGame('RICHESEHOMEWORLDMARKER', newPlayer(richese, richese, 'richese'), advanced, ['choam']);
+ joinGame(game, newPlayer(opponent, opponent, 'atreides'));
+ joinGame(game, newPlayer('guild', 'guild', 'guild'));
+ game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
+ for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
+ // Use the original public start; Advanced explicitly acknowledges preview.
+ // No phase, resource, army or marker is assigned by the fixture.
+ game = applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ // Select one position in the original still-undealt deck, so Guild receives
+ // a real Karama at its starting-card deal. This makes both the cancel window
+ // and the Guild stop observable rather than automatically passed.
+ const karama = game.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karama >= 0 && game.deck.length >= 3);
+ [game.deck[2], game.deck[karama]] = [game.deck[karama], game.deck[2]];
+ game = advance(game, state => state.status === 'playing');
+ assert.ok(player(game, 'guild').hand.some(card => card.effect === 'karama'));
+ return advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
+ !!viewGame(state, richese).homeworldShipment && !!viewGame(state, richese).richeseNoField?.canShip);
+}
+
+function inventory(game: Game) {
+ const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
+ return Object.fromEntries(game.players.map(seat => [seat.id, seat.reserves + seat.tanks +
+ Object.values(seat.forces).reduce((sum, value) => sum + value, 0) + homes
+ .filter(home => home.native !== seat.id)
+ .reduce((sum, home) => sum + (home.forces[seat.id]?.normal ?? 0) + (home.forces[seat.id]?.elite ?? 0), 0)]));
+}
+
+for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} Richese ships its ${value} marker to an actual Homeworld battle, reveals and finishes`, () => {
+ let game = setup(advanced);
+ const initial = structuredClone(game), originalInventory = inventory(game);
+ const own = viewGame(game, richese), marker = own.richeseNoField!.private!.tokens.find(token => token.value === value);
+ assert.ok(marker); assert.equal(own.homeworldShipment!.blocked, null);
+ const declaration: Action = { type: 'homeworldShip', event: own.homeworldShipment!.event,
+ destination: world, sources: {}, noField: marker.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 };
+ game = applyAction(game, richese, declaration);
+ assert.equal(game.response?.kind, 'richeseNoField');
+ assert.equal(player(game, richese).spice, player(initial, richese).spice,
+ 'The pending Karama response cannot charge the shipper.');
+ assert.deepEqual(player(game, richese).noField, player(initial, richese).noField);
+ const trace: string[] = [];
+ game = advance(game, state => state.active === richese && state.phase === 5 && clean(state) &&
+ player(state, richese).shipped, trace);
+ assert.ok(trace.includes('response:richeseNoField'), 'The original Karama opportunity completed.');
+ assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced,
+ advanced ? 'The original Advanced Guild stop was allowed.' : 'Basic does not grant the Advanced Guild stop.');
+ assert.equal(player(game, richese).spice, player(initial, richese).spice - 1);
+ assert.equal(player(game, richese).reserves, player(initial, richese).reserves);
+ assert.deepEqual(inventory(game), originalInventory);
+ assert.deepEqual(player(game, richese).noField!.deployed!.location, { homeworld: world });
+ assert.equal(player(game, richese).noField!.lastShipped, marker.id);
+ const observer = viewGame(game, opponent).richeseNoField!;
+ assert.equal(observer.private, null);
+ assert.deepEqual(observer.public.deployed!.location, { homeworld: world });
+ assert.equal(JSON.stringify(observer.public).includes(marker.id), false, 'A face-down marker does not publish its identity.');
+
+ game = applyAction(game, richese, { type: 'endMovement' });
+ game = advance(game, state => state.phase === 6 && clean(state) && !state.battle && !!state.active &&
+ viewGame(state, state.active).battleChoices.some(choice => choice.territory === world));
+ const chooser = game.active!;
+ const choice = viewGame(game, chooser).battleChoices.find(candidate => candidate.territory === world)!;
+ const target = choice.attacker === chooser ? choice.defender : choice.attacker;
+ game = applyAction(game, chooser, { type: 'chooseBattle', territory: world, target });
+ game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
+ (!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
+ const event = game.battle!.event;
+ assert.ok(game.battle!.noFieldPlayers?.includes(richese));
+ for (const actor of [richese, opponent]) {
+ game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
+ (!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
+ const leader = player(game, actor).leaders.find(candidate => !candidate.dead &&
+ (!candidate.usedAt || candidate.usedAt === world));
+ assert.ok(leader);
+ game = applyAction(game, actor, { type: 'battlePlan', dial: 0, support: 0, allyPayment: 0,
+ leader: leader.id, weapon: null, defense: null });
+ if (actor === richese) assert.ok(player(game, richese).noField!.deployed, 'One seal does not reveal the marker.');
+ }
+ assert.equal(game.battle!.revealed, true);
+ assert.equal(player(game, richese).noField!.deployed, null);
+ assert.equal(player(game, richese).reserves, player(initial, richese).reserves - value);
+ assert.equal(game.homeworlds!.custody!.visitors[world]?.[richese]?.normal ?? 0, value);
+ assert.deepEqual(inventory(game), originalInventory);
+ game = advance(game, state => !state.battle && state.lastBattleContext?.event === event && clean(state));
+ assert.deepEqual(inventory(game), originalInventory);
+ });
+}
+
+void test('Karama cancels the proposed Homeworld marker without charging or spending the shipment', () => {
+ let game = setup();
+ const before = structuredClone(game), own = viewGame(game, richese);
+ const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 3)!;
+ const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
+ game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
+ destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
+ assert.equal(game.response?.kind, 'richeseNoField');
+ game = applyAction(game, 'guild', { type: 'card', mode: 'cancel', card: karama.id });
+ game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
+ assert.equal(player(game, richese).spice, player(before, richese).spice);
+ assert.equal(player(game, richese).reserves, player(before, richese).reserves);
+ assert.equal(player(game, richese).shipped, false);
+ assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
+ assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
+ assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
+ assert.deepEqual(inventory(game), inventory(before));
+ assert.equal(player(game, richese).noFieldBlockedTurn, game.turn);
+ assert.equal(viewGame(game, richese).richeseNoField!.canShip, false);
+ const option = viewGame(game, richese).homeworldShipment!;
+ game = applyAction(game, richese, { type: 'homeworldShip', event: option.event,
+ destination: world, sources: { 'homeworld:richese': { normal: 1, elite: 0 } }, allyPayment: 0 });
+ game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
+ player(state, richese).shipped && !state.pendingHomeworldShipment);
+ assert.equal(game.homeworlds!.custody!.visitors[world][richese].normal, 1,
+ 'The original physical shipment is still available after preventing the marker.');
+ assert.equal(player(game, richese).spice, player(before, richese).spice - 1);
+ assert.deepEqual(inventory(game), inventory(before));
+});
+
+void test('Advanced Guild stops the Homeworld marker before payment or arrival while using the shipment', () => {
+ let game = setup();
+ const before = structuredClone(game), own = viewGame(game, richese);
+ const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 5)!;
+ const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
+ game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
+ destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
+ game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
+ assert.equal(player(game, richese).spice, player(before, richese).spice);
+ assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
+ game = applyAction(game, 'guild', { type: 'card', mode: 'special', card: karama.id });
+ game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
+ assert.equal(player(game, richese).spice, player(before, richese).spice);
+ assert.equal(player(game, richese).reserves, player(before, richese).reserves);
+ assert.equal(player(game, richese).shipped, true);
+ assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
+ assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
+ assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
+ assert.deepEqual(inventory(game), inventory(before));
+});
diff --git a/tests/richese-homeworld-marker-shipment.test.ts b/tests/richese-homeworld-marker-shipment.test.ts
new file mode 100644
index 00000000..80533c95
--- /dev/null
+++ b/tests/richese-homeworld-marker-shipment.test.ts
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { HomeworldCustodyError, type HomeworldCustody } from '../game/homeworld-custody';
+import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
+import { createRicheseNoField, type RicheseNoField } from '../game/richese-no-field';
+import { quoteRicheseHomeworldMarkerShipment as quote,
+ type RicheseHomeworldMarkerShipmentIntent } from '../game/richese-homeworld-marker-shipment';
+
+function fixture(): { context: HomeworldShipmentContext; custody: HomeworldCustody; noField: RicheseNoField;
+ intent: RicheseHomeworldMarkerShipmentIntent } {
+ return {
+ context: { advanced: true, players: [
+ { id: 'r', faction: 'richese', reserves: 12, eliteReserves: 0, ally: null },
+ { id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null },
+ { id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null },
+ ] },
+ custody: { salusa: null, visitors: { 'homeworld:atreides': { r: { normal: 2, elite: 0 } } } },
+ noField: createRicheseNoField(['zero', 'three', 'five']),
+ intent: { player: 'r', destination: 'homeworld:atreides', tokenId: 'zero' },
+ };
+}
+
+void test('each concealed token ships as one marker at one spice without withdrawing a physical reserve', () => {
+ for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
+ const f = fixture(); f.context.advanced = advanced; f.intent.tokenId = tokenId;
+ const before = structuredClone(f);
+ const result = quote(f.context, f.custody, f.intent, f.noField);
+ assert.deepEqual({ player: result.player, tokenId: result.tokenId, source: result.source,
+ destination: result.destination, amount: result.amount, elite: result.elite, cost: result.cost },
+ { player: 'r', tokenId, source: 'homeworld:richese', destination: 'homeworld:atreides', amount: 1, elite: 0, cost: 1 });
+ assert.deepEqual(result.noField.deployed, { tokenId, controller: 'r', location: { homeworld: 'homeworld:atreides' } });
+ assert.equal(result.noField.lastShipped, tokenId);
+ assert.deepEqual(f, before);
+ result.noField.tokens[0].id = 'detached';
+ assert.deepEqual(f, before);
+ }
+});
+
+void test('rates and empty physical reserves do not alter the one-marker invoice', () => {
+ const f = fixture(); f.context.players[0].reserves = 0;
+ f.context.purchasedGuildRate = true; f.context.nativeGuildRateCanceled = true;
+ const result = quote(f.context, f.custody, f.intent, f.noField);
+ assert.equal(result.amount, 1); assert.equal(result.cost, 1);
+ assert.equal(f.context.players[0].reserves, 0);
+ assert.equal(f.custody.visitors['homeworld:atreides'].r.normal, 2);
+});
+
+void test('own, allied, inactive and forged destinations or actors reject without mutation', () => {
+ for (const change of [
+ (f: ReturnType) => { f.intent.player = 'g'; },
+ (f: ReturnType) => { f.intent.destination = 'homeworld:richese'; },
+ (f: ReturnType) => { f.intent.destination = 'homeworld:unknown'; },
+ (f: ReturnType) => { f.context.players[0].ally = 'a'; f.context.players[1].ally = 'r'; },
+ (f: ReturnType) => { (f.intent as unknown as Record).extra = 1; },
+ (f: ReturnType) => { f.context.players[0].ally = 'missing'; },
+ ]) {
+ const f = fixture(); change(f); const before = structuredClone(f);
+ assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
+ assert.deepEqual(f, before);
+ }
+});
+
+void test('invalid, deployed and consecutively used physical markers cannot be shipped', () => {
+ for (const change of [
+ (f: ReturnType) => { f.intent.tokenId = 'missing'; },
+ (f: ReturnType) => { f.noField.lastShipped = 'zero'; },
+ (f: ReturnType) => {
+ f.noField = quote(f.context, f.custody, f.intent, f.noField).noField;
+ f.intent.tokenId = 'three';
+ },
+ (f: ReturnType) => { f.noField.tokens[1].value = 5; },
+ ]) {
+ const f = fixture(); change(f); const before = structuredClone(f);
+ assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
+ assert.deepEqual(f, before);
+ }
+});
+
+void test('malformed physical Homeworld custody is rejected before marker deployment', () => {
+ const f = fixture(); f.custody.visitors['homeworld:unknown'] = { r: { normal: 1, elite: 0 } };
+ const before = structuredClone(f);
+ assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
+ assert.deepEqual(f, before);
+});
diff --git a/tests/richese-homeworld-marker-table.test.tsx b/tests/richese-homeworld-marker-table.test.tsx
new file mode 100644
index 00000000..2259898a
--- /dev/null
+++ b/tests/richese-homeworld-marker-table.test.tsx
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { GameView } from '../game/engine';
+import { NoFieldBoardMarkers } from '../components/richese-no-field';
+import { planetologistNoFieldMarker } from '../components/planetologist-movement-action';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image'
+ : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+const { HomeworldTable } = await import('../components/homeworld-table');
+aliases.deregister();
+
+function view(location: { homeworld: string } | { territory: string; sector: number }): GameView {
+ const publicMarker = { controller: 'r', location, effectiveForces: 1 as const };
+ const privateMarker = { tokenId: 'private-token', controller: 'r', location };
+ return {
+ me: 'r',
+ players: [
+ { id: 'r', name: 'Richese player', faction: 'richese', noField: { deployed: publicMarker, lastUsed: null } },
+ { id: 'e', name: 'Emperor player', faction: 'emperor' },
+ ],
+ richeseNoField: {
+ owner: 'r', event: 'marker-event',
+ public: { deployed: publicMarker, lastUsed: null },
+ private: { deployed: privateMarker, lastShipped: 'private-token',
+ tokens: [{ id: 'private-token', value: 5 }, { id: 'other', value: 3 }, { id: 'zero', value: 0 }] },
+ },
+ homeworlds: { worlds: [{ id: 'homeworld:emperor', card: 'kaitain', native: 'e',
+ side: 'high', population: 6, forces: { e: { normal: 6, elite: 0 } } }] },
+ } as unknown as GameView;
+}
+
+void test('a Homeworld marker appears separately from physical forces without its hidden value', () => {
+ const game = view({ homeworld: 'homeworld:emperor' });
+ const html = renderToStaticMarkup(createElement(HomeworldTable, { game }));
+ assert.match(html, /Richese player: concealed No-Field/);
+ assert.match(html, /counts as one force/);
+ assert.match(html, /Native reserves: 6 normal/);
+ assert.doesNotMatch(html, /private-token|No-Field 5/);
+ assert.equal(renderToStaticMarkup(createElement(NoFieldBoardMarkers, { players: game.players })), '');
+ assert.equal(planetologistNoFieldMarker(game), null);
+});
+
+void test('an Arrakis marker stays on the board and does not appear at a Homeworld', () => {
+ const game = view({ territory: 'arrakeen', sector: 10 });
+ const board = renderToStaticMarkup(createElement('svg', null,
+ createElement(NoFieldBoardMarkers, { players: game.players })));
+ assert.match(board, /concealed No-Field in Arrakeen, sector 10/);
+ const world = renderToStaticMarkup(createElement(HomeworldTable, { game }));
+ assert.doesNotMatch(world, /concealed No-Field/);
+});
diff --git a/tests/richese-no-field-bots.test.ts b/tests/richese-no-field-bots.test.ts
index d71a9606..7b01c51d 100644
--- a/tests/richese-no-field-bots.test.ts
+++ b/tests/richese-no-field-bots.test.ts
@@ -141,6 +141,7 @@ void test('all profiles move the actual marker explicitly, with no fabricated ph
assert.deepEqual(moved.players[0].forces, {});
assert.equal(moved.players[0].reserves, 20);
assert.equal(moved.players[0].moved, 1);
+ assert.ok('territory' in moved.players[0].noField!.deployed!.location);
assert.equal(
moved.players[0].noField!.deployed!.location.territory,
action.territory,
diff --git a/tests/richese-no-field-recovery.test.ts b/tests/richese-no-field-recovery.test.ts
index 0a294a86..ee6a16d9 100644
--- a/tests/richese-no-field-recovery.test.ts
+++ b/tests/richese-no-field-recovery.test.ts
@@ -334,6 +334,7 @@ void test('marker movement and concurrent revelation persist once, reject stale
await f.action(f.owner, move, resumed);
const moved = await resumed.readRoom(f.code);
assert.notEqual(moved.players[0].noFieldEvent, oldEvent);
+ assert.ok('territory' in moved.players[0].noField!.deployed!.location);
assert.equal(
moved.players[0].noField!.deployed!.location.territory,
'arrakeen',
diff --git a/tests/richese-no-field-review.test.ts b/tests/richese-no-field-review.test.ts
index cdfb8c9b..237af89f 100644
--- a/tests/richese-no-field-review.test.ts
+++ b/tests/richese-no-field-review.test.ts
@@ -234,6 +234,7 @@ void test('storm only affects the hit sector and preserves a marker in another s
g = applyAction(g, 'a', { type: 'ready' });
assert.equal(g.players[0].tanks, 2);
assert.equal(g.players[0].reserves, 18);
+ assert.ok('territory' in g.players[0].noField!.deployed!.location);
assert.equal(g.players[0].noField!.deployed!.location.sector, safe);
let sheltered = marker(fixture(), 3, 'imperial_basin', 10);
sheltered.phase = 0;
@@ -332,6 +333,7 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
sector: 10,
});
assert.equal(moving.decision?.kind, 'choamMovement');
+ assert.ok('territory' in moving.players[0].noField!.deployed!.location);
assert.equal(
moving.players[0].noField!.deployed!.location.territory,
'imperial_basin',
@@ -341,6 +343,7 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
const result = applyAction(moving, 'a', { type: 'decision', decline: true });
assert.equal(result.pendingChoamMove, null);
assert.equal(result.decision, null);
+ assert.ok('territory' in result.players[0].noField!.deployed!.location);
assert.equal(
result.players[0].noField!.deployed!.location.territory,
'imperial_basin',
diff --git a/tests/richese-no-field.test.ts b/tests/richese-no-field.test.ts
index 9ef237d6..0befa7b1 100644
--- a/tests/richese-no-field.test.ts
+++ b/tests/richese-no-field.test.ts
@@ -84,6 +84,7 @@ void test('deserialized inventory, history and deployed references are validated
s.deployed!.controller = '';
},
(s: RicheseNoField) => {
+ assert.ok('territory' in s.deployed!.location);
s.deployed!.location.sector = 19;
},
(s: RicheseNoField) => {
@@ -164,6 +165,8 @@ void test('moving preserves hidden identity/history and exact sector without mat
territory: 'imperial_basin',
sector: 9,
});
+ assert.ok('territory' in state.deployed!.location);
+ assert.ok('territory' in moved.deployed!.location);
assert.equal(state.deployed!.location.sector, 10);
assert.equal(moved.deployed!.location.sector, 9);
assert.equal(moved.lastShipped, ids[2]);
@@ -473,13 +476,16 @@ void test('outputs and projections are independent clones and caller-provided pl
};
const state = deployRicheseNoField(initial, placement);
placement.location.sector = 2;
+ assert.ok('territory' in state.deployed!.location);
assert.equal(state.deployed!.location.sector, 10);
const own = projectRicheseNoField(state, true);
own.private.tokens[0].id = 'changed';
+ assert.ok('territory' in own.deployed!.location);
own.deployed!.location.sector = 3;
assert.equal(state.tokens[0].id, ids[0]);
assert.equal(state.deployed!.location.sector, 10);
const result = reveal(state);
+ assert.ok('territory' in result.location);
result.location.sector = 4;
result.state.tokens.reverse();
assert.equal(state.deployed!.location.sector, 10);
diff --git a/tests/truth-movement-no-field-engine.test.ts b/tests/truth-movement-no-field-engine.test.ts
index 41c4f4ae..b7057bcc 100644
--- a/tests/truth-movement-no-field-engine.test.ts
+++ b/tests/truth-movement-no-field-engine.test.ts
@@ -102,7 +102,9 @@ void test('a mixed physical-plus-marker move fulfills only its physical quantity
noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).forces['arrakeen:10'], 3);
- assert.equal(player(g, f.target).noField?.deployed?.location.territory, 'arrakeen');
+ const marker = player(g, f.target).noField?.deployed?.location;
+ assert.ok(marker && 'territory' in marker);
+ assert.equal(marker.territory, 'arrakeen');
});
void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => {