Allow concealed No-Fields in free Discovery entry
This commit is contained in:
1 parent
11e50e7f49
commit
7f999f4573
14 files changed
+830
-38
No files matched your search
@@ -52,6 +52,7 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [moveMarker, setMoveMarker] = useState(!!offer.noField);
|
||||
const me = game.players.find((player) => player.id === game.me)!;
|
||||
const eliteName = specialForceName(me.faction);
|
||||
const groups: DiscoveryEntrySource[] = offer.sources.flatMap((source) => {
|
||||
@@ -64,7 +65,8 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
(sum, group) => sum + group.normal + group.elite,
|
||||
0,
|
||||
);
|
||||
const move = discoveryEntryMoveAction(game, groups);
|
||||
const noField = moveMarker ? offer.noField : undefined;
|
||||
const move = discoveryEntryMoveAction(game, groups, noField);
|
||||
const decline = discoveryEntryDeclineAction(game);
|
||||
const disabled = busy || !!me.autopilot || !!offer.blocked;
|
||||
|
||||
@@ -85,7 +87,7 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
},
|
||||
}));
|
||||
};
|
||||
const selectAll = (selected: boolean) =>
|
||||
const selectAll = (selected: boolean) => {
|
||||
setCounts(
|
||||
Object.fromEntries(
|
||||
offer.sources.map((source) => [
|
||||
@@ -96,6 +98,8 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
]),
|
||||
),
|
||||
);
|
||||
setMoveMarker(selected && !!offer.noField);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Discovery location entry" className="space-y-4">
|
||||
@@ -113,19 +117,32 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
{offer.blocked}
|
||||
</p>
|
||||
)}
|
||||
{offer.noField && <div className="space-y-2">
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
<label className="flex min-h-11 items-center gap-2" htmlFor={`${controlId}-no-field`}>
|
||||
<input id={`${controlId}-no-field`} type="checkbox" checked={moveMarker} disabled={disabled}
|
||||
aria-label="Move concealed No-Field inside" onChange={event => setMoveMarker(event.target.checked)} />
|
||||
Move concealed No-Field from {territory(offer.parent).name}, sector {splitLocation(offer.noField.source).sector}
|
||||
</label>
|
||||
<p aria-live="polite">Selected: {amount} physical {amount === 1 ? 'force' : 'forces'} · {noField ? 1 : 0} concealed marker</p>
|
||||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
disabled={disabled || (!offer.sources.length && !offer.noField)}
|
||||
onClick={() => selectAll(true)}
|
||||
>
|
||||
Select all forces
|
||||
{offer.noField ? 'Select all forces and marker' : 'Select all forces'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
disabled={disabled || (!offer.sources.length && !offer.noField)}
|
||||
onClick={() => selectAll(false)}
|
||||
>
|
||||
Clear selection
|
||||
@@ -191,20 +208,21 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
className="game-action"
|
||||
disabled={disabled || !move}
|
||||
onClick={() => {
|
||||
if (move) act(move);
|
||||
if (!disabled && move) act(move);
|
||||
}}
|
||||
>
|
||||
Move {amount} {amount === 1 ? 'force' : 'forces'} inside
|
||||
{noField ? `Move ${amount} physical ${amount === 1 ? 'force' : 'forces'} and 1 concealed marker inside`
|
||||
: `Move ${amount} ${amount === 1 ? 'force' : 'forces'} inside`}
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !!me.autopilot || !decline}
|
||||
onClick={() => {
|
||||
if (decline) act(decline);
|
||||
if (!busy && !me.autopilot && decline) act(decline);
|
||||
}}
|
||||
>
|
||||
Leave forces outside
|
||||
{offer.noField ? 'Leave forces and marker outside' : 'Leave forces outside'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+60
-10
@@ -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<string, number>;
|
||||
elites?: { forces: Record<string, number> };
|
||||
noField?: RicheseNoField | null;
|
||||
noFieldEvent?: string;
|
||||
};
|
||||
|
||||
export type DiscoveryEntryContext = Omit<MobileBoard, 'discoveries'> & {
|
||||
@@ -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<DiscoveryEntryArrival, 'signature'>) {
|
||||
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: '',
|
||||
};
|
||||
|
||||
+32
-10
@@ -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,
|
||||
|
||||
+5
-4
@@ -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',
|
||||
|
||||
@@ -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(/<button[^>]*disabled=""/g) ?? []).length, 4);
|
||||
assert.match(busy, /<input[^>]*type="checkbox"[^>]*disabled=""/);
|
||||
const automatic = view(); automatic.players[0].autopilot = 'Hard';
|
||||
assert.equal((html(automatic).match(/<button[^>]*disabled=""/g) ?? []).length, 4);
|
||||
const blocked = view(); blocked.discoveryEntry!.blocked = 'The location is full.';
|
||||
assert.equal(discoveryEntryMoveAction(blocked, [], blocked.discoveryEntry!.noField), null);
|
||||
assert.deepEqual(discoveryEntryBotActions(blocked), []);
|
||||
assert.match(html(blocked), /The location is full/);
|
||||
assert.equal((html(blocked).match(/<button[^>]*disabled=""/g) ?? []).length, 4);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { actDiscoveryNoFieldEntry as act, createDiscoveryNoFieldEntryFixture as fixture,
|
||||
discoveryNoFieldEntryInventory as inventory, discoveryNoFieldEntryPlayer as p,
|
||||
discoveryNoFieldEntryReload as reload, finishDiscoveryNoFieldEntry as finish } from './fixture-discovery-no-field-entry';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function allowances(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, shipped: player.shipped, moved: player.moved,
|
||||
movesAllowed: viewGame(game, player.id).players.find(own => own.id === player.id)!.movesAllowed }));
|
||||
}
|
||||
|
||||
void test('public Basic and Advanced sources earn original location placement paid marker inspection and next-turn offer', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced });
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
const optIn = f.sourceActions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries');
|
||||
const ready = f.sourceActions.findIndex(step => step.action.type === 'ready');
|
||||
assert.ok(optIn >= 0 && optIn < ready);
|
||||
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.placement, f.placement.steps.length); assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length);
|
||||
assert.equal(f.sourceCounts.revelation, f.revelation.steps.length);
|
||||
const originalDraw = f.placement.before.spiceDeck[0];
|
||||
assert.ok('territory' in originalDraw && originalDraw.discovery === 'discovery-hagga-basin');
|
||||
assert.equal(f.placement.after.spiceWindow?.territory, 'hagga_basin');
|
||||
assert.ok(f.placement.after.discoveries!.tokens.some(token => token.id === f.discoveryToken && token.face === f.face && token.status === 'placed'));
|
||||
assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2);
|
||||
assert.deepEqual(f.revelation.steps.map(step => [step.action.type, step.action.reveal]), [['discovery', false], ['discovery', true]]);
|
||||
assert.equal(f.beforeReveal.phase, 7); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.decision?.player, f.owner);
|
||||
assert.deepEqual(f.offer.noField, { tokenId: f.markerToken, event: f.noFieldEvent, source: f.source });
|
||||
assert.deepEqual(f.action.groups, []); assert.deepEqual(inventory(f.game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('marker-only original zero three and five enter for free concealed in both modes without materializing', () => {
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
|
||||
const f = fixture({ advanced, value }), before = p(f.game, f.owner), turns = allowances(f.game);
|
||||
let game = act(reload(f.game), f.owner, f.action); game = finish(game);
|
||||
const after = p(game, f.owner);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(after.forces, before.forces); assert.deepEqual(allowances(game), turns);
|
||||
assert.deepEqual(after.noField!.tokens, f.marker!.tokens); assert.equal(after.noField!.lastShipped, f.markerToken);
|
||||
assert.deepEqual(after.noField!.deployed!.location, { territory: f.destination, sector: 0 });
|
||||
assert.notEqual(after.noFieldEvent, f.noFieldEvent); assert.equal(game.discoveryEntry ?? null, null);
|
||||
assert.equal(game.phase, 0); assert.equal(game.stormPending, advanced ? 1 : null);
|
||||
assert.deepEqual(inventory(game), f.original); reject(game, f.owner, f.action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual earlier physical companion and later marker enter as separate native custody in one free group', () => {
|
||||
const f = fixture({ companions: true, value: 5, face: 'shrine' }); assert.ok(f.physicalShipment);
|
||||
assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2);
|
||||
assert.equal(f.beforeReveal.turn, 2); assert.equal(f.game.turn, 3);
|
||||
assert.equal(p(f.physicalShipment.before, f.owner).spice - p(f.physicalShipment.after, f.owner).spice, 2);
|
||||
const before = p(f.game, f.owner), turns = allowances(f.game);
|
||||
assert.equal(before.forces[f.source], 1); assert.deepEqual(f.action.groups, [{ source: f.source, normal: 1, elite: 0 }]);
|
||||
const game = finish(act(reload(f.game), f.owner, f.action)), after = p(game, f.owner);
|
||||
assert.equal(after.forces[f.source] ?? 0, 0); assert.equal(after.forces[`${f.destination}:0`], 1);
|
||||
assert.deepEqual(after.noField!.deployed!.location, { territory: f.destination, sector: 0 });
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks); assert.equal(after.spice, before.spice);
|
||||
assert.deepEqual(allowances(game), turns); assert.deepEqual(after.noField!.tokens, f.marker!.tokens);
|
||||
assert.equal(after.noField!.lastShipped, f.markerToken); assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('declining the original owned offer retains the marker event at its parent and resumes the normal Storm', () => {
|
||||
const f = fixture({ value: 3 }), before = p(f.game, f.owner), turns = allowances(f.game);
|
||||
const game = finish(act(reload(f.game), f.owner, f.decline)), after = p(game, f.owner);
|
||||
assert.deepEqual(after.noField, f.marker); assert.equal(after.noFieldEvent, f.noFieldEvent);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(after.forces, before.forces); assert.deepEqual(allowances(game), turns);
|
||||
assert.equal(game.discoveryEntry ?? null, null); assert.equal(game.phase, 0); assert.equal(game.stormPending, 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('stale marker and offer events foreign actors and forged physical sources reject before free-entry custody changes', () => {
|
||||
const f = fixture({ value: 5 });
|
||||
reject(f.game, f.other, f.action); reject(f.game, f.owner, { ...f.action, event: 'old-discovery-entry' });
|
||||
reject(f.game, f.owner, { ...f.action, noFieldEvent: 'old-marker-event' });
|
||||
reject(f.game, f.owner, { ...f.action, noField: 'foreign-marker' });
|
||||
const unused = p(f.game, f.owner).noField!.tokens.find(token => token.id !== f.markerToken)!;
|
||||
reject(f.game, f.owner, { ...f.action, noField: unused.id });
|
||||
reject(f.game, f.owner, { ...f.action, groups: [{ source: 'arrakeen:10', normal: 1, elite: 0 }] });
|
||||
reject(f.game, f.owner, { ...f.action, groups: [{ source: f.source, normal: 1, elite: 0 }] });
|
||||
reject(f.game, f.owner, { type: 'decision', event: f.event, accept: true, groups: [] });
|
||||
assert.deepEqual(p(f.game, f.owner).noField, f.marker); assert.deepEqual(inventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('a real mixed source can send only its physical companion and leave the concealed marker at the parent', () => {
|
||||
const f = fixture({ companions: true, value: 3 }), before = p(f.game, f.owner);
|
||||
const game = finish(act(reload(f.game), f.owner, { type: 'decision', event: f.event, accept: true, groups: f.action.groups }));
|
||||
const after = p(game, f.owner);
|
||||
assert.equal(after.forces[f.source] ?? 0, 0); assert.equal(after.forces[`${f.destination}:0`], 1);
|
||||
assert.deepEqual(after.noField, f.marker); assert.equal(after.noFieldEvent, f.noFieldEvent);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles propose the current owned marker entry and complete its original free arrival legally', () => {
|
||||
const f = fixture({ value: 0 });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const choice = botActions(view).find(action => action.type === 'decision' && action.accept === true && action.noField === f.markerToken);
|
||||
assert.ok(choice, `${difficulty} supplies the owned native marker option`);
|
||||
const game = finish(act(reload(f.game), f.owner, choice));
|
||||
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: f.destination, sector: 0 });
|
||||
assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves); assert.equal(p(game, f.owner).tanks, 0);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound,
|
||||
discoveryEntryArrivalSignature, discoveryEntrySignature, finishDiscoveryEntryArrival,
|
||||
quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound,
|
||||
type DiscoveryEntryContext, type DiscoveryEntrySelection,
|
||||
} from '../game/discovery-entry';
|
||||
import { createDiscoveryState } from '../game/discoveries';
|
||||
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
|
||||
/** Controlled pure-helper fixture: the last-turn reveal, current storm order,
|
||||
* marker deployment and physical forces are assigned, not earned by gameplay. */
|
||||
function source(value: NoFieldValue = 0, physical = 0) {
|
||||
const discoveries = createDiscoveryState(() => 0);
|
||||
const site = discoveries.tokens.find(token => token.face === 'jacurutu-sietch')!;
|
||||
Object.assign(site, { status: 'placed', territory: 'meridian', sector: 1,
|
||||
revealedTurn: 2, known: [], owner: null, acquiredTurn: null });
|
||||
discoveries.newlyRevealed = [site.id];
|
||||
const stock = createRicheseNoField(['private-zero-token', 'private-three-token', 'private-five-token']);
|
||||
const token = stock.tokens.find(candidate => candidate.value === value)!;
|
||||
const g: DiscoveryEntryContext = { turn: 3, storm: 1, order: ['r', 'a'], discoveries,
|
||||
players: [
|
||||
{ id: 'r', faction: 'richese', ally: null, forces: physical ? { 'meridian:2': physical } : {},
|
||||
noField: deployRicheseNoField(stock, { tokenId: token.id, controller: 'r',
|
||||
location: { territory: 'meridian', sector: 2 } }), noFieldEvent: 'private-marker-event' },
|
||||
{ id: 'a', faction: 'atreides', ally: null, forces: {} },
|
||||
] };
|
||||
const frame = createDiscoveryEntryRound(g, 'public-entry-round');
|
||||
const offer = quoteDiscoveryEntry(g, frame, 'r');
|
||||
assert.ok(offer.noField);
|
||||
return { g, frame, offer, marker: { ...offer.noField }, token: site.id };
|
||||
}
|
||||
|
||||
void test('zero three and five markers can enter without physical sources or any reserve-based permission', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const f = source(value);
|
||||
Object.assign(f.g.players[0], { reserves: 0 });
|
||||
const before = structuredClone(f.g);
|
||||
assert.deepEqual(f.offer.sources, []);
|
||||
assert.equal(f.offer.blocked, null);
|
||||
const selected = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField: f.marker });
|
||||
assert.deepEqual(selected.arrival?.groups, []);
|
||||
assert.equal(selected.arrival?.amount, 1);
|
||||
assert.equal(selected.arrival?.elite, 0);
|
||||
assert.deepEqual(selected.arrival?.noField, f.marker);
|
||||
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [] }), /positive/);
|
||||
assert.deepEqual(f.g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('mixed entry keeps physical groups separate and omitting the marker preserves the old physical signature bytes', () => {
|
||||
const f = source(5, 3), groups = [{ source: 'meridian:2', normal: 2, elite: 0 }];
|
||||
const physical = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups }).arrival; assert.ok(physical);
|
||||
assert.equal(physical.noField, undefined);
|
||||
assert.equal(physical.signature, JSON.stringify([
|
||||
'public-entry-round:0', f.token, 'r', 'meridian', 'jacurutu-sietch', 0, 0, 0,
|
||||
[['meridian:2', 2, 0]], 2, 0,
|
||||
]));
|
||||
const mixed = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups, noField: f.marker }).arrival; assert.ok(mixed);
|
||||
assert.deepEqual(mixed.groups, groups);
|
||||
assert.equal(mixed.amount, 3);
|
||||
assert.equal(mixed.elite, 0);
|
||||
assert.equal(mixed.signature, JSON.stringify([
|
||||
'public-entry-round:0', f.token, 'r', 'meridian', 'jacurutu-sietch', 0, 0, 0,
|
||||
[['meridian:2', 2, 0]], 3, 0, [true, 'meridian:2'],
|
||||
]));
|
||||
assert.ok(!mixed.signature.includes(f.marker.tokenId));
|
||||
assert.ok(!mixed.signature.includes(f.marker.event));
|
||||
const legacy = structuredClone(f.g); delete legacy.players[0].noField; delete legacy.players[0].noFieldEvent;
|
||||
assert.deepEqual(quoteDiscoveryEntry(legacy, f.frame, 'r', { groups }).arrival, physical);
|
||||
});
|
||||
|
||||
void test('a selected marker must keep its current owner event and canonical eligible parent source', () => {
|
||||
const f = source();
|
||||
for (const noField of [
|
||||
{ ...f.marker, tokenId: 'old-token' },
|
||||
{ ...f.marker, event: 'old-marker-event' },
|
||||
{ ...f.marker, source: 'meridian:3' },
|
||||
{ ...f.marker, source: 'meridian:02' },
|
||||
]) assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField }), /current owned No-Field/);
|
||||
const extra = { ...f.marker, amount: 5 };
|
||||
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField: extra }), /positive/);
|
||||
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'a', { groups: [], noField: f.marker }), /another player/);
|
||||
for (const change of ['controller', 'event', 'storm', 'parent'] as const) {
|
||||
const g = structuredClone(f.g), owner = g.players[0];
|
||||
if (change === 'controller') owner.noField!.deployed!.controller = 'a';
|
||||
if (change === 'event') owner.noFieldEvent = '';
|
||||
if (change === 'storm') g.storm = 2;
|
||||
if (change === 'parent') owner.noField!.deployed!.location = { territory: 'imperial_basin', sector: 10 };
|
||||
const offer = quoteDiscoveryEntry(g, f.frame, 'r');
|
||||
assert.equal(offer.noField, undefined);
|
||||
assert.ok(offer.blocked);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a signed marker arrival is copied and remains valid after native relocation and event rotation', () => {
|
||||
const f = source(3), selection: DiscoveryEntrySelection = { groups: [], noField: { ...f.marker } };
|
||||
const next = beginDiscoveryEntryArrival(f.g, f.frame, 'r', selection);
|
||||
assert.ok(next.arrival?.noField);
|
||||
selection.noField!.event = 'changed-selection';
|
||||
assert.equal(next.arrival.noField.event, f.marker.event);
|
||||
assert.ok(next.signature.includes(f.marker.tokenId));
|
||||
assert.ok(next.signature.includes(f.marker.event));
|
||||
f.g.players[0].noField!.deployed!.location = { territory: next.arrival.destination, sector: 0 };
|
||||
f.g.players[0].noFieldEvent = 'rotated-after-native-relocation';
|
||||
assert.doesNotThrow(() => validateDiscoveryEntryRound(f.g, next));
|
||||
assert.doesNotThrow(() => validateDiscoveryEntryArrivalChild(next, { owner: 'r', territory: next.arrival!.destination,
|
||||
sector: 0, amount: 1, elite: 0, discoveryEntry: next.arrival!.signature }));
|
||||
assert.equal(finishDiscoveryEntryArrival(f.g, next).cursor, 1);
|
||||
|
||||
const wrongSource = structuredClone(next);
|
||||
wrongSource.arrival!.noField!.source = 'imperial_basin:10';
|
||||
wrongSource.arrival!.signature = discoveryEntryArrivalSignature(wrongSource.arrival!);
|
||||
wrongSource.signature = discoveryEntrySignature(wrongSource);
|
||||
assert.throws(() => validateDiscoveryEntryRound(f.g, wrongSource), /revealed destination/);
|
||||
const fakeElite = structuredClone(next);
|
||||
fakeElite.arrival!.elite = 1;
|
||||
fakeElite.arrival!.signature = discoveryEntryArrivalSignature(fakeElite.arrival!);
|
||||
fakeElite.signature = discoveryEntrySignature(fakeElite);
|
||||
assert.throws(() => validateDiscoveryEntryRound(f.g, fakeElite), /exact signed arrival/);
|
||||
});
|
||||
|
||||
void test('declining an offered marker advances only the optional entry cursor', () => {
|
||||
const f = source(), before = structuredClone(f.g);
|
||||
const next = advanceDiscoveryEntry(f.g, f.frame, 'r');
|
||||
assert.equal(next.cursor, 1);
|
||||
assert.equal(next.arrival, null);
|
||||
assert.equal(next.stage, 'choose');
|
||||
assert.deepEqual(f.g, before);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { discoveryNoFieldBlowPlayer, discoveryNoFieldBlowReload, discoveryNoFieldBlowInventory,
|
||||
discoveryNoFieldBlowClean, nextDiscoveryNoFieldBlow, type DiscoveryNoFieldBlowStep } from './fixture-discovery-no-field-blow';
|
||||
|
||||
export type DiscoveryNoFieldEntryStep = DiscoveryNoFieldBlowStep;
|
||||
export const discoveryNoFieldEntryPlayer = discoveryNoFieldBlowPlayer;
|
||||
export const discoveryNoFieldEntryReload = discoveryNoFieldBlowReload;
|
||||
export const discoveryNoFieldEntryInventory = discoveryNoFieldBlowInventory;
|
||||
export const discoveryNoFieldEntryClean = discoveryNoFieldBlowClean;
|
||||
export const nextDiscoveryNoFieldEntry = nextDiscoveryNoFieldBlow;
|
||||
|
||||
/** Only original scalar lotteries are chosen. UUIDs and non-scalar native byte
|
||||
* entropy remain untouched. A selected Discovery face is sampled from its real
|
||||
* current supply at the actual printed Spice draw, never installed afterward.
|
||||
*/
|
||||
function originalLottery<T>(game: Game, face: DiscoveryTokenFace | undefined, operation: () => T): T {
|
||||
let scalar = 0xffffffff;
|
||||
if (face) {
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const index = eligible.findIndex(token => token.face === face); assert.ok(index >= 0);
|
||||
scalar = Math.floor((index + 0.5) / eligible.length * 0x100000000);
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actDiscoveryNoFieldEntry(game: Game, actor: string, action: Action,
|
||||
actions: DiscoveryNoFieldEntryStep[] = [], face?: DiscoveryTokenFace): Game {
|
||||
const next = originalLottery(game, face, () => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
export function advanceDiscoveryNoFieldEntry(game: Game, until: (state: Game) => boolean,
|
||||
actions: DiscoveryNoFieldEntryStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Discovery entry chronology ended before its requested boundary');
|
||||
const next = nextDiscoveryNoFieldEntry(game); assert.ok(next, 'Complete the actual native pending control');
|
||||
game = actDiscoveryNoFieldEntry(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Discovery/No-Field entry chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
|
||||
return advanceDiscoveryNoFieldEntry(game, discoveryNoFieldEntryClean, actions);
|
||||
}
|
||||
export function finishDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
|
||||
return advanceDiscoveryNoFieldEntry(game, state => !state.discoveryEntry && discoveryNoFieldEntryClean(state), actions);
|
||||
}
|
||||
function selectLand(game: Game, position: number, parent: string) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
!card.discovery && card.territory !== parent && card.sector !== game.storm);
|
||||
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
}
|
||||
|
||||
/** First original Discovery blow, native supply placement, paid parent shipment
|
||||
* and actual Collection inspection/reveal. Mixed physical1 is paid a full turn
|
||||
* before its marker. Both then enter the original NEXT-turn free-entry offer.
|
||||
* No audit initializer or played component/state installation is used.
|
||||
*/
|
||||
export function createDiscoveryNoFieldEntryFixture(options: {
|
||||
advanced?: boolean; value?: NoFieldValue; companions?: boolean; face?: 'cistern' | 'shrine';
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false;
|
||||
const face = options.face ?? 'cistern', owner = 'richese', observer = 'atreides', other = 'emperor';
|
||||
const printed = 'discovery-hagga-basin', parent = DISCOVERY_CARD_PLACEMENTS[printed];
|
||||
let game = createGame('DISCOVERYNFENTRY', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: DiscoveryNoFieldEntryStep[] = [];
|
||||
const act = (actor: string, action: Action, lotteryFace?: DiscoveryTokenFace) => {
|
||||
game = actDiscoveryNoFieldEntry(game, actor, action, actions, lotteryFace);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldEntry(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
|
||||
const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind));
|
||||
const dealOrder = ordinary.map((card, position) => {
|
||||
assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
|
||||
const original = discoveryNoFieldEntryInventory(game);
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed); assert.ok(from >= 0);
|
||||
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card);
|
||||
const blowSelection = { position: 0, from, card: structuredClone(card) };
|
||||
const followingSpiceOrder = advanced ? [selectLand(game, 1, parent.territory)] : [];
|
||||
reach(state => state.phase === 1 && discoveryNoFieldEntryClean(state) && !state.spiceWindow && !state.spiceSequence && !state.spiceResolution);
|
||||
const placement = transition(() => {
|
||||
assert.deepEqual(game.ready, []);
|
||||
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
|
||||
act(game.players.at(-1)!.id, { type: 'ready' }, face);
|
||||
});
|
||||
const discovery = game.discoveries!.tokens.find(token => token.face === face && token.status === 'placed'); assert.ok(discovery);
|
||||
assert.equal(discovery.territory, parent.territory); assert.equal(discovery.sector, parent.sector);
|
||||
const discoveryToken = discovery.id;
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
|
||||
let markerShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
|
||||
let physicalShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
|
||||
let markerToken = '';
|
||||
const shipMarker = () => {
|
||||
const own = discoveryNoFieldEntryPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
|
||||
assert.ok(selected && own.noFieldEvent); markerToken = selected.id;
|
||||
markerShipment = transition(() => {
|
||||
act(owner, { type: 'ship', noField: markerToken, event: own.noFieldEvent, territory: parent.territory, sector: parent.sector });
|
||||
reach(discoveryNoFieldEntryClean);
|
||||
});
|
||||
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).spice - discoveryNoFieldEntryPlayer(game, owner).spice, 2);
|
||||
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).reserves, discoveryNoFieldEntryPlayer(game, owner).reserves);
|
||||
};
|
||||
let secondSpiceOrder: ReturnType<typeof selectLand>[] = [];
|
||||
if (companions) {
|
||||
physicalShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: parent.territory, sector: parent.sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(discoveryNoFieldEntryClean);
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldEntryClean(state));
|
||||
secondSpiceOrder = [selectLand(game, 0, parent.territory), ...(advanced ? [selectLand(game, 1, parent.territory)] : [])];
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
|
||||
}
|
||||
shipMarker(); assert.ok(markerShipment);
|
||||
const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game };
|
||||
reach(state => state.phase === 7 && discoveryNoFieldEntryClean(state));
|
||||
const beforeReveal = structuredClone(game), beforeRevealActions = structuredClone(actions);
|
||||
assert.ok(viewGame(game, owner).discoveries!.canInspect.includes(discoveryToken));
|
||||
const revelation = transition(() => {
|
||||
act(owner, { type: 'discovery', token: discoveryToken, reveal: false });
|
||||
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(discoveryToken));
|
||||
act(owner, { type: 'discovery', token: discoveryToken, reveal: true });
|
||||
});
|
||||
const revealTurn = game.turn;
|
||||
reach(state => state.turn === revealTurn + 1 && state.phase === 0 &&
|
||||
state.decision?.kind === 'discoveryEntry' && state.decision.player === owner && !state.response && !state.phaseOpening);
|
||||
const own = discoveryNoFieldEntryPlayer(game, owner), noFieldEvent = own.noFieldEvent; assert.ok(noFieldEvent);
|
||||
assert.equal(noFieldEvent, discoveryNoFieldEntryPlayer(shipment.after, owner).noFieldEvent);
|
||||
assert.equal(own.noField!.deployed!.tokenId, markerToken);
|
||||
assert.deepEqual(own.noField!.deployed!.location, { territory: parent.territory, sector: parent.sector });
|
||||
const offer = viewGame(game, owner).discoveryEntry; assert.ok(offer && !offer.blocked);
|
||||
assert.equal(offer.token, discoveryToken); assert.equal(offer.destination, face);
|
||||
const groups = companions ? [{ source: `${parent.territory}:${parent.sector}`, normal: 1, elite: 0 }] : [];
|
||||
const action: Action = { type: 'decision', event: offer.event, accept: true, groups,
|
||||
noField: markerToken, noFieldEvent };
|
||||
const decline: Action = { type: 'decision', event: offer.event, accept: false };
|
||||
assert.deepEqual(discoveryNoFieldEntryInventory(game), original);
|
||||
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, advanced, value, companions,
|
||||
owner, actor: owner, observer, other, face, parent: parent.territory, parentSector: parent.sector,
|
||||
source: `${parent.territory}:${parent.sector}`, destination: face, discoveryToken, markerToken,
|
||||
event: offer.event, noFieldEvent, marker: structuredClone(own.noField), offer: structuredClone(offer), action, decline,
|
||||
placement, markerShipment: shipment, physicalShipment, beforeReveal, beforeRevealActions, revelation,
|
||||
original, dealOrder, blowSelection, followingSpiceOrder, secondSpiceOrder, setupActions, actions,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { setup: setupActions.length, placement: placement.steps.length, markerShipment: shipment.steps.length,
|
||||
physicalShipment: physicalShipment?.steps.length ?? 0, beforeReveal: beforeRevealActions.length,
|
||||
revelation: revelation.steps.length, source: actions.length },
|
||||
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck and public Discovery opt-in before readiness/start. ` +
|
||||
'No audit initializer or other optional module. Select only original unopened starting/Spice positions and scalar lotteries; the actual first Discovery Hagga Basin draw samples the original supply location at GaraKulon8. ' +
|
||||
(companions ? 'Richese actually ships physical1parent on turn1, keeps the location hidden through Collection1, then pays2 for its original marker there on turn2. '
|
||||
: 'Richese actually pays2 for its original marker at the parent on turn1. ') +
|
||||
'Actual Collection inspection/reveal, Mentat, turn end and next-turn original entry round produce this offer. No hand, purse, army, phase, marker, event, token, reveal receipt or free-entry frame is installed. Counts derive from recorded arrays. Native BG arrival reactions are outside this three-seat source.' };
|
||||
}
|
||||
export type DiscoveryNoFieldEntryFixture = ReturnType<typeof createDiscoveryNoFieldEntryFixture>;
|
||||
Reference in new issue
Block a user