Prototype Discovery spice encounters, private tokens and stash rewards
Connect real Basic/Advanced base-faction audit setup to all seven Discovery Spice Cards and eight physical tokens. Add Great Maker's ordered majority vote and Fremen reserve ride, destructive Discovery blows, private faction peeks and Collection inspection/reveal, stash rewards and mandatory saved card overflow, and revealed nested board locations. Share private legal choices with human controls and all four AI profiles. Preserve normal gates. Independent review repairs orphaned worm/vote/ride controls, retargeted reserve arrivals, actor bindings and incomplete overflow discard receipts. Harden room-aware admission against unrevealed nested locations. Extend the backed-up local prototype starter with a Discovery profile; preserve seats. Still missing: next-turn free entry, five special location benefits and Ornithopter movement spending. Current prototype playthroughs do not certify those effects or complete module/expansion compliance. Verification: npm run check passed 4264 offline tests plus types/lint; npm run build passed; npm run test:integration passed 40 tests. The single source-bound broad run took 129358ms / 6782ms / 23918ms respectively. Report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-prototype/verification/report.json Source fingerprint: 4ea7b5209a58c369b0b1f8dc239b5dd2f3302bb759d83c32ba5406e80d8ae154 Two seeded Basic/Advanced games complete with component custody intact. SQLite races pay stashes once and resume committed discards without replay. Browser 3RJBH26Y verifies genuine human Fremen setup, a Discovery blow from one reordered physical card, native private Hiereg visibility, enlarged card/rules reading and refresh. Room remains paused at spice acceptance. All 125 opening saved rooms unchanged; 31 added, 156 total. No server restart. Efficiency: three reused bounded helpers; aggregate counter 60591430 to 61812513 after verification, 1221083 tokens before final Git/push overhead. 47 added offline tests. One stable broad run after focused review fixes; no calibration or exhaustive optional-module interaction matrix.
This commit is contained in:
1 parent
4af6e08b3a
commit
f0256c1b38
44 files changed
+4924
-104
No files matched your search
@@ -10,7 +10,8 @@ import {
|
||||
gameTerritories,
|
||||
MOBILE_LOCATION,
|
||||
splitLocation,
|
||||
validLocation,
|
||||
TERRITORIES,
|
||||
validGameLocation,
|
||||
type MobileBoard,
|
||||
} from './board';
|
||||
|
||||
@@ -29,10 +30,11 @@ export type BoardContext = MobileBoard & {
|
||||
players: readonly BoardSeat[];
|
||||
};
|
||||
export type AdvisorRelease = { player: string; territory: string };
|
||||
function validKey(key: string) {
|
||||
function validKey(g: MobileBoard, key: string) {
|
||||
const p = splitLocation(key);
|
||||
return (
|
||||
key === `${p.territory}:${p.sector}` && validLocation(p.territory, p.sector)
|
||||
key === `${p.territory}:${p.sector}` &&
|
||||
validGameLocation(g, p.territory, p.sector)
|
||||
);
|
||||
}
|
||||
/** A public board projection: no leader, hand, secret No-Field value or resource
|
||||
@@ -48,22 +50,28 @@ export function settledBoard(g: BoardContext) {
|
||||
g.players.every((p) => g.order.includes(p.id)),
|
||||
'Board resolution needs the current distinct player order and storm sector.',
|
||||
);
|
||||
if (g.mobileStronghold?.location)
|
||||
if (g.mobileStronghold?.location) {
|
||||
const pointer = splitLocation(g.mobileStronghold.location);
|
||||
requireBoard(
|
||||
validKey(g.mobileStronghold.location) &&
|
||||
g.mobileStronghold.location !== MOBILE_LOCATION,
|
||||
validKey(g, g.mobileStronghold.location) &&
|
||||
TERRITORIES.some(
|
||||
(t) =>
|
||||
t.id === pointer.territory && t.sectors.includes(pointer.sector),
|
||||
),
|
||||
'The mobile stronghold needs its board pointer.',
|
||||
);
|
||||
}
|
||||
for (const p of g.players) {
|
||||
requireBoard(
|
||||
Object.entries(p.forces).every(([key, n]) => validKey(key) && whole(n)) &&
|
||||
whole(Object.values(p.forces).reduce((a, n) => a + n, 0)),
|
||||
Object.entries(p.forces).every(
|
||||
([key, n]) => validKey(g, key) && whole(n),
|
||||
) && whole(Object.values(p.forces).reduce((a, n) => a + n, 0)),
|
||||
'Board resolution needs valid physical force locations and counts.',
|
||||
);
|
||||
const marker = p.noField?.deployed?.location;
|
||||
if (marker)
|
||||
requireBoard(
|
||||
validLocation(marker.territory, marker.sector),
|
||||
validGameLocation(g, marker.territory, marker.sector),
|
||||
'The No-Field needs a valid board location.',
|
||||
);
|
||||
}
|
||||
@@ -206,7 +214,7 @@ export function quoteSpiceCollection(
|
||||
record(p.elites.forces) &&
|
||||
Object.entries(p.elites.forces).every(
|
||||
([key, n]) =>
|
||||
validKey(key) && whole(n) && n <= (p.forces[key] ?? 0),
|
||||
validKey(g, key) && whole(n) && n <= (p.forces[key] ?? 0),
|
||||
),
|
||||
'Collection needs valid physical elite groups.',
|
||||
);
|
||||
@@ -214,7 +222,7 @@ export function quoteSpiceCollection(
|
||||
const { players, released } = settledBoard(g);
|
||||
const spice = { ...g.spice };
|
||||
requireBoard(
|
||||
Object.entries(spice).every(([key, n]) => validKey(key) && whole(n)),
|
||||
Object.entries(spice).every(([key, n]) => validKey(g, key) && whole(n)),
|
||||
'Collection needs valid spice locations and quantities.',
|
||||
);
|
||||
const capacities = new Map<string, Map<string, number>>();
|
||||
|
||||
+104
-16
@@ -1,5 +1,6 @@
|
||||
import data from './board-data.json';
|
||||
import graphData from './board-graph.json';
|
||||
import { DISCOVERY_LOCATIONS, isDiscoveryLocationId } from './discoveries';
|
||||
export const TERRITORIES = data;
|
||||
export const MOBILE_STRONGHOLD = 'hidden_mobile_stronghold';
|
||||
export const MOBILE_LOCATION = `${MOBILE_STRONGHOLD}:0`;
|
||||
@@ -17,20 +18,73 @@ const mobileTerritory = {
|
||||
center: [1045, 110],
|
||||
neighbors: [],
|
||||
};
|
||||
const discoveryTerritories = DISCOVERY_LOCATIONS.map((entry, index) => {
|
||||
const top = 180 + index * 80;
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
sectors: [0],
|
||||
// Shipping and the shared two-faction admission rule treat every revealed
|
||||
// location like a stronghold. Victory applies its narrower rule separately.
|
||||
type: 'stronghold',
|
||||
points: [
|
||||
[930, top],
|
||||
[1160, top],
|
||||
[1160, top + 60],
|
||||
[930, top + 60],
|
||||
],
|
||||
center: [1045, top + 30],
|
||||
neighbors: [],
|
||||
};
|
||||
});
|
||||
type DiscoveryBoardToken = {
|
||||
face: string | null;
|
||||
status: string;
|
||||
territory: string | null;
|
||||
sector: number | null;
|
||||
revealedTurn: number | null;
|
||||
};
|
||||
export type MobileBoard = {
|
||||
mobileStronghold?: { location: string | null } | null;
|
||||
discoveries?: { tokens: readonly DiscoveryBoardToken[] } | null;
|
||||
};
|
||||
/** Static printed board data stays shared; the mobile territory belongs to a room. */
|
||||
function revealedDiscoveryLocations(g: MobileBoard) {
|
||||
const locations = new Map<string, { territory: string; sector: number }>();
|
||||
for (const token of g.discoveries?.tokens ?? []) {
|
||||
if (
|
||||
token.status !== 'placed' ||
|
||||
!Number.isSafeInteger(token.revealedTurn) ||
|
||||
(token.revealedTurn ?? 0) < 1 ||
|
||||
!isDiscoveryLocationId(token.face) ||
|
||||
typeof token.territory !== 'string' ||
|
||||
typeof token.sector !== 'number'
|
||||
)
|
||||
continue;
|
||||
const parent = TERRITORIES.find((t) => t.id === token.territory);
|
||||
if (!parent?.sectors.includes(token.sector)) continue;
|
||||
locations.set(token.face, {
|
||||
territory: token.territory,
|
||||
sector: token.sector,
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
/** Static printed board data stays shared; mobile and revealed Discovery
|
||||
* territories belong to a room. */
|
||||
export function gameTerritories(g: MobileBoard) {
|
||||
return g.mobileStronghold?.location
|
||||
? [...TERRITORIES, mobileTerritory]
|
||||
: TERRITORIES;
|
||||
const revealed = revealedDiscoveryLocations(g);
|
||||
const dynamic = [
|
||||
...(g.mobileStronghold?.location ? [mobileTerritory] : []),
|
||||
...discoveryTerritories.filter((t) => revealed.has(t.id)),
|
||||
];
|
||||
return dynamic.length ? [...TERRITORIES, ...dynamic] : TERRITORIES;
|
||||
}
|
||||
export const territory = (id: string) => {
|
||||
const t =
|
||||
id === MOBILE_STRONGHOLD
|
||||
? mobileTerritory
|
||||
: TERRITORIES.find((t) => t.id === id);
|
||||
: (discoveryTerritories.find((t) => t.id === id) ??
|
||||
TERRITORIES.find((t) => t.id === id));
|
||||
if (!t) throw new Error('Unknown territory.');
|
||||
return t;
|
||||
};
|
||||
@@ -42,10 +96,19 @@ export function splitLocation(key: string) {
|
||||
}
|
||||
export function validLocation(id: string, sector: number) {
|
||||
if (id === MOBILE_STRONGHOLD) return sector === 0;
|
||||
if (isDiscoveryLocationId(id)) return sector === 0;
|
||||
return (
|
||||
TERRITORIES.find((t) => t.id === id)?.sectors.includes(sector) ?? false
|
||||
);
|
||||
}
|
||||
/** Structural location validity plus the room-local reveal requirement. The
|
||||
* mobile stronghold remains structurally available while awaiting placement. */
|
||||
export function validGameLocation(g: MobileBoard, id: string, sector: number) {
|
||||
return (
|
||||
validLocation(id, sector) &&
|
||||
(!isDiscoveryLocationId(id) || revealedDiscoveryLocations(g).has(id))
|
||||
);
|
||||
}
|
||||
export function distance(
|
||||
from: string,
|
||||
to: string,
|
||||
@@ -80,25 +143,50 @@ export const FREMEN_START_LOCATIONS = FREMEN_START.flatMap((id) =>
|
||||
territory(id).sectors.map((s) => location(id, s)),
|
||||
);
|
||||
|
||||
/** The mobile stronghold is a dead end, one territory from its pointing sector. */
|
||||
function dynamicEntrances(g: MobileBoard, key: string): string[] | undefined {
|
||||
if (key === MOBILE_LOCATION)
|
||||
return g.mobileStronghold?.location ? [g.mobileStronghold.location] : [];
|
||||
const at = splitLocation(key);
|
||||
if (!isDiscoveryLocationId(at.territory)) return undefined;
|
||||
if (at.sector !== 0) return [];
|
||||
const parent = revealedDiscoveryLocations(g).get(at.territory);
|
||||
if (!parent) return [];
|
||||
return territory(parent.territory).sectors.map((sector) =>
|
||||
location(parent.territory, sector),
|
||||
);
|
||||
}
|
||||
|
||||
/** Room-local nested territories are dead ends. The mobile stronghold connects
|
||||
* through its pointer; a revealed Discovery location connects through any
|
||||
* sector of the surrounding territory and costs one extra movement territory. */
|
||||
export function gameDistance(
|
||||
g: MobileBoard,
|
||||
from: string,
|
||||
to: string,
|
||||
blocked: (key: string) => boolean = () => false,
|
||||
): number {
|
||||
if (from !== MOBILE_LOCATION && to !== MOBILE_LOCATION)
|
||||
const fromEntrances = dynamicEntrances(g, from);
|
||||
const toEntrances = dynamicEntrances(g, to);
|
||||
if (fromEntrances === undefined && toEntrances === undefined)
|
||||
return distance(from, to, blocked);
|
||||
const pointer = g.mobileStronghold?.location;
|
||||
if (!pointer || blocked(from) || blocked(to)) return Infinity;
|
||||
if (!fromEntrances?.length && fromEntrances !== undefined) return Infinity;
|
||||
if (!toEntrances?.length && toEntrances !== undefined) return Infinity;
|
||||
// Preserve the mobile stronghold contract: battle callers project its
|
||||
// interior to the storm-covered pointer in their obstruction callback.
|
||||
if (
|
||||
(from === MOBILE_LOCATION && blocked(from)) ||
|
||||
(to === MOBILE_LOCATION && blocked(to))
|
||||
)
|
||||
return Infinity;
|
||||
if (from === to) return 0;
|
||||
return (
|
||||
1 +
|
||||
distance(
|
||||
from === MOBILE_LOCATION ? pointer : from,
|
||||
to === MOBILE_LOCATION ? pointer : to,
|
||||
blocked,
|
||||
)
|
||||
const starts = fromEntrances ?? [from];
|
||||
const ends = toEntrances ?? [to];
|
||||
const endpointCost =
|
||||
Number(fromEntrances !== undefined) + Number(toEntrances !== undefined);
|
||||
return Math.min(
|
||||
...starts.flatMap((start) =>
|
||||
ends.map((end) => endpointCost + distance(start, end, blocked)),
|
||||
),
|
||||
);
|
||||
}
|
||||
/** A declared sector-by-sector route makes spice collection auditable. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { discoveryBotActions } from './discovery-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';
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
@@ -2340,6 +2342,8 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', accept: true, amount: level === 0 ? 1 : (g.homeworldMobility?.advisorSinkMaximum ?? 1) }] : []),
|
||||
{ type: 'decision', accept: false },
|
||||
];
|
||||
if (d.kind === 'discoveryDiscard') return discoveryBotActions(g);
|
||||
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
|
||||
if (d.kind === 'wormProtection')
|
||||
return [{ type: 'decision', accept: true }];
|
||||
if (d.kind === 'stormLosses')
|
||||
@@ -3369,6 +3373,8 @@ export function botActions(g: GameView): Action[] {
|
||||
: nexusTraitorBotActions(g);
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const discovery = discoveryBotActions(g);
|
||||
if (discovery.length) return discovery;
|
||||
const trade = nexusChoamTradeBotActions(g);
|
||||
if (trade.length) return trade;
|
||||
const sardaukar = nexusSardaukarBotActions(g);
|
||||
|
||||
+2
-2
@@ -253,8 +253,8 @@ export const SPICE_CARDS = [
|
||||
['sihaya_ridge', 6, 9],
|
||||
] as const;
|
||||
export type SpiceCard =
|
||||
| { territory: string; amount: number; sector: number }
|
||||
| { worm: true; suppressed?: true; thumper?: true }
|
||||
| { territory: string; amount: number; sector: number; discovery?: string }
|
||||
| { worm: true; suppressed?: true; thumper?: true; greatMaker?: true }
|
||||
| { sandtrout: true };
|
||||
export function spiceDeck(includeSandtrout = false): SpiceCard[] {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
/**
|
||||
* Physical inventory and hidden custody for the Ecaz & Moritani Discovery
|
||||
* variant. Source acquisition: docs/DISCOVERY_COMPONENTS.md.
|
||||
*
|
||||
* This module does not authorize a spice blow, inspection, reveal, movement,
|
||||
* or reward. Callers establish those game conditions and apply emitted effects.
|
||||
*/
|
||||
export type DiscoveryTokenType = 'hiereg' | 'smuggler';
|
||||
export type DiscoveryTokenFace =
|
||||
| 'jacurutu-sietch'
|
||||
| 'cistern'
|
||||
| 'ecological-testing-station'
|
||||
| 'shrine'
|
||||
| 'orgiz-processing-station'
|
||||
| 'treachery-card-stash'
|
||||
| 'spice-stash'
|
||||
| 'ornithopter';
|
||||
export type DiscoveryLocationId = Extract<
|
||||
DiscoveryTokenFace,
|
||||
| 'jacurutu-sietch'
|
||||
| 'cistern'
|
||||
| 'ecological-testing-station'
|
||||
| 'shrine'
|
||||
| 'orgiz-processing-station'
|
||||
>;
|
||||
export type DiscoveryTokenKind = 'location' | 'instant' | 'carried';
|
||||
|
||||
export const JACURUTU_SIETCH: DiscoveryLocationId = 'jacurutu-sietch';
|
||||
|
||||
export type DiscoveryLocationDefinition = Readonly<{
|
||||
id: DiscoveryLocationId;
|
||||
name: string;
|
||||
tokenType: DiscoveryTokenType;
|
||||
stronghold: boolean;
|
||||
}>;
|
||||
|
||||
export const DISCOVERY_LOCATIONS: readonly DiscoveryLocationDefinition[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
id: JACURUTU_SIETCH,
|
||||
name: 'Jacurutu Sietch',
|
||||
tokenType: 'hiereg',
|
||||
stronghold: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'cistern',
|
||||
name: 'Cistern',
|
||||
tokenType: 'hiereg',
|
||||
stronghold: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ecological-testing-station',
|
||||
name: 'Ecological Testing Station',
|
||||
tokenType: 'hiereg',
|
||||
stronghold: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'shrine',
|
||||
name: 'Shrine',
|
||||
tokenType: 'hiereg',
|
||||
stronghold: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'orgiz-processing-station',
|
||||
name: 'Orgiz Processing Station',
|
||||
tokenType: 'smuggler',
|
||||
stronghold: false,
|
||||
}),
|
||||
] satisfies DiscoveryLocationDefinition[]);
|
||||
|
||||
export const DISCOVERY_LOCATION_IDS: readonly DiscoveryLocationId[] =
|
||||
Object.freeze(DISCOVERY_LOCATIONS.map((location) => location.id));
|
||||
|
||||
export const DISCOVERY_LOCATION_BY_ID: Readonly<
|
||||
Record<DiscoveryLocationId, DiscoveryLocationDefinition>
|
||||
> = Object.freeze(
|
||||
Object.fromEntries(
|
||||
DISCOVERY_LOCATIONS.map((location) => [location.id, location]),
|
||||
) as Record<DiscoveryLocationId, DiscoveryLocationDefinition>,
|
||||
);
|
||||
|
||||
export function isDiscoveryLocationId(
|
||||
value: unknown,
|
||||
): value is DiscoveryLocationId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DISCOVERY_LOCATION_IDS.includes(value as DiscoveryLocationId)
|
||||
);
|
||||
}
|
||||
|
||||
export type DiscoveryTokenDefinition = Readonly<{
|
||||
id: DiscoveryTokenFace;
|
||||
name: string;
|
||||
type: DiscoveryTokenType;
|
||||
kind: DiscoveryTokenKind;
|
||||
location: DiscoveryLocationId | null;
|
||||
}>;
|
||||
|
||||
export const DISCOVERY_TOKENS: readonly DiscoveryTokenDefinition[] =
|
||||
Object.freeze([
|
||||
...DISCOVERY_LOCATIONS.map((location) =>
|
||||
Object.freeze({
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
type: location.tokenType,
|
||||
kind: 'location' as const,
|
||||
location: location.id,
|
||||
}),
|
||||
),
|
||||
Object.freeze({
|
||||
id: 'treachery-card-stash',
|
||||
name: 'Treachery Card Stash',
|
||||
type: 'smuggler',
|
||||
kind: 'instant',
|
||||
location: null,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'spice-stash',
|
||||
name: 'Spice Stash',
|
||||
type: 'smuggler',
|
||||
kind: 'instant',
|
||||
location: null,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ornithopter',
|
||||
name: 'Ornithopter',
|
||||
type: 'smuggler',
|
||||
kind: 'carried',
|
||||
location: null,
|
||||
}),
|
||||
] satisfies DiscoveryTokenDefinition[]);
|
||||
|
||||
export const DISCOVERY_TOKEN_DEFINITIONS = DISCOVERY_TOKENS;
|
||||
|
||||
export const DISCOVERY_TOKEN_BY_ID: Readonly<
|
||||
Record<DiscoveryTokenFace, DiscoveryTokenDefinition>
|
||||
> = Object.freeze(
|
||||
Object.fromEntries(
|
||||
DISCOVERY_TOKENS.map((definition) => [definition.id, definition]),
|
||||
) as Record<DiscoveryTokenFace, DiscoveryTokenDefinition>,
|
||||
);
|
||||
|
||||
const DISCOVERY_TOKEN_FACES = Object.freeze(
|
||||
DISCOVERY_TOKENS.map((definition) => definition.id),
|
||||
);
|
||||
|
||||
export function isDiscoveryTokenFace(
|
||||
value: unknown,
|
||||
): value is DiscoveryTokenFace {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DISCOVERY_TOKEN_FACES.includes(value as DiscoveryTokenFace)
|
||||
);
|
||||
}
|
||||
|
||||
export type DiscoverySpiceCardId =
|
||||
| 'discovery-hagga-basin'
|
||||
| 'discovery-rock-outcroppings'
|
||||
| 'discovery-sihaya-ridge'
|
||||
| 'discovery-wind-pass-north'
|
||||
| 'discovery-funeral-plain'
|
||||
| 'discovery-old-gap';
|
||||
export type DiscoverySpiceCard = Readonly<{
|
||||
territory: string;
|
||||
amount: 6;
|
||||
sector: number;
|
||||
discovery: DiscoverySpiceCardId;
|
||||
}>;
|
||||
export type DiscoveryCardPlacement = Readonly<{
|
||||
type: DiscoveryTokenType;
|
||||
territory: string;
|
||||
sector: number;
|
||||
}>;
|
||||
|
||||
export const DISCOVERY_SPICE_CARDS: readonly DiscoverySpiceCard[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
territory: 'hagga_basin',
|
||||
amount: 6,
|
||||
sector: 12,
|
||||
discovery: 'discovery-hagga-basin',
|
||||
}),
|
||||
Object.freeze({
|
||||
territory: 'rock_outcroppings',
|
||||
amount: 6,
|
||||
sector: 13,
|
||||
discovery: 'discovery-rock-outcroppings',
|
||||
}),
|
||||
Object.freeze({
|
||||
territory: 'sihaya_ridge',
|
||||
amount: 6,
|
||||
sector: 9,
|
||||
discovery: 'discovery-sihaya-ridge',
|
||||
}),
|
||||
Object.freeze({
|
||||
territory: 'wind_pass_north',
|
||||
amount: 6,
|
||||
sector: 17,
|
||||
discovery: 'discovery-wind-pass-north',
|
||||
}),
|
||||
Object.freeze({
|
||||
territory: 'funeral_plain',
|
||||
amount: 6,
|
||||
sector: 15,
|
||||
discovery: 'discovery-funeral-plain',
|
||||
}),
|
||||
Object.freeze({
|
||||
territory: 'oh_gap',
|
||||
amount: 6,
|
||||
sector: 10,
|
||||
discovery: 'discovery-old-gap',
|
||||
}),
|
||||
] satisfies DiscoverySpiceCard[]);
|
||||
|
||||
export const DISCOVERY_CARD_PLACEMENTS: Readonly<
|
||||
Record<DiscoverySpiceCardId, DiscoveryCardPlacement>
|
||||
> = Object.freeze({
|
||||
'discovery-hagga-basin': Object.freeze({
|
||||
type: 'hiereg',
|
||||
territory: 'gara_kulon',
|
||||
sector: 8,
|
||||
}),
|
||||
'discovery-rock-outcroppings': Object.freeze({
|
||||
type: 'hiereg',
|
||||
territory: 'meridian',
|
||||
sector: 1,
|
||||
}),
|
||||
'discovery-sihaya-ridge': Object.freeze({
|
||||
type: 'hiereg',
|
||||
territory: 'cielago_east',
|
||||
sector: 3,
|
||||
}),
|
||||
'discovery-wind-pass-north': Object.freeze({
|
||||
type: 'smuggler',
|
||||
territory: 'plastic_basin',
|
||||
sector: 13,
|
||||
}),
|
||||
'discovery-funeral-plain': Object.freeze({
|
||||
type: 'smuggler',
|
||||
territory: 'pasty_mesa',
|
||||
sector: 7,
|
||||
}),
|
||||
'discovery-old-gap': Object.freeze({
|
||||
type: 'smuggler',
|
||||
territory: 'false_wall_west',
|
||||
sector: 17,
|
||||
}),
|
||||
});
|
||||
|
||||
const DISCOVERY_CARD_IDS = Object.freeze(
|
||||
DISCOVERY_SPICE_CARDS.map((card) => card.discovery),
|
||||
);
|
||||
|
||||
export function isDiscoverySpiceCardId(
|
||||
value: unknown,
|
||||
): value is DiscoverySpiceCardId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DISCOVERY_CARD_IDS.includes(value as DiscoverySpiceCardId)
|
||||
);
|
||||
}
|
||||
|
||||
export type DiscoveryTokenStatus =
|
||||
| 'supply'
|
||||
| 'placed'
|
||||
| 'carried'
|
||||
| 'removed';
|
||||
export type DiscoveryOpaqueTokenId = `discovery-token-${number}`;
|
||||
export type DiscoveryTokenId = DiscoveryOpaqueTokenId;
|
||||
export type DiscoveryToken = {
|
||||
/** Stable physical identity. It does not reveal the hidden face. */
|
||||
id: DiscoveryOpaqueTokenId;
|
||||
type: DiscoveryTokenType;
|
||||
face: DiscoveryTokenFace;
|
||||
status: DiscoveryTokenStatus;
|
||||
territory: string | null;
|
||||
sector: number | null;
|
||||
revealedTurn: number | null;
|
||||
/** Caller-authorized faction knowledge; built-in Fremen/Guild peeks need no entry. */
|
||||
known: FactionId[];
|
||||
owner: string | null;
|
||||
acquiredTurn: number | null;
|
||||
};
|
||||
export type DiscoveryState = {
|
||||
version: 1;
|
||||
/** Array order is the private shuffled pool order; projection never exposes it. */
|
||||
tokens: DiscoveryToken[];
|
||||
/** Revealed location IDs awaiting the next-turn force-entry opportunity. */
|
||||
newlyRevealed: DiscoveryOpaqueTokenId[];
|
||||
};
|
||||
|
||||
export type ProjectedDiscoveryToken = Omit<
|
||||
DiscoveryToken,
|
||||
'face' | 'known'
|
||||
> & {
|
||||
face: DiscoveryTokenFace | null;
|
||||
};
|
||||
export type DiscoveryView = {
|
||||
supplyCount: number;
|
||||
tokens: ProjectedDiscoveryToken[];
|
||||
newlyRevealed: DiscoveryOpaqueTokenId[];
|
||||
};
|
||||
|
||||
export type DiscoveryRevealOutcome =
|
||||
| { kind: 'location'; location: DiscoveryLocationId }
|
||||
| { kind: 'treachery-card-stash' }
|
||||
| { kind: 'spice-stash'; amount: 7 }
|
||||
| { kind: 'ornithopter'; owner: string };
|
||||
|
||||
const factionIds = Object.freeze(FACTIONS.map((faction) => faction.id));
|
||||
const tokenKeys = Object.freeze([
|
||||
'acquiredTurn',
|
||||
'face',
|
||||
'id',
|
||||
'known',
|
||||
'owner',
|
||||
'revealedTurn',
|
||||
'sector',
|
||||
'status',
|
||||
'territory',
|
||||
'type',
|
||||
]);
|
||||
|
||||
function requireValid(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function isFactionId(value: unknown): value is FactionId {
|
||||
return (
|
||||
typeof value === 'string' && factionIds.includes(value as FactionId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isDiscoveryTokenId(
|
||||
value: unknown,
|
||||
): value is DiscoveryOpaqueTokenId {
|
||||
return typeof value === 'string' && /^discovery-token-[1-8]$/.test(value);
|
||||
}
|
||||
|
||||
function validTurn(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 1;
|
||||
}
|
||||
|
||||
function validateRandom(random: () => number, count: number): number {
|
||||
const draw = random();
|
||||
requireValid(
|
||||
Number.isFinite(draw) && draw >= 0 && draw < 1,
|
||||
'Discovery random draws must be finite numbers in [0, 1).',
|
||||
);
|
||||
return Math.floor(draw * count);
|
||||
}
|
||||
|
||||
function isPlacement(type: DiscoveryTokenType, territory: string, sector: number) {
|
||||
return Object.values(DISCOVERY_CARD_PLACEMENTS).some(
|
||||
(placement) =>
|
||||
placement.type === type &&
|
||||
placement.territory === territory &&
|
||||
placement.sector === sector,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDiscoveryState(state: DiscoveryState): void {
|
||||
requireValid(
|
||||
state !== null && typeof state === 'object' && !Array.isArray(state),
|
||||
'A Discovery state is required.',
|
||||
);
|
||||
requireValid(
|
||||
Object.keys(state).sort().join(',') === 'newlyRevealed,tokens,version' &&
|
||||
state.version === 1 &&
|
||||
Array.isArray(state.tokens) &&
|
||||
Array.isArray(state.newlyRevealed),
|
||||
'Invalid Discovery state shape or version.',
|
||||
);
|
||||
requireValid(
|
||||
state.tokens.length === DISCOVERY_TOKENS.length,
|
||||
'A Discovery inventory contains eight physical tokens.',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
const faces = new Set<DiscoveryTokenFace>();
|
||||
for (const token of state.tokens) {
|
||||
requireValid(
|
||||
token !== null &&
|
||||
typeof token === 'object' &&
|
||||
!Array.isArray(token) &&
|
||||
Object.keys(token).sort().join(',') === tokenKeys.join(','),
|
||||
'Invalid Discovery token shape.',
|
||||
);
|
||||
requireValid(
|
||||
isDiscoveryTokenId(token.id) && !ids.has(token.id),
|
||||
'Discovery physical IDs must be the eight distinct opaque token IDs.',
|
||||
);
|
||||
ids.add(token.id);
|
||||
requireValid(
|
||||
isDiscoveryTokenFace(token.face) && !faces.has(token.face),
|
||||
'Every Discovery face must appear exactly once.',
|
||||
);
|
||||
faces.add(token.face);
|
||||
const definition = DISCOVERY_TOKEN_BY_ID[token.face];
|
||||
requireValid(
|
||||
token.type === definition.type,
|
||||
'A Discovery token type must match its physical face.',
|
||||
);
|
||||
requireValid(
|
||||
['supply', 'placed', 'carried', 'removed'].includes(token.status),
|
||||
'Invalid Discovery token custody status.',
|
||||
);
|
||||
requireValid(
|
||||
Array.isArray(token.known) &&
|
||||
new Set(token.known).size === token.known.length &&
|
||||
token.known.every(isFactionId),
|
||||
'Discovery private knowledge must contain distinct factions.',
|
||||
);
|
||||
if (token.status === 'supply') {
|
||||
requireValid(
|
||||
token.territory === null &&
|
||||
token.sector === null &&
|
||||
token.revealedTurn === null &&
|
||||
token.known.length === 0 &&
|
||||
token.owner === null &&
|
||||
token.acquiredTurn === null,
|
||||
'A supplied Discovery token cannot retain board, reveal, or owner state.',
|
||||
);
|
||||
} else if (token.status === 'placed') {
|
||||
requireValid(
|
||||
typeof token.territory === 'string' &&
|
||||
token.territory.length > 0 &&
|
||||
Number.isSafeInteger(token.sector) &&
|
||||
isPlacement(token.type, token.territory, token.sector as number) &&
|
||||
(token.revealedTurn === null || validTurn(token.revealedTurn)) &&
|
||||
token.owner === null &&
|
||||
token.acquiredTurn === null,
|
||||
'A placed Discovery token needs a printed destination and valid reveal state.',
|
||||
);
|
||||
requireValid(
|
||||
token.revealedTurn === null || definition.kind === 'location',
|
||||
'Only a location Discovery token remains placed after reveal.',
|
||||
);
|
||||
} else if (token.status === 'carried') {
|
||||
requireValid(
|
||||
token.face === 'ornithopter' &&
|
||||
token.territory === null &&
|
||||
token.sector === null &&
|
||||
validTurn(token.revealedTurn) &&
|
||||
typeof token.owner === 'string' &&
|
||||
token.owner.length > 0 &&
|
||||
token.acquiredTurn === token.revealedTurn,
|
||||
'Only a revealed Ornithopter can be carried by a player.',
|
||||
);
|
||||
} else {
|
||||
requireValid(
|
||||
definition.kind !== 'location' &&
|
||||
token.territory === null &&
|
||||
token.sector === null &&
|
||||
validTurn(token.revealedTurn) &&
|
||||
token.owner === null &&
|
||||
validTurn(token.acquiredTurn),
|
||||
'Only a resolved non-location Discovery token can be removed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
requireValid(
|
||||
faces.size === DISCOVERY_TOKENS.length,
|
||||
'The Discovery inventory must retain every printed face.',
|
||||
);
|
||||
requireValid(
|
||||
new Set(state.newlyRevealed).size === state.newlyRevealed.length,
|
||||
'Newly revealed Discovery locations must be distinct.',
|
||||
);
|
||||
for (const id of state.newlyRevealed) {
|
||||
const token = state.tokens.find((candidate) => candidate.id === id);
|
||||
requireValid(
|
||||
token?.status === 'placed' &&
|
||||
token.revealedTurn !== null &&
|
||||
isDiscoveryLocationId(token.face),
|
||||
'A newly revealed entry must identify a revealed location token.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDiscoveryState(state: DiscoveryState): DiscoveryState {
|
||||
return {
|
||||
version: 1,
|
||||
tokens: state.tokens.map((token) => ({
|
||||
id: token.id,
|
||||
type: token.type,
|
||||
face: token.face,
|
||||
status: token.status,
|
||||
territory: token.territory,
|
||||
sector: token.sector,
|
||||
revealedTurn: token.revealedTurn,
|
||||
known: [...token.known],
|
||||
owner: token.owner,
|
||||
acquiredTurn: token.acquiredTurn,
|
||||
})),
|
||||
newlyRevealed: [...state.newlyRevealed],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscoveryState(random: () => number): DiscoveryState {
|
||||
const faces = [...DISCOVERY_TOKEN_FACES];
|
||||
for (let index = faces.length - 1; index > 0; index--) {
|
||||
const selected = validateRandom(random, index + 1);
|
||||
[faces[index], faces[selected]] = [faces[selected], faces[index]];
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
tokens: faces.map((face, index) => ({
|
||||
id: `discovery-token-${index + 1}`,
|
||||
type: DISCOVERY_TOKEN_BY_ID[face].type,
|
||||
face,
|
||||
status: 'supply',
|
||||
territory: null,
|
||||
sector: null,
|
||||
revealedTurn: null,
|
||||
known: [],
|
||||
owner: null,
|
||||
acquiredTurn: null,
|
||||
})),
|
||||
newlyRevealed: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Caller has already drawn and resolved the printed Discovery spice blow. */
|
||||
export function placeDiscovery(
|
||||
state: DiscoveryState,
|
||||
cardId: DiscoverySpiceCardId,
|
||||
random: () => number,
|
||||
): DiscoveryState {
|
||||
validateDiscoveryState(state);
|
||||
requireValid(
|
||||
isDiscoverySpiceCardId(cardId),
|
||||
'Choose a printed Discovery Spice Card.',
|
||||
);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[cardId];
|
||||
const eligible = state.tokens.filter(
|
||||
(token) => token.status === 'supply' && token.type === placement.type,
|
||||
);
|
||||
requireValid(
|
||||
eligible.length > 0,
|
||||
`No ${placement.type} Discovery token remains in the supply.`,
|
||||
);
|
||||
const selected = eligible[validateRandom(random, eligible.length)];
|
||||
const next = cloneDiscoveryState(state);
|
||||
const token = next.tokens.find((candidate) => candidate.id === selected.id)!;
|
||||
Object.assign(token, {
|
||||
status: 'placed' as const,
|
||||
territory: placement.territory,
|
||||
sector: placement.sector,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Caller authorizes a private look, including an occupying faction's collection look. */
|
||||
export function rememberDiscoveryFace(
|
||||
state: DiscoveryState,
|
||||
tokenId: DiscoveryOpaqueTokenId,
|
||||
faction: FactionId,
|
||||
): DiscoveryState {
|
||||
validateDiscoveryState(state);
|
||||
requireValid(isFactionId(faction), 'Choose a valid faction for Discovery knowledge.');
|
||||
const source = state.tokens.find((token) => token.id === tokenId);
|
||||
requireValid(
|
||||
source?.status === 'placed' && source.revealedTurn === null,
|
||||
'Choose a hidden placed Discovery token to inspect.',
|
||||
);
|
||||
const next = cloneDiscoveryState(state);
|
||||
const token = next.tokens.find((candidate) => candidate.id === tokenId)!;
|
||||
if (!token.known.includes(faction)) token.known.push(faction);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Reveals custody and emits the effect; the caller applies rewards and authorizes the turn. */
|
||||
export function revealDiscoveryToken(
|
||||
state: DiscoveryState,
|
||||
tokenId: DiscoveryOpaqueTokenId,
|
||||
turn: number,
|
||||
owner?: string,
|
||||
): { state: DiscoveryState; outcome: DiscoveryRevealOutcome } {
|
||||
validateDiscoveryState(state);
|
||||
requireValid(validTurn(turn), 'Choose a valid turn for Discovery reveal.');
|
||||
const source = state.tokens.find((token) => token.id === tokenId);
|
||||
requireValid(
|
||||
source?.status === 'placed' && source.revealedTurn === null,
|
||||
'Choose a hidden placed Discovery token to reveal.',
|
||||
);
|
||||
const definition = DISCOVERY_TOKEN_BY_ID[source.face];
|
||||
const next = cloneDiscoveryState(state);
|
||||
const token = next.tokens.find((candidate) => candidate.id === tokenId)!;
|
||||
token.revealedTurn = turn;
|
||||
if (definition.kind === 'location') {
|
||||
next.newlyRevealed.push(token.id);
|
||||
return {
|
||||
state: next,
|
||||
outcome: { kind: 'location', location: definition.location! },
|
||||
};
|
||||
}
|
||||
token.territory = null;
|
||||
token.sector = null;
|
||||
token.acquiredTurn = turn;
|
||||
if (source.face === 'ornithopter') {
|
||||
requireValid(
|
||||
typeof owner === 'string' && owner.length > 0,
|
||||
'An Ornithopter reveal needs its receiving player.',
|
||||
);
|
||||
requireValid(
|
||||
!next.tokens.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'carried' && candidate.owner === owner,
|
||||
),
|
||||
'That player already carries an Ornithopter.',
|
||||
);
|
||||
token.status = 'carried';
|
||||
token.owner = owner;
|
||||
return { state: next, outcome: { kind: 'ornithopter', owner } };
|
||||
}
|
||||
token.status = 'removed';
|
||||
if (source.face === 'spice-stash')
|
||||
return { state: next, outcome: { kind: 'spice-stash', amount: 7 } };
|
||||
return { state: next, outcome: { kind: 'treachery-card-stash' } };
|
||||
}
|
||||
|
||||
export function clearNewlyRevealed(state: DiscoveryState): DiscoveryState {
|
||||
validateDiscoveryState(state);
|
||||
const next = cloneDiscoveryState(state);
|
||||
next.newlyRevealed = [];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Caller authorizes the owner's one movement action before consuming custody. */
|
||||
export function consumeOrnithopter(
|
||||
state: DiscoveryState,
|
||||
owner: string,
|
||||
): DiscoveryState {
|
||||
validateDiscoveryState(state);
|
||||
requireValid(
|
||||
typeof owner === 'string' && owner.length > 0,
|
||||
'Choose an Ornithopter owner.',
|
||||
);
|
||||
const carried = state.tokens.filter(
|
||||
(token) => token.status === 'carried' && token.owner === owner,
|
||||
);
|
||||
requireValid(
|
||||
carried.length === 1,
|
||||
'That player does not carry exactly one Ornithopter.',
|
||||
);
|
||||
const next = cloneDiscoveryState(state);
|
||||
const token = next.tokens.find((candidate) => candidate.id === carried[0].id)!;
|
||||
token.status = 'removed';
|
||||
token.owner = null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supply order and faces are never projected. Hidden placed faces are visible
|
||||
* only to recorded viewers or the type's printed privileged faction.
|
||||
*/
|
||||
export function projectDiscoveryState(
|
||||
state: DiscoveryState,
|
||||
viewerFaction?: FactionId,
|
||||
): DiscoveryView {
|
||||
validateDiscoveryState(state);
|
||||
requireValid(
|
||||
viewerFaction === undefined || isFactionId(viewerFaction),
|
||||
'Choose a valid faction for the Discovery view.',
|
||||
);
|
||||
return {
|
||||
supplyCount: state.tokens.filter((token) => token.status === 'supply').length,
|
||||
tokens: state.tokens
|
||||
.filter((token) => token.status !== 'supply')
|
||||
.map((token) => {
|
||||
const privileged =
|
||||
(viewerFaction === 'fremen' && token.type === 'hiereg') ||
|
||||
(viewerFaction === 'guild' && token.type === 'smuggler');
|
||||
const faceVisible =
|
||||
token.revealedTurn !== null ||
|
||||
privileged ||
|
||||
(viewerFaction !== undefined && token.known.includes(viewerFaction));
|
||||
return {
|
||||
id: token.id,
|
||||
type: token.type,
|
||||
face: faceVisible ? token.face : null,
|
||||
status: token.status,
|
||||
territory: token.territory,
|
||||
sector: token.sector,
|
||||
revealedTurn: token.revealedTurn,
|
||||
owner: token.owner,
|
||||
acquiredTurn: token.acquiredTurn,
|
||||
};
|
||||
}),
|
||||
newlyRevealed: [...state.newlyRevealed],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Game } from './engine';
|
||||
import { fighterCount } from './advisors';
|
||||
import { projectDiscoveryState } from './discoveries';
|
||||
|
||||
export type DiscoveryStash = {
|
||||
event: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
card: string;
|
||||
hand: string[];
|
||||
stage: 'choose' | 'discard' | 'complete';
|
||||
discarded: string | null;
|
||||
signature: string;
|
||||
};
|
||||
export function discoveryStashSignature(record: DiscoveryStash) {
|
||||
return JSON.stringify([
|
||||
record.event,
|
||||
record.token,
|
||||
record.owner,
|
||||
record.turn,
|
||||
record.card,
|
||||
record.hand,
|
||||
record.stage,
|
||||
record.discarded,
|
||||
]);
|
||||
}
|
||||
/** A private view of physical tokens and the current player's Collection choices. */
|
||||
export function discoveryChoices(g: Game, owner: string, automatic: boolean) {
|
||||
const p = g.players.find((player) => player.id === owner);
|
||||
if (!g.discoveries || !p) return null;
|
||||
const view = projectDiscoveryState(g.discoveries, p.faction);
|
||||
const blocked =
|
||||
g.status !== 'playing' || g.phase !== 7
|
||||
? 'Inspect or reveal during Spice Collection.'
|
||||
: automatic ||
|
||||
g.truthtrance ||
|
||||
g.response ||
|
||||
g.decision ||
|
||||
g.phaseOpening ||
|
||||
g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard ||
|
||||
g.pendingNullentropy ||
|
||||
g.pendingExchange ||
|
||||
g.pendingAmbassador ||
|
||||
g.pendingRicheseGift ||
|
||||
g.pendingRichesePurchaseIncome ||
|
||||
g.nexusCards?.phase?.stage === 'drawing' ||
|
||||
g.nexusTraitorExchanges?.some((record) => record.stage === 'return')
|
||||
? 'Finish the current interaction first.'
|
||||
: null;
|
||||
const eligible = view.tokens.filter(
|
||||
(token) =>
|
||||
token.status === 'placed' &&
|
||||
token.revealedTurn === null &&
|
||||
token.territory &&
|
||||
fighterCount(p, token.territory) > 0,
|
||||
);
|
||||
return {
|
||||
...view,
|
||||
blocked,
|
||||
canInspect: blocked
|
||||
? []
|
||||
: eligible
|
||||
.filter((token) => token.face === null)
|
||||
.map((token) => token.id),
|
||||
canReveal: blocked
|
||||
? []
|
||||
: eligible
|
||||
.filter((token) => token.face !== null)
|
||||
.map((token) => token.id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Human and AI choices use only the requesting seat's quoted physical tokens. */
|
||||
export function discoveryAction(game: GameView, token: string, reveal: boolean): Action | null {
|
||||
const offer = game.discoveries;
|
||||
if (!offer || offer.blocked || game.status !== 'playing' || game.phase !== 7 ||
|
||||
game.automaticContinuationPending || game.truthtrance || game.response || game.decision ||
|
||||
typeof reveal !== 'boolean' ||
|
||||
!(reveal ? offer.canReveal : offer.canInspect).some(id => id === token)) return null;
|
||||
return { type: 'discovery', token, reveal };
|
||||
}
|
||||
|
||||
/** The stash has already drawn its card. Any card in this seat's hand may leave. */
|
||||
export function discoveryDiscardAction(game: GameView, card: string): Action | null {
|
||||
const decision = game.decision;
|
||||
if (game.status !== 'playing' || decision?.kind !== 'discoveryDiscard' ||
|
||||
decision.player !== game.me || game.truthtrance || game.response ||
|
||||
game.automaticContinuationPending ||
|
||||
!game.players.find(player => player.id === game.me)?.hand?.some(held => held.id === card)) return null;
|
||||
return { type: 'decision', event: decision.event, card };
|
||||
}
|
||||
|
||||
/** Inspect first, then reconsider the newly visible face through a fresh view. */
|
||||
export function discoveryBotActions(game: GameView): Action[] {
|
||||
if (game.decision?.kind === 'discoveryDiscard') {
|
||||
const hand = game.players.find(player => player.id === game.me)?.hand ?? [];
|
||||
return [...hand]
|
||||
.sort((a, b) => Number(b.kind === 'worthless') - Number(a.kind === 'worthless'))
|
||||
.flatMap(card => {
|
||||
const action = discoveryDiscardAction(game, card.id);
|
||||
return action ? [action] : [];
|
||||
});
|
||||
}
|
||||
const offer = game.discoveries;
|
||||
if (!offer) return [];
|
||||
return [
|
||||
...offer.canInspect.map(token => discoveryAction(game, token, false)),
|
||||
...offer.canReveal.map(token => discoveryAction(game, token, true)),
|
||||
].filter((action): action is Action => action !== null);
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { isDiscoveryLocationId } from './discoveries';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { MOBILE_STRONGHOLD, TERRITORIES, validLocation } from './board';
|
||||
|
||||
@@ -67,7 +68,7 @@ function validate(
|
||||
if (at.kind === 'homeworld') return;
|
||||
requireOccupancy(
|
||||
at.kind === 'territory' &&
|
||||
(at.id === MOBILE_STRONGHOLD ||
|
||||
(at.id === MOBILE_STRONGHOLD || isDiscoveryLocationId(at.id) ||
|
||||
TERRITORIES.some((t) => t.id === at.id)) &&
|
||||
(at.sector === undefined ||
|
||||
(Number.isSafeInteger(at.sector) && validLocation(at.id, at.sector))),
|
||||
|
||||
+310
-23
@@ -1,3 +1,6 @@
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
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';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
@@ -484,7 +487,7 @@ import {
|
||||
location,
|
||||
splitLocation,
|
||||
distance,
|
||||
validLocation,
|
||||
validGameLocation,
|
||||
} from './board';
|
||||
export type Player = {
|
||||
id: string;
|
||||
@@ -743,6 +746,9 @@ export type Decision =
|
||||
}
|
||||
| { kind: 'wormPlacement'; player: string }
|
||||
| { kind: 'wormProtection'; player: string; territory: string; ally: string }
|
||||
| { kind: 'discoveryDiscard'; player: string; event: string }
|
||||
| { kind: 'greatMakerVote'; player: string; event: string }
|
||||
| { kind: 'greatMakerRide'; player: string; event: string }
|
||||
| { kind: 'wormRide'; player: string; territory: string }
|
||||
| {
|
||||
kind: 'moritaniRetention';
|
||||
@@ -884,6 +890,10 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
discoveryEnabled?: boolean;
|
||||
discoveries?: DiscoveryState;
|
||||
discoveryStash?: DiscoveryStash;
|
||||
greatMaker?: GreatMaker;
|
||||
nexusChoamTrades?: NexusChoamTrade[];
|
||||
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
|
||||
treacheryDiscardSequence?: number;
|
||||
@@ -894,6 +904,7 @@ export type Game = {
|
||||
batch: FreshDiscardBatch;
|
||||
continuation:
|
||||
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
|
||||
| { kind: 'discoveryStash'; event: string; owner: string; card: string }
|
||||
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
|
||||
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
|
||||
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable<Game['pendingWinnerDiscards']> }
|
||||
@@ -2464,7 +2475,7 @@ function alliedNoFieldQuote(
|
||||
);
|
||||
requireRule(
|
||||
offer.territory !== MOBILE_STRONGHOLD &&
|
||||
validLocation(offer.territory, offer.sector),
|
||||
validGameLocation(g, offer.territory, offer.sector),
|
||||
'Choose a printed planet sector for the allied No-Field shipment.',
|
||||
);
|
||||
// Test the compulsory old-marker reveal without exposing or mutating it before acceptance.
|
||||
@@ -3963,7 +3974,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
m.player === owner.id &&
|
||||
owner.shipped &&
|
||||
gameTerritories(g).some((t) => t.id === m.origin) &&
|
||||
validLocation(m.to, m.sector) &&
|
||||
validGameLocation(g, m.to, m.sector) &&
|
||||
Number.isSafeInteger(m.total) &&
|
||||
m.total > 0 &&
|
||||
typeof m.noField === 'boolean' &&
|
||||
@@ -4050,7 +4061,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
owner?.faction === 'moritani' &&
|
||||
entrant &&
|
||||
entrant.id !== owner.id &&
|
||||
terrorEntryLocationAllowed(g,entry) && validLocation(entry.territory,entry.sector) &&
|
||||
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory,entry.sector) &&
|
||||
[
|
||||
'shipment',
|
||||
'movement',
|
||||
@@ -4315,6 +4326,12 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length &&
|
||||
c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)),
|
||||
'The mandatory winning card discard no longer matches its resolved battle.');
|
||||
} else if (continuation?.kind === 'discoveryStash') {
|
||||
const c = continuation;
|
||||
discoveryIntegrity(g);
|
||||
requireRule(batch.cause === 'discovery:stash' && batch.entries.length === 1 &&
|
||||
batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card,
|
||||
'The Treachery Card Stash has lost its chosen discard.');
|
||||
} else if (continuation?.kind === 'nexusChoamTrade') {
|
||||
const c = continuation;
|
||||
nexusChoamTradeIntegrity(g);
|
||||
@@ -4370,6 +4387,11 @@ function finishTreacheryDiscard(g: Game) {
|
||||
finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'discoveryStash') {
|
||||
g.discoveryStash!.stage = 'complete';
|
||||
g.discoveryStash!.signature = discoveryStashSignature(g.discoveryStash!);
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'nexusChoamTrade') {
|
||||
const record = g.nexusChoamTrades!.at(-1)!;
|
||||
record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record);
|
||||
@@ -4799,6 +4821,7 @@ function start(g: Game) {
|
||||
requireRule(!g.advanced, 'Advanced rules are still being implemented.');
|
||||
requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
|
||||
requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
|
||||
requireRule(!g.discoveryEnabled, 'Discoveries are still being implemented.');
|
||||
requireRule(
|
||||
g.players.every((p) => faction(p.faction).expansion === 'base'),
|
||||
'Expansion factions are still being implemented.',
|
||||
@@ -4810,6 +4833,7 @@ function start(g: Game) {
|
||||
initializeSetup(g);
|
||||
}
|
||||
function initializeSetup(g: Game) {
|
||||
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
||||
if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
|
||||
@@ -4835,7 +4859,7 @@ function initializeSetup(g: Game) {
|
||||
g.stormDialers = [g.order[0], g.order.at(-1)!];
|
||||
g.lastBattle = [...g.stormDialers];
|
||||
g.deck = shuffle(treacheryDeck(g.expansions));
|
||||
g.spiceDeck = shuffle(spiceDeck(g.expansions.includes('ix')));
|
||||
g.spiceDeck = shuffle([...spiceDeck(g.expansions.includes('ix')), ...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, {worm:true as const, greatMaker:true as const}] : [])]);
|
||||
g.sandtrout = false;
|
||||
g.status = 'setup';
|
||||
g.setupStage = 'prediction';
|
||||
@@ -4998,7 +5022,13 @@ export function initializeIxGameForAudit(state: Game): Game {
|
||||
'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.');
|
||||
return initializeSetupGameForAudit(state, false, false, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false): Game {
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
|
||||
'Enable Discoveries in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, false, false, false, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -5021,6 +5051,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
);
|
||||
requireRule(
|
||||
(homeworlds || nexus || ix || g.expansions.length === 0) &&
|
||||
(discovery || !g.discoveryEnabled) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
!g.techTokens &&
|
||||
!g.strongholdCards &&
|
||||
@@ -5341,7 +5372,7 @@ function refillSpice(g: Game) {
|
||||
g.spiceDiscard
|
||||
.flat()
|
||||
.filter((c) => !('worm' in c && c.thumper))
|
||||
.map((c) => ('worm' in c ? { worm: true as const } : c)),
|
||||
.map((c) => ('worm' in c ? { worm: true as const, ...(c.greatMaker ? {greatMaker: true as const} : {}) } : c)),
|
||||
);
|
||||
g.spiceDiscard = [[], []];
|
||||
}
|
||||
@@ -5395,6 +5426,11 @@ function wormSurvival(g: Game, t: string, protectedAlly?: string) {
|
||||
}
|
||||
}
|
||||
function afterWorm(g: Game) {
|
||||
if (g.greatMaker?.stage === 'worm') {
|
||||
g.wormRides = [...g.greatMaker.ridesBefore];
|
||||
openGreatMakerVote(g);
|
||||
return;
|
||||
}
|
||||
const pending = g.summonedWorm;
|
||||
if (!pending) {
|
||||
continueSpice(g);
|
||||
@@ -5441,6 +5477,226 @@ function afterWorm(g: Game) {
|
||||
'The summoned worm has resolved. Its Nexus occurs after the spice blow.',
|
||||
);
|
||||
}
|
||||
function discoveryIntegrity(g: Game) {
|
||||
if (!g.discoveryEnabled) {
|
||||
requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.');
|
||||
return;
|
||||
}
|
||||
if (g.status === 'lobby') {
|
||||
requireRule(!g.discoveries && !g.discoveryStash, 'Discovery components are dealt during setup.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.discoveries, 'The Discovery module has lost its physical tokens.');
|
||||
nexusRule(() => validateDiscoveryState(g.discoveries!));
|
||||
for (const token of g.discoveries.tokens) requireRule(
|
||||
(token.revealedTurn === null || token.revealedTurn <= g.turn) &&
|
||||
(token.acquiredTurn === null || token.acquiredTurn <= g.turn) &&
|
||||
(token.owner === null || g.players.some(player => player.id === token.owner)),
|
||||
'A Discovery token has lost its receiving player or original turn.');
|
||||
const record = g.discoveryStash, continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const pending = continuation?.kind === 'discoveryStash' ? continuation : null;
|
||||
const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
if (!record) {
|
||||
requireRule(!pending && !controls.some(control => control?.decision?.kind === 'discoveryDiscard'),
|
||||
'The Discovery discard has lost its original draw.');
|
||||
return;
|
||||
}
|
||||
const p = g.players.find(player => player.id === record.owner);
|
||||
requireRule(p && Object.keys(record).sort().join(',') === 'card,discarded,event,hand,owner,signature,stage,token,turn' &&
|
||||
typeof record.event === 'string' && record.event && Number.isSafeInteger(record.turn) && record.turn >= 1 && record.turn <= g.turn &&
|
||||
['choose','discard','complete'].includes(record.stage) && Array.isArray(record.hand) &&
|
||||
record.hand.every(id => typeof id === 'string') && new Set(record.hand).size === record.hand.length && record.hand.includes(record.card) &&
|
||||
g.discoveries.tokens.some(token => token.id === record.token && token.face === 'treachery-card-stash' &&
|
||||
token.status === 'removed' && token.revealedTurn === record.turn) &&
|
||||
record.signature === discoveryStashSignature(record) &&
|
||||
(record.stage === 'choose' ? record.discarded === null : record.discarded === null || record.hand.includes(record.discarded)) &&
|
||||
(record.stage === 'discard') === !!pending,
|
||||
'The Treachery Card Stash has lost its original draw or selected discard.');
|
||||
const overflow = record.hand.length === handLimit(p) + 1;
|
||||
requireRule(record.hand.length >= 1 && record.hand.length <= handLimit(p) + 1 &&
|
||||
(record.stage !== 'complete' ? overflow : overflow === (record.discarded !== null)),
|
||||
'The Treachery Card Stash must complete its original overflow discard.');
|
||||
if (record.stage === 'complete') {
|
||||
requireRule(!controls.some(control => control?.decision?.kind === 'discoveryDiscard'),
|
||||
'The completed stash cannot retain a discard choice.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.status === 'playing' && g.turn === record.turn && g.phase === 7 &&
|
||||
JSON.stringify(p.hand.map(card => card.id).sort()) === JSON.stringify(record.hand.filter(id => id !== record.discarded).sort()),
|
||||
'Finish the original Treachery Card Stash hand before another card change.');
|
||||
if (record.stage === 'choose') requireRule(controls.some(control => control?.decision?.kind === 'discoveryDiscard' &&
|
||||
control.decision.player === p.id && control.decision.event === record.event), 'The stash has lost its owned discard choice.');
|
||||
if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded,
|
||||
'The stash has changed its committed discard.');
|
||||
}
|
||||
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)!;
|
||||
requireRule(card.territory === printed.territory && card.sector === printed.sector && card.amount === printed.amount,
|
||||
'The Discovery spice blow has changed its printed territory, sector or amount.');
|
||||
disasterPreflight(() => validateWormDevouring(g,card.territory,undefined,false));
|
||||
for (const p of g.players) killTerritory(g,p,card.territory);
|
||||
for (const key of Object.keys(g.spice)) if (splitLocation(key).territory === card.territory) delete g.spice[key];
|
||||
log(g, `A Discovery spice blow destroyed all previous spice and forces in ${territory(card.territory).name}, including Fremen forces. The new spice is placed afterward.`);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery];
|
||||
if (g.discoveries.tokens.some(token => token.type === placement.type && token.status === 'supply')) {
|
||||
g.discoveries = nexusRule(() => placeDiscovery(g.discoveries!,card.discovery as Parameters<typeof placeDiscovery>[1],random));
|
||||
log(g, `A face-down ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery was placed in ${territory(placement.territory).name}, sector ${placement.sector}.`);
|
||||
} else log(g, `The ${placement.type} Discovery supply is empty; no additional physical token can be placed.`);
|
||||
}
|
||||
function playDiscovery(g: Game, p: Player, action: Action) {
|
||||
const offer = discoveryChoices(g,p.id,g.phase === 7 && grummanCollectionAutomatic(g));
|
||||
requireRule(offer && !offer.blocked && typeof action.reveal === 'boolean' && isDiscoveryTokenId(action.token) &&
|
||||
Object.keys(action).every(key => ['type','token','reveal'].includes(key)) &&
|
||||
(action.reveal ? offer.canReveal : offer.canInspect).includes(action.token),
|
||||
offer?.blocked ?? 'Choose an eligible hidden Discovery in a territory containing your non-advisor forces.');
|
||||
if (!action.reveal) {
|
||||
g.discoveries = nexusRule(() => rememberDiscoveryFace(g.discoveries!,action.token as Parameters<typeof rememberDiscoveryFace>[1],p.faction));
|
||||
return;
|
||||
}
|
||||
const token = g.discoveries!.tokens.find(token => token.id === action.token)!;
|
||||
if (token.face === 'spice-stash') requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice+7), 'The stash needs a valid spice balance.');
|
||||
if (token.face === 'treachery-card-stash') requireRule(g.deck.length + g.discard.length > 0, 'No Treachery card is available to draw.');
|
||||
const revealed = nexusRule(() => revealDiscoveryToken(g.discoveries!,token.id,g.turn,p.id));
|
||||
g.discoveries = revealed.state;
|
||||
g.ready = [];
|
||||
log(g, `${p.name} revealed ${DISCOVERY_TOKEN_BY_ID[token.face].name} in ${territory(token.territory!).name}.`);
|
||||
if (revealed.outcome.kind === 'spice-stash') {
|
||||
p.spice += revealed.outcome.amount;
|
||||
log(g, `${p.name} gained 7 spice from the bank. The Spice Stash was removed from the game.`);
|
||||
} else if (revealed.outcome.kind === 'treachery-card-stash') {
|
||||
const card = draw(g)!;
|
||||
p.hand.push(card);
|
||||
const overflow = p.hand.length > handLimit(p);
|
||||
g.discoveryStash = {event:crypto.randomUUID(), token:token.id, owner:p.id, turn:g.turn, card:card.id,
|
||||
hand:p.hand.map(card => card.id), stage:overflow ? 'choose' : 'complete', discarded:null, signature:''};
|
||||
g.discoveryStash.signature = discoveryStashSignature(g.discoveryStash);
|
||||
if (overflow) g.decision = {kind:'discoveryDiscard',player:p.id,event:g.discoveryStash.event};
|
||||
log(g, `${p.name} drew one private Treachery card from the stash${overflow ? ' and must now discard any one card from the full hand' : ''}. The stash was removed from the game.`);
|
||||
} else if (revealed.outcome.kind === 'ornithopter') {
|
||||
log(g, `${p.name} took the Ornithopter token; it can be spent on a later turn for one movement action.`);
|
||||
} else log(g, `${DISCOVERY_TOKEN_BY_ID[token.face].name} is now a separate, initially unoccupied location within ${territory(token.territory!).name}.`);
|
||||
}
|
||||
function decideDiscoveryDiscard(g: Game, p: Player, action: Action) {
|
||||
const record = g.discoveryStash;
|
||||
requireRule(record?.stage === 'choose' && record.owner === p.id && action.event === record.event &&
|
||||
typeof action.card === 'string' && record.hand.includes(action.card) &&
|
||||
Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Choose any card from your original stash hand to discard.');
|
||||
const card = discard(g,p,action.card);
|
||||
record.discarded = card.id; record.stage = 'discard'; record.signature = discoveryStashSignature(record);
|
||||
stageTreacheryDiscard(g,'discovery:stash',[{card,discardedBy:p.id,publicFace:true}],
|
||||
{kind:'discoveryStash',event:record.event,owner:p.id,card:card.id});
|
||||
log(g, `${p.name} discarded ${card.name} after opening the Treachery Card Stash.`);
|
||||
}
|
||||
|
||||
function greatMakerRideOptions(g: Game, owner: string) {
|
||||
const p = getPlayer(g,owner), frame = g.greatMaker;
|
||||
if (frame?.stage !== 'ride' || p.faction !== 'fremen') return null;
|
||||
const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => {
|
||||
try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; }
|
||||
catch (error) { if (error instanceof RuleError) return []; throw error; }
|
||||
}));
|
||||
return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations};
|
||||
}
|
||||
function stampGreatMaker(g: Game) {
|
||||
g.greatMaker!.signature = greatMakerSignature(g.greatMaker!);
|
||||
}
|
||||
function greatMakerIntegrity(g: Game) {
|
||||
const frame = g.greatMaker, 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 owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide');
|
||||
if (!frame) {
|
||||
requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.discoveryEnabled && g.discoveries, 'The Great Maker requires its physical Discovery module.');
|
||||
nexusRule(() => validateGreatMaker(frame, g.turn, g.players));
|
||||
if (frame.stage === 'complete') {
|
||||
requireRule(owned.length === 0, 'The completed Great Maker cannot retain another vote or reserve ride.');
|
||||
return;
|
||||
}
|
||||
if (frame.arrival) requireRule(validGameLocation(g,frame.arrival.territory,frame.arrival.sector), 'The Great Maker has lost its declared reserve destination.');
|
||||
const card = g.spiceDiscard[frame.pile][frame.index];
|
||||
requireRule(g.phase === 1 && frame.turn === g.turn && g.spiceSequence?.pile === frame.pile &&
|
||||
card && 'worm' in card && card.greatMaker && !card.suppressed &&
|
||||
frame.order.join('|') === g.order.join('|'), 'The Great Maker has lost its current spice encounter.');
|
||||
if (frame.stage === 'vote' || frame.stage === 'ride') {
|
||||
const kind = frame.stage === 'vote' ? 'greatMakerVote' : 'greatMakerRide';
|
||||
const owner = frame.stage === 'vote' ? frame.order[frame.votes.length] : byFaction(g, 'fremen')?.id;
|
||||
requireRule(owned.length === 1 && owned[0]?.decision?.kind === kind && owned[0].decision.player === owner && owned[0].decision.event === frame.event,
|
||||
'The Great Maker has lost its owned vote or reserve ride.');
|
||||
} else {
|
||||
requireRule(owned.length === 0, 'Resolve the current Great Maker interaction before voting or riding.');
|
||||
const responses = 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];
|
||||
});
|
||||
if (frame.stage === 'worm') requireRule(
|
||||
controls.some(control => control?.decision?.kind === 'wormProtection' && control.decision.territory === frame.territory && control.decision.player === byFaction(g,'fremen')?.id && control.decision.ally === byFaction(g,'fremen')?.ally) ||
|
||||
responses.some(response => (response?.kind === 'wormSurvival' || response?.kind === 'wormAllyProtection') && response.location === frame.territory && response.owner === byFaction(g,'fremen')?.id &&
|
||||
(!response.recipient || response.recipient === byFaction(g,'fremen')?.ally)),
|
||||
'The Great Maker has lost its pending worm survival or protection interaction.');
|
||||
if (frame.stage === 'arrival') requireRule(
|
||||
controls.some(control => control?.decision?.kind === 'intrusion' && control.decision.wormRide && control.decision.player === byFaction(g,'beneGesserit')?.id && control.decision.territory === frame.arrival!.territory) ||
|
||||
responses.some(response => response?.kind === 'advisorFlip' && response.advisorResume === 'wormRide' && response.owner === byFaction(g,'beneGesserit')?.id && response.location === frame.arrival!.territory) ||
|
||||
(g.pendingAmbassador?.resume === 'wormRide' && g.pendingAmbassador.territory === frame.arrival!.territory && g.pendingAmbassador.sector === frame.arrival!.sector) ||
|
||||
(g.pendingTerrorEntry?.resume === 'wormRide' && g.pendingTerrorEntry.territory === frame.arrival!.territory && g.pendingTerrorEntry.sector === frame.arrival!.sector),
|
||||
'The Great Maker has lost its reserve arrival interaction.');
|
||||
}
|
||||
}
|
||||
function openGreatMakerVote(g: Game) {
|
||||
const frame = g.greatMaker!;
|
||||
frame.stage = 'vote'; stampGreatMaker(g);
|
||||
g.decision = {kind:'greatMakerVote', player:frame.order[frame.votes.length], event:frame.event};
|
||||
}
|
||||
function completeGreatMaker(g: Game) {
|
||||
g.greatMaker!.stage = 'complete'; stampGreatMaker(g);
|
||||
continueSpice(g);
|
||||
}
|
||||
function decideGreatMakerVote(g: Game, p: Player, action: Action) {
|
||||
const frame = g.greatMaker!;
|
||||
requireRule(frame?.stage === 'vote' && frame.event === action.event &&
|
||||
frame.order[frame.votes.length] === p.id && typeof action.yes === 'boolean' &&
|
||||
Object.keys(action).every(key => ['type','event','yes'].includes(key)), 'Cast the current Great Maker vote in storm order.');
|
||||
frame.votes.push({player:p.id, yes:action.yes});
|
||||
log(g, `${p.name} voted ${action.yes ? 'yes' : 'no'} to a Great Maker Nexus.`);
|
||||
if (frame.votes.length < frame.order.length) { openGreatMakerVote(g); return; }
|
||||
if (greatMakerMajority(frame)) {
|
||||
g.nexus = true; markNexusOccurred(g);
|
||||
log(g, 'A majority voted yes: the Great Maker produces a Nexus.');
|
||||
} else log(g, 'The Great Maker vote has no yes majority, so this encounter adds no Nexus.');
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (fremen && fremen.reserves > 0) {
|
||||
frame.stage = 'ride'; stampGreatMaker(g);
|
||||
g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event};
|
||||
} else completeGreatMaker(g);
|
||||
}
|
||||
function decideGreatMakerRide(g: Game, p: Player, action: Action) {
|
||||
const frame = g.greatMaker!;
|
||||
requireRule(frame?.stage === 'ride' && frame.event === action.event && p.faction === 'fremen' && typeof action.accept === 'boolean',
|
||||
'Choose the current Great Maker reserve ride.');
|
||||
if (!action.accept) {
|
||||
requireRule(Object.keys(action).every(key => ['type','event','accept'].includes(key)), 'Declining a ride selects no forces or destination.');
|
||||
log(g, `${p.name} left the optional Great Maker riders in reserves.`);
|
||||
completeGreatMaker(g); return;
|
||||
}
|
||||
requireRule(Object.keys(action).every(key => ['type','event','accept','territory','sector','amount','elite'].includes(key)),
|
||||
'Choose only a destination and your Great Maker reserve forces.');
|
||||
const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector');
|
||||
const amount = integer(action.amount,1,p.reserves,'Riding forces');
|
||||
const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite);
|
||||
allowedEntry(g,p,to,sector);
|
||||
withdrawNativeReserves(g,p,amount,elite);
|
||||
place(p,to,sector,amount,elite);
|
||||
frame.arrival = {territory:to,sector,amount,elite};
|
||||
frame.stage = 'arrival'; stampGreatMaker(g);
|
||||
log(g, `${p.name} rode the Great Maker with ${amount} reserve forces to ${territory(to).name}. This spends no shipment, movement or spice.`);
|
||||
const intruded = intrusion(g,p,to,{wormRide:true});
|
||||
if (openTerritoryEntry(g,p,to,sector,amount,elite,'wormRide','wormRide') || intruded) return;
|
||||
completeGreatMaker(g);
|
||||
}
|
||||
function blowSpice(g: Game, thumper = false) {
|
||||
g.nexus = !!g.summonedBeforeBlow;
|
||||
if (!g.summonedBeforeBlow) g.wormRides = [];
|
||||
@@ -5490,6 +5746,16 @@ function continueSpice(g: Game, injected?: SpiceCard) {
|
||||
g,
|
||||
'Sandtrout suppressed Shai-Hulud. Only an immediate territory replacement receives double spice.',
|
||||
);
|
||||
} else if (card.greatMaker) {
|
||||
g.spiceDiscard[sequence.pile].push(card);
|
||||
g.greatMaker = {arrival:null,event:crypto.randomUUID(),turn:g.turn,pile:sequence.pile,
|
||||
index:g.spiceDiscard[sequence.pile].length-1,
|
||||
territory:previous && 'territory' in previous ? previous.territory : null,
|
||||
stage:'worm',order:[...g.order],votes:[],ridesBefore:[...g.wormRides],signature:''};
|
||||
stampGreatMaker(g);
|
||||
if (g.greatMaker.territory) beginWorm(g,g.greatMaker.territory);
|
||||
else openGreatMakerVote(g);
|
||||
return;
|
||||
} else {
|
||||
g.nexus = true;
|
||||
markNexusOccurred(g);
|
||||
@@ -5510,6 +5776,7 @@ function continueSpice(g: Game, injected?: SpiceCard) {
|
||||
card = drawSpice(g);
|
||||
}
|
||||
if (card && 'territory' in card) {
|
||||
if (card.discovery) resolveDiscoveryBlow(g, card);
|
||||
g.spiceDiscard[sequence.pile].push(card);
|
||||
const amount = card.amount * (doubleNext ? 2 : 1);
|
||||
g.spiceWindow = { ...card, amount, harvested: false };
|
||||
@@ -5545,6 +5812,7 @@ function finishSpicePass(g: Game) {
|
||||
}
|
||||
}
|
||||
function nextWormRide(g: Game) {
|
||||
if (g.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; }
|
||||
if (g.summonedNexusBeforeRides) {
|
||||
g.summonedNexusBeforeRides = false;
|
||||
g.nexus = true;
|
||||
@@ -7311,7 +7579,7 @@ function allowedEntry(
|
||||
'The mobile stronghold has not been placed.',
|
||||
);
|
||||
requireRule(
|
||||
validLocation(t, s),
|
||||
validGameLocation(g, t, s),
|
||||
'Choose a sector belonging to this territory.',
|
||||
);
|
||||
requireRule(
|
||||
@@ -7337,7 +7605,7 @@ function eliteChoice(
|
||||
'Elite forces',
|
||||
);
|
||||
}
|
||||
function forceGroup(p: Player, action: Action) {
|
||||
function forceGroup(g: Game, p: Player, action: Action) {
|
||||
let entries: [string, unknown][];
|
||||
if (action.forces !== undefined) {
|
||||
requireRule(
|
||||
@@ -7371,7 +7639,7 @@ function forceGroup(p: Player, action: Action) {
|
||||
for (const [key, value] of entries) {
|
||||
const src = splitLocation(key);
|
||||
requireRule(
|
||||
validLocation(src.territory, src.sector),
|
||||
validGameLocation(g, src.territory, src.sector),
|
||||
'Invalid source sector.',
|
||||
);
|
||||
const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
|
||||
@@ -7739,7 +8007,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
'The movement is no longer available.',
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
const selected = forceGroup(p, {
|
||||
const selected = forceGroup(g, p, {
|
||||
type: 'move',
|
||||
forces: Object.fromEntries(move.group),
|
||||
eliteForces: move.eliteGroup,
|
||||
@@ -8526,7 +8794,7 @@ function currentGuildAmbassador(g: Game, event?: string) {
|
||||
entrant &&
|
||||
entrant.id !== owner.id &&
|
||||
entrant.id !== beneficiary.id &&
|
||||
validLocation(entry.territory, entry.sector) &&
|
||||
validGameLocation(g, entry.territory, entry.sector) &&
|
||||
territory(entry.territory).type === 'stronghold' &&
|
||||
validAmbassadorResume(g, entry) &&
|
||||
entry.relocation === undefined &&
|
||||
@@ -15734,7 +16002,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
|
||||
'Use the offered tariff and explicit physical forces without concealed tokens.');
|
||||
const destination = stringField(action.destination);
|
||||
const context = {...homeworldContext(g), storm: g.storm,
|
||||
const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries,
|
||||
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
|
||||
mobileStronghold: g.mobileStronghold?.location ?? null,
|
||||
board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}};
|
||||
@@ -15809,7 +16077,7 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
|
||||
};
|
||||
const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
|
||||
if (intent.route === 'arrakis') {
|
||||
const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm,
|
||||
const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries,
|
||||
mobileStronghold: g.mobileStronghold?.location ?? null,
|
||||
board: Object.fromEntries(g.players.map((p) => [p.id, {forces: p.forces, eliteForces: p.elites?.forces ?? {}}])),
|
||||
}, g.homeworlds!.custody!, order));
|
||||
@@ -16905,7 +17173,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
!!marker &&
|
||||
location(marker.location.territory, marker.location.sector) === key;
|
||||
requireRule(
|
||||
validLocation(loc.territory, loc.sector) &&
|
||||
validGameLocation(g, loc.territory, loc.sector) &&
|
||||
((other.forces[key] ?? 0) > 0 || concealed),
|
||||
'Choose an occupied board sector.',
|
||||
);
|
||||
@@ -17646,6 +17914,8 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -17797,6 +18067,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileShipmentPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
discoveryIntegrity(g);
|
||||
greatMakerIntegrity(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
@@ -17892,6 +18164,8 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -17920,6 +18194,8 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileShipmentPromises(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
discoveryIntegrity(g);
|
||||
greatMakerIntegrity(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
@@ -18011,6 +18287,7 @@ function applyActionInner(
|
||||
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
|
||||
finishNexusTraitorReturn(g, p, action); return g;
|
||||
}
|
||||
if (t === 'discovery') { playDiscovery(g, p, action); return g; }
|
||||
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
@@ -18393,7 +18670,7 @@ function applyActionInner(
|
||||
requireRule(
|
||||
TERRITORIES.some((t) => t.id === to) &&
|
||||
to !== MOBILE_STRONGHOLD &&
|
||||
validLocation(to, sector),
|
||||
validGameLocation(g, to, sector),
|
||||
'Choose a printed territory and sector.',
|
||||
);
|
||||
requireRule(
|
||||
@@ -18869,7 +19146,7 @@ function applyActionInner(
|
||||
'You cannot replace more forces than the winner has remaining.',
|
||||
);
|
||||
const sector = home ? 0 : integer(action.sector, 0, 18, 'Sector');
|
||||
requireRule(home || validLocation(decision.territory, sector), 'Choose a sector in the battle territory.');
|
||||
requireRule(home || validGameLocation(g, decision.territory, sector), 'Choose a sector in the battle territory.');
|
||||
// This is replacement, not shipment or movement: no transport cost or worm/storm transit.
|
||||
for (const source of selected) {
|
||||
if (source.key === 'reserves') { if (!home) p.reserves -= source.count; }
|
||||
@@ -19292,6 +19569,12 @@ function applyActionInner(
|
||||
passed: [],
|
||||
};
|
||||
} else wormSurvival(g, decision.territory);
|
||||
} else if (decision.kind === 'discoveryDiscard') {
|
||||
decideDiscoveryDiscard(g,p,action);
|
||||
} else if (decision.kind === 'greatMakerVote') {
|
||||
decideGreatMakerVote(g,p,action);
|
||||
} else if (decision.kind === 'greatMakerRide') {
|
||||
decideGreatMakerRide(g,p,action);
|
||||
} else if (decision.kind === 'wormRide') {
|
||||
requireRule(
|
||||
typeof action.accept === 'boolean',
|
||||
@@ -19317,7 +19600,7 @@ function applyActionInner(
|
||||
const source = splitLocation(key);
|
||||
requireRule(
|
||||
source.territory === decision.territory &&
|
||||
validLocation(source.territory, source.sector),
|
||||
validGameLocation(g, source.territory, source.sector),
|
||||
'Ride only from the territory where the worm appeared.',
|
||||
);
|
||||
const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
|
||||
@@ -19535,7 +19818,7 @@ function applyActionInner(
|
||||
const to = stringField(action.territory);
|
||||
const sector = integer(action.sector, 0, 18, 'Sector');
|
||||
requireRule(
|
||||
to !== MOBILE_STRONGHOLD && validLocation(to, sector),
|
||||
to !== MOBILE_STRONGHOLD && validGameLocation(g, to, sector),
|
||||
'Choose a printed board sector; the mobile stronghold is not yet placed.',
|
||||
);
|
||||
requireRule(
|
||||
@@ -19583,7 +19866,7 @@ function applyActionInner(
|
||||
};
|
||||
requireRule(
|
||||
FREMEN_START.includes(loc.territory) &&
|
||||
validLocation(loc.territory, loc.sector),
|
||||
validGameLocation(g, loc.territory, loc.sector),
|
||||
'Starting forces belong in sectors of Sietch Tabr, False Wall South or False Wall West.',
|
||||
);
|
||||
return { ...loc, amount: integer(value, 0, 10, 'Starting forces') };
|
||||
@@ -20452,7 +20735,7 @@ function applyActionInner(
|
||||
requireRule(!blocked, blocked ?? 'Guild Homeworld shipment is unavailable.');
|
||||
requireRule(action.noField === undefined && (action.sector === undefined || action.sector === 0),
|
||||
'A Homeworld arrival has no planet sector or concealed token.');
|
||||
const group = forceGroup(p, action);
|
||||
const group = forceGroup(g, p, action);
|
||||
const sources = Object.fromEntries(group.group.map(([key, amount]) => [key,
|
||||
{normal: amount - (group.eliteGroup[key] ?? 0), elite: group.eliteGroup[key] ?? 0}]));
|
||||
declareHomeworldShipment(g, p, {route: 'arrakis', player: id,
|
||||
@@ -20503,7 +20786,7 @@ function applyActionInner(
|
||||
),
|
||||
total: integer(action.amount, 1, p.reserves, 'Forces'),
|
||||
}
|
||||
: forceGroup(p, action);
|
||||
: forceGroup(g, p, action);
|
||||
requireRule(
|
||||
group.every(([key]) => splitLocation(key).sector !== g.storm),
|
||||
'The source is in storm.',
|
||||
@@ -20662,7 +20945,7 @@ function applyActionInner(
|
||||
total: n,
|
||||
noField,
|
||||
sourceKeys,
|
||||
} = forceGroup(p, action);
|
||||
} = forceGroup(g, p, action);
|
||||
const to = stringField(action.territory),
|
||||
s = integer(action.sector, 0, 18, 'Sector');
|
||||
const advisors = arrivalAsAdvisor(g, p, to, origin);
|
||||
@@ -21234,6 +21517,8 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -21525,6 +21810,8 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
|
||||
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),
|
||||
nexusMoritani: nexusMoritaniOffer(g,id),
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
location,
|
||||
MOBILE_STRONGHOLD,
|
||||
splitLocation,
|
||||
validLocation,
|
||||
TERRITORIES,
|
||||
validGameLocation,
|
||||
} from './board';
|
||||
import { arrivalAsAdvisor } from './advisors';
|
||||
import { presenceAt } from './force-presence';
|
||||
@@ -62,10 +63,17 @@ function usableLocation(g: Game, key: string) {
|
||||
const at = splitLocation(key);
|
||||
return (
|
||||
location(at.territory, at.sector) === key &&
|
||||
validLocation(at.territory, at.sector) &&
|
||||
validGameLocation(g, at.territory, at.sector) &&
|
||||
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location)
|
||||
);
|
||||
}
|
||||
function printedLocation(key: string) {
|
||||
const at = splitLocation(key);
|
||||
return TERRITORIES.some(
|
||||
(territory) =>
|
||||
territory.id === at.territory && territory.sectors.includes(at.sector),
|
||||
);
|
||||
}
|
||||
function playerFor(g: Game, id: string) {
|
||||
requireMove(
|
||||
Array.isArray(g.players) &&
|
||||
@@ -85,8 +93,7 @@ function playerFor(g: Game, id: string) {
|
||||
requireMove(
|
||||
!g.mobileStronghold?.location ||
|
||||
(usableLocation(g, g.mobileStronghold.location) &&
|
||||
splitLocation(g.mobileStronghold.location).territory !==
|
||||
MOBILE_STRONGHOLD),
|
||||
printedLocation(g.mobileStronghold.location)),
|
||||
'The mobile stronghold must point to a board territory.',
|
||||
);
|
||||
for (const p of g.players) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
export function greatMakerRideAction(
|
||||
game: GameView,
|
||||
territory: string,
|
||||
sector: number,
|
||||
amount: number,
|
||||
elite: number,
|
||||
): Action | null {
|
||||
const d = game.decision,
|
||||
offer = game.greatMaker?.ride;
|
||||
if (
|
||||
d?.kind !== 'greatMakerRide' ||
|
||||
d.player !== game.me ||
|
||||
!offer ||
|
||||
game.truthtrance ||
|
||||
!Number.isSafeInteger(amount) ||
|
||||
amount < 1 ||
|
||||
amount > offer.max ||
|
||||
!Number.isSafeInteger(elite) ||
|
||||
elite < Math.max(0, amount - (offer.max - offer.eliteMax)) ||
|
||||
elite > Math.min(amount, offer.eliteMax) ||
|
||||
!offer.destinations.some(
|
||||
(to) => to.territory === territory && to.sector === sector,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
type: 'decision',
|
||||
event: d.event,
|
||||
accept: true,
|
||||
territory,
|
||||
sector,
|
||||
amount,
|
||||
elite,
|
||||
};
|
||||
}
|
||||
export function greatMakerBotActions(game: GameView): Action[] {
|
||||
const d = game.decision;
|
||||
if (!d || d.player !== game.me || game.truthtrance) return [];
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
event: d.event,
|
||||
yes: !game.players.find((p) => p.id === game.me)?.ally,
|
||||
},
|
||||
];
|
||||
if (d.kind !== 'greatMakerRide') return [];
|
||||
const offer = game.greatMaker?.ride,
|
||||
to = offer?.destinations.find((to) => to.territory !== 'polar_sink');
|
||||
const actions: Action[] = [];
|
||||
if (offer && to) {
|
||||
const amount = Math.min(5, offer.max),
|
||||
elite = Math.min(amount, offer.eliteMax);
|
||||
const action = greatMakerRideAction(
|
||||
game,
|
||||
to.territory,
|
||||
to.sector,
|
||||
amount,
|
||||
elite,
|
||||
);
|
||||
if (action) actions.push(action);
|
||||
}
|
||||
return [...actions, { type: 'decision', event: d.event, accept: false }];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export type GreatMaker = {
|
||||
arrival: {
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
} | null;
|
||||
event: string;
|
||||
turn: number;
|
||||
pile: 0 | 1;
|
||||
index: number;
|
||||
territory: string | null;
|
||||
stage: 'worm' | 'vote' | 'ride' | 'arrival' | 'complete';
|
||||
order: string[];
|
||||
votes: { player: string; yes: boolean }[];
|
||||
ridesBefore: string[];
|
||||
signature: string;
|
||||
};
|
||||
export function greatMakerSignature(frame: GreatMaker): string {
|
||||
return JSON.stringify([
|
||||
frame.event,
|
||||
frame.turn,
|
||||
frame.pile,
|
||||
frame.index,
|
||||
frame.territory,
|
||||
frame.stage,
|
||||
frame.order,
|
||||
frame.votes,
|
||||
frame.ridesBefore,
|
||||
frame.arrival,
|
||||
]);
|
||||
}
|
||||
export function validateGreatMaker(
|
||||
frame: GreatMaker,
|
||||
turn: number,
|
||||
players: readonly { id: string }[],
|
||||
) {
|
||||
const ids = players.map((player) => player.id);
|
||||
if (
|
||||
!frame ||
|
||||
Object.keys(frame).sort().join(',') !==
|
||||
'arrival,event,index,order,pile,ridesBefore,signature,stage,territory,turn,votes' ||
|
||||
typeof frame.event !== 'string' ||
|
||||
!frame.event ||
|
||||
!Number.isSafeInteger(frame.turn) ||
|
||||
frame.turn < 2 ||
|
||||
frame.turn > turn ||
|
||||
![0, 1].includes(frame.pile) ||
|
||||
!Number.isSafeInteger(frame.index) ||
|
||||
frame.index < 0 ||
|
||||
!['worm', 'vote', 'ride', 'arrival', 'complete'].includes(frame.stage) ||
|
||||
!Array.isArray(frame.order) ||
|
||||
frame.order.length !== ids.length ||
|
||||
new Set(frame.order).size !== ids.length ||
|
||||
frame.order.some((id) => !ids.includes(id)) ||
|
||||
!Array.isArray(frame.votes) ||
|
||||
frame.votes.length > ids.length ||
|
||||
frame.votes.some(
|
||||
(vote, index) =>
|
||||
!vote ||
|
||||
Object.keys(vote).sort().join(',') !== 'player,yes' ||
|
||||
vote.player !== frame.order[index] ||
|
||||
typeof vote.yes !== 'boolean',
|
||||
) ||
|
||||
!Array.isArray(frame.ridesBefore) ||
|
||||
frame.ridesBefore.some((id) => typeof id !== 'string') ||
|
||||
(frame.territory !== null && typeof frame.territory !== 'string') ||
|
||||
(frame.stage === 'arrival' && !frame.arrival) ||
|
||||
(!['arrival', 'complete'].includes(frame.stage) &&
|
||||
frame.arrival !== null) ||
|
||||
(frame.arrival !== null &&
|
||||
(!frame.arrival ||
|
||||
Object.keys(frame.arrival).sort().join(',') !==
|
||||
'amount,elite,sector,territory' ||
|
||||
typeof frame.arrival.territory !== 'string' ||
|
||||
!Number.isSafeInteger(frame.arrival.sector) ||
|
||||
frame.arrival.sector < 0 ||
|
||||
frame.arrival.sector > 18 ||
|
||||
!Number.isSafeInteger(frame.arrival.amount) ||
|
||||
frame.arrival.amount < 1 ||
|
||||
frame.arrival.amount > 20 ||
|
||||
!Number.isSafeInteger(frame.arrival.elite) ||
|
||||
frame.arrival.elite < 0 ||
|
||||
frame.arrival.elite > frame.arrival.amount)) ||
|
||||
(frame.stage === 'worm' && frame.votes.length !== 0) ||
|
||||
(frame.stage === 'vote' && frame.votes.length >= ids.length) ||
|
||||
(['ride', 'arrival', 'complete'].includes(frame.stage) &&
|
||||
frame.votes.length !== ids.length) ||
|
||||
frame.signature !== greatMakerSignature(frame)
|
||||
)
|
||||
throw new Error(
|
||||
'The Great Maker has lost its original encounter or ordered vote.',
|
||||
);
|
||||
}
|
||||
export function greatMakerMajority(frame: GreatMaker): boolean {
|
||||
return frame.votes.filter((vote) => vote.yes).length > frame.order.length / 2;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
location,
|
||||
MOBILE_STRONGHOLD,
|
||||
splitLocation,
|
||||
validLocation,
|
||||
TERRITORIES,
|
||||
validGameLocation,
|
||||
} from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
@@ -45,10 +46,17 @@ function usableLocation(g: Game, key: string) {
|
||||
const at = splitLocation(key);
|
||||
return (
|
||||
location(at.territory, at.sector) === key &&
|
||||
validLocation(at.territory, at.sector) &&
|
||||
validGameLocation(g, at.territory, at.sector) &&
|
||||
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location)
|
||||
);
|
||||
}
|
||||
function printedLocation(key: string) {
|
||||
const at = splitLocation(key);
|
||||
return TERRITORIES.some(
|
||||
(territory) =>
|
||||
territory.id === at.territory && territory.sectors.includes(at.sector),
|
||||
);
|
||||
}
|
||||
/** Only public board custody and the beneficiary's physical reserves are read.
|
||||
* The caller binds the Ambassador, event, actor and continuation timing. */
|
||||
function beneficiary(g: Game, id: string) {
|
||||
@@ -65,8 +73,7 @@ function beneficiary(g: Game, id: string) {
|
||||
requireShipment(
|
||||
!g.mobileStronghold?.location ||
|
||||
(usableLocation(g, g.mobileStronghold.location) &&
|
||||
splitLocation(g.mobileStronghold.location).territory !==
|
||||
MOBILE_STRONGHOLD),
|
||||
printedLocation(g.mobileStronghold.location)),
|
||||
'The mobile stronghold must point to a board location.',
|
||||
);
|
||||
for (const p of g.players) {
|
||||
@@ -285,7 +292,7 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
typeof shipment.advisors === 'boolean' &&
|
||||
typeof shipment.territory === 'string' &&
|
||||
count(shipment.sector) &&
|
||||
validLocation(shipment.territory, shipment.sector) &&
|
||||
validGameLocation(g, shipment.territory, shipment.sector) &&
|
||||
!Object.hasOwn(shipment, 'noField') &&
|
||||
!Object.hasOwn(shipment, 'alliedNoField'),
|
||||
'BG accompaniment needs the completed physical Guild Ambassador shipment by another off-planet faction.',
|
||||
|
||||
@@ -70,6 +70,7 @@ export function guildHomeworldShipmentChoice(
|
||||
advanced: view.advanced,
|
||||
storm: view.storm,
|
||||
mobileStronghold: view.mobileStronghold?.location ?? null,
|
||||
discoveries: view.discoveries,
|
||||
players: view.players.map((player) => ({
|
||||
id: player.id,
|
||||
faction: player.faction,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MOBILE_LOCATION, splitLocation, validLocation } from './board';
|
||||
import {
|
||||
MOBILE_LOCATION,
|
||||
splitLocation,
|
||||
TERRITORIES,
|
||||
validGameLocation,
|
||||
type MobileBoard,
|
||||
} from './board';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
@@ -16,6 +22,7 @@ export type GuildHomeworldShipmentContext = HomeworldShipmentContext & {
|
||||
>;
|
||||
/** The mobile stronghold's ordinary Arrakis pointer, not its interior. */
|
||||
mobileStronghold?: string | null;
|
||||
discoveries?: MobileBoard['discoveries'];
|
||||
};
|
||||
export type GuildHomeworldShipmentIntent = {
|
||||
player: string;
|
||||
@@ -32,9 +39,21 @@ function requireShipment(
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function canonicalSector(key: string) {
|
||||
function canonicalSector(context: GuildHomeworldShipmentContext, key: string) {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
return key === `${territory}:${sector}` && validLocation(territory, sector);
|
||||
return (
|
||||
key === `${territory}:${sector}` &&
|
||||
validGameLocation({ discoveries: context.discoveries }, territory, sector)
|
||||
);
|
||||
}
|
||||
function printedSector(key: string) {
|
||||
const { territory: id, sector } = splitLocation(key);
|
||||
return (
|
||||
key === `${id}:${sector}` &&
|
||||
TERRITORIES.some(
|
||||
(territory) => territory.id === id && territory.sectors.includes(sector),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Guild's own special shipment from one Arrakis territory to a Homeworld.
|
||||
@@ -92,14 +111,12 @@ export function quoteGuildHomeworldShipment(
|
||||
requireShipment(
|
||||
pointer === undefined ||
|
||||
pointer === null ||
|
||||
(typeof pointer === 'string' &&
|
||||
canonicalSector(pointer) &&
|
||||
pointer !== MOBILE_LOCATION),
|
||||
(typeof pointer === 'string' && printedSector(pointer)),
|
||||
'The mobile stronghold needs a canonical Arrakis pointer.',
|
||||
);
|
||||
for (const [key, amount] of Object.entries(board.forces))
|
||||
requireShipment(
|
||||
canonicalSector(key) &&
|
||||
canonicalSector(context, key) &&
|
||||
whole(amount) &&
|
||||
amount <= 20 &&
|
||||
(key !== MOBILE_LOCATION || !!pointer),
|
||||
@@ -107,7 +124,7 @@ export function quoteGuildHomeworldShipment(
|
||||
);
|
||||
for (const [key, amount] of Object.entries(board.eliteForces))
|
||||
requireShipment(
|
||||
canonicalSector(key) && whole(amount) && amount === 0,
|
||||
canonicalSector(context, key) && whole(amount) && amount === 0,
|
||||
'Guild counters cannot acquire special force identities.',
|
||||
);
|
||||
const boardTotal = Object.values(board.forces).reduce((sum, n) => sum + n, 0);
|
||||
@@ -138,7 +155,7 @@ export function quoteGuildHomeworldShipment(
|
||||
}[] = [];
|
||||
for (const [key, selected] of entries) {
|
||||
requireShipment(
|
||||
canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
canonicalSector(context, key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
'Choose a canonical, active Arrakis source sector.',
|
||||
);
|
||||
const source = splitLocation(key);
|
||||
|
||||
@@ -176,6 +176,7 @@ export function junctionTransportChoice(
|
||||
advanced: view.advanced,
|
||||
storm: view.storm,
|
||||
mobileStronghold: view.mobileStronghold?.location ?? null,
|
||||
discoveries: view.discoveries,
|
||||
players: view.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
|
||||
+23
-10
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
MOBILE_LOCATION,
|
||||
splitLocation,
|
||||
TERRITORIES,
|
||||
territory,
|
||||
validLocation,
|
||||
validGameLocation,
|
||||
type MobileBoard,
|
||||
} from './board';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
@@ -28,6 +30,7 @@ export type JunctionTransportContext = HomeworldShipmentContext & {
|
||||
>;
|
||||
/** Ordinary Arrakis pointer, never the mobile interior itself. */
|
||||
mobileStronghold?: string | null;
|
||||
discoveries?: MobileBoard['discoveries'];
|
||||
};
|
||||
export type JunctionTransportIntent = {
|
||||
player: string;
|
||||
@@ -51,9 +54,21 @@ function requireTransport(
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function canonicalSector(key: string) {
|
||||
function canonicalSector(context: JunctionTransportContext, key: string) {
|
||||
const { territory: id, sector } = splitLocation(key);
|
||||
return key === `${id}:${sector}` && validLocation(id, sector);
|
||||
return (
|
||||
key === `${id}:${sector}` &&
|
||||
validGameLocation({ discoveries: context.discoveries }, id, sector)
|
||||
);
|
||||
}
|
||||
function printedSector(key: string) {
|
||||
const { territory: id, sector } = splitLocation(key);
|
||||
return (
|
||||
key === `${id}:${sector}` &&
|
||||
TERRITORIES.some(
|
||||
(territory) => territory.id === id && territory.sectors.includes(sector),
|
||||
)
|
||||
);
|
||||
}
|
||||
const eliteLimit = (faction: string) =>
|
||||
faction === 'emperor'
|
||||
@@ -133,15 +148,13 @@ export function quoteJunctionTransport(
|
||||
requireTransport(
|
||||
pointer === undefined ||
|
||||
pointer === null ||
|
||||
(typeof pointer === 'string' &&
|
||||
canonicalSector(pointer) &&
|
||||
pointer !== MOBILE_LOCATION),
|
||||
(typeof pointer === 'string' && printedSector(pointer)),
|
||||
'The mobile stronghold needs a canonical Arrakis pointer.',
|
||||
);
|
||||
const destinationWorld = worlds.find((w) => w.id === intent.destination);
|
||||
requireTransport(
|
||||
destinationWorld ||
|
||||
(canonicalSector(intent.destination) &&
|
||||
(canonicalSector(context, intent.destination) &&
|
||||
(intent.destination !== MOBILE_LOCATION || !!pointer)),
|
||||
'Choose an active Homeworld or canonical Arrakis destination.',
|
||||
);
|
||||
@@ -175,7 +188,7 @@ export function quoteJunctionTransport(
|
||||
let boardElite = 0;
|
||||
for (const [key, count] of Object.entries(board.forces)) {
|
||||
requireTransport(
|
||||
canonicalSector(key) &&
|
||||
canonicalSector(context, key) &&
|
||||
whole(count) &&
|
||||
count <= 20 &&
|
||||
(key !== MOBILE_LOCATION || !!pointer),
|
||||
@@ -185,7 +198,7 @@ export function quoteJunctionTransport(
|
||||
}
|
||||
for (const [key, count] of Object.entries(board.eliteForces)) {
|
||||
requireTransport(
|
||||
canonicalSector(key) &&
|
||||
canonicalSector(context, key) &&
|
||||
whole(count) &&
|
||||
count <= (Object.hasOwn(board.forces, key) ? board.forces[key] : 0),
|
||||
'Special counters must be a subset of the physical force group.',
|
||||
@@ -260,7 +273,7 @@ export function quoteJunctionTransport(
|
||||
origin ??= key;
|
||||
} else {
|
||||
requireTransport(
|
||||
canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
canonicalSector(context, key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
'Choose a canonical, active Arrakis source sector.',
|
||||
);
|
||||
const source = splitLocation(key);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Game, Player, ResponseWindow } from './engine';
|
||||
import { MOBILE_STRONGHOLD, validLocation } from './board';
|
||||
import { MOBILE_STRONGHOLD, validGameLocation } from './board';
|
||||
import { validateRicheseNoField } from './richese-no-field';
|
||||
|
||||
export class NoFieldCancellationError extends Error {}
|
||||
@@ -13,6 +13,7 @@ export type NoFieldCancellationContext = Pick<
|
||||
| 'richeseAllyOffer'
|
||||
| 'richeseAllyOpportunity'
|
||||
| 'richeseAllyBlocked'
|
||||
| 'discoveries'
|
||||
> & {
|
||||
players: readonly Pick<
|
||||
Player,
|
||||
@@ -75,7 +76,7 @@ export function quoteNoFieldCancellation(
|
||||
);
|
||||
requireDeclaration(
|
||||
shipment.territory !== MOBILE_STRONGHOLD &&
|
||||
validLocation(shipment.territory, shipment.sector) &&
|
||||
validGameLocation(g, shipment.territory, shipment.sector) &&
|
||||
whole(shipment.amount) &&
|
||||
shipment.amount <= 5 &&
|
||||
whole(shipment.elite) &&
|
||||
|
||||
@@ -214,6 +214,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'automatic-decisions',
|
||||
'ai-pacing',
|
||||
'ecaz-modules',
|
||||
'discoveries',
|
||||
'homeworlds',
|
||||
'advanced-combat',
|
||||
],
|
||||
@@ -3406,6 +3407,54 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'discoveries',
|
||||
title: 'Discoveries: first playable functions',
|
||||
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.',
|
||||
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.',
|
||||
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
|
||||
'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.',
|
||||
'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.',
|
||||
],
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-choam-trade',
|
||||
title: 'CHOAM Nexus: Collection trade',
|
||||
@@ -3514,6 +3563,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 expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.',
|
||||
@@ -3530,6 +3580,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'moritani-terror',
|
||||
'ecaz-ambassadors',
|
||||
'homeworlds',
|
||||
'discoveries',
|
||||
'implementation-checklist',
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -60,6 +60,9 @@ const DECISIONS = {
|
||||
stormLosses: true,
|
||||
wormPlacement: true,
|
||||
wormProtection: true,
|
||||
discoveryDiscard: true,
|
||||
greatMakerVote: true,
|
||||
greatMakerRide: true,
|
||||
wormRide: true,
|
||||
moritaniRetention: true,
|
||||
battleCards: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
|
||||
import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
|
||||
import type { Action, GameView } from './engine';
|
||||
import { splitLocation, validLocation } from './board';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
|
||||
import { shipmentPaymentBounds } from './shipment-price';
|
||||
|
||||
@@ -86,7 +86,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
: [];
|
||||
for (const [key, count] of entries) {
|
||||
const source = splitLocation(key);
|
||||
if (!validLocation(source.territory, source.sector)) {
|
||||
if (!validGameLocation(g, source.territory, source.sector)) {
|
||||
validForces = false;
|
||||
continue;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
Number.isInteger(sector) &&
|
||||
sector >= 0 &&
|
||||
sector <= 18 &&
|
||||
(to === 'reserves' || validLocation(to, sector));
|
||||
(to === 'reserves' || validGameLocation(g, to, sector));
|
||||
if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed)
|
||||
unavailableReasons.push('Only the Guild may return forces to reserves.');
|
||||
if (to === 'reserves' && nexus && g.homeworlds?.worlds?.length)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ecazOccupancyIdentity } from './ecaz-occupy';
|
||||
import { gameTerritories } from './board';
|
||||
import { fighterCount } from './advisors';
|
||||
import { TECH_TOKENS } from './tech-tokens';
|
||||
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
|
||||
|
||||
export type StrongholdProgress = {
|
||||
player: string;
|
||||
@@ -34,6 +35,12 @@ function requireProgress(
|
||||
if (!condition) throw new VictoryProgressError(message);
|
||||
}
|
||||
|
||||
/** Discovery locations use stronghold shipping and occupancy rules, but only
|
||||
* Jacurutu Sietch counts toward the normal stronghold victory. */
|
||||
export function countsForStrongholdVictory(id: string) {
|
||||
return !isDiscoveryLocationId(id) || id === JACURUTU_SIETCH;
|
||||
}
|
||||
|
||||
/** Public prospective stronghold victory only. No prediction, special endgame
|
||||
* victory, combat resolution, resource mutation or Stronghold Card ownership is
|
||||
* considered. Mandatory advisor releases are returned for the caller to commit. */
|
||||
@@ -81,7 +88,9 @@ export function strongholdProgress(
|
||||
'Victory progress requires the complete technology token ownership map.',
|
||||
);
|
||||
}
|
||||
const sites = boardTerritories.filter((t) => t.type === 'stronghold');
|
||||
const sites = boardTerritories.filter(
|
||||
(t) => t.type === 'stronghold' && countsForStrongholdVictory(t.id),
|
||||
);
|
||||
const progress = g.order.map((id) => {
|
||||
const p = players.find((p) => p.id === id)!;
|
||||
const members = g.order.filter(
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
type AdvisorRelease,
|
||||
} from './board-resolution-quote';
|
||||
import { TECH_TOKENS, techStronghold } from './tech-tokens';
|
||||
import { strongholdProgress, VictoryProgressError } from './victory-progress';
|
||||
import {
|
||||
countsForStrongholdVictory,
|
||||
strongholdProgress,
|
||||
VictoryProgressError,
|
||||
} from './victory-progress';
|
||||
import {
|
||||
settleStrongholdCards,
|
||||
strongholdControllers,
|
||||
@@ -146,6 +150,7 @@ export function quoteVictory(g: VictoryContext): VictoryQuote {
|
||||
gameTerritories(g).filter(
|
||||
(t) =>
|
||||
t.type === 'stronghold' &&
|
||||
countsForStrongholdVictory(t.id) &&
|
||||
ids.some((id) => fighterCount(player(id), t.id) > 0) &&
|
||||
players.every((p) => ids.includes(p.id) || fighterCount(p, t.id) === 0),
|
||||
).length;
|
||||
|
||||
Reference in new issue
Block a user