Allow fresh Advanced native No-Field placement
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 20:09:52 +11:00
1 parent 9ca68c9ff8
commit 26d45ecf25
13 files changed
+201 -21

No files matched your search

+8 -6
View File
@@ -26,7 +26,7 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
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 || !!source && game.advanced) &&
(world.native !== me.id || game.advanced) &&
world.native !== me.ally && world.id !== source);
const destination = targets.find(world => world.id === selectedDestination) ??
targets.find(world => world.native !== me.id) ?? targets[0];
@@ -34,19 +34,20 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
const nativeFresh = !source && destination?.native === me.id;
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
forces);
nativeFresh ? { normal: 0, elite: 0 } : forces);
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. Its value stays private and its represented forces
an eligible non-allied Homeworld. Its value stays private and its represented forces
remain in your reserves until the marker is revealed. On your own Homeworld,
the represented forces are already in those reserves and are not added again.
This uses your normal shipment. Physical forces from the same source may accompany it.</p>
This uses your normal shipment. Physical forces from the same source may accompany it when moving to another World.</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)}>
onChange={event => { setSelectedDestination(event.target.value); setForces({ normal: 0, elite: 0 }); }}>
{!targets.length && <option value="">No eligible destination</option>}
{targets.map(world => <option key={world.id} value={world.id}>
{homeworldName(game, world.id)} · {game.players.find(player => player.id === world.native)?.name}
@@ -60,7 +61,8 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
</select>
</label>
{pool && <fieldset className="space-y-2">
{nativeFresh && <p className="fine">Your physical reserves are already on Richese and cannot accompany this placement.</p>}
{pool && !nativeFresh && <fieldset className="space-y-2">
<legend>Accompanying forces from {source ? homeworldName(game, source) : 'your reserves'}</legend>
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
<label key={kind} className="block space-y-1">
+9
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Fresh Richese No-Field on native Homeworld — 29 October. Prototyped /
Partial.** Advanced Richese can provisionally place a fresh concealed marker on
its own World for one spice. The native physical reserve pool stays unchanged,
including on reveal; it cannot be billed as accompanying forces. Basic retains
the foreign-only rule. Eight related files pass 42 cases, including public
setup, all three token values, controls and legal bot candidates; unopened card
and Spice order are controlled. Wider modules and deployed verification remain
open. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-native-homeworld-marker--29-october-2026).
**Ecaz and Moritani with Discovery alone — 29 October. Prototyped / Partial.**
Basic and Advanced previews can use both factions with Discovery without an
unrelated Nexus or independent card selection. Public-start fixtures exercise
+21 -1
View File
@@ -136,6 +136,26 @@ The authenticated three-human Basic transfer case passed in four fresh
isolated rooms. Broader combinations, browser and deployed acceptance remain
open.
## Fresh native Homeworld marker — 29 October 2026
**Prototyped / Partial.** Advanced Richese can provisionally place one fresh
No-Field from its supply on its own Homeworld for one spice and its normal
shipment opportunity. Its value stays private and physical reserves stay on the
World; revealing the marker leaves that reserve pool unchanged. A physical
reserve group cannot accompany this placement because it is already at the
destination. Basic still permits fresh placement only on an eligible foreign
World. The controls expose the native target and clear accompanying selections
when it is chosen; all existing bot profiles receive a legal native candidate.
The supplied Advanced rules permit World-to-World shipments (physical p. 22,
printed p. 20) and count the concealed marker as one force (physical p. 36,
printed p. 34), but do not locate a fresh marker's off-board supply or resolve
placement onto its own reserve World. Treating that supply as a distinct source
and charging one spice is a provisional online composition, not a publisher
ruling. Eight related files pass 42 cases; public-start runtime fixtures control
unopened card/Spice order. Other modules, wider interactions and deployed
verification remain open.
## Advanced native Homeworld marker — 29 October 2026
**Prototyped / Partial.** An already deployed Richese No-Field may use a paid
@@ -143,7 +163,7 @@ Advanced World-to-World shipment to return from a foreign Homeworld to native
Richese, or later leave for another eligible World. The marker remains face
down and counts as public presence; optional accompanying physical forces and
the marker are priced together. Basic retains the foreign-only restriction.
A new marker cannot be shipped from the screen straight to its own World.
The later fresh-marker policy above also permits supply-to-own-World placement.
The supplied Advanced rules permit World-to-World shipments (physical p. 22,
printed p. 20) and treat a No-Field as one force until revealed (physical p. 36,
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Fresh native Homeworld No-Field — 29 October 2026
Advanced Richese may provisionally place its fresh marker on its own World for
one spice while the existing native reserves stay in place; no physical
same-source companions move. The supplied rules do not explicitly settle the
off-board marker source, so this is an online composition. Basic remains
foreign-only. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-native-homeworld-marker--29-october-2026).
## Ecaz and Moritani with Discovery alone — 29 October 2026
The selected E3 pair can use original Discovery without a Nexus or independent
+7
View File
@@ -418,6 +418,13 @@ export function homeworldShipmentActions(
break;
}
}
if (token && !source && view.advanced) {
const native = targets.find(target => target.world.native === me.id);
const quote = native && richeseHomeworldMarkerChoice(view, native.world.id, token.id,
Math.max(0, 1 - (me.spice ?? 0)));
if (quote?.action && !actions.some(action => JSON.stringify(action) === JSON.stringify(quote.action)))
actions.push(quote.action);
}
}
return actions;
}
+6 -4
View File
@@ -1135,7 +1135,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'During supported Basic games you can also reveal while another faction’s ordinary movement waits for a Fremen or Ixian response or a CHOAM decision. The original move and its response remain pending; a zero No-Field disappears without creating physical forces.',
'These supported clean Basic interruptions also work with the Ecaz/Moritani deck and native factions. A token already present does not enter again when it is revealed; pending Ambassador and Terror reactions keep their own controls.',
'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, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. 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; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. 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.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your supply to a foreign non-allied Homeworld, alone or with physical forces from those reserves. Advanced also permits a fresh marker-only placement on your own Homeworld for one spice; physical reserves are already there and cannot accompany it. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole moving 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; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. 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 settle its off-board source or native-world physical representation; this placement is provisional.',
'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.',
@@ -1165,8 +1165,8 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.',
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/richese-reveal-interruption.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
'Fresh Advanced native Homeworld placement uses the original marker and paid shipment while native reserves remain in place. Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.',
evidence: ['game/richese-homeworld-marker-shipment.ts', 'game/shipment-price.ts', 'game/engine.ts', 'game/richese-reveal-interruption.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
},
{
area: 'Player controls',
@@ -1193,8 +1193,10 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Native trained-leader/shipment/movement cases and controlled submit callbacks cover concealed Planetologist range and gather routes plus legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
'Fresh native placement passes eight related files and 42 cases, including public setup, all marker values, controls and legal bot candidates; unopened card/Spice order is controlled. Native trained-leader/shipment/movement cases and controlled submit callbacks cover concealed Planetologist range and gather routes plus legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
evidence: [
'tests/richese-native-marker-runtime.test.ts',
'tests/native-world-no-field-controls.test.tsx',
'tests/fixture-guild-no-field-transport.ts',
'tests/guild-no-field-transport-engine.test.ts',
'tests/guild-no-field-transport-quote.test.ts',
+4 -2
View File
@@ -53,8 +53,10 @@ export function quoteRicheseHomeworldMarkerShipment(
const destination = worlds.find(world => world.id === intent.destination);
requireMarker(player?.faction === 'richese' && source?.native === player.id,
'Only native Richese can ship its concealed No-Field.');
requireMarker(destination && destination.native !== player.id,
'Choose another seated faction’s Homeworld.');
requireMarker(destination && (destination.native !== player.id || context.advanced),
'Choose an eligible Homeworld; Basic requires another seated faction’s Homeworld.');
requireMarker(destination.id !== source.id || !intent.forces || intent.forces.normal + intent.forces.elite === 0,
'Your native reserves are already on this Homeworld and cannot accompany this placement.');
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.');
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import type { NoFieldValue } from '../game/richese-no-field';
import {
createRicheseHomeworldShipmentFixture,
passRicheseHomeworldShipment,
richeseHomeworldShipmentPlayer,
} from './fixture-richese-homeworld-shipment';
export const owner = 'richese';
export const native = 'homeworld:richese';
export const pass = passRicheseHomeworldShipment;
export const player = richeseHomeworldShipmentPlayer;
/** Public Richese/Emperor setup and actual shipment opportunity. The shared
* fixture controls unopened card/spice ordering and obtains ordinary funding;
* it does not stage the marker, forces, or an in-flight decision. */
export function freshNativeMarkerFixture(advanced: boolean, value: NoFieldValue = 3) {
const f = createRicheseHomeworldShipmentFixture({advanced, companions:1, value});
const game = f.game;
const view = viewGame(game, owner);
const token = view.richeseNoField?.private?.tokens.find(row => row.value === value);
assert.ok(token, 'The original hidden marker must be available');
const quote = richeseHomeworldMarkerChoice(view, native, token.id, 0);
return {game, view, token:token.id, quote};
}
export function nativeMarkerQuote(game: Game, token: string) {
return richeseHomeworldMarkerChoice(viewGame(game, owner), native, token, 0);
}
+24 -3
View File
@@ -65,15 +65,17 @@ void test('Advanced deployed marker can select native Homeworld without exposing
assert.deepEqual(view, before);
});
void test('Basic return and fresh Advanced supply placement cannot target the native World', () => {
void test('Basic return remains unavailable while fresh Advanced supply can target the native World', () => {
const basic = fixture(false);
assert.doesNotMatch(destinations(markup(basic)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(basic, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
const fresh = fixture(true);
fresh.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
fresh.richeseNoField!.canShip = true;
assert.doesNotMatch(destinations(markup(fresh)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action, null);
assert.match(destinations(markup(fresh)), /value="homeworld:richese"/);
assert.deepEqual(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action,
{ type:'homeworldShip', event:'homeworld-event', destination:'homeworld:richese', sources:{},
noField:'five', noFieldEvent:'marker-event', allyPayment:0 });
});
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
@@ -119,4 +121,23 @@ void test('the marker control sends the selected native return through the norma
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
destination: 'homeworld:richese', sources: {}, noField: 'five', noFieldEvent: 'marker-event',
allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
// A fresh placement must clear companions chosen for a foreign target:
// native reserves already occupy the destination and must not be charged.
view.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
view.richeseNoField!.canShip = true;
states.length = 0;
sent.length = 0;
const companion = render().find(element => element.type === 'input' && element.props.type === 'number');
assert.ok(companion);
(companion.props.onChange as (event: {target: {value: string}}) => void)({target:{value:'2'}});
const freshSelect = render().find(element => element.type === 'select' && element.props['aria-label'] === 'No-Field destination Homeworld');
assert.ok(freshSelect);
(freshSelect.props.onChange as (event: {target: {value: string}}) => void)({target:{value:'homeworld:richese'}});
assert.ok(!render().some(element => element.type === 'input' && element.props.type === 'number'));
const freshButton = render().find(element => element.type === 'button');
assert.ok(freshButton && freshButton.props.disabled === false);
(freshButton.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type:'homeworldShip', event:'homeworld-event',
destination:'homeworld:richese', sources:{}, noField:'zero', noFieldEvent:'marker-event', allyPayment:0 });
});
+9 -2
View File
@@ -54,7 +54,7 @@ for (const tokenId of ['zero', 'three', 'five']) {
});
}
void test('Basic keeps foreign-only marker endpoints and fresh native placement remains unavailable', () => {
void test('Basic keeps foreign-only endpoints while Advanced permits a fresh native marker', () => {
const f = fixture(), basic = { ...f.context, advanced: false };
const intent = { player: 'r', tokenId: 'zero', source: foreign, destination: native };
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody, intent, f.noField), HomeworldCustodyError);
@@ -62,8 +62,15 @@ void test('Basic keeps foreign-only marker endpoints and fresh native placement
{ tokenId: 'zero', controller: 'r', location: { homeworld: native } });
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody,
{ player: 'r', tokenId: 'zero', source: native, destination: other }, nativeMarker), HomeworldCustodyError);
const supply = createRicheseNoField(['zero', 'three', 'five']);
assert.throws(() => quoteRicheseHomeworldMarkerShipment(basic, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, supply), HomeworldCustodyError);
const placed = quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, supply);
assert.equal(placed.cost, 1);
assert.deepEqual(placed.noField.deployed?.location, {homeworld:native});
assert.throws(() => quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, createRicheseNoField(['zero', 'three', 'five'])), HomeworldCustodyError);
{ player: 'r', tokenId: 'three', destination: native, forces: {normal:1, elite:0} }, supply), HomeworldCustodyError);
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
{ player: 'r', tokenId: 'zero', source: foreign, destination: foreign }, f.noField), HomeworldCustodyError);
const allied: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat =>
@@ -48,7 +48,8 @@ void test('private marker quote emits the native shipment action without withdra
void test('marker quote respects eligibility, payment, privacy and current action windows', () => {
const view = fixture(), info = view.richeseNoField!;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action, null);
assert.ok(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action);
assert.equal(options.richeseHomeworldMarkerChoice({...view, advanced:false}, 'homeworld:richese', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:unknown', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'missing').action, null);
info.private!.lastShipped = 'zero';
@@ -45,10 +45,10 @@ void test('rates and empty physical reserves do not alter the one-marker invoice
assert.equal(f.custody.visitors['homeworld:atreides'].r.normal, 2);
});
void test('own, allied, inactive and forged destinations or actors reject without mutation', () => {
void test('Basic own, allied, inactive and forged destinations or actors reject without mutation', () => {
for (const change of [
(f: ReturnType<typeof fixture>) => { f.intent.player = 'g'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; f.context.advanced = false; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'a'; f.context.players[1].ally = 'r'; },
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = 1; },
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { freshNativeMarkerFixture, native, owner, pass, player } from './fixture-richese-native-marker';
for (const value of [0, 3, 5] as const) void test(
`Advanced fresh No-Field ${value} reaches native Richese without duplicating reserves`, () => {
const f = freshNativeMarkerFixture(true, value), before = structuredClone(f.game);
assert.equal(f.quote.blocked, null);
assert.equal(f.quote.amount, 1);
assert.equal(f.quote.cost, 1);
assert.ok(f.quote.action);
let game = applyAction(f.game, owner, f.quote.action);
assert.equal(game.response?.kind, 'richeseNoField');
assert.equal(player(game, owner).spice, player(before, owner).spice);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
game = pass(game);
assert.ok(player(game, owner).shipped);
assert.equal(player(game, owner).spice, player(before, owner).spice - 1);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.deepEqual(player(game, owner).noField!.deployed!.location, {homeworld:native});
assert.equal(player(game, owner).noField!.lastShipped, f.token);
const publicMarker = viewGame(game, 'emperor').richeseNoField!;
assert.equal(publicMarker.private, null);
assert.equal(publicMarker.public.deployed?.effectiveForces, 1);
assert.equal(JSON.stringify(publicMarker.public).includes(f.token), false);
const own = player(game, owner);
game = pass(applyAction(game, owner, {type:'revealNoField', token:f.token, event:own.noFieldEvent}));
assert.equal(player(game, owner).noField!.deployed, null);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.deepEqual(f.game, before, 'Applying the shipment leaves its source snapshot intact');
});
void test('Basic rejects fresh native placement while Advanced rejects same-source companions and stale events', () => {
const basic = freshNativeMarkerFixture(false);
const originalBasic = structuredClone(basic.game);
assert.equal(basic.quote.action, null);
assert.ok(basic.quote.blocked);
assert.deepEqual(basic.game, originalBasic);
const f = freshNativeMarkerFixture(true), before = structuredClone(f.game);
const mixed = richeseHomeworldMarkerChoice(f.view, native, f.token, 0, undefined, {normal:1, elite:0});
assert.equal(mixed.action, null, 'Native reserves cannot move from a World back to itself');
assert.ok(mixed.blocked);
assert.ok(f.quote.action);
assert.throws(() => applyAction(f.game, owner, {...f.quote.action!, noFieldEvent:'stale-marker'}));
assert.deepEqual(f.game, before);
});
void test('existing bot profiles retain a legal Homeworld action in the fresh native position', () => {
const f = freshNativeMarkerFixture(true);
for (const [index, difficulty] of DIFFICULTIES.entries()) {
const view = viewGame(f.game, owner);
view.players.find(row => row.id === owner)!.bot = difficulty;
const candidates = homeworldShipmentActions(view, index);
assert.ok(candidates.length, difficulty);
assert.doesNotThrow(() => applyAction(f.game, owner, candidates[0]), difficulty);
const nativeCandidate = candidates.find(action => action.type === 'homeworldShip' &&
action.destination === native && action.noField !== undefined);
assert.ok(nativeCandidate, `${difficulty} can select the native marker route`);
assert.doesNotThrow(() => applyAction(f.game, owner, nativeCandidate), difficulty);
const action = botActions(view)[0]; assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(f.game, owner, action), difficulty);
}
});