Prototype Discovery free entry, Cistern income and carried flight

Connect next-turn optional entry before storm/HMS movement, with typed
multi-sector groups, normal source-storm/occupancy checks, legal bot and human
choices, signed saved arrivals and Bene Gesserit reaction continuation.
Cistern pays its sole occupant two bank spice through canonical Collection.
A carried Ornithopter grants fixed range three on a later-turn actual movement,
preserves pending responses and independent Treachery/No-Field custody, and is
removed only after the selected group moves.

Independent reviews bind pending entry destination baselines, typed custody and
exact reaction continuations. Browser QA caught and fixed an incorrect decision
heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle
income, Ecological Testing Station and Shrine remain missing. Mode and publication
gates stay closed; this checkpoint does not certify full Discovery compliance.

Verification: types/lint and 4,313 offline tests passed (49 added), production
build passed, and 40 HTTP tests passed. Final stable run durations: check
139145ms, build 6530ms, integration 24421ms. An earlier run found one stale
Discovery notice assertion, now updated; the next run was interrupted by a
user-confirmed power outage. No interrupted result is claimed as success.

Targeted browser room CA782FQE used genuine human Atreides and two-AI setup,
then physical QA positioning for partial free entry and a three-territory
Ornithopter move. Refresh preserved its private hand, forces, spice and token
removal. The power outage stopped both server and checks; the absent server was
restarted after a private backup. The same browser seat restored correctly and
all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening
rooms unchanged, 31 added QA/integration rooms, total 187. No database reset.

