+ Provisional concealed No-Field entry: move the marker without revealing its value or materializing
+ reserves. Its existing shipment history stays unchanged. This costs no spice and uses no shipment
+ or normal movement.
+
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 1a850168..9ff468c0 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,43 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**No-Field free Discovery entry — 27 October. Prototyped / Partial.**
+An owned concealed marker may now enter a newly revealed location at the
+original next-turn opportunity, alone or with separately selected physical
+counters. It stays concealed, renews its event and retains token inventory,
+last-shipped identity and reserves. No spice, shipment or movement is consumed.
+Current private source/event and ordinary storm/occupancy/advisor rules remain.
+The chooser separates marker and counters, with persistent provisional guidance;
+minimal policies use the same adapter. Old physical-only signatures keep bytes.
+Public arrival signatures carry marker presence/source without its token ID or
+owner event. The combined grant remains explicitly provisional.
+[Source and contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-free-entry--27-october-2026).
+
+Frozen **8-file union, 48/48**, types/lint/build and nine original programmes
+pass. Actual public Basic/Advanced R/A/E+CHOAM starts opt Discoveries in before
+Ready/start. The original Hagga Discovery samples an original Cistern/Shrine
+supply face at GaraKulon8. Paid marker2, actual Collection inspection/reveal,
+Mentat and next-turn entry earn the offer. Mixed cases first ship physical1 on
+turn1, marker5 on turn2, then reveal in Collection2 before turn3 entry.
+
+Runtime records27 GameTable SSR,9 actual chooser SSR,8 actual default-selected
+Move callbacks and1 Leave callback; source/further arrays497/9. Marker0/3/5,
+both modes, mixed1+5 and decline resume native Storm with actual computed action
+allowances unchanged. Checkbox/number-input changes, browser hydration/navigation
+and native BG reaction journeys were not operated. Other source controls use
+native packets. No played hand, purse, army, phase, marker/event, token, reveal
+or entry frame is installed. Controlled cases separately cover stale metadata,
+source/occupancy boundaries and public-signature/legacy-byte composition.
+
+V1 type feedback repaired the test's computed movement-allowance observation;
+v2 repeats frozen checks/runtime using each actual owner view. Failed v1 is
+preserved. Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-entry-proof-v2/`;
+journeys/HTML: adjacent `20261027-discovery-no-field-entry-runtime/run-v2/`.
+Only a recorded three-document evidence overlay follows verification. Existing
+server HTTP200, no restart or saved-game/database operation. Broader combinations,
+complete modes and final rules/recovery/privacy/custody/browser/deployed
+acceptance remain open.
+
**Discovery blow / No-Field — 27 October. Prototyped / Partial.**
An original Discovery blow now reveals an affected concealed marker through a
distinct `discoveryBlow` cause, materializes only available reserves and sends
diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md
index 50fe63ca..159acbdf 100644
--- a/docs/DISCOVERY_PROTOTYPE.md
+++ b/docs/DISCOVERY_PROTOTYPE.md
@@ -5,6 +5,56 @@ partial rules coverage. It connects physical components, real actions, private
controls, AI choices and saved continuation. Normal public Discovery and
Advanced start gates, and the publication gate, remain closed.
+## Concealed No-Field free entry — 27 October 2026
+
+Publisher E3 printed12's independent next-turn entry grant, repeated in supplied
+Advanced physical23/printed21, permits any number of non-advisor forces from
+the surrounding territory before Storm/HMS movement. E3p13 defines the separate
+location territory. E2 FAQ10/supplied physical36 B count a concealed No-Field as
+one, including0. No specific combined answer or free-entry reveal requirement
+was found in targeted publisher/designer research. Marker entry is an explicit
+provisional composition, not an expansion of ordinary Force Movement timing.
+
+The current owner may select the marker alone or alongside exact normal/elite
+physical groups. Quote amount counts counters plus one marker; physical custody
+moves counters only. The marker moves concealed to sector0 and renews its event,
+retaining last-shipped identity and original reserves. It spends no spice,
+shipment or ordinary movement. Decline or physical-only selection leaves it
+unchanged. Existing source-storm/occupancy/advisor restrictions and original
+turn-opening order remain; the grant is not limited to the token's parent sector.
+
+Owner-only offers contain optional `{tokenId,event,source}` metadata. Decision
+packets retain the Discovery `event` and add `noField`/`noFieldEvent` separately.
+Pending arrivals bind that private metadata on the server; public arrival
+signatures include only marker presence/source. Existing child interactions keep
+the effective one-force presence without carrying token identity/owner event.
+Old physical-only signatures retain bytes. The entry panel selects the marker
+separately, gives persistent provisional guidance and uses the same adapter as
+minimal policies. [No-Field scope](RICHESE_NO_FIELD_RULES.md#free-entry-into-a-revealed-discovery-location--27-october-2026).
+
+### Scoped native evidence
+
+Frozen8-file union48/48, types/lint/build and nine original programmes pass.
+Public Basic/Advanced R/A/E+CHOAM sources opt Discoveries in before Ready/start.
+The actual first Hagga blow samples Cistern/Shrine supply at GaraKulon8; paid
+marker2, real Collection inspection/reveal and turn end create original entry.
+Mixed cases earn physical1turn1, marker5turn2 and Collection2 reveal before
+turn3 entry. Marker0/3/5, mixed1+5 and decline preserve reserves/history and actual
+computed ordinary allowances while resuming native Storm.
+
+Runtime records27 GameTable SSR,9 actual chooser renders,8 default-selected
+Move callbacks and1 Leave callback; source/further arrays497/9. Checkbox/number
+changes and browser hydration/navigation were not operated. Other controls use
+native packets. No played hand/purse/army/phase/marker/event/token/reveal or entry
+frame is installed. Controlled cases separately cover source/event/occupancy,
+public signature and physical-byte parity. Native BG reactions, wider modules,
+full modes and final recovery/privacy/custody/deployed acceptance remain open.
+V1's test allowance observation was corrected to actual owner-view computation;
+v2 uses one frozen source, followed by a three-document evidence overlay.
+Existing server HTTP200; no restart or saved-game/database operation.
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-entry-proof-v2/`;
+runtime: adjacent `20261027-discovery-no-field-entry-runtime/run-v2/`.
+
## Concealed No-Field at a Discovery blow — 27 October 2026
Publisher E3 physical/printed12 and supplied Advanced physical23/printed21
@@ -712,7 +762,8 @@ choice discards any held card back to five.
No-Field custody now recognizes the five known nested Discovery IDs at sector
zero. This does not open an unrevealed location: declaration/commitment and
materialization require the current revealed board. A concealed token is still
-one effective presence, not physical reserves or a source for free entry.
+one effective presence rather than physical reserves. The27October extension
+above separately admits an owned concealed marker to free entry.
Its original value-zero/three/five reveal materializes only current reserve
forces, once. The shipment controls label nested sector zero as inside the
location rather than Polar Sink.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index a7703aac..a806e488 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,44 @@
# Dune implementation status
+## 27 October 2026 — No-Field free Discovery entry
+
+**No-Field free Discovery entry — 27 October. Prototyped / Partial.**
+An owned concealed marker may now enter a newly revealed location at the
+original next-turn opportunity, alone or with separately selected physical
+counters. It stays concealed, renews its event and retains token inventory,
+last-shipped identity and reserves. No spice, shipment or movement is consumed.
+Current private source/event and ordinary storm/occupancy/advisor rules remain.
+The chooser separates marker and counters, with persistent provisional guidance;
+minimal policies use the same adapter. Old physical-only signatures keep bytes.
+Public arrival signatures carry marker presence/source without its token ID or
+owner event. The combined grant remains explicitly provisional.
+[Source and contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-free-entry--27-october-2026).
+
+Frozen **8-file union, 48/48**, types/lint/build and nine original programmes
+pass. Actual public Basic/Advanced R/A/E+CHOAM starts opt Discoveries in before
+Ready/start. The original Hagga Discovery samples an original Cistern/Shrine
+supply face at GaraKulon8. Paid marker2, actual Collection inspection/reveal,
+Mentat and next-turn entry earn the offer. Mixed cases first ship physical1 on
+turn1, marker5 on turn2, then reveal in Collection2 before turn3 entry.
+
+Runtime records27 GameTable SSR,9 actual chooser SSR,8 actual default-selected
+Move callbacks and1 Leave callback; source/further arrays497/9. Marker0/3/5,
+both modes, mixed1+5 and decline resume native Storm with actual computed action
+allowances unchanged. Checkbox/number-input changes, browser hydration/navigation
+and native BG reaction journeys were not operated. Other source controls use
+native packets. No played hand, purse, army, phase, marker/event, token, reveal
+or entry frame is installed. Controlled cases separately cover stale metadata,
+source/occupancy boundaries and public-signature/legacy-byte composition.
+
+V1 type feedback repaired the test's computed movement-allowance observation;
+v2 repeats frozen checks/runtime using each actual owner view. Failed v1 is
+preserved. Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-entry-proof-v2/`;
+journeys/HTML: adjacent `20261027-discovery-no-field-entry-runtime/run-v2/`.
+Only a recorded three-document evidence overlay follows verification. Existing
+server HTTP200, no restart or saved-game/database operation. Broader combinations,
+complete modes and final rules/recovery/privacy/custody/browser/deployed
+acceptance remain open.
+
## 27 October 2026 — Discovery blow with concealed No-Field
**Discovery blow / No-Field — 27 October. Prototyped / Partial.**
diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md
index 2f911949..ed75bed7 100644
--- a/docs/RICHESE_NO_FIELD_RULES.md
+++ b/docs/RICHESE_NO_FIELD_RULES.md
@@ -2,6 +2,29 @@
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.
+## Free entry into a revealed Discovery location — 27 October 2026
+
+E3 printed12 and supplied Advanced physical23/printed21 grant a separate move
+before next-turn Storm: any number of non-advisor forces from the surrounding
+territory into the newly revealed location. E3p13 gives that location a separate
+territory. Compose the No-Field one-force rule (E2 FAQ10/supplied physical36 B)
+provisionally: an owned concealed marker can enter alone or with independently
+selected physical groups. No printed free-entry reveal requirement was found.
+Targeted publisher/designer searching found no dedicated combined ruling.
+
+The marker remains concealed, retains token inventory and last-shipped identity,
+and renews its owner event. No reserves materialize; no spice, ordinary shipment
+or movement is consumed. Physical counters and the marker are displayed and
+transferred separately, even for0. Existing source-storm, occupancy, advisor,
+entry-order and arrival-response rules remain. This is the Discovery move grant,
+not a new reading of the ordinary Force Movement window.
+
+Selection binds the current private token/event separately from the Discovery
+decision event. Pending arrivals keep private marker custody on the server;
+public arrival signatures carry only source and marker presence, not token ID,
+event or denomination. Physical-only frames retain their existing signature
+bytes. [Discovery contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-free-entry--27-october-2026).
+
## Discovery spice-blow destruction — 27 October 2026
Publisher E3 physical/printed12 and supplied Advanced physical23/printed21
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 33c7034c..cd968ce7 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,18 @@
# Rule decision index
+## Concealed No-Field free Discovery entry — 27 October 2026
+
+E3printed12 and supplied Advanced physical23/printed21 grant a separate
+next-turn-before-Storm move of non-advisor surrounding forces; E3p13 defines
+the location territory. Under standing recommended-option authority, compose
+that independent grant with E2FAQ10/supplied physical36 B's marker-as-one rule.
+An owned concealed marker may enter alone/mixed without materialization,
+spice, shipment or normal movement, preserving last-shipped identity and renewing
+its owner event. Existing storm/occupancy/advisor/order rules remain. No specific
+combined ruling or mandatory free-entry reveal was found; this is provisional,
+not an expansion of ordinary Force Movement timing. Private metadata stays off
+public arrival signatures. [Contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-free-entry--27-october-2026).
+
## Discovery blow and concealed No-Field — 27 October 2026
Publisher E3physical/printed12 and supplied Advanced physical23/printed21
diff --git a/game/discovery-entry-options.ts b/game/discovery-entry-options.ts
index 61f7ea2f..10644398 100644
--- a/game/discovery-entry-options.ts
+++ b/game/discovery-entry-options.ts
@@ -1,8 +1,10 @@
import type { Action, GameView } from './engine';
import type {
DiscoveryEntryOffer,
+ DiscoveryEntryNoField,
DiscoveryEntrySource,
} from './discovery-entry';
+import { splitLocation } from './board';
function entryDecision(game: GameView) {
const decision = game.decision;
@@ -32,8 +34,9 @@ function whole(value: unknown): value is number {
function selectedGroups(
offer: DiscoveryEntryOffer,
groups: readonly DiscoveryEntrySource[],
+ allowEmpty = false,
): DiscoveryEntrySource[] | null {
- if (!Array.isArray(groups) || !groups.length) return null;
+ if (!Array.isArray(groups) || (!groups.length && !allowEmpty)) return null;
const selected = groups
.map((group) => ({ ...group }))
.sort((a, b) => a.source.localeCompare(b.source));
@@ -61,16 +64,31 @@ function selectedGroups(
export function discoveryEntryMoveAction(
game: GameView,
groups: readonly DiscoveryEntrySource[],
+ noField?: DiscoveryEntryNoField,
): Action | null {
const entry = currentEntry(game);
if (!entry) return null;
- const selected = selectedGroups(entry.offer, groups);
+ if (noField !== undefined) {
+ const offered = entry.offer.noField;
+ if (!noField || typeof noField !== 'object' || Array.isArray(noField) ||
+ Object.keys(noField).sort().join(',') !== 'event,source,tokenId' ||
+ typeof noField.tokenId !== 'string' || !noField.tokenId.trim() ||
+ typeof noField.event !== 'string' || !noField.event.trim() ||
+ typeof noField.source !== 'string' ||
+ !offered || noField.tokenId !== offered.tokenId || noField.event !== offered.event ||
+ noField.source !== offered.source) return null;
+ const source = splitLocation(noField.source);
+ if (source.territory !== entry.offer.parent || noField.source !== `${source.territory}:${source.sector}`)
+ return null;
+ }
+ const selected = selectedGroups(entry.offer, groups, noField !== undefined);
return selected
? {
type: 'decision',
event: entry.decision.event,
accept: true,
groups: selected,
+ ...(noField ? { noField: noField.tokenId, noFieldEvent: noField.event } : {}),
}
: null;
}
@@ -84,11 +102,11 @@ export function discoveryEntryDeclineAction(
: null;
}
-/** Uses only the current player's projected physical source groups. */
+/** Uses only the current player's projected groups and offered owned marker. */
export function discoveryEntryBotActions(game: GameView): Action[] {
const offer = game.discoveryEntry;
if (!currentEntry(game) || !offer) return [];
- const move = discoveryEntryMoveAction(game, offer.sources),
+ const move = discoveryEntryMoveAction(game, offer.sources, offer.noField),
decline = discoveryEntryDeclineAction(game);
return [move, decline].filter((action): action is Action => action !== null);
}
diff --git a/game/discovery-entry.ts b/game/discovery-entry.ts
index e14b9646..e227c64f 100644
--- a/game/discovery-entry.ts
+++ b/game/discovery-entry.ts
@@ -10,6 +10,7 @@ import {
} from './discoveries';
import { splitLocation, validGameLocation, type MobileBoard } from './board';
import { territoryEntryBlock, type OccupancySeat } from './occupancy';
+import type { RicheseNoField } from './richese-no-field';
export class DiscoveryEntryError extends Error {}
@@ -29,6 +30,8 @@ export type DiscoveryEntrySeat = OccupancySeat & {
faction: FactionId;
forces: Record;
elites?: { forces: Record };
+ noField?: RicheseNoField | null;
+ noFieldEvent?: string;
};
export type DiscoveryEntryContext = Omit & {
@@ -45,6 +48,12 @@ export type DiscoveryEntrySource = {
elite: number;
};
+export type DiscoveryEntryNoField = {
+ tokenId: string;
+ event: string;
+ source: string;
+};
+
export type DiscoveryEntryArrival = {
event: string;
token: DiscoveryOpaqueTokenId;
@@ -55,6 +64,7 @@ export type DiscoveryEntryArrival = {
destinationBefore: number;
destinationEliteBefore: number;
groups: DiscoveryEntrySource[];
+ noField?: DiscoveryEntryNoField;
amount: number;
elite: number;
signature: string;
@@ -74,6 +84,7 @@ export type DiscoveryEntryRound = {
export type DiscoveryEntrySelection = {
groups: readonly DiscoveryEntrySource[];
+ noField?: DiscoveryEntryNoField;
};
export type DiscoveryEntryOffer = {
@@ -84,6 +95,7 @@ export type DiscoveryEntryOffer = {
destination: DiscoveryLocationId;
sector: 0;
sources: DiscoveryEntrySource[];
+ noField?: DiscoveryEntryNoField;
blocked: string | null;
arrival: DiscoveryEntryArrival | null;
};
@@ -110,6 +122,7 @@ function arrivalPayload(arrival: Omit) {
arrival.groups.map((group) => [group.source, group.normal, group.elite]),
arrival.amount,
arrival.elite,
+ ...(arrival.noField ? [[true, arrival.noField.source]] : []),
];
}
@@ -161,6 +174,13 @@ function strictSource(value: unknown): value is DiscoveryEntrySource {
);
}
+function strictNoField(value: unknown): value is DiscoveryEntryNoField {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const marker = value as DiscoveryEntryNoField;
+ return Object.keys(marker).sort().join(',') === 'event,source,tokenId' &&
+ identifier(marker.tokenId) && identifier(marker.event) && identifier(marker.source);
+}
+
function validateInventory(discoveries: DiscoveryState) {
try {
validateDiscoveryState(discoveries);
@@ -181,7 +201,10 @@ function validateArrivalShape(
typeof arrival === 'object' &&
!Array.isArray(arrival) &&
Object.keys(arrival).sort().join(',') ===
- 'amount,destination,destinationBefore,destinationEliteBefore,elite,event,groups,owner,parent,sector,signature,token' &&
+ (Object.hasOwn(arrival, 'noField')
+ ? 'amount,destination,destinationBefore,destinationEliteBefore,elite,event,groups,noField,owner,parent,sector,signature,token'
+ : 'amount,destination,destinationBefore,destinationEliteBefore,elite,event,groups,owner,parent,sector,signature,token') &&
+ (!Object.hasOwn(arrival, 'noField') || strictNoField(arrival.noField)) &&
arrival.event === `${frame.event}:${frame.cursor}` &&
arrival.token === current.token &&
arrival.owner === current.owner &&
@@ -192,7 +215,7 @@ function validateArrivalShape(
whole(arrival.destinationEliteBefore) &&
arrival.destinationEliteBefore <= arrival.destinationBefore &&
Array.isArray(arrival.groups) &&
- arrival.groups.length > 0 &&
+ (arrival.groups.length > 0 || !!arrival.noField) &&
arrival.groups.every(strictSource) &&
arrival.groups.every(
(group, index) =>
@@ -202,12 +225,12 @@ function validateArrivalShape(
whole(arrival.amount) &&
arrival.amount > 0 &&
whole(arrival.elite) &&
- arrival.elite <= arrival.amount &&
+ arrival.elite <= arrival.amount - (arrival.noField ? 1 : 0) &&
arrival.amount ===
arrival.groups.reduce(
(sum, group) => sum + group.normal + group.elite,
0,
- ) &&
+ ) + (arrival.noField ? 1 : 0) &&
arrival.elite ===
arrival.groups.reduce((sum, group) => sum + group.elite, 0) &&
arrival.signature === discoveryEntryArrivalSignature(arrival),
@@ -267,6 +290,12 @@ export function validateDiscoveryEntryRound(
const token = g.discoveries.tokens.find(
(candidate) => candidate.id === frame.arrival!.token,
)!;
+ const validSource = (key: string) => {
+ const source = splitLocation(key);
+ return key === `${source.territory}:${source.sector}` &&
+ source.territory === frame.arrival!.parent && source.sector !== g.storm &&
+ validGameLocation(g, source.territory, source.sector);
+ };
const validSources = frame.arrival.groups.every((group) => {
const source = splitLocation(group.source);
return (
@@ -275,7 +304,7 @@ export function validateDiscoveryEntryRound(
source.sector !== g.storm &&
validGameLocation(g, source.territory, source.sector)
);
- });
+ }) && (!frame.arrival.noField || validSource(frame.arrival.noField.source));
requireEntry(
token.territory === frame.arrival.parent &&
token.face === frame.arrival.destination &&
@@ -342,6 +371,18 @@ function sourceOffer(
return sources.sort((a, b) => a.source.localeCompare(b.source));
}
+/** A current owned marker is independently eligible; its value and reserves
+ * do not supply physical counters or alter this optional relocation. */
+function noFieldOffer(g: DiscoveryEntryContext, player: DiscoveryEntrySeat, parent: string): DiscoveryEntryNoField | undefined {
+ const marker = player.noField?.deployed;
+ if (player.faction !== 'richese' || !marker || marker.controller !== player.id ||
+ !identifier(marker.tokenId) || !identifier(player.noFieldEvent) ||
+ marker.location.territory !== parent || marker.location.sector === g.storm ||
+ !validGameLocation(g, marker.location.territory, marker.location.sector)) return undefined;
+ return { tokenId: marker.tokenId, event: player.noFieldEvent,
+ source: `${marker.location.territory}:${marker.location.sector}` };
+}
+
function currentOffer(g: DiscoveryEntryContext, frame: DiscoveryEntryRound) {
const current = step(frame);
const player = g.players.find((candidate) => candidate.id === current.owner)!;
@@ -391,6 +432,7 @@ export function quoteDiscoveryEntry(
'This Discovery entry belongs to another player.',
);
const sources = sourceOffer(g, player, parent);
+ const noField = noFieldOffer(g, player, parent);
let blocked: string | null = null;
try {
blocked = territoryEntryBlock(g.players, owner, destination);
@@ -399,7 +441,7 @@ export function quoteDiscoveryEntry(
error instanceof Error ? error.message : 'Invalid Discovery occupancy.',
);
}
- if (!blocked && !sources.length)
+ if (!blocked && !sources.length && !noField)
blocked =
'No non-advisor forces outside the storm can enter this location.';
const base = {
@@ -410,6 +452,7 @@ export function quoteDiscoveryEntry(
destination,
sector: 0 as const,
sources,
+ ...(noField ? { noField: { ...noField } } : {}),
blocked,
};
if (!selection) return { ...base, arrival: null };
@@ -418,12 +461,17 @@ export function quoteDiscoveryEntry(
selection &&
typeof selection === 'object' &&
!Array.isArray(selection) &&
- Object.keys(selection).join(',') === 'groups' &&
+ Object.keys(selection).sort().join(',') === (Object.hasOwn(selection, 'noField') ? 'groups,noField' : 'groups') &&
+ (!Object.hasOwn(selection, 'noField') || strictNoField(selection.noField)) &&
Array.isArray(selection.groups) &&
- selection.groups.length > 0 &&
+ (selection.groups.length > 0 || !!selection.noField) &&
selection.groups.every(strictSource),
'Choose explicit positive ordinary and elite source groups.',
);
+ if (selection.noField)
+ requireEntry(noField && selection.noField.tokenId === noField.tokenId &&
+ selection.noField.event === noField.event && selection.noField.source === noField.source,
+ 'Choose the current owned No-Field, event and parent source for this Discovery entry.');
const groups = selection.groups
.map((group) => ({ ...group }))
.sort((a, b) => a.source.localeCompare(b.source));
@@ -459,8 +507,9 @@ export function quoteDiscoveryEntry(
destinationBefore,
destinationEliteBefore,
groups,
- amount: groups.reduce((sum, group) => sum + group.normal + group.elite, 0),
+ amount: groups.reduce((sum, group) => sum + group.normal + group.elite, 0) + (selection.noField ? 1 : 0),
elite: groups.reduce((sum, group) => sum + group.elite, 0),
+ ...(selection.noField ? { noField: { ...selection.noField } } : {}),
};
const arrival: DiscoveryEntryArrival = {
...unsigned,
@@ -501,7 +550,7 @@ export function advanceDiscoveryEntry(
return next;
}
-/** Sign an accepted source selection before the caller moves physical forces. */
+/** Sign a selection before the caller transfers physical forces and any marker. */
export function beginDiscoveryEntryArrival(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
@@ -518,6 +567,7 @@ export function beginDiscoveryEntryArrival(
arrival: {
...quote.arrival,
groups: quote.arrival.groups.map((group) => ({ ...group })),
+ ...(quote.arrival.noField ? { noField: { ...quote.arrival.noField } } : {}),
},
signature: '',
};
diff --git a/game/engine.ts b/game/engine.ts
index b3d3d937..ae9b0629 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -13288,6 +13288,17 @@ function discoveryEntryIntegrity(g: Game) {
requireRule(g.discoveryEnabled && g.discoveries && g.status === 'playing' && g.phase === 0 &&
frame.stage !== 'complete', 'Discovery entry must finish before the Storm Phase begins.');
nexusRule(() => validateDiscoveryEntryRound({...g, discoveries:g.discoveries!},frame));
+ if (frame.stage === 'arrival') {
+ const arrival = frame.arrival!, owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`;
+ const physical = arrival.amount - Number(!!arrival.noField), marker = arrival.noField;
+ requireRule((owner.forces[key] ?? 0) === arrival.destinationBefore + physical &&
+ (owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
+ 'The Discovery arrival has lost its transferred ordinary or elite force custody.');
+ if (marker) requireRule(owner.faction === 'richese' && owner.noField?.deployed?.tokenId === marker.tokenId &&
+ owner.noField.deployed.controller === owner.id && owner.noField.deployed.location.territory === arrival.destination &&
+ owner.noField.deployed.location.sector === 0 && !!owner.noFieldEvent && owner.noFieldEvent !== marker.event,
+ 'The Discovery arrival has lost its transferred concealed No-Field.');
+ }
const automatic = g.pendingAutomaticAdvisorRelocation;
if (automatic?.receipt.kind === 'discovery') {
requireRule(frame.stage === 'arrival' && !choices.length && !children.length,
@@ -13308,10 +13319,6 @@ function discoveryEntryIntegrity(g: Game) {
choices[0].event === `${frame.event}:${frame.cursor}`, 'The Discovery entry has lost its owned choice.');
} else {
requireRule(!choices.length && children.length === 1, 'The Discovery entry has lost its pending arrival interaction.');
- const arrival = frame.arrival!, owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`;
- requireRule(owner.forces[key] === arrival.destinationBefore + arrival.amount &&
- (owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
- 'The Discovery arrival has lost its transferred ordinary or elite force custody.');
for (const child of children) {
requireRule(child.valid && child.actor === byFaction(g,'beneGesserit')?.id &&
child.territory === frame.arrival!.destination && child.receipt,
@@ -13346,25 +13353,40 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
const frame = g.discoveryEntry;
requireRule(frame && g.discoveries && typeof action.accept === 'boolean' &&
action.event === `${frame.event}:${frame.cursor}` &&
- Object.keys(action).every(key => ['type','event','accept','groups'].includes(key)),
- 'Choose the current Discovery entry and explicit physical force groups.');
+ Object.keys(action).every(key => ['type','event','accept','groups','noField','noFieldEvent'].includes(key)),
+ 'Choose the current Discovery entry, physical groups and optional owned No-Field.');
if (!action.accept) {
- requireRule(action.groups === undefined, 'Declining Discovery entry must not select forces.');
+ requireRule(action.groups === undefined && action.noField === undefined && action.noFieldEvent === undefined,
+ 'Declining Discovery entry must not select forces or a No-Field.');
g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,p.id));
log(g, `${p.name} declined this optional free Discovery entry.`);
continueDiscoveryEntry(g);
return;
}
+ const marker = action.noField === undefined && action.noFieldEvent === undefined ? undefined : (() => {
+ requireRule(p.noField?.deployed, 'Choose your currently deployed concealed No-Field.');
+ return {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent),
+ source:location(p.noField.deployed.location.territory,p.noField.deployed.location.sector)};
+ })();
g.discoveryEntry = nexusRule(() => beginDiscoveryEntryArrival({...g,discoveries:g.discoveries!},frame,p.id,
- {groups:action.groups as DiscoveryEntrySelection['groups']}));
+ {groups:action.groups as DiscoveryEntrySelection['groups'],...(marker?{noField:marker}:{})}));
const arrival = g.discoveryEntry.arrival!;
+ const physical = arrival.amount - Number(!!arrival.noField);
const advisors = arrivalAsAdvisor(g,p,arrival.destination,arrival.parent);
removeGroup(p,arrival.groups.map(group => [group.source,group.normal+group.elite]),
Object.fromEntries(arrival.groups.map(group => [group.source,group.elite])));
- place(p,arrival.destination,0,arrival.amount,arrival.elite);
+ if (arrival.noField) {
+ p.noField = noFieldRule(() => moveRicheseNoField(p.noField!,arrival.noField!.tokenId,
+ {territory:arrival.destination,sector:0}));
+ p.noFieldEvent = crypto.randomUUID();
+ }
+ if (physical) place(p,arrival.destination,0,physical,arrival.elite);
if (advisors) (p.advisors ??= {})[arrival.destination] ??= {};
else if (p.advisors) delete p.advisors[arrival.destination];
- log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`);
+ const group = arrival.noField
+ ? `${physical ? `${physical} physical ${physical === 1 ? 'force' : 'forces'} and ` : ''}one concealed No-Field`
+ : `${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}`;
+ log(g, `${p.name} moved ${group}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.${arrival.noField ? ' Provisional marker entry preserves concealment, reserves and the last-shipment restriction.' : ''}`);
const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature};
if (prepareRelocationAdvisorFlips(g, {kind:'discovery',player:p.id,origin:arrival.parent,
diff --git a/game/reference.ts b/game/reference.ts
index 198f026c..945bba9f 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -5736,6 +5736,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.',
'A printed Discovery blow destroys all old spice and forces in its target territory, including Fremen. A concealed No-Field provisionally reveals, materializes only available reserves and loses those actual counters with its physical companions; zero creates no casualty. This destructive reveal is a selected interpretation. It preserves token history and the original new-spice, storm and face-down placement sequence. Separately identified nested locations and other territories stay outside the parent target. Continue through normal Ready and Accept spice blow controls.',
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
+ 'Provisional free entry also offers your current owned concealed No-Field separately from physical counters, alone or in a mixed group. It enters the revealed location concealed, including zero, renews its owner event and preserves last-shipped identity and reserves. It spends no spice, ordinary shipment or movement. Select or clear the marker separately; declining or moving only physical counters leaves it outside. Current ownership/event, source storm, normal occupancy and arrival interactions remain. This combines the independent Discovery move grant with one-force marker presence, without a specific combined ruling.',
'After advisor releases, every Cistern fighter occupant receives two bank spice. Orgiz occupants resolve in original storm order using actual ordinary deposit receipts, not mere force presence or old wallets. Advanced charges once per territory; Basic provisionally charges once per positive deposit. The first actual foreign ordinary collector in storm order with available spice pays. Shared Ecaz lots in both modes wait for the actual split, then consider positive foreign recipients Ecaz first, ally second; Advanced counts the territory once and Basic counts actually reduced source deposits. Self, zero shares and empty purses do not qualify. Balances track each transfer without debt; ordering and Basic mapping remain provisional.',
'Jacurutu pays one bank spice for each opposing undialed physical force actually sent to the Tanks. Where an Advanced dial admits different physical counts, the defeated player chooses an exact ordinary/elite/paid reading before support, casualties or rewards settle; unique readings remain automatic. This choice timing is a visibly provisional implementation interpretation. Original Harass returns bind their physical dial and actual withdrawal/retreat counters do not earn spice.',
'Each non-advisor Testing Station occupant may decrease ordinary storm movement by one, keep it or increase it by one. The provisional order is the original storm order, once per owner, with cumulative adjustments on current distance. Choose after the ordinary card window; traversal/protection starts only after all choices. Weather Control is unaffected. Original natural source/base and existing sole-frame history remain authoritative; legacy movement without a source is not invented.',
@@ -5747,17 +5748,17 @@ export const RULE_TOPICS: RuleTopic[] = [
checklist: [
{
area: 'Implementation', status: 'Partial',
- detail: 'Concealed No-Fields now follow provisional destructive revelation at a printed Discovery blow through a distinct cause and all-player preflight; only actual reserve-capped counters are lost before original placement. Original classic and supported native Skills/Nexus/Discovery entries retain their selected modules and controls. Jacurutu uses an exact defeated-player reading before native settlement; Testing Station queues cumulative choices before one traversal. Cistern pays every fighter occupant. Orgiz uses actual ordinary receipts and bilateral shared allocations in both modes, grouping by territory in Advanced and positive source deposit in Basic without guessed payers or debt. Available balances track all transfers and cumulative overflow. Broader mode/module acceptance remains open.',
+ detail: 'Owned concealed No-Fields may also use the original next-turn free entry alone/mixed, with private metadata, public source-only signature, exact physical custody and no action/counter materialization. Concealed No-Fields now follow provisional destructive revelation at a printed Discovery blow through a distinct cause and all-player preflight; only actual reserve-capped counters are lost before original placement. Original classic and supported native Skills/Nexus/Discovery entries retain their selected modules and controls. Jacurutu uses an exact defeated-player reading before native settlement; Testing Station queues cumulative choices before one traversal. Cistern pays every fighter occupant. Orgiz uses actual ordinary receipts and bilateral shared allocations in both modes, grouping by territory in Advanced and positive source deposit in Basic without guessed payers or debt. Available balances track all transfers and cumulative overflow. Broader mode/module acceptance remains open.',
evidence: ['game/disaster-preflight.ts', 'game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/board-resolution-quote.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
},
{
area: 'Player controls', status: 'Partial',
- detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard, ordered Great Maker, free entry and carried movement use existing controls. Jacurutu offers exact physical readings only to the defeated owner before settlement. Testing Station offers cumulative legal distances to each current owner in original storm order. Unique rewards remain automatic; Shrine uses original card controls.',
+ detail: 'Free entry selects an owned marker separately from physical groups, with a distinct event fence and persistent provisional guidance. Private backs and readable authorized faces, optional inspection/reveal, owned stash discard, ordered Great Maker, free entry and carried movement use existing controls. Jacurutu offers exact physical readings only to the defeated owner before settlement. Testing Station offers cumulative legal distances to each current owner in original storm order. Unique rewards remain automatic; Shrine uses original card controls.',
evidence: ['components/discoveries.tsx', 'components/jacurutu-dial.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'],
},
{
area: 'AI', status: 'Partial',
- detail: 'Four profiles share private quoted inspection, discard, vote, ride, entry and carried-flight actions. Properly configured policies select a legal Jacurutu physical reading and each current Testing Station owner uses the same cumulative controls. Shrine uses original Karama/Truthtrance policies. Strategy, combined games and calibration remain unfinished.',
+ detail: 'Minimal free-entry policies include the owned offered marker through the shared selection adapter. Four profiles share private quoted inspection, discard, vote, ride, entry and carried-flight actions. Properly configured policies select a legal Jacurutu physical reading and each current Testing Station owner uses the same cumulative controls. Shrine uses original Karama/Truthtrance policies. Strategy, combined games and calibration remain unfinished.',
evidence: ['game/discovery-options.ts', 'game/discovery-entry-options.ts', 'game/discovery-flight-options.ts', 'game/discovery-storm-options.ts', 'game/shrine.ts', 'game/great-maker-options.ts', 'game/bots.ts'],
},
{
@@ -5768,7 +5769,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
area: 'Verification', status: 'Partial',
detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.',
- evidence: ['tests/fixture-discovery-no-field-blow.ts', 'tests/discovery-no-field-blow-engine.test.ts', 'tests/discovery-no-field-blow-preflight.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
+ evidence: ['tests/fixture-discovery-no-field-entry.ts', 'tests/discovery-no-field-entry-engine.test.ts', 'tests/discovery-no-field-entry-quote.test.ts', 'tests/discovery-no-field-entry-controls.test.tsx', 'tests/fixture-discovery-no-field-blow.ts', 'tests/discovery-no-field-blow-engine.test.ts', 'tests/discovery-no-field-blow-preflight.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
'tests/fixture-jacurutu-mixed-dial.ts', 'tests/jacurutu-mixed-dial-runtime.test.ts', 'tests/fixture-testing-station-contest.ts', 'tests/testing-station-contest-runtime.test.ts',
'tests/fixture-discovery-occupied-collection.ts', 'tests/discovery-occupied-collection-runtime.test.ts', 'tests/fixture-discovery-shared-orgiz.ts', 'tests/discovery-shared-orgiz-runtime.test.ts',
'tests/board-resolution-quote.test.ts', 'tests/ecaz-collection-quote.test.ts',
diff --git a/tests/discovery-no-field-entry-controls.test.tsx b/tests/discovery-no-field-entry-controls.test.tsx
new file mode 100644
index 00000000..d3ccf22b
--- /dev/null
+++ b/tests/discovery-no-field-entry-controls.test.tsx
@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { GameView } from '../game/engine';
+import type { DiscoveryEntryOffer } from '../game/discovery-entry';
+import {
+ discoveryEntryBotActions, discoveryEntryDeclineAction, discoveryEntryMoveAction,
+} from '../game/discovery-entry-options';
+import { DiscoveryEntryDecision } from '../components/discovery-entry';
+
+/** Controlled public projection only. These cases establish rendering and exact
+ * action construction; they do not establish a native reveal/entry chronology
+ * or claim to execute the stateful chooser's React event handlers. */
+function view(physical = 0, marker = true): GameView {
+ const offer: DiscoveryEntryOffer = { event: 'entry-round:0', token: 'discovery-token-1', owner: 'r',
+ parent: 'meridian', destination: 'jacurutu-sietch', sector: 0,
+ sources: physical ? [{ source: 'meridian:2', normal: physical, elite: 0 }] : [],
+ ...(marker ? { noField: { tokenId: 'private-marker-id', event: 'private-marker-event', source: 'meridian:2' } } : {}),
+ blocked: null, arrival: null };
+ return { status: 'playing', phase: 0, turn: 3, me: 'r',
+ decision: { kind: 'discoveryEntry', player: 'r', event: offer.event }, discoveryEntry: offer,
+ players: [{ id: 'r', name: 'Richese', faction: 'richese', autopilot: null }] } as unknown as GameView;
+}
+function html(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(DiscoveryEntryDecision, { game, busy, act() {} }));
+}
+
+void test('marker-only entry defaults to a selected independent checkbox and explains concealed free relocation without a physical counter', () => {
+ const game = view(), markup = html(game);
+ assert.match(markup, /type="checkbox"[^>]*checked=""/);
+ assert.match(markup, /aria-label="Move concealed No-Field inside"/);
+ assert.match(markup, /Selected: 0 physical forces · 1 concealed marker/);
+ assert.match(markup, /Move 0 physical forces and 1 concealed marker inside/);
+ assert.match(markup, /Provisional concealed No-Field entry/);
+ assert.match(markup, /without revealing its value or materializing reserves/);
+ assert.match(markup, /existing shipment history stays unchanged/);
+ assert.match(markup, /costs no spice and uses no shipment or normal movement/);
+ assert.match(markup, /Select all forces and marker/);
+ assert.match(markup, /Clear selection/);
+ assert.match(markup, /Leave forces and marker outside/);
+ assert.doesNotMatch(markup, /type="number"|private-marker-id|private-marker-event/);
+});
+
+void test('marker-only and combined adapters bind both current events while legacy physical packets stay unchanged', () => {
+ for (const physical of [0, 2]) {
+ const game = view(physical), offer = game.discoveryEntry!;
+ const expected = { type: 'decision', event: offer.event, accept: true, groups: offer.sources,
+ noField: offer.noField!.tokenId, noFieldEvent: offer.noField!.event };
+ assert.deepEqual(discoveryEntryMoveAction(game, offer.sources, offer.noField), expected);
+ assert.deepEqual(discoveryEntryBotActions(game), [expected,
+ { type: 'decision', event: offer.event, accept: false }]);
+ assert.match(html(game), new RegExp(`Selected: ${physical} physical forces · 1 concealed marker`));
+ }
+ const game = view(2), offer = game.discoveryEntry!;
+ assert.deepEqual(discoveryEntryMoveAction(game, offer.sources), {
+ type: 'decision', event: offer.event, accept: true, groups: offer.sources,
+ });
+ assert.equal(discoveryEntryMoveAction(game, []), null);
+ assert.deepEqual(discoveryEntryDeclineAction(game), { type: 'decision', event: offer.event, accept: false });
+ const legacy = html(view(2, false));
+ assert.match(legacy, /Move 2 forces inside/);
+ assert.match(legacy, /Select all forces/);
+ assert.doesNotMatch(legacy, /checkbox|concealed marker|Provisional/);
+});
+
+void test('stale token marker-event parent-source and Discovery-event selections cannot construct an entry', () => {
+ const game = view(), marker = game.discoveryEntry!.noField!;
+ for (const stale of [
+ { ...marker, tokenId: 'old-marker' }, { ...marker, event: 'old-marker-event' },
+ { ...marker, source: 'meridian:3' }, { ...marker, source: 'imperial_basin:10' },
+ ]) assert.equal(discoveryEntryMoveAction(game, [], stale), null);
+ const changed = structuredClone(game);
+ changed.discoveryEntry!.noField!.event = 'new-marker-event';
+ assert.equal(discoveryEntryMoveAction(changed, [], marker), null);
+ const wrongDecision = structuredClone(game);
+ wrongDecision.discoveryEntry!.event = 'old-entry-round';
+ assert.equal(discoveryEntryMoveAction(wrongDecision, [], marker), null);
+ assert.equal(html(wrongDecision), '');
+ const observer = structuredClone(game); observer.me = 'a';
+ assert.equal(discoveryEntryMoveAction(observer, [], marker), null);
+ assert.equal(html(observer), '');
+});
+
+void test('busy autopilot and blocked marker-only panels cannot offer an enabled move or selection control', () => {
+ const busy = html(view(), true);
+ assert.equal((busy.match(/