Support concealed Homeworld transfers and Ecaz victory
This commit is contained in:
1 parent
850227c26d
commit
1f03ae673b
14 files changed
+712
-43
No files matched your search
@@ -23,16 +23,18 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
|
||||
const [selectedToken, setSelectedToken] = useState('');
|
||||
const [allyPayment, setAllyPayment] = useState(0);
|
||||
if (!me || me.faction !== 'richese' || info?.owner !== me.id || !info.private) return null;
|
||||
const deployed = info.private.deployed;
|
||||
const source = deployed && 'homeworld' in deployed.location ? deployed.location.homeworld : undefined;
|
||||
const targets = (game.homeworlds?.worlds ?? []).filter(world =>
|
||||
world.native !== me.id && world.native !== me.ally);
|
||||
world.native !== me.id && world.native !== me.ally && world.id !== source);
|
||||
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
|
||||
const tokens = info.private.tokens.filter(token => token.id !== info.private!.lastShipped);
|
||||
const tokens = info.private.tokens.filter(token => source ? token.id === deployed?.tokenId : token.id !== info.private!.lastShipped);
|
||||
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
|
||||
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment);
|
||||
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source);
|
||||
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
|
||||
className="space-y-3 pt-3">
|
||||
<legend>Ship a concealed No-Field</legend>
|
||||
<p className="fine">Provisional Homeworld rule: ship one concealed marker from your supply to
|
||||
<p className="fine">Provisional Homeworld rule: ship one concealed marker {source ? `from ${homeworldName(game, source)}` : 'from your supply'} to
|
||||
another non-allied Homeworld for one spice. Its value stays private and its physical forces
|
||||
remain in your reserves until the marker is revealed. This uses your normal shipment.</p>
|
||||
<label className="block space-y-1">Destination Homeworld for No-Field
|
||||
|
||||
@@ -16,6 +16,18 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Homeworld No-Field continuation — 28 October. Prototyped / Partial.**
|
||||
High Ecaz victory counts an allied marker-only holding as public presence,
|
||||
without inventing physical counters. Richese may provisionally ship its own
|
||||
deployed marker between distinct eligible foreign Homeworlds at the one-force
|
||||
price; its identity, concealment and shipment history persist. Seven related
|
||||
offline files pass 33 cases, including Basic and Advanced native turn
|
||||
progression, victory, a stopped transfer and physical conservation. One
|
||||
isolated authenticated Basic room exercises the transfer and foreign view.
|
||||
Applying World shipment to a face-down marker is provisional; own-world return,
|
||||
mixed groups, optional module overlays and deployed acceptance remain open.
|
||||
[Scope](RICHESE_NO_FIELD_RULES.md#transfer-between-foreign-homeworlds-and-ecaz-victory--28-october-2026).
|
||||
|
||||
**Concealed Richese No-Field on a foreign Homeworld — 28 October.
|
||||
Prototyped / Partial.** In native Homeworld games, Richese may ship its own
|
||||
face-down marker to a foreign non-allied world. It counts as one public marker
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
|
||||
|
||||
## Transfer between foreign Homeworlds and Ecaz victory — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** Richese may provisionally ship its already-deployed
|
||||
concealed marker from one foreign Homeworld to another eligible non-allied
|
||||
foreign Homeworld as one force. This uses the normal Homeworld shipment,
|
||||
payment and Advanced Guild interception, preserving the marker's identity,
|
||||
concealment and last-shipment history; it moves no physical reserve counters.
|
||||
The departure and arrival are observed through the existing occupation history.
|
||||
The own-world return, physical companions and optional modules remain outside
|
||||
this first route.
|
||||
|
||||
High Ecaz Homeworld victory now counts an allied sole concealed marker as
|
||||
public presence at a qualifying foreign World, without reading its hidden
|
||||
value or creating a physical counter. Departure or contest still affects the
|
||||
actual holding. The supplied Advanced revision permits Homeworld-to-Homeworld
|
||||
shipment (physical p. 22) and counts a concealed No-Field as one force
|
||||
(physical p. 36), but does not explicitly resolve their combination; the
|
||||
transfer is a provisional online interpretation.
|
||||
|
||||
Seven related offline files pass 33 cases, including Basic and Advanced native
|
||||
turn progression and the Guild's stop. The native test selects an undealt
|
||||
Karama and makes actual reserve departures without staging forces or spice.
|
||||
The authenticated three-human Basic transfer case passed in four fresh
|
||||
isolated rooms. Broader combinations, browser and deployed acceptance remain
|
||||
open.
|
||||
|
||||
## Foreign Homeworld marker — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In supported native Homeworld games without optional
|
||||
@@ -27,7 +53,7 @@ placement, multiple markers, optional-module combinations, complete mode,
|
||||
browser and deployed acceptance remain open.
|
||||
|
||||
The supplied Advanced revision distinguishes Homeworlds from territories
|
||||
(physical p. 21) and describes No-Field materialization in a territory
|
||||
(physical p. 22) and describes No-Field materialization in a territory
|
||||
(physical p. 36). It does not explicitly resolve this off-planet composition.
|
||||
The single-marker and foreign-Homeworld policy is therefore a provisional
|
||||
online interpretation, not a publisher clarification. Existing on-Arrakis
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Richese No-Field transfer between Homeworlds — 28 October 2026
|
||||
|
||||
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
|
||||
(physical p. 22) and counts a concealed No-Field as one force (physical p. 36).
|
||||
Provisionally compose those rules for the native holder's one-marker shipment
|
||||
between distinct non-allied foreign Worlds; this is not a publisher ruling.
|
||||
Its identity and last-shipment history persist. [Scope](RICHESE_NO_FIELD_RULES.md#transfer-between-foreign-homeworlds-and-ecaz-victory--28-october-2026).
|
||||
|
||||
## Richese No-Field on a foreign Homeworld — 28 October 2026
|
||||
|
||||
The user-authorized Advanced revision calls Homeworlds non-territories
|
||||
(physical p. 21) and describes No-Field materialization in a territory
|
||||
(physical p. 22) and describes No-Field materialization in a territory
|
||||
(physical p. 36), without resolving their combination. Provisionally permit
|
||||
one native face-down marker globally, shipped to a foreign non-allied active
|
||||
Homeworld; count its public presence separately and materialize real reserves
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ecazHomeworldVictoryProgress } from '../game/ecaz-homeworld-victory';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldOccupationMarker, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
import { deployRicheseNoField, recallRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import type { Game } from '../game/engine';
|
||||
import { ecazHomeworldFixture, ecazSeat } from './fixture-ecaz-homeworld-victory';
|
||||
|
||||
const world = 'homeworld:emperor';
|
||||
|
||||
/** The original fixture has native setup and a deliberately staged, conserved
|
||||
* victory position. Exchange its one physical visitor for a real hidden token;
|
||||
* no token denomination is added to the Homeworld force ledger. */
|
||||
function replaceVisitorWithMarker(game: Game, value: NoFieldValue) {
|
||||
const owner = ecazSeat(game, 'al');
|
||||
const marker = owner.noField!.tokens.find(token => token.value === value)!;
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors[world].al, { normal: 1, elite: 0 });
|
||||
delete game.homeworlds!.custody!.visitors[world].al;
|
||||
delete game.homeworlds!.custody!.visitors[world];
|
||||
owner.reserves++;
|
||||
owner.noField = deployRicheseNoField(owner.noField!, {
|
||||
tokenId: marker.id, controller: owner.id, location: { homeworld: world },
|
||||
});
|
||||
observe(game, `controlled-marker-${value}`);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function observe(game: Game, event: string) {
|
||||
homeworldGameIntegrity(game);
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, 'change', event,
|
||||
homeworldOccupationMarker(game.players));
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
function physicalInventory(game: Game) {
|
||||
const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, n) => sum + n, 0) + worlds
|
||||
.filter(home => home.native !== player.id)
|
||||
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} High Ecaz victory counts an ally's concealed Homeworld marker without its value`, () => {
|
||||
const base = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
const before = ecazHomeworldVictoryProgress(base)!;
|
||||
const inventory = physicalInventory(base);
|
||||
assert.equal(before.qualifies, true);
|
||||
const outcomes = ([0, 3, 5] as const).map(value => {
|
||||
const game = structuredClone(base);
|
||||
const marker = replaceVisitorWithMarker(game, value);
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, true);
|
||||
assert.deepEqual(row.foreignHomeworlds, before.foreignHomeworlds);
|
||||
assert.equal(game.homeworlds!.custody!.visitors[world], undefined);
|
||||
assert.deepEqual(physicalInventory(game), inventory);
|
||||
assert.equal(JSON.stringify(row).includes(marker.id), false);
|
||||
return row;
|
||||
});
|
||||
assert.deepEqual(outcomes[0], outcomes[1]);
|
||||
assert.deepEqual(outcomes[1], outcomes[2]);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} High Ecaz requires a currently present marker even after prior occupation`, () => {
|
||||
const game = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
const marker = replaceVisitorWithMarker(game, 3);
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
const owner = ecazSeat(game, 'al');
|
||||
owner.noField = recallRicheseNoField(owner.noField!, marker.id);
|
||||
observe(game, 'controlled-marker-departure');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.deepEqual(row.distinctNativeFactions, ['atreides']);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} an opposing World visitor contests a marker-only ally holding under native occupation rules`, () => {
|
||||
const game = ecazHomeworldFixture(advanced, 'richese').game;
|
||||
replaceVisitorWithMarker(game, 5);
|
||||
const observer = ecazSeat(game, 'observer');
|
||||
assert.ok(observer.reserves > 0);
|
||||
observer.reserves--;
|
||||
game.homeworlds!.custody!.visitors[world] = { observer: { normal: 1, elite: 0 } };
|
||||
observe(game, 'controlled-marker-contest');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, advanced, 'Advanced keeps its original occupation while Basic requires current sole occupation.');
|
||||
assert.equal(row.blocked, null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import * as combat from '../game/combat';
|
||||
import * as options from '../game/homeworld-shipment-options';
|
||||
import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
|
||||
|
||||
function fixture(): GameView {
|
||||
const context = { advanced: true, players: [
|
||||
{ id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null },
|
||||
{ id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null },
|
||||
{ id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null },
|
||||
] };
|
||||
const custody: HomeworldCustody = { salusa: null, visitors: {} };
|
||||
const populations = homeworldPopulations(context, custody);
|
||||
const marker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
|
||||
{ tokenId: 'five', controller: 'r', location: { homeworld: 'homeworld:atreides' } });
|
||||
return {
|
||||
me: 'r', active: 'r', advanced: true, status: 'playing', phase: 5,
|
||||
homeworldShipment: { event: 'homeworld-event', blocked: null }, aid: { available: 0, pledged: 0 },
|
||||
players: context.players.map(p => ({ ...p, name: p.faction, spice: p.id === 'r' ? 4 : undefined,
|
||||
shipped: false, elites: { reserves: 0 } })),
|
||||
homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ ...world,
|
||||
...populations.find(population => population.location === world.id)! })) },
|
||||
richeseNoField: { owner: 'r', event: 'marker-event', private: marker,
|
||||
canShip: false, shipBlock: 'Reveal the existing No-Field before shipping another.' },
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
void test('the owner receives the actual transfer packet although another deployment is blocked', () => {
|
||||
const view = fixture(), before = structuredClone(view);
|
||||
const quote = options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:atreides');
|
||||
assert.equal(quote.cost, 1); assert.equal(quote.ownPayment, 1);
|
||||
assert.deepEqual(quote.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:guild',
|
||||
sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
void test('transfer quote enforces current custody, active timing, destination and funding', () => {
|
||||
const view = fixture();
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'five', 0, 'homeworld:atreides').action, null);
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'three', 0, 'homeworld:atreides').action, null);
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:unknown').action, null);
|
||||
for (const patch of [
|
||||
{ phase: 6 }, { active: 'a' }, { status: 'finished' }, { roomControl: { paused: true } },
|
||||
{ homeworldShipment: { event: 'homeworld-event', blocked: 'Wait.' } },
|
||||
{ richeseNoField: { ...view.richeseNoField!, owner: 'a', private: null } }, { leaderSkills: {} },
|
||||
]) assert.equal(options.richeseHomeworldMarkerChoice({ ...view, ...patch } as GameView,
|
||||
'homeworld:guild', 'five', 0, 'homeworld:atreides').action, null);
|
||||
view.players[0].spice = 0;
|
||||
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:atreides').action, null);
|
||||
});
|
||||
|
||||
void test('owner sees the current marker and different destination; all minimal bot levels can offer transfer', () => {
|
||||
const view = fixture();
|
||||
const html = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, /from Caladan/); assert.match(html, /No-Field 5/);
|
||||
assert.doesNotMatch(html, /No-Field 0|No-Field 3/);
|
||||
const foreign = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
|
||||
assert.equal(renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: foreign, act() {}, busy: false })), '');
|
||||
for (const level of [0, 1, 2, 3]) {
|
||||
const action = options.homeworldShipmentActions(view, level).find(candidate => candidate.noFieldFrom !== undefined);
|
||||
assert.ok(action, `profile ${level} can transport the actual marker`);
|
||||
assert.equal(action.noFieldFrom, 'homeworld:atreides'); assert.equal(action.noField, 'five');
|
||||
assert.equal(action.destination, 'homeworld:guild');
|
||||
}
|
||||
});
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
|
||||
void test('the mounted control submits the current token and source through the normal button', () => {
|
||||
const view = fixture(), sent: Action[] = [], states: unknown[] = [];
|
||||
let cursor = 0;
|
||||
const exports: { HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText, {
|
||||
exports,
|
||||
require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return { useState(initial: unknown) {
|
||||
const index = cursor++; if (!(index in states)) states[index] = initial;
|
||||
return [states[index], (next: unknown) => { states[index] = next; }];
|
||||
} };
|
||||
if (name === '@/game/combat') return combat;
|
||||
if (name === '@/game/homeworld-shipment-options') return options;
|
||||
if (name === './ui/button') return { Button: 'button' };
|
||||
if (name === './ui/input') return { Input: 'input' };
|
||||
throw Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
assert.ok(exports.HomeworldNoFieldShipment);
|
||||
cursor = 0;
|
||||
const nodes = elements(exports.HomeworldNoFieldShipment({ game: view, busy: false, act: action => sent.push(action) }));
|
||||
const button = nodes.find(element => element.type === 'button'); assert.ok(button);
|
||||
assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
|
||||
destination: 'homeworld:guild', sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
async function request(path: string, cookie?: string, body?: unknown) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
|
||||
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(cookie ? { cookie } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
data: await response.json() as GameView & { error?: string } };
|
||||
}
|
||||
|
||||
/** A fresh room uses real human seat credentials and native legal actions.
|
||||
* No server phase, currency, force, token, or card state is manufactured. */
|
||||
void test('authenticated marker transfer retains its hidden identity between foreign Homeworlds', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Marker origin world', faction: 'atreides', advanced: false, expansions: ['choam'],
|
||||
});
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const owner = await request(path, undefined, { type: 'join', name: 'Marker owner', faction: 'richese' });
|
||||
assert.equal(owner.status, 200, JSON.stringify(owner.data)); assert.ok(owner.cookie);
|
||||
const guild = await request(path, undefined, { type: 'join', name: 'Marker destination world', faction: 'guild' });
|
||||
assert.equal(guild.status, 200, JSON.stringify(guild.data)); assert.ok(guild.cookie);
|
||||
const seats = [host, owner, guild].map(result => ({ cookie: result.cookie!, id: result.data.me }));
|
||||
const ownerSeat = seats[1], hostSeat = seats[0], guildSeat = seats[2];
|
||||
let view = guild.data;
|
||||
const act = async (cookie: string, action: Action) => {
|
||||
const next = await request(path, cookie, { version: view.version, action });
|
||||
assert.equal(next.status, 200, `Rejected ${action.type}: ${JSON.stringify(next.data)}`);
|
||||
view = next.data; return next.data;
|
||||
};
|
||||
const read = async (cookie: string) => {
|
||||
const result = await request(path, cookie);
|
||||
assert.equal(result.status, 200, JSON.stringify(result.data));
|
||||
view = result.data; return result.data;
|
||||
};
|
||||
await act(hostSeat.cookie, { type: 'homeworlds', enabled: true });
|
||||
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
|
||||
await act(hostSeat.cookie, { type: 'start' });
|
||||
|
||||
let firstMarkerPlaced = false;
|
||||
async function advance(until: (ownerView: GameView) => boolean) {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
const currentOwner = await read(ownerSeat.cookie);
|
||||
if (until(currentOwner)) return currentOwner;
|
||||
assert.notEqual(currentOwner.status, 'finished', 'The QA game ended before marker transfer.');
|
||||
|
||||
// Atreides sends its actual reserves away from its World, so the
|
||||
// concealed marker can remain there through the next Battle phase.
|
||||
if (currentOwner.status === 'playing' && currentOwner.phase === 5 && currentOwner.active === hostSeat.id &&
|
||||
!currentOwner.response && !currentOwner.decision && !currentOwner.phaseOpening &&
|
||||
!currentOwner.truthtrance) {
|
||||
const at = await read(hostSeat.cookie);
|
||||
const me = at.players.find(player => player.id === hostSeat.id)!;
|
||||
if (!me.shipped && me.reserves > 0) {
|
||||
let accepted = false;
|
||||
for (const territory of TERRITORIES.filter(item => item.type === 'stronghold')) {
|
||||
for (const sector of territory.sectors) {
|
||||
if (sector === at.storm) continue;
|
||||
const attempt = await request(path, hostSeat.cookie, { version: view.version, action: {
|
||||
type: 'ship', territory: territory.id, sector, amount: me.reserves, elite: 0,
|
||||
allyPayment: 0, homeworldSources: { 'homeworld:atreides': { normal: me.reserves, elite: 0 } },
|
||||
} });
|
||||
if (attempt.status === 200) { view = attempt.data; accepted = true; break; }
|
||||
assert.equal(attempt.status, 409, JSON.stringify(attempt.data));
|
||||
}
|
||||
if (accepted) break;
|
||||
}
|
||||
assert.ok(accepted, 'Atreides can ship its physical reserves to an available stronghold.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let proposed: { cookie: string; actor: string; action: Action } | null = null;
|
||||
for (const seat of seats) {
|
||||
const own = await read(seat.cookie);
|
||||
const local = structuredClone(own);
|
||||
const self = local.players.find(player => player.id === seat.id); assert.ok(self);
|
||||
self.bot = 'Easy'; // Client-side adapter only; authoritative seat stays human.
|
||||
const choices = botActions(local);
|
||||
if (choices.length) { proposed = { cookie: seat.cookie, actor: seat.id, action: choices[0] }; break; }
|
||||
}
|
||||
assert.ok(proposed, `No native continuation at ${currentOwner.status}/${currentOwner.phase}/${currentOwner.decision?.kind ?? 'no decision'}.`);
|
||||
if (view.response && !view.response.passed.includes(view.me)) proposed.action = { type: 'passResponse' };
|
||||
// Keep the actual Atreides shipment affordable and avoid extra foreign
|
||||
// arrivals to the temporary marker World; all passes/skips are legal.
|
||||
else if (proposed.actor === hostSeat.id && proposed.action.type === 'bid') proposed.action = { type: 'passBid' };
|
||||
else if (proposed.actor === hostSeat.id && proposed.action.type === 'richeseBid')
|
||||
proposed.action = { type: 'richeseBid', event: proposed.action.event, amount: 0, allyPayment: 0 };
|
||||
else if (view.phase === 5 && view.active === proposed.actor && !view.response && !view.decision &&
|
||||
!view.truthtrance && !view.phaseOpening &&
|
||||
(proposed.actor === guildSeat.id || proposed.actor === ownerSeat.id && firstMarkerPlaced))
|
||||
proposed.action = { type: 'endMovement' };
|
||||
await act(proposed.cookie, proposed.action);
|
||||
}
|
||||
throw Error('The dedicated HTTP room did not reach the native marker transfer boundary.');
|
||||
}
|
||||
|
||||
const first = await advance(own => own.status === 'playing' && own.phase === 5 && own.active === own.me &&
|
||||
!own.players.find(player => player.id === own.me)!.shipped &&
|
||||
own.players.find(player => player.id === hostSeat.id)!.reserves === 0 &&
|
||||
!!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.canShip);
|
||||
const markerInfo = first.richeseNoField!; assert.ok(markerInfo.private);
|
||||
const token = markerInfo.private.tokens.find(candidate => candidate.id !== markerInfo.private!.lastShipped);
|
||||
assert.ok(token);
|
||||
const ids = markerInfo.private.tokens.map(candidate => candidate.id);
|
||||
const initial = richeseHomeworldMarkerChoice(first, 'homeworld:atreides', token.id);
|
||||
assert.ok(initial.action, initial.blocked ?? 'The initial marker shipment is available.');
|
||||
await act(ownerSeat.cookie, initial.action);
|
||||
firstMarkerPlaced = true;
|
||||
const landed = await advance(own => own.phase === 5 && !!own.richeseNoField?.private?.deployed &&
|
||||
own.players.find(player => player.id === own.me)?.shipped === true);
|
||||
assert.deepEqual(landed.richeseNoField!.private!.deployed!.location, { homeworld: 'homeworld:atreides' });
|
||||
const firstTurn = landed.turn;
|
||||
const beforeTransfer = await advance(own => own.status === 'playing' && own.turn > firstTurn && own.phase === 5 &&
|
||||
own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped &&
|
||||
!!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.private?.deployed);
|
||||
const mover = beforeTransfer.players.find(player => player.id === beforeTransfer.me)!;
|
||||
const oldSpice = mover.spice, oldReserves = mover.reserves;
|
||||
const transfer = richeseHomeworldMarkerChoice(beforeTransfer, 'homeworld:guild', token.id, 0, 'homeworld:atreides');
|
||||
assert.ok(transfer.action, transfer.blocked ?? 'The marker transfer is available.');
|
||||
assert.equal(transfer.action.noFieldFrom, 'homeworld:atreides');
|
||||
await act(ownerSeat.cookie, transfer.action);
|
||||
const after = await advance(own => own.phase === 5 && own.players.find(player => player.id === own.me)?.shipped === true &&
|
||||
!!own.richeseNoField?.private?.deployed && 'homeworld' in own.richeseNoField.private.deployed.location &&
|
||||
own.richeseNoField.private.deployed.location.homeworld === 'homeworld:guild');
|
||||
const result = after.players.find(player => player.id === after.me)!;
|
||||
assert.equal(result.spice, oldSpice! - 1); assert.equal(result.reserves, oldReserves);
|
||||
assert.equal(after.richeseNoField!.private!.deployed!.tokenId, token.id);
|
||||
assert.equal(after.richeseNoField!.private!.lastShipped, token.id);
|
||||
const other = await read(hostSeat.cookie);
|
||||
assert.equal(other.richeseNoField!.private, null);
|
||||
assert.deepEqual(other.richeseNoField!.public.deployed!.location, { homeworld: 'homeworld:guild' });
|
||||
for (const id of ids) assert.equal(JSON.stringify(other).includes(id), false, 'A foreign seat cannot inspect marker identity.');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { HomeworldCustodyError, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { quoteRicheseHomeworldMarkerTransfer as quote } from '../game/richese-homeworld-marker-shipment';
|
||||
|
||||
function fixture(tokenId = 'zero') {
|
||||
const context: HomeworldShipmentContext = { advanced: true, players: [
|
||||
{ id: 'r', faction: 'richese', reserves: 0, eliteReserves: 0, ally: null },
|
||||
{ id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null },
|
||||
{ id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null },
|
||||
] };
|
||||
const custody: HomeworldCustody = { salusa: null, visitors: { 'homeworld:atreides': { r: { normal: 2, elite: 0 } } } };
|
||||
const noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
|
||||
{ tokenId, controller: 'r', location: { homeworld: 'homeworld:atreides' } });
|
||||
return { context, custody, noField, intent: { player: 'r', tokenId, source: 'homeworld:atreides', destination: 'homeworld:guild' } };
|
||||
}
|
||||
|
||||
void test('each physical marker transfers as one without materialization or shipment-history changes', () => {
|
||||
for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
|
||||
const f = fixture(tokenId); f.context.advanced = advanced;
|
||||
const before = structuredClone(f), result = quote(f.context, f.custody, f.intent, f.noField);
|
||||
assert.deepEqual({ player: result.player, tokenId: result.tokenId, source: result.source,
|
||||
destination: result.destination, amount: result.amount, elite: result.elite, cost: result.cost },
|
||||
{ player: 'r', tokenId, source: 'homeworld:atreides', destination: 'homeworld:guild', amount: 1, elite: 0, cost: 1 });
|
||||
assert.deepEqual(result.noField.deployed, { tokenId, controller: 'r', location: { homeworld: 'homeworld:guild' } });
|
||||
assert.equal(result.noField.lastShipped, tokenId);
|
||||
assert.deepEqual(result.noField.tokens, f.noField.tokens);
|
||||
assert.deepEqual(f, before);
|
||||
result.noField.tokens[0].id = 'detached';
|
||||
assert.deepEqual(f, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing, stale, own-world, same-world, allied and foreign marker requests reject immutably', () => {
|
||||
for (const change of [
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.tokenId = 'three'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.source = 'homeworld:guild'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:atreides'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.intent.player = 'a'; },
|
||||
(f: ReturnType<typeof fixture>) => { f.noField.deployed = null; },
|
||||
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'g'; f.context.players[2].ally = 'r'; },
|
||||
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = true; },
|
||||
]) {
|
||||
const f = fixture(); change(f); const before = structuredClone(f);
|
||||
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
|
||||
assert.deepEqual(f, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
|
||||
const owner = 'richese', native = 'atreides', guild = 'guild';
|
||||
const source = 'homeworld:atreides', destination = 'homeworld:guild';
|
||||
const seat = (game: Game, id: string) => {
|
||||
const result = game.players.find(player => player.id === id);
|
||||
assert.ok(result); return result;
|
||||
};
|
||||
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Original native action/response continuations, including a real Guild
|
||||
* interception when Advanced allows one. No saved phase or custody is staged. */
|
||||
function advance(game: Game, until: (game: Game) => boolean): Game {
|
||||
let state = game;
|
||||
for (let count = 0; count < 2500; count++) {
|
||||
if (until(state)) return state;
|
||||
assert.notEqual(state.status, 'finished');
|
||||
if (state.decision?.kind === 'homeworldShipmentGuild') {
|
||||
const decision = state.decision;
|
||||
state = applyAction(state, decision.player, { type: 'decision', event: decision.event, allow: true });
|
||||
continue;
|
||||
}
|
||||
const next = nextIndependentCardModulesStep(state);
|
||||
assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
|
||||
// Preserve the native Atreides cash needed for its intentional full
|
||||
// shipment by passing the actual Richese cache lot, rather than buying it.
|
||||
if (next.actor === native && next.action.type === 'richeseBid') {
|
||||
state = applyAction(state, native, { type: 'richeseBid', event: next.action.event, amount: 0, allyPayment: 0 });
|
||||
continue;
|
||||
}
|
||||
state = applyAction(state, next.actor, next.action);
|
||||
}
|
||||
throw Error('The native Homeworld transfer programme did not reach its requested boundary.');
|
||||
}
|
||||
|
||||
/** Actual original lobby/start and force choices. Atreides deliberately ships
|
||||
* its whole real reserve to a legal stronghold before the first marker arrives,
|
||||
* so that the marker survives the intervening Battle phase without inventing
|
||||
* a battle exemption. */
|
||||
function setup(advanced: boolean): Game {
|
||||
let game = createGame('RICHESEWORLDTRANSFER', newPlayer(native, native, 'atreides'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(owner, owner, 'richese'));
|
||||
joinGame(game, newPlayer(guild, guild, 'guild'));
|
||||
game = applyAction(game, native, { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, native, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
// Select one undealt original card position, ensuring Guild has a real
|
||||
// physical Karama for the response/stop case. Never add or duplicate cards.
|
||||
const karama = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karama >= 0 && game.deck.length >= 3);
|
||||
[game.deck[2], game.deck[karama]] = [game.deck[karama], game.deck[2]];
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
assert.ok(seat(game, guild).hand.some(card => card.effect === 'karama'));
|
||||
game = advance(game, state => state.phase === 5 && state.active === native && quiet(state));
|
||||
const amount = seat(game, native).reserves;
|
||||
assert.ok(amount > 0);
|
||||
let shipped: Game | null = null;
|
||||
for (const target of TERRITORIES.filter(territory => territory.type === 'stronghold')) {
|
||||
for (const sector of target.sectors) {
|
||||
if (sector === game.storm) continue;
|
||||
try {
|
||||
shipped = applyAction(game, native, { type: 'ship', territory: target.id, sector, amount,
|
||||
elite: 0, allyPayment: 0, homeworldSources: { [source]: { normal: amount, elite: 0 } } });
|
||||
break;
|
||||
} catch { /* Try another legal native stronghold; rejected actions are immutable. */ }
|
||||
}
|
||||
if (shipped) break;
|
||||
}
|
||||
assert.ok(shipped, 'Atreides can ship its real starting reserve to a legal stronghold.');
|
||||
game = advance(shipped, quiet);
|
||||
assert.equal(seat(game, native).reserves, 0);
|
||||
game = applyAction(game, native, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
!!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
|
||||
return game;
|
||||
}
|
||||
|
||||
function inventory(game: Game) {
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) + homes
|
||||
.filter(home => home.native !== player.id)
|
||||
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
||||
}
|
||||
|
||||
function placeFirstMarker(game: Game) {
|
||||
const own = viewGame(game, owner), info = own.richeseNoField!;
|
||||
const token = info.private!.tokens.find(candidate => candidate.value === 3);
|
||||
assert.ok(token);
|
||||
game = applyAction(game, owner, { type: 'homeworldShip', event: own.homeworldShipment!.event,
|
||||
destination: source, sources: {}, noField: token.id, noFieldEvent: info.event, allyPayment: 0 });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
seat(state, owner).shipped && !!seat(state, owner).noField?.deployed);
|
||||
assert.deepEqual(seat(game, owner).noField!.deployed!.location, { homeworld: source });
|
||||
assert.equal(seat(game, owner).noField!.lastShipped, token.id);
|
||||
return { game, token: token.id };
|
||||
}
|
||||
|
||||
function nextTransferTurn(game: Game): Game {
|
||||
const turn = game.turn;
|
||||
game = applyAction(game, owner, { type: 'endMovement' });
|
||||
return advance(game, state => state.turn > turn && state.phase === 5 && state.active === owner &&
|
||||
quiet(state) && !seat(state, owner).shipped && !!viewGame(state, owner).homeworldShipment &&
|
||||
!viewGame(state, owner).homeworldShipment!.blocked);
|
||||
}
|
||||
|
||||
function declaration(game: Game, token: string): Action {
|
||||
const own = viewGame(game, owner);
|
||||
return { type: 'homeworldShip', event: own.homeworldShipment!.event, destination, sources: {},
|
||||
noField: token, noFieldEvent: own.richeseNoField!.event, noFieldFrom: source, allyPayment: 0 };
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Homeworld marker transfer retains its identity and physical reserves`, () => {
|
||||
const initial = setup(advanced), originalInventory = inventory(initial);
|
||||
const first = placeFirstMarker(initial);
|
||||
let game = nextTransferTurn(first.game);
|
||||
const before = structuredClone(game), action = declaration(game, first.token);
|
||||
const stale = { ...action, noFieldEvent: 'stale-marker-event' };
|
||||
assert.throws(() => applyAction(game, owner, stale));
|
||||
assert.deepEqual(game, before, 'A stale marker event leaves the entire game unchanged.');
|
||||
game = applyAction(game, owner, action);
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
seat(state, owner).shipped && !state.pendingHomeworldShipment);
|
||||
assert.deepEqual(seat(game, owner).noField!.deployed!.location, { homeworld: destination });
|
||||
assert.equal(seat(game, owner).noField!.deployed!.tokenId, first.token);
|
||||
assert.equal(seat(game, owner).noField!.lastShipped, first.token);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
const foreign = viewGame(game, native).richeseNoField!;
|
||||
assert.equal(foreign.private, null);
|
||||
assert.deepEqual(foreign.public.deployed!.location, { homeworld: destination });
|
||||
assert.equal(JSON.stringify(foreign).includes(first.token), false);
|
||||
});
|
||||
|
||||
void test('Advanced Guild can stop the declared Homeworld marker transfer without payment or moving it', () => {
|
||||
const first = placeFirstMarker(setup(true));
|
||||
let game = nextTransferTurn(first.game);
|
||||
const before = structuredClone(game), action = declaration(game, first.token);
|
||||
game = applyAction(game, owner, action);
|
||||
game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
|
||||
const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
game = applyAction(game, guild, { type: 'card', mode: 'special', card: karama.id });
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
!state.pendingHomeworldShipment);
|
||||
assert.equal(seat(game, owner).shipped, true);
|
||||
assert.deepEqual(seat(game, owner).noField, seat(before, owner).noField);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
||||
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
|
||||
});
|
||||
Reference in new issue
Block a user