@cubingcenter/scrambler 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +15 -4
  2. package/dist/core/api.d.ts.map +1 -1
  3. package/dist/core/api.js +10 -2
  4. package/dist/puzzles/cube2/solver/browser-loader.d.ts +4 -7
  5. package/dist/puzzles/cube2/solver/browser-loader.d.ts.map +1 -1
  6. package/dist/puzzles/cube2/solver/browser-loader.js +42 -14
  7. package/dist/puzzles/cube2/solver/generated-assets.d.ts.map +1 -1
  8. package/dist/puzzles/cube2/solver/generated-assets.js +8 -3
  9. package/dist/puzzles/cube3/solver/browser-loader.d.ts.map +1 -1
  10. package/dist/puzzles/cube3/solver/browser-loader.js +42 -20
  11. package/dist/puzzles/cube3/solver/generated-assets.d.ts.map +1 -1
  12. package/dist/puzzles/cube3/solver/generated-assets.js +9 -3
  13. package/dist/puzzles/cube3/solver/phase1.d.ts +19 -0
  14. package/dist/puzzles/cube3/solver/phase1.d.ts.map +1 -1
  15. package/dist/puzzles/cube3/solver/phase1.js +29 -6
  16. package/dist/puzzles/cube3/solver/phase2.d.ts +28 -0
  17. package/dist/puzzles/cube3/solver/phase2.d.ts.map +1 -1
  18. package/dist/puzzles/cube3/solver/phase2.js +56 -15
  19. package/dist/puzzles/cube3/solver/tables.d.ts.map +1 -1
  20. package/dist/puzzles/cube3/solver/tables.js +14 -12
  21. package/dist/puzzles/cube4/browser-worker-pool.d.ts +82 -0
  22. package/dist/puzzles/cube4/browser-worker-pool.d.ts.map +1 -0
  23. package/dist/puzzles/cube4/browser-worker-pool.js +205 -0
  24. package/dist/puzzles/cube4/browser-worker.d.ts +2 -0
  25. package/dist/puzzles/cube4/browser-worker.d.ts.map +1 -0
  26. package/dist/puzzles/cube4/browser-worker.js +31 -0
  27. package/dist/puzzles/cube4/index.d.ts +7 -0
  28. package/dist/puzzles/cube4/index.d.ts.map +1 -1
  29. package/dist/puzzles/cube4/index.js +4 -0
  30. package/dist/puzzles/cube4/moves.d.ts.map +1 -1
  31. package/dist/puzzles/cube4/moves.js +122 -30
  32. package/dist/puzzles/cube4/reduction/browser-loader.d.ts +26 -1
  33. package/dist/puzzles/cube4/reduction/browser-loader.d.ts.map +1 -1
  34. package/dist/puzzles/cube4/reduction/browser-loader.js +212 -48
  35. package/dist/puzzles/cube4/reduction/center2.d.ts +24 -0
  36. package/dist/puzzles/cube4/reduction/center2.d.ts.map +1 -1
  37. package/dist/puzzles/cube4/reduction/center2.js +99 -1
  38. package/dist/puzzles/cube4/reduction/finish3.d.ts +53 -0
  39. package/dist/puzzles/cube4/reduction/finish3.d.ts.map +1 -0
  40. package/dist/puzzles/cube4/reduction/finish3.js +393 -0
  41. package/dist/puzzles/cube4/reduction/generated/hc-manifest.json +6 -0
  42. package/dist/puzzles/cube4/reduction/hc-pdb.d.ts +5 -3
  43. package/dist/puzzles/cube4/reduction/hc-pdb.d.ts.map +1 -1
  44. package/dist/puzzles/cube4/reduction/hc-pdb.js +30 -12
  45. package/dist/puzzles/cube4/reduction/node-loader.d.ts +10 -1
  46. package/dist/puzzles/cube4/reduction/node-loader.d.ts.map +1 -1
  47. package/dist/puzzles/cube4/reduction/node-loader.js +170 -36
  48. package/dist/puzzles/cube4/reduction/pair-transversal.d.ts +28 -0
  49. package/dist/puzzles/cube4/reduction/pair-transversal.d.ts.map +1 -0
  50. package/dist/puzzles/cube4/reduction/pair-transversal.js +107 -0
  51. package/dist/puzzles/cube4/reduction/phase3-search.d.ts +33 -30
  52. package/dist/puzzles/cube4/reduction/phase3-search.d.ts.map +1 -1
  53. package/dist/puzzles/cube4/reduction/phase3-search.js +211 -104
  54. package/dist/puzzles/cube4/reduction/rotation-fix.d.ts +28 -0
  55. package/dist/puzzles/cube4/reduction/rotation-fix.d.ts.map +1 -0
  56. package/dist/puzzles/cube4/reduction/rotation-fix.js +212 -0
  57. package/dist/puzzles/cube4/reduction/solve.d.ts +72 -13
  58. package/dist/puzzles/cube4/reduction/solve.d.ts.map +1 -1
  59. package/dist/puzzles/cube4/reduction/solve.js +374 -92
  60. package/dist/puzzles/cube4/scramble-cache.d.ts +93 -0
  61. package/dist/puzzles/cube4/scramble-cache.d.ts.map +1 -0
  62. package/dist/puzzles/cube4/scramble-cache.js +150 -0
  63. package/dist/puzzles/cube4/scrambler.d.ts +112 -18
  64. package/dist/puzzles/cube4/scrambler.d.ts.map +1 -1
  65. package/dist/puzzles/cube4/scrambler.js +271 -83
  66. package/dist/puzzles/cube4/worker-pool.d.ts +139 -0
  67. package/dist/puzzles/cube4/worker-pool.d.ts.map +1 -0
  68. package/dist/puzzles/cube4/worker-pool.js +405 -0
  69. package/dist/puzzles/cube4/worker-protocol.d.ts +58 -0
  70. package/dist/puzzles/cube4/worker-protocol.d.ts.map +1 -0
  71. package/dist/puzzles/cube4/worker-protocol.js +58 -0
  72. package/dist/puzzles/cube4/worker.d.ts +26 -0
  73. package/dist/puzzles/cube4/worker.d.ts.map +1 -0
  74. package/dist/puzzles/cube4/worker.js +49 -0
  75. package/dist/puzzles/pyraminx/solver/browser-loader.d.ts +4 -8
  76. package/dist/puzzles/pyraminx/solver/browser-loader.d.ts.map +1 -1
  77. package/dist/puzzles/pyraminx/solver/browser-loader.js +44 -15
  78. package/dist/puzzles/pyraminx/solver/generated-assets.d.ts.map +1 -1
  79. package/dist/puzzles/pyraminx/solver/generated-assets.js +8 -3
  80. package/dist/puzzles/shared/compressed-fetch.d.ts +37 -1
  81. package/dist/puzzles/shared/compressed-fetch.d.ts.map +1 -1
  82. package/dist/puzzles/shared/compressed-fetch.js +226 -31
  83. package/dist/puzzles/shared/load-once.d.ts +16 -0
  84. package/dist/puzzles/shared/load-once.d.ts.map +1 -0
  85. package/dist/puzzles/shared/load-once.js +11 -0
  86. package/dist/puzzles/skewb/solver/browser-loader.d.ts +4 -11
  87. package/dist/puzzles/skewb/solver/browser-loader.d.ts.map +1 -1
  88. package/dist/puzzles/skewb/solver/browser-loader.js +44 -16
  89. package/dist/puzzles/skewb/solver/generated-assets.d.ts.map +1 -1
  90. package/dist/puzzles/skewb/solver/generated-assets.js +8 -3
  91. package/dist/puzzles/sq1/solver/browser-loader.d.ts.map +1 -1
  92. package/dist/puzzles/sq1/solver/browser-loader.js +58 -26
  93. package/dist-cjs/core/api.js +10 -2
  94. package/dist-cjs/puzzles/cube2/solver/browser-loader.js +42 -14
  95. package/dist-cjs/puzzles/cube2/solver/generated-assets.js +8 -3
  96. package/dist-cjs/puzzles/cube3/solver/browser-loader.js +42 -20
  97. package/dist-cjs/puzzles/cube3/solver/generated-assets.js +9 -3
  98. package/dist-cjs/puzzles/cube3/solver/phase1.js +29 -6
  99. package/dist-cjs/puzzles/cube3/solver/phase2.js +56 -15
  100. package/dist-cjs/puzzles/cube3/solver/tables.js +14 -12
  101. package/dist-cjs/puzzles/cube4/browser-worker-pool.js +211 -0
  102. package/dist-cjs/puzzles/cube4/index.js +21 -1
  103. package/dist-cjs/puzzles/cube4/moves.js +122 -30
  104. package/dist-cjs/puzzles/cube4/reduction/browser-loader.js +212 -48
  105. package/dist-cjs/puzzles/cube4/reduction/center2.js +102 -2
  106. package/dist-cjs/puzzles/cube4/reduction/finish3.js +398 -0
  107. package/dist-cjs/puzzles/cube4/reduction/generated/hc-manifest.json +6 -0
  108. package/dist-cjs/puzzles/cube4/reduction/hc-pdb.js +31 -12
  109. package/dist-cjs/puzzles/cube4/reduction/node-loader.js +171 -36
  110. package/dist-cjs/puzzles/cube4/reduction/pair-transversal.js +112 -0
  111. package/dist-cjs/puzzles/cube4/reduction/phase3-search.js +211 -104
  112. package/dist-cjs/puzzles/cube4/reduction/rotation-fix.js +216 -0
  113. package/dist-cjs/puzzles/cube4/reduction/solve.js +374 -88
  114. package/dist-cjs/puzzles/cube4/scramble-cache.js +156 -0
  115. package/dist-cjs/puzzles/cube4/scrambler.js +271 -80
  116. package/dist-cjs/puzzles/cube4/worker-pool.js +447 -0
  117. package/dist-cjs/puzzles/cube4/worker-protocol.js +62 -0
  118. package/dist-cjs/puzzles/pyraminx/solver/browser-loader.js +44 -15
  119. package/dist-cjs/puzzles/pyraminx/solver/generated-assets.js +8 -3
  120. package/dist-cjs/puzzles/shared/compressed-fetch.js +229 -31
  121. package/dist-cjs/puzzles/shared/load-once.js +14 -0
  122. package/dist-cjs/puzzles/skewb/solver/browser-loader.js +44 -16
  123. package/dist-cjs/puzzles/skewb/solver/generated-assets.js +8 -3
  124. package/dist-cjs/puzzles/sq1/solver/browser-loader.js +58 -26
  125. package/docs/development.md +19 -7
  126. package/package.json +4 -3
