@johnmorrisdotca/kumimoji 1.0.1

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 (78) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/CONTRIBUTING.md +38 -0
  3. package/LICENSE +21 -0
  4. package/NOTICE.md +38 -0
  5. package/README.md +233 -0
  6. package/dist/check.d.ts +19 -0
  7. package/dist/check.js +70 -0
  8. package/dist/computer.types.d.ts +23 -0
  9. package/dist/computer.types.js +1 -0
  10. package/dist/computerPlay.d.ts +17 -0
  11. package/dist/computerPlay.js +203 -0
  12. package/dist/computerTurn.d.ts +31 -0
  13. package/dist/computerTurn.js +94 -0
  14. package/dist/generate.d.ts +29 -0
  15. package/dist/generate.js +278 -0
  16. package/dist/grid.d.ts +97 -0
  17. package/dist/grid.js +208 -0
  18. package/dist/help.d.ts +20 -0
  19. package/dist/help.js +57 -0
  20. package/dist/index.d.ts +44 -0
  21. package/dist/index.js +44 -0
  22. package/dist/judge.d.ts +13 -0
  23. package/dist/judge.js +19 -0
  24. package/dist/kana.d.ts +25 -0
  25. package/dist/kana.js +38 -0
  26. package/dist/kumimoji.types.d.ts +49 -0
  27. package/dist/kumimoji.types.js +1 -0
  28. package/dist/party.d.ts +91 -0
  29. package/dist/party.js +172 -0
  30. package/dist/party.types.d.ts +86 -0
  31. package/dist/party.types.js +1 -0
  32. package/dist/partyKept.d.ts +17 -0
  33. package/dist/partyKept.js +149 -0
  34. package/dist/partySeats.d.ts +53 -0
  35. package/dist/partySeats.js +119 -0
  36. package/dist/partyTurns.d.ts +75 -0
  37. package/dist/partyTurns.js +153 -0
  38. package/dist/placement.d.ts +26 -0
  39. package/dist/placement.js +87 -0
  40. package/dist/play.d.ts +76 -0
  41. package/dist/play.js +165 -0
  42. package/dist/random.d.ts +11 -0
  43. package/dist/random.js +25 -0
  44. package/dist/react.d.ts +32 -0
  45. package/dist/react.js +53 -0
  46. package/dist/showcase.d.ts +51 -0
  47. package/dist/showcase.js +59 -0
  48. package/dist/tableView.d.ts +92 -0
  49. package/dist/tableView.js +127 -0
  50. package/dist/tileFace.d.ts +25 -0
  51. package/dist/tileFace.js +39 -0
  52. package/dist/tileFamily.d.ts +11 -0
  53. package/dist/tileFamily.js +23 -0
  54. package/dist/tileWords.d.ts +70 -0
  55. package/dist/tileWords.js +164 -0
  56. package/dist/tileWordsModule.d.ts +4 -0
  57. package/dist/tileWordsModule.js +13 -0
  58. package/dist/tiles.constants.d.ts +108 -0
  59. package/dist/tiles.constants.js +132 -0
  60. package/dist/turn.d.ts +62 -0
  61. package/dist/turn.js +96 -0
  62. package/dist/ui/board.d.ts +29 -0
  63. package/dist/ui/board.js +37 -0
  64. package/dist/ui/mount.d.ts +36 -0
  65. package/dist/ui/mount.js +257 -0
  66. package/dist/ui/style.d.ts +6 -0
  67. package/dist/ui/style.js +40 -0
  68. package/dist/ui.d.ts +8 -0
  69. package/dist/ui.js +8 -0
  70. package/dist/wilds.d.ts +27 -0
  71. package/dist/wilds.js +111 -0
  72. package/dist/words.d.ts +6 -0
  73. package/dist/words.en.data.d.ts +23 -0
  74. package/dist/words.en.data.js +610 -0
  75. package/dist/words.ja.data.d.ts +29 -0
  76. package/dist/words.ja.data.js +29 -0
  77. package/dist/words.js +6 -0
  78. package/package.json +90 -0
