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

+19 -7
View File
@@ -12,8 +12,7 @@ import {
import { Button } from './ui/button';
import { Input } from './ui/input';
/** This private control is separate from physical-source shipment: the marker
* costs one but does not withdraw a reserve counter until it is revealed. */
/** The concealed marker does not withdraw a reserve counter until revealed. */
export function HomeworldNoFieldShipment({ game, act, busy }: {
game: GameView; act: (action: Action) => void; busy: boolean;
}) {
@@ -22,6 +21,7 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const [selectedDestination, setSelectedDestination] = useState('');
const [selectedToken, setSelectedToken] = useState('');
const [allyPayment, setAllyPayment] = useState(0);
const [forces, setForces] = useState({ normal: 0, elite: 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;
@@ -30,13 +30,16 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
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, source);
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] : undefined;
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
source ? forces : undefined);
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 {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>
another non-allied Homeworld. Its value stays private and its represented forces
remain in your reserves until the marker is revealed. This uses your normal shipment.
{source && ' Physical forces already on the same Homeworld may accompany it.'}</p>
<label className="block space-y-1">Destination Homeworld for No-Field
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
onChange={event => setSelectedDestination(event.target.value)}>
@@ -53,12 +56,21 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
</select>
</label>
{source && pool && <fieldset className="space-y-2">
<legend>Accompanying forces from {homeworldName(game, source)}</legend>
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
<label key={kind} className="block space-y-1">
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
<Input type="number" min={0} max={pool[kind]} step={1} value={forces[kind]}
onChange={event => setForces({ ...forces, [kind]: Number(event.target.value) })} />
</label>)}
</fieldset>}
{me.ally && <label className="block space-y-1">Allied spice contribution · {game.aid.available} pledged
<Input type="number" min={0} max={Math.min(1, game.aid.available)} step={1} value={allyPayment}
<Input type="number" min={0} max={Math.min(quote.cost, game.aid.available)} step={1} value={allyPayment}
onChange={event => setAllyPayment(Number(event.target.value))} />
</label>}
<output className="notice block" aria-live="polite">
One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
{quote.amount} total (including one marker) · {quote.cost === 1 ? 'one' : quote.cost} spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
{quote.blocked && ` ${quote.blocked}`}
</output>
<Button className="game-action" disabled={busy || !!me.autopilot || !quote.action}
+13
View File
@@ -16,6 +16,19 @@ open mode gates.
## Current checkpoint and work
**Mixed Homeworld No-Field transfers — 28 October. Prototyped / Partial.** Richese may provisionally
ship its existing concealed marker together with its own selected physical
forces from that same foreign Homeworld to another eligible foreign World.
The action retains `noFieldFrom` and supplies only that World's typed `sources`;
the shared quote prices the marker plus physical group together, including
rounding a purchased half rate on the total. Twenty-one related offline files
pass 134 cases, including Basic and Advanced native turn progression and a
Guild stop. One isolated authenticated Basic room exercises the transfer,
payment, physical custody and another player's concealed-token view. New
marker deployment with companions, own-world return, multiple sources,
optional overlays and deployed acceptance remain open.
[Scope](RICHESE_NO_FIELD_RULES.md#existing-marker-with-same-source-homeworld-forces--28-october-2026).
**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
+29 -3
View File
@@ -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.
## Existing marker with same-source Homeworld forces — 28 October 2026
**Prototyped / Partial.** In supported native Basic and Advanced Homeworld
games, Richese may provisionally transfer its already-deployed concealed marker
between distinct eligible foreign Homeworlds together with selected physical
Richese forces actually at the marker's source. The marker remains concealed
and retains its identity and last-shipment history. The physical group moves
through ordinary typed Homeworld custody; no marker value is materialized from
reserves. The departure and arrival are observed after both parts of the same
shipment commit.
Count the marker as one plus the selected physical counters for one shipment
price. A separately purchased Guild rate halves that total, rounding up once;
the marker is not a physical counter and does not consume the physical
twenty-counter limit. Normal payment and Advanced Guild interception remain.
No forces can be picked up from a different source. Initial placement of a
fresh marker with companions, own-world return and optional-module overlays
remain unsupported.
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
(physical p. 22) and counts the concealed No-Field as one force for effects
(physical p. 36). It does not explicitly address this combination; allowing
the marker and same-source physical group together is a provisional
composition, not a publisher ruling. This prototype does not certify complete
Richese, Homeworld or expansion play, and broader acceptance remains open.
## Transfer between foreign Homeworlds and Ecaz victory — 28 October 2026
**Prototyped / Partial.** Richese may provisionally ship its already-deployed
@@ -10,8 +36,8 @@ 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.
The own-world return, physical companions and optional modules were outside
this first route. The later same-source companion prototype is described above.
High Ecaz Homeworld victory now counts an allied sole concealed marker as
public presence at a qualifying foreign World, without reading its hidden
@@ -21,7 +47,7 @@ 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
The earlier marker-only route's 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
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Richese marker with same-source Homeworld forces — 28 October 2026
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
(physical p. 22) and counts a concealed No-Field as one force for effects
(physical p. 36), but does not explicitly resolve their combination. As a
provisional composition, an already-deployed marker may travel with physical
Richese counters from the same foreign source World to another eligible foreign
World. Price the full group as one plus those counters; a purchased Guild rate
halves the total with one upward rounding. Fresh marker deployment with
companions, own-world return and optional-module overlays remain unsupported.
This does not certify complete modes or establish a publisher ruling.
[Scope](RICHESE_NO_FIELD_RULES.md#existing-marker-with-same-source-homeworld-forces--28-october-2026).
## Richese No-Field transfer between Homeworlds — 28 October 2026
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
+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 };
}
@@ -0,0 +1,108 @@
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: { 'homeworld:atreides': { r: { normal: 3, elite: 0 } } } };
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' ? 6 : 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('mixed transfer quotes the total, exact source, funding and purchased discount', () => {
const view = fixture(), before = structuredClone(view);
const quote = options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:atreides', { normal: 3, elite: 0 });
assert.equal(quote.amount, 4); assert.equal(quote.cost, 4);
assert.deepEqual(quote.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:guild',
sources: { 'homeworld:atreides': { normal: 3, elite: 0 } }, noField: 'five', noFieldEvent: 'marker-event',
allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
assert.deepEqual(view, before);
const discounted = { ...view, karamaShipping: { player: 'r' } } as GameView;
const half = options.richeseHomeworldMarkerChoice(discounted, 'homeworld:guild', 'five', 0,
'homeworld:atreides', { normal: 2, elite: 0 });
assert.equal(half.amount, 3); assert.equal(half.cost, 2);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0,
'homeworld:atreides', { normal: 4, elite: 0 }).action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0,
'homeworld:atreides', { normal: -1, elite: 0 }).action, null);
view.players[0].spice = 2;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0,
'homeworld:atreides', { normal: 2, elite: 0 }).action, null);
});
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('owner control bounds the group and submits selected physical forces with the marker', () => {
const view = fixture(), sent: Action[] = [], states: unknown[] = [];
const html = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
assert.match(html, /accompanying No-Field/); assert.match(html, /3.*available/);
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);
const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({ game: view, busy: false, act: action => sent.push(action) })); };
const input = render().find(element => element.type === 'input'); assert.ok(input);
assert.equal(input.props.max, 3);
(input.props.onChange as (event: unknown) => void)({ target: { value: '2' } });
const button = render().find(element => element.type === 'button'); assert.ok(button);
assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
destination: 'homeworld:guild', sources: { 'homeworld:atreides': { normal: 2, elite: 0 } },
noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
});
void test('minimal bots can propose the current marker with an available physical force', () => {
const view = fixture();
for (const level of [0, 1, 2, 3]) {
const action = options.homeworldShipmentActions(view, level).find(candidate =>
candidate.noFieldFrom === 'homeworld:atreides' && (candidate.sources as options.HomeworldShipmentSources | undefined)?.['homeworld:atreides']);
assert.ok(action, `profile ${level} can propose a mixed transfer`);
assert.deepEqual((action.sources as options.HomeworldShipmentSources | undefined)?.['homeworld:atreides'], { normal: 1, elite: 0 });
}
});
@@ -0,0 +1,168 @@
// @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 { homeworldShipmentChoice, 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 mixed transfer moves a concealed marker and physical visitors together', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Mixed transfer origin', 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: 'Mixed transfer 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: 'Mixed transfer destination', 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.actor === ownerSeat.id) && proposed.action.type === 'bid') proposed.action = { type: 'passBid' };
else if ((proposed.actor === hostSeat.id || proposed.actor === ownerSeat.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 physical = homeworldShipmentChoice(first, 'homeworld:atreides',
{ 'homeworld:richese': { normal: 2, elite: 0 } });
assert.ok(physical.action, physical.blocked ?? 'The physical shipment is available.');
await act(ownerSeat.cookie, physical.action);
firstMarkerPlaced = true; // Keep later owner turns for the test's explicit shipments.
const firstLanded = await advance(own => own.phase === 5 && own.players.find(player => player.id === own.me)?.shipped === true);
const physicalTurn = firstLanded.turn;
const markerTurn = await advance(own => own.status === 'playing' && own.turn > physicalTurn && own.phase === 5 &&
own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped &&
!!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.canShip);
assert.deepEqual(markerTurn.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[markerTurn.me],
{ normal: 2, elite: 0 });
const markerInfo = markerTurn.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(markerTurn, 'homeworld:atreides', token.id);
assert.ok(initial.action, initial.blocked ?? 'The initial marker shipment is available.');
await act(ownerSeat.cookie, initial.action);
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;
assert.deepEqual(beforeTransfer.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[beforeTransfer.me],
{ normal: 2, elite: 0 });
const transfer = richeseHomeworldMarkerChoice(beforeTransfer, 'homeworld:guild', token.id, 0, 'homeworld:atreides',
{ normal: 1, elite: 0 });
assert.ok(transfer.action, transfer.blocked ?? 'The marker transfer is available.');
assert.equal(transfer.action.noFieldFrom, 'homeworld:atreides');
const stale = await request(path, ownerSeat.cookie, { version: view.version,
action: { ...transfer.action, noFieldEvent: 'stale-marker-event' } });
assert.equal(stale.status, 409, JSON.stringify(stale.data));
const unchanged = await read(ownerSeat.cookie);
assert.equal(unchanged.version, beforeTransfer.version);
assert.deepEqual(unchanged.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[unchanged.me],
{ normal: 2, elite: 0 });
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! - 2); assert.equal(result.reserves, oldReserves);
assert.deepEqual(after.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[after.me],
{ normal: 1, elite: 0 });
assert.deepEqual(after.homeworlds?.worlds?.find(world => world.id === 'homeworld:guild')?.forces[after.me],
{ normal: 1, elite: 0 });
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,81 @@
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 { quoteRicheseHomeworldMarkerTransfer as quote, type RicheseHomeworldMarkerTransferIntent } from '../game/richese-homeworld-marker-shipment';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
function fixture(tokenId = 'zero', physical = 20) {
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: physical, elite: 0 } },
} };
const noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
{ tokenId, controller: 'r', location: { homeworld: 'homeworld:atreides' } });
const intent: RicheseHomeworldMarkerTransferIntent = { player: 'r', tokenId,
source: 'homeworld:atreides', destination: 'homeworld:guild', forces: { normal: 2, elite: 0 } };
return { context, custody, noField, intent };
}
void test('mixed transfers move actual same-source counters and preserve concealed marker custody in both modes', () => {
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);
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.equal(result.amount, 3);
assert.equal(result.cost, 3);
assert.equal(result.elite, 0);
assert.equal(result.noField.lastShipped, tokenId);
assert.deepEqual(result.noField.deployed, { tokenId, controller: 'r', location: { homeworld: 'homeworld:guild' } });
assert.deepEqual(result.state?.visitors['homeworld:atreides'].r, { normal: 18, elite: 0 });
assert.deepEqual(result.state?.visitors['homeworld:guild'].r, { normal: 2, elite: 0 });
assert.deepEqual(result.sources?.[0], { homeworld: 'homeworld:atreides', player: 'r',
before: { normal: 20, elite: 0 }, after: { normal: 18, elite: 0 } });
assert.equal(result.players?.find(p => p.id === 'r')?.reserves, 0);
assert.deepEqual(f, before);
result.state!.visitors['homeworld:guild'].r.normal = 0;
result.noField.tokens[0].id = 'detached';
assert.deepEqual(f, before);
}
});
void test('purchased Guild rate rounds once over the full marker and physical group', () => {
for (const physical of [1, 2, 3, 19, 20]) {
const f = fixture();
f.context.purchasedGuildRate = true;
f.intent.forces = { normal: physical, elite: 0 };
const result = quote(f.context, f.custody, f.intent, f.noField);
assert.equal(result.amount, physical + 1);
assert.equal(result.cost, Math.ceil((physical + 1) / 2));
}
});
void test('zero physical forces preserve marker-only quote; malformed or unavailable groups reject immutably', () => {
const f = fixture();
const legacy = { ...f.intent }; delete legacy.forces;
const zero = { ...f.intent, forces: { normal: 0, elite: 0 } };
assert.deepEqual(quote(f.context, f.custody, zero, f.noField), quote(f.context, f.custody, legacy, f.noField));
for (const forces of [
{ normal: 21, elite: 0 }, { normal: 1, elite: 1 }, { normal: -1, elite: 0 },
{ normal: 0.5, elite: 0 }, { normal: 1 }, { normal: 1, elite: 0, extra: 0 }, null,
]) {
const bad = { ...f.intent, forces } as unknown as RicheseHomeworldMarkerTransferIntent;
const before = structuredClone({ f, bad });
assert.throws(() => quote(f.context, f.custody, bad, f.noField), HomeworldCustodyError);
assert.deepEqual({ f, bad }, before);
}
});
void test('physical counters at a different world cannot accompany this marker', () => {
const f = fixture('zero', 1);
f.custody.visitors['homeworld:guild'] = { r: { normal: 2, elite: 0 } };
f.intent.forces = { normal: 2, elite: 0 };
const before = structuredClone(f);
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
assert.deepEqual(f, before);
});
@@ -0,0 +1,184 @@
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 { currentHomeworldOccupationSnapshot, homeworldOccupationMarker, homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history';
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('RICHESEWORLDMIXED', 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 nextOwnerTurn(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 visitor(game: Game, world: string) {
return game.homeworlds!.custody!.visitors[world]?.[owner];
}
/** The source counter group and marker arrive in two actual consecutive turns.
* The native evacuation above ensures neither is invented or protected from battle. */
function prepare(advanced: boolean) {
let game = setup(advanced);
let own = viewGame(game, owner);
game = applyAction(game, owner, { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: source, sources: { 'homeworld:richese': { normal: 2, elite: 0 } }, allyPayment: 0 });
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
seat(state, owner).shipped && !state.pendingHomeworldShipment);
assert.deepEqual(visitor(game, source), { normal: 2, elite: 0 });
game = nextOwnerTurn(game);
assert.deepEqual(visitor(game, source), { normal: 2, elite: 0 });
own = viewGame(game, owner);
const 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.deepEqual(visitor(game, source), { normal: 2, elite: 0 });
game = nextOwnerTurn(game);
assert.deepEqual(visitor(game, source), { normal: 2, elite: 0 });
assert.equal(seat(game, owner).noField!.lastShipped, token.id);
return { game, token: token.id };
}
function declaration(game: Game, token: string): Action {
const own = viewGame(game, owner);
return { type: 'homeworldShip', event: own.homeworldShipment!.event, destination,
sources: { [source]: { normal: 1, elite: 0 } }, noField: token,
noFieldEvent: own.richeseNoField!.event, noFieldFrom: source, allyPayment: 0 };
}
function presence(game: Game, world: string) {
const snapshot = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!,
homeworldOccupationMarker(game.players));
return homeworldOccupationSnapshotPresence(snapshot, world);
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} mixed marker and physical Homeworld transfer uses one shipment`, () => {
const ready = prepare(advanced);
let game = ready.game;
const before = structuredClone(game), action = declaration(game, ready.token);
assert.ok(seat(before, owner).spice >= 2, 'The actual Richese seat can pay the two-spice price.');
assert.deepEqual(presence(before, source), [owner]);
for (const stale of [{ ...action, noFieldEvent: 'stale-marker-event' }, { ...action, event: 'stale-shipment-event' }]) {
assert.throws(() => applyAction(game, owner, stale));
assert.deepEqual(game, before, 'A stale event leaves the real game unchanged.');
}
const malformed = { ...action, sources: { [source]: null } } as unknown as Action;
assert.throws(() => applyAction(game, owner, malformed), 'A null typed source cannot be dropped from a mixed transfer.');
assert.deepEqual(game, before, 'Rejecting a malformed source leaves the 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.equal(seat(game, owner).spice, seat(before, owner).spice - 2);
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
assert.deepEqual(visitor(game, source), { normal: 1, elite: 0 });
assert.deepEqual(visitor(game, destination), { normal: 1, elite: 0 });
assert.equal(seat(game, owner).noField!.deployed!.tokenId, ready.token);
assert.deepEqual(seat(game, owner).noField!.deployed!.location, { homeworld: destination });
assert.equal(seat(game, owner).noField!.lastShipped, ready.token);
assert.deepEqual(presence(game, source), [owner]);
assert.deepEqual(presence(game, destination).sort(), [guild, owner].sort());
});
void test('Advanced Guild can stop a mixed Homeworld transfer without payment, moving either group, or changing marker history', () => {
const ready = prepare(true);
let game = ready.game;
const before = structuredClone(game);
game = applyAction(game, owner, declaration(game, ready.token));
game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
assert.match(game.log.at(-1)!.text, /one concealed No-Field and 1 physical forces/);
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);
assert.deepEqual(presence(game, source), presence(before, source));
assert.deepEqual(presence(game, destination), presence(before, destination));
});