@sudobility/sudojo_types 1.2.36 → 1.2.37

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 (3) hide show
  1. package/README.md +46 -0
  2. package/package.json +6 -10
  3. package/dist/index.cjs +0 -1080
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @sudobility/sudojo_types
2
+
3
+ Shared TypeScript type definitions for the Sudojo Sudoku learning platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @sudobility/sudojo_types
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import type { Board, Daily, Level, Technique, TechniqueId } from '@sudobility/sudojo_types';
15
+ import { successResponse, errorResponse } from '@sudobility/sudojo_types';
16
+ import { techniqueToBit, hasTechnique, addTechnique } from '@sudobility/sudojo_types';
17
+ ```
18
+
19
+ ## Types
20
+
21
+ - **Entities**: `Board`, `Daily`, `Level`, `Technique`, `UserProgress`, `Challenge`
22
+ - **Enums**: `TechniqueId` (60 solving techniques)
23
+ - **Requests/Responses**: Create/update types for boards, dailies, levels, techniques
24
+ - **Utilities**: `ApiResponse<T>`, `PaginatedResponse<T>`, `successResponse()`, `errorResponse()` (re-exported from `@sudobility/types`)
25
+ - **BigInt helpers**: `techniqueToBit()`, `hasTechnique()`, `addTechnique()` for technique bitmask fields
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ bun run build # Build ESM + CJS
31
+ bun run test # Run tests once
32
+ bun run typecheck # TypeScript check
33
+ bun run lint # ESLint
34
+ bun run verify # Typecheck + lint + test + build
35
+ ```
36
+
37
+ ## Related Packages
38
+
39
+ - `@sudobility/sudojo_client` -- React Query hooks for Sudojo API
40
+ - `@sudobility/sudojo_lib` -- Business logic and game state hooks
41
+ - `sudojo_api` -- Backend API server
42
+ - `sudojo_app` / `sudojo_app_rn` -- Web and mobile apps
43
+
44
+ ## License
45
+
46
+ BUSL-1.1
package/package.json CHANGED
@@ -1,22 +1,18 @@
1
1
  {
2
2
  "name": "@sudobility/sudojo_types",
3
- "version": "1.2.36",
3
+ "version": "1.2.37",
4
4
  "description": "TypeScript types for Sudojo API - Sudoku learning platform",
5
- "main": "./dist/index.cjs",
6
- "module": "./dist/index.js",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
- "require": "./dist/index.cjs",
12
11
  "types": "./dist/index.d.ts"
13
12
  }
14
13
  },
