@johnmorrisdotca/kazu 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +568 -0
  4. package/dist/cells.d.ts +28 -0
  5. package/dist/cells.js +65 -0
  6. package/dist/check.d.ts +15 -0
  7. package/dist/check.js +189 -0
  8. package/dist/clock.d.ts +2 -0
  9. package/dist/clock.js +9 -0
  10. package/dist/conflicts.d.ts +14 -0
  11. package/dist/conflicts.js +79 -0
  12. package/dist/draw-entry.d.ts +14 -0
  13. package/dist/draw-entry.js +10 -0
  14. package/dist/draw.d.ts +47 -0
  15. package/dist/draw.js +219 -0
  16. package/dist/element-define.d.ts +1 -0
  17. package/dist/element-define.js +13 -0
  18. package/dist/element.d.ts +48 -0
  19. package/dist/element.js +150 -0
  20. package/dist/game.d.ts +65 -0
  21. package/dist/game.js +120 -0
  22. package/dist/generate.d.ts +11 -0
  23. package/dist/generate.js +35 -0
  24. package/dist/geometry.d.ts +33 -0
  25. package/dist/geometry.js +27 -0
  26. package/dist/givens.d.ts +30 -0
  27. package/dist/givens.js +43 -0
  28. package/dist/groupSolve.d.ts +68 -0
  29. package/dist/groupSolve.js +284 -0
  30. package/dist/hint.d.ts +39 -0
  31. package/dist/hint.js +158 -0
  32. package/dist/index.d.ts +36 -0
  33. package/dist/index.js +31 -0
  34. package/dist/jigsaw.d.ts +21 -0
  35. package/dist/jigsaw.js +144 -0
  36. package/dist/kinds.d.ts +51 -0
  37. package/dist/kinds.js +40 -0
  38. package/dist/layout.d.ts +63 -0
  39. package/dist/layout.js +128 -0
  40. package/dist/moreOrLess.d.ts +7 -0
  41. package/dist/moreOrLess.js +90 -0
  42. package/dist/moreOrLessCode.d.ts +22 -0
  43. package/dist/moreOrLessCode.js +52 -0
  44. package/dist/moreOrLessSolve.d.ts +45 -0
  45. package/dist/moreOrLessSolve.js +180 -0
  46. package/dist/mount.d.ts +117 -0
  47. package/dist/mount.js +558 -0
  48. package/dist/names.d.ts +41 -0
  49. package/dist/names.js +163 -0
  50. package/dist/numberPlace.d.ts +14 -0
  51. package/dist/numberPlace.js +123 -0
  52. package/dist/play-entry.d.ts +9 -0
  53. package/dist/play-entry.js +8 -0
  54. package/dist/playStyle.d.ts +11 -0
  55. package/dist/playStyle.js +47 -0
  56. package/dist/progress.d.ts +29 -0
  57. package/dist/progress.js +96 -0
  58. package/dist/random.d.ts +25 -0
  59. package/dist/random.js +42 -0
  60. package/dist/solve.d.ts +22 -0
  61. package/dist/solve.js +60 -0
  62. package/dist/strings.d.ts +17 -0
  63. package/dist/strings.js +143 -0
  64. package/dist/style.d.ts +13 -0
  65. package/dist/style.js +61 -0
  66. package/dist/sumCages.d.ts +60 -0
  67. package/dist/sumCages.js +190 -0
  68. package/dist/towers.d.ts +3 -0
  69. package/dist/towers.js +48 -0
  70. package/dist/towersCode.d.ts +31 -0
  71. package/dist/towersCode.js +79 -0
  72. package/dist/towersSolve.d.ts +65 -0
  73. package/dist/towersSolve.js +276 -0
  74. package/dist/version.d.ts +2 -0
  75. package/dist/version.js +2 -0
  76. package/package.json +104 -0
