Support concealed Homeworld transfers and Ecaz victory

This commit is contained in:
Quintine committed 2026-10-28 20:13:31 +11:00
1 parent 850227c26d
commit 1f03ae673b
14 files changed
+712 -43

No files matched your search

+22 -13
View File
@@ -6,6 +6,7 @@ import { homeworldForceGroups } from './homeworld-custody';
import { homeworldPopulations } from './homeworld-population';
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
import {
liveHomeworldOccupationPresence,
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
} from './homeworld-stable-occupation';
@@ -40,8 +41,9 @@ export type EcazHomeworldVictoryProgress = {
/** Printed High Ecaz victory, separate from ordinary points and occupied Duke
* benefits. Foreign holdings require the existing original source authority AND
* the entitled holder's current physical presence; a departed garrison is never
* a victory holding. Advanced p.22 retained occupation is otherwise respected.
* the entitled holder's current public presence; a concealed No-Field counts
* as one, while a departed garrison is never a victory holding. Advanced p.22
* retained occupation is otherwise respected.
* This returns public facts only, never an income receipt or a winning action. */
export function ecazHomeworldVictoryProgress(
g: EcazHomeworldVictoryContext,
@@ -98,11 +100,21 @@ export function ecazHomeworldVictoryProgress(
advanced: g.advanced, turn: g.turn, homeworlds: state,
homeworldOccupationPreview: g.homeworldOccupationPreview,
homeworldOccupationHistory: g.homeworldOccupationHistory,
players: g.players.map(player => ({
id: player.id, faction: player.faction, ally: player.ally,
reserves: player.reserves,
elites: player.elites ? { reserves: player.elites.reserves } : undefined,
})),
players: g.players.map(player => {
const marker = player.noField?.deployed;
// BoardSeat exposes location but older callers need not include the
// controller. A native marker belongs to its player; when a public
// controller is supplied, retain it for the existing validation.
const controller = marker && 'controller' in marker ? marker.controller : player.id;
if (marker && typeof controller !== 'string')
throw new VictoryProgressError('A public No-Field needs a valid controller.');
return {
id: player.id, faction: player.faction, ally: player.ally,
reserves: player.reserves,
elites: player.elites ? { reserves: player.elites.reserves } : undefined,
...(marker ? { noField: { deployed: { controller, location: { ...marker.location } } } } : {}),
};
}),
} as StableHomeworldOccupationContext;
for (const world of worlds) {
// Ecaz and its ally's own native worlds are not "other factions".
@@ -114,10 +126,8 @@ export function ecazHomeworldVictoryProgress(
const quote = originalHistory
? quoteStableHomeworldOccupation(publicOccupation, card.id)
: { entitlement: null, blocked: 'Ecaz Homeworld holdings require the original fresh occupation history.' };
const alliancePresent = members.some(member => {
const forces = world.forces[member];
return forces && forces.normal + forces.elite > 0;
});
const present = liveHomeworldOccupationPresence(publicOccupation, world.id);
const alliancePresent = members.some(member => present.includes(member));
if (quote.blocked) {
// Pending facts can block a possible win, but cannot manufacture a
// holding; unrelated occupiers and empty departed worlds cannot block it.
@@ -129,8 +139,7 @@ export function ecazHomeworldVictoryProgress(
}
const holding = quote.entitlement;
if (!holding || !members.includes(holding.occupier)) continue;
const present = world.forces[holding.occupier];
if (!present || present.normal + present.elite === 0) continue;
if (!present.includes(holding.occupier)) continue;
foreignHomeworlds.push({
world: world.id, card: card.id, native: world.native,
faction, holder: holding.occupier,
+21 -14
View File
@@ -547,7 +547,7 @@ import {
type RicheseGiftIntent,
} from './richese-gift';
import { richeseCards, richeseCardDefinition } from './richese-cards';
import { quoteRicheseHomeworldMarkerShipment } from './richese-homeworld-marker-shipment';
import { quoteRicheseHomeworldMarkerShipment, quoteRicheseHomeworldMarkerTransfer } from './richese-homeworld-marker-shipment';
import {
createRicheseNoField,
deployRicheseNoField,
@@ -1330,7 +1330,7 @@ type RichesePreCostDeclaration = { round: string; lot: string } & (
);
type PendingHomeworldShipment = HomeworldShipmentIntent & {
/** The selected physical marker and its original private inventory event. */
noField?: { tokenId: string; event: string };
noField?: { tokenId: string; event: string; from?: string };
guildSecretEvent?: string;
guildNexusEvent?: string;
guildRateEvent?: string;
@@ -37121,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;noField?:{tokenId:string;event:string}}) {
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string;from?: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.');
@@ -37141,10 +37141,14 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
'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,
p.noField && p.noFieldEvent === intent.noField.event &&
(intent.noField.from !== undefined || 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!));
const marker = homeworldRule(() => intent.noField!.from === undefined
? quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)
: quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!},p.noField!));
return {...marker, state:g.homeworlds.custody, players:context.players, sources:[],
boardForces:undefined,boardEliteForces:undefined,sourceNames:[combatLocationName(g,marker.source)]};
}
@@ -37270,13 +37274,13 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
{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;noField?:{tokenId:string;event:string}}, allyPayment?: unknown) {
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string;from?: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) {
if (shipment.noField && shipment.noField.from === undefined) {
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.`);
@@ -44494,18 +44498,21 @@ function applyActionInner(
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'] : []), ...(action.noField !== undefined ? ['noField','noFieldEvent'] : [])].includes(key)),
...(guildSecretEvent ? ['nexus'] : []), ...(action.noField !== undefined ? ['noField','noFieldEvent','noFieldFrom'] : [])].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;
let noField: {tokenId:string;event:string;from?: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.');
if (action.noFieldFrom === undefined) {
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.');
noField = {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent),
...(action.noFieldFrom === undefined ? {} : {from:stringField(action.noFieldFrom)})};
} else requireRule(action.noFieldEvent === undefined && action.noFieldFrom === 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} : {}), ...(noField ? {noField} : {})}, action.allyPayment);
+14 -8
View File
@@ -11,7 +11,8 @@ import {
type HomeworldForces,
} from './homeworld-custody';
import { quoteHomeworldShipment } from './homeworld-shipment';
import { quoteRicheseHomeworldMarkerShipment } from './richese-homeworld-marker-shipment';
import { quoteRicheseHomeworldMarkerShipment, quoteRicheseHomeworldMarkerTransfer } from './richese-homeworld-marker-shipment';
import { isHomeworldNoFieldLocation } from './richese-no-field';
export type HomeworldShipmentSources = Record<string, HomeworldForces>;
export function homeworldName(view: GameView, id: string): string {
@@ -29,6 +30,7 @@ export function richeseHomeworldMarkerChoice(
destination: string,
tokenId: string,
allyPayment = 0,
source?: string,
) {
const result: { blocked: string | null; amount: number; cost: number;
ownPayment: number; allyPayment: number; action: Action | null } = {
@@ -48,7 +50,7 @@ export function richeseHomeworldMarkerChoice(
result.blocked = 'Use your available Richese No-Field during your shipment turn.';
return result;
}
if (option.blocked || !info.canShip) {
if (option.blocked || (source === undefined && !info.canShip)) {
result.blocked = option.blocked ?? info.shipBlock ?? 'No-Field shipment is not available now.';
return result;
}
@@ -76,10 +78,12 @@ export function richeseHomeworldMarkerChoice(
visitors.map(([owner, forces]) => [owner, { ...forces }]));
}
try {
quoteRicheseHomeworldMarkerShipment(context, custody,
{ player: me.id, destination, tokenId }, info.private);
if (source === undefined)
quoteRicheseHomeworldMarkerShipment(context, custody, { player: me.id, destination, tokenId }, info.private);
else
quoteRicheseHomeworldMarkerTransfer(context, custody, { player: me.id, destination, tokenId, source }, info.private);
result.action = { type: 'homeworldShip', event: option.event, destination, sources: {},
noField: tokenId, noFieldEvent: info.event, allyPayment };
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
} catch (error) {
if (!(error instanceof HomeworldCustodyError)) throw error;
result.blocked = error.message;
@@ -387,11 +391,13 @@ export function homeworldShipmentActions(
// 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);
const privateState = view.richeseNoField.private, deployed = privateState.deployed;
const source = deployed && isHomeworldNoFieldLocation(deployed.location) ? deployed.location.homeworld : undefined;
const token = source ? privateState.tokens.find(candidate => candidate.id === deployed?.tokenId)
: privateState.tokens.find(candidate => candidate.id !== privateState.lastShipped);
if (token) for (const target of targets) {
const quote = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
Math.max(0, 1 - (me.spice ?? 0)));
Math.max(0, 1 - (me.spice ?? 0)), source);
if (quote.action) { actions.push(quote.action); break; }
}
}
+1 -1
View File
@@ -1123,7 +1123,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. 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.',
'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. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, retaining its identity and last-shipment history. 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.',
+41 -1
View File
@@ -2,13 +2,14 @@ import {
HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody,
} from './homeworld-custody';
import type { HomeworldShipmentContext } from './homeworld-shipment';
import { deployRicheseNoField, type RicheseNoField } from './richese-no-field';
import { deployRicheseNoField, isHomeworldNoFieldLocation, moveRicheseNoField, type RicheseNoField } from './richese-no-field';
export type RicheseHomeworldMarkerShipmentIntent = {
player: string;
destination: string;
tokenId: string;
};
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & { source: string };
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
@@ -66,3 +67,42 @@ export function quoteRicheseHomeworldMarkerShipment(
noField: deployed,
};
}
/** A deployed marker can be transported like one force between eligible
* Homeworlds. Its denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerTransfer(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
intent: RicheseHomeworldMarkerTransferIntent,
noField: RicheseNoField,
) {
const worlds = homeworldForceGroups(context, custody);
requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,source,tokenId' &&
typeof intent.player === 'string' && typeof intent.source === 'string' &&
typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
'Choose the current Homeworld marker, its source and another Homeworld.');
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 === intent.source);
const destination = worlds.find(world => world.id === intent.destination);
requireMarker(player?.faction === 'richese' && source && destination &&
source.native !== player.id && destination.native !== player.id && source.id !== destination.id,
'Choose two different active foreign Homeworlds for native Richese.');
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.');
const marker = noField?.deployed;
requireMarker(marker?.controller === player.id && marker.tokenId === intent.tokenId &&
isHomeworldNoFieldLocation(marker.location) && marker.location.homeworld === source.id,
'The chosen concealed marker is no longer on its original Homeworld.');
let moved: RicheseNoField;
try {
moved = moveRicheseNoField(noField, intent.tokenId, { homeworld: destination.id });
} catch (error) {
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field transfer.');
}
return { player: player.id, tokenId: intent.tokenId, source: source.id,
destination: destination.id, amount: 1, elite: 0, cost: 1, noField: moved };
}