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.
252 lines
8.2 KiB
TypeScript
252 lines
8.2 KiB
TypeScript
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`;
|
|
const mobileTerritory = {
|
|
id: MOBILE_STRONGHOLD,
|
|
name: 'Hidden Mobile Stronghold',
|
|
sectors: [0],
|
|
type: 'stronghold',
|
|
points: [
|
|
[930, 80],
|
|
[1160, 80],
|
|
[1160, 150],
|
|
[930, 150],
|
|
],
|
|
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;
|
|
};
|
|
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) {
|
|
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
|
|
: (discoveryTerritories.find((t) => t.id === id) ??
|
|
TERRITORIES.find((t) => t.id === id));
|
|
if (!t) throw new Error('Unknown territory.');
|
|
return t;
|
|
};
|
|
export const GRAPH: Record<string, string[]> = graphData;
|
|
export const location = (id: string, sector: number) => `${id}:${sector}`;
|
|
export function splitLocation(key: string) {
|
|
const [id, s] = key.split(':');
|
|
return { territory: id, sector: Number(s) };
|
|
}
|
|
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,
|
|
blocked: (key: string) => boolean = () => false,
|
|
): number {
|
|
if (blocked(from) || blocked(to)) return Infinity;
|
|
if (from === to) return 0;
|
|
const queue: [string, number][] = [[from, 0]],
|
|
best = new Map([[from, 0]]);
|
|
while (queue.length) {
|
|
const [node, d] = queue.shift()!;
|
|
for (const n of GRAPH[node] ?? []) {
|
|
if (blocked(n)) continue;
|
|
const next =
|
|
d +
|
|
(splitLocation(node).territory === splitLocation(n).territory ? 0 : 1);
|
|
if (next < (best.get(n) ?? Infinity)) {
|
|
best.set(n, next);
|
|
queue.push([n, next]);
|
|
}
|
|
}
|
|
}
|
|
return best.get(to) ?? Infinity;
|
|
}
|
|
|
|
export const FREMEN_START = [
|
|
'sietch_tabr',
|
|
'false_wall_south',
|
|
'false_wall_west',
|
|
];
|
|
export const FREMEN_START_LOCATIONS = FREMEN_START.flatMap((id) =>
|
|
territory(id).sectors.map((s) => location(id, s)),
|
|
);
|
|
|
|
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 {
|
|
const fromEntrances = dynamicEntrances(g, from);
|
|
const toEntrances = dynamicEntrances(g, to);
|
|
if (fromEntrances === undefined && toEntrances === undefined)
|
|
return distance(from, to, blocked);
|
|
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;
|
|
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. */
|
|
export function mobileRouteDistance(route: string[]): number {
|
|
if (
|
|
!route.length ||
|
|
route.length > Object.keys(GRAPH).length ||
|
|
new Set(route).size !== route.length
|
|
)
|
|
return Infinity;
|
|
let steps = 0;
|
|
for (let i = 0; i < route.length; i++) {
|
|
if (!Object.hasOwn(GRAPH, route[i])) return Infinity;
|
|
if (i) {
|
|
if (!(GRAPH[route[i - 1]] ?? []).includes(route[i])) return Infinity;
|
|
if (
|
|
splitLocation(route[i - 1]).territory !==
|
|
splitLocation(route[i]).territory
|
|
)
|
|
steps++;
|
|
}
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
/** One shortest connected route to each possible destination, for AI candidates. */
|
|
export function mobileRoutes(
|
|
g: MobileBoard & { storm?: number },
|
|
max: number,
|
|
): string[][] {
|
|
const origin = g.mobileStronghold?.location;
|
|
if (!origin || splitLocation(origin).sector === g.storm) return [];
|
|
const queue: { route: string[]; cost: number }[] = [
|
|
{ route: [origin], cost: 0 },
|
|
];
|
|
const best = new Map([[origin, 0]]);
|
|
const result: string[][] = [];
|
|
while (queue.length) {
|
|
queue.sort((a, b) => a.cost - b.cost || a.route.length - b.route.length);
|
|
const { route, cost } = queue.shift()!;
|
|
const current = route.at(-1)!;
|
|
if (cost !== best.get(current)) continue;
|
|
if (
|
|
route.length > 1 &&
|
|
territory(splitLocation(current).territory).type !== 'stronghold'
|
|
)
|
|
result.push(route);
|
|
for (const key of GRAPH[current] ?? []) {
|
|
if (splitLocation(key).sector === g.storm) continue;
|
|
const next =
|
|
cost +
|
|
(splitLocation(current).territory === splitLocation(key).territory
|
|
? 0
|
|
: 1);
|
|
if (next <= max && next < (best.get(key) ?? Infinity)) {
|
|
best.set(key, next);
|
|
queue.push({ route: [...route, key], cost: next });
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|