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:
1 parent
f0256c1b38
commit
ab0ab52849
28 files changed
+3873
-59
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -61,6 +61,7 @@ const DECISIONS = {
|
||||
wormPlacement: true,
|
||||
wormProtection: true,
|
||||
discoveryDiscard: true,
|
||||
discoveryEntry: true,
|
||||
greatMakerVote: true,
|
||||
greatMakerRide: true,
|
||||
wormRide: true,
|
||||
|
||||
Reference in new issue
Block a user