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
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user