Files
healer-man/src/game/dungeonRoutes.ts
T
2026-09-16 14:30:58 -04:00

109 lines
4.0 KiB
TypeScript

import rawCatalog from "./generated/dungeonRouteCatalog.generated.json";
import type {
DungeonRouteApproval,
DungeonRouteCatalogV1,
DungeonRouteDefinitionV1,
DungeonRouteLeg,
} from "./dungeonSimulationTypes";
import type { Vector3Tuple } from "./dungeonTypes";
export const DUNGEON_ROUTE_CATALOG = rawCatalog as unknown as DungeonRouteCatalogV1;
export const DUNGEON_ROUTE_APPROVAL_CACHE_KEY = "healer-man.dungeon-route-approvals.v1";
const routesByDungeon = new Map(
DUNGEON_ROUTE_CATALOG.routes.map((route) => [route.dungeonId, route] as const),
);
export function dungeonRouteDefinition(dungeonId: string, routeId = "primary"): DungeonRouteDefinitionV1 | null {
const route = routesByDungeon.get(dungeonId) ?? null;
return route?.id === routeId ? route : null;
}
export function dungeonRouteLeg(
route: DungeonRouteDefinitionV1 | null,
objectiveId: string | null,
): DungeonRouteLeg | null {
if (!route || !objectiveId) return null;
return route.legs.find((leg) => leg.objectiveId === objectiveId) ?? null;
}
export function routeDistance(points: readonly Vector3Tuple[]): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
points[index][2] - points[index - 1][2],
);
}
return total;
}
export function routePointDistanceSquared(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): {
readonly planarSquared: number;
readonly vertical: number;
readonly progress: number;
} {
const dx = end[0] - start[0];
const dz = end[2] - start[2];
const lengthSquared = dx * dx + dz * dz;
const progress = lengthSquared > 1e-9
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSquared))
: 0;
const projectedX = start[0] + dx * progress;
const projectedY = start[1] + (end[1] - start[1]) * progress;
const projectedZ = start[2] + dz * progress;
return {
planarSquared: (point[0] - projectedX) ** 2 + (point[2] - projectedZ) ** 2,
vertical: Math.abs(point[1] - projectedY),
progress,
};
}
export function positionAlongRouteCorridor(
point: Vector3Tuple,
checkpoints: readonly Vector3Tuple[],
planarLimit = 10,
verticalLimit = 4,
): boolean {
for (let index = 1; index < checkpoints.length; index += 1) {
const distance = routePointDistanceSquared(point, checkpoints[index - 1], checkpoints[index]);
if (distance.planarSquared <= planarLimit ** 2 && distance.vertical <= verticalLimit) return true;
}
return checkpoints.length === 1
&& Math.hypot(point[0] - checkpoints[0][0], point[2] - checkpoints[0][2]) <= planarLimit
&& Math.abs(point[1] - checkpoints[0][1]) <= verticalLimit;
}
export function readCachedDungeonRouteApprovals(): readonly DungeonRouteApproval[] {
if (typeof localStorage === "undefined") return [];
try {
const parsed = JSON.parse(localStorage.getItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY) ?? "null");
if (!Array.isArray(parsed?.approvals)) return [];
return parsed.approvals.filter((approval: unknown): approval is DungeonRouteApproval => {
if (!approval || typeof approval !== "object") return false;
const value = approval as Partial<DungeonRouteApproval>;
return typeof value.dungeonId === "string" && typeof value.routeId === "string"
&& /^[a-f0-9]{64}$/.test(value.routeHash ?? "") && Number.isFinite(value.reviewedAt);
});
} catch {
return [];
}
}
export function cacheDungeonRouteApprovals(approvals: readonly DungeonRouteApproval[]): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY, JSON.stringify({ approvals }));
}
export function routeHasExactApproval(
route: DungeonRouteDefinitionV1 | null,
approvals: readonly DungeonRouteApproval[],
): boolean {
return Boolean(route?.generationStatus === "Candidate" && approvals.some((approval) => (
approval.dungeonId === route.dungeonId
&& approval.routeId === route.id
&& approval.routeHash === route.hash
)));
}