Prototype Richese No-Fields on foreign Homeworlds
This commit is contained in:
1 parent
dd3c199e2e
commit
850227c26d
71 files changed
+1806
-187
No files matched your search
+17
-14
@@ -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({
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
{g.players
|
||||
.filter(
|
||||
(p) => p.noField?.deployed?.location.territory === selected,
|
||||
)
|
||||
.map((p) => (
|
||||
<span
|
||||
key={`no-field-detail-${p.id}`}
|
||||
style={{ color: faction(p.faction).color }}
|
||||
>
|
||||
{faction(p.faction).name}: concealed No-Field in sector{' '}
|
||||
{p.noField!.deployed!.location.sector} · counts as one force
|
||||
</span>
|
||||
))}
|
||||
{g.players.flatMap((p) => {
|
||||
const marker = p.noField?.deployed;
|
||||
if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected)
|
||||
return [];
|
||||
return [<span
|
||||
key={`no-field-detail-${p.id}`}
|
||||
style={{ color: faction(p.faction).color }}
|
||||
>
|
||||
{faction(p.faction).name}: concealed No-Field in sector{' '}
|
||||
{marker.location.sector} · counts as one force
|
||||
</span>];
|
||||
})}
|
||||
</div>
|
||||
{g.status !== 'lobby' && (
|
||||
<section
|
||||
|
||||
@@ -6,11 +6,66 @@ import { specialForceName } from '@/game/combat';
|
||||
import {
|
||||
homeworldName,
|
||||
homeworldShipmentChoice,
|
||||
richeseHomeworldMarkerChoice,
|
||||
type HomeworldShipmentSources,
|
||||
} from '@/game/homeworld-shipment-options';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
/** This private control is separate from physical-source shipment: the marker
|
||||
* costs one but does not withdraw a reserve counter until it is revealed. */
|
||||
export function HomeworldNoFieldShipment({ game, act, busy }: {
|
||||
game: GameView; act: (action: Action) => 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 <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
|
||||
className="space-y-3 pt-3">
|
||||
<legend>Ship a concealed No-Field</legend>
|
||||
<p className="fine">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.</p>
|
||||
<label className="block space-y-1">Destination Homeworld for No-Field
|
||||
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
|
||||
onChange={event => setSelectedDestination(event.target.value)}>
|
||||
{!targets.length && <option value="">No eligible destination</option>}
|
||||
{targets.map(world => <option key={world.id} value={world.id}>
|
||||
{homeworldName(game, world.id)} · {game.players.find(player => player.id === world.native)?.name}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">Private No-Field token
|
||||
<select aria-label="Private No-Field token" value={token?.id ?? ''}
|
||||
onChange={event => setSelectedToken(event.target.value)}>
|
||||
{!tokens.length && <option value="">No different token available</option>}
|
||||
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{me.ally && <label className="block space-y-1">Allied spice contribution · {game.aid.available} pledged
|
||||
<Input type="number" min={0} max={Math.min(1, game.aid.available)} step={1} value={allyPayment}
|
||||
onChange={event => setAllyPayment(Number(event.target.value))} />
|
||||
</label>}
|
||||
<output className="notice block" aria-live="polite">
|
||||
One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
|
||||
{quote.blocked && ` ${quote.blocked}`}
|
||||
</output>
|
||||
<Button className="game-action" disabled={busy || !!me.autopilot || !quote.action}
|
||||
onClick={() => { if (quote.action) act(quote.action); }}>
|
||||
Ship No-Field to {homeworldName(game, destination?.id ?? '')}
|
||||
</Button>
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
export function HomeworldShipment({
|
||||
game: g,
|
||||
act,
|
||||
@@ -265,6 +320,7 @@ export function HomeworldShipment({
|
||||
Ship to {homeworldName(g, destination)}
|
||||
</Button>
|
||||
</fieldset>
|
||||
<HomeworldNoFieldShipment game={g} act={act} busy={busy} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
: ''}
|
||||
</p>
|
||||
))}
|
||||
{g.players.flatMap(player => {
|
||||
const marker = player.noField?.deployed;
|
||||
if (!marker || !isHomeworldNoFieldLocation(marker.location) || marker.location.homeworld !== world.id)
|
||||
return [];
|
||||
return [<p key={`no-field-${player.id}`} className="text-base! leading-7!">
|
||||
{player.name}: concealed No-Field · counts as one force
|
||||
</p>];
|
||||
})}
|
||||
<details>
|
||||
<summary className="cursor-pointer py-2">
|
||||
{card.name} card
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
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);
|
||||
|
||||
@@ -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 : '',
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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({
|
||||
<strong>No-Field {token.value}</strong>
|
||||
<span className="block text-sm">
|
||||
{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({
|
||||
</ul>
|
||||
{deployed && (
|
||||
<>
|
||||
<p>
|
||||
{isBoardNoFieldLocation(deployed.location) ? <p>
|
||||
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.
|
||||
</p>
|
||||
</p> : <p>
|
||||
Concealed at {deployedHomeworldName}.
|
||||
The marker counts as one force until it is revealed.
|
||||
</p>}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !info.canReveal}
|
||||
|
||||
@@ -16,6 +16,21 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Concealed Richese No-Field on a foreign Homeworld — 28 October.
|
||||
Prototyped / Partial.** In native Homeworld games, Richese may ship its own
|
||||
face-down marker to a foreign non-allied world. It counts as one public marker
|
||||
without becoming a physical counter until reveal, when actual reserves move
|
||||
into visitor custody. Native payment, Karama and Advanced Guild interception
|
||||
remain in place. Forty related files pass 301 cases, including real Basic and
|
||||
Advanced lobby starts, all three values, battle, cancellation, Guild stop and
|
||||
the marker-only alliance restriction. One isolated authenticated HTTP case
|
||||
also exercises real Basic shipment and the other player's concealed-token
|
||||
view. The undealt Karama placement is selected to exercise responses;
|
||||
resources and forces are not staged. This off-planet
|
||||
composition is provisional; own-world placement, multiple simultaneous markers,
|
||||
optional modules, full mode and deployed acceptance remain open.
|
||||
[Scope](RICHESE_NO_FIELD_RULES.md#foreign-homeworld-marker--28-october-2026).
|
||||
|
||||
**Public-deal deadline at turn rollover — 28 October. Prototyped / Partial.**
|
||||
When the mounted form observes a later turn, a past whole-number deadline
|
||||
advances to the current turn. A future deadline and incomplete edits stay in
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
|
||||
|
||||
## Foreign Homeworld marker — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In supported native Homeworld games without optional
|
||||
modules, Richese may use its own shipment to place a face-down No-Field on a
|
||||
foreign non-allied Homeworld. It costs the one-force Homeworld price and uses
|
||||
the original Karama response, Advanced Guild stop and payment continuation.
|
||||
There is one deployed physical marker globally; a selected marker cannot be
|
||||
the last shipped. The marker's controller and location are public, its value
|
||||
and identity private. No reserve counter leaves until revelation. Occupation
|
||||
history records marker presence separately from typed physical forces and
|
||||
native population.
|
||||
|
||||
Voluntary or battle revelation draws up to the printed value from actual
|
||||
Richese reserves into the foreign Homeworld's visitor custody. Zero remains
|
||||
a meaningful concealed presence until reveal. Basic and Advanced native lobby
|
||||
programmes cover each printed value, real battle continuation, Karama
|
||||
cancellation and the Advanced Guild stop. The owned source setup selects an
|
||||
undealt Karama but does not stage resources, phases or forces. An isolated
|
||||
authenticated Basic room also shipped the marker and confirmed the
|
||||
other player's view contains the location but not the private token IDs.
|
||||
Own-Homeworld
|
||||
placement, multiple markers, optional-module combinations, complete mode,
|
||||
browser and deployed acceptance remain open.
|
||||
|
||||
The supplied Advanced revision distinguishes Homeworlds from territories
|
||||
(physical p. 21) and describes No-Field materialization in a territory
|
||||
(physical p. 36). It does not explicitly resolve this off-planet composition.
|
||||
The single-marker and foreign-Homeworld policy is therefore a provisional
|
||||
online interpretation, not a publisher clarification. Existing on-Arrakis
|
||||
records keep their original location shape.
|
||||
|
||||
## Free entry into a revealed Discovery location — 27 October 2026
|
||||
|
||||
E3 printed12 and supplied Advanced physical23/printed21 grant a separate move
|
||||
@@ -432,7 +463,7 @@ This table records the original adapter contract. The implemented subset and cur
|
||||
1. **Reserve shortage when destroyed.** Voluntary/battle text explicitly caps by reserves; the destruction sentence says to lose the indicated quantity without repeating the shortage clause. The pure model caps materialization to avoid creating physical units, but that is conservation-based composition for storm/worm losses, not an independently answered FAQ. It does not select substitute losses from unrelated board forces.
|
||||
2. **Mixed forces and battle dials.** The FAQ forbids dialing more than reserves in a No-Field battle. Existing ordinary counters are possible, but no retrieved specific answer defines the cap across those counters, multiple sectors, typed units or independent reserve uses. Basic/Advanced ordinary and the selected Advanced Occupy prototype use the explicitly provisional additive pool above. This does not settle that source question or admit Basic mixed coalition/module profiles.
|
||||
3. **Full-plan special Karama and structured Truthtrance.** The current prototype distinguishes supplied p27 F's special entire-plan power from p27 C/p36 B ordinary Prescience under the explicit provisional contract above; private reading does not reveal the physical token. No retrieved dedicated combined FAQ adjudicates that distinction or broader promise questions. Structured Truthtrance, public maxima, other-player legal previews and reasoning retain their original entitled boundaries.
|
||||
4. **Homeworld use.** R3 recommends Richese in a Homeworld combination but supplies no retrieved No-Field-specific rule defining off-planet hidden custody, one-on-planet versus off-planet coexistence, or homeworld threshold disclosure. General module availability is not an answer to those lifecycle questions. The isolated model represents a single Arrakis marker and does not authorize Homeworld placement.
|
||||
4. **Homeworld use.** R3 recommends Richese in a Homeworld combination but supplies no retrieved No-Field-specific rule defining off-planet hidden custody, one-on-planet versus off-planet coexistence, or homeworld threshold disclosure. General module availability is not an answer to those lifecycle questions. The later provisional foreign-Homeworld prototype above supersedes the earlier Arrakis-only model; broader placement and coexistence remain unresolved.
|
||||
5. **Capturing, transferring or returning a concealed marker as a force.** The selected Guild Secret Ally route above now provisionally recalls the marker face down while preserving last-use history. It does not establish a general conversion for every card, ordinary Guild ally return, foreign custody or Homeworld placement. Other return causes retain their own recorded contracts.
|
||||
6. **Allied discounts and reserve source.** Ordinary consent, typed allocation and funding are integrated. Guild-recipient and already-active Karama price/payee use the explicitly provisional prototype in both modes. Ordinary Fremen reserves are on-planet and remain ineligible for this off-planet method. See `RICHESE_ALLIED_SHIPMENT_AUDIT.md`.
|
||||
7. **Supply orientation.** Concealed denomination is explicit. Hiding unused IDs/values in public JSON is the minimum privacy consequence, not a claim about an otherwise undocumented physical supply layout. The last revealed used face is public until another deployment; historical log knowledge may legitimately remain.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Richese No-Field on a foreign Homeworld — 28 October 2026
|
||||
|
||||
The user-authorized Advanced revision calls Homeworlds non-territories
|
||||
(physical p. 21) and describes No-Field materialization in a territory
|
||||
(physical p. 36), without resolving their combination. Provisionally permit
|
||||
one native face-down marker globally, shipped to a foreign non-allied active
|
||||
Homeworld; count its public presence separately and materialize real reserves
|
||||
there on reveal. Own-world and optional-module compositions remain excluded.
|
||||
[Scope](RICHESE_NO_FIELD_RULES.md#foreign-homeworld-marker--28-october-2026).
|
||||
|
||||
## Recovered Sapho after a suspended Movement turn — 28 October 2026
|
||||
|
||||
The holder's fresh physical Sapho may provisionally replace its own protected
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import type { StableHomeworldOccupationContext } from './homeworld-stable-occupation';
|
||||
import { liveHomeworldOccupationPresence, type StableHomeworldOccupationContext } from './homeworld-stable-occupation';
|
||||
import { homeworldOccupationSnapshotPresence, readHomeworldOccupationSnapshot } from './homeworld-occupation-history';
|
||||
|
||||
export type BasicTupileActivation = {
|
||||
event: string;
|
||||
@@ -25,11 +26,11 @@ export function basicTupileActivations(game: StableHomeworldOccupationContext):
|
||||
throw new HomeworldCustodyError('Basic Tupile activation requires its original Basic occupation history.');
|
||||
const high = HOMEWORLD_CARDS.find(card => 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<string, string[]>();
|
||||
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 };
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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<string>();
|
||||
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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
+12
-8
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+155
-43
@@ -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<SpecialKaramaIntent, { kind: 'richese' }> }
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-2
@@ -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<Record<string, number>>;
|
||||
elites?: { reserves: number; tanks: number; forces: Readonly<Record<string, number>> };
|
||||
advisors?: Record<string, { lockedTurn?: number }>;
|
||||
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;
|
||||
|
||||
@@ -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<Record<string, number>>;
|
||||
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<string, number> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Source, 'signature'>,
|
||||
@@ -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<string>(),
|
||||
used = new Set<number>(),
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, OccupiedTupile
|
||||
}
|
||||
let retained: string | null = null;
|
||||
for (const observation of history.sources) {
|
||||
const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, [string, number, number][]][];
|
||||
const row = snapshot.find(([world]) => 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,
|
||||
|
||||
@@ -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<string, HomeworldForces>;
|
||||
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;
|
||||
}
|
||||
@@ -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<Game,
|
||||
'advanced' | 'turn' | 'players' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'>;
|
||||
@@ -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<typeof homeworldForceGroups>[number];
|
||||
type History = NonNullable<Game['homeworldOccupationHistory']>;
|
||||
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)
|
||||
|
||||
@@ -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<Player, 'id' | 'faction' | 'advisors'> & {
|
||||
@@ -8,7 +9,7 @@ export type MobileCollectionPlayer = Pick<Player, 'id' | 'faction' | 'advisors'>
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -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 ?? {})])) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, Record<string, EcazBetr
|
||||
* constructing offers, even for a zero marker or a controller with no reserves. */
|
||||
function markerSupplier(g: Game, ally: string, territory: string): Player | undefined {
|
||||
const suppliers = g.players.filter(p => 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<string, Record<string, EcazBe
|
||||
const result = locations(g, ally);
|
||||
for (const supplier of g.players) {
|
||||
const marker = supplier.noField?.deployed;
|
||||
if (!marker || marker.controller !== ally.id) continue;
|
||||
if (!marker || marker.controller !== ally.id || !isBoardNoFieldLocation(marker.location)) continue;
|
||||
if (!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
|
||||
throw new Error('Ecaz Betrayal has invalid concealed board custody.');
|
||||
if (!result.has(marker.location.territory)) result.set(marker.location.territory, {});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Action, GameView } from './engine';
|
||||
import { gameTerritories, MOBILE_STRONGHOLD, splitLocation, validGameLocation } from './board';
|
||||
import { quoteNexusGuildSecretShipment } from './nexus-guild-secret-ally';
|
||||
import { guildNoFieldSelection } from './guild-no-field-selection';
|
||||
import { isBoardNoFieldLocation } from './richese-no-field';
|
||||
|
||||
/** Consume only the current actor's private offer and public faction roster. */
|
||||
export function nexusGuildSecretAllyCanAct(g: GameView): boolean {
|
||||
@@ -84,7 +85,7 @@ export function nexusGuildSecretAllyAction(
|
||||
let amount: number;
|
||||
if (noField) {
|
||||
const marker = guildNoFieldSelection(g, action);
|
||||
if (!marker || !action.forces || typeof action.forces !== 'object' || Array.isArray(action.forces) ||
|
||||
if (!marker || !isBoardNoFieldLocation(marker.location) || !action.forces || typeof action.forces !== 'object' || Array.isArray(action.forces) ||
|
||||
!validGameLocation(g, marker.location.territory, marker.location.sector) || marker.location.sector === g.storm)
|
||||
return null;
|
||||
let physical = 0;
|
||||
|
||||
+2
-1
@@ -1122,7 +1122,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
|
||||
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
|
||||
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return, and marker Homeworld placement remains unavailable. Select the marker separately from physical counters; ordinary movement remains available.',
|
||||
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
|
||||
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld for the one-force price. It counts as one public marker without changing physical reserves until reveal, when its printed value is placed there up to available reserves. Only one marker may be deployed globally. Own-Homeworld placement and optional-module combinations remain unavailable.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
|
||||
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
|
||||
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
homeworldCombatLocation,
|
||||
quoteHomeworldBattles,
|
||||
type HomeworldCombatContext,
|
||||
type HomeworldCombatPair,
|
||||
} from './homeworld-combat';
|
||||
import { homeworldForceGroups, type HomeworldCustody } from './homeworld-custody';
|
||||
|
||||
/** Only public marker presence enters this helper. The physical identity and
|
||||
* printed value are not needed to identify a battle. */
|
||||
export type PublicHomeworldNoField = {
|
||||
controller: string;
|
||||
location: { homeworld: string };
|
||||
};
|
||||
|
||||
export type HomeworldNoFieldPresence = {
|
||||
homeworld: string;
|
||||
controller: string;
|
||||
effectiveForces: 1;
|
||||
};
|
||||
|
||||
/** This is public presence, not a deposit into physical Homeworld custody.
|
||||
* The engine retains ownership of entry, reveal, reserve withdrawal and loss. */
|
||||
export function homeworldNoFieldPresence(
|
||||
context: HomeworldCombatContext,
|
||||
custody: HomeworldCustody,
|
||||
marker: PublicHomeworldNoField | null | undefined,
|
||||
): HomeworldNoFieldPresence | null {
|
||||
if (marker == null) return null;
|
||||
if (!marker || typeof marker !== 'object' || typeof marker.controller !== 'string' ||
|
||||
!marker.location || typeof marker.location !== 'object' ||
|
||||
typeof marker.location.homeworld !== 'string')
|
||||
throw new Error('Choose a valid public Homeworld No-Field location and controller.');
|
||||
const owner = context.players.find(player => 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)!,
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> =>
|
||||
!!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,
|
||||
};
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string,number>={};
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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!;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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<GameView> {
|
||||
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.');
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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<typeof fixture>) => { f.intent.player = 'g'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'a'; f.context.players[1].ally = 'r'; },
|
||||
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = 1; },
|
||||
(f: ReturnType<typeof fixture>) => { 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<typeof fixture>) => { f.intent.tokenId = 'missing'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.noField.lastShipped = 'zero'; },
|
||||
(f: ReturnType<typeof fixture>) => {
|
||||
f.noField = quote(f.context, f.custody, f.intent, f.noField).noField;
|
||||
f.intent.tokenId = 'three';
|
||||
},
|
||||
(f: ReturnType<typeof fixture>) => { 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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user