import fs from "node:fs/promises"; import http, { type Server } from "node:http"; import path from "node:path"; import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ReviewSampleListResult, ScannerCommand, ScannerStatusPayload, } from "../src/types/global.js"; interface DevControlServerDependencies { registeredHotkeys: Record; hasMainWindow: () => boolean; sendScannerCommand: (command: ScannerCommand | "probe-click") => void; clickScreen: (x: number, y: number) => Promise; scannerStatus: () => ScannerStatusPayload; loadReviewSamples: (limit?: number) => Promise; listCaptureSources: () => Promise; captureSource: ( id: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions, ) => Promise; } function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) { res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", }); res.end(JSON.stringify(payload)); } function dataUrlBase64(dataUrl: string) { return dataUrl.replace(/^data:image\/png;base64,/, ""); } function devCaptureOutputDir() { return path.join(process.cwd(), "outputs", "live-capture"); } function safeDebugFilePart(value: string) { return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture"; } function dataUrlFingerprint(dataUrl: string | undefined) { if (!dataUrl) return ""; let hash = 2166136261; const stride = Math.max(1, Math.floor(dataUrl.length / 4096)); for (let index = 0; index < dataUrl.length; index += stride) { hash ^= dataUrl.charCodeAt(index); hash = Math.imul(hash, 16777619); } return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`; } async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) { if (!dataUrl) return null; await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64")); return filePath; } async function writeDevCaptureSnapshot(capture: CaptureResult) { const outputDir = devCaptureOutputDir(); await fs.mkdir(outputDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, ""); const prefix = safeDebugFilePart(`${stamp}-${capture.name}`); const files = { full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl), detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl), inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl), crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>, }; for (const crop of capture.crops ?? []) { const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`); const written = await writeDevCaptureImage(cropPath, crop.dataUrl); if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect }); } const summary = { id: capture.id, name: capture.name, width: capture.width, height: capture.height, capturedAt: capture.capturedAt, captureTarget: capture.captureTarget, ocrSkipped: capture.ocrSkipped, ocrTimedOut: capture.ocrTimedOut, layout: capture.layout, inventoryGrid: capture.inventoryGrid ? { rows: capture.inventoryGrid.rows, cols: capture.inventoryGrid.cols, confidence: capture.inventoryGrid.confidence, source: capture.inventoryGrid.source, firstCenter: capture.inventoryGrid.centers[0] ?? null, lastCenter: capture.inventoryGrid.centers.at(-1) ?? null, } : null, inventoryCount: capture.inventoryCount ?? null, locked: capture.locked, crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })), ocr: capture.ocr ?? [], files, }; const summaryPath = path.join(outputDir, `${prefix}-summary.json`); await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8"); return { ...summary, summaryPath }; } function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) { return sourceId ? sources.find((entry) => entry.id === sourceId) : sources.find((entry) => entry.isGenshinCandidate); } function sourceListForError(sources: CaptureSourceInfo[]) { return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate })); } export function createDevControlServer(deps: DevControlServerDependencies): Server { const server = http.createServer((req, res) => { if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) { writeDevJson(res, 403, { ok: false, error: "local only" }); return; } const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (url.pathname === "/health") { writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() }); return; } if (url.pathname === "/scanner/start") { const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const command: ScannerCommand = Number.isFinite(limit) && limit > 0 ? { type: "start-auto", scanLimit: limit } : "start-auto"; deps.sendScannerCommand(command); writeDevJson(res, 200, { ok: true, command }); return; } if (url.pathname === "/scanner/stop") { deps.sendScannerCommand("stop"); writeDevJson(res, 200, { ok: true, command: "stop" }); return; } if (url.pathname === "/scanner/probe") { deps.sendScannerCommand("probe-click"); writeDevJson(res, 200, { ok: true, command: "probe-click" }); return; } if (url.pathname === "/automation/click") { const x = Number(url.searchParams.get("x")); const y = Number(url.searchParams.get("y")); if (!Number.isFinite(x) || !Number.isFinite(y)) { writeDevJson(res, 400, { ok: false, error: "x and y query params are required" }); return; } deps.clickScreen(Math.round(x), Math.round(y)) .then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/status") { writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }); return; } if (url.pathname === "/review/samples") { deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20)) .then((payload: unknown) => writeDevJson(res, 200, payload)) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/capture/smart") { const sourceId = url.searchParams.get("sourceId"); const focus = url.searchParams.get("focus") !== "0"; const skipOcr = url.searchParams.get("skipOcr") === "1"; deps.listCaptureSources() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); if (!source) { writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) }); return; } const capture = await deps.captureSource(source.id, 250, focus, { skipOcr }); const summary = await writeDevCaptureSnapshot(capture); writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/automation/probe-click") { const sourceId = url.searchParams.get("sourceId"); const requestedIndex = Number(url.searchParams.get("index") ?? "1"); const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN); const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN); deps.listCaptureSources() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); if (!source) { writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) }); return; } const before = await deps.captureSource(source.id, 150, true, { skipOcr: true }); const centers = before.inventoryGrid?.centers ?? []; const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol) ? centers.find((center) => center.row === requestedRow && center.col === requestedCol) : centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))]; if (!target) { writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null }); return; } const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl); const click = await deps.clickScreen(target.x, target.y); await wait(650); const after = await deps.captureSource(source.id, 0, true, { skipOcr: true }); const afterFingerprint = dataUrlFingerprint(after.detailDataUrl); const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint); writeDevJson(res, 200, { ok: Boolean(click.ok && click.clicked && changed), changed, target, click, before: { captureTarget: before.captureTarget, grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null, detailFingerprint: beforeFingerprint, }, after: { captureTarget: after.captureTarget, grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null, detailFingerprint: afterFingerprint, }, }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } writeDevJson(res, 404, { ok: false, error: "unknown endpoint" }); }); server.listen(17317, "127.0.0.1"); return server; }