@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
@@ -5,8 +5,19 @@
5
5
  //
6
6
  // Node never uses this module: node-loader.ts reads the .bin files directly
7
7
  // from the filesystem.
8
+ //
9
+ // Integrity: callers can pass the manifest's expectations — sha256 + byte
10
+ // length (4x4 manifests) or the FNV-1a32 checksums the older manifests ship
11
+ // — and every check runs on the FINAL (decompressed) bytes, i.e. exactly the
12
+ // .bin payload: identical checks to the node loaders. A payload served by
13
+ // the Cache API that fails verification is purged (`cache.delete`) and
14
+ // re-downloaded exactly once, so a corrupt cache entry heals instead of
15
+ // failing every load forever.
8
16
  Object.defineProperty(exports, "__esModule", { value: true });
9
17
  exports.fetchTableBuffer = fetchTableBuffer;
18
+ exports.fnv1a32 = fnv1a32;
19
+ exports.sha256Hex = sha256Hex;
20
+ exports.sha256HexJs = sha256HexJs;
10
21
  // DecompressionStream only guarantees "gzip"/"deflate"; "br" support varies,
11
22
  // so probe once and never attempt formats the runtime cannot decompress.
12
23
  const SUPPORTED_FORMATS = (() => {
@@ -26,46 +37,233 @@ const SUPPORTED_FORMATS = (() => {
26
37
  })();
27
38
  async function fetchTableBuffer(baseUrl, dir, file, options = {}) {
28
39
  const urlFor = (name) => `${baseUrl}/${dir}/${name}`;
29
- const cachedFetch = options.cacheName === undefined ? undefined : makeCachedFetch(options.cacheName);
30
- if (options.compressed !== false && SUPPORTED_FORMATS.length > 0) {
31
- for (const [suffix, format] of SUPPORTED_FORMATS) {
32
- try {
33
- const response = await (cachedFetch ? cachedFetch(urlFor(`${file}.${suffix}`)) : fetch(urlFor(`${file}.${suffix}`)));
34
- if (response.ok && response.body) {
35
- const stream = response.body.pipeThrough(new DecompressionStream(format));
36
- return await new Response(stream).arrayBuffer();
40
+ const canonicalUrl = urlFor(file);
41
+ const cache = options.cacheName === undefined ? undefined : makeCacheChannel(options.cacheName);
42
+ // Cache API URLs that served the current download attempt; when the
43
+ // payload fails verification they are purged before the single retry.
44
+ let servedFromCache = [];
45
+ const get = async (url) => {
46
+ if (!cache)
47
+ return fetch(url);
48
+ const { response, fromCache } = await cache.fetch(url);
49
+ if (fromCache)
50
+ servedFromCache.push(url);
51
+ return response;
52
+ };
53
+ const download = async () => {
54
+ servedFromCache = [];
55
+ if (options.compressed !== false && SUPPORTED_FORMATS.length > 0) {
56
+ for (const [suffix, format] of SUPPORTED_FORMATS) {
57
+ try {
58
+ const response = await get(urlFor(`${file}.${suffix}`));
59
+ if (response.ok && response.body) {
60
+ const stream = response.body.pipeThrough(new DecompressionStream(format));
61
+ return await new Response(stream).arrayBuffer();
62
+ }
63
+ }
64
+ catch {
65
+ // try the next candidate
37
66
  }
38
67
  }
39
- catch {
40
- // try the next candidate
41
- }
42
68
  }
69
+ const response = await get(canonicalUrl);
70
+ if (!response.ok) {
71
+ throw new Error(`Failed to load table ${canonicalUrl}: ${response.status} ${response.statusText}` +
72
+ (options.expectedBytes !== undefined ? ` (expected ${options.expectedBytes} bytes)` : ""));
73
+ }
74
+ return response.arrayBuffer();
75
+ };
76
+ let buffer = await download();
77
+ try {
78
+ await verifyTableBuffer(buffer, canonicalUrl, options);
43
79
  }
44
- const response = await (cachedFetch ? cachedFetch(urlFor(file)) : fetch(urlFor(file)));
45
- if (!response.ok) {
46
- throw new Error(`Failed to load ${file}: ${response.status} ${response.statusText}`);
80
+ catch (error) {
81
+ // A corrupt Cache API entry fails every load until it is purged: delete
82
+ // the cached copies that served this attempt and re-download exactly
83
+ // ONCE. A second verification failure (or a payload that never came
84
+ // from the cache) propagates unchanged.
85
+ if (!cache || servedFromCache.length === 0)
86
+ throw error;
87
+ await cache.purge(servedFromCache);
88
+ buffer = await download();
89
+ await verifyTableBuffer(buffer, canonicalUrl, options);
47
90
  }
48
- return response.arrayBuffer();
91
+ return buffer;
49
92
  }
50
- function makeCachedFetch(cacheName) {
51
- return async (url) => {
52
- if (typeof caches === "undefined") {
53
- return fetch(url);
93
+ /**
94
+ * Verify the downloaded payload against the manifest: byte length first
95
+ * (clearer truncation messages), then sha256 / FNV-1a. Runs on the
96
+ * decompressed bytes, so .br/.gz variants and the raw file are checked
97
+ * identically — and identically to the node loaders.
98
+ */
99
+ async function verifyTableBuffer(buffer, url, options) {
100
+ if (options.expectedBytes !== undefined && buffer.byteLength !== options.expectedBytes) {
101
+ throw new Error(`Table size mismatch: ${url} (expected ${options.expectedBytes} bytes, got ${buffer.byteLength})`);
102
+ }
103
+ if (options.expectedSha256 !== undefined) {
104
+ const actual = await sha256Hex(buffer);
105
+ if (actual !== options.expectedSha256) {
106
+ throw new Error(`Table checksum mismatch: ${url} (expected sha256 ${options.expectedSha256}, got ${actual}, ${buffer.byteLength} bytes)`);
54
107
  }
55
- try {
56
- const cache = await caches.open(cacheName);
57
- const cached = await cache.match(url);
58
- if (cached) {
59
- return cached;
108
+ }
109
+ if (options.expectedFnv1a !== undefined) {
110
+ const spec = options.expectedFnv1a;
111
+ const actual = fnv1a32(buffer, spec.elementSize, spec.signed ?? false);
112
+ if (actual !== spec.value) {
113
+ throw new Error(`Table checksum mismatch: ${url} (expected fnv1a ${spec.value}, got ${actual}, ${buffer.byteLength} bytes)`);
114
+ }
115
+ }
116
+ }
117
+ /**
118
+ * FNV-1a (32-bit) over the elements decoded from a wire buffer: the browser
119
+ * twin of the node loaders' `checksum(values)` (imul multiply, `>>> 0`
120
+ * result). `elementSize` mirrors each loader's decoding of the manifest
121
+ * checksum — see `ExpectedFnv1a`.
122
+ */
123
+ function fnv1a32(buffer, elementSize, signed = false) {
124
+ if (buffer.byteLength % elementSize !== 0) {
125
+ throw new Error(`FNV-1a input length ${buffer.byteLength} is not a multiple of ${elementSize}-byte elements`);
126
+ }
127
+ let hash = 0x811c9dc5;
128
+ const view = new DataView(buffer);
129
+ const count = buffer.byteLength / elementSize;
130
+ for (let index = 0; index < count; index += 1) {
131
+ let value;
132
+ if (elementSize === 1)
133
+ value = signed ? view.getInt8(index) : view.getUint8(index);
134
+ else if (elementSize === 2)
135
+ value = view.getUint16(index * 2, true);
136
+ else
137
+ value = view.getInt32(index * 4, true);
138
+ hash ^= value;
139
+ hash = Math.imul(hash, 0x01000193);
140
+ }
141
+ return hash >>> 0;
142
+ }
143
+ /** sha256 hex of a buffer; WebCrypto when available, portable JS fallback otherwise. */
144
+ async function sha256Hex(buffer) {
145
+ const subtle = globalThis.crypto?.subtle;
146
+ if (subtle) {
147
+ const digest = await subtle.digest("SHA-256", buffer);
148
+ const bytes = new Uint8Array(digest);
149
+ let hex = "";
150
+ for (let i = 0; i < bytes.length; i++)
151
+ hex += bytes[i].toString(16).padStart(2, "0");
152
+ return hex;
153
+ }
154
+ // Insecure contexts expose no SubtleCrypto; keep integrity checks working
155
+ // with a self-contained implementation instead of skipping verification.
156
+ return sha256HexJs(buffer);
157
+ }
158
+ // FIPS 180-4 SHA-256, used only when `crypto.subtle` is unavailable.
159
+ const SHA256_K = new Uint32Array([
160
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
161
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
162
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
163
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
164
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
165
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
166
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
167
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
168
+ ]);
169
+ function sha256HexJs(buffer) {
170
+ const bytes = new Uint8Array(buffer);
171
+ const paddedLength = (bytes.length + 72) & ~63;
172
+ const data = new Uint8Array(paddedLength);
173
+ data.set(bytes);
174
+ data[bytes.length] = 0x80;
175
+ const view = new DataView(data.buffer);
176
+ view.setUint32(paddedLength - 8, Math.floor(bytes.length / 0x20000000));
177
+ view.setUint32(paddedLength - 4, (bytes.length << 3) >>> 0);
178
+ let h0 = 0x6a09e667;
179
+ let h1 = 0xbb67ae85;
180
+ let h2 = 0x3c6ef372;
181
+ let h3 = 0xa54ff53a;
182
+ let h4 = 0x510e527f;
183
+ let h5 = 0x9b05688c;
184
+ let h6 = 0x1f83d9ab;
185
+ let h7 = 0x5be0cd19;
186
+ const w = new Int32Array(64);
187
+ for (let offset = 0; offset < paddedLength; offset += 64) {
188
+ for (let i = 0; i < 16; i += 1)
189
+ w[i] = view.getInt32(offset + i * 4);
190
+ for (let i = 16; i < 64; i += 1) {
191
+ const w15 = w[i - 15];
192
+ const w2 = w[i - 2];
193
+ const s0 = ((w15 >>> 7) | (w15 << 25)) ^ ((w15 >>> 18) | (w15 << 14)) ^ (w15 >>> 3);
194
+ const s1 = ((w2 >>> 17) | (w2 << 15)) ^ ((w2 >>> 19) | (w2 << 13)) ^ (w2 >>> 10);
195
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
196
+ }
197
+ let a = h0;
198
+ let b = h1;
199
+ let c = h2;
200
+ let d = h3;
201
+ let e = h4;
202
+ let f = h5;
203
+ let g = h6;
204
+ let h = h7;
205
+ for (let i = 0; i < 64; i += 1) {
206
+ const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
207
+ const ch = (e & f) ^ (~e & g);
208
+ const t1 = (h + s1 + ch + SHA256_K[i] + w[i]) | 0;
209
+ const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
210
+ const maj = (a & b) ^ (a & c) ^ (b & c);
211
+ const t2 = (s0 + maj) | 0;
212
+ h = g;
213
+ g = f;
214
+ f = e;
215
+ e = (d + t1) | 0;
216
+ d = c;
217
+ c = b;
218
+ b = a;
219
+ a = (t1 + t2) | 0;
220
+ }
221
+ h0 = (h0 + a) | 0;
222
+ h1 = (h1 + b) | 0;
223
+ h2 = (h2 + c) | 0;
224
+ h3 = (h3 + d) | 0;
225
+ h4 = (h4 + e) | 0;
226
+ h5 = (h5 + f) | 0;
227
+ h6 = (h6 + g) | 0;
228
+ h7 = (h7 + h) | 0;
229
+ }
230
+ return [h0, h1, h2, h3, h4, h5, h6, h7]
231
+ .map((x) => (x >>> 0).toString(16).padStart(8, "0"))
232
+ .join("");
233
+ }
234
+ function makeCacheChannel(cacheName) {
235
+ if (typeof caches === "undefined") {
236
+ return undefined;
237
+ }
238
+ return {
239
+ async fetch(url) {
240
+ try {
241
+ const cache = await caches.open(cacheName);
242
+ const cached = await cache.match(url);
243
+ if (cached) {
244
+ return { response: cached, fromCache: true };
245
+ }
246
+ const response = await fetch(url);
247
+ if (response.ok) {
248
+ await cache.put(url, response.clone());
249
+ }
250
+ return { response, fromCache: false };
60
251
  }
61
- const response = await fetch(url);
62
- if (response.ok) {
63
- await cache.put(url, response.clone());
252
+ catch {
253
+ return { response: await fetch(url), fromCache: false };
254
+ }
255
+ },
256
+ async purge(urls) {
257
+ try {
258
+ const cache = await caches.open(cacheName);
259
+ for (const url of urls) {
260
+ await cache.delete(url);
261
+ }
262
+ }
263
+ catch {
264
+ // Best effort: a failed purge must not mask the verification error
265
+ // that triggered it — the load still fails loudly either way.
64
266
  }
65
- return response;
66
- }
67
- catch {
68
- return fetch(url);
69
267
  }
70
268
  };
71
269
  }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadOnce = loadOnce;
4
+ function loadOnce(slot, load) {
5
+ if (!slot.promise) {
6
+ const wrapped = load().catch((error) => {
7
+ if (slot.promise === wrapped)
8
+ slot.promise = undefined;
9
+ throw error;
10
+ });
11
+ slot.promise = wrapped;
12
+ }
13
+ return slot.promise;
14
+ }
@@ -6,25 +6,44 @@ 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
8
  const TABLES_DIR = "puzzles/skewb/solver/generated";
9
+ const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
9
10
  let cachedBrowserTables;
11
+ let cachedBrowserTablesPromise;
10
12
  function getCachedBrowserTables() {
11
13
  return cachedBrowserTables;
12
14
  }
13
15
  function clearCachedBrowserTables() {
14
16
  cachedBrowserTables = undefined;
17
+ cachedBrowserTablesPromise = undefined;
15
18
  }
16
19
  async function loadBrowserTables(baseUrl) {
17
20
  if (cachedBrowserTables) {
18
21
  return cachedBrowserTables;
19
22
  }
23
+ if (cachedBrowserTablesPromise) {
24
+ // One shared in-flight promise: concurrent calls trigger exactly one
25
+ // fetch pass (dedup).
26
+ return cachedBrowserTablesPromise;
27
+ }
28
+ const promise = fetchTables(baseUrl);
29
+ cachedBrowserTablesPromise = promise;
30
+ promise.then(() => { }, () => {
31
+ // A failed load must not poison the dedup slot: purge the rejected
32
+ // promise so a later call retries from scratch.
33
+ if (cachedBrowserTablesPromise === promise)
34
+ cachedBrowserTablesPromise = undefined;
35
+ });
36
+ return promise;
37
+ }
38
+ async function fetchTables(baseUrl) {
20
39
  const resolved = resolveBaseUrl(baseUrl);
21
40
  const manifest = await fetchManifest(resolved);
22
41
  const [permTable, orientTable, centersTable, helpers, policy] = await Promise.all([
23
- fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.permutationTable.entries),
24
- fetchUint16Table(resolved, manifest.files.orientationTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.orientationTable.entries),
25
- fetchUint16Table(resolved, manifest.files.centersTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.centersTable.entries),
26
- fetchUint8Table(resolved, manifest.files.helpers, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.helpers.entries),
27
- fetchUint8Table(resolved, manifest.files.policyTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.policyTable.entries)
42
+ fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.permutationTable),
43
+ fetchUint16Table(resolved, manifest.files.orientationTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.orientationTable),
44
+ fetchUint16Table(resolved, manifest.files.centersTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.centersTable),
45
+ fetchUint8Table(resolved, manifest.files.helpers, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.helpers),
46
+ fetchUint8Table(resolved, manifest.files.policyTable, table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.policyTable)
28
47
  ]);
29
48
  const helpersOffset = table_metadata_js_1.SKEWB_SOLVER_TABLE_METADATA.helpers;
30
49
  const evenCenterRank = new Uint16Array(helpersOffset.evenCenterRankBytes / 2);
@@ -70,22 +89,31 @@ async function fetchManifest(baseUrl) {
70
89
  }
71
90
  return await response.json();
72
91
  }
73
- async function fetchUint16Table(baseUrl, entry, expectedEntries) {
74
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
75
- if (buffer.byteLength !== expectedEntries * 2) {
76
- throw new Error(`Invalid byte size for ${entry.file}`);
77
- }
78
- const values = new Uint16Array(expectedEntries);
92
+ /**
93
+ * Fetch + verify with the SAME checks as node-loader.ts: byte length from
94
+ * the manifest entries, FNV-1a checksum against the table-metadata
95
+ * constants (the values node asserts against).
96
+ */
97
+ async function fetchUint16Table(baseUrl, entry, meta) {
98
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
99
+ cacheName: CACHE_NAME,
100
+ compressed: entry.compressed !== false,
101
+ expectedBytes: entry.entries * 2,
102
+ expectedFnv1a: { value: meta.checksum, elementSize: 2 }
103
+ });
104
+ const values = new Uint16Array(entry.entries);
79
105
  const view = new DataView(buffer);
80
106
  for (let i = 0; i < values.length; i++) {
81
107
  values[i] = view.getUint16(i * 2, true);
82
108
  }
83
109
  return values;
84
110
  }
85
- async function fetchUint8Table(baseUrl, entry, expectedEntries) {
86
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
87
- if (buffer.byteLength !== expectedEntries) {
88
- throw new Error(`Invalid byte size for ${entry.file}`);
89
- }
111
+ async function fetchUint8Table(baseUrl, entry, meta) {
112
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
113
+ cacheName: CACHE_NAME,
114
+ compressed: entry.compressed !== false,
115
+ expectedBytes: entry.entries,
116
+ expectedFnv1a: { value: meta.checksum, elementSize: 1 }
117
+ });
90
118
  return new Uint8Array(buffer);
91
119
  }
@@ -35,7 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadGeneratedSkewbSolverTables = loadGeneratedSkewbSolverTables;
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 loadGeneratedSkewbSolverTables() {
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(
@@ -6,29 +6,48 @@ 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
8
  const TABLES_DIR = "puzzles/sq1/solver/generated";
9
+ const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
9
10
  let cachedBrowserTables;
11
+ let cachedBrowserTablesPromise;
10
12
  function getCachedBrowserTables() {
11
13
  return cachedBrowserTables;
12
14
  }
13
15
  function clearCachedBrowserTables() {
14
16
  cachedBrowserTables = undefined;
17
+ cachedBrowserTablesPromise = undefined;
15
18
  }
16
19
  async function loadBrowserTables(baseUrl) {
17
20
  if (cachedBrowserTables) {
18
21
  return cachedBrowserTables;
19
22
  }
23
+ if (cachedBrowserTablesPromise) {
24
+ // One shared in-flight promise: concurrent calls trigger exactly one
25
+ // fetch pass (dedup).
26
+ return cachedBrowserTablesPromise;
27
+ }
28
+ const promise = fetchTables(baseUrl);
29
+ cachedBrowserTablesPromise = promise;
30
+ promise.then(() => { }, () => {
31
+ // A failed load must not poison the dedup slot: purge the rejected
32
+ // promise so a later call retries from scratch.
33
+ if (cachedBrowserTablesPromise === promise)
34
+ cachedBrowserTablesPromise = undefined;
35
+ });
36
+ return promise;
37
+ }
38
+ async function fetchTables(baseUrl) {
20
39
  const resolved = resolveBaseUrl(baseUrl);
21
40
  const manifest = await fetchManifest(resolved);
22
41
  const [shapeIdx, shapePrun, shapeTopMove, shapeBottomMove, shapeTwistMove, squareTwistMove, squareTopMove, squareBottomMove, squarePrun] = await Promise.all([
23
- fetchInt32Table(resolved, manifest.files.shapeIdx, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeIdx.entries),
24
- fetchInt8Table(resolved, manifest.files.shapePrun, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapePrun.entries),
25
- fetchInt32Table(resolved, manifest.files.shapeTopMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeTopMove.entries),
26
- fetchInt32Table(resolved, manifest.files.shapeBottomMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeBottomMove.entries),
27
- fetchInt32Table(resolved, manifest.files.shapeTwistMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeTwistMove.entries),
28
- fetchUint16Table(resolved, manifest.files.squareTwistMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareTwistMove.entries),
29
- fetchUint16Table(resolved, manifest.files.squareTopMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareTopMove.entries),
30
- fetchUint16Table(resolved, manifest.files.squareBottomMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareBottomMove.entries),
31
- fetchInt8Table(resolved, manifest.files.squarePrun, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squarePrun.entries),
42
+ fetchInt32Table(resolved, manifest.files.shapeIdx, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeIdx),
43
+ fetchInt8Table(resolved, manifest.files.shapePrun, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapePrun),
44
+ fetchInt32Table(resolved, manifest.files.shapeTopMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeTopMove),
45
+ fetchInt32Table(resolved, manifest.files.shapeBottomMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeBottomMove),
46
+ fetchInt32Table(resolved, manifest.files.shapeTwistMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.shapeTwistMove),
47
+ fetchUint16Table(resolved, manifest.files.squareTwistMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareTwistMove),
48
+ fetchUint16Table(resolved, manifest.files.squareTopMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareTopMove),
49
+ fetchUint16Table(resolved, manifest.files.squareBottomMove, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squareBottomMove),
50
+ fetchInt8Table(resolved, manifest.files.squarePrun, table_metadata_js_1.SQ1_SOLVER_TABLE_METADATA.squarePrun),
32
51
  ]);
33
52
  cachedBrowserTables = {
34
53
  shapeIdx,
@@ -64,34 +83,47 @@ async function fetchManifest(baseUrl) {
64
83
  }
65
84
  return (await response.json());
66
85
  }
67
- async function fetchInt32Table(baseUrl, entry, expectedEntries) {
68
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
69
- if (buffer.byteLength !== expectedEntries * 4) {
70
- throw new Error(`Invalid byte size for ${entry.file}`);
71
- }
72
- const values = new Int32Array(expectedEntries);
86
+ /**
87
+ * Fetch + verify with the SAME checks as node-loader.ts: byte length from
88
+ * the manifest entries, FNV-1a checksum against the table-metadata
89
+ * constants (node skips the checksum when that constant is 0 = "unchecked";
90
+ * the browser mirrors the guard).
91
+ */
92
+ async function fetchInt32Table(baseUrl, entry, meta) {
93
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
94
+ cacheName: CACHE_NAME,
95
+ compressed: entry.compressed !== false,
96
+ expectedBytes: entry.entries * 4,
97
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 4 } } : {})
98
+ });
99
+ const values = new Int32Array(entry.entries);
73
100
  const view = new DataView(buffer);
74
101
  for (let i = 0; i < values.length; i++) {
75
102
  values[i] = view.getInt32(i * 4, true);
76
103
  }
77
104
  return values;
78
105
  }
79
- async function fetchUint16Table(baseUrl, entry, expectedEntries) {
80
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
81
- if (buffer.byteLength !== expectedEntries * 2) {
82
- throw new Error(`Invalid byte size for ${entry.file}`);
83
- }
84
- const values = new Uint16Array(expectedEntries);
106
+ async function fetchUint16Table(baseUrl, entry, meta) {
107
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
108
+ cacheName: CACHE_NAME,
109
+ compressed: entry.compressed !== false,
110
+ expectedBytes: entry.entries * 2,
111
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 2 } } : {})
112
+ });
113
+ const values = new Uint16Array(entry.entries);
85
114
  const view = new DataView(buffer);
86
115
  for (let i = 0; i < values.length; i++) {
87
116
  values[i] = view.getUint16(i * 2, true);
88
117
  }
89
118
  return values;
90
119
  }
91
- async function fetchInt8Table(baseUrl, entry, expectedEntries) {
92
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
93
- if (buffer.byteLength !== expectedEntries) {
94
- throw new Error(`Invalid byte size for ${entry.file}`);
95
- }
120
+ async function fetchInt8Table(baseUrl, entry, meta) {
121
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
122
+ cacheName: CACHE_NAME,
123
+ compressed: entry.compressed !== false,
124
+ expectedBytes: entry.entries,
125
+ // Square-1's int8 tables hash SIGNED values in node (Int8Array).
126
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 1, signed: true } } : {})
127
+ });
96
128
  return new Int8Array(buffer);
97
129
  }
