@sudobility/sudojo_lib 0.0.70 → 0.0.75
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/dist/config/authProviders.d.ts +18 -0
- package/dist/config/authProviders.d.ts.map +1 -0
- package/dist/config/authProviders.js +12 -0
- package/dist/config/authProviders.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/useBoardEntry.d.ts +45 -0
- package/dist/hooks/useBoardEntry.d.ts.map +1 -0
- package/dist/hooks/useBoardEntry.js +197 -0
- package/dist/hooks/useBoardEntry.js.map +1 -0
- package/dist/hooks/useGamePlay.d.ts.map +1 -1
- package/dist/hooks/useGamePlay.js +23 -13
- package/dist/hooks/useGamePlay.js.map +1 -1
- package/dist/hooks/useGameSession.d.ts +45 -0
- package/dist/hooks/useGameSession.d.ts.map +1 -0
- package/dist/hooks/useGameSession.js +108 -0
- package/dist/hooks/useGameSession.js.map +1 -0
- package/dist/hooks/useLevelGame.d.ts +2 -0
- package/dist/hooks/useLevelGame.d.ts.map +1 -1
- package/dist/hooks/useLevelGame.js +3 -2
- package/dist/hooks/useLevelGame.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/digitDisplay.d.ts +14 -0
- package/dist/utils/digitDisplay.d.ts.map +1 -0
- package/dist/utils/digitDisplay.js +46 -0
- package/dist/utils/digitDisplay.js.map +1 -0
- package/dist/utils/hintExplanation.d.ts +18 -0
- package/dist/utils/hintExplanation.d.ts.map +1 -0
- package/dist/utils/hintExplanation.js +896 -0
- package/dist/utils/hintExplanation.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/localizedHint.d.ts +17 -0
- package/dist/utils/localizedHint.d.ts.map +1 -0
- package/dist/utils/localizedHint.js +35 -0
- package/dist/utils/localizedHint.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate detailed hint explanations based on the hint payload
|
|
3
|
+
* Similar to tutorial explanations but for live hints
|
|
4
|
+
*
|
|
5
|
+
* Each explanation is designed to teach the user WHY the technique works,
|
|
6
|
+
* not just WHAT to do.
|
|
7
|
+
*/
|
|
8
|
+
import { displayDigit as mapDigitDisplay } from './digitDisplay';
|
|
9
|
+
// Module-level display format, set by public entry points before calling internals.
|
|
10
|
+
let _display = 'numeric';
|
|
11
|
+
// Technique IDs (matching SudokuDefines.h and TechniqueId enum)
|
|
12
|
+
const TECHNIQUE_IDS = {
|
|
13
|
+
'Full House': 1,
|
|
14
|
+
'Hidden Single': 2,
|
|
15
|
+
'Naked Single': 3,
|
|
16
|
+
'Hidden Pair': 4,
|
|
17
|
+
'Naked Pair': 5,
|
|
18
|
+
'Locked Candidates': 6,
|
|
19
|
+
'Hidden Triple': 7,
|
|
20
|
+
'Naked Triple': 8,
|
|
21
|
+
'Hidden Quad': 9,
|
|
22
|
+
'Naked Quad': 10,
|
|
23
|
+
'X-Wing': 11,
|
|
24
|
+
Swordfish: 12,
|
|
25
|
+
Jellyfish: 13,
|
|
26
|
+
'XY-Wing': 14,
|
|
27
|
+
'Finned X-Wing': 15,
|
|
28
|
+
Squirmbag: 16,
|
|
29
|
+
'Finned Swordfish': 17,
|
|
30
|
+
'Finned Jellyfish': 18,
|
|
31
|
+
'XYZ-Wing': 19,
|
|
32
|
+
'WXYZ-Wing': 20,
|
|
33
|
+
'Almost Locked Sets': 21,
|
|
34
|
+
'Finned Squirmbag': 22,
|
|
35
|
+
'ALS Chain': 23,
|
|
36
|
+
Skyscraper: 24,
|
|
37
|
+
'Two-String Kite': 25,
|
|
38
|
+
'Empty Rectangle': 26,
|
|
39
|
+
'Simple Coloring': 27,
|
|
40
|
+
'W-Wing': 28,
|
|
41
|
+
'Remote Pairs': 29,
|
|
42
|
+
'Unique Rectangle Type 1': 30,
|
|
43
|
+
'Unique Rectangle Type 2': 31,
|
|
44
|
+
'BUG+1': 32,
|
|
45
|
+
'Sue de Coq': 33,
|
|
46
|
+
'ALS-XZ': 34,
|
|
47
|
+
'X-Cycles': 35,
|
|
48
|
+
'Forcing Chains': 36,
|
|
49
|
+
'3D Medusa': 37,
|
|
50
|
+
Crane: 38,
|
|
51
|
+
'Unique Rectangle Type 3': 39,
|
|
52
|
+
'Unique Rectangle Type 4': 40,
|
|
53
|
+
'Unique Rectangle Type 5': 41,
|
|
54
|
+
'X-Chain': 42,
|
|
55
|
+
'XY-Chain': 43,
|
|
56
|
+
'VWXYZ-Wing': 44,
|
|
57
|
+
'UVWXYZ-Wing': 45,
|
|
58
|
+
'TUVWXYZ-Wing': 46,
|
|
59
|
+
'STUVWXYZ-Wing': 47,
|
|
60
|
+
AIC: 48,
|
|
61
|
+
'Forcing Net': 49,
|
|
62
|
+
'Avoidable Rectangle': 50,
|
|
63
|
+
'Sashimi X-Wing': 51,
|
|
64
|
+
'Sashimi Swordfish': 52,
|
|
65
|
+
'Sashimi Jellyfish': 53,
|
|
66
|
+
'Hidden Unique Rectangle': 54,
|
|
67
|
+
Firework: 55,
|
|
68
|
+
'Death Blossom': 56,
|
|
69
|
+
'Franken X-Wing': 57,
|
|
70
|
+
'Franken Swordfish': 58,
|
|
71
|
+
'Franken Jellyfish': 59,
|
|
72
|
+
'Grouped X-Cycles': 60,
|
|
73
|
+
};
|
|
74
|
+
// Cell notation (R1C1 format)
|
|
75
|
+
function cellName(row, col) {
|
|
76
|
+
return `R${row + 1}C${col + 1}`;
|
|
77
|
+
}
|
|
78
|
+
// Format a list of cells
|
|
79
|
+
function cellList(cells) {
|
|
80
|
+
return cells.map(c => cellName(c.row, c.col)).join(', ');
|
|
81
|
+
}
|
|
82
|
+
// Format digit(s) - only valid Sudoku digits 1-9
|
|
83
|
+
// Uses module-level _display set by public entry points.
|
|
84
|
+
function formatDigit(d) {
|
|
85
|
+
const s = String(d);
|
|
86
|
+
if (s < '1' || s > '9')
|
|
87
|
+
return '?';
|
|
88
|
+
if (_display !== 'numeric') {
|
|
89
|
+
return mapDigitDisplay(parseInt(s, 10), _display);
|
|
90
|
+
}
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
// Format multiple digits as set - only valid Sudoku digits 1-9
|
|
94
|
+
function formatDigits(digits) {
|
|
95
|
+
const arr = Array.isArray(digits)
|
|
96
|
+
? digits.filter(d => d >= '1' && d <= '9')
|
|
97
|
+
: digits.split('').filter(d => d >= '1' && d <= '9');
|
|
98
|
+
if (arr.length === 0)
|
|
99
|
+
return '';
|
|
100
|
+
if (arr.length === 1)
|
|
101
|
+
return formatDigit(arr[0] ?? '');
|
|
102
|
+
return `{${arr
|
|
103
|
+
.sort()
|
|
104
|
+
.map(d => formatDigit(d))
|
|
105
|
+
.join(', ')}}`;
|
|
106
|
+
}
|
|
107
|
+
// Extract first valid Sudoku digit (1-9) from a string
|
|
108
|
+
function getFirstValidDigit(digits) {
|
|
109
|
+
if (!digits)
|
|
110
|
+
return '?';
|
|
111
|
+
for (const d of digits) {
|
|
112
|
+
if (d >= '1' && d <= '9')
|
|
113
|
+
return d;
|
|
114
|
+
}
|
|
115
|
+
return '?';
|
|
116
|
+
}
|
|
117
|
+
// Extract all valid Sudoku digits (1-9) from a string
|
|
118
|
+
function getValidDigits(digits) {
|
|
119
|
+
if (!digits)
|
|
120
|
+
return [];
|
|
121
|
+
return digits.split('').filter(d => d >= '1' && d <= '9');
|
|
122
|
+
}
|
|
123
|
+
// Get block number (1-9) from row/col
|
|
124
|
+
function getBlock(row, col) {
|
|
125
|
+
return Math.floor(row / 3) * 3 + Math.floor(col / 3) + 1;
|
|
126
|
+
}
|
|
127
|
+
// Determine the common house for cells
|
|
128
|
+
function findCommonHouse(cells) {
|
|
129
|
+
const first = cells[0];
|
|
130
|
+
if (cells.length < 2 || !first)
|
|
131
|
+
return null;
|
|
132
|
+
const sameRow = cells.every(c => c.row === first.row);
|
|
133
|
+
if (sameRow) {
|
|
134
|
+
return { type: 'row', index: first.row, name: `Row ${first.row + 1}` };
|
|
135
|
+
}
|
|
136
|
+
const sameCol = cells.every(c => c.col === first.col);
|
|
137
|
+
if (sameCol) {
|
|
138
|
+
return { type: 'col', index: first.col, name: `Column ${first.col + 1}` };
|
|
139
|
+
}
|
|
140
|
+
const sameBlock = cells.every(c => getBlock(c.row, c.col) === getBlock(first.row, first.col));
|
|
141
|
+
if (sameBlock) {
|
|
142
|
+
const block = getBlock(first.row, first.col);
|
|
143
|
+
return { type: 'block', index: block, name: `Block ${block}` };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// Extract cells that have select action (digit placement)
|
|
148
|
+
// Note: select: "0" means no selection (placeholder), only 1-9 are valid digits
|
|
149
|
+
function getSelectCells(hint) {
|
|
150
|
+
return hint.cells
|
|
151
|
+
.filter((c) => c.actions.select && c.actions.select !== '' && c.actions.select !== '0')
|
|
152
|
+
.map((c) => ({
|
|
153
|
+
row: c.row,
|
|
154
|
+
col: c.column,
|
|
155
|
+
digit: c.actions.select,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
// Extract cells that have remove action (eliminations)
|
|
159
|
+
// Note: Filter out cells where remove only contains "0" (placeholder)
|
|
160
|
+
function getRemoveCells(hint) {
|
|
161
|
+
return hint.cells
|
|
162
|
+
.filter((c) => c.actions.remove &&
|
|
163
|
+
c.actions.remove !== '' &&
|
|
164
|
+
getValidDigits(c.actions.remove).length > 0)
|
|
165
|
+
.map((c) => ({
|
|
166
|
+
row: c.row,
|
|
167
|
+
col: c.column,
|
|
168
|
+
digits: c.actions.remove,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
// Extract cells that have highlight action (pattern cells)
|
|
172
|
+
// Note: Filter out cells where highlight only contains "0" (placeholder)
|
|
173
|
+
function getHighlightCells(hint) {
|
|
174
|
+
return hint.cells
|
|
175
|
+
.filter((c) => c.actions.highlight &&
|
|
176
|
+
c.actions.highlight !== '' &&
|
|
177
|
+
getValidDigits(c.actions.highlight).length > 0)
|
|
178
|
+
.map((c) => ({
|
|
179
|
+
row: c.row,
|
|
180
|
+
col: c.column,
|
|
181
|
+
digits: c.actions.highlight,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Generate detailed explanation for a hint
|
|
186
|
+
*/
|
|
187
|
+
export function generateDetailedExplanation(hint, digitDisplay) {
|
|
188
|
+
_display = digitDisplay ?? 'numeric';
|
|
189
|
+
const techniqueId = TECHNIQUE_IDS[hint.title];
|
|
190
|
+
const selectCells = getSelectCells(hint);
|
|
191
|
+
const removeCells = getRemoveCells(hint);
|
|
192
|
+
const highlightCells = getHighlightCells(hint);
|
|
193
|
+
// Technique-specific explanations (IDs match SudokuDefines.h)
|
|
194
|
+
switch (techniqueId) {
|
|
195
|
+
case 1: // Full House
|
|
196
|
+
return explainFullHouse(hint, selectCells);
|
|
197
|
+
case 2: // Hidden Single
|
|
198
|
+
return explainHiddenSingle(hint, selectCells);
|
|
199
|
+
case 3: // Naked Single
|
|
200
|
+
return explainNakedSingle(hint, selectCells);
|
|
201
|
+
case 4: // Hidden Pair
|
|
202
|
+
return explainHiddenPair(hint, highlightCells, removeCells);
|
|
203
|
+
case 5: // Naked Pair
|
|
204
|
+
return explainNakedPair(hint, highlightCells, removeCells);
|
|
205
|
+
case 6: // Locked Candidates
|
|
206
|
+
return explainLockedCandidates(hint, highlightCells, removeCells);
|
|
207
|
+
case 7: // Hidden Triple
|
|
208
|
+
return explainHiddenTriple(hint, highlightCells, removeCells);
|
|
209
|
+
case 8: // Naked Triple
|
|
210
|
+
return explainNakedTriple(hint, highlightCells, removeCells);
|
|
211
|
+
case 9: // Hidden Quad
|
|
212
|
+
return explainHiddenQuad(hint, highlightCells, removeCells);
|
|
213
|
+
case 10: // Naked Quad
|
|
214
|
+
return explainNakedQuad(hint, highlightCells, removeCells);
|
|
215
|
+
case 11: // X-Wing
|
|
216
|
+
return explainXWing(hint, highlightCells, removeCells);
|
|
217
|
+
case 12: // Swordfish
|
|
218
|
+
return explainSwordfish(hint, highlightCells, removeCells);
|
|
219
|
+
case 13: // Jellyfish
|
|
220
|
+
return explainJellyfish(hint, highlightCells, removeCells);
|
|
221
|
+
case 14: // XY-Wing
|
|
222
|
+
return explainXYWing(hint, highlightCells, removeCells);
|
|
223
|
+
case 15: // Finned X-Wing
|
|
224
|
+
return explainFinnedXWing(hint, highlightCells, removeCells);
|
|
225
|
+
case 16: // Squirmbag
|
|
226
|
+
return explainSquirmbag(hint, highlightCells, removeCells);
|
|
227
|
+
case 17: // Finned Swordfish
|
|
228
|
+
return explainFinnedSwordfish(hint, highlightCells, removeCells);
|
|
229
|
+
case 18: // Finned Jellyfish
|
|
230
|
+
return explainFinnedJellyfish(hint, highlightCells, removeCells);
|
|
231
|
+
case 19: // XYZ-Wing
|
|
232
|
+
return explainXYZWing(hint, highlightCells, removeCells);
|
|
233
|
+
case 20: // WXYZ-Wing
|
|
234
|
+
return explainWXYZWing(hint, highlightCells, removeCells);
|
|
235
|
+
case 21: // Almost Locked Sets
|
|
236
|
+
return explainAlmostLockedSets(hint, highlightCells, removeCells);
|
|
237
|
+
case 22: // Finned Squirmbag
|
|
238
|
+
return explainFinnedSquirmbag(hint, highlightCells, removeCells);
|
|
239
|
+
case 23: // ALS Chain
|
|
240
|
+
case 34: // ALS-XZ
|
|
241
|
+
return explainALSChain(hint, highlightCells, removeCells);
|
|
242
|
+
default:
|
|
243
|
+
// Generic explanation with action details
|
|
244
|
+
return generateGenericExplanation(hint, selectCells, removeCells);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Full House: The last empty cell in a house.
|
|
249
|
+
*/
|
|
250
|
+
function explainFullHouse(hint, selectCells) {
|
|
251
|
+
if (selectCells.length !== 1)
|
|
252
|
+
return hint.text;
|
|
253
|
+
const cell = selectCells[0];
|
|
254
|
+
if (!cell)
|
|
255
|
+
return hint.text;
|
|
256
|
+
const digit = formatDigit(cell.digit);
|
|
257
|
+
const area = hint.areas?.[0];
|
|
258
|
+
let houseName = '';
|
|
259
|
+
if (area) {
|
|
260
|
+
if (area.type === 'row')
|
|
261
|
+
houseName = `Row ${area.index + 1}`;
|
|
262
|
+
else if (area.type === 'column')
|
|
263
|
+
houseName = `Column ${area.index + 1}`;
|
|
264
|
+
else if (area.type === 'block')
|
|
265
|
+
houseName = `Block ${area.index + 1}`;
|
|
266
|
+
}
|
|
267
|
+
if (houseName) {
|
|
268
|
+
return (`${houseName} has 8 cells filled, leaving only ${cellName(cell.row, cell.col)} empty. ` +
|
|
269
|
+
`The missing digit is ${digit} — place it here.`);
|
|
270
|
+
}
|
|
271
|
+
return (`Cell ${cellName(cell.row, cell.col)} is the only empty cell in its house. ` +
|
|
272
|
+
`Place ${digit} here.`);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Hidden Single: A digit that can only go in one cell within a house.
|
|
276
|
+
*/
|
|
277
|
+
function explainHiddenSingle(hint, selectCells) {
|
|
278
|
+
if (selectCells.length !== 1)
|
|
279
|
+
return hint.text;
|
|
280
|
+
const cell = selectCells[0];
|
|
281
|
+
if (!cell)
|
|
282
|
+
return hint.text;
|
|
283
|
+
const digit = formatDigit(cell.digit);
|
|
284
|
+
const area = hint.areas?.[0];
|
|
285
|
+
let houseName;
|
|
286
|
+
if (area) {
|
|
287
|
+
houseName =
|
|
288
|
+
area.type === 'row'
|
|
289
|
+
? `Row ${area.index + 1}`
|
|
290
|
+
: area.type === 'column'
|
|
291
|
+
? `Column ${area.index + 1}`
|
|
292
|
+
: `Block ${area.index + 1}`;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
houseName = `Block ${getBlock(cell.row, cell.col)}`;
|
|
296
|
+
}
|
|
297
|
+
return (`In ${houseName}, digit ${digit} can only go in one cell: ${cellName(cell.row, cell.col)}. ` +
|
|
298
|
+
`Even though this cell may have other candidates, ${digit} has no other place in ${houseName.toLowerCase()}. ` +
|
|
299
|
+
`Place ${digit} here.`);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Naked Single: A cell with only one candidate remaining.
|
|
303
|
+
*/
|
|
304
|
+
function explainNakedSingle(hint, selectCells) {
|
|
305
|
+
if (selectCells.length !== 1)
|
|
306
|
+
return hint.text;
|
|
307
|
+
const cell = selectCells[0];
|
|
308
|
+
if (!cell)
|
|
309
|
+
return hint.text;
|
|
310
|
+
const digit = formatDigit(cell.digit);
|
|
311
|
+
return (`Cell ${cellName(cell.row, cell.col)} has only one candidate left: ${digit}. ` +
|
|
312
|
+
`All other digits are eliminated by numbers already placed in its row, column, and block. ` +
|
|
313
|
+
`Place ${digit} here.`);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Hidden Pair: Two digits that can only appear in exactly two cells within a house.
|
|
317
|
+
*/
|
|
318
|
+
function explainHiddenPair(hint, highlightCells, removeCells) {
|
|
319
|
+
if (highlightCells.length < 2)
|
|
320
|
+
return hint.text;
|
|
321
|
+
const cells = highlightCells
|
|
322
|
+
.slice(0, 2)
|
|
323
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
324
|
+
const house = findCommonHouse(cells);
|
|
325
|
+
const houseName = house?.name || 'their house';
|
|
326
|
+
const hiddenDigits = new Set();
|
|
327
|
+
highlightCells.slice(0, 2).forEach(c => {
|
|
328
|
+
for (const d of getValidDigits(c.digits))
|
|
329
|
+
hiddenDigits.add(d);
|
|
330
|
+
});
|
|
331
|
+
const pairDigits = formatDigits(Array.from(hiddenDigits));
|
|
332
|
+
const eliminations = removeCells.filter(c => cells.some(hc => hc.row === c.row && hc.col === c.col));
|
|
333
|
+
let explanation = `In ${houseName}, digits ${pairDigits} can ONLY appear in ${cellList(cells)}. `;
|
|
334
|
+
explanation += `These form a Hidden Pair — the pair is "hidden" among other candidates. `;
|
|
335
|
+
explanation += `Since ${pairDigits} must occupy these 2 cells, `;
|
|
336
|
+
if (eliminations.length > 0) {
|
|
337
|
+
const elimDigits = new Set();
|
|
338
|
+
eliminations.forEach(e => {
|
|
339
|
+
for (const d of getValidDigits(e.digits))
|
|
340
|
+
elimDigits.add(d);
|
|
341
|
+
});
|
|
342
|
+
explanation += `eliminate the other candidates ${formatDigits(Array.from(elimDigits))} from these cells.`;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
explanation += `other candidates can be eliminated.`;
|
|
346
|
+
}
|
|
347
|
+
return explanation;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Naked Pair: Two cells in a house that contain exactly the same two candidates.
|
|
351
|
+
*/
|
|
352
|
+
function explainNakedPair(hint, highlightCells, removeCells) {
|
|
353
|
+
if (highlightCells.length < 2)
|
|
354
|
+
return hint.text;
|
|
355
|
+
const cells = highlightCells
|
|
356
|
+
.slice(0, 2)
|
|
357
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
358
|
+
const house = findCommonHouse(cells);
|
|
359
|
+
const houseName = house?.name || 'their shared house';
|
|
360
|
+
const pairDigits = formatDigits(getValidDigits(highlightCells[0]?.digits));
|
|
361
|
+
let explanation = `Cells ${cellList(cells)} both contain exactly ${pairDigits} as their only candidates. `;
|
|
362
|
+
explanation += `This is a Naked Pair — the pair is "naked" because these cells show only these digits. `;
|
|
363
|
+
explanation += `Since ${pairDigits} must go in these cells, `;
|
|
364
|
+
if (removeCells.length > 0) {
|
|
365
|
+
const elimCells = removeCells
|
|
366
|
+
.filter(c => !cells.some(pc => pc.row === c.row && pc.col === c.col))
|
|
367
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
368
|
+
if (elimCells.length > 0) {
|
|
369
|
+
explanation += `eliminate ${pairDigits} from other cells in ${houseName.toLowerCase()}: ${cellList(elimCells)}.`;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
explanation += `they can be eliminated from other cells in the house.`;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return explanation;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Locked Candidates (Pointing/Claiming)
|
|
379
|
+
*/
|
|
380
|
+
function explainLockedCandidates(hint, highlightCells, removeCells) {
|
|
381
|
+
if (highlightCells.length < 2)
|
|
382
|
+
return hint.text;
|
|
383
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
384
|
+
const first = cells[0];
|
|
385
|
+
if (!first)
|
|
386
|
+
return hint.text;
|
|
387
|
+
const lockedDigit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
388
|
+
const sameRow = cells.every(c => c.row === first.row);
|
|
389
|
+
const sameCol = cells.every(c => c.col === first.col);
|
|
390
|
+
const sameBlock = cells.every(c => getBlock(c.row, c.col) === getBlock(first.row, first.col));
|
|
391
|
+
const block = getBlock(first.row, first.col);
|
|
392
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
393
|
+
const elimInSameBlock = elimCells.every(c => getBlock(c.row, c.col) === block);
|
|
394
|
+
let explanation = '';
|
|
395
|
+
if (sameBlock && (sameRow || sameCol)) {
|
|
396
|
+
if (elimInSameBlock) {
|
|
397
|
+
const lineName = sameRow
|
|
398
|
+
? `Row ${first.row + 1}`
|
|
399
|
+
: `Column ${first.col + 1}`;
|
|
400
|
+
explanation = `In ${lineName}, digit ${lockedDigit} can only appear in Block ${block} (cells ${cellList(cells)}). `;
|
|
401
|
+
explanation += `This "claims" ${lockedDigit} for this line within the block. `;
|
|
402
|
+
if (elimCells.length > 0) {
|
|
403
|
+
explanation += `Eliminate ${lockedDigit} from other cells in Block ${block}: ${cellList(elimCells)}.`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const lineName = sameRow
|
|
408
|
+
? `Row ${first.row + 1}`
|
|
409
|
+
: `Column ${first.col + 1}`;
|
|
410
|
+
explanation = `In Block ${block}, digit ${lockedDigit} can only appear in ${lineName} (cells ${cellList(cells)}). `;
|
|
411
|
+
explanation += `This "points" to ${lockedDigit} being restricted to this line. `;
|
|
412
|
+
if (elimCells.length > 0) {
|
|
413
|
+
explanation += `Eliminate ${lockedDigit} from ${cellList(elimCells)} in ${lineName} outside Block ${block}.`;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else if (sameRow) {
|
|
418
|
+
explanation = `Digit ${lockedDigit} is locked to Row ${first.row + 1} in these cells: ${cellList(cells)}. `;
|
|
419
|
+
if (elimCells.length > 0) {
|
|
420
|
+
explanation += `Eliminate ${lockedDigit} from ${cellList(elimCells)}.`;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (sameCol) {
|
|
424
|
+
explanation = `Digit ${lockedDigit} is locked to Column ${first.col + 1} in these cells: ${cellList(cells)}. `;
|
|
425
|
+
if (elimCells.length > 0) {
|
|
426
|
+
explanation += `Eliminate ${lockedDigit} from ${cellList(elimCells)}.`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
explanation = `Locked Candidates pattern for digit ${lockedDigit}. `;
|
|
431
|
+
if (elimCells.length > 0) {
|
|
432
|
+
explanation += `Eliminate ${lockedDigit} from ${cellList(elimCells)}.`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return explanation;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Hidden Triple: Three digits that can only appear in exactly three cells within a house.
|
|
439
|
+
*/
|
|
440
|
+
function explainHiddenTriple(hint, highlightCells, removeCells) {
|
|
441
|
+
if (highlightCells.length < 3)
|
|
442
|
+
return hint.text;
|
|
443
|
+
const cells = highlightCells
|
|
444
|
+
.slice(0, 3)
|
|
445
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
446
|
+
const house = findCommonHouse(cells);
|
|
447
|
+
const houseName = house?.name || 'their house';
|
|
448
|
+
const hiddenDigits = new Set();
|
|
449
|
+
highlightCells.slice(0, 3).forEach(c => {
|
|
450
|
+
for (const d of getValidDigits(c.digits))
|
|
451
|
+
hiddenDigits.add(d);
|
|
452
|
+
});
|
|
453
|
+
const tripleDigits = formatDigits(Array.from(hiddenDigits));
|
|
454
|
+
let explanation = `In ${houseName}, digits ${tripleDigits} can ONLY appear in cells ${cellList(cells)}. `;
|
|
455
|
+
explanation += `This is a Hidden Triple — these 3 digits are "hidden" among other candidates. `;
|
|
456
|
+
explanation += `Since ${tripleDigits} must occupy these 3 cells, `;
|
|
457
|
+
if (removeCells.length > 0) {
|
|
458
|
+
const elimDigits = new Set();
|
|
459
|
+
removeCells
|
|
460
|
+
.filter(c => cells.some(hc => hc.row === c.row && hc.col === c.col))
|
|
461
|
+
.forEach(e => {
|
|
462
|
+
for (const d of getValidDigits(e.digits))
|
|
463
|
+
elimDigits.add(d);
|
|
464
|
+
});
|
|
465
|
+
if (elimDigits.size > 0) {
|
|
466
|
+
explanation += `eliminate the other candidates ${formatDigits(Array.from(elimDigits))} from these cells.`;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
explanation += `other candidates can be eliminated.`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
explanation += `other candidates can be eliminated.`;
|
|
474
|
+
}
|
|
475
|
+
return explanation;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Naked Triple: Three cells in a house that together contain only three candidates total.
|
|
479
|
+
*/
|
|
480
|
+
function explainNakedTriple(hint, highlightCells, removeCells) {
|
|
481
|
+
if (highlightCells.length < 3)
|
|
482
|
+
return hint.text;
|
|
483
|
+
const cells = highlightCells
|
|
484
|
+
.slice(0, 3)
|
|
485
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
486
|
+
const house = findCommonHouse(cells);
|
|
487
|
+
const houseName = house?.name || 'their house';
|
|
488
|
+
const nakedDigits = new Set();
|
|
489
|
+
highlightCells.slice(0, 3).forEach(c => {
|
|
490
|
+
for (const d of getValidDigits(c.digits))
|
|
491
|
+
nakedDigits.add(d);
|
|
492
|
+
});
|
|
493
|
+
const tripleDigits = formatDigits(Array.from(nakedDigits));
|
|
494
|
+
let explanation = `Cells ${cellList(cells)} together contain only the candidates ${tripleDigits}. `;
|
|
495
|
+
explanation += `This is a Naked Triple — these 3 cells share only 3 digits between them. `;
|
|
496
|
+
explanation += `Since ${tripleDigits} must go in these cells, `;
|
|
497
|
+
if (removeCells.length > 0) {
|
|
498
|
+
const elimCells = removeCells.filter(c => !cells.some(nc => nc.row === c.row && nc.col === c.col));
|
|
499
|
+
if (elimCells.length > 0) {
|
|
500
|
+
explanation += `eliminate ${tripleDigits} from other cells in ${houseName.toLowerCase()}: ${cellList(elimCells.map(c => ({ row: c.row, col: c.col })))}.`;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
explanation += `they can be eliminated from other cells.`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
explanation += `they can be eliminated from other cells.`;
|
|
508
|
+
}
|
|
509
|
+
return explanation;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Hidden Quad: Four digits that can only appear in exactly four cells within a house.
|
|
513
|
+
*/
|
|
514
|
+
function explainHiddenQuad(hint, highlightCells, removeCells) {
|
|
515
|
+
if (highlightCells.length < 4)
|
|
516
|
+
return hint.text;
|
|
517
|
+
const cells = highlightCells
|
|
518
|
+
.slice(0, 4)
|
|
519
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
520
|
+
const house = findCommonHouse(cells);
|
|
521
|
+
const houseName = house?.name || 'their house';
|
|
522
|
+
const hiddenDigits = new Set();
|
|
523
|
+
highlightCells.slice(0, 4).forEach(c => {
|
|
524
|
+
for (const d of getValidDigits(c.digits))
|
|
525
|
+
hiddenDigits.add(d);
|
|
526
|
+
});
|
|
527
|
+
const quadDigits = formatDigits(Array.from(hiddenDigits));
|
|
528
|
+
let explanation = `In ${houseName}, digits ${quadDigits} can ONLY appear in cells ${cellList(cells)}. `;
|
|
529
|
+
explanation += `This is a Hidden Quad — 4 digits restricted to 4 cells. `;
|
|
530
|
+
if (removeCells.length > 0) {
|
|
531
|
+
const elimDigits = new Set();
|
|
532
|
+
removeCells
|
|
533
|
+
.filter(c => cells.some(hc => hc.row === c.row && hc.col === c.col))
|
|
534
|
+
.forEach(e => {
|
|
535
|
+
for (const d of getValidDigits(e.digits))
|
|
536
|
+
elimDigits.add(d);
|
|
537
|
+
});
|
|
538
|
+
if (elimDigits.size > 0) {
|
|
539
|
+
explanation += `Eliminate the other candidates ${formatDigits(Array.from(elimDigits))} from these cells.`;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return explanation;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Naked Quad: Four cells in a house that together contain only four candidates total.
|
|
546
|
+
*/
|
|
547
|
+
function explainNakedQuad(hint, highlightCells, removeCells) {
|
|
548
|
+
if (highlightCells.length < 4)
|
|
549
|
+
return hint.text;
|
|
550
|
+
const cells = highlightCells
|
|
551
|
+
.slice(0, 4)
|
|
552
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
553
|
+
const house = findCommonHouse(cells);
|
|
554
|
+
const houseName = house?.name || 'their house';
|
|
555
|
+
const nakedDigits = new Set();
|
|
556
|
+
highlightCells.slice(0, 4).forEach(c => {
|
|
557
|
+
for (const d of getValidDigits(c.digits))
|
|
558
|
+
nakedDigits.add(d);
|
|
559
|
+
});
|
|
560
|
+
const quadDigits = formatDigits(Array.from(nakedDigits));
|
|
561
|
+
let explanation = `Cells ${cellList(cells)} together contain only the candidates ${quadDigits}. `;
|
|
562
|
+
explanation += `This is a Naked Quad — 4 cells sharing only 4 digits. `;
|
|
563
|
+
if (removeCells.length > 0) {
|
|
564
|
+
const elimCells = removeCells.filter(c => !cells.some(nc => nc.row === c.row && nc.col === c.col));
|
|
565
|
+
if (elimCells.length > 0) {
|
|
566
|
+
explanation += `Eliminate ${quadDigits} from other cells in ${houseName.toLowerCase()}: ${cellList(elimCells.map(c => ({ row: c.row, col: c.col })))}.`;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return explanation;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* X-Wing: A 2x2 fish pattern.
|
|
573
|
+
*/
|
|
574
|
+
function explainXWing(hint, highlightCells, removeCells) {
|
|
575
|
+
if (highlightCells.length < 4)
|
|
576
|
+
return hint.text;
|
|
577
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
578
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
579
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
580
|
+
const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
|
|
581
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
582
|
+
const elimInCols = elimCells.length > 0 && elimCells.every(c => cols.includes(c.col));
|
|
583
|
+
let explanation = '';
|
|
584
|
+
if (elimInCols || rows.length === 2) {
|
|
585
|
+
explanation = `X-Wing found for digit ${digit}. `;
|
|
586
|
+
explanation += `In Rows ${rows.map(r => r + 1).join(' and ')}, digit ${digit} appears only in Columns ${cols.map(c => c + 1).join(' and ')}. `;
|
|
587
|
+
explanation += `This forms a rectangle where ${digit} must occupy two opposite corners. `;
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
explanation = `X-Wing found for digit ${digit}. `;
|
|
591
|
+
explanation += `In Columns ${cols.map(c => c + 1).join(' and ')}, digit ${digit} appears only in Rows ${rows.map(r => r + 1).join(' and ')}. `;
|
|
592
|
+
explanation += `This forms a rectangle where ${digit} must occupy two opposite corners. `;
|
|
593
|
+
}
|
|
594
|
+
if (elimCells.length > 0) {
|
|
595
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
596
|
+
}
|
|
597
|
+
return explanation;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Swordfish: A 3x3 fish pattern.
|
|
601
|
+
*/
|
|
602
|
+
function explainSwordfish(hint, highlightCells, removeCells) {
|
|
603
|
+
if (highlightCells.length < 6)
|
|
604
|
+
return hint.text;
|
|
605
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
606
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
607
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
608
|
+
const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
|
|
609
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
610
|
+
let explanation = `Swordfish found for digit ${digit}. `;
|
|
611
|
+
explanation += `In Rows ${rows.map(r => r + 1).join(', ')}, digit ${digit} is restricted to Columns ${cols.map(c => c + 1).join(', ')}. `;
|
|
612
|
+
explanation += `This 3×3 fish pattern means ${digit} must appear once per row in these columns. `;
|
|
613
|
+
if (elimCells.length > 0) {
|
|
614
|
+
explanation += `Eliminate ${digit} from other cells in these columns: ${cellList(elimCells)}.`;
|
|
615
|
+
}
|
|
616
|
+
return explanation;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Jellyfish: A 4x4 fish pattern.
|
|
620
|
+
*/
|
|
621
|
+
function explainJellyfish(hint, highlightCells, removeCells) {
|
|
622
|
+
if (highlightCells.length < 8)
|
|
623
|
+
return hint.text;
|
|
624
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
625
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
626
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
627
|
+
const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
|
|
628
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
629
|
+
let explanation = `Jellyfish found for digit ${digit}. `;
|
|
630
|
+
explanation += `In Rows ${rows.map(r => r + 1).join(', ')}, digit ${digit} is restricted to Columns ${cols.map(c => c + 1).join(', ')}. `;
|
|
631
|
+
explanation += `This 4×4 fish pattern locks ${digit} within these intersections. `;
|
|
632
|
+
if (elimCells.length > 0) {
|
|
633
|
+
explanation += `Eliminate ${digit} from other cells in these columns: ${cellList(elimCells)}.`;
|
|
634
|
+
}
|
|
635
|
+
return explanation;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Squirmbag: A 5x5 fish pattern.
|
|
639
|
+
*/
|
|
640
|
+
function explainSquirmbag(hint, highlightCells, removeCells) {
|
|
641
|
+
if (highlightCells.length < 10)
|
|
642
|
+
return hint.text;
|
|
643
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
644
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
645
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
646
|
+
const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
|
|
647
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
648
|
+
let explanation = `Squirmbag (5-fish) found for digit ${digit}. `;
|
|
649
|
+
explanation += `Across Rows ${rows.map(r => r + 1).join(', ')}, digit ${digit} is confined to Columns ${cols.map(c => c + 1).join(', ')}. `;
|
|
650
|
+
explanation += `This rare 5×5 fish pattern restricts ${digit} to these intersections. `;
|
|
651
|
+
if (elimCells.length > 0) {
|
|
652
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
653
|
+
}
|
|
654
|
+
return explanation;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* XY-Wing: Three cells forming a bent pattern with bivalve cells.
|
|
658
|
+
*/
|
|
659
|
+
function explainXYWing(hint, highlightCells, removeCells) {
|
|
660
|
+
if (highlightCells.length < 3)
|
|
661
|
+
return hint.text;
|
|
662
|
+
const cells = highlightCells
|
|
663
|
+
.slice(0, 3)
|
|
664
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
665
|
+
const zDigit = removeCells.length > 0
|
|
666
|
+
? formatDigit(getFirstValidDigit(removeCells[0]?.digits))
|
|
667
|
+
: '?';
|
|
668
|
+
let explanation = `XY-Wing pattern found. `;
|
|
669
|
+
explanation += `Three cells form a "bent triple": ${cellList(cells)}. `;
|
|
670
|
+
explanation += `The pivot cell sees both wing cells, and each wing contains the digit ${zDigit}. `;
|
|
671
|
+
explanation += `If the pivot takes one value, one wing gets ${zDigit}. If it takes the other, the other wing gets ${zDigit}. `;
|
|
672
|
+
explanation += `Either way, ${zDigit} must appear in one of the wings. `;
|
|
673
|
+
if (removeCells.length > 0) {
|
|
674
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
675
|
+
explanation += `Eliminate ${zDigit} from cells that see both wings: ${cellList(elimCells)}.`;
|
|
676
|
+
}
|
|
677
|
+
return explanation;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* XYZ-Wing: Similar to XY-Wing but pivot has 3 candidates (XYZ).
|
|
681
|
+
*/
|
|
682
|
+
function explainXYZWing(hint, highlightCells, removeCells) {
|
|
683
|
+
if (highlightCells.length < 3)
|
|
684
|
+
return hint.text;
|
|
685
|
+
const cells = highlightCells
|
|
686
|
+
.slice(0, 3)
|
|
687
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
688
|
+
const zDigit = removeCells.length > 0
|
|
689
|
+
? formatDigit(getFirstValidDigit(removeCells[0]?.digits))
|
|
690
|
+
: '?';
|
|
691
|
+
let explanation = `XYZ-Wing pattern found. `;
|
|
692
|
+
explanation += `The pivot cell has 3 candidates (XYZ), and two wing cells each have 2 candidates. `;
|
|
693
|
+
explanation += `Cells: ${cellList(cells)}. `;
|
|
694
|
+
explanation += `The digit ${zDigit} appears in all three pattern cells. `;
|
|
695
|
+
explanation += `One of these cells MUST contain ${zDigit}. `;
|
|
696
|
+
if (removeCells.length > 0) {
|
|
697
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
698
|
+
explanation += `Eliminate ${zDigit} from cells that see all three: ${cellList(elimCells)}.`;
|
|
699
|
+
}
|
|
700
|
+
return explanation;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* WXYZ-Wing: Four cells with four candidates total (WXYZ).
|
|
704
|
+
*/
|
|
705
|
+
function explainWXYZWing(hint, highlightCells, removeCells) {
|
|
706
|
+
if (highlightCells.length < 4)
|
|
707
|
+
return hint.text;
|
|
708
|
+
const cells = highlightCells
|
|
709
|
+
.slice(0, 4)
|
|
710
|
+
.map(c => ({ row: c.row, col: c.col }));
|
|
711
|
+
const elimDigit = removeCells.length > 0
|
|
712
|
+
? formatDigit(getFirstValidDigit(removeCells[0]?.digits))
|
|
713
|
+
: '?';
|
|
714
|
+
const allDigits = new Set();
|
|
715
|
+
highlightCells.slice(0, 4).forEach(c => {
|
|
716
|
+
for (const d of getValidDigits(c.digits))
|
|
717
|
+
allDigits.add(d);
|
|
718
|
+
});
|
|
719
|
+
let explanation = `WXYZ-Wing pattern found. `;
|
|
720
|
+
explanation += `Four cells ${cellList(cells)} contain exactly 4 candidates: ${formatDigits(Array.from(allDigits))}. `;
|
|
721
|
+
explanation += `One of these cells must contain ${elimDigit}. `;
|
|
722
|
+
if (removeCells.length > 0) {
|
|
723
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
724
|
+
explanation += `Eliminate ${elimDigit} from cells that see all instances of ${elimDigit} in the pattern: ${cellList(elimCells)}.`;
|
|
725
|
+
}
|
|
726
|
+
return explanation;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Finned X-Wing
|
|
730
|
+
*/
|
|
731
|
+
function explainFinnedXWing(hint, highlightCells, removeCells) {
|
|
732
|
+
if (highlightCells.length < 4)
|
|
733
|
+
return hint.text;
|
|
734
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
735
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
736
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
737
|
+
const cols = [...new Set(cells.map(c => c.col))].sort((a, b) => a - b);
|
|
738
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
739
|
+
let explanation = `Finned X-Wing for digit ${digit}. `;
|
|
740
|
+
explanation += `An X-Wing pattern exists in Rows ${rows.map(r => r + 1).join(' and ')}, Columns ${cols
|
|
741
|
+
.slice(0, 2)
|
|
742
|
+
.map(c => c + 1)
|
|
743
|
+
.join(' and ')}, `;
|
|
744
|
+
explanation += `but with an extra "fin" candidate that breaks the perfect rectangle. `;
|
|
745
|
+
explanation += `The fin limits eliminations to cells that see both the pattern and the fin. `;
|
|
746
|
+
if (elimCells.length > 0) {
|
|
747
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
748
|
+
}
|
|
749
|
+
return explanation;
|
|
750
|
+
}
|
|
751
|
+
function explainFinnedSwordfish(hint, highlightCells, removeCells) {
|
|
752
|
+
if (highlightCells.length < 6)
|
|
753
|
+
return hint.text;
|
|
754
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
755
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
756
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
757
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
758
|
+
let explanation = `Finned Swordfish for digit ${digit}. `;
|
|
759
|
+
explanation += `A Swordfish pattern exists across Rows ${rows.map(r => r + 1).join(', ')}, `;
|
|
760
|
+
explanation += `but with an extra "fin" that restricts eliminations. `;
|
|
761
|
+
explanation += `Only cells seeing both the pattern and fin can have ${digit} eliminated. `;
|
|
762
|
+
if (elimCells.length > 0) {
|
|
763
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
764
|
+
}
|
|
765
|
+
return explanation;
|
|
766
|
+
}
|
|
767
|
+
function explainFinnedJellyfish(hint, highlightCells, removeCells) {
|
|
768
|
+
if (highlightCells.length < 8)
|
|
769
|
+
return hint.text;
|
|
770
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
771
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
772
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
773
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
774
|
+
let explanation = `Finned Jellyfish for digit ${digit}. `;
|
|
775
|
+
explanation += `A Jellyfish pattern exists across Rows ${rows.map(r => r + 1).join(', ')}, `;
|
|
776
|
+
explanation += `with an extra "fin" candidate. `;
|
|
777
|
+
if (elimCells.length > 0) {
|
|
778
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
779
|
+
}
|
|
780
|
+
return explanation;
|
|
781
|
+
}
|
|
782
|
+
function explainFinnedSquirmbag(hint, highlightCells, removeCells) {
|
|
783
|
+
if (highlightCells.length < 10)
|
|
784
|
+
return hint.text;
|
|
785
|
+
const digit = formatDigit(getFirstValidDigit(highlightCells[0]?.digits));
|
|
786
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
787
|
+
const rows = [...new Set(cells.map(c => c.row))].sort((a, b) => a - b);
|
|
788
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
789
|
+
let explanation = `Finned Squirmbag (5-fish) for digit ${digit}. `;
|
|
790
|
+
explanation += `A rare 5-fish pattern across Rows ${rows.map(r => r + 1).join(', ')}, with a fin. `;
|
|
791
|
+
if (elimCells.length > 0) {
|
|
792
|
+
explanation += `Eliminate ${digit} from ${cellList(elimCells)}.`;
|
|
793
|
+
}
|
|
794
|
+
return explanation;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Almost Locked Sets (ALS)
|
|
798
|
+
*/
|
|
799
|
+
function explainAlmostLockedSets(hint, highlightCells, removeCells) {
|
|
800
|
+
if (highlightCells.length < 2)
|
|
801
|
+
return hint.text;
|
|
802
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
803
|
+
const house = findCommonHouse(cells);
|
|
804
|
+
const houseName = house?.name || 'their shared house';
|
|
805
|
+
const alsDigits = new Set();
|
|
806
|
+
highlightCells.forEach(c => {
|
|
807
|
+
for (const d of getValidDigits(c.digits))
|
|
808
|
+
alsDigits.add(d);
|
|
809
|
+
});
|
|
810
|
+
const n = highlightCells.length;
|
|
811
|
+
const nPlus1 = alsDigits.size;
|
|
812
|
+
let explanation = `Almost Locked Set (ALS) in ${houseName}. `;
|
|
813
|
+
explanation += `Cells ${cellList(cells)} (${n} cells) contain ${nPlus1} candidates: ${formatDigits(Array.from(alsDigits))}. `;
|
|
814
|
+
explanation += `Since there's one "extra" candidate, if ANY digit is removed externally, `;
|
|
815
|
+
explanation += `the remaining ${n} candidates lock into these ${n} cells. `;
|
|
816
|
+
if (removeCells.length > 0) {
|
|
817
|
+
const elimDigits = new Set();
|
|
818
|
+
removeCells.forEach(c => {
|
|
819
|
+
for (const d of getValidDigits(c.digits))
|
|
820
|
+
elimDigits.add(d);
|
|
821
|
+
});
|
|
822
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
823
|
+
explanation += `Eliminate ${formatDigits(Array.from(elimDigits))} from ${cellList(elimCells)}.`;
|
|
824
|
+
}
|
|
825
|
+
return explanation;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* ALS-Chain (ALS-XZ)
|
|
829
|
+
*/
|
|
830
|
+
function explainALSChain(hint, highlightCells, removeCells) {
|
|
831
|
+
if (highlightCells.length < 4)
|
|
832
|
+
return hint.text;
|
|
833
|
+
const cells = highlightCells.map(c => ({ row: c.row, col: c.col }));
|
|
834
|
+
let explanation = `ALS-XZ (ALS-Chain) pattern. `;
|
|
835
|
+
explanation += `Two Almost Locked Sets share a Restricted Common Candidate (RCC). `;
|
|
836
|
+
explanation += `Pattern cells: ${cellList(cells)}. `;
|
|
837
|
+
explanation += `The RCC can only be true in one ALS, which forces the other ALS to become fully locked. `;
|
|
838
|
+
if (removeCells.length > 0) {
|
|
839
|
+
const elimDigits = new Set();
|
|
840
|
+
removeCells.forEach(c => {
|
|
841
|
+
for (const d of getValidDigits(c.digits))
|
|
842
|
+
elimDigits.add(d);
|
|
843
|
+
});
|
|
844
|
+
const elimCells = removeCells.map(c => ({ row: c.row, col: c.col }));
|
|
845
|
+
explanation += `Eliminate ${formatDigits(Array.from(elimDigits))} from ${cellList(elimCells)}.`;
|
|
846
|
+
}
|
|
847
|
+
return explanation;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Generic explanation for unknown or unsupported techniques.
|
|
851
|
+
*/
|
|
852
|
+
function generateGenericExplanation(hint, selectCells, removeCells) {
|
|
853
|
+
const parts = [];
|
|
854
|
+
parts.push(hint.text);
|
|
855
|
+
if (selectCells.length > 0) {
|
|
856
|
+
const placements = selectCells.map(c => `${formatDigit(c.digit)} in ${cellName(c.row, c.col)}`);
|
|
857
|
+
parts.push(`Place: ${placements.join(', ')}.`);
|
|
858
|
+
}
|
|
859
|
+
if (removeCells.length > 0) {
|
|
860
|
+
const eliminations = removeCells.map(c => `${formatDigits(c.digits)} from ${cellName(c.row, c.col)}`);
|
|
861
|
+
if (eliminations.length <= 5) {
|
|
862
|
+
parts.push(`Eliminate: ${eliminations.join('; ')}.`);
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
parts.push(`Eliminate candidates from ${removeCells.length} cells.`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return parts.join(' ');
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Get a summary of the hint action (what to do)
|
|
872
|
+
*/
|
|
873
|
+
export function getHintActionSummary(hint, digitDisplay) {
|
|
874
|
+
_display = digitDisplay ?? 'numeric';
|
|
875
|
+
const selectCells = getSelectCells(hint);
|
|
876
|
+
const removeCells = getRemoveCells(hint);
|
|
877
|
+
if (selectCells.length > 0) {
|
|
878
|
+
if (selectCells.length === 1) {
|
|
879
|
+
const c = selectCells[0];
|
|
880
|
+
if (!c)
|
|
881
|
+
return 'Place a digit';
|
|
882
|
+
return `Place ${formatDigit(c.digit)} in ${cellName(c.row, c.col)}`;
|
|
883
|
+
}
|
|
884
|
+
return `Place digits in ${selectCells.length} cells`;
|
|
885
|
+
}
|
|
886
|
+
if (removeCells.length > 0) {
|
|
887
|
+
const allDigits = new Set();
|
|
888
|
+
removeCells.forEach(c => {
|
|
889
|
+
for (const d of getValidDigits(c.digits))
|
|
890
|
+
allDigits.add(d);
|
|
891
|
+
});
|
|
892
|
+
return `Eliminate ${formatDigits(Array.from(allDigits))} from ${removeCells.length} cell${removeCells.length > 1 ? 's' : ''}`;
|
|
893
|
+
}
|
|
894
|
+
return 'Apply the technique';
|
|
895
|
+
}
|
|
896
|
+
//# sourceMappingURL=hintExplanation.js.map
|