@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
@@ -1,29 +1,48 @@
1
1
  import { SQ1_SOLVER_TABLE_METADATA } from "./table-metadata.js";
2
2
  import { fetchTableBuffer } from "../../shared/compressed-fetch.js";
3
3
  const TABLES_DIR = "puzzles/sq1/solver/generated";
4
+ const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
4
5
  let cachedBrowserTables;
6
+ let cachedBrowserTablesPromise;
5
7
  export function getCachedBrowserTables() {
6
8
  return cachedBrowserTables;
7
9
  }
8
10
  export function clearCachedBrowserTables() {
9
11
  cachedBrowserTables = undefined;
12
+ cachedBrowserTablesPromise = undefined;
10
13
  }
11
14
  export async function loadBrowserTables(baseUrl) {
12
15
  if (cachedBrowserTables) {
13
16
  return cachedBrowserTables;
14
17
  }
18
+ if (cachedBrowserTablesPromise) {
19
+ // One shared in-flight promise: concurrent calls trigger exactly one
20
+ // fetch pass (dedup).
21
+ return cachedBrowserTablesPromise;
22
+ }
23
+ const promise = fetchTables(baseUrl);
24
+ cachedBrowserTablesPromise = promise;
25
+ promise.then(() => { }, () => {
26
+ // A failed load must not poison the dedup slot: purge the rejected
27
+ // promise so a later call retries from scratch.
28
+ if (cachedBrowserTablesPromise === promise)
29
+ cachedBrowserTablesPromise = undefined;
30
+ });
31
+ return promise;
32
+ }
33
+ async function fetchTables(baseUrl) {
15
34
  const resolved = resolveBaseUrl(baseUrl);
16
35
  const manifest = await fetchManifest(resolved);
17
36
  const [shapeIdx, shapePrun, shapeTopMove, shapeBottomMove, shapeTwistMove, squareTwistMove, squareTopMove, squareBottomMove, squarePrun] = await Promise.all([
18
- fetchInt32Table(resolved, manifest.files.shapeIdx, SQ1_SOLVER_TABLE_METADATA.shapeIdx.entries),
19
- fetchInt8Table(resolved, manifest.files.shapePrun, SQ1_SOLVER_TABLE_METADATA.shapePrun.entries),
20
- fetchInt32Table(resolved, manifest.files.shapeTopMove, SQ1_SOLVER_TABLE_METADATA.shapeTopMove.entries),
21
- fetchInt32Table(resolved, manifest.files.shapeBottomMove, SQ1_SOLVER_TABLE_METADATA.shapeBottomMove.entries),
22
- fetchInt32Table(resolved, manifest.files.shapeTwistMove, SQ1_SOLVER_TABLE_METADATA.shapeTwistMove.entries),
23
- fetchUint16Table(resolved, manifest.files.squareTwistMove, SQ1_SOLVER_TABLE_METADATA.squareTwistMove.entries),
24
- fetchUint16Table(resolved, manifest.files.squareTopMove, SQ1_SOLVER_TABLE_METADATA.squareTopMove.entries),
25
- fetchUint16Table(resolved, manifest.files.squareBottomMove, SQ1_SOLVER_TABLE_METADATA.squareBottomMove.entries),
26
- fetchInt8Table(resolved, manifest.files.squarePrun, SQ1_SOLVER_TABLE_METADATA.squarePrun.entries),
37
+ fetchInt32Table(resolved, manifest.files.shapeIdx, SQ1_SOLVER_TABLE_METADATA.shapeIdx),
38
+ fetchInt8Table(resolved, manifest.files.shapePrun, SQ1_SOLVER_TABLE_METADATA.shapePrun),
39
+ fetchInt32Table(resolved, manifest.files.shapeTopMove, SQ1_SOLVER_TABLE_METADATA.shapeTopMove),
40
+ fetchInt32Table(resolved, manifest.files.shapeBottomMove, SQ1_SOLVER_TABLE_METADATA.shapeBottomMove),
41
+ fetchInt32Table(resolved, manifest.files.shapeTwistMove, SQ1_SOLVER_TABLE_METADATA.shapeTwistMove),
42
+ fetchUint16Table(resolved, manifest.files.squareTwistMove, SQ1_SOLVER_TABLE_METADATA.squareTwistMove),
43
+ fetchUint16Table(resolved, manifest.files.squareTopMove, SQ1_SOLVER_TABLE_METADATA.squareTopMove),
44
+ fetchUint16Table(resolved, manifest.files.squareBottomMove, SQ1_SOLVER_TABLE_METADATA.squareBottomMove),
45
+ fetchInt8Table(resolved, manifest.files.squarePrun, SQ1_SOLVER_TABLE_METADATA.squarePrun),
27
46
  ]);
28
47
  cachedBrowserTables = {
29
48
  shapeIdx,
@@ -59,34 +78,47 @@ async function fetchManifest(baseUrl) {
59
78
  }
60
79
  return (await response.json());
61
80
  }
62
- async function fetchInt32Table(baseUrl, entry, expectedEntries) {
63
- const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
64
- if (buffer.byteLength !== expectedEntries * 4) {
65
- throw new Error(`Invalid byte size for ${entry.file}`);
66
- }
67
- const values = new Int32Array(expectedEntries);
81
+ /**
82
+ * Fetch + verify with the SAME checks as node-loader.ts: byte length from
83
+ * the manifest entries, FNV-1a checksum against the table-metadata
84
+ * constants (node skips the checksum when that constant is 0 = "unchecked";
85
+ * the browser mirrors the guard).
86
+ */
87
+ async function fetchInt32Table(baseUrl, entry, meta) {
88
+ const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, {
89
+ cacheName: CACHE_NAME,
90
+ compressed: entry.compressed !== false,
91
+ expectedBytes: entry.entries * 4,
92
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 4 } } : {})
93
+ });
94
+ const values = new Int32Array(entry.entries);
68
95
  const view = new DataView(buffer);
