@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.
- package/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +38 -0
- package/LICENSE +21 -0
- package/NOTICE.md +38 -0
- package/README.md +233 -0
- package/dist/check.d.ts +19 -0
- package/dist/check.js +70 -0
- package/dist/computer.types.d.ts +23 -0
- package/dist/computer.types.js +1 -0
- package/dist/computerPlay.d.ts +17 -0
- package/dist/computerPlay.js +203 -0
- package/dist/computerTurn.d.ts +31 -0
- package/dist/computerTurn.js +94 -0
- package/dist/generate.d.ts +29 -0
- package/dist/generate.js +278 -0
- package/dist/grid.d.ts +97 -0
- package/dist/grid.js +208 -0
- package/dist/help.d.ts +20 -0
- package/dist/help.js +57 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/dist/judge.d.ts +13 -0
- package/dist/judge.js +19 -0
- package/dist/kana.d.ts +25 -0
- package/dist/kana.js +38 -0
- package/dist/kumimoji.types.d.ts +49 -0
- package/dist/kumimoji.types.js +1 -0
- package/dist/party.d.ts +91 -0
- package/dist/party.js +172 -0
- package/dist/party.types.d.ts +86 -0
- package/dist/party.types.js +1 -0
- package/dist/partyKept.d.ts +17 -0
- package/dist/partyKept.js +149 -0
- package/dist/partySeats.d.ts +53 -0
- package/dist/partySeats.js +119 -0
- package/dist/partyTurns.d.ts +75 -0
- package/dist/partyTurns.js +153 -0
- package/dist/placement.d.ts +26 -0
- package/dist/placement.js +87 -0
- package/dist/play.d.ts +76 -0
- package/dist/play.js +165 -0
- package/dist/random.d.ts +11 -0
- package/dist/random.js +25 -0
- package/dist/react.d.ts +32 -0
- package/dist/react.js +53 -0
- package/dist/showcase.d.ts +51 -0
- package/dist/showcase.js +59 -0
- package/dist/tableView.d.ts +92 -0
- package/dist/tableView.js +127 -0
- package/dist/tileFace.d.ts +25 -0
- package/dist/tileFace.js +39 -0
- package/dist/tileFamily.d.ts +11 -0
- package/dist/tileFamily.js +23 -0
- package/dist/tileWords.d.ts +70 -0
- package/dist/tileWords.js +164 -0
- package/dist/tileWordsModule.d.ts +4 -0
- package/dist/tileWordsModule.js +13 -0
- package/dist/tiles.constants.d.ts +108 -0
- package/dist/tiles.constants.js +132 -0
- package/dist/turn.d.ts +62 -0
- package/dist/turn.js +96 -0
- package/dist/ui/board.d.ts +29 -0
- package/dist/ui/board.js +37 -0
- package/dist/ui/mount.d.ts +36 -0
- package/dist/ui/mount.js +257 -0
- package/dist/ui/style.d.ts +6 -0
- package/dist/ui/style.js +40 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/wilds.d.ts +27 -0
- package/dist/wilds.js +111 -0
- package/dist/words.d.ts +6 -0
- package/dist/words.en.data.d.ts +23 -0
- package/dist/words.en.data.js +610 -0
- package/dist/words.ja.data.d.ts +29 -0
- package/dist/words.ja.data.js +29 -0
- package/dist/words.js +6 -0
- package/package.json +90 -0
package/dist/turn.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TURNING THE TABLE, WITH EVERY TILE KEPT UPRIGHT. John, 2026-09-28: "if you
|
|
3
|
+
* rotate the board like flip it 180° for example then all the tiles will flip
|
|
4
|
+
* 180° to right themselves directly just so that they're not backwards for you
|
|
5
|
+
* or upside down."
|
|
6
|
+
*
|
|
7
|
+
* So the turn is a mapping of squares, never a rotation of the picture: the
|
|
8
|
+
* grid (its squares, `encodeGrid`, the kept game, the check) stays where the
|
|
9
|
+
* player built it, and only WHERE each square is drawn is turned — about the
|
|
10
|
+
* middle of square (0, 0), a quarter clockwise per press. A tile drawn at its
|
|
11
|
+
* turned place is drawn as it always is, so its letter reads the right way up
|
|
12
|
+
* whichever way the table faces. A point on the screen means the square drawn
|
|
13
|
+
* there, so a tap or a drop lands where the finger is.
|
|
14
|
+
*
|
|
15
|
+
* Pure, like the rest of the view (`tableView.ts`), which works on the turned
|
|
16
|
+
* squares unchanged: fitting, zooming and panning are all in screen terms.
|
|
17
|
+
*/
|
|
18
|
+
/** The next turn a press gives: a quarter clockwise, and four presses back to the start. */
|
|
19
|
+
export function nextTurn(turn) {
|
|
20
|
+
return ((turn + 1) % 4);
|
|
21
|
+
}
|
|
22
|
+
/** `0 - n` rather than `-n`, so a square is never named "-0". */
|
|
23
|
+
const negate = (n) => 0 - n;
|
|
24
|
+
/**
|
|
25
|
+
* Where square (row, col) of the grid is drawn with the table turned: a
|
|
26
|
+
* quarter clockwise sends a word running right to one running down, and one
|
|
27
|
+
* running down to one running left. It is linear, so it turns a step (a
|
|
28
|
+
* direction) as well as a square.
|
|
29
|
+
*/
|
|
30
|
+
export function turnPlace(row, col, turn) {
|
|
31
|
+
switch (turn) {
|
|
32
|
+
case 0:
|
|
33
|
+
return { row, col };
|
|
34
|
+
case 1:
|
|
35
|
+
return { row: col, col: negate(row) };
|
|
36
|
+
case 2:
|
|
37
|
+
return { row: negate(row), col: negate(col) };
|
|
38
|
+
case 3:
|
|
39
|
+
return { row: negate(col), col: row };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The grid's square drawn at (row, col) of the turned table: `turnPlace` undone. */
|
|
43
|
+
export function unturnPlace(row, col, turn) {
|
|
44
|
+
return turnPlace(row, col, ((4 - turn) % 4));
|
|
45
|
+
}
|
|
46
|
+
/** The squares a turned table shows: the grid's area, turned. */
|
|
47
|
+
export function turnArea(area, turn) {
|
|
48
|
+
const a = turnPlace(area.top, area.left, turn);
|
|
49
|
+
const b = turnPlace(area.top + area.rows - 1, area.left + area.cols - 1, turn);
|
|
50
|
+
return { top: Math.min(a.row, b.row), left: Math.min(a.col, b.col), rows: Math.abs(a.row - b.row) + 1, cols: Math.abs(a.col - b.col) + 1 };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A view the player zoomed or panned, turned a quarter clockwise with the
|
|
54
|
+
* table about the middle of its box: the same zoom, and the spot of the table
|
|
55
|
+
* that was in the middle stays there. (A fitted view simply fits again.)
|
|
56
|
+
*
|
|
57
|
+
* On the screen a square's corner is `x + col * tile`; the turn takes a point
|
|
58
|
+
* (row v, col u) of the table to (row u, col 1 - v), which is a quarter turn
|
|
59
|
+
* about the middle of square (0, 0) — the same turn as `turnPlace`.
|
|
60
|
+
*/
|
|
61
|
+
export function turnView(view, width, height) {
|
|
62
|
+
const px = width / 2;
|
|
63
|
+
const py = height / 2;
|
|
64
|
+
const u = (px - view.x) / view.tile;
|
|
65
|
+
const v = (py - view.y) / view.tile;
|
|
66
|
+
return { tile: view.tile, x: px - (1 - v) * view.tile, y: py - u * view.tile };
|
|
67
|
+
}
|
|
68
|
+
/** The screen's four ways, as a step of one square, with the arrow that draws each and its name. */
|
|
69
|
+
const WAYS = [
|
|
70
|
+
{ row: 0, col: 1, arrow: "→", name: "right" },
|
|
71
|
+
{ row: 1, col: 0, arrow: "↓", name: "down" },
|
|
72
|
+
{ row: 0, col: -1, arrow: "←", name: "left" },
|
|
73
|
+
{ row: -1, col: 0, arrow: "↑", name: "up" },
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* Which way typed letters run ON THE SCREEN. Typing lays a word across or
|
|
77
|
+
* down the grid, so that it reads as a word; with the table turned, that is
|
|
78
|
+
* another way on the screen, and the cursor's arrow points it.
|
|
79
|
+
*/
|
|
80
|
+
export function typingWay(across, turn) {
|
|
81
|
+
const step = turnPlace(across ? 0 : 1, across ? 1 : 0, turn);
|
|
82
|
+
const way = WAYS.find((one) => one.row === step.row && one.col === step.col);
|
|
83
|
+
return { arrow: way.arrow, name: way.name };
|
|
84
|
+
}
|
|
85
|
+
/** The keyboard's arrows, as a step on the screen. */
|
|
86
|
+
const ARROW_KEYS = {
|
|
87
|
+
ArrowRight: { row: 0, col: 1 },
|
|
88
|
+
ArrowDown: { row: 1, col: 0 },
|
|
89
|
+
ArrowLeft: { row: 0, col: -1 },
|
|
90
|
+
ArrowUp: { row: -1, col: 0 },
|
|
91
|
+
};
|
|
92
|
+
/** The step in the grid an arrow key means: the square that way on the screen, with the table turned. Null for any other key. */
|
|
93
|
+
export function arrowStep(key, turn) {
|
|
94
|
+
const step = ARROW_KEYS[key];
|
|
95
|
+
return step === undefined ? null : unturnPlace(step.row, step.col, turn);
|
|
96
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type GridVerdict, type Tiles } from "../grid.ts";
|
|
2
|
+
/** How a tile on the table stands: part of a sound crossword, in a run that is not a word, or apart from the rest. */
|
|
3
|
+
export type TileMark = "sound" | "misspelt" | "apart";
|
|
4
|
+
export type BoardSquare = {
|
|
5
|
+
square: string;
|
|
6
|
+
row: number;
|
|
7
|
+
col: number;
|
|
8
|
+
/** The tile standing there, or null for an empty square. */
|
|
9
|
+
tile: string | null;
|
|
10
|
+
glyph: string;
|
|
11
|
+
wild: boolean;
|
|
12
|
+
mark: TileMark | null;
|
|
13
|
+
};
|
|
14
|
+
export type BoardModel = {
|
|
15
|
+
rows: number;
|
|
16
|
+
cols: number;
|
|
17
|
+
squares: BoardSquare[];
|
|
18
|
+
};
|
|
19
|
+
/** How many empty squares are kept round the tiles on every side, so there is always room to build out. */
|
|
20
|
+
export declare const BOARD_MARGIN = 2;
|
|
21
|
+
/** The fewest squares a side, so an empty or small table is still a table. */
|
|
22
|
+
export declare const BOARD_LEAST = 7;
|
|
23
|
+
/**
|
|
24
|
+
* THE PART OF THE TABLE TO DRAW, for a grid and its verdict: the tiles and a
|
|
25
|
+
* margin round them, at least `BOARD_LEAST` a side, each square with its tile,
|
|
26
|
+
* what is printed on it, and how it stands. Row by row, left to right. The
|
|
27
|
+
* grid itself has no edges; this is only the window a board shows it through.
|
|
28
|
+
*/
|
|
29
|
+
export declare function boardModel(tiles: Tiles, verdict: GridVerdict | null, glyphOf?: (tile: string) => string): BoardModel;
|
package/dist/ui/board.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { boundsOf, squareAt } from "../grid.js";
|
|
2
|
+
import { tileFace } from "../tileFace.js";
|
|
3
|
+
/** How many empty squares are kept round the tiles on every side, so there is always room to build out. */
|
|
4
|
+
export const BOARD_MARGIN = 2;
|
|
5
|
+
/** The fewest squares a side, so an empty or small table is still a table. */
|
|
6
|
+
export const BOARD_LEAST = 7;
|
|
7
|
+
/**
|
|
8
|
+
* THE PART OF THE TABLE TO DRAW, for a grid and its verdict: the tiles and a
|
|
9
|
+
* margin round them, at least `BOARD_LEAST` a side, each square with its tile,
|
|
10
|
+
* what is printed on it, and how it stands. Row by row, left to right. The
|
|
11
|
+
* grid itself has no edges; this is only the window a board shows it through.
|
|
12
|
+
*/
|
|
13
|
+
export function boardModel(tiles, verdict, glyphOf = (tile) => tileFace(tile).glyph) {
|
|
14
|
+
const bounds = boundsOf(tiles) ?? { top: 0, left: 0, bottom: 0, right: 0 };
|
|
15
|
+
let top = bounds.top - BOARD_MARGIN;
|
|
16
|
+
let left = bounds.left - BOARD_MARGIN;
|
|
17
|
+
let rows = bounds.bottom - bounds.top + 1 + BOARD_MARGIN * 2;
|
|
18
|
+
let cols = bounds.right - bounds.left + 1 + BOARD_MARGIN * 2;
|
|
19
|
+
if (rows < BOARD_LEAST) {
|
|
20
|
+
top -= Math.floor((BOARD_LEAST - rows) / 2);
|
|
21
|
+
rows = BOARD_LEAST;
|
|
22
|
+
}
|
|
23
|
+
if (cols < BOARD_LEAST) {
|
|
24
|
+
left -= Math.floor((BOARD_LEAST - cols) / 2);
|
|
25
|
+
cols = BOARD_LEAST;
|
|
26
|
+
}
|
|
27
|
+
const squares = [];
|
|
28
|
+
for (let row = top; row < top + rows; row += 1) {
|
|
29
|
+
for (let col = left; col < left + cols; col += 1) {
|
|
30
|
+
const square = squareAt(row, col);
|
|
31
|
+
const tile = tiles.get(square) ?? null;
|
|
32
|
+
const mark = tile === null || verdict === null ? null : verdict.misspelt.has(square) ? "misspelt" : verdict.apart.has(square) ? "apart" : "sound";
|
|
33
|
+
squares.push({ square, row, col, tile, glyph: tile === null ? "" : glyphOf(tile), wild: tile !== null && tileFace(tile).wild, mark });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { rows, cols, squares };
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { KumimojiDeal, KumimojiLanguage, KumimojiLength, KumimojiLevel } from "../kumimoji.types.ts";
|
|
2
|
+
import { type TilePlay } from "../play.ts";
|
|
3
|
+
export type KumimojiTableOptions = {
|
|
4
|
+
language?: KumimojiLanguage;
|
|
5
|
+
/** The opening hand: 3, 7 or 11 tiles. */
|
|
6
|
+
hand?: number;
|
|
7
|
+
level?: KumimojiLevel;
|
|
8
|
+
gameLength?: KumimojiLength;
|
|
9
|
+
/** Every diagonal run of three or more must be a word too. */
|
|
10
|
+
diagonals?: boolean;
|
|
11
|
+
/** The seed the bag is dealt from; a new one each game when absent. */
|
|
12
|
+
seed?: number;
|
|
13
|
+
/** Told when the last tile is laid and every run is a word, with the time it took. */
|
|
14
|
+
onFinish?: (result: {
|
|
15
|
+
deal: KumimojiDeal;
|
|
16
|
+
play: TilePlay;
|
|
17
|
+
elapsedMs: number;
|
|
18
|
+
}) => void;
|
|
19
|
+
};
|
|
20
|
+
export type KumimojiTableHandle = {
|
|
21
|
+
play: () => TilePlay | null;
|
|
22
|
+
newGame: (options?: Omit<KumimojiTableOptions, "onFinish">) => Promise<void>;
|
|
23
|
+
destroy: () => void;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A GAME OF KUMIMOJI ALONE, IN PLAIN DOM: the bag dealt from a seed, your
|
|
27
|
+
* hand, and a table to build your crossword on, every run judged as you lay
|
|
28
|
+
* it. Tap a tile in your hand and then a square to lay it; tap a tile on the
|
|
29
|
+
* table and then a square to move it, or tap it again to take it back. When
|
|
30
|
+
* the hand is empty and every run is a word, Draw brings the next tile; a
|
|
31
|
+
* tile you cannot use can be traded for three. Use every tile in the bag to
|
|
32
|
+
* finish.
|
|
33
|
+
*
|
|
34
|
+
* The word list is fetched the first time a game in its language is dealt.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mountKumimoji(target: HTMLElement, options?: KumimojiTableOptions): KumimojiTableHandle;
|
package/dist/ui/mount.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { generateKumimoji } from "../generate.js";
|
|
2
|
+
import { judgeWithWords } from "../judge.js";
|
|
3
|
+
import { assignHandTile, deal, draw, isFinished, liftAll, liftToHand, mayDraw, mayTrade, moveOnTable, placeFromHand, sortHand, swapWithHand, tilesLeft, trade } from "../play.js";
|
|
4
|
+
import { KUMIMOJI_HANDS } from "../tiles.constants.js";
|
|
5
|
+
import { tileFace } from "../tileFace.js";
|
|
6
|
+
import { loadTileWords } from "../tileWords.js";
|
|
7
|
+
import { boardModel } from "./board.js";
|
|
8
|
+
import { KUMIMOJI_STYLE } from "./style.js";
|
|
9
|
+
/** What is picked up: a tile in the hand, or one on the table. */
|
|
10
|
+
/** What a wild nobody has given a letter yet shows in the hand. */
|
|
11
|
+
const WILD_MARK = "★";
|
|
12
|
+
function node(name, className, text) {
|
|
13
|
+
const made = document.createElement(name);
|
|
14
|
+
if (className !== undefined)
|
|
15
|
+
made.className = className;
|
|
16
|
+
if (text !== undefined)
|
|
17
|
+
made.textContent = text;
|
|
18
|
+
return made;
|
|
19
|
+
}
|
|
20
|
+
function button(text, onClick, primary = false) {
|
|
21
|
+
const made = node("button", primary ? "km-button km-primary" : "km-button", text);
|
|
22
|
+
made.type = "button";
|
|
23
|
+
made.addEventListener("click", onClick);
|
|
24
|
+
return made;
|
|
25
|
+
}
|
|
26
|
+
function clock(ms) {
|
|
27
|
+
const seconds = Math.floor(ms / 1000);
|
|
28
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A GAME OF KUMIMOJI ALONE, IN PLAIN DOM: the bag dealt from a seed, your
|
|
32
|
+
* hand, and a table to build your crossword on, every run judged as you lay
|
|
33
|
+
* it. Tap a tile in your hand and then a square to lay it; tap a tile on the
|
|
34
|
+
* table and then a square to move it, or tap it again to take it back. When
|
|
35
|
+
* the hand is empty and every run is a word, Draw brings the next tile; a
|
|
36
|
+
* tile you cannot use can be traded for three. Use every tile in the bag to
|
|
37
|
+
* finish.
|
|
38
|
+
*
|
|
39
|
+
* The word list is fetched the first time a game in its language is dealt.
|
|
40
|
+
*/
|
|
41
|
+
export function mountKumimoji(target, options = {}) {
|
|
42
|
+
let settings = { ...options };
|
|
43
|
+
let words = null;
|
|
44
|
+
let dealt = null;
|
|
45
|
+
let play = null;
|
|
46
|
+
let held = null;
|
|
47
|
+
let started = 0;
|
|
48
|
+
let finishedAt = null;
|
|
49
|
+
let destroyed = false;
|
|
50
|
+
const root = node("div", "km-root");
|
|
51
|
+
const style = node("style");
|
|
52
|
+
style.textContent = KUMIMOJI_STYLE;
|
|
53
|
+
const status = node("p", "km-status");
|
|
54
|
+
status.setAttribute("aria-live", "polite");
|
|
55
|
+
const board = node("div", "km-board");
|
|
56
|
+
const notes = node("p", "km-notes");
|
|
57
|
+
const hand = node("div", "km-hand");
|
|
58
|
+
const picker = node("div", "km-picker");
|
|
59
|
+
const controls = node("div", "km-controls");
|
|
60
|
+
root.append(style, status, board, notes, picker, hand, controls);
|
|
61
|
+
target.append(root);
|
|
62
|
+
const tick = setInterval(() => {
|
|
63
|
+
if (play !== null && finishedAt === null)
|
|
64
|
+
renderStatus();
|
|
65
|
+
}, 1000);
|
|
66
|
+
const verdictOf = (now) => judgeWithWords(now.tiles, words, { diagonals: settings.diagonals === true });
|
|
67
|
+
const change = (next) => {
|
|
68
|
+
play = next;
|
|
69
|
+
const verdict = verdictOf(next);
|
|
70
|
+
if (finishedAt === null && isFinished(next, verdict)) {
|
|
71
|
+
finishedAt = Date.now();
|
|
72
|
+
options.onFinish?.({ deal: dealt, play: next, elapsedMs: finishedAt - started });
|
|
73
|
+
}
|
|
74
|
+
render();
|
|
75
|
+
};
|
|
76
|
+
const tapSquare = (square) => {
|
|
77
|
+
if (play === null || finishedAt !== null)
|
|
78
|
+
return;
|
|
79
|
+
const there = play.tiles.get(square);
|
|
80
|
+
if (held === null) {
|
|
81
|
+
if (there !== undefined)
|
|
82
|
+
held = { from: "table", square };
|
|
83
|
+
render();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (held.from === "hand") {
|
|
87
|
+
const at = held.at;
|
|
88
|
+
const tile = play.hand[at];
|
|
89
|
+
if (tile === undefined)
|
|
90
|
+
return;
|
|
91
|
+
// A wild is given a letter before it is laid.
|
|
92
|
+
if (tile === "*")
|
|
93
|
+
return;
|
|
94
|
+
held = null;
|
|
95
|
+
change(there === undefined ? placeFromHand(play, at, square) : swapWithHand(play, at, square));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const from = held.square;
|
|
99
|
+
held = null;
|
|
100
|
+
change(from === square ? liftToHand(play, square) : moveOnTable(play, from, square));
|
|
101
|
+
};
|
|
102
|
+
const tapHand = (at) => {
|
|
103
|
+
if (play === null || finishedAt !== null)
|
|
104
|
+
return;
|
|
105
|
+
held = held?.from === "hand" && held.at === at ? null : { from: "hand", at };
|
|
106
|
+
render();
|
|
107
|
+
};
|
|
108
|
+
board.addEventListener("click", (event) => {
|
|
109
|
+
const square = event.target.closest("[data-square]")?.getAttribute("data-square");
|
|
110
|
+
if (square !== null && square !== undefined)
|
|
111
|
+
tapSquare(square);
|
|
112
|
+
});
|
|
113
|
+
hand.addEventListener("click", (event) => {
|
|
114
|
+
const at = event.target.closest("[data-hand]")?.getAttribute("data-hand");
|
|
115
|
+
if (at !== null && at !== undefined)
|
|
116
|
+
tapHand(Number(at));
|
|
117
|
+
});
|
|
118
|
+
function renderStatus() {
|
|
119
|
+
if (play === null) {
|
|
120
|
+
status.textContent = "Dealing…";
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const left = tilesLeft(play);
|
|
124
|
+
const time = clock((finishedAt ?? Date.now()) - started);
|
|
125
|
+
status.textContent = finishedAt !== null ? `Finished in ${time}. Every tile laid, every run a word.` : `${left} ${left === 1 ? "tile" : "tiles"} left in the bag · ${time}`;
|
|
126
|
+
}
|
|
127
|
+
function renderBoard(verdict) {
|
|
128
|
+
const model = boardModel(play.tiles, play.tiles.size === 0 ? null : verdict, words.glyphOf);
|
|
129
|
+
const grid = node("div", "km-grid");
|
|
130
|
+
grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
|
|
131
|
+
grid.setAttribute("role", "grid");
|
|
132
|
+
for (const square of model.squares) {
|
|
133
|
+
const cell = node("button", "km-square");
|
|
134
|
+
cell.type = "button";
|
|
135
|
+
cell.dataset.square = square.square;
|
|
136
|
+
if (square.tile !== null) {
|
|
137
|
+
cell.classList.add("km-tile");
|
|
138
|
+
if (square.wild)
|
|
139
|
+
cell.classList.add("km-wild");
|
|
140
|
+
if (square.mark !== null)
|
|
141
|
+
cell.classList.add(`km-${square.mark}`);
|
|
142
|
+
cell.textContent = square.glyph;
|
|
143
|
+
cell.setAttribute("aria-label", `${square.glyph}${square.wild ? ", wild" : ""}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
cell.setAttribute("aria-label", "Empty square");
|
|
147
|
+
}
|
|
148
|
+
if (held?.from === "table" && held.square === square.square)
|
|
149
|
+
cell.classList.add("km-held");
|
|
150
|
+
grid.append(cell);
|
|
151
|
+
}
|
|
152
|
+
board.replaceChildren(grid);
|
|
153
|
+
notes.textContent = verdict.notWords.length > 0 ? `Not words: ${verdict.notWords.join(", ")}` : verdict.apart.size > 0 ? "Every tile must join the one crossword." : "";
|
|
154
|
+
}
|
|
155
|
+
function renderHand() {
|
|
156
|
+
hand.replaceChildren();
|
|
157
|
+
play.hand.forEach((tile, at) => {
|
|
158
|
+
const face = tileFace(tile);
|
|
159
|
+
const made = node("button", "km-tile km-in-hand");
|
|
160
|
+
made.type = "button";
|
|
161
|
+
made.dataset.hand = String(at);
|
|
162
|
+
made.textContent = face.blank ? WILD_MARK : words.glyphOf(tile);
|
|
163
|
+
if (face.wild)
|
|
164
|
+
made.classList.add("km-wild");
|
|
165
|
+
if (held?.from === "hand" && held.at === at)
|
|
166
|
+
made.classList.add("km-held");
|
|
167
|
+
made.setAttribute("aria-label", face.blank ? "Wild, no letter yet" : `${words.glyphOf(tile)}${face.wild ? ", wild" : ""}`);
|
|
168
|
+
hand.append(made);
|
|
169
|
+
});
|
|
170
|
+
if (play.hand.length === 0 && finishedAt === null)
|
|
171
|
+
hand.append(node("span", "km-empty", "No tiles in hand"));
|
|
172
|
+
}
|
|
173
|
+
function renderPicker() {
|
|
174
|
+
picker.replaceChildren();
|
|
175
|
+
if (held?.from !== "hand" || play === null)
|
|
176
|
+
return;
|
|
177
|
+
const at = held.at;
|
|
178
|
+
const tile = play.hand[at];
|
|
179
|
+
if (tile === undefined || !tileFace(tile).wild)
|
|
180
|
+
return;
|
|
181
|
+
picker.append(node("span", "km-picker-title", tile === "*" ? "Give the wild a letter:" : "Change the wild's letter:"));
|
|
182
|
+
for (const sound of words.wildOptions) {
|
|
183
|
+
const code = words.wildFor(sound);
|
|
184
|
+
if (code === null)
|
|
185
|
+
continue;
|
|
186
|
+
picker.append(button(sound, () => {
|
|
187
|
+
held = { from: "hand", at };
|
|
188
|
+
change(assignHandTile(play, at, code));
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function renderControls(verdict) {
|
|
193
|
+
controls.replaceChildren();
|
|
194
|
+
if (finishedAt !== null) {
|
|
195
|
+
controls.append(button("New game", () => void handle.newGame(), true));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const now = play;
|
|
199
|
+
const drawButton = button("Draw", () => {
|
|
200
|
+
held = null;
|
|
201
|
+
change(draw(now));
|
|
202
|
+
}, true);
|
|
203
|
+
drawButton.disabled = !mayDraw(now, verdict);
|
|
204
|
+
const tradeButton = button("Trade for three", () => {
|
|
205
|
+
if (held?.from !== "hand")
|
|
206
|
+
return;
|
|
207
|
+
const at = held.at;
|
|
208
|
+
held = null;
|
|
209
|
+
change(trade(now, at));
|
|
210
|
+
});
|
|
211
|
+
tradeButton.disabled = held?.from !== "hand" || !mayTrade(now);
|
|
212
|
+
controls.append(drawButton, tradeButton, button("Sort", () => change(sortHand(now))), button("Lift all", () => {
|
|
213
|
+
held = null;
|
|
214
|
+
change(liftAll(now));
|
|
215
|
+
}), button("New game", () => void handle.newGame()));
|
|
216
|
+
}
|
|
217
|
+
function render() {
|
|
218
|
+
if (destroyed)
|
|
219
|
+
return;
|
|
220
|
+
renderStatus();
|
|
221
|
+
if (play === null || words === null)
|
|
222
|
+
return;
|
|
223
|
+
const verdict = verdictOf(play);
|
|
224
|
+
renderBoard(verdict);
|
|
225
|
+
renderPicker();
|
|
226
|
+
renderHand();
|
|
227
|
+
renderControls(verdict);
|
|
228
|
+
}
|
|
229
|
+
const handle = {
|
|
230
|
+
play: () => play,
|
|
231
|
+
newGame: async (changed = {}) => {
|
|
232
|
+
settings = { ...settings, ...changed, seed: undefined };
|
|
233
|
+
const language = settings.language ?? "english";
|
|
234
|
+
const size = settings.hand ?? KUMIMOJI_HANDS.quick;
|
|
235
|
+
play = null;
|
|
236
|
+
held = null;
|
|
237
|
+
finishedAt = null;
|
|
238
|
+
render();
|
|
239
|
+
words = await loadTileWords(language);
|
|
240
|
+
if (destroyed)
|
|
241
|
+
return;
|
|
242
|
+
// A seed deals one game: the next is dealt from a new one unless asked for by seed again.
|
|
243
|
+
const seed = changed.seed ?? Math.floor(Math.random() * 2 ** 31);
|
|
244
|
+
root.classList.toggle("km-japanese", language === "japanese");
|
|
245
|
+
dealt = generateKumimoji(size, settings.level ?? "medium", seed, { language, gameLength: settings.gameLength, diagonals: settings.diagonals });
|
|
246
|
+
started = Date.now();
|
|
247
|
+
change(deal(dealt.givens, size));
|
|
248
|
+
},
|
|
249
|
+
destroy: () => {
|
|
250
|
+
destroyed = true;
|
|
251
|
+
clearInterval(tick);
|
|
252
|
+
root.remove();
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
void handle.newGame(settings);
|
|
256
|
+
return handle;
|
|
257
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table's own styles, scoped to `.km-root`, every colour a CSS variable
|
|
3
|
+
* so a page can wear it in its own colours. Light and dark follow the
|
|
4
|
+
* reader's system.
|
|
5
|
+
*/
|
|
6
|
+
export declare const KUMIMOJI_STYLE = "\n.km-root {\n --km-square: 36px; --km-felt: #2f5d4a; --km-grid-line: rgba(255,255,255,.12); --km-tile: #f3e6c8; --km-tile-ink: #2a2118;\n --km-wild: #f6d27a; --km-wrong: #d9534f; --km-apart: #9aa3a0; --km-held: #ffcf3f; --km-ink: #1f2320; --km-panel: #f7f3ea;\n --km-line: rgba(20,20,20,.35); --km-accent: #2f5d4a; --km-accent-ink: #fff;\n display: grid; gap: 10px; color: var(--km-ink); font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; font-size: 15px;\n}\n@media (prefers-color-scheme: dark) {\n .km-root { --km-felt: #1e3a2f; --km-ink: #ece8dc; --km-panel: #1d201e; --km-line: rgba(255,255,255,.25); --km-accent: #6fb08f; --km-accent-ink: #10150f; }\n}\n.km-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }\n.km-board { background: var(--km-felt); border-radius: 12px; padding: 10px; overflow: auto; max-height: 70vh; }\n.km-grid { display: grid; gap: 2px; width: max-content; margin: 0 auto; }\n.km-square { width: var(--km-square); height: var(--km-square); border: 0; border-radius: 5px; background: var(--km-grid-line); padding: 0; cursor: pointer; }\n.km-tile { display: grid; place-items: center; width: var(--km-square); height: var(--km-square); border-radius: 6px; border: 0; padding: 0;\n background: var(--km-tile); color: var(--km-tile-ink); font: 700 20px/1 system-ui, sans-serif; text-transform: uppercase;\n box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; }\n.km-japanese .km-tile { font-size: 19px; text-transform: none; }\n.km-wild { background: var(--km-wild); }\n.km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }\n.km-apart { opacity: .6; }\n.km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }\n.km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; }\n.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: 40px; align-items: center; padding: 8px; border-radius: 10px; background: var(--km-panel); }\n.km-empty { opacity: .6; font-size: 14px; }\n.km-picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }\n.km-picker:empty { display: none; }\n.km-picker-title { width: 100%; font-size: 14px; }\n.km-picker .km-button { padding: 4px 8px; min-width: 32px; text-transform: uppercase; }\n.km-japanese .km-picker .km-button { text-transform: none; }\n.km-controls { display: flex; flex-wrap: wrap; gap: 8px; }\n.km-button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; }\n.km-button:disabled { opacity: .45; cursor: default; }\n.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }\n";
|
package/dist/ui/style.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table's own styles, scoped to `.km-root`, every colour a CSS variable
|
|
3
|
+
* so a page can wear it in its own colours. Light and dark follow the
|
|
4
|
+
* reader's system.
|
|
5
|
+
*/
|
|
6
|
+
export const KUMIMOJI_STYLE = `
|
|
7
|
+
.km-root {
|
|
8
|
+
--km-square: 36px; --km-felt: #2f5d4a; --km-grid-line: rgba(255,255,255,.12); --km-tile: #f3e6c8; --km-tile-ink: #2a2118;
|
|
9
|
+
--km-wild: #f6d27a; --km-wrong: #d9534f; --km-apart: #9aa3a0; --km-held: #ffcf3f; --km-ink: #1f2320; --km-panel: #f7f3ea;
|
|
10
|
+
--km-line: rgba(20,20,20,.35); --km-accent: #2f5d4a; --km-accent-ink: #fff;
|
|
11
|
+
display: grid; gap: 10px; color: var(--km-ink); font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 15px;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
.km-root { --km-felt: #1e3a2f; --km-ink: #ece8dc; --km-panel: #1d201e; --km-line: rgba(255,255,255,.25); --km-accent: #6fb08f; --km-accent-ink: #10150f; }
|
|
15
|
+
}
|
|
16
|
+
.km-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }
|
|
17
|
+
.km-board { background: var(--km-felt); border-radius: 12px; padding: 10px; overflow: auto; max-height: 70vh; }
|
|
18
|
+
.km-grid { display: grid; gap: 2px; width: max-content; margin: 0 auto; }
|
|
19
|
+
.km-square { width: var(--km-square); height: var(--km-square); border: 0; border-radius: 5px; background: var(--km-grid-line); padding: 0; cursor: pointer; }
|
|
20
|
+
.km-tile { display: grid; place-items: center; width: var(--km-square); height: var(--km-square); border-radius: 6px; border: 0; padding: 0;
|
|
21
|
+
background: var(--km-tile); color: var(--km-tile-ink); font: 700 20px/1 system-ui, sans-serif; text-transform: uppercase;
|
|
22
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; }
|
|
23
|
+
.km-japanese .km-tile { font-size: 19px; text-transform: none; }
|
|
24
|
+
.km-wild { background: var(--km-wild); }
|
|
25
|
+
.km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }
|
|
26
|
+
.km-apart { opacity: .6; }
|
|
27
|
+
.km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }
|
|
28
|
+
.km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; }
|
|
29
|
+
.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: 40px; align-items: center; padding: 8px; border-radius: 10px; background: var(--km-panel); }
|
|
30
|
+
.km-empty { opacity: .6; font-size: 14px; }
|
|
31
|
+
.km-picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
32
|
+
.km-picker:empty { display: none; }
|
|
33
|
+
.km-picker-title { width: 100%; font-size: 14px; }
|
|
34
|
+
.km-picker .km-button { padding: 4px 8px; min-width: 32px; text-transform: uppercase; }
|
|
35
|
+
.km-japanese .km-picker .km-button { text-transform: none; }
|
|
36
|
+
.km-controls { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
37
|
+
.km-button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; }
|
|
38
|
+
.km-button:disabled { opacity: .45; cursor: default; }
|
|
39
|
+
.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }
|
|
40
|
+
`;
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kumimoji played in the browser, in plain DOM: a whole game alone
|
|
3
|
+
* (`mountKumimoji`), and the window a board shows the grid through
|
|
4
|
+
* (`boardModel`). Its own entry, so the rules never carry a stylesheet.
|
|
5
|
+
*/
|
|
6
|
+
export { mountKumimoji, type KumimojiTableHandle, type KumimojiTableOptions } from "./ui/mount.ts";
|
|
7
|
+
export { BOARD_LEAST, BOARD_MARGIN, boardModel, type BoardModel, type BoardSquare, type TileMark } from "./ui/board.ts";
|
|
8
|
+
export { KUMIMOJI_STYLE } from "./ui/style.ts";
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kumimoji played in the browser, in plain DOM: a whole game alone
|
|
3
|
+
* (`mountKumimoji`), and the window a board shows the grid through
|
|
4
|
+
* (`boardModel`). Its own entry, so the rules never carry a stylesheet.
|
|
5
|
+
*/
|
|
6
|
+
export { mountKumimoji } from "./ui/mount.js";
|
|
7
|
+
export { BOARD_LEAST, BOARD_MARGIN, boardModel } from "./ui/board.js";
|
|
8
|
+
export { KUMIMOJI_STYLE } from "./ui/style.js";
|
package/dist/wilds.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type GridRules, type Tiles } from "./grid.ts";
|
|
2
|
+
import type { TileWords } from "./tileWords.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A WILD LAID WITHOUT A LETTER READS AS WHATEVER MAKES THE GRID WORDS. John,
|
|
5
|
+
* 2026-09-29, having laid D, a blank wild, O, N, E and been told "Not a word:
|
|
6
|
+
* D*ONE": "Either we are smarter and use a regex or something to know that
|
|
7
|
+
* it's a valid possible word." Choosing the wild's letter is still offered,
|
|
8
|
+
* and a wild given one reads as that letter only; a blank one is any letter
|
|
9
|
+
* the list allows.
|
|
10
|
+
*
|
|
11
|
+
* One blank can stand in two runs, across and down, and two blanks can share
|
|
12
|
+
* a run, so the letters are chosen together: blank by blank, each letter
|
|
13
|
+
* kept only while every run through it could still be a word — a run with
|
|
14
|
+
* blanks left in it is asked whether any word of its length fits the letters
|
|
15
|
+
* it has, a run with none whether it is one. Blanks that share no run are
|
|
16
|
+
* settled apart, so one crossword's wilds never multiply another's.
|
|
17
|
+
*
|
|
18
|
+
* `readWilds` hands back the grid with each blank given the wild of a letter
|
|
19
|
+
* that works (`wildFor`), or null where no choice makes every run a word —
|
|
20
|
+
* and the grid is then judged as laid, blank and all, so the line under the
|
|
21
|
+
* table names the run as the player spelled it. It gives up, answering null,
|
|
22
|
+
* past `WILD_SEARCH_MOST` steps: a grid it could not settle is not called
|
|
23
|
+
* sound.
|
|
24
|
+
*/
|
|
25
|
+
export declare const WILD_SEARCH_MOST = 20000;
|
|
26
|
+
/** The grid with every blank wild read as a letter that makes all its runs words, or null for none; the grid itself when it holds no blank. */
|
|
27
|
+
export declare function readWilds(tiles: Tiles, words: TileWords, rules?: GridRules): Tiles | null;
|
package/dist/wilds.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { runsOf } from "./grid.js";
|
|
2
|
+
/**
|
|
3
|
+
* A WILD LAID WITHOUT A LETTER READS AS WHATEVER MAKES THE GRID WORDS. John,
|
|
4
|
+
* 2026-09-29, having laid D, a blank wild, O, N, E and been told "Not a word:
|
|
5
|
+
* D*ONE": "Either we are smarter and use a regex or something to know that
|
|
6
|
+
* it's a valid possible word." Choosing the wild's letter is still offered,
|
|
7
|
+
* and a wild given one reads as that letter only; a blank one is any letter
|
|
8
|
+
* the list allows.
|
|
9
|
+
*
|
|
10
|
+
* One blank can stand in two runs, across and down, and two blanks can share
|
|
11
|
+
* a run, so the letters are chosen together: blank by blank, each letter
|
|
12
|
+
* kept only while every run through it could still be a word — a run with
|
|
13
|
+
* blanks left in it is asked whether any word of its length fits the letters
|
|
14
|
+
* it has, a run with none whether it is one. Blanks that share no run are
|
|
15
|
+
* settled apart, so one crossword's wilds never multiply another's.
|
|
16
|
+
*
|
|
17
|
+
* `readWilds` hands back the grid with each blank given the wild of a letter
|
|
18
|
+
* that works (`wildFor`), or null where no choice makes every run a word —
|
|
19
|
+
* and the grid is then judged as laid, blank and all, so the line under the
|
|
20
|
+
* table names the run as the player spelled it. It gives up, answering null,
|
|
21
|
+
* past `WILD_SEARCH_MOST` steps: a grid it could not settle is not called
|
|
22
|
+
* sound.
|
|
23
|
+
*/
|
|
24
|
+
export const WILD_SEARCH_MOST = 20000;
|
|
25
|
+
/** The grid with every blank wild read as a letter that makes all its runs words, or null for none; the grid itself when it holds no blank. */
|
|
26
|
+
export function readWilds(tiles, words, rules = {}) {
|
|
27
|
+
const blanks = [...tiles].filter(([, tile]) => tile === "*").map(([square]) => square);
|
|
28
|
+
if (blanks.length === 0)
|
|
29
|
+
return tiles;
|
|
30
|
+
const runs = runsOf(tiles, rules);
|
|
31
|
+
const through = new Map(blanks.map((square) => [square, runs.filter((run) => run.squares.includes(square))]));
|
|
32
|
+
const choices = words.wildOptions.flatMap((face) => {
|
|
33
|
+
const code = words.wildFor(face);
|
|
34
|
+
const sound = code === null ? null : words.soundOf(code);
|
|
35
|
+
return code === null || sound === null ? [] : [{ code, sound }];
|
|
36
|
+
});
|
|
37
|
+
const fits = patternFits(words);
|
|
38
|
+
const read = new Map(tiles);
|
|
39
|
+
let steps = 0;
|
|
40
|
+
const possible = (run) => {
|
|
41
|
+
const sounds = run.squares.map((at) => {
|
|
42
|
+
const tile = read.get(at);
|
|
43
|
+
return tile === "*" ? null : words.soundOf(tile);
|
|
44
|
+
});
|
|
45
|
+
if (sounds.every((sound) => sound !== null))
|
|
46
|
+
return words.allowed.has(sounds.join(""));
|
|
47
|
+
return fits(sounds);
|
|
48
|
+
};
|
|
49
|
+
const settle = (group, at) => {
|
|
50
|
+
if (at === group.length)
|
|
51
|
+
return true;
|
|
52
|
+
const square = group[at];
|
|
53
|
+
for (const choice of choices) {
|
|
54
|
+
steps += 1;
|
|
55
|
+
if (steps > WILD_SEARCH_MOST)
|
|
56
|
+
return false;
|
|
57
|
+
read.set(square, choice.code);
|
|
58
|
+
if (through.get(square).every(possible) && settle(group, at + 1))
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
read.set(square, "*");
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
for (const group of blankGroups(blanks, through)) {
|
|
65
|
+
if (!settle(group, 0))
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return read;
|
|
69
|
+
}
|
|
70
|
+
/** The blanks in groups that share a run, directly or through one another. */
|
|
71
|
+
function blankGroups(blanks, through) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const groups = [];
|
|
74
|
+
for (const start of blanks) {
|
|
75
|
+
if (seen.has(start))
|
|
76
|
+
continue;
|
|
77
|
+
const group = [];
|
|
78
|
+
const queue = [start];
|
|
79
|
+
seen.add(start);
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const square = queue.shift();
|
|
82
|
+
group.push(square);
|
|
83
|
+
for (const run of through.get(square)) {
|
|
84
|
+
for (const other of run.squares) {
|
|
85
|
+
if (through.has(other) && !seen.has(other)) {
|
|
86
|
+
seen.add(other);
|
|
87
|
+
queue.push(other);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
groups.push(group);
|
|
93
|
+
}
|
|
94
|
+
return groups;
|
|
95
|
+
}
|
|
96
|
+
/** Whether any word of the list spells a run whose null places may be anything, remembered by pattern. */
|
|
97
|
+
function patternFits(words) {
|
|
98
|
+
const known = new Map();
|
|
99
|
+
return (sounds) => {
|
|
100
|
+
const key = sounds.map((sound) => sound ?? "\u0000").join("\u0001");
|
|
101
|
+
const already = known.get(key);
|
|
102
|
+
if (already !== undefined)
|
|
103
|
+
return already;
|
|
104
|
+
const found = (words.byLength.get(sounds.length) ?? []).some((word) => {
|
|
105
|
+
const letters = [...word];
|
|
106
|
+
return letters.length === sounds.length && sounds.every((sound, at) => sound === null || words.soundOf(letters[at]) === sound);
|
|
107
|
+
});
|
|
108
|
+
known.set(key, found);
|
|
109
|
+
return found;
|
|
110
|
+
};
|
|
111
|
+
}
|