15
14
  "scripts": {
16
- "build": "bun run build:cjs && bun run build:esm",
17
- "build:esm": "tsc -p tsconfig.esm.json",
18
- "build:cjs": "tsc -p tsconfig.cjs.json && bun run build:cjs-rename",
19
- "build:cjs-rename": "for f in dist/*.js; do mv \"$f\" \"${f%.js}.cjs\"; done",
15
+ "build": "tsc -p tsconfig.esm.json",
20
16
  "clean": "rimraf dist",
21
17
  "dev": "tsc --watch",
22
18
  "test": "vitest run",
@@ -44,11 +40,11 @@
44
40
  "author": "Sudobility",
45
41
  "license": "BUSL-1.1",
46
42
  "peerDependencies": {
47
- "@sudobility/types": "^1.9.55"
43
+ "@sudobility/types": "^1.9.57"
48
44
  },
49
45
  "devDependencies": {
50
46
  "@eslint/js": "^9.38.0",
51
- "@sudobility/types": "^1.9.55",
47
+ "@sudobility/types": "^1.9.57",
52
48
  "@typescript-eslint/eslint-plugin": "^8.46.2",
53
49
  "@typescript-eslint/parser": "^8.46.2",
54
50
  "eslint": "^9.38.0",
package/dist/index.cjs DELETED
@@ -1,1080 +0,0 @@
1
- "use strict";
2
- /**
3
- * @sudobility/sudojo-types
4
- * TypeScript types for Sudojo API - Sudoku learning platform
5
- */
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.EMPTY_PENCILMARKS = exports.EMPTY_BOARD = exports.DEFAULT_SCRAMBLE_CONFIG = exports.TOTAL_CELLS = exports.BLOCK_SIZE = exports.BOARD_SIZE = exports.BELT_ICON_VIEWBOX = exports.BELT_ICON_PATHS = exports.BELT_COLORS = exports.ALL_TECHNIQUE_IDS = exports.TechniqueId = exports.HINT_LEVEL_LIMITS = void 0;
8
- exports.successResponse = successResponse;
9
- exports.errorResponse = errorResponse;
10
- exports.techniqueToBit = techniqueToBit;
11
- exports.hasTechnique = hasTechnique;
12
- exports.addTechnique = addTechnique;
13
- exports.getBeltForLevel = getBeltForLevel;
14
- exports.getAllBelts = getAllBelts;
15
- exports.getBeltIconSvg = getBeltIconSvg;
16
- exports.getBeltIconForLevel = getBeltIconForLevel;
17
- exports.parseBoardString = parseBoardString;
18
- exports.stringifyBoard = stringifyBoard;
19
- exports.isValidBoardString = isValidBoardString;
20
- exports.scrambleBoard = scrambleBoard;
21
- exports.noScramble = noScramble;
22
- exports.isBoardFilled = isBoardFilled;
23
- exports.isBoardSolved = isBoardSolved;
24
- exports.getMergedBoardState = getMergedBoardState;
25
- exports.hasInvalidPencilmarksStep = hasInvalidPencilmarksStep;
26
- exports.hasPencilmarkContent = hasPencilmarkContent;
27
- exports.getTechniqueNameById = getTechniqueNameById;
28
- exports.cellName = cellName;
29
- exports.cellList = cellList;
30
- exports.getBlockIndex = getBlockIndex;
31
- exports.getBlockNumber = getBlockNumber;
32
- exports.indexToRowCol = indexToRowCol;
33
- exports.rowColToIndex = rowColToIndex;
34
- exports.formatTime = formatTime;
35
- exports.parseTime = parseTime;
36
- exports.formatDigits = formatDigits;
37
- exports.isValidUUID = isValidUUID;
38
- exports.validateUUID = validateUUID;
39
- exports.getTechniqueIconUrl = getTechniqueIconUrl;
40
- /** Create a success response */
41
- function successResponse(data) {
42
- return {
43
- success: true,
44
- data,
45
- timestamp: new Date().toISOString(),
46
- };
47
- }
48
- /** Create an error response */
49
- function errorResponse(error) {
50
- return {
51
- success: false,
52
- error,
53
- timestamp: new Date().toISOString(),
54
- };
55
- }
56
- /** Hint level limits by entitlement */
57
- exports.HINT_LEVEL_LIMITS = {
58
- red_belt: Infinity,
59
- blue_belt: 5,
60
- free: 3,
61
- };
62
- // =============================================================================
63
- // Technique Bitfield (matches SudokuDefines.h enum SudokuTechnique)
64
- // =============================================================================
65
- /**
66
- * Technique IDs matching the solver engine's SudokuTechnique enum.
67
- * Used as bitfield values in boards.techniques and technique_examples.techniques_bitfield.
68
- */
69
- var TechniqueId;
70
- (function (TechniqueId) {
71
- TechniqueId[TechniqueId["FULL_HOUSE"] = 1] = "FULL_HOUSE";
72
- TechniqueId[TechniqueId["HIDDEN_SINGLE"] = 2] = "HIDDEN_SINGLE";
73
- TechniqueId[TechniqueId["NAKED_SINGLE"] = 3] = "NAKED_SINGLE";
74
- TechniqueId[TechniqueId["HIDDEN_PAIR"] = 4] = "HIDDEN_PAIR";
75
- TechniqueId[TechniqueId["NAKED_PAIR"] = 5] = "NAKED_PAIR";
76
- TechniqueId[TechniqueId["LOCKED_CANDIDATES"] = 6] = "LOCKED_CANDIDATES";
77
- TechniqueId[TechniqueId["HIDDEN_TRIPLE"] = 7] = "HIDDEN_TRIPLE";
78
- TechniqueId[TechniqueId["NAKED_TRIPLE"] = 8] = "NAKED_TRIPLE";
79
- TechniqueId[TechniqueId["HIDDEN_QUAD"] = 9] = "HIDDEN_QUAD";
80
- TechniqueId[TechniqueId["NAKED_QUAD"] = 10] = "NAKED_QUAD";
81
- TechniqueId[TechniqueId["X_WING"] = 11] = "X_WING";
82
- TechniqueId[TechniqueId["SWORDFISH"] = 12] = "SWORDFISH";
83
- TechniqueId[TechniqueId["JELLYFISH"] = 13] = "JELLYFISH";
84
- TechniqueId[TechniqueId["XY_WING"] = 14] = "XY_WING";
85
- TechniqueId[TechniqueId["FINNED_X_WING"] = 15] = "FINNED_X_WING";
86
- TechniqueId[TechniqueId["SQUIRMBAG"] = 16] = "SQUIRMBAG";
87
- TechniqueId[TechniqueId["FINNED_SWORDFISH"] = 17] = "FINNED_SWORDFISH";
88
- TechniqueId[TechniqueId["FINNED_JELLYFISH"] = 18] = "FINNED_JELLYFISH";
89
- TechniqueId[TechniqueId["XYZ_WING"] = 19] = "XYZ_WING";
90
- TechniqueId[TechniqueId["WXYZ_WING"] = 20] = "WXYZ_WING";
91
- TechniqueId[TechniqueId["ALMOST_LOCKED_SETS"] = 21] = "ALMOST_LOCKED_SETS";
92
- TechniqueId[TechniqueId["FINNED_SQUIRMBAG"] = 22] = "FINNED_SQUIRMBAG";
93
- TechniqueId[TechniqueId["ALS_CHAIN"] = 23] = "ALS_CHAIN";
94
- TechniqueId[TechniqueId["SKYSCRAPER"] = 24] = "SKYSCRAPER";
95
- TechniqueId[TechniqueId["TWO_STRING_KITE"] = 25] = "TWO_STRING_KITE";
96
- TechniqueId[TechniqueId["EMPTY_RECTANGLE"] = 26] = "EMPTY_RECTANGLE";
97
- TechniqueId[TechniqueId["SIMPLE_COLORING"] = 27] = "SIMPLE_COLORING";
98
- TechniqueId[TechniqueId["W_WING"] = 28] = "W_WING";
99
- TechniqueId[TechniqueId["REMOTE_PAIRS"] = 29] = "REMOTE_PAIRS";
100
- TechniqueId[TechniqueId["UNIQUE_RECTANGLE_1"] = 30] = "UNIQUE_RECTANGLE_1";
101
- TechniqueId[TechniqueId["UNIQUE_RECTANGLE_2"] = 31] = "UNIQUE_RECTANGLE_2";
102
- TechniqueId[TechniqueId["BUG_PLUS_1"] = 32] = "BUG_PLUS_1";
103
- TechniqueId[TechniqueId["SUE_DE_COQ"] = 33] = "SUE_DE_COQ";
104
- TechniqueId[TechniqueId["ALS_XZ"] = 34] = "ALS_XZ";
105
- TechniqueId[TechniqueId["X_CYCLES"] = 35] = "X_CYCLES";
106
- TechniqueId[TechniqueId["FORCING_CHAINS"] = 36] = "FORCING_CHAINS";
107
- TechniqueId[TechniqueId["MEDUSA_COLORING"] = 37] = "MEDUSA_COLORING";
108
- TechniqueId[TechniqueId["CRANE"] = 38] = "CRANE";
109
- TechniqueId[TechniqueId["UNIQUE_RECTANGLE_3"] = 39] = "UNIQUE_RECTANGLE_3";
110
- TechniqueId[TechniqueId["UNIQUE_RECTANGLE_4"] = 40] = "UNIQUE_RECTANGLE_4";
111
- TechniqueId[TechniqueId["UNIQUE_RECTANGLE_5"] = 41] = "UNIQUE_RECTANGLE_5";
112
- TechniqueId[TechniqueId["X_CHAIN"] = 42] = "X_CHAIN";
113
- TechniqueId[TechniqueId["XY_CHAIN"] = 43] = "XY_CHAIN";
114
- TechniqueId[TechniqueId["VWXYZ_WING"] = 44] = "VWXYZ_WING";
115
- TechniqueId[TechniqueId["UVWXYZ_WING"] = 45] = "UVWXYZ_WING";
116
- TechniqueId[TechniqueId["TUVWXYZ_WING"] = 46] = "TUVWXYZ_WING";
117
- TechniqueId[TechniqueId["STUVWXYZ_WING"] = 47] = "STUVWXYZ_WING";
118
- TechniqueId[TechniqueId["AIC"] = 48] = "AIC";
119
- TechniqueId[TechniqueId["FORCING_NET"] = 49] = "FORCING_NET";
120
- TechniqueId[TechniqueId["AVOIDABLE_RECTANGLE"] = 50] = "AVOIDABLE_RECTANGLE";
121
- TechniqueId[TechniqueId["SASHIMI_X_WING"] = 51] = "SASHIMI_X_WING";
122
- TechniqueId[TechniqueId["SASHIMI_SWORDFISH"] = 52] = "SASHIMI_SWORDFISH";
123
- TechniqueId[TechniqueId["SASHIMI_JELLYFISH"] = 53] = "SASHIMI_JELLYFISH";
124
- TechniqueId[TechniqueId["HIDDEN_UNIQUE_RECTANGLE"] = 54] = "HIDDEN_UNIQUE_RECTANGLE";
125
- TechniqueId[TechniqueId["FIREWORK"] = 55] = "FIREWORK";
126
- TechniqueId[TechniqueId["DEATH_BLOSSOM"] = 56] = "DEATH_BLOSSOM";
127
- TechniqueId[TechniqueId["FRANKEN_X_WING"] = 57] = "FRANKEN_X_WING";
128
- TechniqueId[TechniqueId["FRANKEN_SWORDFISH"] = 58] = "FRANKEN_SWORDFISH";
129
- TechniqueId[TechniqueId["FRANKEN_JELLYFISH"] = 59] = "FRANKEN_JELLYFISH";
130
- TechniqueId[TechniqueId["GROUPED_X_CYCLES"] = 60] = "GROUPED_X_CYCLES";
131
- })(TechniqueId || (exports.TechniqueId = TechniqueId = {}));
132
- /** All technique IDs sorted by value (ascending) */
133
- exports.ALL_TECHNIQUE_IDS = Object.values(TechniqueId).filter((v) => typeof v === 'number');
134
- /**
135
- * Convert a TechniqueId to its bit value in the bitfield.
136
- * TechniqueId N maps to bit N (1 << N).
137
- *
138
- * Uses BigInt internally to support techniques >= 32, then converts to Number.
139
- *
140
- * **Precision warning:** Safe for technique IDs < 53 (since 2^52 is within
141
- * `Number.MAX_SAFE_INTEGER`). For IDs >= 53, the Number conversion may lose
142
- * precision. Currently all defined techniques (1-60) include IDs up to 60,
143
- * so IDs 53-60 may produce imprecise results when converted to Number.
144
- *
145
- * @param techniqueId - The technique ID from {@link TechniqueId} enum
146
- * @returns The bit value as a number (1 << techniqueId)
147
- */
148
- function techniqueToBit(techniqueId) {
149
- return Number(BigInt(1) << BigInt(techniqueId));
150
- }
151
- /**
152
- * Check if a technique is present in a bitfield.
153
- * Uses BigInt internally to support techniques >= 32.
154
- *
155
- * @param bitfield - The techniques bitmask (from {@link Board.techniques} or {@link Daily.techniques})
156
- * @param techniqueId - The technique ID to check
157
- * @returns true if the technique bit is set in the bitfield
158
- */
159
- function hasTechnique(bitfield, techniqueId) {
160
- const bit = BigInt(1) << BigInt(techniqueId);
161
- return (BigInt(bitfield) & bit) !== BigInt(0);
162
- }
163
- /**
164
- * Add a technique to a bitfield by setting its bit.
165
- * Uses BigInt internally to support techniques >= 32.
166
- *
167
- * **Precision warning:** The result is converted back to Number, which may
168
- * lose precision if the resulting bitfield exceeds `Number.MAX_SAFE_INTEGER`
169
- * (i.e., when techniques with IDs >= 53 are included).
170
- *
171
- * @param bitfield - The current techniques bitmask
172
- * @param techniqueId - The technique ID to add
173
- * @returns The updated bitmask with the technique bit set
174
- */
175
- function addTechnique(bitfield, techniqueId) {
176
- const bit = BigInt(1) << BigInt(techniqueId);
177
- return Number(BigInt(bitfield) | bit);
178
- }
179
- /** Belt colors mapped to level index (1-12) */
180
- exports.BELT_COLORS = {
181
- 1: { name: 'White', hex: '#FFFFFF' },
182
- 2: { name: 'White (Yellow Stripe)', hex: '#FFFFFF', stripeHex: '#FFEB3B' },
183
- 3: { name: 'Yellow', hex: '#FFEB3B' },
184
- 4: { name: 'Yellow (Orange Stripe)', hex: '#FFEB3B', stripeHex: '#FF9800' },
185
- 5: { name: 'Orange', hex: '#FF9800' },
186
- 6: { name: 'Orange (Green Stripe)', hex: '#FF9800', stripeHex: '#4CAF50' },
187
- 7: { name: 'Green', hex: '#4CAF50' },
188
- 8: { name: 'Blue', hex: '#2196F3' },
189
- 9: { name: 'Purple', hex: '#9C27B0' },
190
- 10: { name: 'Brown', hex: '#795548' },
191
- 11: { name: 'Red', hex: '#F44336' },
192
- 12: { name: 'Black', hex: '#212121' },
193
- };
194
- /** Get the belt for a given level index (1-12) */
195
- function getBeltForLevel(levelIndex) {
196
- return exports.BELT_COLORS[levelIndex] ?? null;
197
- }
198
- /** Get all belts as an array ordered by level */
199
- function getAllBelts() {
200
- return Object.values(exports.BELT_COLORS);
201
- }
202
- /**
203
- * SVG paths for the martial arts belt icon.
204
- * Based on Wikimedia Commons Judo belt design.
205
- * ViewBox: 0 0 478.619 184.676
206
- */
207
- exports.BELT_ICON_PATHS = [
208
- 'M192.044,46.054c0,0-1.475,4.952,0.21,7.375c1.686,2.423,24.86,1.791,24.86,1.791L205.845,45L192.044,46.054z',
209
- 'M9.831,23.198c0,0,119.181,32.087,233.779,32.087c114.598,0,214.679-51.187,218.5-53.479c3.819-2.292,12.987,38.963,0.765,48.131c-12.225,9.168-80.983,48.896-216.208,48.896c-135.226,0-233.015-21.392-239.892-29.032C-0.101,62.161-0.101,31.602,9.831,23.198z',
210
- 'M252.014,126.336c0,0-22.156-6.112-28.268-21.392c-6.111-15.279,58.827-29.795,58.827-29.795l-6.112,31.324L252.014,126.336z',
211
- 'M195.479,102.652c0,0,30.56,21.392,35.143,19.1c4.584-2.292,58.827-36.671,58.827-36.671L243.61,51.465l-50.423,38.2L195.479,102.652z',
212
- 'M22.818,152.312c0,0,125.293-76.398,200.928-106.958c75.635-30.56,30.56,29.031,30.56,29.031s-78.69,38.199-110.778,57.299s-81.746,50.424-87.858,51.188C49.558,183.635,22.818,152.312,22.818,152.312z',
213
- 'M255.967,27.303c0,0-5.29-1.851-14.146,8.46c-8.857,10.312,15.07,8.197,15.07,8.197L255.967,27.303z',
214
- 'M232.15,28.546c0,0,94.734,49.659,127.586,60.355c32.851,10.696,113.832,46.603,116.889,55.771s-27.503,30.559-27.503,30.559s-23.685-21.391-54.243-34.379c-30.56-12.987-83.274-34.379-112.306-48.131c-29.031-13.751-89.387-47.367-89.387-47.367L232.15,28.546z',
215
- 'M255.834,27.782c0,0-2.292,92.442-4.584,97.026c-2.293,4.584,42.783-12.987,43.546-18.335c0.765-5.349,6.877-50.423,2.293-55.007S260.417,25.49,255.834,27.782z',
216
- ];
217
- /** Original viewBox for the belt icon */
218
- exports.BELT_ICON_VIEWBOX = '0 0 478.619 184.676';
219
- /**
220
- * Generate a complete SVG string for the martial arts belt icon.
221
- * Uses black stroke for all colors except black belt, which uses white stroke.
222
- * Supports optional stripe color for striped belt variants.
223
- *
224
- * Stripes are placed at the tips/ends of the belt tails (as per real karate belt conventions),
225
- * not across the center of the belt.
226
- *
227
- * @param fill - Fill color for the belt
228
- * @param width - Width in pixels (default: 100)
229
- * @param height - Height in pixels (default: 40)
230
- * @param strokeColor - Stroke color (default: auto-detected based on fill)
231
- * @param stripeColor - Optional stripe color for striped belt variants (placed at belt tips)
232
- *
233
- * @example
234
- * // Get SVG for blue belt:
235
- * const svg = getBeltIconSvg('#2196F3');
236
- *
237
- * // Get SVG for black belt (auto white stroke):
238
- * const svg = getBeltIconSvg('#212121');
239
- *
240
- * // Get SVG for white belt with yellow stripe:
241
- * const svg = getBeltIconSvg('#FFFFFF', 100, 40, undefined, '#FFEB3B');
242
- *
243
- * // React with dangerouslySetInnerHTML:
244
- * <div dangerouslySetInnerHTML={{ __html: getBeltIconSvg(belt.hex, 100, 40, undefined, belt.stripeHex) }} />
245
- */
246
- function getBeltIconSvg(fill, width = 100, height = 40, strokeColor, stripeColor) {
247
- // Auto-detect stroke color: use white for dark fills (black belt)
248
- const stroke = strokeColor ??
249
- (fill.toLowerCase() === '#212121' || fill.toLowerCase() === '#000000'
250
- ? '#FFFFFF'
251
- : '#000000');
252
- const paths = exports.BELT_ICON_PATHS.map((d) => `<path fill="${fill}" stroke="${stroke}" stroke-width="4" d="${d}"/>`).join('');
253
- // Add stripes at the belt tail tips if stripeColor is provided
254
- // Real karate belt stripes are placed at the END of the belt (near the tips), not across the center
255
- // The belt has two tails: left tail (lower-left) and right tail (upper-right)
256
- const stripes = stripeColor
257
- ? `
258
- <rect x="25" y="158" width="50" height="18" fill="${stripeColor}" stroke="${stroke}" stroke-width="1.5" transform="rotate(-20, 50, 167)" clip-path="url(#beltClip)"/>
259
- <rect x="425" y="148" width="50" height="18" fill="${stripeColor}" stroke="${stroke}" stroke-width="1.5" transform="rotate(20, 450, 157)" clip-path="url(#beltClip)"/>
260
- `
261
- : '';
262
- // Create a clip path using the belt paths for proper stripe clipping
263
- const clipPath = stripeColor
264
- ? `<defs><clipPath id="beltClip">${exports.BELT_ICON_PATHS.map((d) => `<path d="${d}"/>`).join('')}</clipPath></defs>`
265
- : '';
266
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${exports.BELT_ICON_VIEWBOX}" width="${width}" height="${height}">${clipPath}${paths}${stripes}</svg>`;
267
- }
268
- /**
269
- * Generate belt icon SVG for a specific level index.
270
- * Convenience function that combines getBeltForLevel and getBeltIconSvg.
271
- * Automatically includes stripe if the belt has one.
272
- *
273
- * @param levelIndex - Level index (1-12)
274
- * @param width - Width in pixels (default: 100)
275
- * @param height - Height in pixels (default: 40)
276
- * @returns SVG string or null if level is invalid
277
- */
278
- function getBeltIconForLevel(levelIndex, width = 100, height = 40) {
279
- const belt = getBeltForLevel(levelIndex);
280
- if (!belt)
281
- return null;
282
- return getBeltIconSvg(belt.hex, width, height, undefined, belt.stripeHex);
283
- }
284
- // =============================================================================
285
- // Board Utilities (for parsing and manipulating Sudoku boards)
286
- // =============================================================================
287
- /** Board size constant (9x9 grid) */
288
- exports.BOARD_SIZE = 9;
289
- /** Block size constant (3x3 blocks) */
290
- exports.BLOCK_SIZE = 3;
291
- /** Total cells in a board */
292
- exports.TOTAL_CELLS = exports.BOARD_SIZE * exports.BOARD_SIZE;
293
- /**
294
- * Parses an 81-character board string into a 2D array of numbers
295
- * @param boardString - 81-character string where '0' or '.' represents empty cells
296
- * @returns 9x9 array of numbers (0 = empty, 1-9 = filled)
297
- */
298
- function parseBoardString(boardString) {
299
- if (boardString.length !== exports.TOTAL_CELLS) {
300
- throw new Error(`Invalid board string length: expected ${exports.TOTAL_CELLS}, got ${boardString.length}`);
301
- }
302
- const board = [];
303
- for (let row = 0; row < exports.BOARD_SIZE; row++) {
304
- const rowArray = [];
305
- for (let col = 0; col < exports.BOARD_SIZE; col++) {
306
- const index = row * exports.BOARD_SIZE + col;
307
- const char = boardString[index];
308
- if (char === undefined) {
309
- throw new Error(`Missing character at position ${index}`);
310
- }
311
- const value = char === '.' ? 0 : parseInt(char, 10);
312
- if (isNaN(value) || value < 0 || value > 9) {
313
- throw new Error(`Invalid character at position ${index}: '${char}'`);
314
- }
315
- rowArray.push(value);
316
- }
317
- board.push(rowArray);
318
- }
319
- return board;
320
- }
321
- /**
322
- * Converts a 2D number array back to an 81-character string
323
- * @param board - 9x9 array of numbers
324
- * @returns 81-character string
325
- */
326
- function stringifyBoard(board) {
327
- if (board.length !== exports.BOARD_SIZE) {
328
- throw new Error(`Invalid board rows: expected ${exports.BOARD_SIZE}, got ${board.length}`);
329
- }
330
- let result = '';
331
- for (let row = 0; row < exports.BOARD_SIZE; row++) {
332
- if (board[row]?.length !== exports.BOARD_SIZE) {
333
- throw new Error(`Invalid row ${row} length: expected ${exports.BOARD_SIZE}, got ${board[row]?.length}`);
334
- }
335
- for (let col = 0; col < exports.BOARD_SIZE; col++) {
336
- const value = board[row]?.[col];
337
- if (value === undefined || value < 0 || value > 9) {
338
- throw new Error(`Invalid value at (${row}, ${col}): ${value}`);
339
- }
340
- result += value.toString();
341
- }
342
- }
343
- return result;
344
- }
345
- /** Pattern matching a valid 81-character board string (digits 0-9 and dots) */
346
- const BOARD_STRING_PATTERN = /^[0-9.]{81}$/;
347
- /**
348
- * Validate that a string is a well-formed 81-character board string.
349
- * A valid board string consists of exactly 81 characters, each being a digit
350
- * ('0'-'9') or a dot ('.'), where '0' and '.' represent empty cells.
351
- *
352
- * This is a lightweight validation that checks format only -- it does not
353
- * verify that the board represents a solvable or valid Sudoku puzzle.
354
- *
355
- * @param s - The string to validate
356
- * @returns true if the string is a valid board string format
357
- *
358
- * @example
359
- * ```typescript
360
- * isValidBoardString('530070000600195000098000060800060003400803001700020006060000280000419005000080079'); // true
361
- * isValidBoardString('.'.repeat(81)); // true (all empty)
362
- * isValidBoardString('0'.repeat(81)); // true (all empty)
363
- * isValidBoardString('short'); // false (wrong length)
364
- * isValidBoardString('x'.repeat(81)); // false (invalid characters)
365
- * ```
366
- */
367
- function isValidBoardString(s) {
368
- return BOARD_STRING_PATTERN.test(s);
369
- }
370
- /**
371
- * Default scramble configuration (all transformations enabled)
372
- */
373
- exports.DEFAULT_SCRAMBLE_CONFIG = {
374
- scrambleRows: true,
375
- scrambleColumns: true,
376
- scrambleRowBlocks: true,
377
- scrambleColumnBlocks: true,
378
- scrambleDigits: true,
379
- rotate: true,
380
- mirror: true,
381
- };
382
- /**
383
- * Fisher-Yates shuffle algorithm for arrays.
384
- *
385
- * Uses `Math.random()` which is non-deterministic and not
386
- * cryptographically secure. Scramble results are intended for visual
387
- * variety, not for security purposes.
388
- */
389
- function shuffleArray(array) {
390
- for (let i = array.length - 1; i > 0; i--) {
391
- const j = Math.floor(Math.random() * (i + 1));
392
- const temp = array[i];
393
- array[i] = array[j];
394
- array[j] = temp;
395
- }
396
- return array;
397
- }
398
- /**
399
- * Creates a random digit mapping (1-9 -> 1-9)
400
- */
401
- function createDigitMapping() {
402
- const digits = [1, 2, 3, 4, 5, 6, 7, 8, 9];
403
- const shuffled = shuffleArray([...digits]);
404
- const mapping = new Map();
405
- for (let i = 0; i < digits.length; i++) {
406
- mapping.set(digits[i], shuffled[i]);
407
- }
408
- return mapping;
409
- }
410
- /**
411
- * Creates the reverse of a digit mapping
412
- */
413
- function reverseDigitMapping(mapping) {
414
- const reverse = new Map();
415
- for (const [original, scrambled] of mapping) {
416
- reverse.set(scrambled, original);
417
- }
418
- return reverse;
419
- }
420
- /**
421
- * Applies digit mapping to a board
422
- */
423
- function applyDigitMapping(board, mapping) {
424
- return board.map((row) => row.map((value) => {
425
- if (value === 0)
426
- return 0;
427
- return mapping.get(value) ?? value;
428
- }));
429
- }
430
- /**
431
- * Rotates the board 90 degrees clockwise
432
- */
433
- function rotateBoard90(board) {
434
- const rotated = [];
435
- for (let col = 0; col < exports.BOARD_SIZE; col++) {
436
- const newRow = [];
437
- for (let row = exports.BOARD_SIZE - 1; row >= 0; row--) {
438
- newRow.push(board[row]?.[col] ?? 0);
439
- }
440
- rotated.push(newRow);
441
- }
442
- return rotated;
443
- }
444
- /**
445
- * Mirrors the board horizontally (left-right)
446
- */
447
- function mirrorHorizontally(board) {
448
- return board.map((row) => [...row].reverse());
449
- }
450
- /**
451
- * Mirrors the board vertically (top-bottom)
452
- */
453
- function mirrorVertically(board) {
454
- return [...board].reverse().map((row) => [...row]);
455
- }
456
- /**
457
- * Scrambles a Sudoku board while preserving its logical structure.
458
- *
459
- * Scrambling preserves the logical structure of a Sudoku puzzle while making it
460
- * appear different. This is useful for:
461
- * - Preventing players from recognizing puzzles they've seen before
462
- * - Creating variety from a limited puzzle database
463
- * - Making it harder to look up solutions online
464
- *
465
- * @param puzzle - 81-character puzzle string
466
- * @param solution - 81-character solution string
467
- * @param config - Scramble configuration (defaults to all transformations enabled)
468
- * @returns Scramble result with scrambled puzzle, solution, and digit mapping
469
- *
470
- * @example
471
- * ```typescript
472
- * const result = scrambleBoard(puzzle, solution);
473
- * console.log(result.puzzle); // Scrambled puzzle
474
- * console.log(result.solution); // Scrambled solution
475
- * console.log(result.digitMapping); // Map from original to scrambled digits
476
- * ```
477
- */
478
- function scrambleBoard(puzzle, solution, config = exports.DEFAULT_SCRAMBLE_CONFIG) {
479
- // Parse the boards
480
- let puzzleBoard = parseBoardString(puzzle);
481
- let solutionBoard = parseBoardString(solution);
482
- // Create digit mapping (applied to both puzzle and solution)
483
- let digitMapping = new Map();
484
- for (let i = 1; i <= 9; i++) {
485
- digitMapping.set(i, i); // Identity mapping by default
486
- }
487
- if (config.scrambleDigits) {
488
- digitMapping = createDigitMapping();
489
- puzzleBoard = applyDigitMapping(puzzleBoard, digitMapping);
490
- solutionBoard = applyDigitMapping(solutionBoard, digitMapping);
491
- }
492
- // Scramble rows within blocks
493
- if (config.scrambleRows) {
494
- const rowPermutations = [];
495
- for (let blockRow = 0; blockRow < exports.BLOCK_SIZE; blockRow++) {
496
- rowPermutations.push(shuffleArray([0, 1, 2]));
497
- }
498
- const applyRowPermutation = (board) => {
499
- for (let blockRow = 0; blockRow < exports.BLOCK_SIZE; blockRow++) {
500
- const startRow = blockRow * exports.BLOCK_SIZE;
501
- const perm = rowPermutations[blockRow];
502
- const rowsCopy = [
503
- [...(board[startRow] ?? [])],
504
- [...(board[startRow + 1] ?? [])],
505
- [...(board[startRow + 2] ?? [])],
506
- ];
507
- for (let i = 0; i < exports.BLOCK_SIZE; i++) {
508
- board[startRow + i] = rowsCopy[perm[i]];
509
- }
510
- }
511
- };
512
- applyRowPermutation(puzzleBoard);
513
- applyRowPermutation(solutionBoard);
514
- }
515
- // Scramble columns within blocks
516
- if (config.scrambleColumns) {
517
- const colPermutations = [];
518
- for (let blockCol = 0; blockCol < exports.BLOCK_SIZE; blockCol++) {
519
- colPermutations.push(shuffleArray([0, 1, 2]));
520
- }
521
- const applyColPermutation = (board) => {
522
- for (let blockCol = 0; blockCol < exports.BLOCK_SIZE; blockCol++) {
523
- const startCol = blockCol * exports.BLOCK_SIZE;
524
- const perm = colPermutations[blockCol];
525
- for (let row = 0; row < exports.BOARD_SIZE; row++) {
526
- const boardRow = board[row];
527
- if (boardRow) {
528
- const colsCopy = [
529
- boardRow[startCol],
530
- boardRow[startCol + 1],
531
- boardRow[startCol + 2],
532
- ];
533
- for (let i = 0; i < exports.BLOCK_SIZE; i++) {
534
- boardRow[startCol + i] = colsCopy[perm[i]];
535
- }
536
- }
537
- }
538
- }
539
- };
540
- applyColPermutation(puzzleBoard);
541
- applyColPermutation(solutionBoard);
542
- }
543
- // Scramble row blocks
544
- if (config.scrambleRowBlocks) {
545
- const blockOrder = shuffleArray([0, 1, 2]);
546
- const applyRowBlockPermutation = (board) => {
547
- const allRows = board.map((row) => [...row]);
548
- const result = [];
549
- for (let newBlockIndex = 0; newBlockIndex < exports.BLOCK_SIZE; newBlockIndex++) {
550
- const oldBlockIndex = blockOrder[newBlockIndex];
551
- for (let i = 0; i < exports.BLOCK_SIZE; i++) {
552
- result.push(allRows[oldBlockIndex * exports.BLOCK_SIZE + i]);
553
- }
554
- }
555
- return result;
556
- };
557
- puzzleBoard = applyRowBlockPermutation(puzzleBoard);
558
- solutionBoard = applyRowBlockPermutation(solutionBoard);
559
- }
560
- // Scramble column blocks
561
- if (config.scrambleColumnBlocks) {
562
- const blockOrder = shuffleArray([0, 1, 2]);
563
- const applyColBlockPermutation = (board) => {
564
- const result = board.map(() => new Array(exports.BOARD_SIZE).fill(0));
565
- for (let newBlockIndex = 0; newBlockIndex < exports.BLOCK_SIZE; newBlockIndex++) {
566
- const oldBlockIndex = blockOrder[newBlockIndex];
567
- for (let i = 0; i < exports.BLOCK_SIZE; i++) {
568
- const oldCol = oldBlockIndex * exports.BLOCK_SIZE + i;
569
- const newCol = newBlockIndex * exports.BLOCK_SIZE + i;
570
- for (let row = 0; row < exports.BOARD_SIZE; row++) {
571
- const resultRow = result[row];
572
- if (resultRow) {
573
- resultRow[newCol] = board[row]?.[oldCol] ?? 0;
574
- }
575
- }
576
- }
577
- }
578
- return result;
579
- };
580
- puzzleBoard = applyColBlockPermutation(puzzleBoard);
581
- solutionBoard = applyColBlockPermutation(solutionBoard);
582
- }
583
- // Rotate
584
- if (config.rotate) {
585
- const rotations = Math.floor(Math.random() * 4);
586
- for (let i = 0; i < rotations; i++) {
587
- puzzleBoard = rotateBoard90(puzzleBoard);
588
- solutionBoard = rotateBoard90(solutionBoard);
589
- }
590
- }
591
- // Mirror
592
- if (config.mirror) {
593
- const mirrorType = Math.floor(Math.random() * 4);
594
- const applyMirror = (board) => {
595
- switch (mirrorType) {
596
- case 1:
597
- return mirrorHorizontally(board);
598
- case 2:
599
- return mirrorVertically(board);
600
- case 3:
601
- return mirrorVertically(mirrorHorizontally(board));
602
- default:
603
- return board;
604
- }
605
- };
606
- puzzleBoard = applyMirror(puzzleBoard);
607
- solutionBoard = applyMirror(solutionBoard);
608
- }
609
- return {
610
- puzzle: stringifyBoard(puzzleBoard),
611
- solution: stringifyBoard(solutionBoard),
612
- digitMapping,
613
- reverseDigitMapping: reverseDigitMapping(digitMapping),
614
- };
615
- }
616
- /**
617
- * Creates an identity scramble result (no scrambling)
618
- * @param puzzle - 81-character puzzle string
619
- * @param solution - 81-character solution string
620
- * @returns ScrambleResult with identity mapping
621
- */
622
- function noScramble(puzzle, solution) {
623
- const identityMapping = new Map();
624
- for (let i = 1; i <= 9; i++) {
625
- identityMapping.set(i, i);
626
- }
627
- return {
628
- puzzle,
629
- solution,
630
- digitMapping: identityMapping,
631
- reverseDigitMapping: identityMapping,
632
- };
633
- }
634
- // =============================================================================
635
- // Solver Utilities (shared between frontend and backend)
636
- // =============================================================================
637
- /**
638
- * Check if all cells are filled (no empty cells remaining).
639
- * A cell is considered filled if either the original puzzle has a digit
640
- * or the user has entered a digit.
641
- *
642
- * @param original - 81-char original puzzle string (0 = empty)
643
- * @param user - 81-char user input string (0 = no input)
644
- * @returns true if all 81 cells have a non-zero digit
645
- *
646
- * @example
647
- * ```typescript
648
- * const original = '530070000...'; // partial puzzle
649
- * const user = '000000000...'; // no user input yet
650
- * isBoardFilled(original, user); // false
651
- * ```
652
- */
653
- function isBoardFilled(original, user) {
654
- for (let i = 0; i < exports.TOTAL_CELLS; i++) {
655
- const originalChar = original[i] || '0';
656
- const userChar = user[i] || '0';
657
- const actualChar = userChar !== '0' ? userChar : originalChar;
658
- if (actualChar === '0')
659
- return false;
660
- }
661
- return true;
662
- }
663
- /**
664
- * Check if all cells are filled AND match the solution.
665
- * Useful for validating that a puzzle is correctly solved.
666
- *
667
- * @param original - 81-char original puzzle string (0 = empty)
668
- * @param user - 81-char user input string (0 = no input)
669
- * @param solution - 81-char solution string
670
- * @returns true if board is filled and all values match solution
671
- *
672
- * @example
673
- * ```typescript
674
- * isBoardSolved(original, userInput, solution); // true if correctly solved
675
- * ```
676
- */
677
- function isBoardSolved(original, user, solution) {
678
- for (let i = 0; i < exports.TOTAL_CELLS; i++) {
679
- const originalChar = original[i] || '0';
680
- const userChar = user[i] || '0';
681
- const solutionChar = solution[i] || '0';
682
- const actualChar = userChar !== '0' ? userChar : originalChar;
683
- if (actualChar === '0')
684
- return false;
685
- if (actualChar !== solutionChar)
686
- return false;
687
- }
688
- return true;
689
- }
690
- /**
691
- * Get the current board state by merging original puzzle and user input.
692
- * For each cell, returns the user's input if non-zero, otherwise the original value.
693
- *
694
- * @param original - 81-char original puzzle string (0 = empty)
695
- * @param user - 81-char user input string (0 = no input)
696
- * @returns 81-char string representing current board state
697
- *
698
- * @example
699
- * ```typescript
700
- * const original = '530070000...';
701
- * const user = '006000000...';
702
- * getMergedBoardState(original, user); // '536070000...'
703
- * ```
704
- */
705
- function getMergedBoardState(original, user) {
706
- let result = '';
707
- for (let i = 0; i < exports.TOTAL_CELLS; i++) {
708
- const originalChar = original[i] || '0';
709
- const userChar = user[i] || '0';
710
- result += userChar !== '0' ? userChar : originalChar;
711
- }
712
- return result;
713
- }
714
- /**
715
- * Check for "Invalid Pencilmarks" error in hint steps.
716
- * This indicates the solver detected inconsistent pencilmarks.
717
- *
718
- * @param steps - Array of hint steps from solver response
719
- * @returns true if any step has title "Invalid Pencilmarks"
720
- *
721
- * @example
722
- * ```typescript
723
- * if (hasInvalidPencilmarksStep(response.data.hints.steps)) {
724
- * throw new Error('Invalid pencilmarks detected');
725
- * }
726
- * ```
727
- */
728
- function hasInvalidPencilmarksStep(steps) {
729
- if (!steps)
730
- return false;
731
- return steps.some(step => step.title === 'Invalid Pencilmarks');
732
- }
733
- /**
734
- * Check if pencilmarks string has actual content (not just empty commas).
735
- * Pencilmarks are stored as comma-separated values like "123,45,9,,...".
736
- * An "empty" pencilmarks string would be all commas: ",,,,,...".
737
- *
738
- * @param pencilmarks - Comma-separated pencilmarks string
739
- * @returns true if there are actual pencilmark values
740
- *
741
- * @example
742
- * ```typescript
743
- * hasPencilmarkContent('123,45,,9,'); // true
744
- * hasPencilmarkContent(',,,,,,,,'); // false
745
- * hasPencilmarkContent(''); // false
746
- * ```
747
- */
748
- function hasPencilmarkContent(pencilmarks) {
749
- return pencilmarks.replace(/,/g, '').length > 0;
750
- }
751
- /**
752
- * Map from TechniqueId to display title.
753
- * Covers all defined technique IDs (1-60).
754
- */
755
- const TECHNIQUE_ID_TO_TITLE = {
756
- [TechniqueId.FULL_HOUSE]: 'Full House',
757
- [TechniqueId.HIDDEN_SINGLE]: 'Hidden Single',
758
- [TechniqueId.NAKED_SINGLE]: 'Naked Single',
759
- [TechniqueId.HIDDEN_PAIR]: 'Hidden Pair',
760
- [TechniqueId.NAKED_PAIR]: 'Naked Pair',
761
- [TechniqueId.LOCKED_CANDIDATES]: 'Locked Candidates',
762
- [TechniqueId.HIDDEN_TRIPLE]: 'Hidden Triple',
763
- [TechniqueId.NAKED_TRIPLE]: 'Naked Triple',
764
- [TechniqueId.HIDDEN_QUAD]: 'Hidden Quad',
765
- [TechniqueId.NAKED_QUAD]: 'Naked Quad',
766
- [TechniqueId.X_WING]: 'X-Wing',
767
- [TechniqueId.SWORDFISH]: 'Swordfish',
768
- [TechniqueId.JELLYFISH]: 'Jellyfish',
769
- [TechniqueId.XY_WING]: 'XY-Wing',
770
- [TechniqueId.FINNED_X_WING]: 'Finned X-Wing',
771
- [TechniqueId.SQUIRMBAG]: 'Squirmbag',
772
- [TechniqueId.FINNED_SWORDFISH]: 'Finned Swordfish',
773
- [TechniqueId.FINNED_JELLYFISH]: 'Finned Jellyfish',
774
- [TechniqueId.XYZ_WING]: 'XYZ-Wing',
775
- [TechniqueId.WXYZ_WING]: 'WXYZ-Wing',
776
- [TechniqueId.ALMOST_LOCKED_SETS]: 'Almost Locked Sets',
777
- [TechniqueId.FINNED_SQUIRMBAG]: 'Finned Squirmbag',
778
- [TechniqueId.ALS_CHAIN]: 'ALS Chain',
779
- [TechniqueId.SKYSCRAPER]: 'Skyscraper',
780
- [TechniqueId.TWO_STRING_KITE]: 'Two-String Kite',
781
- [TechniqueId.EMPTY_RECTANGLE]: 'Empty Rectangle',
782
- [TechniqueId.SIMPLE_COLORING]: 'Simple Coloring',
783
- [TechniqueId.W_WING]: 'W-Wing',
784
- [TechniqueId.REMOTE_PAIRS]: 'Remote Pairs',
785
- [TechniqueId.UNIQUE_RECTANGLE_1]: 'Unique Rectangle Type 1',
786
- [TechniqueId.UNIQUE_RECTANGLE_2]: 'Unique Rectangle Type 2',
787
- [TechniqueId.BUG_PLUS_1]: 'BUG+1',
788
- [TechniqueId.SUE_DE_COQ]: 'Sue de Coq',
789
- [TechniqueId.ALS_XZ]: 'ALS-XZ',
790
- [TechniqueId.X_CYCLES]: 'X-Cycles',
791
- [TechniqueId.FORCING_CHAINS]: 'Forcing Chains',
792
- [TechniqueId.MEDUSA_COLORING]: '3D Medusa',
793
- [TechniqueId.CRANE]: 'Crane',
794
- [TechniqueId.UNIQUE_RECTANGLE_3]: 'Unique Rectangle Type 3',
795
- [TechniqueId.UNIQUE_RECTANGLE_4]: 'Unique Rectangle Type 4',
796
- [TechniqueId.UNIQUE_RECTANGLE_5]: 'Unique Rectangle Type 5',
797
- [TechniqueId.X_CHAIN]: 'X-Chain',
798
- [TechniqueId.XY_CHAIN]: 'XY-Chain',
799
- [TechniqueId.VWXYZ_WING]: 'VWXYZ-Wing',
800
- [TechniqueId.UVWXYZ_WING]: 'UVWXYZ-Wing',
801
- [TechniqueId.TUVWXYZ_WING]: 'TUVWXYZ-Wing',
802
- [TechniqueId.STUVWXYZ_WING]: 'STUVWXYZ-Wing',
803
- [TechniqueId.AIC]: 'AIC',
804
- [TechniqueId.FORCING_NET]: 'Forcing Net',
805
- [TechniqueId.AVOIDABLE_RECTANGLE]: 'Avoidable Rectangle',
806
- [TechniqueId.SASHIMI_X_WING]: 'Sashimi X-Wing',
807
- [TechniqueId.SASHIMI_SWORDFISH]: 'Sashimi Swordfish',
808
- [TechniqueId.SASHIMI_JELLYFISH]: 'Sashimi Jellyfish',
809
- [TechniqueId.HIDDEN_UNIQUE_RECTANGLE]: 'Hidden Unique Rectangle',
810
- [TechniqueId.FIREWORK]: 'Firework',
811
- [TechniqueId.DEATH_BLOSSOM]: 'Death Blossom',
812
- [TechniqueId.FRANKEN_X_WING]: 'Franken X-Wing',
813
- [TechniqueId.FRANKEN_SWORDFISH]: 'Franken Swordfish',
814
- [TechniqueId.FRANKEN_JELLYFISH]: 'Franken Jellyfish',
815
- [TechniqueId.GROUPED_X_CYCLES]: 'Grouped X-Cycles',
816
- };
817
- /**
818
- * Get the technique title/name from its ID.
819
- *
820
- * @param techniqueId - The technique ID number
821
- * @returns The technique title, or "Technique {id}" if unknown
822
- *
823
- * @example
824
- * ```typescript
825
- * getTechniqueNameById(1); // "Full House"
826
- * getTechniqueNameById(14); // "XY-Wing"
827
- * getTechniqueNameById(999); // "Technique 999"
828
- * ```
829
- */
830
- function getTechniqueNameById(techniqueId) {
831
- return (TECHNIQUE_ID_TO_TITLE[techniqueId] ??
832
- `Technique ${techniqueId}`);
833
- }
834
- // =============================================================================
835
- // Board State Constants
836
- // =============================================================================
837
- /**
838
- * Empty board string (81 zeros) representing no user input.
839
- * Used as default value for user input in solver APIs.
840
- */
841
- exports.EMPTY_BOARD = '0'.repeat(81);
842
- /**
843
- * Empty pencilmarks string (80 commas = 81 empty elements).
844
- * Used as default value for pencilmarks in solver APIs.
845
- */
846
- exports.EMPTY_PENCILMARKS = ','.repeat(80);
847
- // =============================================================================
848
- // Cell Notation Utilities
849
- // =============================================================================
850
- /**
851
- * Format a cell position in R1C1 notation (1-indexed for human readability).
852
- *
853
- * @param row - Row index (0-8)
854
- * @param col - Column index (0-8)
855
- * @returns String like "R1C1" for top-left, "R9C9" for bottom-right
856
- *
857
- * @example
858
- * ```typescript
859
- * cellName(0, 0); // "R1C1"
860
- * cellName(4, 4); // "R5C5"
861
- * cellName(8, 8); // "R9C9"
862
- * ```
863
- */
864
- function cellName(row, col) {
865
- return `R${row + 1}C${col + 1}`;
866
- }
867
- /**
868
- * Format multiple cell positions as a comma-separated list in R1C1 notation.
869
- *
870
- * @param cells - Array of [row, col] pairs
871
- * @returns String like "R1C1, R2C3, R5C5"
872
- *
873
- * @example
874
- * ```typescript
875
- * cellList([[0, 0], [1, 2], [4, 4]]); // "R1C1, R2C3, R5C5"
876
- * ```
877
- */
878
- function cellList(cells) {
879
- return cells.map(([row, col]) => cellName(row ?? 0, col ?? 0)).join(', ');
880
- }
881
- /**
882
- * Get the block index (0-8) for a given cell position.
883
- * Blocks are numbered left-to-right, top-to-bottom:
884
- * ```
885
- * 0 1 2
886
- * 3 4 5
887
- * 6 7 8
888
- * ```
889
- *
890
- * @param row - Row index (0-8)
891
- * @param col - Column index (0-8)
892
- * @returns Block index (0-8)
893
- *
894
- * @example
895
- * ```typescript
896
- * getBlockIndex(0, 0); // 0 (top-left block)
897
- * getBlockIndex(4, 4); // 4 (center block)
898
- * getBlockIndex(8, 8); // 8 (bottom-right block)
899
- * ```
900
- */
901
- function getBlockIndex(row, col) {
902
- return Math.floor(row / exports.BLOCK_SIZE) * exports.BLOCK_SIZE + Math.floor(col / exports.BLOCK_SIZE);
903
- }
904
- /**
905
- * Get the block number (1-9) for a given cell position.
906
- * Same as getBlockIndex but 1-indexed for human readability.
907
- *
908
- * @param row - Row index (0-8)
909
- * @param col - Column index (0-8)
910
- * @returns Block number (1-9)
911
- */
912
- function getBlockNumber(row, col) {
913
- return getBlockIndex(row, col) + 1;
914
- }
915
- /**
916
- * Convert a flat cell index (0-80) to row and column.
917
- *
918
- * @param index - Flat index (0-80)
919
- * @returns [row, col] tuple (both 0-8)
920
- *
921
- * @example
922
- * ```typescript
923
- * indexToRowCol(0); // [0, 0]
924
- * indexToRowCol(40); // [4, 4]
925
- * indexToRowCol(80); // [8, 8]
926
- * ```
927
- */
928
- function indexToRowCol(index) {
929
- return [Math.floor(index / exports.BOARD_SIZE), index % exports.BOARD_SIZE];
930
- }
931
- /**
932
- * Convert row and column to a flat cell index (0-80).
933
- *
934
- * @param row - Row index (0-8)
935
- * @param col - Column index (0-8)
936
- * @returns Flat index (0-80)
937
- *
938
- * @example
939
- * ```typescript
940
- * rowColToIndex(0, 0); // 0
941
- * rowColToIndex(4, 4); // 40
942
- * rowColToIndex(8, 8); // 80
943
- * ```
944
- */
945
- function rowColToIndex(row, col) {
946
- return row * exports.BOARD_SIZE + col;
947
- }
948
- // =============================================================================
949
- // Time Formatting Utilities
950
- // =============================================================================
951
- /**
952
- * Format seconds as MM:SS or HH:MM:SS.
953
- *
954
- * @param seconds - Total seconds to format
955
- * @returns Formatted time string
956
- *
957
- * @example
958
- * ```typescript
959
- * formatTime(65); // "01:05"
960
- * formatTime(3661); // "01:01:01"
961
- * formatTime(0); // "00:00"
962
- * ```
963
- */
964
- function formatTime(seconds) {
965
- const hours = Math.floor(seconds / 3600);
966
- const minutes = Math.floor((seconds % 3600) / 60);
967
- const secs = seconds % 60;
968
- if (hours > 0) {
969
- return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
970
- }
971
- return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
972
- }
973
- /**
974
- * Parse MM:SS or HH:MM:SS time string to seconds.
975
- *
976
- * @param timeString - Time string in MM:SS or HH:MM:SS format
977
- * @returns Total seconds, or 0 if invalid format
978
- *
979
- * @example
980
- * ```typescript
981
- * parseTime("01:05"); // 65
982
- * parseTime("01:01:01"); // 3661
983
- * parseTime("invalid"); // 0
984
- * ```
985
- */
986
- function parseTime(timeString) {
987
- const parts = timeString.split(':').map(p => parseInt(p, 10));
988
- if (parts.some(isNaN))
989
- return 0;
990
- if (parts.length === 2) {
991
- return (parts[0] ?? 0) * 60 + (parts[1] ?? 0);
992
- }
993
- else if (parts.length === 3) {
994
- return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
995
- }
996
- return 0;
997
- }
998
- /**
999
- * Format a set of digits like {1, 2, 3} for display.
1000
- *
1001
- * @param digits - String or array of digits
1002
- * @returns Formatted string like "{1, 2, 3}"
1003
- *
1004
- * @example
1005
- * ```typescript
1006
- * formatDigits("123"); // "{1, 2, 3}"
1007
- * formatDigits([1, 2, 3]); // "{1, 2, 3}"
1008
- * ```
1009
- */
1010
- function formatDigits(digits) {
1011
- const arr = typeof digits === 'string' ? digits.split('') : digits.map(String);
1012
- return `{${arr.join(', ')}}`;
1013
- }
1014
- // =============================================================================
1015
- // UUID Validation Utilities
1016
- // =============================================================================
1017
- /** UUID regex pattern (lowercase or uppercase) */
1018
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1019
- /**
1020
- * Check if a string is a valid UUID format.
1021
- *
1022
- * @param id - String to validate
1023
- * @returns true if valid UUID format
1024
- *
1025
- * @example
1026
- * ```typescript
1027
- * isValidUUID('123e4567-e89b-12d3-a456-426614174000'); // true
1028
- * isValidUUID('not-a-uuid'); // false
1029
- * ```
1030
- */
1031
- function isValidUUID(id) {
1032
- return UUID_PATTERN.test(id);
1033
- }
1034
- /**
1035
- * Validate a UUID and throw if invalid.
1036
- *
1037
- * @param uuid - UUID string to validate
1038
- * @param name - Optional name for error message (e.g., "Board UUID")
1039
- * @returns The validated UUID string
1040
- * @throws Error if UUID is missing or invalid format
1041
- *
1042
- * @example
1043
- * ```typescript
1044
- * const id = validateUUID(params.id, 'Board UUID'); // throws if invalid
1045
- * ```
1046
- */
1047
- function validateUUID(uuid, name = 'UUID') {
1048
- if (!uuid) {
1049
- throw new Error(`${name} is required`);
1050
- }
1051
- if (!isValidUUID(uuid)) {
1052
- throw new Error(`Invalid ${name} format: "${uuid}". Expected UUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)`);
1053
- }
1054
- return uuid;
1055
- }
1056
- // =============================================================================
1057
- // Technique URL Utilities
1058
- // =============================================================================
1059
- /**
1060
- * Get technique icon URL from technique ID.
1061
- *
1062
- * @param techniqueId - Technique ID number
1063
- * @returns Icon URL path (e.g., "/technique.full.house.svg")
1064
- *
1065
- * @example
1066
- * ```typescript
1067
- * getTechniqueIconUrl(TechniqueId.FULL_HOUSE); // "/technique.full.house.svg"
1068
- * getTechniqueIconUrl(TechniqueId.X_WING); // "/technique.x.wing.svg"
1069
- * getTechniqueIconUrl(TechniqueId.XY_WING); // "/technique.xy.wing.svg"
1070
- * ```
1071
- */
1072
- function getTechniqueIconUrl(techniqueId) {
1073
- const title = getTechniqueNameById(techniqueId);
1074
- const normalized = title
1075
- .toLowerCase()
1076
- .replace(/\s+/g, '.') // spaces to dots
1077
- .replace(/-/g, '.'); // hyphens to dots
1078
- return `/technique.${normalized}.svg`;
1079
- }
1080
- //# sourceMappingURL=index.js.map