package/dist/solve.js ADDED
@@ -0,0 +1,60 @@
1
+ import { encodeCells } from "./cells.js";
2
+ import { countSolutionsWithin, guessDepth as groupDepth, solutionOf as groupSolution } from "./groupSolve.js";
3
+ import { layoutOfGivens, readGivens } from "./givens.js";
4
+ import { countSolutions as countMarks, guessDepth as marksDepth, solutionOf as marksSolution } from "./moreOrLessSolve.js";
5
+ import { countSolutions as countClues, guessDepth as cluesDepth, solutionOf as cluesSolution } from "./towersSolve.js";
6
+ /**
7
+ * How many answers a puzzle has, up to `limit` (two by default, so "many" costs no more than "two"):
8
+ * 1 is a puzzle, 0 is a grid that cannot be finished, 2 is a guessing game. Null, never a number,
9
+ * for givens that are not a puzzle of this kind and side, and for a search that ran past `budget`
10
+ * steps (Sudoku, Jigsaw, Diagonal and Sum Cages count steps; the other two have no budget): "I
11
+ * could not say" is not "there are none".
12
+ */
13
+ export function countKazuSolutions(kind, size, givens, limit = 2, budget = Infinity) {
14
+ const read = readGivens(kind, size, givens);
15
+ if (read === null)
16
+ return null;
17
+ if (kind === "more-or-less")
18
+ return countMarks(read.cells, size, read.marks, limit);
19
+ if (kind === "towers")
20
+ return countClues(read.cells, size, read.clues, limit);
21
+ const layout = layoutOfGivens(read);
22
+ return layout === null ? null : countSolutionsWithin(read.cells, layout, limit, budget);
23
+ }
24
+ /**
25
+ * The one answer a puzzle's givens allow, as a cells code, or null when they allow none, more than
26
+ * one, or the search ran past `budget` steps (Sudoku, Jigsaw, Diagonal and Sum Cages). A grid this
27
+ * cannot vouch for is never handed back as though it were the answer.
28
+ */
29
+ export function solveKazu(kind, size, givens, budget = 2000000) {
30
+ const read = readGivens(kind, size, givens);
31
+ if (read === null)
32
+ return null;
33
+ let grid;
34
+ if (kind === "more-or-less")
35
+ grid = marksSolution(read.cells, size, read.marks);
36
+ else if (kind === "towers")
37
+ grid = cluesSolution(read.cells, size, read.clues);
38
+ else {
39
+ const layout = layoutOfGivens(read);
40
+ grid = layout === null ? null : groupSolution(read.cells, layout, budget);
41
+ }
42
+ return grid === null ? null : encodeCells(grid);
43
+ }
44
+ /**
45
+ * How many guesses, each followed by everything reasoning then finds, a person needs to finish the
46
+ * puzzle: 0 when reasoning alone finishes it (easy), 1 when one guess does (medium), more when more
47
+ * (hard); Infinity when there is no answer. Null for givens that are not a puzzle. Meant for a
48
+ * puzzle already known to have exactly one answer.
49
+ */
50
+ export function kazuGuessDepth(kind, size, givens) {
51
+ const read = readGivens(kind, size, givens);
52
+ if (read === null)
53
+ return null;
54
+ if (kind === "more-or-less")
55
+ return marksDepth(read.cells, size, read.marks);
56
+ if (kind === "towers")
57
+ return cluesDepth(read.cells, size, read.clues);
58
+ const layout = layoutOfGivens(read);
59
+ return layout === null ? null : groupDepth(read.cells, layout);
60
+ }
@@ -0,0 +1,17 @@
1
+ import type { KazuKind } from "./kinds.ts";
2
+ /**
3
+ * THE WORDS A KAZU BOARD SAYS, in English and Japanese: what a screen reader hears of the drawing, the
4
+ * buttons and lines under a playable board, and what a hint says. Plain data, so a page can read them,
5
+ * replace a few or add a language of its own beside these two.
6
+ *
7
+ * `{name}` in a line is a value filled in; a line `foo` that has a `fooOne` beside it is said as
8
+ * `fooOne` when its `{n}` is 1.
9
+ */
10
+ export type KazuLanguage = "en" | "ja";
11
+ export declare const KAZU_STRINGS: Record<KazuLanguage, Record<string, string>>;
12
+ /** A line in a language, with its values filled in; the line itself if there is none by that name. */
13
+ export declare function kazuSay(language: KazuLanguage, key: string, values?: Record<string, string | number>): string;
14
+ /** The language a piece of text is in: Japanese for anything starting `ja`, English for everything else. */
15
+ export declare function kazuLanguageOf(tag: string | null | undefined): KazuLanguage;
16
+ /** What a puzzle is called in a language, as a screen reader says it. */
17
+ export declare function kazuNameOf(kind: KazuKind, language: KazuLanguage): string;
@@ -0,0 +1,143 @@
1
+ import { KAZU_NAMES } from "./names.js";
2
+ export const KAZU_STRINGS = {
3
+ en: {
4
+ board: "{name} puzzle, {size} by {size}",
5
+ cell: "row {row}, column {col}",
6
+ cellEmpty: "{cell}, empty",
7
+ cellValue: "{cell}, {value}",
8
+ cellGiven: "{cell}, {value}, printed",
9
+ cellNotes: "{cell}, empty, pencil marks {notes}",
10
+ cellConflict: "{cell}, {value}, breaks a rule",
11
+ cageSum: "a cage adding to {sum}",
12
+ clueTop: "Towers seen from the top of column {at}: {n}",
13
+ clueBottom: "Towers seen from the bottom of column {at}: {n}",
14
+ clueLeft: "Towers seen from the left of row {at}: {n}",
15
+ clueRight: "Towers seen from the right of row {at}: {n}",
16
+ markLess: "{a} is smaller than {b}",
17
+ solved: "Solved. Every cell is filled and right.",
18
+ solvedIn: "Solved in {time}. Every cell is filled and right.",
19
+ tap: "Tap a cell, then a number.",
20
+ tapPencil: "Pencil marks are on: tap a number to note it in the cell.",
21
+ keys: "Arrow keys move, a number fills the cell, Backspace empties it, N turns pencil marks on or off.",
22
+ undo: "Undo",
23
+ pencil: "Pencil",
24
+ pencilTitle: "Write small notes in a cell instead of a number",
25
+ erase: "Erase the cell",
26
+ hint: "Hint",
27
+ hintTitle: "Show the next cell you could fill in, and why",
28
+ check: "Check",
29
+ checkTitle: "Say how many cells are wrong, without saying which",
30
+ restart: "Restart",
31
+ clock: "Time",
32
+ pad: "Numbers",
33
+ padNumber: "Number {n}",
34
+ padDone: "Number {n}, all placed",
35
+ full: "Every cell is filled, and it is not right yet.",
36
+ checkRight: "Everything filled in so far is right.",
37
+ checkWrong: "{wrong} cells are wrong, {empty} still to fill.",
38
+ checkWrongOne: "{wrong} cell is wrong, {empty} still to fill.",
39
+ checkWrongNoneEmpty: "{wrong} cells are wrong.",
40
+ checkWrongNoneEmptyOne: "{wrong} cell is wrong.",
41
+ checkOnlyEmpty: "Nothing wrong so far, {empty} still to fill.",
42
+ conflicts: "{n} cells break a rule.",
43
+ conflictsOne: "{n} cell breaks a rule.",
44
+ hintOnly: "{cell} can only be {value}: every other number is ruled out by {where}.",
45
+ hintOnlyBy: "{cell} can only be {value}: every other number is ruled out by {where} and {by}.",
46
+ hintPlace: "In {group}, the {value} can only go in {cell}: no other cell there can hold it.",
47
+ hintAnswer: "{cell} is {value}. Nothing here follows from a single step, so this is the answer's own number.",
48
+ hintReplaces: "What is in {cell} now is wrong. ",
49
+ hintDone: "Every cell is right.",
50
+ whereNumberPlace: "its row, column and box",
51
+ whereDiagonal: "its row, column, box and diagonal",
52
+ whereJigsaw: "its row, column and region",
53
+ whereSumCages: "its row, column, box and cage",
54
+ whereMoreOrLess: "its row and column",
55
+ whereTowers: "its row and column",
56
+ byCage: "the cage's sum",
57
+ byMarks: "the more-than marks",
58
+ byClues: "the clues round the edge",
59
+ groupRow: "row {n}",
60
+ groupColumn: "column {n}",
61
+ groupBox: "box {n}",
62
+ groupRegion: "region {n}",
63
+ groupDiagonal: "the diagonal from the top left",
64
+ groupDiagonalOther: "the diagonal from the top right",
65
+ paused: "Paused",
66
+ },
67
+ ja: {
68
+ board: "{name}、{size}×{size}",
69
+ cell: "{row}行{col}列",
70
+ cellEmpty: "{cell}、空き",
71
+ cellValue: "{cell}、{value}",
72
+ cellGiven: "{cell}、{value}、最初から書かれています",
73
+ cellNotes: "{cell}、空き、メモ{notes}",
74
+ cellConflict: "{cell}、{value}、ルールに反しています",
75
+ cageSum: "合計{sum}の囲み",
76
+ clueTop: "第{at}列の上から見える塔の数:{n}",
77
+ clueBottom: "第{at}列の下から見える塔の数:{n}",
78
+ clueLeft: "第{at}行の左から見える塔の数:{n}",
79
+ clueRight: "第{at}行の右から見える塔の数:{n}",
80
+ markLess: "{a}は{b}より小さい",
81
+ solved: "解けました。すべてのマスが埋まり、すべて正しい数字です。",
82
+ solvedIn: "{time}で解けました。すべてのマスが埋まり、すべて正しい数字です。",
83
+ tap: "マスをタップして、数字を選びます。",
84
+ tapPencil: "メモがオンです。数字をタップすると、マスにメモします。",
85
+ keys: "矢印キーで移動、数字キーで入力、Backspaceで消去、Nでメモのオンとオフ。",
86
+ undo: "元に戻す",
87
+ pencil: "メモ",
88
+ pencilTitle: "数字のかわりに、マスに小さくメモを書きます",
89
+ erase: "このマスを消す",
90
+ hint: "ヒント",
91
+ hintTitle: "次に入れられるマスと、その理由を表示します",
92
+ check: "確かめる",
93
+ checkTitle: "まちがっているマスの数だけを教えます(どこかは教えません)",
94
+ restart: "やり直す",
95
+ clock: "時間",
96
+ pad: "数字",
97
+ padNumber: "数字{n}",
98
+ padDone: "数字{n}、すべて置きました",
99
+ full: "すべてのマスが埋まりましたが、まだ正しくありません。",
100
+ checkRight: "ここまでに入れた数字は、すべて正しいです。",
101
+ checkWrong: "まちがいが{wrong}マス、まだ空きが{empty}マスあります。",
102
+ checkWrongNoneEmpty: "まちがいが{wrong}マスあります。",
103
+ checkOnlyEmpty: "いまのところまちがいはなく、空きが{empty}マスあります。",
104
+ conflicts: "ルールに反するマスが{n}マスあります。",
105
+ hintOnly: "{cell}は{value}しか入りません。ほかの数字は、{where}で入れないからです。",
106
+ hintOnlyBy: "{cell}は{value}しか入りません。ほかの数字は、{where}と{by}で入れないからです。",
107
+ hintPlace: "{group}で、{value}を入れられるのは{cell}だけです。ほかのマスには入りません。",
108
+ hintAnswer: "{cell}は{value}です。ここは一手で決まるところがないので、答えの数字をそのまま示しています。",
109
+ hintReplaces: "いま{cell}に入っている数字はまちがいです。",
110
+ hintDone: "すべてのマスが正しいです。",
111
+ whereNumberPlace: "同じ行、列、ブロック",
112
+ whereDiagonal: "同じ行、列、ブロック、対角線",
113
+ whereJigsaw: "同じ行、列、領域",
114
+ whereSumCages: "同じ行、列、ブロック、囲み",
115
+ whereMoreOrLess: "同じ行と列",
116
+ whereTowers: "同じ行と列",
117
+ byCage: "囲みの合計",
118
+ byMarks: "不等号",
119
+ byClues: "盤の外の数字",
120
+ groupRow: "第{n}行",
121
+ groupColumn: "第{n}列",
122
+ groupBox: "第{n}ブロック",
123
+ groupRegion: "第{n}領域",
124
+ groupDiagonal: "左上から右下への対角線",
125
+ groupDiagonalOther: "右上から左下への対角線",
126
+ paused: "一時停止中",
127
+ },
128
+ };
129
+ /** A line in a language, with its values filled in; the line itself if there is none by that name. */
130
+ export function kazuSay(language, key, values = {}) {
131
+ const table = KAZU_STRINGS[language] ?? KAZU_STRINGS.en;
132
+ const one = values.n === 1 || values.wrong === 1 ? table[`${key}One`] : undefined;
133
+ const line = one ?? table[key] ?? KAZU_STRINGS.en[key] ?? key;
134
+ return line.replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
135
+ }
136
+ /** The language a piece of text is in: Japanese for anything starting `ja`, English for everything else. */
137
+ export function kazuLanguageOf(tag) {
138
+ return String(tag ?? "").toLowerCase().startsWith("ja") ? "ja" : "en";
139
+ }
140
+ /** What a puzzle is called in a language, as a screen reader says it. */
141
+ export function kazuNameOf(kind, language) {
142
+ return KAZU_NAMES[kind][language];
143
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * THE STYLE a Kazu drawing wears: the colours of its board as custom properties, and the one rule that
3
+ * matters for a puzzle played with fingers: nothing in the drawing can be selected, dragged or
4
+ * double-tapped.
5
+ *
6
+ * `drawKazu` only writes classes, data attributes and a few custom properties; this is what gives them
7
+ * a look. Every colour is a custom property on `.kazu` (`--kz-paper`, `--kz-ink`, `--kz-given`,
8
+ * `--kz-entry`, `--kz-note`, `--kz-grid`, `--kz-box`, `--kz-frame`, `--kz-cage`, `--kz-clue`,
9
+ * `--kz-diagonal`, `--kz-peer`, `--kz-same`, `--kz-select`, `--kz-hint`, `--kz-conflict`, `--kz-wrong`,
10
+ * `--kz-good`), so a page's own style needs to set only the ones it wants different. The paper follows
11
+ * the page's light or dark. Nothing moves, so there is nothing for reduced motion to still.
12
+ */
13
+ export declare const KAZU_STYLE = "\n.kazu {\n --kz-paper: #fbf8f1; --kz-ink: #1f2320; --kz-given: #1f2320; --kz-entry: #1d5fa8; --kz-note: #5b6b7d;\n --kz-grid: #cfc6b2; --kz-box: #3a3d38; --kz-frame: #a98954; --kz-cage: #6a5a8e; --kz-clue: #7a4b14;\n --kz-diagonal: #e9dfc6; --kz-peer: #efe8d8; --kz-same: #dcd0f2; --kz-select: #ffe08a; --kz-hint: #b9e3c4;\n --kz-conflict: #f4b8ad; --kz-wrong: #f4b8ad; --kz-bad: #b5452c; --kz-good: #2f7a4f;\n --kz-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: block; width: 100%; height: auto;\n user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent;\n overflow: visible;\n}\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) .kazu {\n --kz-paper: #262a27; --kz-ink: #ece8dc; --kz-given: #ece8dc; --kz-entry: #8fc1ff; --kz-note: #9fb0c2;\n --kz-grid: #454a44; --kz-box: #c9c5b8; --kz-frame: #6b5632; --kz-cage: #b8a5e6; --kz-clue: #e8c48f;\n --kz-diagonal: #34382f; --kz-peer: #2f332f; --kz-same: #433a5c; --kz-select: #6b5a1f; --kz-hint: #25503a;\n --kz-conflict: #6e2f26; --kz-wrong: #6e2f26; --kz-bad: #ff8a6b; --kz-good: #6fcf97;\n }\n}\n:root[data-theme=\"dark\"] .kazu {\n --kz-paper: #262a27; --kz-ink: #ece8dc; --kz-given: #ece8dc; --kz-entry: #8fc1ff; --kz-note: #9fb0c2;\n --kz-grid: #454a44; --kz-box: #c9c5b8; --kz-frame: #6b5632; --kz-cage: #b8a5e6; --kz-clue: #e8c48f;\n --kz-diagonal: #34382f; --kz-peer: #2f332f; --kz-same: #433a5c; --kz-select: #6b5a1f; --kz-hint: #25503a;\n --kz-conflict: #6e2f26; --kz-wrong: #6e2f26; --kz-bad: #ff8a6b; --kz-good: #6fcf97;\n}\n.kazu * { user-select: none; -webkit-user-select: none; }\n.kazu .kz-frame { fill: var(--kz-frame); }\n.kazu .kz-paper { fill: var(--kz-paper); }\n.kazu .kz-diagonal { fill: var(--kz-diagonal); }\n.kazu .kz-peer { fill: var(--kz-peer); }\n.kazu .kz-same { fill: var(--kz-same); }\n.kazu .kz-select { fill: var(--kz-select); }\n.kazu .kz-hint { fill: var(--kz-hint); }\n.kazu .kz-conflict, .kazu .kz-wrong { fill: var(--kz-conflict); }\n.kazu .kz-solved { fill: var(--kz-good); opacity: .14; pointer-events: none; }\n.kazu .kz-grid { fill: none; stroke: var(--kz-grid); stroke-width: 1px; vector-effect: non-scaling-stroke; }\n.kazu .kz-box { fill: none; stroke: var(--kz-box); stroke-width: 2.5px; stroke-linecap: square; vector-effect: non-scaling-stroke; }\n.kazu .kz-cage { stroke: var(--kz-cage); stroke-width: 1.5px; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }\n.kazu .kz-digit { font-family: var(--kz-font); font-weight: 600; text-anchor: middle; dominant-baseline: central; font-variant-numeric: tabular-nums; pointer-events: none; }\n.kazu .kz-given { fill: var(--kz-given); font-weight: 700; }\n.kazu .kz-entry { fill: var(--kz-entry); }\n.kazu .kz-digit.kz-bad { fill: var(--kz-bad); }\n.kazu .kz-note { font-family: var(--kz-font); font-weight: 600; fill: var(--kz-note); text-anchor: middle; dominant-baseline: central; pointer-events: none; }\n.kazu .kz-cage-sum { font-family: var(--kz-font); font-weight: 600; fill: var(--kz-cage); text-anchor: start; dominant-baseline: hanging; pointer-events: none; }\n.kazu .kz-clue { font-family: var(--kz-font); font-weight: 700; fill: var(--kz-clue); text-anchor: middle; dominant-baseline: central; pointer-events: none; }\n.kazu .kz-mark-bg { fill: var(--kz-paper); stroke: none; pointer-events: none; }\n.kazu .kz-mark { fill: none; stroke: var(--kz-ink); stroke-width: 2px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; pointer-events: none; }\n.kazu .kz-hit { fill: transparent; cursor: pointer; }\n";
package/dist/style.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * THE STYLE a Kazu drawing wears: the colours of its board as custom properties, and the one rule that
3
+ * matters for a puzzle played with fingers: nothing in the drawing can be selected, dragged or
4
+ * double-tapped.
5
+ *
6
+ * `drawKazu` only writes classes, data attributes and a few custom properties; this is what gives them
7
+ * a look. Every colour is a custom property on `.kazu` (`--kz-paper`, `--kz-ink`, `--kz-given`,
8
+ * `--kz-entry`, `--kz-note`, `--kz-grid`, `--kz-box`, `--kz-frame`, `--kz-cage`, `--kz-clue`,
9
+ * `--kz-diagonal`, `--kz-peer`, `--kz-same`, `--kz-select`, `--kz-hint`, `--kz-conflict`, `--kz-wrong`,
10
+ * `--kz-good`), so a page's own style needs to set only the ones it wants different. The paper follows
11
+ * the page's light or dark. Nothing moves, so there is nothing for reduced motion to still.
12
+ */
13
+ export const KAZU_STYLE = `
14
+ .kazu {
15
+ --kz-paper: #fbf8f1; --kz-ink: #1f2320; --kz-given: #1f2320; --kz-entry: #1d5fa8; --kz-note: #5b6b7d;
16
+ --kz-grid: #cfc6b2; --kz-box: #3a3d38; --kz-frame: #a98954; --kz-cage: #6a5a8e; --kz-clue: #7a4b14;
17
+ --kz-diagonal: #e9dfc6; --kz-peer: #efe8d8; --kz-same: #dcd0f2; --kz-select: #ffe08a; --kz-hint: #b9e3c4;
18
+ --kz-conflict: #f4b8ad; --kz-wrong: #f4b8ad; --kz-bad: #b5452c; --kz-good: #2f7a4f;
19
+ --kz-font: system-ui, -apple-system, "Segoe UI", sans-serif;
20
+ display: block; width: 100%; height: auto;
21
+ user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
22
+ overflow: visible;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root:not([data-theme="light"]) .kazu {
26
+ --kz-paper: #262a27; --kz-ink: #ece8dc; --kz-given: #ece8dc; --kz-entry: #8fc1ff; --kz-note: #9fb0c2;
27
+ --kz-grid: #454a44; --kz-box: #c9c5b8; --kz-frame: #6b5632; --kz-cage: #b8a5e6; --kz-clue: #e8c48f;
28
+ --kz-diagonal: #34382f; --kz-peer: #2f332f; --kz-same: #433a5c; --kz-select: #6b5a1f; --kz-hint: #25503a;
29
+ --kz-conflict: #6e2f26; --kz-wrong: #6e2f26; --kz-bad: #ff8a6b; --kz-good: #6fcf97;
30
+ }
31
+ }
32
+ :root[data-theme="dark"] .kazu {
33
+ --kz-paper: #262a27; --kz-ink: #ece8dc; --kz-given: #ece8dc; --kz-entry: #8fc1ff; --kz-note: #9fb0c2;
34
+ --kz-grid: #454a44; --kz-box: #c9c5b8; --kz-frame: #6b5632; --kz-cage: #b8a5e6; --kz-clue: #e8c48f;
35
+ --kz-diagonal: #34382f; --kz-peer: #2f332f; --kz-same: #433a5c; --kz-select: #6b5a1f; --kz-hint: #25503a;
36
+ --kz-conflict: #6e2f26; --kz-wrong: #6e2f26; --kz-bad: #ff8a6b; --kz-good: #6fcf97;
37
+ }
38
+ .kazu * { user-select: none; -webkit-user-select: none; }
39
+ .kazu .kz-frame { fill: var(--kz-frame); }
40
+ .kazu .kz-paper { fill: var(--kz-paper); }
41
+ .kazu .kz-diagonal { fill: var(--kz-diagonal); }
42
+ .kazu .kz-peer { fill: var(--kz-peer); }
43
+ .kazu .kz-same { fill: var(--kz-same); }
44
+ .kazu .kz-select { fill: var(--kz-select); }
45
+ .kazu .kz-hint { fill: var(--kz-hint); }
46
+ .kazu .kz-conflict, .kazu .kz-wrong { fill: var(--kz-conflict); }
47
+ .kazu .kz-solved { fill: var(--kz-good); opacity: .14; pointer-events: none; }
48
+ .kazu .kz-grid { fill: none; stroke: var(--kz-grid); stroke-width: 1px; vector-effect: non-scaling-stroke; }
49
+ .kazu .kz-box { fill: none; stroke: var(--kz-box); stroke-width: 2.5px; stroke-linecap: square; vector-effect: non-scaling-stroke; }
50
+ .kazu .kz-cage { stroke: var(--kz-cage); stroke-width: 1.5px; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
51
+ .kazu .kz-digit { font-family: var(--kz-font); font-weight: 600; text-anchor: middle; dominant-baseline: central; font-variant-numeric: tabular-nums; pointer-events: none; }
52
+ .kazu .kz-given { fill: var(--kz-given); font-weight: 700; }
53
+ .kazu .kz-entry { fill: var(--kz-entry); }
54
+ .kazu .kz-digit.kz-bad { fill: var(--kz-bad); }
55
+ .kazu .kz-note { font-family: var(--kz-font); font-weight: 600; fill: var(--kz-note); text-anchor: middle; dominant-baseline: central; pointer-events: none; }
56
+ .kazu .kz-cage-sum { font-family: var(--kz-font); font-weight: 600; fill: var(--kz-cage); text-anchor: start; dominant-baseline: hanging; pointer-events: none; }
57
+ .kazu .kz-clue { font-family: var(--kz-font); font-weight: 700; fill: var(--kz-clue); text-anchor: middle; dominant-baseline: central; pointer-events: none; }
58
+ .kazu .kz-mark-bg { fill: var(--kz-paper); stroke: none; pointer-events: none; }
59
+ .kazu .kz-mark { fill: none; stroke: var(--kz-ink); stroke-width: 2px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; pointer-events: none; }
60
+ .kazu .kz-hit { fill: transparent; cursor: pointer; }
61
+ `;
@@ -0,0 +1,60 @@
1
+ import type { KazuLevel, KazuPuzzle } from "./kinds.ts";
2
+ /**
3
+ * Making a Killer Sudoku (Sum Cages) puzzle from a seed: a filled Sudoku grid, cut into cages whose
4
+ * sums are printed, and no numbers printed at all beyond the odd cage of one cell.
5
+ *
6
+ * THE CAGES ARE GROWN BY JOINING, NOT CUT. Every cell starts as a cage of its own (a grid of printed
7
+ * numbers, which has one answer) and two neighbouring cages are joined only while the puzzle still
8
+ * has exactly one answer, the joined cage holds no number twice, and it is no bigger than the level
9
+ * allows. It stops at the level's count of cages. That is Sudoku's carving the other way up: there
10
+ * a given is taken away while one answer remains; here two sums become one. The answer is checked
11
+ * with a step budget, and a join whose check runs past it is not made: "I stopped looking" is not
12
+ * "one answer".
13
+ *
14
+ * Deterministic in the seed.
15
+ */
16
+ /** The letters that name a cage in a code: up to sixty-two cages. */
17
+ export declare const CAGE_LETTERS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
18
+ /** A cage: the cells in it and the sum of the numbers inside. */
19
+ export type Cage = {
20
+ cells: number[];
21
+ sum: number;
22
+ };
23
+ /**
24
+ * A Sum Cages puzzle's givens: the cells, then which cage each cell is in, then each cage's sum.
25
+ *
26
+ * `cells` is size² characters as every number puzzle writes them (all empty, usually; a cage of one
27
+ * cell is its own given). `cages` is size² characters, one per cell, naming its cage from
28
+ * `CAGE_LETTERS`. `sums` is two base-36 characters a cage, in cage order. The cages ride in the
29
+ * givens because they are the puzzle.
30
+ */
31
+ export declare function encodeKiller(cells: readonly number[], cages: readonly Cage[]): string;
32
+ /**
33
+ * The cells and cages a code says, or null for one that is not a whole, well-formed puzzle: every
34
+ * cell named to a cage, every cage used, and a sum for each.
35
+ */
36
+ export declare function decodeKiller(code: string, size: number): {
37
+ cells: number[];
38
+ cages: Cage[];
39
+ } | null;
40
+ /** A line segment in cell units: where one part of a cage's dashed outline runs. */
41
+ export type Segment = {
42
+ x1: number;
43
+ y1: number;
44
+ x2: number;
45
+ y2: number;
46
+ };
47
+ /**
48
+ * THE DASHED OUTLINE OF EVERY CAGE, as line segments in cell units: each cage drawn a little inside
49
+ * its own edge, the way a printed Killer Sudoku draws it, so a cage reads as a shape within the box
50
+ * rules rather than as one of them.
51
+ *
52
+ * Each cell draws the sides where its neighbour is in another cage, `inset` inside the cell. Where
53
+ * the cage goes on past a side's end, the line runs on to meet the next line of the outline: to the
54
+ * cell's edge when the cage's edge carries on straight, and `inset` past it at an inside corner,
55
+ * where the outline turns back into the cage. That is what makes the lines of one cage meet, with
56
+ * no gap and no overshoot, whatever its shape.
57
+ */
58
+ export declare function cageOutline(size: number, cageOf: (index: number) => number | undefined, inset?: number): Segment[];
59
+ /** A Killer Sudoku (Sum Cages) of this side, level and seed: 6 or 9. */
60
+ export declare function generateSumCages(size: number, level: KazuLevel, seed: number): KazuPuzzle;
@@ -0,0 +1,190 @@
1
+ import { decodeCells, encodeCells } from "./cells.js";
2
+ import { countSolutionsWithin, fillLayout } from "./groupSolve.js";
3
+ import { boxedLayout, cagedLayout, neighbours } from "./layout.js";
4
+ import { seededRandom } from "./random.js";
5
+ /**
6
+ * Making a Killer Sudoku (Sum Cages) puzzle from a seed: a filled Sudoku grid, cut into cages whose
7
+ * sums are printed, and no numbers printed at all beyond the odd cage of one cell.
8
+ *
9
+ * THE CAGES ARE GROWN BY JOINING, NOT CUT. Every cell starts as a cage of its own (a grid of printed
10
+ * numbers, which has one answer) and two neighbouring cages are joined only while the puzzle still
11
+ * has exactly one answer, the joined cage holds no number twice, and it is no bigger than the level
12
+ * allows. It stops at the level's count of cages. That is Sudoku's carving the other way up: there
13
+ * a given is taken away while one answer remains; here two sums become one. The answer is checked
14
+ * with a step budget, and a join whose check runs past it is not made: "I stopped looking" is not
15
+ * "one answer".
16
+ *
17
+ * Deterministic in the seed.
18
+ */
19
+ /** The letters that name a cage in a code: up to sixty-two cages. */
20
+ export const CAGE_LETTERS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
21
+ /**
22
+ * A Sum Cages puzzle's givens: the cells, then which cage each cell is in, then each cage's sum.
23
+ *
24
+ * `cells` is size² characters as every number puzzle writes them (all empty, usually; a cage of one
25
+ * cell is its own given). `cages` is size² characters, one per cell, naming its cage from
26
+ * `CAGE_LETTERS`. `sums` is two base-36 characters a cage, in cage order. The cages ride in the
27
+ * givens because they are the puzzle.
28
+ */
29
+ export function encodeKiller(cells, cages) {
30
+ const cageOf = new Array(cells.length).fill(0);
31
+ cages.forEach((cage, c) => cage.cells.forEach((index) => (cageOf[index] = c)));
32
+ if (cages.length > CAGE_LETTERS.length)
33
+ throw new Error(`${cages.length} cages is more than a code can name.`);
34
+ const letters = cageOf.map((c) => CAGE_LETTERS[c]).join("");
35
+ const sums = cages.map((cage) => cage.sum.toString(36).padStart(2, "0")).join("");
36
+ return encodeCells(cells) + letters + sums;
37
+ }
38
+ /**
39
+ * The cells and cages a code says, or null for one that is not a whole, well-formed puzzle: every
40
+ * cell named to a cage, every cage used, and a sum for each.
41
+ */
42
+ export function decodeKiller(code, size) {
43
+ const area = size * size;
44
+ if (typeof code !== "string" || code.length < 2 * area)
45
+ return null;
46
+ const cells = decodeCells(code.slice(0, area), size);
47
+ if (cells === null)
48
+ return null;
49
+ const cageOf = [];
50
+ for (const letter of code.slice(area, 2 * area)) {
51
+ const c = CAGE_LETTERS.indexOf(letter);
52
+ if (c === -1)
53
+ return null;
54
+ cageOf.push(c);
55
+ }
56
+ const count = Math.max(...cageOf) + 1;
57
+ const sums = code.slice(2 * area);
58
+ if (sums.length !== 2 * count)
59
+ return null;
60
+ const cages = [];
61
+ for (let c = 0; c < count; c += 1) {
62
+ const sum = Number.parseInt(sums.slice(2 * c, 2 * c + 2), 36);
63
+ const members = cageOf.flatMap((value, index) => (value === c ? [index] : []));
64
+ if (!Number.isInteger(sum) || sum <= 0 || members.length === 0)
65
+ return null;
66
+ cages.push({ cells: members, sum });
67
+ }
68
+ return { cells, cages };
69
+ }
70
+ /**
71
+ * THE DASHED OUTLINE OF EVERY CAGE, as line segments in cell units: each cage drawn a little inside
72
+ * its own edge, the way a printed Killer Sudoku draws it, so a cage reads as a shape within the box
73
+ * rules rather than as one of them.
74
+ *
75
+ * Each cell draws the sides where its neighbour is in another cage, `inset` inside the cell. Where
76
+ * the cage goes on past a side's end, the line runs on to meet the next line of the outline: to the
77
+ * cell's edge when the cage's edge carries on straight, and `inset` past it at an inside corner,
78
+ * where the outline turns back into the cage. That is what makes the lines of one cage meet, with
79
+ * no gap and no overshoot, whatever its shape.
80
+ */
81
+ export function cageOutline(size, cageOf, inset = 0.12) {
82
+ const at = (row, col) => (row < 0 || col < 0 || row >= size || col >= size ? undefined : cageOf(row * size + col));
83
+ const out = [];
84
+ for (let row = 0; row < size; row += 1) {
85
+ for (let col = 0; col < size; col += 1) {
86
+ const mine = at(row, col);
87
+ if (mine === undefined)
88
+ continue;
89
+ const same = (r, c) => at(r, c) === mine;
90
+ const edge = { top: !same(row - 1, col), bottom: !same(row + 1, col), left: !same(row, col - 1), right: !same(row, col + 1) };
91
+ /*
92
+ * How far a side's line runs past the cell toward one end: stops `inset` short when the cage
93
+ * ends there, runs to the edge when the neighbour carries the same side on, and `inset` past
94
+ * it at an inside corner.
95
+ */
96
+ const reach = (ends, neighbourHasSide) => (ends ? -inset : neighbourHasSide ? 0 : inset);
97
+ if (edge.top) {
98
+ const y = row + inset;
99
+ out.push({ x1: col - reach(edge.left, !same(row - 1, col - 1) && same(row, col - 1)), y1: y, x2: col + 1 + reach(edge.right, !same(row - 1, col + 1) && same(row, col + 1)), y2: y });
100
+ }
101
+ if (edge.bottom) {
102
+ const y = row + 1 - inset;
103
+ out.push({ x1: col - reach(edge.left, !same(row + 1, col - 1) && same(row, col - 1)), y1: y, x2: col + 1 + reach(edge.right, !same(row + 1, col + 1) && same(row, col + 1)), y2: y });
104
+ }
105
+ if (edge.left) {
106
+ const x = col + inset;
107
+ out.push({ x1: x, y1: row - reach(edge.top, !same(row - 1, col - 1) && same(row - 1, col)), x2: x, y2: row + 1 + reach(edge.bottom, !same(row + 1, col - 1) && same(row + 1, col)) });
108
+ }
109
+ if (edge.right) {
110
+ const x = col + 1 - inset;
111
+ out.push({ x1: x, y1: row - reach(edge.top, !same(row - 1, col + 1) && same(row - 1, col)), x2: x, y2: row + 1 + reach(edge.bottom, !same(row + 1, col + 1) && same(row + 1, col)) });
112
+ }
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ /** The biggest cage, and how many cages to stop at, by level and side. */
118
+ const SHAPE = {
119
+ easy: { biggest: 3, cages: { 6: 17, 9: 38 } },
120
+ medium: { biggest: 4, cages: { 6: 14, 9: 31 } },
121
+ hard: { biggest: 5, cages: { 6: 12, 9: 27 } },
122
+ };
123
+ /** Steps the answer check may take for one join before the join is passed over. */
124
+ const CHECK_BUDGET = 4000;
125
+ /** Joins tried, per cell, before the cages are taken as they stand. */
126
+ const JOINS_PER_CELL = 12;
127
+ /**
128
+ * Joins refused in a row, per side, after which the cages are taken as they stand. Near the level's
129
+ * count most joins would leave two answers, and each refusal is a full search: a run of them is the
130
+ * grid saying it is done.
131
+ */
132
+ const REFUSED_IN_A_ROW_PER_SIDE = 3;
133
+ /** A Killer Sudoku (Sum Cages) of this side, level and seed: 6 or 9. */
134
+ export function generateSumCages(size, level, seed) {
135
+ const random = seededRandom(seed);
136
+ for (;;) {
137
+ const solution = fillLayout(boxedLayout(size), random);
138
+ if (solution === null)
139
+ continue;
140
+ const cages = joinCages(size, solution, level, random);
141
+ // A cage of one cell is printed as its number too, as it would be in a newspaper's.
142
+ const single = new Set(cages.flatMap((cage) => (cage.cells.length === 1 ? cage.cells : [])));
143
+ const cells = solution.map((value, index) => (single.has(index) ? value : 0));
144
+ return { kind: "sum-cages", size, level, seed, givens: encodeKiller(cells, cages), solution: encodeCells(solution) };
145
+ }
146
+ }
147
+ function joinCages(size, solution, level, random) {
148
+ const { biggest, cages: target } = SHAPE[level];
149
+ const want = target[size] ?? Math.round(size * size * 0.4);
150
+ let cages = solution.map((value, index) => ({ cells: [index], sum: value }));
151
+ const blank = new Array(size * size).fill(0);
152
+ let refused = 0;
153
+ for (let tried = 0; tried < JOINS_PER_CELL * size * size && cages.length > want && refused < REFUSED_IN_A_ROW_PER_SIDE * size; tried += 1) {
154
+ const cageOf = new Array(size * size);
155
+ cages.forEach((cage, c) => cage.cells.forEach((index) => (cageOf[index] = c)));
156
+ /*
157
+ * The smallest cages first, most of the time: a cage of one cell is a printed number, and a Sum
158
+ * Cages puzzle is the one with next to none of those. The rest of the time any cage, so the
159
+ * shapes are not all grown from the same few.
160
+ */
161
+ const smallest = Math.min(...cages.map((cage) => cage.cells.length));
162
+ const pool = random() < 0.8 ? cages.flatMap((cage, c) => (cage.cells.length === smallest ? [c] : [])) : cages.map((_, c) => c);
163
+ const a = pool[Math.floor(random() * pool.length)];
164
+ const touching = [...new Set(cages[a].cells.flatMap((index) => neighbours(size, index)).map((index) => cageOf[index]))].filter((c) => c !== a);
165
+ if (touching.length === 0)
166
+ continue;
167
+ const b = touching[Math.floor(random() * touching.length)];
168
+ const cells = [...cages[a].cells, ...cages[b].cells];
169
+ if (cells.length > biggest)
170
+ continue;
171
+ const values = cells.map((index) => solution[index]);
172
+ if (new Set(values).size !== values.length)
173
+ continue;
174
+ const joined = { cells: cells.sort((x, y) => x - y), sum: values.reduce((total, value) => total + value, 0) };
175
+ const next = cages.filter((_, c) => c !== a && c !== b).concat(joined);
176
+ // A cage of one cell is a printed number: the check sees it as a given, as the solver would.
177
+ const givens = blank.map((_, index) => (next[cageIndex(next, index)].cells.length === 1 ? solution[index] : 0));
178
+ if (countSolutionsWithin(givens, cagedLayout(size, next), 2, CHECK_BUDGET) === 1) {
179
+ cages = next;
180
+ refused = 0;
181
+ }
182
+ else
183
+ refused += 1;
184
+ }
185
+ // In reading order of each cage's first cell, so the code and the sums read top to bottom.
186
+ return cages.sort((x, y) => x.cells[0] - y.cells[0]);
187
+ }
188
+ function cageIndex(cages, index) {
189
+ return cages.findIndex((cage) => cage.cells.includes(index));
190
+ }
@@ -0,0 +1,3 @@
1
+ import type { KazuLevel, KazuPuzzle } from "./kinds.ts";
2
+ /** A Skyscrapers (Towers) puzzle of this side, level and seed: 4, 5, 6 or 7. */
3
+ export declare function generateTowers(size: number, level: KazuLevel, seed: number): KazuPuzzle;
package/dist/towers.js ADDED
@@ -0,0 +1,48 @@
1
+ import { encodeCells } from "./cells.js";
2
+ import { latinSquare } from "./moreOrLess.js";
3
+ import { seededRandom, shuffled } from "./random.js";
4
+ import { cluesOf, encodeTowers, TOWER_SIDES } from "./towersCode.js";
5
+ import { countSolutions, guessDepth } from "./towersSolve.js";
6
+ /**
7
+ * Making a Skyscrapers (Towers) puzzle from a seed.
8
+ *
9
+ * A seeded Latin square first, and every clue it makes true around its edge. Givens are added only
10
+ * if the clues alone are not yet a puzzle of the level asked for, which with all of them showing is
11
+ * rare. Then, in a seeded order, every given and after them every clue is tried for removal, kept
12
+ * out only while the puzzle stays unique and within its level. Givens go first so that what is left
13
+ * leans on the clues, which are the puzzle; what remains is all needed.
14
+ *
15
+ * Deterministic in the seed.
16
+ */
17
+ const LEVELS = { easy: 0, medium: 1, hard: Infinity };
18
+ /** A Skyscrapers (Towers) puzzle of this side, level and seed: 4, 5, 6 or 7. */
19
+ export function generateTowers(size, level, seed) {
20
+ const random = seededRandom(seed);
21
+ const solution = latinSquare(size, random);
22
+ const allowed = LEVELS[level];
23
+ // Unique, and within the level: a hard puzzle may be as deep as it likes, so its depth is never measured.
24
+ const fits = (grid, clues) => countSolutions(grid, size, clues, 2) === 1 && (allowed === Infinity || guessDepth(grid, size, clues, allowed) <= allowed);
25
+ const clues = cluesOf(solution, size);
26
+ const givens = new Array(size * size).fill(0);
27
+ for (const index of shuffled(givens.map((_, i) => i), random)) {
28
+ if (fits(givens, clues))
29
+ break;
30
+ givens[index] = solution[index];
31
+ }
32
+ for (const index of shuffled(givens.map((_, i) => i), random)) {
33
+ if (givens[index] === 0)
34
+ continue;
35
+ const value = givens[index];
36
+ givens[index] = 0;
37
+ if (!fits(givens, clues))
38
+ givens[index] = value;
39
+ }
40
+ const places = TOWER_SIDES.flatMap((side) => Array.from({ length: size }, (_, at) => ({ side, at })));
41
+ for (const { side, at } of shuffled(places, random)) {
42
+ const clue = clues[side][at];
43
+ clues[side][at] = 0;
44
+ if (!fits(givens, clues))
45
+ clues[side][at] = clue;
46
+ }
47
+ return { kind: "towers", size, level, seed, givens: encodeTowers(givens, clues), solution: encodeCells(solution) };
48
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Towers (Skyscrapers) as a string: the cells, then the clues around the edge.
3
+ *
4
+ * The cells come first, row-major, as every number grid is written (`.` for
5
+ * empty; most puzzles print none). Then one character for every place a clue
6
+ * can stand outside the square, four sides of `size`: along the top left to
7
+ * right, along the bottom left to right, down the left top to bottom, down the
8
+ * right top to bottom. A digit is how many towers can be seen from there; `.`
9
+ * is no clue. A 7×7 is 49 + 28 characters.
10
+ */
11
+ export declare const TOWER_SIDES: readonly ["top", "bottom", "left", "right"];
12
+ export type TowerSide = (typeof TOWER_SIDES)[number];
13
+ /** Each side's clues, in reading order along it; 0 where there is none. */
14
+ export type TowerClues = Record<TowerSide, number[]>;
15
+ export declare function noClues(size: number): TowerClues;
16
+ /**
17
+ * The cells a clue looks along, nearest first: the clue at `at` on the top
18
+ * looks down column `at`, on the bottom up it, on the left along row `at`
19
+ * to the right, on the right along it to the left.
20
+ */
21
+ export declare function lineFrom(side: TowerSide, at: number, size: number): number[];
22
+ /** How many towers show looking along `heights` from its first end: each one taller than every one before it. */
23
+ export declare function towersSeen(heights: readonly number[]): number;
24
+ /** Every clue a finished square makes true: what each side of it sees. */
25
+ export declare function cluesOf(solution: readonly number[], size: number): TowerClues;
26
+ export declare function encodeTowers(cells: readonly number[], clues: TowerClues): string;
27
+ /** The cells and clues a code says, or null for a string that is not a Towers puzzle of this size. */
28
+ export declare function decodeTowers(code: string, size: number): {
29
+ cells: number[];
30
+ clues: TowerClues;
31
+ } | null;