Support mixed No-Field Homeworld transfers

This commit is contained in:
Quintine committed 2026-10-28 20:22:37 +11:00
1 parent 1f03ae673b
commit d0e4c177e4
12 files changed
+695 -38

No files matched your search

+14 -9
View File
@@ -37140,16 +37140,21 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
!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 &&
!Array.isArray(intent.sources) &&
(intent.noField.from === undefined ? Object.keys(intent.sources).length === 0 :
Object.keys(intent.sources).every(source => source === intent.noField!.from)) &&
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(() => 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:[],
const marker = intent.noField.from === undefined
? {...homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)),
state:g.homeworlds.custody, players:context.players, sources:[]}
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!,
...(Object.hasOwn(intent.sources, intent.noField!.from!) ? {forces:intent.sources[intent.noField!.from!]} : {})},p.noField!));
return {...marker, state:marker.state ?? g.homeworlds.custody,
players:marker.players ?? context.players, sources:marker.sources ?? [],
boardForces:undefined,boardEliteForces:undefined,sourceNames:[combatLocationName(g,marker.source)]};
}
const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
@@ -37270,7 +37275,7 @@ 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 ${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.' : ''}`,
log(g, `${p.name} shipped ${shipment.noField ? `one concealed No-Field${quote.amount > 1 ? ` and ${quote.amount - 1} physical forces` : ''}` : `${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);
}
@@ -37291,7 +37296,7 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
g.pendingHomeworldShipment = shipment;
g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id,
destination: intent.destination, amount: quote.amount, event: shipment.event};
log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
log(g, `${p.name} declared ${intent.noField ? `one concealed No-Field${quote.amount > 1 ? ` and ${quote.amount - 1} physical forces` : ''}` : `${quote.amount} physical forces`} for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
} else continueHomeworldShipment(g, shipment);
}
function homeworldUsesGuildTransport(g: Game,
+37 -14
View File
@@ -31,6 +31,7 @@ export function richeseHomeworldMarkerChoice(
tokenId: string,
allyPayment = 0,
source?: string,
forces?: HomeworldForces,
) {
const result: { blocked: string | null; amount: number; cost: number;
ownPayment: number; allyPayment: number; action: Action | null } = {
@@ -54,15 +55,6 @@ export function richeseHomeworldMarkerChoice(
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,
@@ -78,11 +70,31 @@ export function richeseHomeworldMarkerChoice(
visitors.map(([owner, forces]) => [owner, { ...forces }]));
}
try {
if (source === undefined)
if (source === undefined) {
if (forces !== undefined) {
result.blocked = 'Physical forces may accompany only a deployed Homeworld No-Field.';
return result;
}
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: {},
} else {
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
result.amount = quote.amount;
result.cost = quote.cost;
}
result.ownPayment = result.cost - allyPayment;
if (!Number.isSafeInteger(allyPayment) || allyPayment < 0 || allyPayment > result.cost ||
allyPayment > (me.ally ? view.aid.available : 0)) {
result.blocked = 'Choose an allied contribution within the current pledge and shipment price.';
return result;
}
if (result.ownPayment > (me.spice ?? 0)) {
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
return result;
}
const sources = source !== undefined && forces && forces.normal + forces.elite > 0
? { [source]: forces } : {};
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
} catch (error) {
if (!(error instanceof HomeworldCustodyError)) throw error;
@@ -398,7 +410,18 @@ export function homeworldShipmentActions(
if (token) for (const target of targets) {
const quote = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
Math.max(0, 1 - (me.spice ?? 0)), source);
if (quote.action) { actions.push(quote.action); break; }
if (quote.action) {
actions.push(quote.action);
const pool = source && view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id];
if (pool && (pool.normal > 0 || pool.elite > 0)) {
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
const mixed = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
Math.max(0, initial.cost - (me.spice ?? 0)), source, forces);
if (mixed.action) actions.push(mixed.action);
}
break;
}
}
}
return actions;
+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. 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.',
'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, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Initial marker placement with companions, own-Homeworld placement or return, and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'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.',
+28 -4
View File
@@ -1,7 +1,8 @@
import {
HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody,
} from './homeworld-custody';
import type { HomeworldShipmentContext } from './homeworld-shipment';
import { quoteHomeworldShipment, type HomeworldShipmentContext } from './homeworld-shipment';
import type { HomeworldForces } from './homeworld-custody';
import { deployRicheseNoField, isHomeworldNoFieldLocation, moveRicheseNoField, type RicheseNoField } from './richese-no-field';
export type RicheseHomeworldMarkerShipmentIntent = {
@@ -9,7 +10,10 @@ export type RicheseHomeworldMarkerShipmentIntent = {
destination: string;
tokenId: string;
};
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & { source: string };
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
source: string;
forces?: HomeworldForces;
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
@@ -69,7 +73,8 @@ export function quoteRicheseHomeworldMarkerShipment(
}
/** A deployed marker can be transported like one force between eligible
* Homeworlds. Its denomination never enters the price or physical custody. */
* Homeworlds, provisionally alongside physical forces from that same source.
* Its denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerTransfer(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
@@ -77,10 +82,18 @@ export function quoteRicheseHomeworldMarkerTransfer(
noField: RicheseNoField,
) {
const worlds = homeworldForceGroups(context, custody);
requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,source,tokenId' &&
requireMarker(record(intent) &&
(Object.keys(intent).sort().join(',') === 'destination,player,source,tokenId' ||
Object.keys(intent).sort().join(',') === 'destination,forces,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.');
if ('forces' in intent) {
requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
intent.forces.normal >= 0 && intent.forces.elite >= 0,
'Choose a valid physical normal and special counter group.');
}
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.');
@@ -103,6 +116,17 @@ export function quoteRicheseHomeworldMarkerTransfer(
} catch (error) {
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field transfer.');
}
const forces = intent.forces;
if (forces && forces.normal + forces.elite > 0) {
const physical = quoteHomeworldShipment(context, custody, {
player: player.id, destination: destination.id, sources: { [source.id]: forces },
});
const amount = 1 + physical.amount;
return { player: player.id, tokenId: intent.tokenId, source: source.id,
destination: destination.id, amount, elite: physical.elite,
cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: moved,
state: physical.state, players: physical.players, sources: physical.sources };
}
return { player: player.id, tokenId: intent.tokenId, source: source.id,
destination: destination.id, amount: 1, elite: 0, cost: 1, noField: moved };
}