69
96
  for (let i = 0; i < values.length; i++) {
70
97
  values[i] = view.getInt32(i * 4, true);
71
98
  }
72
99
  return values;
73
100
  }
74
- async function fetchUint16Table(baseUrl, entry, expectedEntries) {
75
- const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
76
- if (buffer.byteLength !== expectedEntries * 2) {
77
- throw new Error(`Invalid byte size for ${entry.file}`);
78
- }
79
- const values = new Uint16Array(expectedEntries);
101
+ async function fetchUint16Table(baseUrl, entry, meta) {
102
+ const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, {
103
+ cacheName: CACHE_NAME,
104
+ compressed: entry.compressed !== false,
105
+ expectedBytes: entry.entries * 2,
106
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 2 } } : {})
107
+ });
108
+ const values = new Uint16Array(entry.entries);
80
109
  const view = new DataView(buffer);
81
110
  for (let i = 0; i < values.length; i++) {
82
111
  values[i] = view.getUint16(i * 2, true);
83
112
  }
84
113
  return values;
85
114
  }
86
- async function fetchInt8Table(baseUrl, entry, expectedEntries) {
87
- const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
88
- if (buffer.byteLength !== expectedEntries) {
89
- throw new Error(`Invalid byte size for ${entry.file}`);
90
- }
115
+ async function fetchInt8Table(baseUrl, entry, meta) {
116
+ const buffer = await fetchTableBuffer(baseUrl, TABLES_DIR, entry.file, {
117
+ cacheName: CACHE_NAME,
118
+ compressed: entry.compressed !== false,
119
+ expectedBytes: entry.entries,
120
+ // Square-1's int8 tables hash SIGNED values in node (Int8Array).
121
+ ...(meta.checksum !== 0 ? { expectedFnv1a: { value: meta.checksum, elementSize: 1, signed: true } } : {})
122
+ });
91
123
  return new Int8Array(buffer);
92
124
  }
@@ -80,8 +80,16 @@ async function initializeScrambler(baseUrl, eventId) {
80
80
  await loadBrowserTables(baseUrl);
81
81
  }
82
82
  else if (eventId === "444") {
83
- const { loadCube4ReductionTablesBrowser } = await Promise.resolve().then(() => __importStar(require("../puzzles/cube4/reduction/browser-loader.js")));
84
- await loadCube4ReductionTablesBrowser(baseUrl);
83
+ // Reduction ends with a 3x3 solve in the same worker. Initializing 444
84
+ // must also load those required tables, including the caller's base URL.
85
+ const [cube4, cube3] = await Promise.all([
86
+ Promise.resolve().then(() => __importStar(require("../puzzles/cube4/reduction/browser-loader.js"))),
87
+ Promise.resolve().then(() => __importStar(require("../puzzles/cube3/solver/browser-loader.js")))
88
+ ]);
89
+ await Promise.all([
90
+ cube4.loadCube4ReductionTablesBrowser(baseUrl),
91
+ cube3.loadBrowserTables(baseUrl)
92
+ ]);
85
93
  }
86
94
  else if (!eventId) {
87
95
  await Promise.all([
@@ -6,23 +6,42 @@ 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/cube2/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, dist] = await Promise.all([
23
- fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.permutationTable.entries),
24
- fetchUint16Table(resolved, manifest.files.orientationTable, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.orientationTable.entries),
25
- fetchUint8Table(resolved, manifest.files.policy, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.policy.entries)
42
+ fetchUint16Table(resolved, manifest.files.permutationTable, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.permutationTable),
43
+ fetchUint16Table(resolved, manifest.files.orientationTable, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.orientationTable),
44
+ fetchUint8Table(resolved, manifest.files.policy, table_metadata_js_1.CUBE2_SOLVER_TABLE_METADATA.policy)
26
45
  ]);
27
46
  cachedBrowserTables = {
28
47
  permutationTable: permTable,
@@ -56,22 +75,31 @@ async function fetchManifest(baseUrl) {
56
75
  }
57
76
  return manifest;
58
77
  }
59
- async function fetchUint16Table(baseUrl, entry, expectedEntries) {
60
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
61
- if (buffer.byteLength !== expectedEntries * 2) {
62
- throw new Error(`Invalid byte size for ${entry.file}`);
63
- }
64
- const values = new Uint16Array(expectedEntries);
78
+ /**
79
+ * Fetch + verify with the SAME checks as node-loader.ts: byte length from
80
+ * the manifest entries, FNV-1a checksum against the table-metadata
81
+ * constants (the values node asserts against).
82
+ */
83
+ async function fetchUint16Table(baseUrl, entry, meta) {
84
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
85
+ cacheName: CACHE_NAME,
86
+ compressed: entry.compressed !== false,
87
+ expectedBytes: entry.entries * 2,
88
+ expectedFnv1a: { value: meta.checksum, elementSize: 2 }
89
+ });
90
+ const values = new Uint16Array(entry.entries);
65
91
  const view = new DataView(buffer);
66
92
  for (let i = 0; i < values.length; i++) {
67
93
  values[i] = view.getUint16(i * 2, true);
68
94
  }
69
95
  return values;
70
96
  }
71
- async function fetchUint8Table(baseUrl, entry, expectedEntries) {
72
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { compressed: entry.compressed !== false });
73
- if (buffer.byteLength !== expectedEntries) {
74
- throw new Error(`Invalid byte size for ${entry.file}`);
75
- }
97
+ async function fetchUint8Table(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,
102
+ expectedFnv1a: { value: meta.checksum, elementSize: 1 }
103
+ });
76
104
  return new Uint8Array(buffer);