@@ -0,0 +1,203 @@
1
+ import { boundsOf, placeOf, squareAt } from "./grid.js";
2
+ import { judgeWithWords } from "./judge.js";
3
+ import { crossingFit } from "./placement.js";
4
+ import { assignHandTile, placeFromHand } from "./play.js";
5
+ import { KUMIMOJI_GRID_MOST } from "./tiles.constants.js";
6
+ /**
7
+ * WHAT A COMPUTER PLAYER LAYS: one word at a time, from its own hand, onto its
8
+ * own table. Pure, and with no randomness at all — the same hand on the same
9
+ * table lays the same word — so a computer's turn replays exactly after a
10
+ * reload (`computerTurn.ts`).
11
+ *
12
+ * On an empty table it lays the longest word its hand spells, across. After
13
+ * that, each word crosses a tile already down, laid by the generator's own
14
+ * rule (`crossingFit`): nothing at either end and nothing at a new tile's
15
+ * sides, so a sound grid stays sound, and every laying is judged once more
16
+ * (`judgeGrid`) before it is kept. At a table set up with Diagonals it lays
17
+ * only where every diagonal run of three or more stays a word, and judges
18
+ * by that rule too, so it never leaves an unsound diagonal either. It never moves a tile it has laid, which is
19
+ * what a good player does and it does not: that, and not looking for words
20
+ * that run beside others, is what keeps it beatable.
21
+ *
22
+ * WILDS are played, never left: a word short of a letter or two the hand
23
+ * lacks uses a wild for each, given that letter as it is laid — but a word
24
+ * that needs none is always preferred to one as long that does.
25
+ */
26
+ /** A tile's letter as the word list spells it (`a`, or a kana's base code), whatever wild it is on; "" for a wild with none. */
27
+ function plainOf(tile, words) {
28
+ const sound = words.soundOf(tile);
29
+ return sound === null ? "" : (words.codeOf(sound) ?? "");
30
+ }
31
+ /** Whether a grid is sound, read against the loaded word list by the game's rules: the same judgement the desk shows. */
32
+ export function judgeTiles(tiles, words, rules = {}) {
33
+ return judgeWithWords(tiles, words, rules);
34
+ }
35
+ function countHand(hand, words) {
36
+ const letters = new Map();
37
+ let wilds = 0;
38
+ for (const tile of hand) {
39
+ if (words.isWild(tile))
40
+ wilds += 1;
41
+ else
42
+ letters.set(plainOf(tile, words), (letters.get(plainOf(tile, words)) ?? 0) + 1);
43
+ }
44
+ return { letters, wilds };
45
+ }
46
+ /** How many letters of `word` the hand lacks, stopping once past `most`. */
47
+ function lacking(word, hand, most) {
48
+ const used = new Map();
49
+ let short = 0;
50
+ for (const letter of word) {
51
+ const count = (used.get(letter) ?? 0) + 1;
52
+ used.set(letter, count);
53
+ if (count > (hand.letters.get(letter) ?? 0)) {
54
+ short += 1;
55
+ if (short > most)
56
+ return short;
57
+ }
58
+ }
59
+ return short;
60
+ }
61
+ /** How many tiles already down one word may run through: two lets it bridge a gap, as players do. */
62
+ const CROSSED_MOST = 2;
63
+ /** How many placements are tried in one search at most, so a turn's thinking stays a fraction of a second on a phone. */
64
+ const FITS_MOST = 20000;
65
+ /** A laying's worth: tiles used first, rare letters next, wilds spent last. */
66
+ function worth(fresh, wilds, words) {
67
+ let rare = 0;
68
+ for (const { letter } of fresh)
69
+ rare += 1 / Math.max(1, words.mix.get(letter) ?? 1);
70
+ return fresh.length * 10 + rare * 2 - wilds * 4;
71
+ }
72
+ /** The letters new tiles need, covered by the hand: how many wilds that takes, or null where even the wilds cannot cover it. */
73
+ function wildsNeeded(fresh, hand) {
74
+ const used = new Map();
75
+ let wilds = 0;
76
+ for (const { letter } of fresh) {
77
+ const count = (used.get(letter) ?? 0) + 1;
78
+ used.set(letter, count);
79
+ if (count > (hand.letters.get(letter) ?? 0))
80
+ wilds += 1;
81
+ }
82
+ return wilds > hand.wilds ? null : wilds;
83
+ }
84
+ /** Every laying of the hand on this table, best first; an empty table takes one word across from its first square. */
85
+ function layings(play, words, rules) {
86
+ const hand = countHand(play.hand, words);
87
+ const empty = play.tiles.size === 0;
88
+ const letterAt = (row, col) => {
89
+ const tile = play.tiles.get(squareAt(row, col));
90
+ return tile === undefined ? "" : plainOf(tile, words) || "?";
91
+ };
92
+ // With Diagonals, a laying must leave every diagonal run of three or more a word (`crossingFit`).
93
+ const diagonalWord = rules.diagonals === true
94
+ ? (run) => {
95
+ const read = words.wordOf(run);
96
+ return read !== null && words.allowed.has(read);
97
+ }
98
+ : null;
99
+ const anchors = new Map();
100
+ for (const square of [...play.tiles.keys()].sort()) {
101
+ const letter = letterAt(placeOf(square).row, placeOf(square).col);
102
+ anchors.set(letter, [...(anchors.get(letter) ?? []), placeOf(square)]);
103
+ }
104
+ const bounds = boundsOf(play.tiles);
105
+ const fits = (first, last) => bounds === null ||
106
+ (Math.max(bounds.bottom, last.row) - Math.min(bounds.top, first.row) < KUMIMOJI_GRID_MOST && Math.max(bounds.right, last.col) - Math.min(bounds.left, first.col) < KUMIMOJI_GRID_MOST);
107
+ const found = [];
108
+ let tried = 0;
109
+ let mostFresh = 0;
110
+ const keep = (laying) => {
111
+ found.push(laying);
112
+ mostFresh = Math.max(mostFresh, laying.fresh.length);
113
+ };
114
+ const crossed = empty ? 0 : CROSSED_MOST;
115
+ const longest = Math.min(play.hand.length + crossed, Math.max(...words.byLength.keys()));
116
+ for (let length = longest; length >= 2 && tried < FITS_MOST; length -= 1) {
117
+ // Nothing shorter can use more tiles than the best already found: a word crossing one tile lays one fewer than its length.
118
+ if ((empty ? length : length - 1) < mostFresh)
119
+ break;
120
+ for (const word of words.byLength.get(length) ?? []) {
121
+ if (lacking(word, hand, hand.wilds + crossed) > hand.wilds + crossed)
122
+ continue;
123
+ if (empty) {
124
+ const fresh = [...word].map((letter, col) => ({ square: squareAt(0, col), letter }));
125
+ const wilds = wildsNeeded(fresh, hand);
126
+ if (wilds !== null)
127
+ keep({ word, fresh, wilds, score: worth(fresh, wilds, words) });
128
+ continue;
129
+ }
130
+ for (let at = 0; at < word.length && tried < FITS_MOST; at += 1) {
131
+ for (const anchor of anchors.get(word[at]) ?? []) {
132
+ for (const across of [true, false]) {
133
+ tried += 1;
134
+ const fit = crossingFit(letterAt, () => true, word, anchor, at, across, diagonalWord);
135
+ if (fit === null)
136
+ continue;
137
+ const step = across ? { row: 0, col: 1 } : { row: 1, col: 0 };
138
+ if (!fits(fit.first, { row: fit.first.row + (length - 1) * step.row, col: fit.first.col + (length - 1) * step.col }))
139
+ continue;
140
+ const fresh = fit.fresh.map((square) => ({ square: squareAt(square.row, square.col), letter: word[(square.row - fit.first.row) + (square.col - fit.first.col)] }));
141
+ const wilds = wildsNeeded(fresh, hand);
142
+ if (wilds !== null)
143
+ keep({ word, fresh, wilds, score: worth(fresh, wilds, words) });
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ // A stable sort: equal worth keeps the list's own order, so the choice never depends on anything but the hand and the table.
150
+ return found.sort((a, b) => b.score - a.score);
151
+ }
152
+ /** A laying made with the solo game's own moves: a tile of that letter from the hand, or a wild given it. */
153
+ function lay(play, laying, words) {
154
+ let now = play;
155
+ for (const { square, letter } of laying.fresh) {
156
+ let at = now.hand.findIndex((tile) => !words.isWild(tile) && plainOf(tile, words) === letter);
157
+ if (at === -1) {
158
+ at = now.hand.findIndex((tile) => words.isWild(tile));
159
+ const sound = words.soundOf(letter);
160
+ const wild = sound === null ? null : words.wildFor(sound);
161
+ if (at === -1 || wild === null)
162
+ return null;
163
+ now = assignHandTile(now, at, wild);
164
+ }
165
+ now = placeFromHand(now, at, square);
166
+ }
167
+ return now;
168
+ }
169
+ /** How many of the best layings are judged before giving up on a hand: each is sound by construction, so the first nearly always is. */
170
+ const JUDGED_MOST = 12;
171
+ /**
172
+ * THE COMPUTER'S NEXT WORD: its play after laying the best word its hand can
173
+ * lay on its table, and the word, or null when there is none. The grid after
174
+ * it is judged against the list, by the game's rules (`rules`), and is
175
+ * always sound.
176
+ */
177
+ export function bestLaying(play, words, rules = {}) {
178
+ if (play.hand.length === 0)
179
+ return null;
180
+ if (play.tiles.size > 0 && !judgeTiles(play.tiles, words, rules).sound)
181
+ return null;
182
+ for (const laying of layings(play, words, rules).slice(0, JUDGED_MOST)) {
183
+ const next = lay(play, laying, words);
184
+ if (next !== null && judgeTiles(next.tiles, words, rules).sound)
185
+ return { play: next, word: laying.word };
186
+ }
187
+ return null;
188
+ }
189
+ /** The tile a computer trades: its rarest letter in the set, the first of those in its hand; never a wild. Null for a hand of wilds. */
190
+ export function tradeChoice(hand, words) {
191
+ let chosen = null;
192
+ let fewest = Infinity;
193
+ hand.forEach((tile, at) => {
194
+ if (words.isWild(tile))
195
+ return;
196
+ const count = words.mix.get(plainOf(tile, words)) ?? 0;
197
+ if (count < fewest) {
198
+ fewest = count;
199
+ chosen = at;
200
+ }
201
+ });
202
+ return chosen;
203
+ }
@@ -0,0 +1,31 @@
1
+ import type { ComputerStep } from "./computer.types.ts";
2
+ import type { PartyGame } from "./party.types.ts";
3
+ import type { TileWords } from "./tileWords.ts";
4
+ /**
5
+ * A COMPUTER'S TURN in a pass-and-play game (John, 2026-09-28: "Also you can
6
+ * add computer bots"), planned whole before any of it is shown: a list of
7
+ * steps, each the game after one thing it did, the last one the game it
8
+ * passes on. The page shows them one at a time with a short pause and keeps
9
+ * only the last (`KumimojiPartyComputer`), so a reload in the middle plays
10
+ * the same turn again from its start — the plan is pure and has no
11
+ * randomness, so it is the same turn — and a reload after it finds it done.
12
+ *
13
+ * Its play, by the same rules as anybody's and through the same moves:
14
+ *
15
+ * - Lay the best word it can (`bestLaying`): its own tiles only, crossing
16
+ * what is down, the grid sound after every word.
17
+ * - Draw whenever its hand is used and its grid sound, so everybody takes a
18
+ * tile, and play on.
19
+ * - Trade its rarest tile when its hand lays nothing, once a turn at most,
20
+ * as anybody may (`mayTradeThisTurn`).
21
+ * - Press Done — going out, when its hand is used and the bag cannot give
22
+ * everybody a tile — once it is done or has taken `COMPUTER_STEPS_MOST`
23
+ * steps, which keeps a turn to a few seconds to watch.
24
+ * - Resign only where the rules offer it (`mayResign`) and it can do nothing
25
+ * at all: nothing to lay, nothing to trade for, and it is not its last turn.
26
+ */
27
+ /** The most things a computer does in one turn before pressing Done: at the page's pause, a turn of about three seconds. */
28
+ export declare const COMPUTER_STEPS_MOST = 8;
29
+ export declare function planComputerTurn(game: PartyGame, words: TileWords): ComputerStep[];
30
+ /** The game a computer's turn passes on: its plan's last step, or the game as it was where there is nothing to play. */
31
+ export declare function afterComputerTurn(game: PartyGame, words: TileWords): PartyGame;
@@ -0,0 +1,94 @@
1
+ import { bestLaying, judgeTiles, tradeChoice } from "./computerPlay.js";
2
+ import { drawAll, isComputer, mayDrawAll, mayTradeThisTurn, seatPlay, withSeatPlay } from "./party.js";
3
+ import { doneRefused, endTurn, goesOut, handCanSpell, isLastTurn, laidThisTurn, mayResign, resign } from "./partyTurns.js";
4
+ import { liftAll, mayTrade, trade } from "./play.js";
5
+ /**
6
+ * A COMPUTER'S TURN in a pass-and-play game (John, 2026-09-28: "Also you can
7
+ * add computer bots"), planned whole before any of it is shown: a list of
8
+ * steps, each the game after one thing it did, the last one the game it
9
+ * passes on. The page shows them one at a time with a short pause and keeps
10
+ * only the last (`KumimojiPartyComputer`), so a reload in the middle plays
11
+ * the same turn again from its start — the plan is pure and has no
12
+ * randomness, so it is the same turn — and a reload after it finds it done.
13
+ *
14
+ * Its play, by the same rules as anybody's and through the same moves:
15
+ *
16
+ * - Lay the best word it can (`bestLaying`): its own tiles only, crossing
17
+ * what is down, the grid sound after every word.
18
+ * - Draw whenever its hand is used and its grid sound, so everybody takes a
19
+ * tile, and play on.
20
+ * - Trade its rarest tile when its hand lays nothing, once a turn at most,
21
+ * as anybody may (`mayTradeThisTurn`).
22
+ * - Press Done — going out, when its hand is used and the bag cannot give
23
+ * everybody a tile — once it is done or has taken `COMPUTER_STEPS_MOST`
24
+ * steps, which keeps a turn to a few seconds to watch.
25
+ * - Resign only where the rules offer it (`mayResign`) and it can do nothing
26
+ * at all: nothing to lay, nothing to trade for, and it is not its last turn.
27
+ */
28
+ /** The most things a computer does in one turn before pressing Done: at the page's pause, a turn of about three seconds. */
29
+ export const COMPUTER_STEPS_MOST = 8;
30
+ /** How many settling steps may be needed at the end to be allowed Done at all; one trade a turn and a hand of tiles, so it is bounded anyway. */
31
+ const SETTLING_MOST = 200;
32
+ export function planComputerTurn(game, words) {
33
+ const steps = [];
34
+ if (game.ending !== null || !isComputer(game, game.turn))
35
+ return steps;
36
+ let now = game;
37
+ const take = (next, said) => {
38
+ now = next;
39
+ steps.push({ game: next, said });
40
+ };
41
+ // The game's own rules, Diagonals included, for every judgement and every word it lays.
42
+ const rules = { diagonals: game.settings.diagonals };
43
+ const verdict = () => judgeTiles(now.players[now.turn].tiles, words, rules);
44
+ const handSpells = (hand) => handCanSpell(hand, words);
45
+ // Only its own moves ever reach its table, so this is a guard: a table that is not sound is taken up and built again.
46
+ if (now.players[now.turn].tiles.size > 0 && !verdict().sound)
47
+ take(withSeatPlay(now, liftAll(seatPlay(now))), { kind: "rebuilt" });
48
+ while (steps.length < COMPUTER_STEPS_MOST) {
49
+ const play = seatPlay(now);
50
+ if (play.hand.length === 0) {
51
+ if (!mayDrawAll(now, verdict()))
52
+ break;
53
+ take(drawAll(now), { kind: "drew" });
54
+ continue;
55
+ }
56
+ const laid = bestLaying(play, words, rules);
57
+ if (laid !== null) {
58
+ take(withSeatPlay(now, laid.play), { kind: "laid", word: laid.word });
59
+ continue;
60
+ }
61
+ const worst = tradeChoice(play.hand, words);
62
+ if (!mayTradeThisTurn(now) || worst === null)
63
+ break;
64
+ take(withSeatPlay(now, trade(play, worst)), { kind: "traded", tile: play.hand[worst] });
65
+ }
66
+ // Done may be held back: a hand that must be traded first, or the last one standing with nothing laid. Lay or trade until it is not.
67
+ for (let settle = 0; settle < SETTLING_MOST && doneRefused(now, verdict(), handSpells) !== null; settle += 1) {
68
+ const play = seatPlay(now);
69
+ const laid = bestLaying(play, words, rules);
70
+ const worst = tradeChoice(play.hand, words) ?? 0;
71
+ if (laid !== null)
72
+ take(withSeatPlay(now, laid.play), { kind: "laid", word: laid.word });
73
+ else if (mayTradeThisTurn(now))
74
+ take(withSeatPlay(now, trade(play, worst)), { kind: "traded", tile: play.hand[worst] });
75
+ else
76
+ break;
77
+ }
78
+ const hand = now.players[now.turn].hand;
79
+ const stuck = hand.length > 0 && !laidThisTurn(now) && !mayTrade(seatPlay(now)) && bestLaying(seatPlay(now), words, rules) === null;
80
+ const refused = doneRefused(now, verdict(), handSpells) !== null;
81
+ if ((refused || (stuck && !isLastTurn(now))) && mayResign(now)) {
82
+ take(resign(now), { kind: "resigned" });
83
+ return steps;
84
+ }
85
+ // Done held back with no resigning either cannot happen (a refusal the bag cannot trade away is one resigning answers); the page stops rather than play the turn again.
86
+ if (refused)
87
+ return steps;
88
+ take(endTurn(now, verdict(), handSpells), { kind: "done", out: goesOut(now, verdict()) });
89
+ return steps;
90
+ }
91
+ /** The game a computer's turn passes on: its plan's last step, or the game as it was where there is nothing to play. */
92
+ export function afterComputerTurn(game, words) {
93
+ return planComputerTurn(game, words).at(-1)?.game ?? game;
94
+ }
@@ -0,0 +1,29 @@
1
+ import type { KumimojiDeal, KumimojiLevel, KumimojiOptions } from "./kumimoji.types.ts";
2
+ /**
3
+ * MAKING A KUMIMOJI, in the browser, from a seed: the bag the game is played
4
+ * from, in the order its tiles come out.
5
+ *
6
+ * A bag drawn blind from the mix can be one nobody can finish — two Q's and
7
+ * no U, and every tile must be laid before the game ends. So the bag is made
8
+ * the other way round: a crossword is laid first, word by word, from letters
9
+ * drawn from the mix (`TILE_MIX`), and the bag is that crossword's tiles,
10
+ * shuffled. Every bag has at least one finished grid, which is the puzzle's
11
+ * `solution` — kept only to prove that, never shown — and a player may build
12
+ * any other.
13
+ *
14
+ * The crossword grows from a word across the middle: each next word crosses
15
+ * a tile already down, its new tiles touching nothing at their sides, so every
16
+ * run on it is a word by construction. Words are chosen to use the letters
17
+ * drawn from the mix, and never a letter more times than the whole set holds,
18
+ * so the bag reads like a handful from the full set.
19
+ *
20
+ * With Diagonals, the crossword must read as a word along its diagonals too:
21
+ * a new tile that would make a diagonal run of three or more is laid only
22
+ * where that run is a word (`fit`), so the proof holds under the rule the
23
+ * game is played by. Without, nothing about the laying changes, and a seed
24
+ * deals the bag it always dealt.
25
+ *
26
+ * Deterministic in the seed, like every generator here: two browsers in a
27
+ * race, or one tomorrow, deal the same bag in the same order.
28
+ */
29
+ export declare function generateKumimoji(size: number, level: KumimojiLevel, seed: number, options?: KumimojiOptions): KumimojiDeal;
@@ -0,0 +1,278 @@
1
+ import { seededRandom, shuffled } from "./random.js";
2
+ import { encodeGrid, squareAt } from "./grid.js";
3
+ import { crossingFit } from "./placement.js";
4
+ import { kumimojiTileCount, kumimojiWildCount, TILE_MIX_TOTAL } from "./tiles.constants.js";
5
+ import { tileWords } from "./tileWords.js";
6
+ /**
7
+ * MAKING A KUMIMOJI, in the browser, from a seed: the bag the game is played
8
+ * from, in the order its tiles come out.
9
+ *
10
+ * A bag drawn blind from the mix can be one nobody can finish — two Q's and
11
+ * no U, and every tile must be laid before the game ends. So the bag is made
12
+ * the other way round: a crossword is laid first, word by word, from letters
13
+ * drawn from the mix (`TILE_MIX`), and the bag is that crossword's tiles,
14
+ * shuffled. Every bag has at least one finished grid, which is the puzzle's
15
+ * `solution` — kept only to prove that, never shown — and a player may build
16
+ * any other.
17
+ *
18
+ * The crossword grows from a word across the middle: each next word crosses
19
+ * a tile already down, its new tiles touching nothing at their sides, so every
20
+ * run on it is a word by construction. Words are chosen to use the letters
21
+ * drawn from the mix, and never a letter more times than the whole set holds,
22
+ * so the bag reads like a handful from the full set.
23
+ *
24
+ * With Diagonals, the crossword must read as a word along its diagonals too:
25
+ * a new tile that would make a diagonal run of three or more is laid only
26
+ * where that run is a word (`fit`), so the proof holds under the rule the
27
+ * game is played by. Without, nothing about the laying changes, and a seed
28
+ * deals the bag it always dealt.
29
+ *
30
+ * Deterministic in the seed, like every generator here: two browsers in a
31
+ * race, or one tomorrow, deal the same bag in the same order.
32
+ */
33
+ export function generateKumimoji(size, level, seed, options = {}) {
34
+ const gameLength = options.gameLength ?? "short";
35
+ const language = options.language ?? "english";
36
+ const doubleSet = language === "english" && (options.doubleSet ?? false);
37
+ const diagonals = options.diagonals === true;
38
+ const multiplier = doubleSet ? 2 : 1;
39
+ const words = tileWords(language);
40
+ const setSize = [...words.mix.values()].reduce((sum, count) => sum + count, 0);
41
+ const tiles = kumimojiTileCount(size, gameLength, language === "english" ? TILE_MIX_TOTAL : setSize, doubleSet);
42
+ const wilds = kumimojiWildCount(size, level, tiles);
43
+ const random = seededRandom(seed);
44
+ const side = layingSideFor(tiles);
45
+ let bestProgress = 0;
46
+ for (let attempt = 0; attempt < 50; attempt += 1) {
47
+ const squares = layCrossword(tiles, random, words, side, multiplier, words.mix, (laid) => { bestProgress = Math.max(bestProgress, laid); }, diagonals);
48
+ if (squares === null)
49
+ continue;
50
+ const positions = shuffled(squares.flatMap((tile, at) => tile === "" ? [] : [at]), random);
51
+ const wildAt = new Set(positions.slice(0, wilds));
52
+ const bag = squares.flatMap((tile, at) => {
53
+ if (tile === "")
54
+ return [];
55
+ if (!wildAt.has(at))
56
+ return [tile];
57
+ const assigned = words.wildFor(words.soundOf(tile) ?? tile);
58
+ if (assigned === null)
59
+ throw new Error(`No wild tile can represent ${tile}.`);
60
+ squares[at] = assigned;
61
+ return ["*"];
62
+ });
63
+ return { kind: "kumimoji", size, level, seed, givens: shuffled(bag, random).join(""), solution: encodeGrid(tilesOf(squares, side)), gameLength, doubleSet, language, ...(diagonals ? { diagonals } : {}) };
64
+ }
65
+ throw new Error(`Could not lay a Kumimoji of ${tiles} tiles from seed ${seed}; furthest attempt laid ${bestProgress}.`);
66
+ }
67
+ /**
68
+ * The generator lays its crossword on a square of its own, this wide, from
69
+ * the middle out; only where the tiles stand beside each other is kept
70
+ * (`encodeGrid`), so the square is scaffolding and not a board.
71
+ */
72
+ const MIN_LAYING_SIDE = 21;
73
+ function layingSideFor(tiles) {
74
+ return Math.max(MIN_LAYING_SIDE, Math.ceil(Math.sqrt(tiles * 4)));
75
+ }
76
+ /** The laid square's tiles, by row and column. */
77
+ function tilesOf(squares, side) {
78
+ const tiles = new Map();
79
+ squares.forEach((letter, index) => {
80
+ if (letter !== "")
81
+ tiles.set(squareAt(Math.floor(index / side), index % side), letter);
82
+ });
83
+ return tiles;
84
+ }
85
+ /** The longest word the generator lays: long words leave no room to cross. */
86
+ const LONGEST_LAID = 7;
87
+ /** How many tiles it tries to cross from, and how many words of a length it reads for each. */
88
+ const ANCHORS_TRIED = 8;
89
+ const WORDS_READ = 60;
90
+ const FINISH_BRANCHES = 40;
91
+ const FINISH_NODES = 2000;
92
+ /**
93
+ * With Diagonals the last tiles are placed from every tile but a sample of
94
+ * words, and a try that cannot finish is given up sooner: a crossword read
95
+ * along its diagonals too has fewer ways to finish, and reading every word
96
+ * from every tile of a full game at each step took minutes where starting
97
+ * again takes a moment. Measured 2026-09-28, a try that finishes does so in
98
+ * under ten steps; fifteen keeps a full game to a second or two.
99
+ */
100
+ const DIAGONAL_FINISH_NODES = 15;
101
+ /**
102
+ * A GAME THIS BIG FINISHES THE SAME WAY. Reading every word from every tile
103
+ * of a table of a hundred-odd tiles is about seventeen million checks a step,
104
+ * and a Full game with a Quick hand (seed 2) ran over five minutes on
105
+ * "Making your puzzle…" (found 2026-09-28). A bag of more than this many
106
+ * tiles — Full, and Double at Medium or Full — is laid the way a Diagonals
107
+ * game is: a sample of words from every tile, and fewer steps before a try
108
+ * starts again. Smaller bags are laid exactly as before, so their games, kept
109
+ * and raced, are the same tiles they always were.
110
+ */
111
+ const WIDE_FINISH_FROM = 100;
112
+ /** A crossword of exactly `tiles` tiles, or null where this try got stuck (the caller tries again). */
113
+ function layCrossword(tiles, random, words, side, multiplier, mix, progress, diagonals) {
114
+ const squares = new Array(side * side).fill("");
115
+ const wide = diagonals || tiles > WIDE_FINISH_FROM;
116
+ // What the set still holds of each letter, and the handful drawn from it that the words are chosen to use.
117
+ const left = new Map([...mix].map(([letter, count]) => [letter, count * multiplier]));
118
+ const wanted = new Map();
119
+ const set = [...mix].flatMap(([letter, count]) => new Array(count * multiplier).fill(letter));
120
+ for (const letter of shuffled(set, random).slice(0, tiles))
121
+ wanted.set(letter, (wanted.get(letter) ?? 0) + 1);
122
+ const lay = (placement) => {
123
+ const step = placement.across ? 1 : side;
124
+ [...placement.word].forEach((letter, at) => {
125
+ const index = placement.start + at * step;
126
+ if (squares[index] !== "")
127
+ return;
128
+ squares[index] = letter;
129
+ left.set(letter, (left.get(letter) ?? 0) - 1);
130
+ wanted.set(letter, (wanted.get(letter) ?? 0) - 1);
131
+ });
132
+ };
133
+ const lift = (placement) => {
134
+ const step = placement.across ? 1 : side;
135
+ [...placement.word].forEach((letter, at) => {
136
+ const index = placement.start + at * step;
137
+ if (!placement.fresh.includes(index))
138
+ return;
139
+ squares[index] = "";
140
+ left.set(letter, (left.get(letter) ?? 0) + 1);
141
+ wanted.set(letter, (wanted.get(letter) ?? 0) + 1);
142
+ });
143
+ };
144
+ // The first word, across the middle.
145
+ const firstLength = Math.min(tiles, 3 + Math.floor(random() * 4));
146
+ const first = bestOf(sample(words.byLength.get(firstLength) ?? [], WORDS_READ * 4, random).map((word) => {
147
+ const start = Math.floor(side / 2) * side + Math.floor((side - firstLength) / 2);
148
+ return scored(word, start, true, Array.from({ length: word.length }, (_, at) => start + at), left, wanted, random, undefined, side);
149
+ }));
150
+ if (first === null)
151
+ return null;
152
+ lay(first);
153
+ let laid = firstLength;
154
+ progress(laid);
155
+ /*
156
+ * Where the next word could go: from a few tiles, a sample of words
157
+ * (`sampled`); from every tile, a sample of words (`wide`, Diagonals only);
158
+ * or from every tile, every word (`exhaustive`).
159
+ */
160
+ const placementsFor = (room, reach) => {
161
+ const exhaustive = reach === "exhaustive";
162
+ const anchors = shuffled(squares.flatMap((letter, index) => (letter === "" ? [] : [index])), random).slice(0, reach === "sampled" ? ANCHORS_TRIED : squares.length);
163
+ const found = [];
164
+ for (const anchor of anchors) {
165
+ for (const across of [true, false]) {
166
+ for (let length = 2; length <= LONGEST_LAID; length += 1) {
167
+ const letter = squares[anchor];
168
+ const candidates = words.byLength.get(length) ?? [];
169
+ for (const word of sample(candidates, exhaustive ? candidates.length : WORDS_READ, random, letter)) {
170
+ for (let at = 0; at < word.length; at += 1) {
171
+ if (word[at] !== letter)
172
+ continue;
173
+ const placement = fit(squares, word, anchor, at, across, room, left, wanted, random, side, diagonals ? words : null);
174
+ if (placement !== null)
175
+ found.push(placement);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ return found;
182
+ };
183
+ let finishNodes = 0;
184
+ const finish = (room) => {
185
+ if (room === 0)
186
+ return true;
187
+ if (finishNodes >= (wide ? DIAGONAL_FINISH_NODES : FINISH_NODES))
188
+ return false;
189
+ finishNodes += 1;
190
+ const placements = placementsFor(room, wide ? "wide" : "exhaustive")
191
+ .sort((a, b) => b.score - a.score || b.fresh.length - a.fresh.length)
192
+ .slice(0, FINISH_BRANCHES);
193
+ for (const placement of placements) {
194
+ lay(placement);
195
+ const nextRoom = room - placement.fresh.length;
196
+ progress(tiles - nextRoom);
197
+ if (finish(nextRoom))
198
+ return true;
199
+ lift(placement);
200
+ }
201
+ return false;
202
+ };
203
+ while (laid < tiles) {
204
+ const room = tiles - laid;
205
+ if (room <= 10) {
206
+ if (!finish(room))
207
+ return null;
208
+ laid = tiles;
209
+ break;
210
+ }
211
+ /* With Diagonals, or a big bag, a few tiles often offer nothing: every tile is tried before this try is given up. */
212
+ const next = bestOf(placementsFor(room, "sampled")) ?? (wide ? bestOf(placementsFor(room, "wide")) : null);
213
+ if (next === null)
214
+ return null;
215
+ lay(next);
216
+ laid += next.fresh.length;
217
+ progress(laid);
218
+ }
219
+ return squares;
220
+ }
221
+ /** Up to `count` words of a list, read from a random place onward, only those holding `letter` when one is named. */
222
+ function sample(list, count, random, letter) {
223
+ if (list.length === 0)
224
+ return [];
225
+ const out = [];
226
+ const from = Math.floor(random() * list.length);
227
+ for (let read = 0; read < list.length && out.length < count; read += 1) {
228
+ const word = list[(from + read) % list.length];
229
+ if (letter === undefined || word.includes(letter))
230
+ out.push(word);
231
+ }
232
+ return out;
233
+ }
234
+ /**
235
+ * Where `word` would stand crossing the tile at `anchor` with its letter at
236
+ * `at`, or null where it cannot: the laying rule every crossword here keeps
237
+ * (`crossingFit`: on the laying square, nothing at either end, no new tile
238
+ * with a neighbour at its side, and with Diagonals no diagonal run of three
239
+ * or more that is not a word), then more new tiles than the bag has room
240
+ * for, or a letter the set has run out of.
241
+ */
242
+ function fit(squares, word, anchor, at, across, room, left, wanted, random, side, diagonalWords = null) {
243
+ const place = crossingFit((row, col) => squares[row * side + col], (row, col) => row >= 0 && row < side && col >= 0 && col < side, word, { row: Math.floor(anchor / side), col: anchor % side }, at, across, diagonalWords === null
244
+ ? null
245
+ : (run) => {
246
+ const read = diagonalWords.wordOf(run);
247
+ return read !== null && diagonalWords.allowed.has(read);
248
+ });
249
+ if (place === null || place.fresh.length > room)
250
+ return null;
251
+ const fresh = place.fresh.map((square) => square.row * side + square.col);
252
+ return scored(word, place.first.row * side + place.first.col, across, fresh, left, wanted, random, squares, side);
253
+ }
254
+ /** A placement's worth: a letter drawn from the mix is worth two, any other costs three, and the set's own counts are a wall. */
255
+ function scored(word, start, across, fresh, left, wanted, random, squares, side = MIN_LAYING_SIDE) {
256
+ const step = across ? 1 : side;
257
+ const using = new Map();
258
+ let score = 0;
259
+ for (let k = 0; k < word.length; k += 1) {
260
+ const index = start + k * step;
261
+ if (squares !== undefined && squares[index] !== "")
262
+ continue;
263
+ const letter = word[k];
264
+ const count = (using.get(letter) ?? 0) + 1;
265
+ using.set(letter, count);
266
+ if (count > (left.get(letter) ?? 0))
267
+ return null;
268
+ score += (count <= (wanted.get(letter) ?? 0) ? 2 : -3) + 8 / Math.max(1, left.get(letter) ?? 0);
269
+ }
270
+ return { word, start, across, fresh, score: score + 0.3 * fresh.length + 0.5 * random() };
271
+ }
272
+ function bestOf(placements) {
273
+ let best = null;
274
+ for (const placement of placements)
275
+ if (placement !== null && (best === null || placement.score > best.score))
276
+ best = placement;
277
+ return best;
278
+ }