import { classifyGraphTarget } from "../domain/socket.js"; import type { MateriaConfig, MateriaForeachConfig, MateriaLoopConfig, MateriaPipelineConfig, MateriaPipelineSocketConfig, ResolvedMateriaPipeline, ResolvedMateriaSocket } from "../types.js"; export class LoadoutTopologyError extends Error { constructor(public readonly path: string, message: string) { super(`${path}: ${message}`); this.name = "LoadoutTopologyError"; } } export interface SocketReferenceIssue { path: string; message: string; } export interface SocketReferenceValidation { ok: boolean; issues: SocketReferenceIssue[]; } /** Socket-first accessors for the loadout model. */ export function loadoutSockets(loadout: Pick): Record { return loadout.sockets ?? {}; } export function loadoutSocketEntries(loadout: Pick): [string, MateriaPipelineSocketConfig][] { return Object.entries(loadoutSockets(loadout)); } export function loadoutSocketIds(loadout: Pick): string[] { return Object.keys(loadoutSockets(loadout)); } export function loadoutSocketIdSet(loadout: Pick): Set { return new Set(loadoutSocketIds(loadout)); } export function getLoadoutSocket(loadout: Pick, socketId: string): MateriaPipelineSocketConfig | undefined { return loadoutSockets(loadout)[socketId]; } export function requireLoadoutSocket(loadout: Pick, socketId: string, path = `sockets.${socketId}`): MateriaPipelineSocketConfig { const socket = getLoadoutSocket(loadout, socketId); if (!socket) throw new LoadoutTopologyError(path, `unknown socket ${JSON.stringify(socketId)}`); return socket; } export function loopSockets(loop: Pick): string[] { return loop.sockets ?? []; } export function loopSocketSet(loop: Pick): Set { return new Set(loopSockets(loop)); } export function loadoutLoopEntries(loadout: Pick): [string, MateriaLoopConfig][] { return Object.entries(loadout.loops ?? {}).filter((entry): entry is [string, MateriaLoopConfig] => Boolean(entry[1])); } export function resolvedPipelineSockets(pipeline: Partial>): Record { return pipeline.sockets ?? {}; } export function resolvedPipelineSocketEntries(pipeline: Partial>): [string, ResolvedMateriaSocket][] { return Object.entries(resolvedPipelineSockets(pipeline)); } export function getResolvedPipelineSocket(pipeline: Partial>, socketId: string): ResolvedMateriaSocket | undefined { return resolvedPipelineSockets(pipeline)[socketId]; } export function loopIteratorForSocket(pipeline: Pick | Pick, socketId: string): MateriaForeachConfig | undefined { const resolved = resolvedPipelineSockets(pipeline as Partial>); const entry = resolved[socketId] as ResolvedMateriaSocket | undefined; const socket = entry?.socket ?? getLoadoutSocket(pipeline as Pick, socketId); const direct = socket?.foreach; if (direct) return direct; for (const loop of Object.values(pipeline.loops ?? {})) { if (loop.iterator && loopSockets(loop).includes(socketId)) return loop.iterator; } return undefined; } export function validateLoadoutSocketReferences(loadout: Pick): SocketReferenceValidation { const issues: SocketReferenceIssue[] = []; const socketIds = loadoutSocketIdSet(loadout); if (!socketIds.has(loadout.entry)) issues.push({ path: "entry", message: `entry must reference an existing socket ${JSON.stringify(loadout.entry)}` }); for (const [socketId, socket] of loadoutSocketEntries(loadout)) { for (const [index, edge] of (socket.edges ?? []).entries()) addMissingReferenceIssue(issues, socketIds, edge.to, `sockets.${socketId}.edges.${index}.to`, "edge target"); addMissingReferenceIssue(issues, socketIds, socket.foreach?.done, `sockets.${socketId}.foreach.done`, "foreach done target"); addMissingReferenceIssue(issues, socketIds, socket.advance?.done, `sockets.${socketId}.advance.done`, "advance exhaustion target"); } for (const [loopId, loop] of loadoutLoopEntries(loadout)) { for (const [index, socketId] of loopSockets(loop).entries()) addMissingReferenceIssue(issues, socketIds, socketId, `loops.${loopId}.sockets.${index}`, "loop socket", { allowTerminal: false }); addMissingReferenceIssue(issues, socketIds, loop.consumes?.from, `loops.${loopId}.consumes.from`, "loop consumer source", { allowTerminal: false }); addMissingReferenceIssue(issues, socketIds, loop.consumes?.done, `loops.${loopId}.consumes.done`, "loop consumer done target"); addMissingReferenceIssue(issues, socketIds, loop.iterator?.done, `loops.${loopId}.iterator.done`, "loop iterator done target"); addMissingReferenceIssue(issues, socketIds, loop.exit?.from, `loops.${loopId}.exit.from`, "loop exit source", { allowTerminal: false }); addMissingReferenceIssue(issues, socketIds, loop.exit?.to, `loops.${loopId}.exit.to`, "loop exit target"); for (const [index, route] of (loop.exits ?? []).entries()) { addMissingReferenceIssue(issues, socketIds, route.from, `loops.${loopId}.exits.${index}.from`, "loop-exit route source", { allowTerminal: false }); addMissingReferenceIssue(issues, socketIds, route.targetSocketId, `loops.${loopId}.exits.${index}.targetSocketId`, "loop-exit route target"); } } return { ok: issues.length === 0, issues }; } export function assertValidLoadoutSocketReferences(loadout: Pick): void { const result = validateLoadoutSocketReferences(loadout); if (!result.ok) throw new LoadoutTopologyError(result.issues[0]?.path ?? "loadout", result.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")); } export function materializeCanonicalSockets(loadout: TLoadout, materia: Record> = {}): TLoadout { loadout.sockets = loadout.sockets ?? {}; for (const loop of Object.values(loadout.loops ?? {})) { loop.sockets = loop.sockets ?? []; } return loadout; } function addMissingReferenceIssue(issues: SocketReferenceIssue[], socketIds: Set, socketId: string | undefined, path: string, label: string, options: { allowTerminal?: boolean } = {}): void { if (!socketId) return; const target = classifyGraphTarget(socketId, socketIds); if (target.kind === "terminal" && options.allowTerminal === false) issues.push({ path, message: `${label} must reference an existing socket ${JSON.stringify(socketId)}` }); if (target.kind === "unknown") issues.push({ path, message: `${label} must reference an existing socket${options.allowTerminal === false ? "" : " or terminal end"} ${JSON.stringify(socketId)}` }); }