77
105
  }
@@ -35,7 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadGeneratedCube2SolverTables = loadGeneratedCube2SolverTables;
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
  }
@@ -49,8 +55,7 @@ async function loadGeneratedCube2SolverTables() {
49
55
  "In browser environments, call `initializeScrambler(baseUrl)` " +
50
56
  "before generating scrambles.");
51
57
  }
52
- cachedTables ??= await loadNodeTables();
53
- return cachedTables;
58
+ return (0, load_once_js_1.loadOnce)(nodeTablesSlot, loadNodeTables);
54
59
  }
55
60
  async function loadNodeTables() {
56
61
  const loader = await Promise.resolve().then(() => __importStar(require(
@@ -7,6 +7,7 @@ const compressed_fetch_js_1 = require("../../shared/compressed-fetch.js");
7
7
  const TABLES_DIR = "puzzles/cube3/solver/generated";
8
8
  const CACHE_NAME = "cubingcenter-scrambler-tables-v1";
9
9
  let browserTables;
10
+ let browserTablesPromise;
10
11
  function resolveBaseUrl(provided) {
11
12
  if (provided) {
12
13
  return provided.replace(/\/$/, "");
@@ -25,11 +26,28 @@ function getCachedBrowserTables() {
25
26
  }
26
27
  function clearCachedBrowserTables() {
27
28
  browserTables = undefined;
29
+ browserTablesPromise = undefined;
28
30
  }
29
31
  async function loadBrowserTables(baseUrl) {
30
32
  if (browserTables) {
31
33
  return browserTables;
32
34
  }
35
+ if (browserTablesPromise) {
36
+ // One shared in-flight promise: concurrent calls trigger exactly one
37
+ // fetch pass (dedup).
38
+ return browserTablesPromise;
39
+ }
40
+ const promise = fetchTables(baseUrl);
41
+ browserTablesPromise = promise;
42
+ promise.then(() => { }, () => {
43
+ // A failed load must not poison the dedup slot: purge the rejected
44
+ // promise so a later call retries from scratch.
45
+ if (browserTablesPromise === promise)
46
+ browserTablesPromise = undefined;
47
+ });
48
+ return promise;
49
+ }
50
+ async function fetchTables(baseUrl) {
33
51
  const resolvedBase = resolveBaseUrl(baseUrl);
34
52
  const manifest = await fetchManifest(resolvedBase);
35
53
  browserTables = {
@@ -63,19 +81,15 @@ async function fetchJointTables(baseUrl, entries) {
63
81
  return undefined;
64
82
  }
65
83
  const fetchJoint = async (entry) => {
84
+ // Verified by fetchTableBuffer with the SAME checks as node-loader.ts:
85
+ // nibble-packed byte length + FNV-1a over the packed bytes.
66
86
  const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
67
87
  cacheName: CACHE_NAME,
68
- compressed: entry.compressed !== false
88
+ compressed: entry.compressed !== false,
89
+ expectedBytes: Math.ceil((entry.rangeA * entry.rangeB) / 2),
90
+ expectedFnv1a: { value: entry.checksum, elementSize: 1 }
69
91
  });
70
- const expectedBytes = Math.ceil((entry.rangeA * entry.rangeB) / 2);
71
- if (buffer.byteLength !== expectedBytes) {
72
- throw new Error(`Invalid byte size for ${entry.file}.`);
73
- }
74
- const values = new Uint8Array(buffer);
75
- if (checksum(values) !== entry.checksum) {
76
- throw new Error(`Checksum mismatch for ${entry.file}.`);
77
- }
78
- return values;
92
+ return new Uint8Array(buffer);
79
93
  };
80
94
  return {
81
95
  jointUdCo: await fetchJoint(entries.udCo),
@@ -95,11 +109,15 @@ async function fetchManifest(baseUrl) {
95
109
  return manifest;
96
110
  }
97
111
  async function fetchMoveTable(baseUrl, entry) {
98
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { cacheName: CACHE_NAME, compressed: entry.compressed !== false });
99
- const expectedBytes = entry.range * entry.moves.length * 4;
100
- if (buffer.byteLength !== expectedBytes) {
101
- throw new Error(`Invalid byte size for ${entry.coordinate} move table.`);
102
- }
112
+ // Verified by fetchTableBuffer with the SAME checks as node-loader.ts:
113
+ // int32 wire length + FNV-1a over the decoded coordinate values (element
114
+ // size 4 reproduces node's hash over the uint16 values — same numbers).
115
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
116
+ cacheName: CACHE_NAME,
117
+ compressed: entry.compressed !== false,
118
+ expectedBytes: entry.range * entry.moves.length * 4,
119
+ expectedFnv1a: { value: entry.checksum, elementSize: 4 }
120
+ });
103
121
  const values = new Uint16Array(entry.range * entry.moves.length);
104
122
  const view = new DataView(buffer);
105
123
  for (let index = 0; index < values.length; index += 1) {
@@ -119,11 +137,15 @@ async function fetchMoveTable(baseUrl, entry) {
119
137
  };
120
138
  }
121
139
  async function fetchPruningTable(baseUrl, entry) {
122
- const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, { cacheName: CACHE_NAME, compressed: entry.compressed !== false });
123
- const expectedBytes = Math.ceil(entry.range / 2);
124
- if (buffer.byteLength !== expectedBytes) {
125
- throw new Error(`Invalid byte size for ${entry.coordinate} pruning table.`);
126
- }
140
+ // Verified by fetchTableBuffer with the SAME checks as node-loader.ts:
141
+ // nibble-packed byte length + FNV-1a over the packed bytes (this browser
142
+ // path did NOT compare the pruning checksum before — only the length).
143
+ const buffer = await (0, compressed_fetch_js_1.fetchTableBuffer)(baseUrl, TABLES_DIR, entry.file, {
144
+ cacheName: CACHE_NAME,
145
+ compressed: entry.compressed !== false,
146
+ expectedBytes: Math.ceil(entry.range / 2),
147
+ expectedFnv1a: { value: entry.checksum, elementSize: 1 }
148
+ });
127
149
  const values = new Uint8Array(buffer);
128
150
  return {
129
151
  coordinate: entry.coordinate,
@@ -37,10 +37,17 @@ exports.loadGeneratedCube3SolverTables = loadGeneratedCube3SolverTables;
37
37
  exports.loadGeneratedCube3Phase1Tables = loadGeneratedCube3Phase1Tables;
38
38
  exports.loadGeneratedCube3Phase2Tables = loadGeneratedCube3Phase2Tables;
39
39
  const browser_loader_js_1 = require("./browser-loader.js");
40
- let cachedGeneratedTables;
40
+ const load_once_js_1 = require("../../shared/load-once.js");
41
41
  function isBrowser() {
42
42
  return typeof self !== "undefined";
43
43
  }
44
+ // One shared in-flight promise for the node fs load: mixed concurrent
45
+ // phase-1 + phase-2 loads (separate slots in tables.ts) converge on ONE
46
+ // `readGeneratedCube3SolverTables()` call. The previous `cached ??= await …`
47
+ // raced — two callers interleaving on the await both ran `loadNodeTables()`
48
+ // (two filesystem reads, two table sets, last assignment wins). On
49
+ // rejection the slot is purged so the next call can retry.
50
+ const nodeTablesSlot = { promise: undefined };
44
51
  async function loadGeneratedCube3SolverTables() {
45
52
  if (isBrowser()) {
46
53
  const browserTables = (0, browser_loader_js_1.getCachedBrowserTables)();
@@ -51,8 +58,7 @@ async function loadGeneratedCube3SolverTables() {
51
58
  "In browser environments, call `initializeScrambler(baseUrl)` " +
52
59
  "before generating scrambles.");
53
60
  }
54
- cachedGeneratedTables ??= await loadNodeTables();
55
- return cachedGeneratedTables;
61
+ return (0, load_once_js_1.loadOnce)(nodeTablesSlot, loadNodeTables);
56
62
  }
57
63
  async function loadNodeTables() {
58
64
  const loader = await Promise.resolve().then(() => __importStar(require(
@@ -37,7 +37,7 @@ async function solveCube3Phase1(state, options = {}) {
37
37
  const maxDepth = options.maxDepth ?? 12;
38
38
  const start = encodeCube3Phase1Coordinates(state);
39
39
  const deadline = options.timeoutMs === undefined ? undefined : performance.now() + options.timeoutMs;
40
- const context = createPhase1SearchContext(tables, jointTables, maxDepth, deadline);
40
+ const context = createPhase1SearchContext(tables, jointTables, maxDepth, deadline, options.maxNodes);
41
41
  if (await isCube3Phase1Solved(start, tables)) {
42
42
  return {
43
43
  moves: [],
@@ -79,6 +79,9 @@ async function solveCube3Phase1(state, options = {}) {
79
79
  visitedNodes: context.visitedNodes
80
80
  };
81
81
  }
82
+ if (context.nodesExceeded) {
83
+ throw new Cube3Phase1SearchError(`3x3 Phase 1 node budget exceeded (${options.maxNodes} nodes).`, context.visitedNodes);
84
+ }
82
85
  if (context.timedOut) {
83
86
  throw new Cube3Phase1SearchError(`3x3 Phase 1 search timed out after ${options.timeoutMs}ms.`, context.visitedNodes);
84
87
  }
@@ -109,8 +112,17 @@ const PHASE1_MOVE_FACES = tables_js_1.CUBE3_PHASE1_MOVES.map((move) => {
109
112
  });
110
113
  function searchPhase1(cornerOrientation, edgeOrientation, udSlice, previousFace, previousPreviousFace, remainingDepth, path, context) {
111
114
  context.visitedNodes += 1;
112
- if (context.deadline !== undefined && (context.visitedNodes & 1023) === 0 && performance.now() > context.deadline) {
115
+ if (context.nodeCap !== undefined &&
116
+ context.visitedNodes > context.nodeCap) {
117
+ context.nodesExceeded = true;
118
+ }
119
+ if (!context.nodesExceeded &&
120
+ context.deadline !== undefined &&
121
+ (context.visitedNodes & 1023) === 0 &&
122
+ performance.now() > context.deadline) {
113
123
  context.timedOut = true;
124
+ }
125
+ if (context.timedOut || context.nodesExceeded) {
114
126
  return false;
115
127
  }
116
128
  if (remainingDepth === 0) {
@@ -133,7 +145,7 @@ function searchPhase1(cornerOrientation, edgeOrientation, udSlice, previousFace,
133
145
  return true;
134
146
  }
135
147
  path.pop();
136
- if (context.timedOut) {
148
+ if (context.timedOut || context.nodesExceeded) {
137
149
  return false;
138
150
  }
139
151
  }
@@ -150,13 +162,13 @@ async function collectCube3Phase1Solutions(state, depth, options = {}) {
150
162
  const jointTables = options.jointTables ?? tables.jointTables;
151
163
  const limit = options.limit ?? 32;
152
164
  const deadline = options.timeoutMs === undefined ? undefined : performance.now() + options.timeoutMs;
153
- const context = createPhase1SearchContext(tables, jointTables, depth, deadline);
165
+ const context = createPhase1SearchContext(tables, jointTables, depth, deadline, options.maxNodes);
154
166
  const start = encodeCube3Phase1Coordinates(state);
155
167
  const solutions = [];
156
168
  searchPhase1Collect(start.cornerOrientation, start.edgeOrientation, start.udSlice, -1, -1, depth, [], solutions, limit, context);
157
169
  return { solutions, visitedNodes: context.visitedNodes };
158
170
  }
159
- function createPhase1SearchContext(tables, jointTables, maxDepth, deadline) {
171
+ function createPhase1SearchContext(tables, jointTables, maxDepth, deadline, nodeCap) {
160
172
  const scratchLevels = maxDepth + 1;
161
173
  const scratchSize = scratchLevels * tables_js_1.CUBE3_PHASE1_MOVES.length;
162
174
  return {
@@ -164,8 +176,10 @@ function createPhase1SearchContext(tables, jointTables, maxDepth, deadline) {
164
176
  jointTables,
165
177
  maxDepth,
166
178
  deadline,
179
+ nodeCap,
167
180
  visitedNodes: 0,
168
181
  timedOut: false,
182
+ nodesExceeded: false,
169
183
  scratchMove: new Int32Array(scratchSize),
170
184
  scratchBound: new Int32Array(scratchSize),
171
185
  scratchCo: new Int32Array(scratchSize),
@@ -261,8 +275,17 @@ function collectPhase1Children(cornerOrientation, edgeOrientation, udSlice, prev
261
275
  */
262
276
  function searchPhase1Collect(cornerOrientation, edgeOrientation, udSlice, previousFace, previousPreviousFace, remainingDepth, path, solutions, limit, context) {
263
277
  context.visitedNodes += 1;
264
- if (context.deadline !== undefined && (context.visitedNodes & 1023) === 0 && performance.now() > context.deadline) {
278
+ if (context.nodeCap !== undefined &&
279
+ context.visitedNodes > context.nodeCap) {
280
+ context.nodesExceeded = true;
281
+ }
282
+ if (!context.nodesExceeded &&
283
+ context.deadline !== undefined &&
284
+ (context.visitedNodes & 1023) === 0 &&
285
+ performance.now() > context.deadline) {
265
286
  context.timedOut = true;
287
+ }
288
+ if (context.timedOut || context.nodesExceeded) {
266
289
  return true;
267
290
  }
268
291
  if (remainingDepth === 0) {
@@ -55,7 +55,7 @@ async function solveCube3Phase2(state, options = {}) {
55
55
  const maxDepth = options.maxDepth ?? 18;
56
56
  const start = encodeCube3Phase2Coordinates(state);
57
57
  const deadline = options.timeoutMs === undefined ? undefined : performance.now() + options.timeoutMs;
58
- const context = createPhase2SearchContext(tables, pairTables, maxDepth, deadline);
58
+ const context = createPhase2SearchContext(tables, pairTables, maxDepth, deadline, options.maxNodes);
59
59
  const spRange = tables.moveTables.slicePermutation.range;
60
60
  const cpspTable = tables.pruningTables.cornerPermutationSlicePermutation.values;
61
61
  const epspTable = tables.pruningTables.udEdgePermutationSlicePermutation.values;
@@ -86,6 +86,9 @@ async function solveCube3Phase2(state, options = {}) {
86
86
  visitedNodes: context.visitedNodes
87
87
  };
88
88
  }
89
+ if (context.nodesExceeded) {
90
+ throw new Cube3Phase2SearchError(`3x3 Phase 2 node budget exceeded (${options.maxNodes} nodes).`, context.visitedNodes);
91
+ }
89
92
  if (context.timedOut) {
90
93
  throw new Cube3Phase2SearchError(`3x3 Phase 2 search timed out after ${options.timeoutMs}ms.`, context.visitedNodes);
91
94
  }
@@ -126,7 +129,19 @@ async function solveCube3State(state, options = {}) {
126
129
  const p2Tables = options.phase2?.tables ?? await (0, tables_js_1.loadCube3Phase2Tables)();
127
130
  const scoredPairTables = options.phase2?.pairTables ??
128
131
  (options.phase2?.skipPairTables ? undefined : await (0, pair_tables_js_1.loadCube3PairTables)());
129
- const deadline = performance.now() + (options.timeoutMs ?? 800);
132
+ // Work budget for the improvement loop. Preferred: `improveMaxNodes`
133
+ // (deterministic node counter — the wall deadline stays unarmed so the
134
+ // outcome cannot depend on CPU load/clock). Legacy: `timeoutMs ?? 800`
135
+ // wall deadline, kept for existing callers.
136
+ const nodeMode = options.improveMaxNodes !== undefined;
137
+ let remainingNodes = options.improveMaxNodes ?? 0;
138
+ const deadline = nodeMode ? undefined : performance.now() + (options.timeoutMs ?? 800);
139
+ const budgetExhausted = () => nodeMode ? remainingNodes <= 0 : performance.now() > deadline;
140
+ const spendNodes = (used) => {
141
+ if (nodeMode) {
142
+ remainingNodes = Math.max(0, remainingNodes - used);
143
+ }
144
+ };
130
145
  const p2SpRange = p2Tables.moveTables.slicePermutation.range;
131
146
  const p2cpsp = p2Tables.pruningTables.cornerPermutationSlicePermutation.values;
132
147
  const p2uepsp = p2Tables.pruningTables.udEdgePermutationSlicePermutation.values;
@@ -138,18 +153,21 @@ async function solveCube3State(state, options = {}) {
138
153
  const minDepth = phase1.depth;
139
154
  const scored = [];
140
155
  for (let d = minDepth; d <= minDepth + improveDepths && d < bestMoves.length; d++) {
141
- if (performance.now() > deadline)
156
+ if (budgetExhausted())
142
157
  break;
143
158
  const { solutions: p1Solutions, visitedNodes } = await (0, phase1_js_1.collectCube3Phase1Solutions)(state, d, {
144
159
  tables: p1Tables,
145
160
  limit: solutionLimit,
146
- timeoutMs: Math.max(1, deadline - performance.now())
161
+ ...(nodeMode
162
+ ? { maxNodes: Math.max(1, remainingNodes) }
163
+ : { timeoutMs: Math.max(1, deadline - performance.now()) })
147
164
  });
148
165
  improvementNodes += visitedNodes;
149
- if (performance.now() > deadline)
166
+ spendNodes(visitedNodes);
167
+ if (budgetExhausted())
150
168
  break;
151
169
  for (const p1Moves of p1Solutions) {
152
- if (performance.now() > deadline)
170
+ if (budgetExhausted())
153
171
  break;
154
172
  if (p1Moves.length >= bestMoves.length)
155
173
  continue;
@@ -186,7 +204,7 @@ async function solveCube3State(state, options = {}) {
186
204
  // Sort ALL candidates globally across depths by admissible total lower bound
187
205
  scored.sort((left, right) => left.est - right.est);
188
206
  for (const cand of scored) {
189
- if (performance.now() > deadline)
207
+ if (budgetExhausted())
190
208
  break;
191
209
  // Prune: since candidates are sorted by est, no subsequent candidate can beat bestMoves.length
192
210
  if (cand.est >= bestMoves.length)
@@ -195,19 +213,31 @@ async function solveCube3State(state, options = {}) {
195
213
  if (maxP2 <= 0)
196
214
  continue;
197
215
  try {
198
- const remainingBudget = Math.max(1, deadline - performance.now());
199
- const attemptTimeout = options.improvePhase2TimeoutMs ??
200
- options.phase2?.timeoutMs ?? 25;
216
+ // Strip any legacy wall `timeoutMs` from the spread: in node mode
217
+ // the wall deadline must not be armed at all (pure function of
218
+ // budgets); in wall mode the per-candidate cap below re-adds it.
219
+ const phase2Source = options.phase2 ?? {};
220
+ const { timeoutMs: legacyPhase2Wall, ...phase2CandidateBase } = phase2Source;
221
+ // Per-candidate budget: node cap (deterministic) when the loop runs
222
+ // on `improveMaxNodes`, else the legacy wall cap.
223
+ const candidateCap = nodeMode
224
+ ? {
225
+ maxNodes: Math.max(1, Math.min(options.improvePhase2MaxNodes ?? phase2CandidateBase.maxNodes ?? remainingNodes, remainingNodes))
226
+ }
227
+ : {
228
+ timeoutMs: Math.min(options.improvePhase2TimeoutMs ?? legacyPhase2Wall ?? 25, Math.max(1, deadline - performance.now()))
229
+ };
201
230
  const candP2 = await solveCube3Phase2(cand.state, {
202
- ...options.phase2,
231
+ ...phase2CandidateBase,
203
232
  pairTables: scoredPairTables,
204
233
  tables: p2Tables,
205
234
  maxDepth: maxP2,
206
235
  previousFace: cand.pFace,
207
236
  previousPreviousFace: cand.ppFace,
208
- timeoutMs: Math.min(attemptTimeout, remainingBudget)
237
+ ...candidateCap
209
238
  });
210
239
  improvementNodes += candP2.visitedNodes;
240
+ spendNodes(candP2.visitedNodes);
211
241
  const candTotal = cand.moves.length + candP2.moves.length;
212
242
  if (candTotal < bestMoves.length) {
213
243
  bestMoves = [...cand.moves, ...candP2.moves];
@@ -301,7 +331,7 @@ const PHASE2_MOVE_FACES = tables_js_1.CUBE3_PHASE2_MOVES.map((move) => {
301
331
  const char = move[0];
302
332
  return char === "U" ? 0 : char === "D" ? 1 : char === "R" ? 2 : char === "L" ? 3 : char === "F" ? 4 : 5;
303
333
  });
304
- function createPhase2SearchContext(tables, pairTables, maxDepth, deadline) {
334
+ function createPhase2SearchContext(tables, pairTables, maxDepth, deadline, nodeCap = undefined) {
305
335
  const scratchLevels = maxDepth + 1;
306
336
  const scratchSize = scratchLevels * tables_js_1.CUBE3_PHASE2_MOVES.length;
307
337
  return {
@@ -309,8 +339,10 @@ function createPhase2SearchContext(tables, pairTables, maxDepth, deadline) {
309
339
  pairTables,
310
340
  maxDepth,
311
341
  deadline,
342
+ nodeCap,
312
343
  visitedNodes: 0,
313
344
  timedOut: false,
345
+ nodesExceeded: false,
314
346
  scratchMove: new Int32Array(scratchSize),
315
347
  scratchBound: new Int32Array(scratchSize),
316
348
  scratchCp: new Int32Array(scratchSize),
@@ -320,8 +352,17 @@ function createPhase2SearchContext(tables, pairTables, maxDepth, deadline) {
320
352
  }
321
353
  function searchPhase2(cornerPermutation, udEdgePermutation, slicePermutation, previousFace, previousPreviousFace, remainingDepth, path, context) {
322
354
  context.visitedNodes += 1;
323
- if (context.deadline !== undefined && (context.visitedNodes & 1023) === 0 && performance.now() > context.deadline) {
355
+ if (context.nodeCap !== undefined &&
356
+ context.visitedNodes > context.nodeCap) {
357
+ context.nodesExceeded = true;
358
+ }
359
+ if (!context.nodesExceeded &&
360
+ context.deadline !== undefined &&
361
+ (context.visitedNodes & 1023) === 0 &&
362
+ performance.now() > context.deadline) {
324
363
  context.timedOut = true;
364
+ }
365
+ if (context.timedOut || context.nodesExceeded) {
325
366
  return false;
326
367
  }
327
368
  if (remainingDepth === 0) {
@@ -412,7 +453,7 @@ function searchPhase2(cornerPermutation, udEdgePermutation, slicePermutation, pr
412
453
  return true;
413
454
  }
414
455
  path.pop();
415
- if (context.timedOut) {
456
+ if (context.timedOut || context.nodesExceeded) {
416
457
  return false;
417
458
  }
418
459
  }