Source fingerprint:
7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc
Private report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json
Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification:
1,290,446 tokens before final Git overhead for three connected functions,
including helper coordination, reviews, browser QA and interrupted checks.
This commit is contained in:
Quintine committed 2026-09-13 14:13:40 +10:00
1 parent f0256c1b38
commit ab0ab52849
28 files changed
+3873 -59

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
);
})}
</div>
<p className="fine">Inspection and reveal are optional. Location abilities, next-turn free entry and Ornithopter use are still in development.</p>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.</p>
</section>
);
}
+212
View File
@@ -0,0 +1,212 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { splitLocation, territory } from '@/game/board';
import { DISCOVERY_LOCATION_BY_ID } from '@/game/discoveries';
import { specialForceName } from '@/game/combat';
import {
discoveryEntryDeclineAction,
discoveryEntryMoveAction,
} from '@/game/discovery-entry-options';
import type { DiscoveryEntrySource } from '@/game/discovery-entry';
import { Button } from './ui/button';
import { Input } from './ui/input';
type Props = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
};
type Counts = Record<string, { normal: number; elite: number }>;
export function DiscoveryEntryDecision({ game, act, busy }: Props) {
const decision = game.decision,
offer = game.discoveryEntry;
if (
decision?.kind !== 'discoveryEntry' ||
decision.player !== game.me ||
!offer ||
offer.owner !== game.me ||
offer.event !== decision.event
)
return null;
return (
<DiscoveryEntryChoice
key={`${offer.event}:${offer.token}:${offer.owner}`}
game={game}
act={act}
busy={busy}
/>
);
}
function DiscoveryEntryChoice({ game, act, busy }: Props) {
const controlId = useId();
const offer = game.discoveryEntry!;
const [counts, setCounts] = useState<Counts>(() =>
Object.fromEntries(
offer.sources.map((source) => [
source.source,
{ normal: source.normal, elite: source.elite },
]),
),
);
const me = game.players.find((player) => player.id === game.me)!;
const eliteName = specialForceName(me.faction);
const groups: DiscoveryEntrySource[] = offer.sources.flatMap((source) => {
const selected = counts[source.source] ?? { normal: 0, elite: 0 };
return selected.normal + selected.elite > 0
? [{ source: source.source, ...selected }]
: [];
});
const amount = groups.reduce(
(sum, group) => sum + group.normal + group.elite,
0,
);
const move = discoveryEntryMoveAction(game, groups);
const decline = discoveryEntryDeclineAction(game);
const disabled = busy || !!me.autopilot || !!offer.blocked;
const setCount = (
source: DiscoveryEntrySource,
kind: 'normal' | 'elite',
value: number,
) => {
const maximum = source[kind];
const next = Number.isFinite(value)
? Math.max(0, Math.min(maximum, Math.floor(value)))
: 0;
setCounts((current) => ({
...current,
[source.source]: {
...(current[source.source] ?? { normal: 0, elite: 0 }),
[kind]: next,
},
}));
};
const selectAll = (selected: boolean) =>
setCounts(
Object.fromEntries(
offer.sources.map((source) => [
source.source,
selected
? { normal: source.normal, elite: source.elite }
: { normal: 0, elite: 0 },
]),
),
);
return (
<section aria-label="Discovery location entry" className="space-y-4">
<div>
<h3>Enter {DISCOVERY_LOCATION_BY_ID[offer.destination].name}</h3>
<p>
Move any number of your non-advisor forces from{' '}
{territory(offer.parent).name} into this newly revealed location.
This free entry happens before the storm and does not use shipment or
movement.
</p>
</div>
{offer.blocked && (
<p className="notice" aria-live="polite">
{offer.blocked}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={disabled || !offer.sources.length}
onClick={() => selectAll(true)}
>
Select all forces
</Button>
<Button
type="button"
variant="outline"
disabled={disabled || !offer.sources.length}
onClick={() => selectAll(false)}
>
Clear selection
</Button>
</div>
<div className="space-y-3">
{offer.sources.map((source) => {
const selected = counts[source.source] ?? { normal: 0, elite: 0 };
const where = splitLocation(source.source);
const sourceId = `${controlId}-${source.source.replace(/[^a-z0-9_-]/gi, '-')}`;
return (
<fieldset
key={source.source}
className="space-y-2 rounded-lg border border-[#a88b60]/40 p-3"
>
<legend className="px-1 font-semibold">
{territory(where.territory).name} · sector {where.sector}
</legend>
<p className="fine">
Available: {source.normal} ordinary
{source.elite > 0
? ` · ${source.elite} ${eliteName}`
: ''}
</p>
<label className="grid gap-1" htmlFor={`${sourceId}-normal`}>
<span>Ordinary forces</span>
<Input
id={`${sourceId}-normal`}
aria-label={`${source.source} ordinary forces`}
type="number"
min={0}
max={source.normal}
value={selected.normal}
disabled={disabled}
onChange={(event) =>
setCount(source, 'normal', Number(event.target.value))
}
/>
</label>
{source.elite > 0 && (
<label className="grid gap-1" htmlFor={`${sourceId}-elite`}>
<span>{eliteName}</span>
<Input
id={`${sourceId}-elite`}
aria-label={`${source.source} ${eliteName}`}
type="number"
min={0}
max={source.elite}
value={selected.elite}
disabled={disabled}
onChange={(event) =>
setCount(source, 'elite', Number(event.target.value))
}
/>
</label>
)}
</fieldset>
);
})}
</div>
<div className="flex flex-wrap gap-2">
<Button
className="game-action"
disabled={disabled || !move}
onClick={() => {
if (move) act(move);
}}
>
Move {amount} {amount === 1 ? 'force' : 'forces'} inside
</Button>
<Button
className="game-action"
variant="outline"
disabled={busy || !!me.autopilot || !decline}
onClick={() => {
if (decline) act(decline);
}}
>
Leave forces outside
</Button>
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { discoveryFlightMove } from '@/game/discovery-flight-options';
import { Button } from './ui/button';
export function DiscoveryOrnithopterMovement({ game, act, busy, move }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
move: Action | null;
}) {
const id = useId();
if (!game.discoveryOrnithopter) return null;
const quote = discoveryFlightMove(game, move);
const unavailable = busy || !!game.players.find(p => p.id === game.me)?.autopilot ||
!!game.automaticContinuationPending;
return <section className="my-4 flex min-w-0 flex-col gap-3" aria-labelledby={`${id}-heading`}>
<h3 id={`${id}-heading`}>Carried Discovery Ornithopter</h3>
<p>Use a range of three territories for the selected group’s movement. This replaces its normal range. The token is removed after that group moves.</p>
<p className="fine">Acquired on turn {game.discoveryOrnithopter.acquiredTurn}. Available from the following turn.</p>
<Button disabled={unavailable || !quote.action} aria-describedby={quote.blocked ? `${id}-reason` : undefined}
onClick={() => { if (!unavailable && quote.action) act(quote.action); }}>
Move with Discovery Ornithopter
</Button>
{quote.blocked && <output id={`${id}-reason`} className="fine">{quote.blocked}</output>}
</section>;
}
+27
View File
@@ -14,6 +14,7 @@ import { NexusCards } from './nexus-cards';
import { NexusTraitors } from './nexus-traitors';
import { GreatMakerDecision } from './great-maker';
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
import { DiscoveryEntryDecision } from './discovery-entry';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
@@ -47,6 +48,7 @@ import { Distrans } from './distrans';
import { JuiceOfSapho } from './juice-of-sapho';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
import { PortableSnooper } from './portable-snooper';
import {
@@ -2206,6 +2208,11 @@ export function GameTable({
.kind ===
'wormProtection'
? 'Protection of Shai-Hulud'
: g
.decision
.kind ===
'discoveryEntry'
? 'Enter a Discovery location'
: g
.decision
.kind ===
@@ -2941,6 +2948,8 @@ export function GameTable({
accept: false,
})}
</>
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'discoveryDiscard' ? (
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
@@ -3957,6 +3966,24 @@ export function GameTable({
}
}
/>
<DiscoveryOrnithopterMovement
game={g}
act={act}
busy={busy}
move={
!source || !movementAvailable ||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
? null
: {
type: 'move',
fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory),
...movementGroup,
territory: selected,
sector,
...(includesNoField ? {} : { amount }),
}
}
/>
<p className="fine">
{guildCunning?.stage === 'secondShipment'
? 'Choose or decline the second shipment before any Hajr movement.'
+23 -8
View File
@@ -4,11 +4,24 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth
- The current [Discovery prototype](DISCOVERY_PROTOTYPE.md) adds
signed next-turn entry, sole-occupant Cistern income and later-turn carried
Ornithopter movement to the first batch. Focused lifecycle, engine, recovery,
private control and AI paths are present. Targeted browser room `CA782FQE`
verified partial free entry and a later range-three token move from a genuine
Discovery setup after QA state positioning; it was not an unmodified full-game
journey. Refresh and a subsequent power-outage restart restored its forces,
private hand, spice and spent token; all 157 saved rooms survived unchanged.
Required broad results are recorded in the checkpoint commit. Four location abilities
remain unavailable, and contested Cistern and Orgiz collection interpretations
remain unresolved. This is still **Prototyped**, not complete-module verification.
- [First Discovery prototype](DISCOVERY_PROTOTYPE.md): genuine base-faction
Basic/Advanced setup, seven Spice Cards, eight tokens, Great Maker, Collection
inspection/rewards and revealed nested locations are connected. Focused checks
pass; final broad checkpoint evidence is pending. Free entry, special location
benefits and Ornithopter spending remain missing.
inspection/rewards and revealed nested locations are connected. Checkpoint
`f0256c1` passed 4,264 offline tests, build and 40 HTTP tests; two seeded games
finished and the Fremen browser view restored its private token face. All 125
opening saved rooms remained unchanged. That checkpoint predates the current
free-entry, Cistern and carried-Ornithopter additions.
- Ix prototype checkpoint `5a76ae0`: types, lint, **4,211 offline tests**, build and **40 HTTP tests** passed. Genuine Basic/Advanced setup and two seeded complete simulations were exercised; a short browser game finished with private hand restoration. All 64 opening saved rooms were preserved; 31 QA rooms were added. This is bounded prototype evidence, not complete Ix compliance.
- [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate.
@@ -41,7 +54,7 @@ restoration after necessary restarts. No recurring automation is installed.
| --- | --- | --- |
| Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance facts and bounded Basic/no-Guild Advanced reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. |
| Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. |
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; the first Discovery actions now have a connected prototype. | Every unfinished effect and interaction, occupation entitlement, remaining Discovery effects and complete combined games. Inventory or fixture coverage does not enable a module. |
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; Discovery now includes free location entry, sole-occupant Cistern income and later-turn carried Ornithopter movement. | Every unfinished effect and interaction, occupation entitlement, four remaining Discovery location abilities, contested collection rulings and complete combined games. Inventory or fixture coverage does not enable a module. |
| AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. |
| Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry and voluntary own-seat AI control. | Intentional transfer, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. |
| Player experience | Internal component inspectors and many desktop/phone feature journeys verified. | Complete end-to-end games, accessible controls, responsive layout, clear rules/errors and recovery across all supported combinations. |
@@ -60,10 +73,12 @@ with private controls, all four AI profiles and a saved payment/discard continua
Its after-victory inspection remains missing. The [first Discovery batch](DISCOVERY_PROTOTYPE.md)
now connects the [sourced components](DISCOVERY_COMPONENTS.md) to Great Maker,
destructive Discovery blows, private inspection/reveal, stash rewards, carried
Ornithopter custody and five revealed nested locations. Only Jacurutu counts
toward stronghold victory. Next dependencies are free entry at the next turn,
the five special location benefits, and spending Ornithopter on a later move;
complete variant acceptance and normal public starts remain gated.
Ornithopter custody and later-turn movement, five revealed nested locations,
signed free entry on the following turn and sole-occupant Cistern income. Only
Jacurutu counts toward stronghold victory. Next dependencies are Jacurutu battle
income, Ecological Testing Station, Shrine and Orgiz, plus unresolved contested
Cistern and collected-spice-blow interpretations. Complete variant acceptance
and normal public starts remain gated.
The user changed the development order on 13 September: get working prototypes
of all remaining functions into the game, then use integration and play to find
+64 -17
View File
@@ -52,19 +52,33 @@ to redeal a started game. The default `ix` profile remains available.
an owned discard choice, including the new card; its saved draw/discard receipt
prevents replay. Both stashes then retain removed physical custody.
- **Carried token:** revealing Ornithopter records its owner and acquisition
turn. Spending it for movement is not implemented in this batch.
turn. On a later turn, its owner may spend it on one ordinary movement action
to give the selected group a fixed range of three. It grants no extra action
or shipment. The physical token remains carried through pending movement and
arrival interactions, then leaves play once the selected movement commits.
- **Nested locations:** the five revealed location tokens become separate,
initially empty board territories inside their printed surroundings. Public
reveal controls admission; a private peek alone cannot create a destination.
Ordinary movement uses the parent territory, shipment uses stronghold prices,
no more than two factions enter, and occupants are protected from storm and
sandworms. Only Jacurutu Sietch contributes a stronghold toward victory.
- **Next-turn free entry:** before the next Storm phase and Ixian Hidden Mobile
Stronghold movement, each eligible faction receives an optional signed choice
for a location revealed on the prior turn. It may move any positive subset of
ordinary and elite non-advisor forces across eligible sectors of the enclosing
territory into sector zero of that location. This spends no spice, shipment or
normal movement. Empty, storm-blocked and occupancy-blocked offers skip without
holding up the turn. Accepted groups and arrival reactions survive restoration.
- **Cistern:** during Collection, a sole occupant receives two bank spice. If two
factions occupy Cistern, the unresolved benefit is withheld without blocking
Collection; this boundary does not decide which occupant should receive it.
Great Maker vote/ride controls and Discovery token/discard controls use the same
private legal choices as the four AI profiles. Token backs expose only permitted
information; authorized faces have readable explanations of all eight effects.
Busy and autopilot states disable human actions. Those explanations include
remaining printed effects and explicitly distinguish them from available play.
Great Maker vote/ride, Discovery token/discard, next-turn entry and carried
Ornithopter controls use the same private legal choices as the four AI profiles.
Token backs expose only permitted information; authorized faces have readable
explanations of all eight effects. Busy and autopilot states disable human
actions. Those explanations include remaining printed effects and explicitly
distinguish them from available play.
## Interpretations retained for refinement
@@ -81,6 +95,20 @@ empty-supply resolution remains an implementation interpretation to refine,
rather than a certified additional rule. Existing unrelated pending rulings in
the [decision index](RULE_DECISIONS.md) remain pending.
The printed next-turn entry text specifies neither an order among simultaneous
locations and factions nor whether the ordinary source-sector storm restriction
applies to this exceptional movement. Processing tokens in reveal order and
factions in storm order, while excluding source groups in storm, are **source
inferences** retained for deterministic legal play. They are not publisher
answers or newly approved table rulings.
The printed Cistern text names an occupant but permits two factions to occupy a
revealed location. The prototype pays an ordinary sole occupant and withholds
the contested bonus. Orgiz remains disabled because the source also does not
settle whether a stacked board deposit represents one or several collected
spice blows; contested occupancy and unresolved Ecaz shared collection add
further unanswered cases.
## Evidence and next dependencies
Focused suites cover component inventory and private projection
@@ -88,9 +116,28 @@ Focused suites cover component inventory and private projection
(`tests/discovery-runtime.test.ts`), ordered Great Maker continuations
(`tests/great-maker.test.ts`), nested board and admission rules
(`tests/discovery-board.test.ts`, `tests/discovery-admission.test.ts`), and private
human/AI controls (`tests/discovery-controls.test.ts`). They exercise immutable
rejections, physical custody, hidden information and JSON restoration. They do
not certify a complete module, all combined routes or difficulty strength ordering.
human/AI controls (`tests/discovery-controls.test.ts`). Signed free entry and its
engine, saved interaction and human/AI paths are covered by
`tests/discovery-entry.test.ts`, `tests/discovery-entry-runtime.test.ts` and
`tests/discovery-entry-controls.test.ts`. Cistern composition is covered by
`tests/discovery-collection.test.ts`; carried token selection, delayed spending
and disposal are covered by `tests/discovery-flight.test.ts`. They exercise
immutable rejections, physical custody, hidden information and JSON restoration.
They do not certify a complete module, all combined routes or difficulty strength
ordering. Required broad checkpoint checks are pending for this expanded batch.
Browser room `CA782FQE` began through the genuine Discovery setup with one human
and two AI seats, then used a targeted QA state to reach the new controls without
playing a full game. It verified a partial one-force free entry into Cistern with
no spice or movement cost and a later three-territory Ornithopter move that spent
the carried token once. This is targeted browser evidence; it does not represent
an unmodified complete-game journey. Refresh restored the same forces, private
hand, ten spice, one spent movement and removed token. All 156 opening saved
rooms remain part of the preservation baseline. A user-confirmed power outage
subsequently stopped the server and verification process. After backing up all
157 current rooms, restarting the absent server restored the same browser seat,
forces, private Stunner, spice and spent token. All 157 saved rooms were unchanged.
Required broad results are recorded in the checkpoint commit.
SQLite recovery tests (`tests/discovery-recovery.test.ts`) verify competing
reveals pay only once, the private overflow choice survives restoration, and an
@@ -111,11 +158,11 @@ The room is paused at acceptance of that blow. No other saved room was changed
and no server restart was needed. Required broad verification results and the
source-bound report are recorded in the checkpoint commit.
1. Add the next-turn free entry into newly revealed locations, preserving typed
groups, occupancy, turn order and saved pending choices.
2. Connect the five special location benefits: Jacurutu battle income, Cistern
Collection income, Ecological Testing Station storm adjustment, Shrine card
conversion and Orgiz collection transfer. Ordinary Jacurutu victory counting
does not implement its battle income.
3. Connect later-turn Ornithopter spending to one actual movement action, then
integrate combined modules and complete games before refining or opening gates.
1. Connect the four remaining special location benefits: Jacurutu battle income,
Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz
collection transfer. Ordinary Jacurutu victory counting does not implement its
battle income; Orgiz awaits the collected-pile ruling above.
2. Resolve contested Cistern ownership, then integrate combined modules and
complete games before refining or opening gates.
3. Run the required broad checkpoint, browser and saved-room verification for
this expanded prototype. Focused evidence does not open normal public starts.
+13
View File
@@ -104,3 +104,16 @@ lost saved controls, a retargetable arrival and skipped overflow discard before
the stable broad check. The implementation adds no calibration study or server
restart. Final aggregate endpoint, source-bound checks and push belong in the
checkpoint commit; whole-module completion remains unclaimed.
The following Discovery batch starts at aggregate counter 61,819,586 and connects
three more functions: next-turn free entry, Cistern Collection income and
later-turn Ornithopter movement. Three reused helpers build separate quotes and
controls, then integrate Collection/flight and independently review entry/flight;
the owner integrates entry, persistence tests and browser acceptance. Entry review
repaired arrival custody and conflicting saved continuations; targeted browser
play found an incorrect decision heading. Orgiz's unresolved stacked-blow
interpretation remains disabled while the independent functions advance. One new
browser room uses genuine setup followed by explicitly targeted scenarios; the
156 opening rooms are preserved. Required stable checks and the aggregate endpoint
are recorded in the checkpoint commit, without counting these prototypes as
complete Discovery rules compliance.
+3 -2
View File
@@ -15,7 +15,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. First actions are prototyped; free entry, special location benefits and Ornithopter spending remain missing. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; four special location effects remain missing. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
@@ -31,7 +31,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above |
| Discovery prototype inferences | Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Orgiz collection | User question pending: take one spice per positive board pile collected, or leave the effect pending? The publisher does not clarify stacked Spice Blows. The draft quote remains opt-in and is not enabled by the engine. Contested Cistern income also remains outside the ordinary sole-occupant prototype. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
When a decision changes, record the exact source or user ruling in its detailed feature document, update affected implementation guidance/tests, and adjust this index and [current status](CURRENT_STATUS.md). Retain historical evidence without presenting a superseded proposal as current behavior.
+12
View File
@@ -1,4 +1,6 @@
import { discoveryBotActions } from './discovery-options';
import { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options';
import { greatMakerBotActions } from './great-maker-options';
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
@@ -2343,6 +2345,7 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
if (d.kind === 'discoveryDiscard') return discoveryBotActions(g);
if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g);
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
if (d.kind === 'wormProtection')
return [{ type: 'decision', accept: true }];
@@ -2828,6 +2831,13 @@ function policyActions(g: GameView): Action[] {
const hajr = nexusGuildHajrAction(g);
if (hajr) return [hajr];
const cardMoves = nexusGuildCunningActive(g) ? [] : ornithopterMoves(g);
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked
? discoveryFlightBotActions(g,
destinations(g).filter(target => target.score > -1000)
.map(target => ({ territory: target.t, sector: target.s })))
: [];
if (discoveryMoves.length && (me.shipped || me.reserves === 0))
return discoveryMoves;
if (
cardMoves.length &&
(g.ornithopter?.active || me.shipped || me.reserves === 0)
@@ -3373,6 +3383,8 @@ export function botActions(g: GameView): Action[] {
: nexusTraitorBotActions(g);
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const discoveryEntry = discoveryEntryBotActions(g);
if (discoveryEntry.length) return discoveryEntry;
const discovery = discoveryBotActions(g);
if (discovery.length) return discovery;
const trade = nexusChoamTradeBotActions(g);
+269
View File
@@ -0,0 +1,269 @@
import { fighterCount, isAdvisor } from './advisors';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { Player } from './engine';
import { presenceByLocation, type ForcePresence } from './force-presence';
export const CISTERN = 'cistern';
export const ORGIZ_PROCESSING_STATION = 'orgiz-processing-station';
export class DiscoveryCollectionError extends Error {
constructor(message: string) {
super(message);
this.name = 'DiscoveryCollectionError';
}
}
type DiscoveryCollectionSeat = Pick<Player, 'id' | 'faction' | 'advisors'> &
ForcePresence;
export type DiscoveryCollectionContext = MobileBoard & {
order: readonly string[];
players: readonly DiscoveryCollectionSeat[];
/** Deposits before ordinary collection. */
spice: Readonly<Record<string, number>>;
};
export type DiscoveryBaseCollectionReceipt = {
player: string;
strongholds: number;
collected: number;
desert: number;
balance: number;
};
export type DiscoveryBaseCollectionQuote = {
/** Deposits remaining after ordinary and shared collection. */
spice: Readonly<Record<string, number>>;
receipts: readonly DiscoveryBaseCollectionReceipt[];
shared: readonly {
territory: string;
ecaz: string;
ally: string;
amount: number;
}[];
};
export type CisternCollectionReceipt = {
kind: 'cistern';
player: string;
amount: 2;
source: 'bank';
};
export type OrgizCollectionReceipt = {
kind: 'orgiz';
player: string;
from: string;
location: string;
amount: 1;
source: 'player';
};
export type DiscoveryCollectionQuote = {
/** Drop-in replacement for the ordinary collection receipts. */
receipts: DiscoveryBaseCollectionReceipt[];
effects: (CisternCollectionReceipt | OrgizCollectionReceipt)[];
};
export type DiscoveryCollectionOptions = {
/**
* Draft interpretation only. The publisher text does not clarify whether a
* stacked physical spice pile represents one or several “spice blows.”
*/
orgizObservableDeposits?: boolean;
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
function requireCollection(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new DiscoveryCollectionError(message);
}
function occupants(
context: DiscoveryCollectionContext,
location: string,
): DiscoveryCollectionSeat[] {
if (!gameTerritories(context).some((territory) => territory.id === location))
return [];
return context.players.filter((player) => fighterCount(player, location) > 0);
}
/**
* Compose Cistern, plus an explicitly enabled bounded Orgiz draft, over the
* canonical ordinary collection quote. The ordinary `collected` and `desert`
* fields stay factual: Cistern is bank income and Orgiz moves spice afterward.
*
* The printed source does not resolve two occupants claiming the same location
* or identify a payer before an Ecaz shared lot is allocated. Cistern therefore
* pays only an ordinary sole occupant; the unresolved contested case is retained
* without a bonus instead of blocking the Collection phase.
*/
export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext,
collection: DiscoveryBaseCollectionQuote,
options: DiscoveryCollectionOptions = {},
): DiscoveryCollectionQuote {
requireCollection(
record(context) &&
Array.isArray(context.players) &&
context.players.length > 0 &&
Array.isArray(context.order) &&
context.order.length === context.players.length &&
new Set(context.order).size === context.order.length &&
context.players.every(
(player) =>
record(player) &&
typeof player.id === 'string' &&
player.id.length > 0 &&
record(player.forces) &&
context.order.includes(player.id),
) &&
new Set(context.players.map((player) => player.id)).size ===
context.players.length &&
record(context.spice) &&
record(collection) &&
record(collection.spice) &&
Array.isArray(collection.receipts) &&
Array.isArray(collection.shared) &&
record(options) &&
Object.keys(options).every((key) => key === 'orgizObservableDeposits') &&
(options.orgizObservableDeposits === undefined ||
typeof options.orgizObservableDeposits === 'boolean'),
'Discovery collection needs the canonical public collection context and quote.',
);
const sourceKeys = Object.keys(context.spice).sort();
const remainingKeys = Object.keys(collection.spice).sort();
requireCollection(
sourceKeys.every((key) => Object.hasOwn(collection.spice, key)) &&
remainingKeys.every(
(key) =>
Object.hasOwn(context.spice, key) || collection.spice[key] === 0,
) &&
sourceKeys.every(
(key) =>
whole(context.spice[key]) &&
whole(collection.spice[key]) &&
collection.spice[key] <= context.spice[key],
),
'Discovery collection needs the ordinary quote’s conserved board deposits.',
);
const receipts = collection.receipts.map((receipt) => {
requireCollection(
receipt !== null &&
typeof receipt === 'object' &&
!Array.isArray(receipt) &&
typeof receipt.player === 'string' &&
context.order.includes(receipt.player) &&
whole(receipt.strongholds) &&
whole(receipt.collected) &&
whole(receipt.desert) &&
receipt.desert <= receipt.collected &&
whole(receipt.balance),
'Discovery collection needs one valid ordinary receipt per player.',
);
return { ...receipt };
});
requireCollection(
receipts.length === context.order.length &&
new Set(receipts.map((receipt) => receipt.player)).size ===
context.order.length &&
context.order.every((id) =>
receipts.some((receipt) => receipt.player === id),
),
'Discovery collection receipts must match the complete player order.',
);
requireCollection(
collection.shared.every(
(lot) =>
lot !== null &&
typeof lot === 'object' &&
!Array.isArray(lot) &&
typeof lot.territory === 'string' &&
typeof lot.ecaz === 'string' &&
typeof lot.ally === 'string' &&
lot.ecaz !== lot.ally &&
whole(lot.amount),
),
'Discovery collection needs valid retained shared-spice lots.',
);
const effects: DiscoveryCollectionQuote['effects'] = [];
const cistern = occupants(context, CISTERN);
if (cistern.length === 1) {
const receipt = receipts.find((entry) => entry.player === cistern[0].id)!;
requireCollection(
Number.isSafeInteger(receipt.balance + 2),
'Cistern bank income would overflow the spice balance.',
);
receipt.balance += 2;
effects.push({
kind: 'cistern',
player: receipt.player,
amount: 2,
source: 'bank',
});
}
const orgiz = occupants(context, ORGIZ_PROCESSING_STATION);
const collectedLocations = sourceKeys.filter(
(key) => context.spice[key] > collection.spice[key],
);
requireCollection(
!options.orgizObservableDeposits ||
orgiz.length <= 1 ||
collectedLocations.length === 0,
'Orgiz theft with two occupying factions awaits an explicit ruling.',
);
if (
options.orgizObservableDeposits &&
orgiz.length === 1 &&
collectedLocations.length > 0
) {
requireCollection(
!collection.shared.some((lot) => lot.amount > 0),
'Orgiz theft from an unresolved Ecaz shared lot awaits an explicit ruling.',
);
const owner = orgiz[0];
const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!;
for (const key of collectedLocations) {
const collectors = context.players.filter(
(player) =>
!isAdvisor(player, splitLocation(key).territory) &&
(presenceByLocation(player)[key] ?? 0) > 0,
);
requireCollection(
collectors.length === 1,
'Orgiz theft needs one ordinary collector for each collected spice blow.',
);
const collector = collectors[0];
if (collector.id === owner.id) continue;
const collectorReceipt = receipts.find(
(entry) => entry.player === collector.id,
)!;
requireCollection(
collectorReceipt.balance >= 1 &&
Number.isSafeInteger(ownerReceipt.balance + 1),
'Orgiz theft would invalidate a player spice balance.',
);
collectorReceipt.balance -= 1;
ownerReceipt.balance += 1;
effects.push({
kind: 'orgiz',
player: owner.id,
from: collector.id,
location: key,
amount: 1,
source: 'player',
});
}
}
return { receipts, effects };
}
+94
View File
@@ -0,0 +1,94 @@
import type { Action, GameView } from './engine';
import type {
DiscoveryEntryOffer,
DiscoveryEntrySource,
} from './discovery-entry';
function entryDecision(game: GameView) {
const decision = game.decision;
return decision?.kind === 'discoveryEntry' ? decision : null;
}
function currentEntry(game: GameView) {
const decision = entryDecision(game),
offer = game.discoveryEntry;
if (
game.status !== 'playing' ||
!decision ||
decision.player !== game.me ||
!offer ||
offer.owner !== game.me ||
offer.event !== decision.event ||
offer.blocked
)
return null;
return { decision, offer };
}
function whole(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function selectedGroups(
offer: DiscoveryEntryOffer,
groups: readonly DiscoveryEntrySource[],
): DiscoveryEntrySource[] | null {
if (!Array.isArray(groups) || !groups.length) return null;
const selected = groups
.map((group) => ({ ...group }))
.sort((a, b) => a.source.localeCompare(b.source));
if (
new Set(selected.map((group) => group.source)).size !== selected.length ||
selected.some((group) => {
const available = offer.sources.find(
(source) => source.source === group.source,
);
return (
Object.keys(group).sort().join(',') !== 'elite,normal,source' ||
!available ||
!whole(group.normal) ||
!whole(group.elite) ||
group.normal + group.elite <= 0 ||
group.normal > available.normal ||
group.elite > available.elite
);
})
)
return null;
return selected;
}
export function discoveryEntryMoveAction(
game: GameView,
groups: readonly DiscoveryEntrySource[],
): Action | null {
const entry = currentEntry(game);
if (!entry) return null;
const selected = selectedGroups(entry.offer, groups);
return selected
? {
type: 'decision',
event: entry.decision.event,
accept: true,
groups: selected,
}
: null;
}
export function discoveryEntryDeclineAction(
game: GameView,
): Action | null {
const entry = currentEntry(game);
return entry
? { type: 'decision', event: entry.decision.event, accept: false }
: null;
}
/** Uses only the current player's projected physical source groups. */
export function discoveryEntryBotActions(game: GameView): Action[] {
const offer = game.discoveryEntry;
if (!currentEntry(game) || !offer) return [];
const move = discoveryEntryMoveAction(game, offer.sources),
decline = discoveryEntryDeclineAction(game);
return [move, decline].filter((action): action is Action => action !== null);
}
+563
View File
@@ -0,0 +1,563 @@
import type { FactionId } from './catalog';
import { fighterCount } from './advisors';
import {
isDiscoveryLocationId,
isDiscoveryTokenId,
validateDiscoveryState,
type DiscoveryLocationId,
type DiscoveryOpaqueTokenId,
type DiscoveryState,
} from './discoveries';
import { splitLocation, validGameLocation, type MobileBoard } from './board';
import { territoryEntryBlock, type OccupancySeat } from './occupancy';
export class DiscoveryEntryError extends Error {}
function requireEntry(condition: unknown, message: string): asserts condition {
if (!condition) throw new DiscoveryEntryError(message);
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const identifier = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'prototype', 'constructor'].includes(value);
export type DiscoveryEntrySeat = OccupancySeat & {
id: string;
faction: FactionId;
forces: Record<string, number>;
elites?: { forces: Record<string, number> };
};
export type DiscoveryEntryContext = Omit<MobileBoard, 'discoveries'> & {
turn: number;
storm: number;
order: readonly string[];
players: readonly DiscoveryEntrySeat[];
discoveries: DiscoveryState;
};
export type DiscoveryEntrySource = {
source: string;
normal: number;
elite: number;
};
export type DiscoveryEntryArrival = {
event: string;
token: DiscoveryOpaqueTokenId;
owner: string;
parent: string;
destination: DiscoveryLocationId;
sector: 0;
destinationBefore: number;
destinationEliteBefore: number;
groups: DiscoveryEntrySource[];
amount: number;
elite: number;
signature: string;
};
export type DiscoveryEntryRound = {
version: 1;
event: string;
turn: number;
tokens: DiscoveryOpaqueTokenId[];
order: string[];
cursor: number;
stage: 'choose' | 'arrival' | 'complete';
arrival: DiscoveryEntryArrival | null;
signature: string;
};
export type DiscoveryEntrySelection = {
groups: readonly DiscoveryEntrySource[];
};
export type DiscoveryEntryOffer = {
event: string;
token: DiscoveryOpaqueTokenId;
owner: string;
parent: string;
destination: DiscoveryLocationId;
sector: 0;
sources: DiscoveryEntrySource[];
blocked: string | null;
arrival: DiscoveryEntryArrival | null;
};
export type DiscoveryEntryArrivalChild = {
owner: string;
territory: string;
sector: number;
amount: number;
elite: number;
discoveryEntry: string;
};
function arrivalPayload(arrival: Omit<DiscoveryEntryArrival, 'signature'>) {
return [
arrival.event,
arrival.token,
arrival.owner,
arrival.parent,
arrival.destination,
arrival.sector,
arrival.destinationBefore,
arrival.destinationEliteBefore,
arrival.groups.map((group) => [group.source, group.normal, group.elite]),
arrival.amount,
arrival.elite,
];
}
export function discoveryEntryArrivalSignature(
arrival: Omit<DiscoveryEntryArrival, 'signature'>,
): string {
return JSON.stringify(arrivalPayload(arrival));
}
export function discoveryEntrySignature(frame: DiscoveryEntryRound): string {
return JSON.stringify([
frame.version,
frame.event,
frame.turn,
frame.tokens,
frame.order,
frame.cursor,
frame.stage,
frame.arrival,
]);
}
function expectedTokens(g: DiscoveryEntryContext) {
return g.discoveries.newlyRevealed.filter((id) => {
const token = g.discoveries.tokens.find((candidate) => candidate.id === id);
return (
token?.status === 'placed' &&
token.revealedTurn === g.turn - 1 &&
isDiscoveryLocationId(token.face)
);
});
}
function step(frame: DiscoveryEntryRound) {
const owner = frame.order[frame.cursor % frame.order.length];
const token = frame.tokens[Math.floor(frame.cursor / frame.order.length)];
return { owner, token };
}
function strictSource(value: unknown): value is DiscoveryEntrySource {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const source = value as DiscoveryEntrySource;
return (
Object.keys(source).sort().join(',') === 'elite,normal,source' &&
identifier(source.source) &&
whole(source.normal) &&
whole(source.elite) &&
source.normal + source.elite > 0
);
}
function validateInventory(discoveries: DiscoveryState) {
try {
validateDiscoveryState(discoveries);
} catch (error) {
throw new DiscoveryEntryError(
error instanceof Error ? error.message : 'Invalid Discovery inventory.',
);
}
}
function validateArrivalShape(
frame: DiscoveryEntryRound,
arrival: DiscoveryEntryArrival,
) {
const current = step(frame);
requireEntry(
!!arrival &&
typeof arrival === 'object' &&
!Array.isArray(arrival) &&
Object.keys(arrival).sort().join(',') ===
'amount,destination,destinationBefore,destinationEliteBefore,elite,event,groups,owner,parent,sector,signature,token' &&
arrival.event === `${frame.event}:${frame.cursor}` &&
arrival.token === current.token &&
arrival.owner === current.owner &&
identifier(arrival.parent) &&
isDiscoveryLocationId(arrival.destination) &&
arrival.sector === 0 &&
whole(arrival.destinationBefore) &&
whole(arrival.destinationEliteBefore) &&
arrival.destinationEliteBefore <= arrival.destinationBefore &&
Array.isArray(arrival.groups) &&
arrival.groups.length > 0 &&
arrival.groups.every(strictSource) &&
arrival.groups.every(
(group, index) =>
index === 0 ||
arrival.groups[index - 1].source.localeCompare(group.source) < 0,
) &&
whole(arrival.amount) &&
arrival.amount > 0 &&
whole(arrival.elite) &&
arrival.elite <= arrival.amount &&
arrival.amount ===
arrival.groups.reduce(
(sum, group) => sum + group.normal + group.elite,
0,
) &&
arrival.elite ===
arrival.groups.reduce((sum, group) => sum + group.elite, 0) &&
arrival.signature === discoveryEntryArrivalSignature(arrival),
'The Discovery entry has lost its exact signed arrival.',
);
}
export function validateDiscoveryEntryRound(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
): void {
validateInventory(g.discoveries);
requireEntry(
whole(g.turn) &&
g.turn >= 2 &&
whole(g.storm) &&
g.storm <= 18 &&
Array.isArray(g.order) &&
g.order.length > 0 &&
new Set(g.order).size === g.order.length &&
Array.isArray(g.players) &&
g.players.length === g.order.length &&
new Set(g.players.map((player) => player.id)).size === g.players.length &&
g.order.every((id) => g.players.some((player) => player.id === id)),
'Discovery entry needs the current turn, storm order, and seated players.',
);
requireEntry(
!!frame &&
typeof frame === 'object' &&
!Array.isArray(frame) &&
Object.keys(frame).sort().join(',') ===
'arrival,cursor,event,order,signature,stage,tokens,turn,version' &&
frame.version === 1 &&
identifier(frame.event) &&
frame.turn === g.turn &&
Array.isArray(frame.tokens) &&
frame.tokens.every(isDiscoveryTokenId) &&
new Set(frame.tokens).size === frame.tokens.length &&
JSON.stringify(frame.tokens) === JSON.stringify(expectedTokens(g)) &&
Array.isArray(frame.order) &&
JSON.stringify(frame.order) === JSON.stringify(g.order) &&
whole(frame.cursor) &&
frame.cursor <= frame.tokens.length * frame.order.length &&
['choose', 'arrival', 'complete'].includes(frame.stage) &&
frame.signature === discoveryEntrySignature(frame),
'The Discovery entry round has lost its original token and player order.',
);
const complete = frame.cursor === frame.tokens.length * frame.order.length;
requireEntry(
(frame.stage === 'complete') === complete &&
(frame.stage === 'arrival') === (frame.arrival !== null) &&
(frame.stage !== 'complete' || frame.arrival === null),
'The Discovery entry cursor and pending arrival disagree.',
);
if (frame.arrival) {
validateArrivalShape(frame, frame.arrival);
const token = g.discoveries.tokens.find(
(candidate) => candidate.id === frame.arrival!.token,
)!;
const validSources = frame.arrival.groups.every((group) => {
const source = splitLocation(group.source);
return (
group.source === `${source.territory}:${source.sector}` &&
source.territory === frame.arrival!.parent &&
source.sector !== g.storm &&
validGameLocation(g, source.territory, source.sector)
);
});
requireEntry(
token.territory === frame.arrival.parent &&
token.face === frame.arrival.destination &&
validGameLocation(g, frame.arrival.destination, 0) &&
validSources,
'The Discovery entry arrival has lost its revealed destination.',
);
}
}
export function createDiscoveryEntryRound(
g: DiscoveryEntryContext,
event: string,
): DiscoveryEntryRound {
validateInventory(g.discoveries);
requireEntry(identifier(event), 'Discovery entry needs a stable event ID.');
requireEntry(
whole(g.turn) &&
g.turn >= 2 &&
Array.isArray(g.order) &&
g.order.length > 0,
'Discovery entry starts on the turn after a location was revealed.',
);
const tokens = [...expectedTokens(g)];
const frame: DiscoveryEntryRound = {
version: 1,
event,
turn: g.turn,
tokens,
order: [...g.order],
cursor: 0,
stage: tokens.length ? 'choose' : 'complete',
arrival: null,
signature: '',
};
frame.signature = discoveryEntrySignature(frame);
validateDiscoveryEntryRound(g, frame);
return frame;
}
function sourceOffer(
g: DiscoveryEntryContext,
player: DiscoveryEntrySeat,
parent: string,
): DiscoveryEntrySource[] {
if (fighterCount(player, parent) === 0) return [];
const sources: DiscoveryEntrySource[] = [];
for (const [source, total] of Object.entries(player.forces)) {
const location = splitLocation(source);
if (
location.territory !== parent ||
source !== `${location.territory}:${location.sector}` ||
location.sector === g.storm ||
!validGameLocation(g, location.territory, location.sector)
)
continue;
const elite = player.elites?.forces[source] ?? 0;
requireEntry(
whole(total) && whole(elite) && elite <= total,
'Discovery entry needs valid ordinary and elite source custody.',
);
if (total > 0) sources.push({ source, normal: total - elite, elite });
}
return sources.sort((a, b) => a.source.localeCompare(b.source));
}
function currentOffer(g: DiscoveryEntryContext, frame: DiscoveryEntryRound) {
const current = step(frame);
const player = g.players.find((candidate) => candidate.id === current.owner)!;
const token = g.discoveries.tokens.find(
(candidate) => candidate.id === current.token,
)!;
requireEntry(
token.status === 'placed' &&
token.revealedTurn === g.turn - 1 &&
token.territory !== null &&
isDiscoveryLocationId(token.face) &&
validGameLocation(g, token.face, 0),
'The Discovery entry step has lost its newly revealed location.',
);
return {
current,
player,
token,
parent: token.territory,
destination: token.face,
} as {
current: typeof current;
player: DiscoveryEntrySeat;
token: typeof token;
parent: string;
destination: DiscoveryLocationId;
};
}
export function quoteDiscoveryEntry(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
owner: string,
selection?: DiscoveryEntrySelection,
): DiscoveryEntryOffer {
validateDiscoveryEntryRound(g, frame);
requireEntry(
frame.stage === 'choose',
'No Discovery entry choice is pending.',
);
const { current, player, token, parent, destination } = currentOffer(
g,
frame,
);
requireEntry(
owner === current.owner,
'This Discovery entry belongs to another player.',
);
const sources = sourceOffer(g, player, parent);
let blocked: string | null = null;
try {
blocked = territoryEntryBlock(g.players, owner, destination);
} catch (error) {
throw new DiscoveryEntryError(
error instanceof Error ? error.message : 'Invalid Discovery occupancy.',
);
}
if (!blocked && !sources.length)
blocked =
'No non-advisor forces outside the storm can enter this location.';
const base = {
event: frame.event,
token: token.id,
owner,
parent,
destination,
sector: 0 as const,
sources,
blocked,
};
if (!selection) return { ...base, arrival: null };
requireEntry(!blocked, blocked ?? 'This Discovery entry is unavailable.');
requireEntry(
selection &&
typeof selection === 'object' &&
!Array.isArray(selection) &&
Object.keys(selection).join(',') === 'groups' &&
Array.isArray(selection.groups) &&
selection.groups.length > 0 &&
selection.groups.every(strictSource),
'Choose explicit positive ordinary and elite source groups.',
);
const groups = selection.groups
.map((group) => ({ ...group }))
.sort((a, b) => a.source.localeCompare(b.source));
requireEntry(
new Set(groups.map((group) => group.source)).size === groups.length,
'Choose each Discovery entry source at most once.',
);
for (const group of groups) {
const available = sources.find((source) => source.source === group.source);
requireEntry(
available &&
group.normal <= available.normal &&
group.elite <= available.elite,
'Discovery entry groups must remain within their typed source custody.',
);
}
const destinationKey = `${destination}:0`;
const destinationBefore = player.forces[destinationKey] ?? 0;
const destinationEliteBefore = player.elites?.forces[destinationKey] ?? 0;
requireEntry(
whole(destinationBefore) &&
whole(destinationEliteBefore) &&
destinationEliteBefore <= destinationBefore,
'Discovery entry needs valid pre-arrival destination custody.',
);
const unsigned: Omit<DiscoveryEntryArrival, 'signature'> = {
event: `${frame.event}:${frame.cursor}`,
token: token.id,
owner,
parent,
destination,
sector: 0,
destinationBefore,
destinationEliteBefore,
groups,
amount: groups.reduce((sum, group) => sum + group.normal + group.elite, 0),
elite: groups.reduce((sum, group) => sum + group.elite, 0),
};
const arrival: DiscoveryEntryArrival = {
...unsigned,
signature: discoveryEntryArrivalSignature(unsigned),
};
return { ...base, arrival };
}
function nextFrame(frame: DiscoveryEntryRound): DiscoveryEntryRound {
const cursor = frame.cursor + 1;
const complete = cursor === frame.tokens.length * frame.order.length;
const next: DiscoveryEntryRound = {
...frame,
tokens: [...frame.tokens],
order: [...frame.order],
cursor,
stage: complete ? 'complete' : 'choose',
arrival: null,
signature: '',
};
next.signature = discoveryEntrySignature(next);
return next;
}
/** Decline or auto-skip the current optional entry step. */
export function advanceDiscoveryEntry(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
owner: string,
): DiscoveryEntryRound {
validateDiscoveryEntryRound(g, frame);
requireEntry(
frame.stage === 'choose' && step(frame).owner === owner,
'Only the current player may decline this Discovery entry.',
);
const next = nextFrame(frame);
validateDiscoveryEntryRound(g, next);
return next;
}
/** Sign an accepted source selection before the caller moves physical forces. */
export function beginDiscoveryEntryArrival(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
owner: string,
selection: DiscoveryEntrySelection,
): DiscoveryEntryRound {
const quote = quoteDiscoveryEntry(g, frame, owner, selection);
requireEntry(quote.arrival, 'Choose forces for this Discovery entry.');
const next: DiscoveryEntryRound = {
...frame,
tokens: [...frame.tokens],
order: [...frame.order],
stage: 'arrival',
arrival: {
...quote.arrival,
groups: quote.arrival.groups.map((group) => ({ ...group })),
},
signature: '',
};
next.signature = discoveryEntrySignature(next);
validateDiscoveryEntryRound(g, next);
return next;
}
/** Bind an intrusion, advisor or other arrival child to the signed transfer. */
export function validateDiscoveryEntryArrivalChild(
frame: DiscoveryEntryRound,
child: DiscoveryEntryArrivalChild,
): void {
const arrival = frame.arrival;
requireEntry(
frame.stage === 'arrival' &&
arrival &&
child &&
typeof child === 'object' &&
!Array.isArray(child) &&
Object.keys(child).sort().join(',') ===
'amount,discoveryEntry,elite,owner,sector,territory' &&
child.owner === arrival.owner &&
child.territory === arrival.destination &&
child.sector === arrival.sector &&
child.amount === arrival.amount &&
child.elite === arrival.elite &&
child.discoveryEntry === arrival.signature,
'The Discovery interaction has lost its exact signed arrival.',
);
}
/** Advance after the engine has transferred the signed groups and settled entry reactions. */
export function finishDiscoveryEntryArrival(
g: DiscoveryEntryContext,
frame: DiscoveryEntryRound,
): DiscoveryEntryRound {
validateDiscoveryEntryRound(g, frame);
requireEntry(frame.stage === 'arrival', 'No Discovery arrival is pending.');
const next = nextFrame(frame);
validateDiscoveryEntryRound(g, next);
return next;
}
+125
View File
@@ -0,0 +1,125 @@
import type { Action, GameView } from './engine';
import { gameDistance, gameTerritories, location, splitLocation, validGameLocation } from './board';
import { isAdvisor } from './advisors';
import { presenceAt } from './force-presence';
import { strongholdPathBlocked, territoryEntryBlock } from './occupancy';
import { botEntryAllowed, botMovementRange } from './bot-mobility';
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
import { validateCohortSelection } from './ornithopter';
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
type Quote = { action: Action | null; blocked: string | null };
/** One projected selection shared by the human control and every bot profile.
* The server binds its own canonical order and receipt when this action arrives. */
export function discoveryFlightMove(game: GameView, move: Action | null): Quote {
const fail = (blocked: string): Quote => ({ action: null, blocked });
const me = game.players.find(player => player.id === game.me), offer = game.discoveryOrnithopter;
if (!me || !offer) return fail('You do not carry a Discovery Ornithopter.');
if (offer.blocked) return fail(offer.blocked);
if (!nexusGuildMovementAvailable(game)) return fail('No movement is currently available.');
const held = game.discoveries?.tokens.find(token => token.id === offer.token);
if (!held || held.face !== 'ornithopter' || held.status !== 'carried' || held.owner !== me.id ||
held.acquiredTurn !== offer.acquiredTurn || held.acquiredTurn >= game.turn)
return fail('This carried token is no longer available.');
if (!move || move.type !== 'move') return fail('Select a movement group and destination above.');
if (typeof move.territory !== 'string' || !whole(move.sector) ||
!validGameLocation(game, move.territory, move.sector))
return fail('Select an available destination and sector.');
const destination = location(move.territory, move.sector);
const entries = move.forces !== undefined
? object(move.forces) ? Object.entries(move.forces) : null
: typeof move.from === 'string' ? [[move.from, move.amount] as const] : null;
if (!entries) return fail('Select forces from one territory.');
const forces: Record<string, number> = {}, elites: Record<string, number> = {};
let origin: string | undefined;
for (const [key, count] of entries) {
const source = splitLocation(key);
if (!validGameLocation(game, source.territory, source.sector) ||
!whole(count) || count > (me.forces[key] ?? 0))
return fail('The selected forces are no longer available.');
if (!count) continue;
if (origin && origin !== source.territory) return fail('Select forces from one territory.');
origin = source.territory;
const availableElite = me.elites?.forces[key] ?? 0;
const selectedElite = move.forces !== undefined
? object(move.eliteForces) ? move.eliteForces[key] : undefined : move.elite;
const elite = selectedElite ?? Math.max(0, count - ((me.forces[key] ?? 0) - availableElite));
if (!whole(elite) || elite > Math.min(count, availableElite) ||
count - elite > (me.forces[key] ?? 0) - availableElite)
return fail('Choose an available normal and elite force allocation.');
forces[key] = count;
elites[key] = elite;
}
const deployed = game.richeseNoField?.owner === me.id
? game.richeseNoField.private?.deployed : undefined;
const marker = move.noField === undefined ? undefined : deployed &&
deployed.tokenId === move.noField && game.richeseNoField?.event === move.event
? { tokenId: deployed.tokenId, event: String(move.event),
from: location(deployed.location.territory, deployed.location.sector) } : null;
if (marker === null) return fail('Choose the current deployed No-Field.');
if (marker) {
const markerOrigin = splitLocation(marker.from).territory;
if (origin && origin !== markerOrigin) return fail('The selected No-Field and forces must share a territory.');
origin = markerOrigin;
}
if (!origin) return fail('Select at least one force or the deployed No-Field.');
const sources = [...Object.keys(forces), ...(marker ? [marker.from] : [])];
if (sources.some(key => key === destination)) return fail('Choose a different destination.');
if (!botEntryAllowed(game, me, move.territory, move.sector, 'move', origin))
return fail('The selected destination is unavailable to this group.');
if (origin !== move.territory && game.balisetRestrictions.some(b =>
b.player === me.id && b.territory === move.territory) && game.players.some(p =>
p.faction === 'choam' && presenceAt(p, String(move.territory))))
return fail('Baliset prevents movement into this CHOAM territory.');
const advisors = isAdvisor(me, origin);
if (move.fighters === true && advisors) {
if (me.advisors?.[origin]?.lockedTurn === game.turn || presenceAt(me, move.territory) ||
territoryEntryBlock(game.players, me.id, move.territory, false))
return fail('These advisors cannot flip to fighters on arrival.');
}
if (sources.some(from => gameDistance(game, from, destination, key => {
const source = splitLocation(key);
return (source.sector !== 0 && source.sector === game.storm) ||
strongholdPathBlocked(game.players, me.id, source.territory, advisors);
}) > 3)) return fail('This route is blocked or more than three territories away.');
const flight = game.ornithopter?.active;
if (flight && advisors) return fail('Advanced advisor use of the active movement card awaits its ruling.');
if (flight?.cohort) {
try { validateCohortSelection(flight.cohort, me.forces, me.elites?.forces ?? {}, forces, elites, marker); }
catch { return fail('Choose original forces that have not moved with the active card.'); }
}
return {
action: { ...move, discoveryOrnithopter: offer.token,
...(flight ? { ornithopterEvent: flight.event } : {}) },
blocked: null,
};
}
/** Spend the token only when its fixed range opens a route ordinary movement
* cannot reach. Destination preference remains with the existing bot policy. */
export function discoveryFlightBotActions(game: GameView,
targets = gameTerritories(game).flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))),
): Action[] {
const me = game.players.find(player => player.id === game.me);
if (!me || !game.discoveryOrnithopter || game.discoveryOrnithopter.blocked ||
!nexusGuildMovementAvailable(game)) return [];
const actions: Action[] = [];
for (const target of targets) for (const [from, amount] of Object.entries(me.forces)) {
if (amount <= 0) continue;
const elite = me.elites?.forces[from] ?? 0;
const advisors = isAdvisor(me, splitLocation(from).territory);
const distance = gameDistance(game, from, location(target.territory, target.sector), key => {
const source = splitLocation(key);
return (source.sector !== 0 && source.sector === game.storm) ||
strongholdPathBlocked(game.players, me.id, source.territory, advisors);
});
if (distance <= botMovementRange(game, me, elite) || distance > 3) continue;
const quote = discoveryFlightMove(game, { type: 'move', from, amount, elite, ...target });
if (quote.action) actions.push(quote.action);
}
return actions;
}
+215
View File
@@ -0,0 +1,215 @@
import {
consumeOrnithopter,
isDiscoveryTokenId,
validateDiscoveryState,
type DiscoveryOpaqueTokenId,
type DiscoveryState,
type ProjectedDiscoveryToken,
} from './discoveries';
import type { MovementMarker } from './ornithopter';
export class DiscoveryFlightError extends Error {
constructor(message: string) {
super(message);
this.name = 'DiscoveryFlightError';
}
}
/** Structural subset of an ordinary canonical MovementOrder, never an action. */
export type DiscoveryFlightMovement = {
source?: string;
player: string;
group: readonly (readonly [string, number])[];
eliteGroup: Readonly<Record<string, number>>;
elite: number;
origin: string;
total: number;
to: string;
sector: number;
advisors: boolean;
wantsFighters: boolean;
lockedTurn?: number;
noField?: MovementMarker;
};
/** Kept on the pending order until that one group actually moves. No token is
* consumed by creating this receipt or by an opposing response. */
export type DiscoveryFlightReceipt = {
token: DiscoveryOpaqueTokenId;
player: string;
turn: number;
move: number;
acquiredTurn: number;
selection: string;
};
type Player = { id: string; moved: number };
type Context = {
status: string;
turn: number;
phase: number;
active: string | null;
players: readonly Player[];
};
export type DiscoveryFlightContext = Context & {
discoveries?: DiscoveryState | null;
};
export type DiscoveryFlightOfferContext = Context & {
discoveries?: {
tokens: readonly Pick<ProjectedDiscoveryToken,
'id' | 'face' | 'status' | 'owner' | 'acquiredTurn' | 'revealedTurn'>[];
} | null;
};
const integer = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const name = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireFlight(value: unknown, message: string): asserts value {
if (!value) throw new DiscoveryFlightError(message);
}
function validSource(value: unknown, origin: string): value is string {
if (typeof value !== 'string') return false;
const prefix = `${origin}:`, sector = Number(value.slice(prefix.length));
return value.startsWith(prefix) && integer(sector) && sector <= 18 && value === `${prefix}${sector}`;
}
/** Fixed whitelist: a stored receipt or the separate Treachery flight fields
* cannot recursively enter this signature. Entry and route legality stay with
* the canonical movement quote, including publicly revealed board locations. */
function selection(move: DiscoveryFlightMovement): string {
requireFlight(record(move) && move.source === undefined && name(move.player) && name(move.origin) &&
name(move.to) && integer(move.sector) && move.sector <= 18 &&
typeof move.advisors === 'boolean' && typeof move.wantsFighters === 'boolean' &&
(!move.wantsFighters || move.advisors) &&
(move.lockedTurn === undefined || integer(move.lockedTurn)) &&
Array.isArray(move.group) && record(move.eliteGroup),
'Bind the Discovery Ornithopter to an ordinary declared movement group.');
const group = move.group;
requireFlight(group.every(part => Array.isArray(part) && part.length === 2 &&
validSource(part[0], move.origin) && integer(part[1]) && part[1] > 0) &&
new Set(group.map(([key]) => key)).size === group.length,
'The Discovery flight has invalid or repeated source forces.');
requireFlight(Object.entries(move.eliteGroup).every(([key, count]) => integer(count) &&
group.some(([source, total]) => source === key && count <= total)),
'The Discovery flight has invalid typed forces.');
const total = group.reduce((sum, [, amount]) => sum + amount, 0);
const elite = Object.values(move.eliteGroup).reduce((sum, amount) => sum + amount, 0);
requireFlight(integer(total) && integer(elite) && integer(move.total) && move.total > 0 &&
move.total === total + Number(!!move.noField) && integer(move.elite) && move.elite === elite,
'The Discovery flight must retain its exact physical force count.');
if (move.noField) requireFlight(record(move.noField) &&
name(move.noField.tokenId) && name(move.noField.event) && validSource(move.noField.from, move.origin),
'The Discovery flight has lost its selected No-Field marker.');
const destination = `${move.to}:${move.sector}`;
requireFlight(group.every(([key]) => key !== destination) && move.noField?.from !== destination,
'The Discovery flight must move its selected group to a different location.');
return JSON.stringify([
move.player, move.origin,
[...group].sort(([a], [b]) => a.localeCompare(b)).map(([key, amount]) =>
[key, amount, move.eliteGroup[key] ?? 0]),
move.elite, move.total, move.to, move.sector, move.advisors, move.wantsFighters,
move.lockedTurn ?? null,
move.noField ? [move.noField.tokenId, move.noField.event, move.noField.from] : null,
]);
}
function currentPlayer(context: Context, player: string): Player {
const owner = context.players.find(candidate => candidate.id === player);
requireFlight(context.status === 'playing' && context.phase === 5 && context.active === player &&
integer(context.turn) && context.turn > 0 && owner && integer(owner.moved),
'Use the Discovery Ornithopter during its owner’s current movement turn.');
return owner;
}
function inventory(context: DiscoveryFlightContext): DiscoveryState {
requireFlight(context.discoveries, 'No physical Discovery token inventory is available.');
try { validateDiscoveryState(context.discoveries); }
catch (error) {
throw new DiscoveryFlightError(error instanceof Error ? error.message : 'Invalid Discovery token custody.');
}
return context.discoveries;
}
function carriedToken(context: DiscoveryFlightContext, token: unknown, player: string) {
const state = inventory(context);
requireFlight(isDiscoveryTokenId(token), 'Choose the physical Discovery Ornithopter token.');
const held = state.tokens.find(candidate => candidate.id === token);
requireFlight(held?.face === 'ornithopter' && held.status === 'carried' && held.owner === player,
'That player does not carry this Discovery Ornithopter.');
requireFlight(integer(held.acquiredTurn) && held.acquiredTurn > 0 && held.acquiredTurn < context.turn,
'The Discovery Ornithopter may be used only on a turn after it was acquired.');
return held;
}
/** Projected eligibility only. The engine still supplies ordinary movement
* allowances and interaction locks, and quotes the selected route separately. */
export function discoveryFlightOffer(
context: DiscoveryFlightOfferContext,
owner: string,
options: { movesAllowed?: number; blocked?: string | null } = {},
): { token: DiscoveryOpaqueTokenId; acquiredTurn: number; range: 3; blocked: string | null } | null {
const player = context.players.find(player => player.id === owner);
const tokens = context.discoveries?.tokens.filter(token =>
token.face === 'ornithopter' && token.status === 'carried' && token.owner === owner) ?? [];
if (!player || tokens.length !== 1 || !isDiscoveryTokenId(tokens[0].id) ||
!integer(tokens[0].acquiredTurn) || tokens[0].acquiredTurn < 1 ||
tokens[0].revealedTurn !== tokens[0].acquiredTurn) return null;
const token = tokens[0];
const blocked = !integer(context.turn) || context.turn < 1 || !integer(player.moved)
? 'The current movement turn is unavailable.'
: token.acquiredTurn! >= context.turn
? 'Use this token on a turn after it was acquired.'
: context.status !== 'playing' || context.phase !== 5 || context.active !== owner
? 'Use this token during your own Shipment and Movement turn.'
: options.blocked ?? (options.movesAllowed !== undefined &&
(!integer(options.movesAllowed) || player.moved >= options.movesAllowed)
? 'No ordinary movement action remains.' : null);
return { token: token.id, acquiredTurn: token.acquiredTurn!, range: 3, blocked };
}
/** Creates an unspent selection. Run the normal movement/arrival quote before
* publishing it; this never grants another movement action or spends the token. */
export function quoteDiscoveryFlight(
context: DiscoveryFlightContext,
token: unknown,
move: DiscoveryFlightMovement,
): DiscoveryFlightReceipt {
const player = currentPlayer(context, move.player);
const held = carriedToken(context, token, player.id);
return {
token: held.id, player: player.id, turn: context.turn, move: player.moved,
acquiredTurn: held.acquiredTurn!, selection: selection(move),
};
}
function validateReceipt(context: DiscoveryFlightContext, receipt: DiscoveryFlightReceipt,
move: DiscoveryFlightMovement, completed: boolean) {
requireFlight(record(receipt) && Object.keys(receipt).sort().join(',') ===
'acquiredTurn,move,player,selection,token,turn' && isDiscoveryTokenId(receipt.token) &&
name(receipt.player) && integer(receipt.turn) && receipt.turn > 0 &&
integer(receipt.move) && integer(receipt.acquiredTurn) && receipt.acquiredTurn > 0 &&
typeof receipt.selection === 'string', 'The saved Discovery flight receipt is invalid.');
const player = currentPlayer(context, receipt.player);
requireFlight(receipt.player === move.player && receipt.turn === context.turn &&
player.moved === receipt.move + Number(completed) && receipt.selection === selection(move),
'The Discovery flight no longer matches its original turn, movement or selected group.');
const token = carriedToken(context, receipt.token, receipt.player);
requireFlight(token.acquiredTurn === receipt.acquiredTurn,
'The Discovery flight no longer matches its token’s acquisition.');
}
/** Recheck the same owned group after a pending response or saved continuation.
* Response controls themselves are not a reason to invalidate the receipt. */
export function validateDiscoveryFlight(context: DiscoveryFlightContext,
receipt: DiscoveryFlightReceipt, move: DiscoveryFlightMovement): void {
validateReceipt(context, receipt, move, false);
}
/** Call only after the canonical group physically moves and its movement count
* advances once, before post-arrival effects. Return a new inventory for the
* engine's atomic working copy; rejected or abandoned movement never calls this. */
export function completeDiscoveryFlight(context: DiscoveryFlightContext,
receipt: DiscoveryFlightReceipt, move: DiscoveryFlightMovement): DiscoveryState {
validateReceipt(context, receipt, move, true);
return consumeOrnithopter(context.discoveries!, receipt.player);
}
+191 -16
View File
@@ -1,4 +1,6 @@
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
import { advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound, finishDiscoveryEntryArrival, quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound, type DiscoveryEntryArrivalChild, type DiscoveryEntryRound, type DiscoveryEntrySelection } from './discovery-entry';
import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryState } from './discoveries';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
@@ -228,6 +230,10 @@ import {
BoardResolutionError,
type AdvisorRelease,
} from './board-resolution-quote';
import {
quoteDiscoveryCollection,
DiscoveryCollectionError,
} from './discovery-collection';
import {
quoteMovementCancellation,
validateAmbassadorRelocationContext,
@@ -731,6 +737,7 @@ export type Decision =
player: string;
territory: string;
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
followup?: { shipment: string; destination: string };
}
@@ -747,6 +754,7 @@ export type Decision =
| { kind: 'wormPlacement'; player: string }
| { kind: 'wormProtection'; player: string; territory: string; ally: string }
| { kind: 'discoveryDiscard'; player: string; event: string }
| { kind: 'discoveryEntry'; player: string; event: string }
| { kind: 'greatMakerVote'; player: string; event: string }
| { kind: 'greatMakerRide'; player: string; event: string }
| { kind: 'wormRide'; player: string; territory: string }
@@ -767,7 +775,8 @@ export type ResponseWindow = {
source?: 'ambassador';
intent?: string;
advisors?: boolean;
advisorResume?: 'wormRide' | 'declaration' | 'ambassador';
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string };
@@ -893,6 +902,7 @@ export type Game = {
discoveryEnabled?: boolean;
discoveries?: DiscoveryState;
discoveryStash?: DiscoveryStash;
discoveryEntry?: DiscoveryEntryRound;
greatMaker?: GreatMaker;
nexusChoamTrades?: NexusChoamTrade[];
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
@@ -5478,6 +5488,7 @@ function afterWorm(g: Game) {
);
}
function discoveryIntegrity(g: Game) {
discoveryEntryIntegrity(g);
if (!g.discoveryEnabled) {
requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.');
return;
@@ -5530,6 +5541,100 @@ function discoveryIntegrity(g: Game) {
if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded,
'The stash has changed its committed discard.');
}
function discoveryEntryIntegrity(g: Game) {
const frame = g.discoveryEntry;
const continuation = g.pendingTreacheryDiscard?.continuation;
const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null];
const choices = controls.flatMap(control => control?.decision?.kind === 'discoveryEntry' ? [control.decision] : []);
const children = controls.flatMap(control => {
const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null;
return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]
.filter(response => response?.advisorResume === 'discoveryEntry' || response?.discoveryEntry)
.map(response => ({receipt:response!.discoveryEntry, actor:response!.owner,
territory:response!.location, valid:response!.kind === 'advisorFlip' &&
response!.advisorResume === 'discoveryEntry' && response!.advisors === true &&
response!.advisorAmbassadorEvent === undefined && response!.advisorFollowup === undefined &&
response!.advisorRemaining === undefined}));
});
for (const control of controls) if (control?.decision?.kind === 'intrusion' && control.decision.discoveryEntry)
children.push({receipt:control.decision.discoveryEntry, actor:control.decision.player,
territory:control.decision.territory, valid:control.decision.wormRide === undefined &&
control.decision.ambassadorEvent === undefined && control.decision.followup === undefined});
if (!frame) {
requireRule(!choices.length && !children.length, 'The Discovery entry has lost its original turn-opening round.');
return;
}
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 === 'choose') {
const owner = frame.order[frame.cursor % frame.order.length];
requireRule(!children.length && choices.length === 1 && choices[0].player === owner &&
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,
'The Discovery arrival must retain its original Bene Gesserit interaction.');
nexusRule(() => validateDiscoveryEntryArrivalChild(frame, child.receipt!));
}
}
}
function continueDiscoveryEntry(g: Game) {
requireRule(g.discoveryEntry && g.discoveries, 'No Discovery entry round is pending.');
while (g.discoveryEntry!.stage === 'choose') {
const frame: DiscoveryEntryRound = g.discoveryEntry!, owner = frame.order[frame.cursor % frame.order.length];
const offer = nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner));
if (!offer.blocked) {
g.decision = {kind:'discoveryEntry',player:owner,event:`${frame.event}:${frame.cursor}`};
return;
}
g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner));
}
requireRule(g.discoveryEntry!.stage === 'complete', 'Finish the Discovery arrival before the next entry.');
g.discoveries = {...g.discoveries,newlyRevealed:[]};
delete g.discoveryEntry;
beginTurnBoardMovement(g);
}
function completeDiscoveryEntryArrival(g: Game, child: DiscoveryEntryArrivalChild) {
requireRule(g.discoveryEntry && g.discoveries, 'The Discovery arrival has lost its round.');
nexusRule(() => validateDiscoveryEntryArrivalChild(g.discoveryEntry!,child));
g.discoveryEntry = nexusRule(() => finishDiscoveryEntryArrival({...g,discoveries:g.discoveries!},g.discoveryEntry!));
continueDiscoveryEntry(g);
}
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.');
if (!action.accept) {
requireRule(action.groups === undefined, 'Declining Discovery entry must not select forces.');
g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,p.id));
log(g, `${p.name} declined this optional free Discovery entry.`);
continueDiscoveryEntry(g);
return;
}
g.discoveryEntry = nexusRule(() => beginDiscoveryEntryArrival({...g,discoveries:g.discoveries!},frame,p.id,
{groups:action.groups as DiscoveryEntrySelection['groups']}));
const arrival = g.discoveryEntry.arrival!;
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 (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 child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature};
if (!intrusion(g,p,arrival.destination,{discoveryEntry:child})) completeDiscoveryEntryArrival(g,child);
}
function resolveDiscoveryBlow(g: Game, card: Extract<SpiceCard, {territory:string}>) {
requireRule(g.discoveryEnabled && g.discoveries && isDiscoverySpiceCardId(card.discovery), 'This Discovery card requires its physical module.');
const printed = DISCOVERY_SPICE_CARDS.find(value => value.discovery === card.discovery)!;
@@ -7765,7 +7870,16 @@ function ornithopterBlock(
return 'Kulon combined with the fixed three-territory card range awaits a ruling.';
return null;
}
function discoveryFlightIntegrity(g: Game) {
const pendingFlights = [g.pendingChoamMove, g.pendingIxMove, g.pendingFremenMove?.order]
.filter((move): move is MovementOrder => !!move && move.discoveryFlight !== undefined);
requireRule(pendingFlights.length <= 1,
'One Discovery Ornithopter cannot be reserved for multiple pending movements.');
for (const move of pendingFlights)
nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move));
}
function ornithopterIntegrity(g: Game) {
discoveryFlightIntegrity(g);
const flight = g.ornithopter;
if (!flight) return;
const owner = getPlayer(g, flight.player);
@@ -7883,9 +7997,11 @@ function ornithopterView(g: Game, p: Player) {
};
}
function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
return move.ornithopterRange ? 3 : movementRange(g, p, move.elite);
return move.discoveryFlight || move.ornithopterRange ? 3 : movementRange(g, p, move.elite);
}
function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
if (move.discoveryFlight)
nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move));
const flight = g.ornithopter;
requireRule(
!flight || flight.player !== p.id || move.ornithopterEvent === flight.event,
@@ -8194,6 +8310,10 @@ function completeMove(g: Game, move: MovementOrder) {
finishShipmentPromises(g, p, null);
p.shipped = true;
p.moved++;
if (move.discoveryFlight) {
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
}
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
const completed: CompletedMovement = {
@@ -10805,7 +10925,8 @@ function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) {
function finishAdvisorReaction(
g: Game,
details: {
advisorResume?: 'wormRide' | 'declaration' | 'ambassador';
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string };
@@ -10819,6 +10940,10 @@ function finishAdvisorReaction(
beginAdvisorBattle(g, details.advisorRemaining ?? []);
else if (details.advisorResume === 'ambassador')
continueAmbassadorArrival(g, details.advisorAmbassadorEvent!);
else if (details.advisorResume === 'discoveryEntry') {
requireRule(details.discoveryEntry, 'The advisor reaction has lost its Discovery arrival.');
completeDiscoveryEntryArrival(g, details.discoveryEntry);
}
}
function intrusion(
g: Game,
@@ -10826,6 +10951,7 @@ function intrusion(
t: string,
options: {
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
followup?: { shipment: string; destination: string };
} = {},
@@ -11235,6 +11361,13 @@ function beginPhase(g: Game) {
} else if (!byFaction(g, 'choam')) victory(g);
}
if (g.phase === 0 && g.turn > 1) {
if (g.discoveryEnabled && g.discoveries) {
g.discoveryEntry = nexusRule(() => createDiscoveryEntryRound({...g,discoveries:g.discoveries!},crypto.randomUUID()));
continueDiscoveryEntry(g);
} else beginTurnBoardMovement(g);
}
}
function beginTurnBoardMovement(g: Game) {
const ixians = byFaction(g, 'ixians');
if (
g.mobileStronghold?.location &&
@@ -11250,7 +11383,6 @@ function beginPhase(g: Game) {
return;
}
beginStormTurn(g);
}
}
/** Transfer actual charity only after its applicable power response. */
function payCharity(g: Game, player: Player, amount: number, homeworld = 0) {
@@ -11649,10 +11781,19 @@ function commitCollection(
quote: ReturnType<typeof quoteSpiceCollection>,
canceled = false,
) {
let discovery: ReturnType<typeof quoteDiscoveryCollection>;
try {
// Orgiz remains disabled pending a source ruling on stacked spice blows.
discovery = quoteDiscoveryCollection(g, quote);
} catch (error) {
if (error instanceof DiscoveryCollectionError)
throw new RuleError(error.message);
throw error;
}
const sourceSpice = { ...g.spice };
applyAdvisorReleases(g, quote.released);
g.spice = quote.spice;
for (const receipt of quote.receipts) {
for (const receipt of discovery.receipts) {
const p = getPlayer(g, receipt.player);
p.spice = receipt.balance;
if (receipt.strongholds)
@@ -11666,6 +11807,21 @@ function commitCollection(
`${faction(p.faction).name} collected ${receipt.collected} spice.`,
);
}
for (const effect of discovery.effects) {
const p = getPlayer(g, effect.player);
if (effect.kind === 'cistern')
log(
g,
`${faction(p.faction).name} received ${effect.amount} spice from the bank for occupying Cistern.`,
{ faction: p.faction, name: 'Cistern' },
);
else
log(
g,
`${faction(p.faction).name} took ${effect.amount} spice from ${faction(getPlayer(g, effect.from).faction).name} through Orgiz Processing Station.`,
{ faction: p.faction, name: 'Orgiz Processing Station' },
);
}
const allocation = collectionAllocation(() =>
createSpiceAllocation(quote.shared),
);
@@ -11701,7 +11857,7 @@ function commitCollection(
`Ecaz and its ally collected ${quote.shared.reduce((sum, lot) => sum + lot.amount, 0)} shared desert spice. Allocate each territory by agreement, or split it equally with any odd spice going to the ally.`,
);
}
for (const receipt of quote.receipts)
for (const receipt of discovery.receipts)
creditGiediCollection(g, receipt.player, receipt.desert);
stageGrummanCollection(g);
}
@@ -17899,6 +18055,7 @@ type MovementOrder = {
noField?: { tokenId: string; event: string; from: string };
ornithopterEvent?: string;
ornithopterRange?: boolean;
discoveryFlight?: DiscoveryFlightReceipt;
};
export type Action = { type: string; [key: string]: unknown };
function normalizeCardNames(g: Game) {
@@ -19432,10 +19589,13 @@ function applyActionInner(
advisorFollowup: decision.followup,
advisorResume: decision.ambassadorEvent
? ('ambassador' as const)
: decision.discoveryEntry
? ('discoveryEntry' as const)
: decision.wormRide
? ('wormRide' as const)
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
discoveryEntry: decision.discoveryEntry,
};
if (action.accept)
g.response = {
@@ -19571,6 +19731,8 @@ function applyActionInner(
} else wormSurvival(g, decision.territory);
} else if (decision.kind === 'discoveryDiscard') {
decideDiscoveryDiscard(g,p,action);
} else if (decision.kind === 'discoveryEntry') {
decideDiscoveryEntry(g,p,action);
} else if (decision.kind === 'greatMakerVote') {
decideGreatMakerVote(g,p,action);
} else if (decision.kind === 'greatMakerRide') {
@@ -20928,6 +21090,8 @@ function applyActionInner(
return g;
}
if (t === 'move') {
requireRule(action.discoveryFlight === undefined,
'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.');
if (action.noField !== undefined) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
@@ -21024,16 +21188,6 @@ function applyActionInner(
!flight || !isAdvisor(p, origin),
'Advanced advisor use of the Ornithopter card awaits its ruling.',
);
const speed = flight?.mode === 'range3' ? 3 : movementRange(g, p, elite);
requireRule(
sourceKeys.every(
(key) =>
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin)),
) <= speed,
),
`That destination is blocked or more than ${speed} territories away.`,
);
const move: MovementOrder = {
player: id,
group,
@@ -21054,6 +21208,18 @@ function applyActionInner(
}
: {}),
};
if (action.discoveryOrnithopter !== undefined)
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
const speed = movementOrderRange(g, p, move);
requireRule(
sourceKeys.every(
(key) =>
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin)),
) <= speed,
),
`That destination is blocked or more than ${speed} territories away.`,
);
validateFlightSelection(g, p, move);
if (
!move.ornithopterRange &&
@@ -21517,6 +21683,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -21811,6 +21978,8 @@ export function viewGame(state: Game, id: string) {
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
@@ -22290,6 +22459,12 @@ export function viewGame(state: Game, id: string) {
distrans: distransView(g, me),
nullentropy: nullentropyView(g, me),
ornithopter: ornithopterView(g, me),
discoveryOrnithopter: discoveryFlightOffer(g, id, {
movesAllowed: movesAllowed(g, me),
blocked: g.response || g.decision || g.pendingKarama || g.phaseOpening ||
g.pendingTreacheryDiscard || g.pendingNullentropy
? 'Finish the current response or decision before declaring movement.' : null,
}),
residualPoison: residualPoisonView(g, me),
portableSnooper: portableSnooperView(g, me),
saphoOptions: saphoOptions(g, me),
+18
View File
@@ -1,4 +1,5 @@
import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import {
validateGuildAmbassadorArrivalContext,
@@ -50,6 +51,7 @@ export type MovementCancellationQuote =
| 'advisor'
| 'advisorBattle'
| 'movementTurn'
| 'discoveryEntry'
| 'ambassador';
};
@@ -405,9 +407,25 @@ export function quoteMovementCancellation(
(response.advisorResume === undefined ||
response.advisorResume === 'declaration' ||
response.advisorResume === 'wormRide' ||
response.advisorResume === 'discoveryEntry' ||
response.advisorResume === 'ambassador'),
'This advisor cancellation has no valid owner, territory or continuation.',
);
if (response.advisorResume === 'discoveryEntry') {
requireContext(g.phase === 0 && g.discoveryEnabled && g.discoveries && g.discoveryEntry &&
response.discoveryEntry && response.advisors === true &&
response.location === g.discoveryEntry.arrival?.destination &&
response.advisorAmbassadorEvent === undefined && response.advisorFollowup === undefined &&
response.advisorRemaining === undefined,
'The advisor cancellation has lost its Discovery entry continuation.');
try {
validateDiscoveryEntryRound({...g,discoveries:g.discoveries},g.discoveryEntry);
validateDiscoveryEntryArrivalChild(g.discoveryEntry,response.discoveryEntry);
} catch (error) {
throw new MovementCancellationError(error instanceof Error ? error.message : 'Invalid Discovery arrival.');
}
return {kind:'advisorFlip',successor:'discoveryEntry'};
}
if (response.advisorResume === 'ambassador') {
if (g.pendingAmbassador?.effect === 'guild') {
try {
+15 -14
View File
@@ -3413,7 +3413,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards and revealed locations. Remaining token abilities and normal public starts stay unavailable.',
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income and later-turn Ornithopter movement. Remaining location abilities and normal public starts stay unavailable.',
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [
'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.',
@@ -3421,37 +3421,38 @@ export const RULE_TOPICS: RuleTopic[] = [
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.',
'Spice Stash grants seven bank spice and leaves play. Treachery Card Stash draws one private Treachery Card and leaves play; if the new hand is too large, choose any held card to discard, including the newly drawn card. The owned discard panel includes a card inspector.',
'Revealing Ornithopter records who carries it and the turn it was gained. Its later-turn movement action remains in development; acquiring the token does not grant an immediate extra move.',
'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.',
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
'Next-turn free entry and all five special location benefits remain missing: Jacurutu battle income, Cistern income, Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz collection transfer. Full token explanations describe these printed effects, but their action controls are not yet available.',
'All four AI profiles use the same offered inspection, reveal, stash-discard and Great Maker choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.',
'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.',
'A sole occupant of Cistern receives two bank spice during Collection. The printed text does not settle which of two occupants receives this benefit, so the contested bonus is withheld without blocking the phase. Jacurutu battle income, Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz collection transfer remain unavailable; Orgiz also awaits a ruling on what counts as each collected spice blow.',
'All four AI profiles use the same offered inspection, reveal, stash-discard, Great Maker, free-entry and Ornithopter choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.',
],
related: ['ecaz-modules', 'spice-blow', 'collection', 'movement', 'mentat', 'implementation-checklist'],
checklist: [
{
area: 'Implementation', status: 'Partial',
detail: 'First functions are prototyped: genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, carried custody and public nested locations. Free entry, special location benefits and Ornithopter spending remain missing.',
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income and later-turn Ornithopter spending. Four location abilities remain missing.',
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.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 with card inspection, and ordered Great Maker vote/typed reserve-ride controls. Remaining effects have no action controls.',
evidence: ['components/discoveries.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx'],
detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Four location effects have no action controls.',
evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'],
},
{
area: 'AI', status: 'Partial',
detail: 'Four profiles share the private quoted token, discard, vote and reserve-ride actions. Complete Discovery strategy, combined games and strength calibration remain unfinished.',
evidence: ['game/discovery-options.ts', 'game/great-maker-options.ts', 'game/bots.ts'],
detail: 'Four profiles share the private quoted token, discard, vote, reserve-ride, free-entry and carried-Ornithopter actions. Complete Discovery strategy, combined games and strength calibration remain unfinished.',
evidence: ['game/discovery-options.ts', 'game/discovery-entry-options.ts', 'game/discovery-flight-options.ts', 'game/great-maker-options.ts', 'game/bots.ts'],
},
{
area: 'Documentation', status: 'Partial',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling and exhausted token supply remain identified interpretations for refinement.',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order and source-storm handling remain identified interpretations for refinement; contested Cistern and Orgiz collection cases remain unresolved.',
evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'],
},
{
area: 'Verification', status: 'Partial',
detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission and saved JSON continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.',
evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts'],
detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission, signed free entry, Cistern collection, carried flight and saved continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.',
evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts'],
},
],
},
@@ -3563,7 +3564,7 @@ export const RULE_TOPICS: RuleTopic[] = [
...(id === 'ecaz-modules'
? [
'Ecaz inventory, public Ambassador inspection, end-of-Revival placement, six entry effects with Bene Gesserit copies and direct Ecaz Duke acquisition have focused support. Storm/explosion token returns are supported. Other effects and competing arrival ordering remain unfinished. See the linked feature checklist; full expansion starts remain disabled.',
'The first Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards and revealed nested locations. Free entry, special location benefits and Ornithopter spending remain missing; see the Discovery checklist.',
'The Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards, revealed nested locations, next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement. Four location abilities and contested collection interpretations remain unfinished; see the Discovery checklist.',
]
: []),
'The expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.',
+1
View File
@@ -61,6 +61,7 @@ const DECISIONS = {
wormPlacement: true,
wormProtection: true,
discoveryDiscard: true,
discoveryEntry: true,
greatMakerVote: true,
greatMakerRide: true,
wormRide: true,
+418
View File
@@ -0,0 +1,418 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { baseDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import {
createDiscoveryState,
validateDiscoveryState,
type DiscoveryLocationId,
} from '../game/discoveries';
import {
CISTERN,
ORGIZ_PROCESSING_STATION,
quoteDiscoveryCollection,
} from '../game/discovery-collection';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
type Seat = [id: string, faction: FactionId];
function fixture(
advanced = false,
seats: Seat[] = [
['a', 'atreides'],
['h', 'harkonnen'],
['g', 'guild'],
],
): Game {
const [first, ...rest] = seats;
const game = createGame(
'DISCOVERYCOLLECTION',
newPlayer(first[0], first[0], first[1]),
advanced,
);
game.players.push(...rest.map(([id, faction]) => newPlayer(id, id, faction)));
Object.assign(game, {
status: 'playing',
phase: 7,
turn: 2,
storm: 18,
order: seats.map(([id]) => id),
spice: {},
discoveries: createDiscoveryState(() => 0),
});
for (const player of game.players) {
player.forces = {};
player.spice = 0;
}
return game;
}
function reveal(game: Game, id: DiscoveryLocationId) {
const token = game.discoveries!.tokens.find((entry) => entry.face === id)!;
const smuggler = token.type === 'smuggler';
Object.assign(token, {
status: 'placed',
territory: smuggler ? 'pasty_mesa' : 'meridian',
sector: smuggler ? 7 : 1,
revealedTurn: 2,
});
validateDiscoveryState(game.discoveries!);
}
function composed(game: Game, orgizObservableDeposits = false) {
const ordinary = quoteSpiceCollection(game);
return {
ordinary,
discovery: quoteDiscoveryCollection(
game,
ordinary,
orgizObservableDeposits ? { orgizObservableDeposits: true } : {},
),
};
}
function balance(
quote: ReturnType<typeof quoteDiscoveryCollection>,
player: string,
) {
return quote.receipts.find((receipt) => receipt.player === player)!.balance;
}
void test('a sole Cistern occupant receives two bank spice in Basic and Advanced without changing collection facts', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
reveal(game, CISTERN);
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[0].spice = 4;
const before = structuredClone(game);
const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [
{
kind: 'cistern',
player: 'a',
amount: 2,
source: 'bank',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
);
assert.deepEqual(
discovery.receipts.map(({ player, collected, desert, strongholds }) => ({
player,
collected,
desert,
strongholds,
})),
ordinary.receipts.map(({ player, collected, desert, strongholds }) => ({
player,
collected,
desert,
strongholds,
})),
);
assert.deepEqual(game, before);
}
});
void test('Cistern requires non-advisor occupation and has no effect while unrevealed or empty', () => {
const hidden = fixture(true);
const hiddenToken = hidden.discoveries!.tokens.find(
(entry) => entry.face === CISTERN,
)!;
Object.assign(hiddenToken, {
status: 'placed',
territory: 'meridian',
sector: 1,
});
assert.deepEqual(composed(hidden).discovery.effects, []);
const advisors = fixture(true, [
['b', 'beneGesserit'],
['h', 'harkonnen'],
]);
reveal(advisors, CISTERN);
advisors.players[0].forces = { [`${CISTERN}:0`]: 1 };
advisors.players[0].advisors = { [CISTERN]: {} };
assert.deepEqual(composed(advisors).discovery.effects, []);
});
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = {
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
'red_chasm:7': 1,
};
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[2].forces = { 'cielago_south:2': 1 };
game.spice = {
'red_chasm:7': 2,
'hagga_basin:12': 4,
'cielago_south:2': 3,
};
const before = structuredClone(game);
const { ordinary, discovery } = composed(game, true);
assert.deepEqual(discovery.effects, [
{
kind: 'orgiz',
player: 'a',
from: 'g',
location: 'cielago_south:2',
amount: 1,
source: 'player',
},
{
kind: 'orgiz',
player: 'a',
from: 'h',
location: 'hagga_basin:12',
amount: 1,
source: 'player',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
);
assert.equal(
balance(discovery, 'h'),
balance({ ...discovery, receipts: ordinary.receipts }, 'h') - 1,
);
assert.equal(
balance(discovery, 'g'),
balance({ ...discovery, receipts: ordinary.receipts }, 'g') - 1,
);
assert.equal(
discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
);
assert.deepEqual(game, before);
assert.deepEqual(quoteSpiceCollection(game), ordinary);
});
void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = {
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
'red_chasm:7': 1,
};
game.players[1].forces = { 'hagga_basin:12': 1 };
game.spice = {
'red_chasm:7': 2,
'hagga_basin:12': 2,
'cielago_south:2': 6,
};
const disabled = quoteDiscoveryCollection(game, quoteSpiceCollection(game));
assert.deepEqual(disabled.effects, []);
assert.deepEqual(disabled.receipts, quoteSpiceCollection(game).receipts);
const { ordinary, discovery } = composed(game, true);
assert.deepEqual(discovery.effects, [
{
kind: 'orgiz',
player: 'a',
from: 'h',
location: 'hagga_basin:12',
amount: 1,
source: 'player',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 1,
);
assert.equal(discovery.receipts[0].collected, ordinary.receipts[0].collected);
assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert);
});
void test('Orgiz retains unresolved Ecaz shared custody instead of creating or assigning spice', () => {
const game = fixture(false, [
['e', 'ecaz'],
['a', 'atreides'],
['g', 'guild'],
]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { 'hagga_basin:12': 1 };
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.spice = { 'hagga_basin:12': 4 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(ordinary.shared, [
{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 },
]);
const before = structuredClone({ game, ordinary });
assert.throws(
() =>
quoteDiscoveryCollection(game, ordinary, {
orgizObservableDeposits: true,
}),
/unresolved Ecaz shared lot/,
);
assert.deepEqual({ game, ordinary }, before);
});
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
const game = fixture(false, [
['e', 'ecaz'],
['a', 'atreides'],
['g', 'guild'],
]);
reveal(game, CISTERN);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[1].forces = { [`${CISTERN}:0`]: 1 };
const ordinary = quoteSpiceCollection(game);
const before = structuredClone({ game, ordinary });
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
receipts: ordinary.receipts,
effects: [],
});
assert.deepEqual({ game, ordinary }, before);
});
void test('the real collection phase credits Cistern once and preserves the receipt through JSON recovery', () => {
const game = fixture(false);
game.discoveryEnabled = true;
reveal(game, CISTERN);
Object.assign(game, {
phase: 5,
active: 'g',
movementRemaining: ['g'],
phaseOpening: null,
});
for (const player of game.players)
Object.assign(player, {
hand: [],
traitors: [],
traitorChoices: [],
shipped: true,
moved: 1,
reserves: 20,
});
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[0].forces['red_chasm:7'] = 1;
game.players[0].reserves = 18;
game.players[0].spice = 4;
game.spice = { 'red_chasm:7': 2 };
const before = structuredClone(game);
const ordinary = quoteSpiceCollection(game);
assert.equal(
ordinary.receipts.find((receipt) => receipt.player === 'a')!.balance,
6,
);
let collected = applyAction(game, 'g', { type: 'endMovement' });
assert.deepEqual(game, before);
assert.equal(collected.phase, 7);
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
assert.equal(collected.spice['red_chasm:7'], 0);
assert.equal(
collected.log.filter((entry) =>
entry.text.includes(
'received 2 spice from the bank for occupying Cistern',
),
).length,
1,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(collected), collected);
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
});
void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => {
const game = createGame(
'DISCOVERYECAZCOLLECTION',
newPlayer('ec', 'Ecaz', 'ecaz'),
true,
['ecaz'],
);
game.players.push(
newPlayer('al', 'Ally', 'atreides'),
newPlayer('en', 'Rival', 'emperor'),
);
Object.assign(game, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
order: ['ec', 'al', 'en'],
active: 'en',
movementRemaining: ['en'],
phaseOpening: null,
spice: {},
deck: baseDeck(),
discard: [],
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
});
reveal(game, CISTERN);
for (const player of game.players)
Object.assign(player, {
spice: 10,
hand: [],
traitors: [],
traitorChoices: [],
forces: {},
reserves: 20,
shipped: true,
moved: 1,
});
game.players[0].ally = 'al';
game.players[1].ally = 'ec';
game.players[0].allySinceTurn = 1;
game.players[1].allySinceTurn = 1;
game.players[0].forces = { 'arrakeen:10': 1 };
game.players[1].forces = { 'arrakeen:10': 1 };
game.players[2].forces = { [`${CISTERN}:0`]: 1 };
for (const player of game.players) player.reserves = 19;
const karama = game.deck.findIndex((card) => card.name === 'Karama');
assert.ok(karama >= 0);
game.players[2].hand.push(game.deck.splice(karama, 1)[0]);
let collected = applyAction(game, 'en', { type: 'endMovement' });
assert.equal(collected.response?.kind, 'ecazCollection');
assert.equal(collected.players[2].spice, 10);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
0,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
for (let count = 0; collected.response && count < 10; count++) {
const responder = collected.players.find(
(player) => !collected.response!.passed.includes(player.id),
)!;
collected = applyAction(collected, responder.id, { type: 'passResponse' });
}
assert.equal(collected.response, null);
assert.equal(collected.players[2].spice, 12);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(collected), collected);
assert.equal(collected.players[2].spice, 12);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
});
+2 -1
View File
@@ -104,7 +104,8 @@ void test('all eight authorized faces have readable rules while shared location
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
assert.doesNotMatch(markup, /href="https?:/);
assert.match(markup, /still in development/);
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
assert.ok(markup.includes('Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.'));
});
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
+178
View File
@@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { botActions } from '../game/bots';
import type { DiscoveryEntryOffer } from '../game/discovery-entry';
import {
discoveryEntryBotActions,
discoveryEntryDeclineAction,
discoveryEntryMoveAction,
} from '../game/discovery-entry-options';
import { DiscoveryEntryDecision } from '../components/discovery-entry';
type EntryView = GameView & { discoveryEntry: DiscoveryEntryOffer | null };
function view(): EntryView {
return {
status: 'playing',
me: 'f',
decision: {
kind: 'discoveryEntry',
player: 'f',
event: 'entry-round:0',
},
discoveryEntry: {
event: 'entry-round:0',
token: 'discovery-token-1',
owner: 'f',
parent: 'meridian',
destination: 'jacurutu-sietch',
sector: 0,
sources: [
{ source: 'meridian:2', normal: 2, elite: 1 },
{ source: 'meridian:3', normal: 1, elite: 1 },
],
blocked: null,
arrival: null,
},
players: [
{
id: 'f',
name: 'Fremen',
faction: 'fremen',
autopilot: null,
},
],
} as unknown as EntryView;
}
function html(game: EntryView, busy = false) {
return renderToStaticMarkup(
createElement(DiscoveryEntryDecision, {
game,
busy,
act() {},
}),
);
}
void test('entry actions bind the projected event and exact positive typed source subsets', () => {
const game = view(), before = structuredClone(game);
assert.deepEqual(
discoveryEntryMoveAction(game, [
{ source: 'meridian:3', normal: 1, elite: 0 },
{ source: 'meridian:2', normal: 0, elite: 1 },
]),
{
type: 'decision',
event: 'entry-round:0',
accept: true,
groups: [
{ source: 'meridian:2', normal: 0, elite: 1 },
{ source: 'meridian:3', normal: 1, elite: 0 },
],
},
);
assert.deepEqual(discoveryEntryDeclineAction(game), {
type: 'decision',
event: 'entry-round:0',
accept: false,
});
assert.equal(discoveryEntryMoveAction(game, []), null);
assert.equal(
discoveryEntryMoveAction(game, [
{ source: 'meridian:2', normal: 3, elite: 0 },
]),
null,
);
assert.equal(
discoveryEntryMoveAction(game, [
{ source: 'meridian:2', normal: 0, elite: 0 },
]),
null,
);
assert.equal(
discoveryEntryMoveAction(game, [
{ source: 'meridian:2', normal: 1, elite: 0 },
{ source: 'meridian:2', normal: 1, elite: 0 },
]),
null,
);
assert.deepEqual(game, before);
});
void test('the legal bot path moves every quoted typed force and retains decline second', () => {
const game = view(), before = structuredClone(game);
const expected = [
{
type: 'decision',
event: 'entry-round:0',
accept: true,
groups: game.discoveryEntry!.sources,
},
{
type: 'decision',
event: 'entry-round:0',
accept: false,
},
];
assert.deepEqual(discoveryEntryBotActions(game), expected);
assert.deepEqual(botActions(game), expected);
assert.deepEqual(game, before);
const blocked = structuredClone(game);
blocked.discoveryEntry!.blocked = 'The location is full.';
assert.deepEqual(discoveryEntryBotActions(blocked), []);
const observer = structuredClone(game);
observer.me = 'a';
assert.deepEqual(discoveryEntryBotActions(observer), []);
const stale = structuredClone(game);
(stale.decision as { event: string }).event = 'entry-round:3';
assert.deepEqual(discoveryEntryBotActions(stale), []);
});
void test('the chooser explains free entry and renders all-sector ordinary and elite controls', () => {
const markup = html(view());
for (const text of [
'Enter Jacurutu Sietch',
'Meridian',
'before the storm',
'does not use shipment or movement',
'sector 2',
'sector 3',
'Available: 2 ordinary · 1 Fedaykin',
'Select all forces',
'Clear selection',
'Move 5 forces inside',
'Leave forces outside',
])
assert.ok(markup.includes(text), text);
assert.equal((markup.match(/type="number"/g) ?? []).length, 4);
assert.equal((markup.match(/max="1"/g) ?? []).length, 3);
});
void test('busy and autopilot views disable every action while mismatched controls render nothing', () => {
const busy = html(view(), true);
assert.equal(
(busy.match(/<button[^>]*disabled=""/g) ?? []).length,
4,
);
const autopilot = view();
autopilot.players[0].autopilot = 'Hard';
assert.equal(
(html(autopilot).match(/<button[^>]*disabled=""/g) ?? []).length,
4,
);
const wrongEvent = view();
wrongEvent.discoveryEntry!.event = 'entry-round:1';
assert.equal(html(wrongEvent), '');
const wrongOwner = view();
wrongOwner.discoveryEntry!.owner = 'a';
assert.equal(html(wrongOwner), '');
const noOffer = view();
noOffer.discoveryEntry = null;
assert.equal(html(noOffer), '');
});
+300
View File
@@ -0,0 +1,300 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isAdvisor } from '../game/advisors';
import { MOBILE_LOCATION } from '../game/board';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import { createDiscoveryState } from '../game/discoveries';
import {
applyAction,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { enterDiscoveryCollection } from './fixture-discovery';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function initialized(
factions: [FactionId, FactionId, FactionId],
expansions: string[] = [],
) {
let game = createGame(
'ENTRYREVIEW',
newPlayer('a', 'Actor', factions[0]),
true,
expansions,
);
joinGame(game, newPlayer('b', 'Second', factions[1]));
joinGame(game, newPlayer('g', 'Third', factions[2]));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
game.discoveryEnabled = true;
game = initializeDiscoveryGameForAudit(game);
for (let step = 0; step < 80 && game.status === 'setup'; step++) {
let next: Game | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(game, player.id, action);
break;
}
}
assert.ok(
next,
`setup stalled at ${game.setupStage}/${game.decision?.kind}`,
);
game = next;
}
assert.equal(game.status, 'playing');
return game;
}
function revealCistern(game: Game, owner = 'a') {
const token = enterDiscoveryCollection(game, 'cistern', owner);
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id))
game = applyAction(game, owner, {
type: 'discovery',
token: token.id,
reveal: false,
});
game = applyAction(game, owner, {
type: 'discovery',
token: token.id,
reveal: true,
});
return { game, parent: `${token.territory}:${token.sector}` };
}
function nextTurn(game: Game) {
Object.assign(game, {
phase: 8,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
return game;
}
function entryAction(game: Game): Action {
assert.equal(game.decision?.kind, 'discoveryEntry');
const offer = viewGame(game, game.decision.player).discoveryEntry!;
return {
type: 'decision',
event: offer.event,
accept: true,
groups: offer.sources,
};
}
function pendingAdvisorEntry(
owner: 'atreides' | 'fremen' = 'atreides',
existing = 0,
) {
const revealed = revealCistern(initialized([owner, 'beneGesserit', 'guild']));
let game = revealed.game;
const parent = revealed.parent;
const actor = game.players[0];
if (owner === 'fremen') {
actor.elites!.reserves--;
actor.elites!.forces[parent] = 1;
}
if (existing) {
actor.reserves -= existing;
actor.forces['cistern:0'] = existing;
}
const beneGesserit = game.players[1];
beneGesserit.reserves--;
beneGesserit.forces['cistern:0'] = 1;
const karama = game.deck.find((card) => card.effect === 'karama');
assert.ok(karama);
game.deck = game.deck.filter((card) => card.id !== karama.id);
actor.hand.push(karama);
game = nextTurn(game);
game = applyAction(game, actor.id, entryAction(game));
assert.equal(game.discoveryEntry?.stage, 'arrival');
assert.equal(game.decision?.kind, 'intrusion');
return { game, parent };
}
void test('saved Discovery arrival binds the transferred force stack and rejects conflicting intrusion continuations', () => {
const { game } = pendingAdvisorEntry();
assert.equal(game.players[0].forces['cistern:0'], 2);
assert.doesNotThrow(() => viewGame(game, 'a'));
const retargeted = reload(game);
retargeted.players[0].forces['cistern:0']--;
retargeted.players[0].forces['arrakeen:10'] =
(retargeted.players[0].forces['arrakeen:10'] ?? 0) + 1;
assert.throws(
() => viewGame(retargeted, 'a'),
/transferred ordinary or elite force custody/,
);
const conflicting = reload(game);
assert.equal(conflicting.decision?.kind, 'intrusion');
if (conflicting.decision?.kind === 'intrusion')
conflicting.decision.followup = {
shipment: 'a',
destination: 'polar_sink:0',
};
assert.throws(() => viewGame(conflicting, 'b'), /Bene Gesserit interaction/);
const response = applyAction(reload(game), 'b', {
type: 'decision',
accept: true,
});
assert.equal(response.response?.kind, 'advisorFlip');
assert.equal(response.response?.advisorResume, 'discoveryEntry');
const misrouted = reload(response);
misrouted.response!.advisorFollowup = {
shipment: 'a',
destination: 'polar_sink:0',
};
assert.throws(() => viewGame(misrouted, 'a'), /Bene Gesserit interaction/);
});
void test('saved Fedaykin arrival binds its elite overlay until the BG response finishes', () => {
const { game, parent } = pendingAdvisorEntry('fremen');
const fremen = game.players[0];
assert.equal(fremen.forces['cistern:0'], 2);
assert.equal(fremen.elites!.forces['cistern:0'], 1);
const corrupted = reload(game);
delete corrupted.players[0].elites!.forces['cistern:0'];
corrupted.players[0].elites!.forces[parent] = 1;
assert.throws(
() => viewGame(corrupted, 'a'),
/transferred ordinary or elite force custody/,
);
let resumed = applyAction(reload(game), 'b', {
type: 'decision',
accept: true,
});
while (resumed.response) {
const responder = resumed.players.find(
(player) => !resumed.response!.passed.includes(player.id),
)!;
resumed = applyAction(reload(resumed), responder.id, {
type: 'passResponse',
});
}
assert.equal(resumed.discoveryEntry, undefined);
assert.equal(isAdvisor(resumed.players[1], 'cistern'), true);
assert.equal(resumed.players[0].elites!.forces['cistern:0'], 1);
assert.deepEqual(normalizeAutomaticGame(reload(resumed)), resumed);
});
void test('signed pre-arrival custody permits an existing owner stack beside BG and binds only the added forces', () => {
const { game } = pendingAdvisorEntry('atreides', 1);
assert.equal(game.discoveryEntry?.arrival?.destinationBefore, 1);
assert.equal(game.discoveryEntry?.arrival?.destinationEliteBefore, 0);
assert.equal(game.players[0].forces['cistern:0'], 3);
assert.doesNotThrow(() => viewGame(reload(game), 'a'));
let resumed = applyAction(reload(game), 'b', {
type: 'decision',
accept: false,
});
assert.equal(resumed.discoveryEntry, undefined);
assert.equal(resumed.players[0].forces['cistern:0'], 3);
resumed = normalizeAutomaticGame(reload(resumed));
assert.equal(resumed.players[0].forces['cistern:0'], 3);
});
void test('automatic no-choice skips retain the Hidden Mobile Stronghold opening before normal storm continuation', () => {
let game = createGame(
'ENTRYHMSREVIEW',
newPlayer('a', 'Ixians', 'ixians'),
true,
['ix'],
);
game.players.push(
newPlayer('b', 'Guild', 'guild'),
newPlayer('g', 'Fremen', 'fremen'),
);
Object.assign(game, {
status: 'playing',
turn: 1,
phase: 7,
storm: 18,
order: ['a', 'b', 'g'],
ready: [],
decision: null,
response: null,
phaseOpening: null,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
});
for (const player of game.players) {
player.forces = {};
player.reserves = 20;
}
({ game } = revealCistern(game));
game.mobileStronghold = { location: 'polar_sink:0' };
const ixians = game.players[0];
ixians.reserves--;
ixians.forces[MOBILE_LOCATION] = (ixians.forces[MOBILE_LOCATION] ?? 0) + 1;
game = nextTurn(game);
assert.ok(game.phaseOpening);
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
assert.equal(
game.decision?.kind,
'discoveryEntry',
JSON.stringify({
turn: game.turn,
phase: game.phase,
entry: game.discoveryEntry,
newlyRevealed: game.discoveries?.newlyRevealed,
forces: game.players[0].forces,
storm: game.storm,
stormPending: game.stormPending,
mobileStronghold: game.mobileStronghold,
}),
);
game = applyAction(game, 'a', {
type: 'decision',
event: game.decision!.event,
accept: false,
});
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.decision?.kind, 'mobileStronghold');
assert.equal(game.phase, 0);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
game = applyAction(game, 'a', { type: 'decision', decline: true });
assert.equal(game.phase, 0);
assert.equal(game.decision, null);
assert.ok(game.log.some((entry) => entry.text === 'Turn 2 begins.'));
});
void test('one-force entry uses singular chronicle wording and resumes the ordinary storm opening', () => {
let { game } = revealCistern(initialized(['atreides', 'guild', 'fremen']));
game = nextTurn(game);
const offer = viewGame(game, 'a').discoveryEntry!;
game = applyAction(game, 'a', {
type: 'decision',
event: offer.event,
accept: true,
groups: [{ ...offer.sources[0], normal: 1, elite: 0 }],
});
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.players[0].forces['cistern:0'], 1);
assert.ok(
game.log.some((entry) => entry.text.includes('moved 1 force from')),
);
assert.ok(game.log.every((entry) => !entry.text.includes('moved 1 forces')));
assert.ok(game.log.some((entry) => entry.text === 'Turn 2 begins.'));
});
+274
View File
@@ -0,0 +1,274 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
discoveryFixture,
enterDiscoveryCollection,
} from './fixture-discovery';
import { isAdvisor } from '../game/advisors';
import { unitStore } from './fixture-nexus-room-store';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function reveal(
g: Game,
face: 'cistern' | 'shrine' = 'cistern',
owner = g.players[0].id,
) {
const token = enterDiscoveryCollection(g, face, owner);
if (viewGame(g, owner).discoveries!.canInspect.includes(token.id))
g = applyAction(g, owner, {
type: 'discovery',
token: token.id,
reveal: false,
});
return applyAction(g, owner, {
type: 'discovery',
token: token.id,
reveal: true,
});
}
function nextTurn(g: Game) {
Object.assign(g, {
phase: 8,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function accept(g: Game) {
assert.equal(g.decision?.kind, 'discoveryEntry');
const offer = viewGame(g, g.decision!.player).discoveryEntry!;
return {
type: 'decision',
accept: true,
event: offer.event,
groups: offer.sources,
};
}
function custody(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
p.faction === 'fremen' ? 3 : 5,
);
}
}
function reject(g: Game, id: string, action: Action) {
const before = reload(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
void test('next-turn free entry is a real owned decision before storm; partial movement survives reload and consumes no ordinary move or spice', () => {
let g = nextTurn(reveal(discoveryFixture()));
assert.equal(g.turn, 2);
assert.equal(g.phase, 0);
assert.equal(g.decision?.kind, 'discoveryEntry');
assert.equal(g.decision?.player, 'a');
const before = reload(g),
offer = viewGame(g, 'a').discoveryEntry!;
assert.equal(viewGame(g, 'g').discoveryEntry, null);
const selection = {
...accept(g),
groups: offer.sources.map((s) => ({ ...s, normal: 1, elite: 0 })),
};
offer.sources[0].normal = 20;
assert.deepEqual(g, before);
g = applyAction(reload(g), 'a', selection);
assert.equal(g.players[0].forces['cistern:0'], 1);
assert.equal(g.players[0].forces['gara_kulon:8'], 1);
assert.equal(g.players[0].moved, before.players[0].moved);
assert.equal(g.players[0].spice, before.players[0].spice);
assert.equal(g.discoveryEntry, undefined);
assert.deepEqual(g.discoveries!.newlyRevealed, []);
assert.equal(g.phase, 0);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, 'a', selection);
custody(g);
});
void test('decline, wrong owner, storm source and invalid typed groups cannot spend or retarget free entry', () => {
const g = nextTurn(reveal(discoveryFixture()));
const action = accept(g);
reject(g, 'g', action);
reject(g, 'a', { ...action, event: 'old-entry' });
reject(g, 'a', {
...action,
groups: [{ source: 'arrakeen:9', normal: 1, elite: 0 }],
});
reject(g, 'a', {
...action,
groups: [{ source: 'gara_kulon:8', normal: 0, elite: 1 }],
});
reject(g, 'a', { type: 'storm', dial: 1 });
const done = applyAction(reload(g), 'a', {
type: 'decision',
accept: false,
event: action.event,
});
assert.equal(done.players[0].forces['cistern:0'], undefined);
assert.equal(done.discoveryEntry, undefined);
const storm = reveal(discoveryFixture());
storm.storm = 8;
const skipped = nextTurn(storm);
assert.equal(skipped.discoveryEntry, undefined);
custody(skipped);
});
void test('typed Fedaykin free entry preserves physical forces and all four AI policies select a legal decision', () => {
let g = reveal(discoveryFixture(true), 'cistern', 'f');
const f = g.players.find((p) => p.id === 'f')!;
f.elites!.reserves--;
f.elites!.forces['gara_kulon:8'] = 1;
g = nextTurn(g);
assert.equal(g.decision?.player, 'f');
const action = accept(g);
assert.equal(action.groups[0].elite, 1);
for (const difficulty of DIFFICULTIES) {
const v = viewGame(g, 'f');
v.players.find((p) => p.id === 'f')!.bot = difficulty;
const choices = botActions(v);
assert.ok(choices.length);
for (const choice of choices)
assert.doesNotThrow(() => applyAction(g, 'f', choice));
}
g = applyAction(reload(g), 'f', action);
assert.equal(
g.players.find((p) => p.id === 'f')!.elites!.forces['cistern:0'],
1,
);
custody(g);
});
function advisorFixture() {
let g = createGame('ENTRYBG01', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('g', 'Guild', 'guild'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g.discoveryEnabled = true;
g = initializeDiscoveryGameForAudit(g);
for (let step = 0; step < 80 && g.status === 'setup'; step++) {
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next);
g = next;
}
g = reveal(g);
const bg = g.players.find((p) => p.id === 'b')!;
bg.reserves--;
bg.forces['cistern:0'] = 1;
// Preserve one physical Karama so the real advisor response remains actionable.
const card = g.deck.find((c) => c.effect === 'karama')!;
g.deck = g.deck.filter((c) => c.id !== card.id);
g.players[0].hand.push(card);
return nextTurn(g);
}
void test('free entry persists through an owned Bene Gesserit intrusion and a canceled advisor flip before storm resumes', () => {
let g = advisorFixture();
g = applyAction(g, 'a', accept(g));
assert.equal(g.discoveryEntry?.stage, 'arrival');
assert.equal(g.decision?.kind, 'intrusion');
const corrupt = reload(g);
assert.equal(corrupt.decision?.kind, 'intrusion');
if (corrupt.decision?.kind === 'intrusion')
corrupt.decision.territory = 'arrakeen';
assert.throws(() => viewGame(corrupt, 'b'));
g = applyAction(reload(g), 'b', { type: 'decision', accept: true });
assert.equal(g.response?.advisorResume, 'discoveryEntry');
const orphan = reload(g);
delete orphan.discoveryEntry;
assert.throws(() => viewGame(orphan, 'a'));
const card = g.players[0].hand.find((c) => c.effect === 'karama')!;
g = applyAction(reload(g), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.equal(g.discoveryEntry, undefined);
assert.equal(g.response, null);
assert.equal(
isAdvisor(
g.players.find((p) => p.id === 'b')!,
'cistern',
),
false,
);
assert.equal(g.players[0].forces['cistern:0'], 2);
custody(g);
});
void test('SQLite saved entry keeps seats and exactly one competing force transfer wins after restart', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom(
'Entry SQL',
'atreides',
false,
[],
),
code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((a) => a.playerId) as [string, string, string];
const g = nextTurn(reveal(discoveryFixture(false, ids)));
g.code = code;
store.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code);
const action = accept(g),
clock = { now: () => 10000, sleep: async () => {} };
const results = await Promise.allSettled(
[0, 1].map(() =>
store.restart().act(code, auths[0], g.version, action, clock),
),
);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
const done = await store.restart().readRoom(code);
assert.equal(done.players[0].forces['cistern:0'], 2);
assert.equal(done.version, g.version + 1);
for (const [i, token] of tokens.entries()) {
const rooms = store.restart(),
auth = await rooms.authenticate(code, token);
assert.deepEqual(
await rooms.readSeatView(code, auth),
viewGame(done, ids[i]),
);
}
await assert.rejects(
store.restart().act(code, auths[0], done.version, action, clock),
);
custody(done);
} finally {
store.sqlite.close();
}
});
+256
View File
@@ -0,0 +1,256 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
advanceDiscoveryEntry,
beginDiscoveryEntryArrival,
createDiscoveryEntryRound,
discoveryEntryArrivalSignature,
discoveryEntrySignature,
finishDiscoveryEntryArrival,
quoteDiscoveryEntry,
validateDiscoveryEntryArrivalChild,
validateDiscoveryEntryRound,
type DiscoveryEntryContext,
} from '../game/discovery-entry';
import {
createDiscoveryState,
type DiscoveryLocationId,
type DiscoveryOpaqueTokenId,
} from '../game/discoveries';
function context(
locations: readonly {
face: DiscoveryLocationId;
territory: string;
sector: number;
}[] = [
{ face: 'jacurutu-sietch', territory: 'meridian', sector: 1 },
],
): DiscoveryEntryContext {
const discoveries = createDiscoveryState(() => 0);
const ids: DiscoveryOpaqueTokenId[] = [];
for (const location of locations) {
const token = discoveries.tokens.find(
(candidate) => candidate.face === location.face,
)!;
Object.assign(token, {
status: 'placed',
territory: location.territory,
sector: location.sector,
revealedTurn: 2,
known: [],
owner: null,
acquiredTurn: null,
});
ids.push(token.id);
}
discoveries.newlyRevealed = ids;
return {
turn: 3,
storm: 1,
order: ['a', 'g', 'f'],
discoveries,
players: [
{
id: 'a',
faction: 'atreides',
ally: null,
forces: { 'meridian:1': 2, 'meridian:2': 3 },
elites: { forces: { 'meridian:2': 1 } },
},
{
id: 'g',
faction: 'guild',
ally: null,
forces: {},
},
{
id: 'f',
faction: 'fremen',
ally: null,
forces: {},
},
],
};
}
void test('a round freezes last-turn revealed locations in token order and players in storm order', () => {
const g = context([
{ face: 'jacurutu-sietch', territory: 'meridian', sector: 1 },
{ face: 'cistern', territory: 'gara_kulon', sector: 8 },
]);
const before = structuredClone(g);
const frame = createDiscoveryEntryRound(g, 'entry-round');
assert.deepEqual(frame.tokens, g.discoveries.newlyRevealed);
assert.deepEqual(frame.order, g.order);
assert.equal(frame.cursor, 0);
assert.equal(frame.stage, 'choose');
assert.equal(frame.signature, discoveryEntrySignature(frame));
assert.deepEqual(g, before);
const none = context();
none.discoveries.newlyRevealed = [];
const complete = createDiscoveryEntryRound(none, 'no-entry');
assert.equal(complete.stage, 'complete');
assert.equal(complete.arrival, null);
});
void test('a quote offers typed non-advisor source groups outside the storm and accepts any positive subset', () => {
const g = context();
const frame = createDiscoveryEntryRound(g, 'typed-entry');
const before = structuredClone(g);
const offer = quoteDiscoveryEntry(g, frame, 'a');
assert.equal(offer.blocked, null);
assert.deepEqual(offer.sources, [
{ source: 'meridian:2', normal: 2, elite: 1 },
]);
const chosen = quoteDiscoveryEntry(g, frame, 'a', {
groups: [{ source: 'meridian:2', normal: 1, elite: 1 }],
});
assert.deepEqual(chosen.arrival?.groups, [
{ source: 'meridian:2', normal: 1, elite: 1 },
]);
assert.equal(chosen.arrival?.amount, 2);
assert.equal(chosen.arrival?.elite, 1);
assert.deepEqual(g, before);
assert.throws(
() =>
quoteDiscoveryEntry(g, frame, 'a', {
groups: [{ source: 'meridian:1', normal: 1, elite: 0 }],
}),
/typed source custody/i,
);
const advisor = context();
Object.assign(advisor.players[0], {
faction: 'beneGesserit',
advisors: { meridian: {} },
});
const advisorOffer = quoteDiscoveryEntry(
advisor,
createDiscoveryEntryRound(advisor, 'advisor-entry'),
'a',
);
assert.match(advisorOffer.blocked ?? '', /non-advisor forces/i);
assert.deepEqual(advisorOffer.sources, []);
});
void test('full occupancy is a skippable offer and declining walks the token-player cursor to completion', () => {
const g = context();
g.players[1].forces = { 'jacurutu-sietch:0': 1 };
g.players[2].forces = { 'jacurutu-sietch:0': 1 };
let frame = createDiscoveryEntryRound(g, 'occupied-entry');
const offer = quoteDiscoveryEntry(g, frame, 'a');
assert.match(offer.blocked ?? '', /three occupying factions/i);
assert.throws(
() =>
quoteDiscoveryEntry(g, frame, 'a', {
groups: [{ source: 'meridian:2', normal: 1, elite: 0 }],
}),
/three occupying factions/i,
);
const originalFrame = frame;
const original = structuredClone(frame);
frame = advanceDiscoveryEntry(g, frame, 'a');
assert.deepEqual(originalFrame, original);
assert.equal(frame.cursor, 1);
frame = advanceDiscoveryEntry(g, frame, 'g');
assert.equal(frame.cursor, 2);
frame = advanceDiscoveryEntry(g, frame, 'f');
assert.equal(frame.stage, 'complete');
assert.equal(frame.cursor, 3);
assert.equal(frame.arrival, null);
assert.throws(() => advanceDiscoveryEntry(g, frame, 'a'), /current player/i);
});
void test('an accepted entry remains valid after transfer and binds its exact arrival child', () => {
const g = context();
const choose = createDiscoveryEntryRound(g, 'signed-entry');
const before = structuredClone(choose);
const arrival = beginDiscoveryEntryArrival(g, choose, 'a', {
groups: [{ source: 'meridian:2', normal: 1, elite: 1 }],
});
assert.deepEqual(choose, before);
assert.equal(arrival.stage, 'arrival');
assert.equal(arrival.arrival?.destination, 'jacurutu-sietch');
const moved = structuredClone(g);
moved.players[0].forces['meridian:2'] = 1;
moved.players[0].elites!.forces['meridian:2'] = 0;
moved.players[0].forces['jacurutu-sietch:0'] = 2;
moved.players[0].elites!.forces['jacurutu-sietch:0'] = 1;
assert.doesNotThrow(() => validateDiscoveryEntryRound(moved, arrival));
const child = {
owner: 'a',
territory: 'jacurutu-sietch',
sector: 0,
amount: 2,
elite: 1,
discoveryEntry: arrival.arrival!.signature,
};
assert.doesNotThrow(() =>
validateDiscoveryEntryArrivalChild(arrival, child),
);
assert.throws(
() =>
validateDiscoveryEntryArrivalChild(arrival, {
...child,
territory: 'cistern',
}),
/signed arrival/i,
);
const finished = finishDiscoveryEntryArrival(moved, arrival);
assert.equal(finished.cursor, 1);
assert.equal(finished.stage, 'choose');
assert.equal(finished.arrival, null);
});
void test('resigned turn, token, owner, and arrival mutations are rejected', () => {
const g = context();
const frame = createDiscoveryEntryRound(g, 'restore-entry');
const stale = structuredClone(frame);
stale.turn = 2;
stale.signature = discoveryEntrySignature(stale);
assert.throws(
() => validateDiscoveryEntryRound(g, stale),
/original token and player order/i,
);
const missing = structuredClone(frame);
missing.tokens = [];
missing.cursor = 0;
missing.stage = 'complete';
missing.signature = discoveryEntrySignature(missing);
assert.throws(
() => validateDiscoveryEntryRound(g, missing),
/original token and player order/i,
);
const arrival = beginDiscoveryEntryArrival(g, frame, 'a', {
groups: [{ source: 'meridian:2', normal: 1, elite: 0 }],
});
const wrongOwner = structuredClone(arrival);
wrongOwner.arrival!.owner = 'g';
wrongOwner.arrival!.signature = JSON.stringify('forged');
wrongOwner.signature = discoveryEntrySignature(wrongOwner);
assert.throws(
() => validateDiscoveryEntryRound(g, wrongOwner),
/exact signed arrival/i,
);
const wrongSource = structuredClone(arrival);
wrongSource.arrival!.groups[0].source = 'arrakeen:9';
wrongSource.arrival!.signature = discoveryEntryArrivalSignature(
wrongSource.arrival!,
);
wrongSource.signature = discoveryEntrySignature(wrongSource);
assert.throws(
() => validateDiscoveryEntryRound(g, wrongSource),
/revealed destination/i,
);
});
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { gameDistance, gameTerritories, splitLocation } from '../game/board';
import { discoveryFlightMove, discoveryFlightBotActions } from '../game/discovery-flight-options';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
import { discoveryFlightFixture, flightDestination, flightOrigin } from './fixture-discovery-flight';
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const held = (game: Game) => game.discoveries!.tokens.find(token => token.face === 'ornithopter')!;
const declaration = (game: Game, extra: Partial<Action> = {}): Action => ({
type: 'move', from: flightOrigin, amount: 2, ...splitLocation(flightDestination(game)),
discoveryOrnithopter: held(game).id, ...extra,
});
function rejectsUnchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function choamFixture() {
const fixture = discoveryFlightFixture(true, 'f');
const choam = newPlayer('c', 'CHOAM', 'choam');
fixture.game.players.push(choam);
fixture.game.order.push(choam.id);
const destination = flightDestination(fixture.game);
choam.forces = { [destination]: 1 };
choam.reserves--;
const baliset = fixture.game.deck.findIndex(card => card.name === 'Baliset');
assert.ok(baliset >= 0);
choam.hand.push(fixture.game.deck.splice(baliset, 1)[0]);
const pilot = fixture.game.players.find(player => player.id === 'f')!;
pilot.elites!.reserves -= 2;
pilot.elites!.forces[flightOrigin] = 2;
return { ...fixture, destination };
}
void test('real Collection acquisition grants one later-turn three-territory movement in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const { game, owner, acquiredTurn } = discoveryFlightFixture(advanced);
const action = declaration(game), destination = `${String(action.territory)}:${Number(action.sector)}`;
const before = structuredClone(game), ordinary = { ...action };
delete ordinary.discoveryOrnithopter;
rejectsUnchanged(game, owner, ordinary);
const view = viewGame(game, owner);
assert.equal(view.discoveryOrnithopter!.blocked, null);
assert.equal(viewGame(game, 'g').discoveryOrnithopter, null);
assert.ok(discoveryFlightMove(view, ordinary).action);
const done = applyAction(game, owner, action);
assert.deepEqual(game, before);
assert.equal(done.players[0].forces[destination], 2);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].reserves, game.players[0].reserves);
assert.equal(done.players[0].spice, game.players[0].spice);
assert.equal(held(done).status, 'removed');
assert.equal(held(done).owner, null);
assert.equal(held(done).acquiredTurn, acquiredTurn);
assert.equal(viewGame(done, owner).discoveryOrnithopter, null);
assert.deepEqual(normalizeAutomaticGame(reload(done)), reload(done));
}
});
void test('same-turn use, nonowner, false token, invalid group, storm, overrange and receipt injection reject immutably', () => {
const { game, owner } = discoveryFlightFixture();
const sameTurn = reload(game); sameTurn.turn = held(game).acquiredTurn!;
assert.match(viewGame(sameTurn, owner).discoveryOrnithopter!.blocked!, /after/);
rejectsUnchanged(sameTurn, owner, declaration(sameTurn));
for (const extra of [
{ discoveryOrnithopter: 'discovery-token-99' },
{ discoveryOrnithopter: game.discoveries!.tokens.find(token => token.face === 'cistern')!.id },
{ amount: 6 }, { amount: 0 }, { elite: 1 },
{ ...splitLocation(flightDestination(game, 4)) },
{ discoveryFlight: { token: held(game).id } },
]) rejectsUnchanged(game, owner, declaration(game, extra));
const storm = reload(game), action = declaration(storm);
storm.storm = Number(action.sector);
rejectsUnchanged(storm, owner, action);
const foreign = reload(game); foreign.active = 'g';
foreign.players[1].forces[flightOrigin] = 2; foreign.players[1].reserves -= 2;
rejectsUnchanged(foreign, 'g', declaration(foreign));
const used = applyAction(game, owner, declaration(game));
used.hajr = [owner];
rejectsUnchanged(used, owner, declaration(used));
});
void test('one selected multi-sector typed group moves at fixed range without a native Fremen speed response', () => {
const { game, owner } = discoveryFlightFixture(true, 'f');
const pilot = game.players.find(player => player.id === owner)!;
const source = gameTerritories(game).find(t => t.sectors.length >= 2 && !t.sectors.includes(game.storm))!;
const [first, second] = source.sectors.map(sector => `${source.id}:${sector}`);
pilot.forces = { [first]: 3, [second]: 2 };
pilot.elites!.reserves -= 2; pilot.elites!.forces = { [first]: 1, [second]: 1 };
const to = gameTerritories(game).flatMap(t => t.sectors.map(sector => `${t.id}:${sector}`))
.find(key => [first, second].every(from => gameDistance(game, from, key,
key => splitLocation(key).sector === game.storm) === 3))!;
assert.ok(to);
const move: Action = { type: 'move', forces: { [first]: 2, [second]: 1 },
eliteForces: { [first]: 1, [second]: 1 }, ...splitLocation(to) };
const quote = discoveryFlightMove(viewGame(game, owner), move);
assert.equal(quote.blocked, null);
const done = applyAction(game, owner, quote.action!);
assert.equal(done.response, null);
const next = done.players.find(player => player.id === owner)!;
assert.equal(next.forces[to], 3); assert.equal(next.elites!.forces[to], 2);
assert.equal(next.moved, 1); assert.equal(held(done).status, 'removed');
});
void test('pending CHOAM decision preserves the exact owned token and typed group across JSON; allowing resumes once', () => {
const { game, owner, destination } = choamFixture();
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
assert.equal(pending.decision?.kind, 'choamMovement');
assert.equal(held(pending).status, 'carried');
assert.equal(pending.players.find(p => p.id === owner)!.moved, 0);
assert.ok(pending.pendingChoamMove!.discoveryFlight);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
assert.match(viewGame(pending, owner).discoveryOrnithopter!.blocked!, /response or decision/);
assert.equal(JSON.stringify(viewGame(pending, 'c')).includes('discoveryFlight'), false);
const done = applyAction(reload(pending), 'c', { type: 'decision', decline: true });
const pilot = done.players.find(p => p.id === owner)!;
assert.equal(pilot.forces[destination], 2); assert.equal(pilot.elites!.forces[destination], 1);
assert.equal(pilot.moved, 1); assert.equal(held(done).status, 'removed');
assert.equal(done.pendingChoamMove, null);
rejectsUnchanged(done, 'c', { type: 'decision', decline: true });
});
void test('altered pending movement receipt fails closed on normalize, view and answer', () => {
const { game, owner } = choamFixture();
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
for (const alter of [
(g: Game) => { g.pendingChoamMove!.to = 'hagga_basin'; },
(g: Game) => { g.pendingChoamMove!.eliteGroup[flightOrigin] = 0; },
(g: Game) => { g.pendingChoamMove!.discoveryFlight!.move++; },
(g: Game) => { g.turn++; },
(g: Game) => { held(g).owner = 'g'; },
(g: Game) => { g.pendingIxMove = reload(g.pendingChoamMove); },
]) {
const changed = reload(pending); alter(changed);
const before = structuredClone(changed);
assert.throws(() => normalizeAutomaticGame(changed));
assert.throws(() => viewGame(changed, owner));
rejectsUnchanged(changed, 'c', { type: 'decision', decline: true });
assert.deepEqual(changed, before);
}
});
void test('Baliset prevents the pending move without spending its token; a different route can still use it', () => {
const { game, owner, destination } = choamFixture();
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
const choam = pending.players.find(player => player.id === 'c')!;
pending = applyAction(reload(pending), 'c', { type: 'card', mode: 'choam',
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
territory: splitLocation(destination).territory });
assert.equal(pending.pendingChoamMove, null);
assert.equal(held(pending).status, 'carried');
assert.equal(pending.players.find(player => player.id === owner)!.moved, 0);
const alternate = discoveryFlightBotActions(viewGame(pending, owner))[0];
assert.ok(alternate);
assert.equal(held(applyAction(pending, owner, alternate)).status, 'removed');
});
void test('actual Baliset cancellation response restores the same flight and either allows or abandons its one movement', () => {
for (const cancel of [false, true]) {
const { game, owner, destination } = choamFixture();
const cardIndex = game.deck.findIndex(card => card.effect === 'karama');
const karama = game.deck.splice(cardIndex, 1)[0];
game.players.find(player => player.id === owner)!.hand.push(karama);
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
const choam = pending.players.find(player => player.id === 'c')!;
pending = applyAction(pending, 'c', { type: 'card', mode: 'choam',
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
territory: splitLocation(destination).territory });
assert.equal(pending.response?.kind, 'choamWorthless');
assert.equal(held(pending).status, 'carried');
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
const done = applyAction(reload(pending), owner, cancel
? { type: 'card', card: karama.id, mode: 'cancel' } : { type: 'passResponse' });
assert.equal(done.pendingChoamMove, null);
assert.equal(done.players.find(player => player.id === owner)!.moved, Number(cancel));
assert.equal(held(done).status, cancel ? 'removed' : 'carried');
}
});
void test('Discovery flight replaces a Kulon-enhanced normal range with exactly three', () => {
const { game, owner } = discoveryFlightFixture();
game.players[0].faction = 'choam';
game.players[0].forces['arrakeen:10'] = 1; game.players[0].reserves--;
game.choamMovement = { turn: game.turn, bonus: 1 };
const four = declaration(game, splitLocation(flightDestination(game, 4)));
rejectsUnchanged(game, owner, four);
assert.equal(discoveryFlightMove(viewGame(game, owner), four).action, null);
const ordinary = { ...four }; delete ordinary.discoveryOrnithopter;
assert.equal(held(applyAction(game, owner, ordinary)).status, 'carried');
assert.equal(held(applyAction(game, owner, declaration(game))).status, 'removed');
});
void test('a selected concealed marker flies with physical companions and retains its private denomination', () => {
const { game, owner } = discoveryFlightFixture();
const pilot = game.players[0]; pilot.faction = 'richese';
const ids = ['flight-zero', 'flight-three', 'flight-five'];
pilot.noField = deployRicheseNoField(createRicheseNoField(ids),
{ tokenId: ids[1], controller: owner, location: splitLocation(flightOrigin) });
pilot.noFieldEvent = 'flight-marker';
const action: Action = { type: 'move', forces: { [flightOrigin]: 2 }, noField: ids[1],
event: pilot.noFieldEvent, ...splitLocation(flightDestination(game)) };
const quote = discoveryFlightMove(viewGame(game, owner), action);
assert.equal(quote.blocked, null);
const done = applyAction(game, owner, quote.action!);
assert.deepEqual(done.players[0].noField!.deployed!.location, splitLocation(flightDestination(game)));
assert.equal(done.players[0].noField!.deployed!.tokenId, ids[1]);
assert.equal(done.players[0].reserves, pilot.reserves);
assert.equal(held(done).status, 'removed');
assert.equal(JSON.stringify(viewGame(done, 'g')).includes(ids[1]), false);
});
void test('Discovery flight can serve one group of an active Treachery card without spending its second original group', () => {
const { game, owner } = discoveryFlightFixture();
game.richeseCache = richeseCards();
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
const card = game.richeseCache.splice(index, 1)[0]; game.players[0].hand.push(card);
const first = applyAction(game, owner, declaration(game,
{ movementCard: card.id, ornithopter: 'twoGroups' }));
assert.equal(held(first).status, 'removed');
assert.equal(first.ornithopter!.completed, 1);
assert.equal(first.ornithopter!.cohort!.forces[flightOrigin], 3);
assert.equal(first.discard.some(candidate => candidate.id === card.id), false);
const done = applyAction(reload(first), owner, { type: 'move', from: flightOrigin, amount: 2,
...splitLocation(flightDestination(first, 1)), ornithopterEvent: first.ornithopter!.event });
assert.equal(done.players[0].moved, 2);
assert.equal(done.ornithopter, null);
assert.equal(done.discard.filter(candidate => candidate.id === card.id).length, 1);
});
void test('all AI profiles quote usable owned flight routes and conserve tokens when normal range already suffices', () => {
const { game, owner } = discoveryFlightFixture();
for (const difficulty of DIFFICULTIES) {
const g = reload(game), pilot = g.players.find(player => player.id === owner)!;
pilot.bot = difficulty;
const view = viewGame(g, owner), actions = botActions(view);
assert.ok(actions.length);
assert.equal(actions[0].discoveryOrnithopter, held(g).id);
assert.equal(held(applyAction(g, owner, actions[0])).status, 'removed');
pilot.forces['arrakeen:10'] = 1; pilot.reserves--;
assert.deepEqual(discoveryFlightBotActions(viewGame(g, owner)), []);
}
});
void test('a physical Treachery Ornithopter remains independently owned after spending the Discovery token', () => {
const { game, owner } = discoveryFlightFixture();
game.richeseCache = richeseCards();
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
const card = game.richeseCache.splice(index, 1)[0];
game.players[0].hand.push(card);
const done = applyAction(game, owner, declaration(game));
assert.deepEqual(done.players[0].hand, [card]);
assert.equal(done.ornithopter, game.ornithopter);
assert.equal(done.discard.some(candidate => candidate.id === card.id), false);
assert.equal(held(done).status, 'removed');
});
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import { discoveryFlightFixture, flightDestination, flightOrigin } from './fixture-discovery-flight';
import { splitLocation } from '../game/board';
import { newPlayer, viewGame } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restored Discovery flight preserves its owned declaration and competing CHOAM answers spend it once', async () => {
const store = unitStore();
try {
const created = await store.rooms.createRoom('Discovery flight SQL', 'atreides', true, ['choam']);
const code = created.view.code, sessions = [created.token];
for (const faction of ['guild', 'fremen', 'choam'] as const)
sessions.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(sessions.map(token => store.rooms.authenticate(code, token)));
const ids = auths.slice(0, 3).map(auth => auth.playerId) as [string, string, string];
const { game, token, acquiredTurn } = discoveryFlightFixture(true, ids[2], ids);
game.code = code;
const destination = flightDestination(game), choam = newPlayer(auths[3].playerId, 'CHOAM', 'choam');
game.players.push(choam); game.order.push(choam.id);
choam.forces = { [destination]: 1 }; choam.reserves--;
const pilot = game.players[2];
pilot.elites!.forces[flightOrigin] = 1; pilot.elites!.reserves--;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, code);
const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
await store.restart().act(code, auths[2], game.version, { type: 'move',
from: flightOrigin, amount: 2, elite: 1, ...splitLocation(destination),
discoveryOrnithopter: token }, clock);
const pending = await store.restart().readRoom(code);
assert.equal(pending.decision?.kind, 'choamMovement');
assert.equal(pending.pendingChoamMove!.discoveryFlight!.token, token);
assert.equal(pending.discoveries!.tokens.find(t => t.id === token)!.status, 'carried');
assert.equal(pending.players[2].moved, 0);
for (const [index, auth] of auths.entries()) {
const view = await store.restart().readSeatView(code, auth);
assert.deepEqual(view, viewGame(pending, auth.playerId));
assert.equal(view.discoveryOrnithopter !== null, index === 2);
assert.equal(JSON.stringify(view).includes('discoveryFlight'), false);
}
let arrivals = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
const answer = { type: 'decision', decline: true };
try {
const results = await Promise.allSettled([0, 1].map(() =>
store.restart().act(code, auths[3], pending.version, answer, clock)));
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
const done = await store.restart().readRoom(code), spent = done.discoveries!.tokens.find(t => t.id === token)!;
assert.equal(done.version, pending.version + 1);
assert.equal(done.players[2].forces[destination], 2);
assert.equal(done.players[2].elites!.forces[destination], 1);
assert.equal(done.players[2].moved, 1);
assert.equal(done.pendingChoamMove, null);
assert.equal(spent.status, 'removed'); assert.equal(spent.owner, null);
assert.equal(spent.acquiredTurn, acquiredTurn);
await assert.rejects(store.restart().act(code, auths[3], done.version, answer, clock));
assert.deepEqual(await store.restart().readRoom(code), done);
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
} finally { store.sqlite.close(); }
});
+199
View File
@@ -0,0 +1,199 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDiscoveryState, placeDiscovery, projectDiscoveryState, revealDiscoveryToken,
type DiscoveryOpaqueTokenId,
} from '../game/discoveries';
import {
completeDiscoveryFlight, discoveryFlightOffer, DiscoveryFlightError, quoteDiscoveryFlight,
validateDiscoveryFlight,
type DiscoveryFlightContext, type DiscoveryFlightMovement, type DiscoveryFlightReceipt,
} from '../game/discovery-flight';
function fixture() {
const supplied = createDiscoveryState(() => 0.999);
const token = supplied.tokens.find(token => token.face === 'ornithopter')!.id;
const placed = placeDiscovery(supplied, 'discovery-funeral-plain', () => 0.999);
const discoveries = revealDiscoveryToken(placed, token, 2, 'p').state;
const context: DiscoveryFlightContext = {
status: 'playing', phase: 5, active: 'p', turn: 3, discoveries,
players: [{ id: 'p', moved: 0 }, { id: 'q', moved: 0 }],
};
const move: DiscoveryFlightMovement = {
player: 'p', origin: 'false_wall_west',
group: [['false_wall_west:16', 2], ['false_wall_west:17', 1]],
eliteGroup: { 'false_wall_west:16': 1, 'false_wall_west:17': 0 },
elite: 1, total: 3, to: 'the_great_flat', sector: 15,
advisors: false, wantsFighters: false,
};
return { context, token, move };
}
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
function rejectedUnchanged<T>(input: T, work: () => unknown) {
const before = structuredClone(input);
assert.throws(work, DiscoveryFlightError);
assert.deepEqual(input, before);
}
void test('Discovery flight quote binds one typed group without spending custody or changing the source state', () => {
const { context, token, move } = fixture(), before = structuredClone({ context, move });
const receipt = quoteDiscoveryFlight(context, token, move);
assert.equal(receipt.token, token);
assert.equal(receipt.player, 'p');
assert.equal(receipt.turn, 3);
assert.equal(receipt.acquiredTurn, 2);
assert.equal(receipt.move, 0);
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, move));
assert.equal(context.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'carried');
assert.deepEqual({ context, move }, before);
rejectedUnchanged(context, () => completeDiscoveryFlight(context, receipt, move));
});
void test('turn of acquisition, earlier turn, foreign owner and unavailable physical tokens cannot create flight receipts', () => {
const { context, token, move } = fixture();
for (const turn of [1, 2]) {
const sameTurn = { ...context, turn };
rejectedUnchanged(sameTurn, () => quoteDiscoveryFlight(sameTurn, token, move));
}
for (const wrong of [undefined, '', 'discovery-token-99',
context.discoveries!.tokens.find(candidate => candidate.face === 'cistern')!.id])
rejectedUnchanged(context, () => quoteDiscoveryFlight(context, wrong, move));
const foreign = { ...context, active: 'q' };
rejectedUnchanged(foreign, () => quoteDiscoveryFlight(foreign, token, { ...move, player: 'q' }));
for (const changes of [{ status: 'finished' }, { phase: 7 }, { active: 'q' }, { turn: 0 }]) {
const unavailable = { ...context, ...changes };
rejectedUnchanged(unavailable, () => quoteDiscoveryFlight(unavailable, token, move));
}
});
void test('pending owned movement responses and JSON restore retain the exact group without consuming the token', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const order = { ...move, discoveryFlight: receipt };
const pending = { ...context, response: { kind: 'choamPower', owner: 'q' }, pendingChoamMove: order };
const saved = reload(pending), before = structuredClone(saved);
assert.doesNotThrow(() => validateDiscoveryFlight(saved, saved.pendingChoamMove.discoveryFlight, saved.pendingChoamMove));
assert.deepEqual(quoteDiscoveryFlight(saved, token, saved.pendingChoamMove), receipt);
assert.deepEqual(saved, before);
// Abandoning an invalidated route drops the order, not its unspent token.
const abandoned = { ...saved, response: null, pendingChoamMove: null };
assert.equal(discoveryFlightOffer(abandoned, 'p', { movesAllowed: 1 })!.blocked, null);
assert.deepEqual(abandoned.discoveries, context.discoveries);
});
void test('completed flight removes only the carried token after one committed movement and preserves its acquisition history', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const committed = reload(context);
committed.players[0].moved++;
const before = structuredClone(committed), next = completeDiscoveryFlight(committed, reload(receipt), reload(move));
const originalToken = context.discoveries!.tokens.find(candidate => candidate.id === token)!;
assert.deepEqual(next.tokens.find(candidate => candidate.id === token), {
...originalToken, status: 'removed', owner: null,
});
assert.deepEqual(next.tokens.filter(candidate => candidate.id !== token),
context.discoveries!.tokens.filter(candidate => candidate.id !== token));
assert.deepEqual(next.newlyRevealed, context.discoveries!.newlyRevealed);
assert.deepEqual(committed, before);
assert.notEqual(next, committed.discoveries);
const spent = { ...committed, discoveries: next };
rejectedUnchanged(spent, () => completeDiscoveryFlight(spent, receipt, move));
rejectedUnchanged(spent, () => quoteDiscoveryFlight(spent, token, move));
assert.equal(discoveryFlightOffer(spent, 'p'), null);
});
void test('saved receipts reject replay onto another move, changed groups or destinations, and altered token acquisition', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
for (const alter of [
(order: DiscoveryFlightMovement) => { order.to = 'hagga_basin'; },
(order: DiscoveryFlightMovement) => { order.sector = 14; },
(order: DiscoveryFlightMovement) => { order.group = [['false_wall_west:16', 1], ['false_wall_west:17', 2]]; },
(order: DiscoveryFlightMovement) => { order.eliteGroup = { 'false_wall_west:16': 0, 'false_wall_west:17': 1 }; },
(order: DiscoveryFlightMovement) => { order.advisors = true; },
(order: DiscoveryFlightMovement) => { order.lockedTurn = 3; },
]) {
const changed = reload(move); alter(changed);
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt, changed));
}
for (const changes of [{ turn: 4 }, { players: [{ id: 'p', moved: 1 }, { id: 'q', moved: 0 }] }]) {
const changed = { ...context, ...changes };
rejectedUnchanged(changed, () => validateDiscoveryFlight(changed, receipt, move));
}
const laterMove = reload(context); laterMove.players[0].moved = 2;
rejectedUnchanged(laterMove, () => completeDiscoveryFlight(laterMove, receipt, move));
const reacquired = reload(context), held = reacquired.discoveries!.tokens.find(candidate => candidate.id === token)!;
held.acquiredTurn = 1; held.revealedTurn = 1;
rejectedUnchanged(reacquired, () => validateDiscoveryFlight(reacquired, receipt, move));
for (const damage of [
{ ...receipt, player: 'q' }, { ...receipt, move: -1 }, { ...receipt, selection: 'changed' },
{ ...receipt, turn: 2 }, { ...receipt, extra: true },
]) rejectedUnchanged(context, () => validateDiscoveryFlight(context, damage, move));
});
void test('receipt signature ignores property order and independent Treachery flight fields while binding a selected marker', () => {
const { context, token, move } = fixture();
const marked = { ...move, total: 4, noField: { tokenId: 'field-one', event: 'marker-event', from: 'false_wall_west:17' } };
const receipt = quoteDiscoveryFlight(context, token, marked);
const equivalent = {
...marked, group: [...marked.group].reverse(),
eliteGroup: { 'false_wall_west:17': 0, 'false_wall_west:16': 1 },
ornithopterEvent: 'physical-treachery-card-event', ornithopterRange: false,
discoveryFlight: receipt,
};
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, equivalent));
assert.deepEqual(quoteDiscoveryFlight(context, token, equivalent), receipt);
for (const changes of [{ tokenId: 'other-field' }, { event: 'other-event' }, { from: 'false_wall_west:16' }])
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt,
{ ...marked, noField: { ...marked.noField, ...changes } }));
const withCard = { ...context, ornithopter: { event: 'physical-treachery-card-event', completed: 0, mode: 'twoGroups' } };
const before = structuredClone(withCard);
assert.deepEqual(quoteDiscoveryFlight(withCard, token, equivalent), receipt);
assert.deepEqual(withCard, before);
});
void test('malformed or motionless canonical groups cannot reserve the Discovery token', () => {
const { context, token, move } = fixture();
const malformed: DiscoveryFlightMovement[] = [
{ ...move, source: 'ambassador' },
{ ...move, total: 0, elite: 0, group: [], eliteGroup: {} },
{ ...move, total: 4 },
{ ...move, group: [['false_wall_west:16', 3], ['false_wall_west:16', 1]] },
{ ...move, group: [['false_wall_west:16', 0], ['false_wall_west:17', 3]] },
{ ...move, group: [['arrakeen:10', 2], ['false_wall_west:17', 1]] },
{ ...move, eliteGroup: { 'false_wall_west:16': 3 }, elite: 3 },
{ ...move, eliteGroup: { 'false_wall_west:16': 1, 'not-selected:0': 0 } },
{ ...move, to: 'false_wall_west', sector: 16 },
{ ...move, wantsFighters: true },
];
for (const invalid of malformed) rejectedUnchanged(context, () => quoteDiscoveryFlight(context, token, invalid));
});
void test('private projected offers show a later-turn fixed range, ordinary allowance and response locks without consuming custody', () => {
const { context, token } = fixture();
const projected = { ...context, discoveries: projectDiscoveryState(context.discoveries!, 'atreides') };
const before = structuredClone(projected);
assert.deepEqual(discoveryFlightOffer(projected, 'p', { movesAllowed: 1 }), {
token, acquiredTurn: 2, range: 3, blocked: null,
});
assert.equal(discoveryFlightOffer(projected, 'q'), null);
assert.match(discoveryFlightOffer({ ...projected, turn: 2 }, 'p')!.blocked!, /turn after/);
assert.match(discoveryFlightOffer({ ...projected, phase: 7 }, 'p')!.blocked!, /own Shipment/);
assert.match(discoveryFlightOffer(projected, 'p', { movesAllowed: 0 })!.blocked!, /No ordinary movement/);
assert.equal(discoveryFlightOffer(projected, 'p', { blocked: 'Finish the current response.' })!.blocked, 'Finish the current response.');
// It replaces one action's movement range; neither Kulon nor a card adds to it.
const otherBenefits = { ...projected, choamMovement: { turn: 3, bonus: 1 }, ornithopter: { mode: 'range3' } };
assert.equal(discoveryFlightOffer(otherBenefits, 'p')!.range, 3);
assert.deepEqual(projected, before);
const badView = reload(projected);
badView.discoveries.tokens[0].face = null;
assert.equal(discoveryFlightOffer(badView, 'p'), null);
});
void test('missing or duplicated physical custody cannot authenticate a saved flight', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const missing = { ...context, discoveries: undefined };
rejectedUnchanged(missing, () => quoteDiscoveryFlight(missing, token, move));
const duplicated = reload(context);
duplicated.discoveries!.tokens[0].id = token as DiscoveryOpaqueTokenId;
rejectedUnchanged(duplicated, () => validateDiscoveryFlight(duplicated, receipt, move));
const wrongReceipt = { ...receipt, token: 'invalid' } as unknown as DiscoveryFlightReceipt;
rejectedUnchanged(context, () => validateDiscoveryFlight(context, wrongReceipt, move));
});
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { gameDistance, gameTerritories, splitLocation } from '../game/board';
import { discoveryFixture, enterDiscoveryCollection } from './fixture-discovery';
export const flightOrigin = 'red_chasm:7';
/** Start from the actual base audit setup and acquire the actual token through
* Collection. Reposition existing physical forces for a focused movement turn. */
export function discoveryFlightFixture(advanced = false, owner = 'a',
ids: [string, string, string] = ['a', 'g', 'f']) {
let game = discoveryFixture(advanced, ids);
const token = enterDiscoveryCollection(game, 'ornithopter', owner);
if (viewGame(game, owner).discoveries?.canInspect.includes(token.id))
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: true });
const acquiredTurn = game.turn;
game.turn++;
Object.assign(game, { phase: 5, active: owner, phaseOpening: null, response: null,
decision: null, stormPending: null, storm: 18, ready: [], movementRemaining: [owner] });
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
game.deck.push(...player.hand);
player.hand = [];
player.shipped = true;
player.moved = 0;
}
const pilot = game.players.find(player => player.id === owner)!;
pilot.reserves -= 5;
pilot.forces[flightOrigin] = 5;
return { game, token: token.id, owner, acquiredTurn };
}
export function flightDestination(game: Game, steps = 3, from = flightOrigin) {
const target = gameTerritories(game).flatMap(t => t.sectors.map(sector => `${t.id}:${sector}`))
.find(key => splitLocation(key).territory !== splitLocation(from).territory &&
splitLocation(key).sector !== game.storm &&
gameDistance(game, from, key, key => splitLocation(key).sector === game.storm) === steps);
assert.ok(target, `a ${steps}-territory route exists`);
return target;
}