@@ -0,0 +1,447 @@
1
+ "use strict";
2
+ /**
3
+ * Worker pool for 4x4 random-state scrambles (Node.js `worker_threads`).
4
+ *
5
+ * Why: generation is CPU-bound in a single event loop — a `Promise.all`
6
+ * batch of N scrambles does not scale. This pool spreads one batch across
7
+ * N threads so `generateScrambles`-style batches grow roughly linearly
8
+ * with core count, while keeping the (seed, complete-options) → moves
9
+ * contract byte-identical to the inline path: tasks forward the FULL
10
+ * options object verbatim and `worker.ts` calls the very same
11
+ * `generateCube4Scramble`.
12
+ *
13
+ * Lifecycle:
14
+ * - Lazy spawn: threads are created on demand (one per queued job, up to
15
+ * `size`), never in the constructor. A cold first generation pays for
16
+ * table loading (≈37 MB) per spawned thread, once; the thread then
17
+ * reuses its tables for every later job (per-thread promise cache inside
18
+ * `scrambler.ts`).
19
+ * - Idle threads are `unref()`d, so a pool that is never terminated does
20
+ * not keep the process alive; in-flight jobs `ref()` their thread so an
21
+ * awaited batch always completes.
22
+ * - One task in flight per thread; extra tasks queue FIFO.
23
+ * - `terminate()` rejects everything queued/in-flight with
24
+ * `Cube4WorkerPoolClosedError` and joins all threads — after it
25
+ * resolves there are no open handles.
26
+ *
27
+ * Failure: a crashed thread only fails ITS in-flight task and is dropped
28
+ * from the pool; the next dispatch spawns a fresh thread (which reloads
29
+ * tables). Typed solver errors from inside a worker arrive as an `Error`
30
+ * with the original `name`, `message` and `code` preserved (see
31
+ * `worker-protocol.ts`).
32
+ *
33
+ * Dual-build note: this file intentionally contains no bare `import.meta`
34
+ * (it must also compile in the CommonJS build that `index.ts` is part of).
35
+ * The worker entry is located from `__filename` (CJS) or this module's top
36
+ * stack frame (ESM), and `dist-cjs` falls back to the sibling ESM tree,
37
+ * which always ships `worker.js`.
38
+ */
39
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
40
+ if (k2 === undefined) k2 = k;
41
+ var desc = Object.getOwnPropertyDescriptor(m, k);
42
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
43
+ desc = { enumerable: true, get: function() { return m[k]; } };
44
+ }
45
+ Object.defineProperty(o, k2, desc);
46
+ }) : (function(o, m, k, k2) {
47
+ if (k2 === undefined) k2 = k;
48
+ o[k2] = m[k];
49
+ }));
50
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
51
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
52
+ }) : function(o, v) {
53
+ o["default"] = v;
54
+ });
55
+ var __importStar = (this && this.__importStar) || (function () {
56
+ var ownKeys = function(o) {
57
+ ownKeys = Object.getOwnPropertyNames || function (o) {
58
+ var ar = [];
59
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
60
+ return ar;
61
+ };
62
+ return ownKeys(o);
63
+ };
64
+ return function (mod) {
65
+ if (mod && mod.__esModule) return mod;
66
+ var result = {};
67
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
68
+ __setModuleDefault(result, mod);
69
+ return result;
70
+ };
71
+ })();
72
+ Object.defineProperty(exports, "__esModule", { value: true });
73
+ exports.Cube4WorkerPool = exports.Cube4WorkerPoolClosedError = void 0;
74
+ exports.deriveBatchCube4Options = deriveBatchCube4Options;
75
+ exports.getDefaultCube4WorkerPool = getDefaultCube4WorkerPool;
76
+ exports.closeDefaultCube4WorkerPool = closeDefaultCube4WorkerPool;
77
+ exports.generateCube4ScrambleBatch = generateCube4ScrambleBatch;
78
+ const errors_js_1 = require("../../core/errors.js");
79
+ const scrambler_js_1 = require("./scrambler.js");
80
+ const worker_protocol_js_1 = require("./worker-protocol.js");
81
+ /** Typed rejection used for post-terminate operations and shutdown itself. */
82
+ class Cube4WorkerPoolClosedError extends Error {
83
+ code = "cube4-pool-closed";
84
+ constructor(action = "operation") {
85
+ super(`[cube4] worker pool is closed (${action} rejected).`);
86
+ this.name = "Cube4WorkerPoolClosedError";
87
+ }
88
+ }
89
+ exports.Cube4WorkerPoolClosedError = Cube4WorkerPoolClosedError;
90
+ /**
91
+ * Batch seed derivation — byte-for-byte the semantics of
92
+ * `deriveBatchScrambleOptions` in `src/core/api.ts` (`generateScrambles`):
93
+ * with a `seed` and `count > 1`, item i uses `${seed}:${i}`; otherwise the
94
+ * options pass through untouched. Exported so tests can assert the pool
95
+ * batch equals the sequential/core batch for the same inputs.
96
+ */
97
+ function deriveBatchCube4Options(options, index, count) {
98
+ if (count === 1 || options.seed === undefined) {
99
+ return options;
100
+ }
101
+ return { ...options, seed: `${options.seed}:${index}` };
102
+ }
103
+ const TSX_ARGV = ["--import", "tsx"];
104
+ /**
105
+ * Locates this module's own file (extension included), dual-build safe:
106
+ * CJS knows it as `__filename`; in ESM we take the top stack frame, which
107
+ * V8 reports as the module URL (`file://…`) of `worker-pool.ts|js` — the
108
+ * path is correct whether or not source maps are enabled (they only move
109
+ * line/column, never the module URL).
110
+ */
111
+ function resolveCurrentModuleLocation() {
112
+ if (typeof __filename === "string" && __filename.length > 0) {
113
+ return __filename;
114
+ }
115
+ const holder = new Error();
116
+ Error.captureStackTrace?.(holder);
117
+ const raw = holder.stack;
118
+ const candidates = [];
119
+ if (typeof raw === "string") {
120
+ for (const line of raw.split("\n")) {
121
+ const match = /^\s*at (?:(?:.*?\s+\())?(.+?):\d+:\d+\)?$/.exec(line.trim());
122
+ if (match && match[1] !== undefined)
123
+ candidates.push(match[1]);
124
+ }
125
+ }
126
+ else if (Array.isArray(raw)) {
127
+ for (const frame of raw) {
128
+ const name = frame?.getFileName?.();
129
+ if (typeof name === "string")
130
+ candidates.push(name);
131
+ }
132
+ }
133
+ for (const candidate of candidates) {
134
+ if (candidate.includes("worker-pool.")) {
135
+ return candidate.startsWith("file://") ? fileUrlToPath(candidate) : candidate;
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ /** Minimal `file://` → path conversion (global `URL`, no node:url import). */
141
+ function fileUrlToPath(value) {
142
+ try {
143
+ let pathname = decodeURIComponent(new URL(value).pathname);
144
+ // Windows file URLs keep a leading slash before the drive letter.
145
+ if (/^\/[A-Za-z]:/.test(pathname))
146
+ pathname = pathname.slice(1);
147
+ return pathname;
148
+ }
149
+ catch {
150
+ return value.replace(/^file:\/\//, "");
151
+ }
152
+ }
153
+ class Cube4WorkerPool {
154
+ workers = [];
155
+ queue = [];
156
+ explicitSize;
157
+ workerPathOption;
158
+ sizePromise;
159
+ /** Last resolved size, kept sync-readable for the pump's headroom check. */
160
+ sizeSync = 0;
161
+ pumping;
162
+ terminatePromise;
163
+ nextId = 0;
164
+ terminated = false;
165
+ constructor(options = {}) {
166
+ if (options.size !== undefined) {
167
+ if (!Number.isInteger(options.size) || options.size < 1) {
168
+ throw new RangeError(`[cube4] pool size must be an integer >= 1 (got ${options.size}).`);
169
+ }
170
+ this.sizeSync = options.size;
171
+ }
172
+ this.explicitSize = options.size;
173
+ this.workerPathOption = options.workerPath;
174
+ }
175
+ /** Maximum thread count (resolves the default lazily on first use). */
176
+ get size() {
177
+ return this.resolveSize();
178
+ }
179
+ /** Threads spawned so far (lazily grown; 0 before the first job). */
180
+ get workerCount() {
181
+ return this.workers.length;
182
+ }
183
+ get queuedCount() {
184
+ return this.queue.length;
185
+ }
186
+ get busyCount() {
187
+ return this.workers.reduce((n, rec) => n + (rec.task !== null ? 1 : 0), 0);
188
+ }
189
+ get closed() {
190
+ return this.terminated;
191
+ }
192
+ /**
193
+ * Generates one scramble on an available thread. Rejects (typed) if the
194
+ * pool is closed, the worker crashes, the options cannot be
195
+ * structured-cloned, or generation fails inside the worker.
196
+ */
197
+ generate(options = {}) {
198
+ if (this.terminated) {
199
+ return Promise.reject(new Cube4WorkerPoolClosedError("generate"));
200
+ }
201
+ const id = this.nextId++;
202
+ return new Promise((resolve, reject) => {
203
+ this.queue.push({ id, options, resolve, reject });
204
+ this.pump();
205
+ });
206
+ }
207
+ /**
208
+ * Batch generation with EXACTLY the seed derivation of core
209
+ * `generateScrambles` (see `deriveBatchCube4Options`), so results are
210
+ * identical — move for move — to the sequential path for the same
211
+ * inputs. Order of the returned array matches request order regardless
212
+ * of completion order.
213
+ */
214
+ generateBatch(count, options = {}) {
215
+ if (!Number.isInteger(count) || count < 1) {
216
+ return Promise.reject(new errors_js_1.InvalidCountError(count));
217
+ }
218
+ return Promise.all(Array.from({ length: count }, (_, index) => this.generate(deriveBatchCube4Options(options, index, count))));
219
+ }
220
+ /**
221
+ * Prompt shutdown: rejects queued and in-flight tasks with
222
+ * `Cube4WorkerPoolClosedError`, terminates every thread and resolves
223
+ * when none is left. Idempotent.
224
+ */
225
+ terminate() {
226
+ if (this.terminatePromise !== undefined)
227
+ return this.terminatePromise;
228
+ this.terminated = true;
229
+ for (const task of this.queue.splice(0)) {
230
+ task.reject(new Cube4WorkerPoolClosedError("queued task"));
231
+ }
232
+ const records = this.workers.splice(0);
233
+ const joins = [];
234
+ for (const rec of records) {
235
+ if (rec.task !== null) {
236
+ rec.task.reject(new Cube4WorkerPoolClosedError("in-flight task"));
237
+ rec.task = null;
238
+ }
239
+ joins.push(rec.worker.terminate().then(() => undefined));
240
+ }
241
+ this.terminatePromise = Promise.all(joins).then(() => undefined);
242
+ return this.terminatePromise;
243
+ }
244
+ // ── internals ────────────────────────────────────────────────────────
245
+ resolveSize() {
246
+ if (this.explicitSize !== undefined)
247
+ return Promise.resolve(this.explicitSize);
248
+ this.sizePromise ??= (async () => {
249
+ try {
250
+ // @vite-ignore: Node-only; the browser bundle never executes this.
251
+ const os = await Promise.resolve().then(() => __importStar(require(/* webpackIgnore: true */ /* @vite-ignore */ "node:os")));
252
+ const parallelism = typeof os.availableParallelism === "function"
253
+ ? os.availableParallelism()
254
+ : os.cpus().length;
255
+ this.sizeSync = Math.max(1, parallelism - 1);
256
+ }
257
+ catch {
258
+ this.sizeSync = 4;
259
+ }
260
+ return this.sizeSync;
261
+ })();
262
+ return this.sizePromise;
263
+ }
264
+ /**
265
+ * Serialised dispatcher: grows the pool (lazy spawn), hands queued jobs
266
+ * to idle threads, and stops when every thread is busy at capacity.
267
+ * Re-entry while a spawn is awaited is a no-op — the running loop's
268
+ * `while` re-checks the queue, and every completion event (`response`,
269
+ * `crash`) calls `pump()` again.
270
+ */
271
+ pump() {
272
+ if (this.pumping !== undefined || this.terminated)
273
+ return;
274
+ this.pumping = this.runPump()
275
+ .catch(() => undefined) // runPump handles failures per task; belt and braces
276
+ .finally(() => {
277
+ this.pumping = undefined;
278
+ // A task pushed by a microtask that landed between runPump's last
279
+ // queue check and this finally would have been skipped (pump was
280
+ // still "running"). Re-pump only when dispatch can make actual
281
+ // progress — an idle thread or spawn headroom — so tasks parked at
282
+ // capacity (which a completion event will pump) cannot spin here.
283
+ if (this.queue.length > 0 && !this.terminated && this.hasDispatchHeadroom()) {
284
+ this.pump();
285
+ }
286
+ });
287
+ }
288
+ hasDispatchHeadroom() {
289
+ if (this.workers.some((rec) => rec.task === null))
290
+ return true;
291
+ return this.sizeSync > 0 && this.workers.length < this.sizeSync;
292
+ }
293
+ async runPump() {
294
+ while (!this.terminated && this.queue.length > 0) {
295
+ const idle = this.workers.find((rec) => rec.task === null);
296
+ if (idle !== undefined) {
297
+ this.dispatch(idle, this.queue.shift());
298
+ continue;
299
+ }
300
+ if (this.workers.length < (await this.resolveSize())) {
301
+ let rec;
302
+ try {
303
+ rec = await this.spawnWorker();
304
+ }
305
+ catch (error) {
306
+ // Spawning failed (missing entry, bad workerPath, …): fail THIS
307
+ // task and keep trying for the rest of the queue.
308
+ this.queue.shift()?.reject(error);
309
+ continue;
310
+ }
311
+ continue; // the fresh thread is idle → dispatched on the next pass
312
+ }
313
+ break; // at capacity with every thread busy — wait for a response
314
+ }
315
+ }
316
+ async spawnWorker() {
317
+ // @vite-ignore: Node-only; never executed in a browser bundle.
318
+ const { Worker: WorkerCtor } = await Promise.resolve().then(() => __importStar(require(
319
+ /* webpackIgnore: true */ /* @vite-ignore */ "node:worker_threads")));
320
+ if (this.terminated)
321
+ throw new Cube4WorkerPoolClosedError("spawn");
322
+ const entry = this.resolveWorkerEntry();
323
+ const worker = new WorkerCtor(entry.path, { execArgv: [...entry.execArgv] });
324
+ const rec = { worker, task: null };
325
+ worker.on("message", (response) => {
326
+ this.handleResponse(rec, response);
327
+ });
328
+ worker.on("error", (error) => {
329
+ this.handleWorkerGone(rec, error);
330
+ });
331
+ worker.on("exit", (code) => {
332
+ this.handleWorkerGone(rec, new Error(`[cube4] worker exited unexpectedly (code ${code}).`));
333
+ });
334
+ this.workers.push(rec);
335
+ worker.unref(); // idle threads must not keep the process alive
336
+ return rec;
337
+ }
338
+ dispatch(rec, task) {
339
+ rec.task = task;
340
+ rec.worker.ref(); // an awaited job keeps the event loop alive
341
+ const message = { id: task.id, options: task.options };
342
+ try {
343
+ rec.worker.postMessage(message);
344
+ }
345
+ catch (error) {
346
+ // e.g. DataCloneError for non-cloneable options.metadata — the
347
+ // thread is fine; fail only this task.
348
+ rec.task = null;
349
+ rec.worker.unref();
350
+ task.reject(error);
351
+ this.pump();
352
+ }
353
+ }
354
+ handleResponse(rec, response) {
355
+ const task = rec.task;
356
+ if (task === null || task.id !== response.id)
357
+ return; // stale/unknown id
358
+ rec.task = null;
359
+ if (this.terminated)
360
+ return;
361
+ if (response.ok)
362
+ task.resolve(response.result);
363
+ else
364
+ task.reject((0, worker_protocol_js_1.deserializeCube4Error)(response.error));
365
+ const next = this.queue.shift();
366
+ if (next !== undefined)
367
+ this.dispatch(rec, next);
368
+ else
369
+ rec.worker.unref();
370
+ this.pump();
371
+ }
372
+ handleWorkerGone(rec, error) {
373
+ const index = this.workers.indexOf(rec);
374
+ if (index === -1)
375
+ return; // already removed ('error' precedes 'exit')
376
+ this.workers.splice(index, 1);
377
+ const task = rec.task;
378
+ rec.task = null;
379
+ if (task !== null && !this.terminated) {
380
+ // Only THIS thread's job fails; the rest of the queue keeps running
381
+ // (a replacement thread is spawned lazily by pump()).
382
+ task.reject(error);
383
+ }
384
+ if (!this.terminated)
385
+ this.pump();
386
+ }
387
+ resolveWorkerEntry() {
388
+ if (this.workerPathOption !== undefined) {
389
+ return {
390
+ path: this.workerPathOption,
391
+ execArgv: this.workerPathOption.endsWith(".ts") ? [...TSX_ARGV] : []
392
+ };
393
+ }
394
+ const hereRaw = resolveCurrentModuleLocation();
395
+ if (hereRaw === undefined) {
396
+ throw new Error("[cube4] cannot locate worker.ts/worker.js next to worker-pool.js; " +
397
+ "pass an explicit `workerPath` to Cube4WorkerPool.");
398
+ }
399
+ // Normalise Windows separators: forward slashes are valid in every
400
+ // Node path/URL consumer here (Worker entry, fs lookups by Node).
401
+ const here = hereRaw.replace(/\\/g, "/");
402
+ const separator = here.lastIndexOf("/");
403
+ const dir = separator === -1 ? "." : here.slice(0, separator);
404
+ if (here.endsWith(".ts")) {
405
+ // Running from source under tsx → the thread needs the tsx loader.
406
+ return { path: `${dir}/worker.ts`, execArgv: [...TSX_ARGV] };
407
+ }
408
+ const distCjs = dir.lastIndexOf("/dist-cjs");
409
+ if (distCjs !== -1) {
410
+ // The CJS build excludes the worker entry (it is Node-ESM-only); the
411
+ // ESM tree is always published alongside and always contains it.
412
+ const esmDir = `${dir.slice(0, distCjs)}/dist${dir.slice(distCjs + "/dist-cjs".length)}`;
413
+ return { path: `${esmDir}/worker.js`, execArgv: [] };
414
+ }
415
+ return { path: `${dir}/worker.js`, execArgv: [] };
416
+ }
417
+ }
418
+ exports.Cube4WorkerPool = Cube4WorkerPool;
419
+ // ── module-level conveniences ──────────────────────────────────────────
420
+ let defaultPool;
421
+ /** Shared lazily-created pool (used by `generateCube4ScrambleBatch`). */
422
+ function getDefaultCube4WorkerPool() {
423
+ defaultPool ??= new Cube4WorkerPool();
424
+ return defaultPool;
425
+ }
426
+ /** Terminates (and forgets) the shared pool — call on process shutdown. */
427
+ async function closeDefaultCube4WorkerPool() {
428
+ const pool = defaultPool;
429
+ defaultPool = undefined;
430
+ if (pool !== undefined)
431
+ await pool.terminate();
432
+ }
433
+ /**
434
+ * Batch convenience mirroring core `generateScrambles` for `444`:
435
+ * - `count <= 1` runs INLINE in the caller (no thread spawn, no cold
436
+ * start — a single scramble must never get slower);
437
+ * - `count > 1` fans out over the shared pool (same seed derivation).
438
+ */
439
+ async function generateCube4ScrambleBatch(count, options = {}) {
440
+ if (!Number.isInteger(count) || count < 1) {
441
+ throw new errors_js_1.InvalidCountError(count);
442
+ }
443
+ if (count === 1) {
444
+ return [await (0, scrambler_js_1.generateCube4Scramble)(options)];
445
+ }
446
+ return getDefaultCube4WorkerPool().generateBatch(count, options);
447
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * Message protocol shared by the 4x4 Node worker (`worker.ts`), its pool
4
+ * (`worker-pool.ts`), and the browser equivalents (`browser-worker.ts`,
5
+ * `browser-worker-pool.ts`).
6
+ *
7
+ * Design notes:
8
+ * - Tasks carry the FULL `generateCube4Scramble` options object. The
9
+ * scrambler is a pure function of (seed, complete options), so forwarding
10
+ * every option verbatim is what makes the pool output bit-identical to
11
+ * the inline path for the same inputs.
12
+ * - Errors cross the thread boundary as data (`Cube4SerializedError`):
13
+ * `name`, `message`, `code` and `stack` survive, so typed solver errors
14
+ * (e.g. `Cube4TargetMismatchError` with `code: "cube4-target-mismatch"`)
15
+ * remain discriminated on the consumer side by name/code — the same
16
+ * classification keys `scrambler.ts` itself uses. Classes cannot be
17
+ * structured-cloned, so `instanceof` does not cross the boundary
18
+ * (documented contract).
19
+ *
20
+ * This module is deliberately free of runtime imports (type-only imports
21
+ * are erased at emit) so it can be bundled for the browser worker without
22
+ * dragging Node-only code into the graph.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.serializeCube4Error = serializeCube4Error;
26
+ exports.deserializeCube4Error = deserializeCube4Error;
27
+ /**
28
+ * Flattens an unknown thrown value into the structured-clone-safe shape
29
+ * above. Non-Error values are stringified so nothing is lost silently.
30
+ */
31
+ function serializeCube4Error(error) {
32
+ if (error instanceof Error) {
33
+ const code = error.code;
34
+ const primitiveCode = typeof code === "string" || typeof code === "number" ? code : undefined;
35
+ const serialized = {
36
+ name: error.name,
37
+ message: error.message
38
+ };
39
+ if (primitiveCode !== undefined)
40
+ serialized.code = primitiveCode;
41
+ if (typeof error.stack === "string")
42
+ serialized.stack = error.stack;
43
+ return serialized;
44
+ }
45
+ return { name: "Error", message: String(error) };
46
+ }
47
+ /**
48
+ * Rebuilds an Error from the wire shape, restoring `name`, `code` and
49
+ * `stack` (stack kept only as a debugging aid — its file locations point
50
+ * into the worker thread).
51
+ */
52
+ function deserializeCube4Error(serialized) {
53
+ const error = new Error(serialized.message);
54
+ error.name = serialized.name;
55
+ if (serialized.code !== undefined) {
56
+ error.code = serialized.code;
57
+ }
58
+ if (serialized.stack !== undefined) {
59
+ error.stack = serialized.stack;
60
+ }
61
+ return error;
62
+ }
@@ -5,23 +5,43 @@ exports.clearCachedBrowserTables = clearCachedBrowserTables;
5
5
  exports.loadBrowserTables = loadBrowserTables;
6
6
  const table_metadata_js_1 = require("./table-metadata.js");
7
7
  const compressed_fetch_js_1 = require("../../shared/compressed-fetch.js");
8
+ const TABLES_DIR = "puzzles/pyraminx/solver/generated";
9
+ const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
8
10
  let cachedBrowserTables;
11
+ let cachedBrowserTablesPromise;
9
12
  function getCachedBrowserTables() {
10
13
  return cachedBrowserTables;
11
14
  }
12
15
  function clearCachedBrowserTables() {
13
16
  cachedBrowserTables = undefined;
17
+ cachedBrowserTablesPromise = undefined;
14
18
  }
15
19
  async function loadBrowserTables(baseUrl) {
16
20
  if (cachedBrowserTables)
17
21
  return cachedBrowserTables;
22
+ if (cachedBrowserTablesPromise) {
23
+ // One shared in-flight promise: concurrent calls trigger exactly one
24
+ // fetch pass (dedup).
25
+ return cachedBrowserTablesPromise;
26
+ }
27
+ const promise = fetchTables(baseUrl);
28
+ cachedBrowserTablesPromise = promise;
29
+ promise.then(() => { }, () => {
30
+ // A failed load must not poison the dedup slot: purge the rejected
31
+ // promise so a later call retries from scratch.
32
+ if (cachedBrowserTablesPromise === promise)
33
+ cachedBrowserTablesPromise = undefined;
34
+ });
35
+ return promise;
36
+ }
37
+ async function fetchTables(baseUrl) {
18
38
  const resolved = resolveBaseUrl(baseUrl);
19
39
  const manifest = await fetchManifest(resolved);
20
40
  const [centersTable, permutationTable, orientationTable, policyTable] = await Promise.all([
21
- fetchUint8Table(resolved, manifest.files.centersTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.centersTable.entries),
22
- fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.permutationTable.entries),
23
- fetchUint8Table(resolved, manifest.files.orientationTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.orientationTable.entries),
24
- fetchUint8Table(resolved, manifest.files.policyTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.policyTable.entries)
41
+ fetchUint8Table(resolved, manifest.files.centersTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.centersTable),
42
+ fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.permutationTable),
43
+ fetchUint8Table(resolved, manifest.files.orientationTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.orientationTable),
44
+ fetchUint8Table(resolved, manifest.files.policyTable, table_metadata_js_1.PYRAMINX_SOLVER_TABLE_METADATA.policyTable)
25
45
  ]);
26
46
  cachedBrowserTables = { centersTable, permutationTable, orientationTable, policyTable };
27
47
  return cachedBrowserTables;
@@ -48,19 +68,28 @@ async function fetchManifest(baseUrl) {
48
68
  throw new Error(`Unsupported pyraminx solver table manifest version: ${manifest.version}`);
49
69
  return manifest;
50
70
  }
51
- const TABLES_DIR = "puzzles/pyraminx/solver/generated";
52
- const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
53
- async function fetchUint8Table(baseUrl, entry, expectedEntries) {
54
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { cacheName: CACHE_NAME, compressed: entry.compressed !== false });
55
- if (buffer.byteLength !== expectedEntries)
56
- throw new Error(`Invalid byte size for ${entry.file}`);
71
+ /**
72
+ * Fetch + verify with the SAME checks as node-loader.ts: byte length from
73
+ * the manifest entries, FNV-1a checksum against the table-metadata
74
+ * constants (the values node asserts against).
75
+ */
76
+ async function fetchUint8Table(baseUrl, entry, meta) {
77
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
78
+ cacheName: CACHE_NAME,
79
+ compressed: entry.compressed !== false,
80
+ expectedBytes: entry.entries,
81
+ expectedFnv1a: { value: meta.checksum, elementSize: 1 }
82
+ });
57
83
  return new Uint8Array(buffer);
58
84
  }
59
- async function fetchUint16Table(baseUrl, entry, expectedEntries) {
60
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { cacheName: CACHE_NAME, compressed: entry.compressed !== false });
61
- if (buffer.byteLength !== expectedEntries * 2)
62
- throw new Error(`Invalid byte size for ${entry.file}`);
63
- const values = new Uint16Array(expectedEntries);
85
+ async function fetchUint16Table(baseUrl, entry, meta) {
86
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
87
+ cacheName: CACHE_NAME,
88
+ compressed: entry.compressed !== false,
89
+ expectedBytes: entry.entries * 2,
90
+ expectedFnv1a: { value: meta.checksum, elementSize: 2 }
91
+ });
92
+ const values = new Uint16Array(entry.entries);
64
93
  const view = new DataView(buffer);
65
94
  for (let i = 0; i < values.length; i++)
66
95
  values[i] = view.getUint16(i * 2, true);
@@ -35,7 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadGeneratedPyraminxSolverTables = loadGeneratedPyraminxSolverTables;
37
37
  const browser_loader_js_1 = require("./browser-loader.js");
38
- let cachedTables;
38
+ const load_once_js_1 = require("../../shared/load-once.js");
39
+ // One shared in-flight promise for the node fs load (same pattern as
40
+ // cube3/solver/generated-assets.ts): the previous `cached ??= await …` raced —
41
+ // concurrent callers interleaving on the await both ran `loadNodeTables()`
42
+ // (two filesystem reads, last assignment wins). On rejection the slot is
43
+ // purged so the next call can retry.
44
+ const nodeTablesSlot = { promise: undefined };
39
45
  function isBrowser() {
40
46
  return typeof self !== "undefined";
41
47
  }
@@ -48,8 +54,7 @@ async function loadGeneratedPyraminxSolverTables() {
48
54
  "In browser environments, call `initializeScrambler(baseUrl)` " +
49
55
  "before generating scrambles.");
50
56
  }
51
- cachedTables ??= await loadNodeTables();
52
- return cachedTables;
57
+ return (0, load_once_js_1.loadOnce)(nodeTablesSlot, loadNodeTables);
53
58
  }
54
59
  async function loadNodeTables() {
55
60
  const loader = await Promise.resolve().then(() => __importStar(require(