@@ -9,25 +9,37 @@ pnpm test
9
9
  pnpm build
10
10
  pnpm package:check
11
11
  pnpm verify
12
+ pnpm verify:scrambles
13
+ node --import tsx scripts/uniformity-444.ts --n=500
12
14
  ```
13
15
 
14
16
  - `pnpm lint`: TypeScript type check without emitting files.
15
- - `pnpm test`: Node test runner with `tsx`.
17
+ - `pnpm test`: Node test runner with `tsx`. Targeted suites exist per puzzle (`pnpm test:cube2`, `test:cube3`, `test:cube4`, …) and the runner also accepts file arguments (`pnpm test test/cube4.test.ts`).
16
18
  - `pnpm build`: ESM build, CJS build, CJS package marker, generated asset copy.
17
19
  - `pnpm package:check`: local package and consumption checks.
18
- - `pnpm verify`: full local verification for normal development.
20
+ - `pnpm verify`: full local verification for normal development — lint, tests, build, **all** solver-table checksum checks (cube2/cube3/pyraminx/skewb/sq1 and the 4x4 `cube4:{center1,center2,center3,edge3,hc}:tables:check`), then the package checks.
21
+ - `pnpm verify:scrambles [--count N] [events...]`: consistency harness. Generates scrambles with `skipValidation: true` and replays them **independently** — the target is re-sampled from the seed by the puzzle's own sampler (never derived from the emitted scramble, so the check cannot be tautological) and the solution must reach a state where the real `isSolved` predicate holds. Any mismatch exits 1. Events: `222 333 pymx skwb fto 444` (444 defaults to 25 generations).
22
+ - `node --import tsx scripts/uniformity-444.ts [--n=500] [--concurrency=4] [--threshold=…]`: χ² uniformity bench for the 4x4 pipeline over the state it **reaches** (`apply(solved, moves)`), on a fixed deterministic seed corpus: center colors per slot, wing/corner pieces per slot, twist, corner×wing parity quadrants, and joint (parity × center class / twist) bins. Prints a χ²/p-value table and exits 1 if any family falls below the threshold. P-values are reference values of a fixed corpus, not classical significance — see the interpretation notes in the script header. The lightweight companion runs inside `pnpm test` (`test/cube4-uniformity.test.ts`: direct sampler N=20,000 + full pipeline N=40 with structural checks).
19
23
 
20
- ## Solver Tables
24
+ ## Seeded generation
25
+
26
+ `{ seed }` makes generation reproducible: the seed fully determines the sampled target state (a seeded PRNG in `src/random/`), and the scramblers emit a scramble for exactly that state — for `444` the replay is validated against the sample on every generation. With the same seed **and** the same options the output is deterministic: search budgets are node counters, not wall deadlines (the only wall-clock code in the 4x4 path is a pair of hang guards that never bound an observed solve). Batch APIs and the CLI derive per-item seeds of the form `seed:index` (`generateScrambles("333", 5, { seed: "s" })` and `--seed s --count 5` both use `s:0` … `s:4`), so items are independent and earlier items do not change when the count grows.
21
27
 
22
- 3x3 solver tables are committed as generated binary assets under the cube3 solver generated directory.
28
+ ## Solver Tables
23
29
 
24
- Regenerate them only when coordinates, moves, pruning logic, or table encoding changes:
30
+ Solver tables are committed as generated binary assets per puzzle — regenerate them only when coordinates, moves, pruning logic, or table encoding changes:
25
31
 
26
32
  ```sh
