@sudobility/sudojo_types 1.2.35 → 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.
- package/README.md +46 -0
- package/package.json +6 -10
- 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.
|
|
3
|
+
"version": "1.2.37",
|
|
4
4
|
"description": "TypeScript types for Sudojo API - Sudoku learning platform",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
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": "
|
|
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.
|
|
43
|
+
"@sudobility/types": "^1.9.57"
|
|
48
44
|
},
|
|
49
45
|
"devDependencies": {
|
|
50
46
|
"@eslint/js": "^9.38.0",
|
|
51
|
-
"@sudobility/types": "^1.9.
|
|
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
|