1// Deno.Command wrapper around mosaic-engine. JSON in, JSON out, no state.2import type { AnyManifest, LocalRect } from "./fold.ts";3import { dataDir, siteDir } from "./log.ts";45/** Private per-tile generator output; never written to the log. */6export type GenTile = {7 id: number;8 givens: [number, 0 | 1][];9 blank?: number;10 flip?: number[];11 needs_unique?: boolean;12 needs_bifurcation?: boolean;13 ladder?: Record<string, unknown>;14};1516export type GenOutput = {17 manifest: AnyManifest;18 solution: string[]; // G rows; '.' where no tile covers the cell19 tiles: GenTile[];20 stats?: Record<string, number>;21 /** private until the day is archived: the day seed behind seed_commit22 * and the hex day key behind hash_key_commit (gen-v7) */23 day_seed?: string;24 hash_key?: string;25};2627/** A revealed tile as the judge sees it: bounding box and rect union. */28export type JudgeShape = {29 x0: number;30 y0: number;31 w: number;32 h: number;33 rects: LocalRect[];34};3536export type JudgeInput = {37 G: number;38 shapes: JudgeShape[]; // revealed tiles39 known: [number, number][];40 candidates: [number, number][];41 depth: number;42};4344export type JudgeResult = {45 id: number;46 v: number;47 class: "forced" | "guess" | "contradiction" | "known";48 rules: string[];49};5051export type JudgeOutput = { ms: number; steps: number; results: JudgeResult[] };5253export function enginePath(): string {54 return Deno.env.get("MOSAIC_ENGINE") ??55 siteDir() + "../engine/bin/mosaic-engine";56}5758export function cacheDir(): string {59 return Deno.env.get("MOSAIC_CACHE") ?? dataDir() + "cache/";60}6162async function sha256(s: string): Promise<string> {63 const buf = await crypto.subtle.digest(64 "SHA-256",65 new TextEncoder().encode(s),66 );67 return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0"))68 .join("");69}7071/** Log lines from the engine's stderr (the daily supervises generation). */72export let engineLog: (line: string) => void = () => {};73export function setEngineLog(f: (line: string) => void): void {74 engineLog = f;75}7677/**78 * Run the engine as a child process. With `timeoutMs` the child (and, via79 * setsid where available, its own chunk processes) is killed when the80 * budget runs out and the call rejects with "timed out"; the site keeps81 * serving throughout, since the work is in another process.82 */83async function run(84 args: string[],85 stdin?: string,86 timeoutMs?: number,87): Promise<string> {88 const useSetsid = timeoutMs !== undefined && await hasSetsid();89 const cmd = new Deno.Command(useSetsid ? "setsid" : enginePath(), {90 args: useSetsid ? [enginePath(), ...args] : args,91 stdin: stdin === undefined ? "null" : "piped",92 stdout: "piped",93 stderr: "piped",94 // a soft heap limit per engine process (each chunk is its own process95 // and inherits it): the box has 2 GB for everything96 env: { GOMEMLIMIT: Deno.env.get("MOSAIC_GOMEMLIMIT") ?? "256MiB" },97 });98 const child = cmd.spawn();99 if (stdin !== undefined) {100 const w = child.stdin.getWriter();101 await w.write(new TextEncoder().encode(stdin));102 await w.close();103 }104 let timedOut = false;105 let timer: number | undefined;106 if (timeoutMs !== undefined) {107 timer = setTimeout(() => {108 timedOut = true;109 try {110 // the whole process group when setsid put the engine in its own111 Deno.kill(useSetsid ? -child.pid : child.pid, "SIGKILL");112 } catch {113 try {114 child.kill("SIGKILL");115 } catch {116 // already gone117 }118 }119 }, timeoutMs);120 }121 const out = await child.output();122 if (timer !== undefined) clearTimeout(timer);123 const text = new TextDecoder().decode(out.stdout);124 const err = new TextDecoder().decode(out.stderr);125 for (const line of err.split("\n")) if (line.trim()) engineLog(line);126 if (timedOut) {127 throw new Error(`mosaic-engine ${args[0]} timed out after ${timeoutMs} ms`);128 }129 if (!out.success) {130 throw new Error(`mosaic-engine ${args[0]} failed: ${err.trim() || text}`);131 }132 return text;133}134135let setsidKnown: boolean | undefined;136async function hasSetsid(): Promise<boolean> {137 if (setsidKnown !== undefined) return setsidKnown;138 try {139 const o = await new Deno.Command("setsid", {140 args: ["--version"],141 stdout: "null",142 stderr: "null",143 }).output();144 setsidKnown = o.success;145 } catch {146 setsidKnown = false;147 }148 return setsidKnown;149}150151/**152 * Generation parameters: the field side in cells and the layout kind, or a153 * chunked field (`kind: "field"`: chunks x chunks chunks of side chunk_g,154 * generated as a checkerboard of processes by `mosaic-engine field`).155 */156export type GenParams = {157 seed: string;158 G: number;159 kind: "scatter" | "grid" | "field";160 n?: number; // grid only161 chunks?: number; // field only162 chunk_g?: number; // field only163 /** field only: allocate the field at this side (>= the chunk grid) with164 * the initial cluster centred, so rings can grow around it */165 field_g?: number;166 /** field only: pre-generate this many growth rings ("all" = until the167 * field is full); the engine emits {base, rings: [delta, ...]} */168 rings?: number | "all";169 /** field only: chunk processes at once (2 on the hosted box) */170 jobs?: number;171 /** field only: overlap band between chunk regions (24 is cheapest) */172 band?: number;173 /** field only: the engine's own overall budget, seconds */174 timeout_s?: number;175};176177/** A field with its pre-generated growth rings (deltas in order). */178export type FieldOutput = GenOutput & { rings?: GenOutput[] };179180/** The single place the generator's command line is spelled out. */181export function genArgs(p: GenParams): string[] {182 if (p.kind === "field") {183 return [184 "field",185 "--seed",186 p.seed,187 "--chunks",188 String(p.chunks ?? 3),189 "--chunk-G",190 String(p.chunk_g ?? 60),191 ...(p.field_g ? ["--G", String(p.field_g)] : []),192 ...(p.rings !== undefined ? ["--rings", String(p.rings)] : []),193 ...(p.jobs ? ["--jobs", String(p.jobs)] : []),194 ...(p.band ? ["--band", String(p.band)] : []),195 ...(p.timeout_s ? ["--timeout", `${p.timeout_s}s`] : []),196 ];197 }198 const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];199 if (p.kind === "grid") args.push("--n", String(p.n ?? 14));200 return args;201}202203let engineVersion: Promise<string> | undefined;204205/** The generator's version string (`mosaic-engine version`), read once. */206export function version(): Promise<string> {207 engineVersion ??= run(["version"]).then((t) => t.trim());208 return engineVersion;209}210211/** Generation is a batch job: results are cached by parameter hash, which212 * includes the generator version so a changed generator never serves a213 * stale board for the same seed. */214/** The cache file a generation lands in (jobs and timeout do not change215 * the output, so they are not part of the key). */216export async function cacheFile(p: GenParams): Promise<string> {217 const key = await sha256(218 `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}|${219 p.chunks ?? ""220 }|${p.chunk_g ?? ""}|${p.field_g ?? ""}|${p.rings ?? ""}|${p.band ?? ""}`,221 );222 return `${cacheDir()}${key}.json`;223}224225/** Whether a generation is already in the cache (prepared). */226export async function isCached(p: GenParams): Promise<boolean> {227 try {228 await Deno.stat(await cacheFile(p));229 return true;230 } catch {231 return false;232 }233}234235export async function gen(236 p: GenParams,237 opts: { timeoutMs?: number } = {},238): Promise<FieldOutput> {239 const file = await cacheFile(p);240 try {241 return unwrapField(JSON.parse(await Deno.readTextFile(file)));242 } catch {243 // miss244 }245 const text = await run(genArgs(p), undefined, opts.timeoutMs);246 await Deno.mkdir(cacheDir(), { recursive: true });247 // written whole, then renamed: a killed run never leaves a half file248 await Deno.writeTextFile(file + ".tmp", text);249 await Deno.rename(file + ".tmp", file);250 return unwrapField(JSON.parse(text));251}252253/**254 * `field --rings N` emits {base, rings}; everything else emits an Output.255 * Both become a FieldOutput: the base with its rings attached.256 */257export function unwrapField(raw: unknown): FieldOutput {258 const o = raw as { base?: GenOutput; rings?: GenOutput[] } & GenOutput;259 if (o.base) return { ...o.base, rings: o.rings ?? [] };260 return o;261}262263export async function judge(input: JudgeInput): Promise<JudgeOutput> {264 const text = await run(["judge"], JSON.stringify(input));265 return JSON.parse(text) as JudgeOutput;266}267268/**269 * Growth: one ring of chunks around the current field. `state` is the270 * full private output so far (manifest, merged solution, private tiles);271 * the engine returns a delta with only the new tiles. Cached like gen,272 * keyed by seed and ring (the state is a deterministic function of both).273 */274export async function grow(275 state: GenOutput,276 seed: string,277 ring: number,278): Promise<GenOutput> {279 const key = await sha256(280 `${await version()}|grow|${seed}|${ring}|${state.manifest.G}|${281 (state.manifest as { tiles: unknown[] }).tiles.length282 }`,283 );284 const dir = cacheDir();285 const file = `${dir}${key}.json`;286 try {287 return JSON.parse(await Deno.readTextFile(file)) as GenOutput;288 } catch {289 // miss290 }291 await Deno.mkdir(dir, { recursive: true });292 const stateFile = `${dir}state-${key}.json`;293 await Deno.writeTextFile(stateFile, JSON.stringify(state));294 try {295 const text = await run([296 "field",297 "--grow",298 "--seed",299 seed,300 "--state",301 stateFile,302 "--ring",303 "1", // rings to add; which ring it is follows from the state304 ]);305 await Deno.writeTextFile(file, text);306 return JSON.parse(text) as GenOutput;307 } finally {308 await Deno.remove(stateFile).catch(() => {});309 }310}311312/** Merge a growth delta into the private state (pure). */313export function mergeGrowth(state: GenOutput, delta: GenOutput): GenOutput {314 const m = state.manifest as unknown as Record<string, unknown>;315 const d = delta.manifest as unknown as Record<string, unknown>;316 return {317 manifest: {318 ...m,319 tiles: [...(m.tiles as unknown[]), ...(d.tiles as unknown[])],320 seed_tiles: [321 ...((m.seed_tiles as number[] | undefined) ?? []),322 ...((d.seed_tiles as number[] | undefined) ?? []),323 ],324 ...(d.field ? { field: d.field } : {}),325 } as unknown as AnyManifest,326 solution: delta.solution,327 tiles: [...state.tiles, ...delta.tiles],328 stats: delta.stats,329 };330}