27
- pnpm cube3:tables
28
- pnpm cube3:tables:check
33
+ pnpm cube3:tables && pnpm cube3:tables:check # 3x3 (also: cube2, pyraminx, skewb, sq1)
34
+ pnpm cube4:center1:tables && pnpm cube4:center1:tables:check # 4x4 reduction, phase by phase
35
+ pnpm cube4:center2:tables && pnpm cube4:center2:tables:check
36
+ pnpm cube4:center3:tables && pnpm cube4:center3:tables:check
37
+ pnpm cube4:edge3:tables && pnpm cube4:edge3:tables:check # pairing BFS (C++ generator)
38
+ pnpm cube4:hc:tables && pnpm cube4:hc:tables:check # hc PDB
29
39
  ```
30
40
 
41
+ Each `:tables:check` verifies the committed `manifest.json` checksums and fails when a `.bin` has drifted; `pnpm verify` runs them all. After regenerating, `pnpm tables:web:sync` refreshes the demo site copies (with `.gz`/`.br` variants).
42
+
31
43
  The build copies those assets into both `dist` and `dist-cjs`. Package checks fail if required generated assets are missing from the packed output.
32
44
 
33
45
  ## Public API Expectations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubingcenter/scrambler",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Extensible scramble generation toolkit for CubingCenter projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,7 +72,7 @@
72
72
  "test:watch": "node --test --watch --import tsx",
73
73
  "lint": "tsc -p tsconfig.json --noEmit",
74
74
  "package:check": "pnpm build && node scripts/check-package.cjs",
75
- "verify": "pnpm lint && pnpm test && pnpm build && pnpm cube3:tables:check && pnpm cube2:tables:check && pnpm pyraminx:tables:check && pnpm skewb:tables:check && pnpm sq1:tables:check && pnpm package:check",
75
+ "verify": "pnpm lint && pnpm test && pnpm build && pnpm cube3:tables:check && pnpm cube2:tables:check && pnpm pyraminx:tables:check && pnpm skewb:tables:check && pnpm sq1:tables:check && pnpm cube4:center1:tables:check && pnpm cube4:center2:tables:check && pnpm cube4:center3:tables:check && pnpm cube4:edge3:tables:check && pnpm cube4:hc:tables:check && pnpm package:check",
76
76
  "dev": "tsx src/cli/index.ts",
77
77
  "cube3:tables": "node --import tsx scripts/generate-cube3-solver-tables.ts",
78
78
  "cube3:tables:check": "node --import tsx scripts/check-cube3-solver-tables.ts",
@@ -103,6 +103,7 @@
103
103
  "bench:skewb": "node --import tsx bench-skewb.ts",
104
104
  "bench:pyraminx": "node --import tsx bench-pyraminx.ts",
105
105
  "bench:phases": "node --import tsx bench-phases.ts",
106
- "bench:uniformity": "node --import tsx bench-uniformity.ts"
106
+ "bench:uniformity": "node --import tsx bench-uniformity.ts",
107
+ "bench:cube4-pool": "node --import tsx scripts/bench-444-pool.ts"
107
108
  }
108
109
  }