@johnmorrisdotca/kumimoji 1.3.0 → 1.3.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/dist/tileWords.js CHANGED
@@ -51,7 +51,7 @@ export function unpackTileWords(data) {
51
51
  }
52
52
  const letters = [...Object.keys(TILE_MIX)];
53
53
  const assigned = new Map(letters.map((letter) => [letter.toUpperCase(), letter]));
54
- const soundOf = (tile) => (tile === "*" ? null : assigned.get(tile) ?? (/^[a-z]$/.test(tile) ? tile : null));
54
+ const soundOf = (tile) => tile === "*" ? null : (assigned.get(tile) ?? (/^[a-z]$/.test(tile) ? tile : null));
55
55
  const isWild = (tile) => tile === "*" || assigned.has(tile);
56
56
  return {
57
57
  language: "english",
@@ -94,8 +94,12 @@ function unpackJapaneseWords(data) {
94
94
  allowed.add([...word].map((tile) => tileFace(tile).glyph).join(""));
95
95
  }
96
96
  const mix = new Map(Object.entries(JAPANESE_TILE_MIX).map(([face, count]) => [kanaTileCode(face), count]));
97
- const isTile = (tile) => tile.length === 1 && tile.codePointAt(0) - KANA_TILE_START >= 0 && tile.codePointAt(0) - KANA_TILE_START < kana.length;
98
- const isAssignedWild = (tile) => tile.length === 1 && tile.codePointAt(0) - KANA_WILD_START >= 0 && tile.codePointAt(0) - KANA_WILD_START < kana.length;
97
+ const isTile = (tile) => tile.length === 1 &&
98
+ tile.codePointAt(0) - KANA_TILE_START >= 0 &&
99
+ tile.codePointAt(0) - KANA_TILE_START < kana.length;
100
+ const isAssignedWild = (tile) => tile.length === 1 &&
101
+ tile.codePointAt(0) - KANA_WILD_START >= 0 &&
102
+ tile.codePointAt(0) - KANA_WILD_START < kana.length;
99
103
  const isWild = (tile) => tile === "*" || isAssignedWild(tile);
100
104
  const soundOf = (tile) => (isTile(tile) || isAssignedWild(tile) ? tileFace(tile).glyph : null);
101
105
  const identity = (tile) => (isWild(tile) ? "*" : isTile(tile) ? tileFace(tile).glyph : null);
@@ -196,9 +200,10 @@ export async function loadTileWords(language = "english") {
196
200
  return already;
197
201
  let words;
198
202
  if (typeof window !== "undefined") {
199
- words = language === "english"
200
- ? unpackTileWords((await import("./words.en.data.js")).TILE_WORDS_EN)
201
- : unpackJapaneseWords((await import("./words.ja.data.js")).TILE_WORDS_JA);
203
+ words =
204
+ language === "english"
205
+ ? unpackTileWords((await import("./words.en.data.js")).TILE_WORDS_EN)
206
+ : unpackJapaneseWords((await import("./words.ja.data.js")).TILE_WORDS_JA);
202
207
  }
203
208
  else {
204
209
  if (fromModule === null)
@@ -1,5 +1,5 @@
1
- import { type TileWords } from "./tileWords.ts";
2
1
  import type { KumimojiLanguage } from "./kumimoji.types.ts";
2
+ import { type TileWords } from "./tileWords.ts";
3
3
  /** The list, read from its module: `loadTileWords` for a caller with no browser.
4
4
  *
5
5
  * @param language - The English or Japanese tile language.
@@ -6,7 +6,9 @@ import { loadTileWords, readTileWordsWith, tileWordsFrom } from "./tileWords.js"
6
6
  * page's components never import it, so no page's server function carries the
7
7
  * lists for the browser's sake (see `loadTileWords`).
8
8
  */
9
- readTileWordsWith(async (language) => tileWordsFrom(language, language === "english" ? (await import("./words.en.data.js")).TILE_WORDS_EN : (await import("./words.ja.data.js")).TILE_WORDS_JA));
9
+ readTileWordsWith(async (language) => tileWordsFrom(language, language === "english"
10
+ ? (await import("./words.en.data.js")).TILE_WORDS_EN
11
+ : (await import("./words.ja.data.js")).TILE_WORDS_JA));
10
12
  /** The list, read from its module: `loadTileWords` for a caller with no browser.
11
13
  *
12
14
  * @param language - The English or Japanese tile language.
@@ -21,8 +21,32 @@
21
21
  * ```
22
22
  */
23
23
  export const TILE_MIX = {
24
- a: 13, b: 3, c: 3, d: 6, e: 18, f: 3, g: 4, h: 3, i: 12, j: 2, k: 2, l: 5, m: 3,
25
- n: 8, o: 11, p: 3, q: 2, r: 9, s: 6, t: 9, u: 6, v: 3, w: 3, x: 2, y: 3, z: 2,
24
+ a: 13,
25
+ b: 3,
26
+ c: 3,
27
+ d: 6,
28
+ e: 18,
29
+ f: 3,
30
+ g: 4,
31
+ h: 3,
32
+ i: 12,
33
+ j: 2,
34
+ k: 2,
35
+ l: 5,
36
+ m: 3,
37
+ n: 8,
38
+ o: 11,
39
+ p: 3,
40
+ q: 2,
41
+ r: 9,
42
+ s: 6,
43
+ t: 9,
44
+ u: 6,
45
+ v: 3,
46
+ w: 3,
47
+ x: 2,
48
+ y: 3,
49
+ z: 2,
26
50
  };
27
51
  /**
28
52
  * THE JAPANESE SET: 144 hiragana tiles in 45 kinds (`kana.ts`), as the English
@@ -45,11 +69,51 @@ export const TILE_MIX = {
45
69
  * ```
46
70
  */
47
71
  export const JAPANESE_TILE_MIX = {
48
- あ: 2, い: 11, う: 12, え: 1, お: 2, か: 7, き: 6, く: 6, け: 3, こ: 4,
49
- さ: 3, し: 10, す: 2, せ: 3, そ: 2, た: 4, ち: 3, つ: 6, て: 2, と: 3,
50
- な: 1, に: 1, ぬ: 1, ね: 1, の: 1, は: 2, ひ: 2, ふ: 2, へ: 1, ほ: 1,
51
- ま: 2, み: 1, む: 1, め: 1, も: 1, や: 2, ゆ: 4, よ: 6, ら: 1, り: 3,
52
- る: 3, れ: 1, ろ: 1, わ: 1, ん: 11,
72
+ あ: 2,
73
+ い: 11,
74
+ う: 12,
75
+ え: 1,
76
+ お: 2,
77
+ か: 7,
78
+ き: 6,
79
+ く: 6,
80
+ け: 3,
81
+ こ: 4,
82
+ さ: 3,
83
+ し: 10,
84
+ す: 2,
85
+ せ: 3,
86
+ そ: 2,
87
+ た: 4,
88
+ ち: 3,
89
+ つ: 6,
90
+ て: 2,
91
+ と: 3,
92
+ な: 1,
93
+ に: 1,
94
+ ぬ: 1,
95
+ ね: 1,
96
+ の: 1,
97
+ は: 2,
98
+ ひ: 2,
99
+ ふ: 2,
100
+ へ: 1,
101
+ ほ: 1,
102
+ ま: 2,
103
+ み: 1,
104
+ む: 1,
105
+ め: 1,
106
+ も: 1,
107
+ や: 2,
108
+ ゆ: 4,
109
+ よ: 6,
110
+ ら: 1,
111
+ り: 3,
112
+ る: 3,
113
+ れ: 1,
114
+ ろ: 1,
115
+ わ: 1,
116
+ ん: 11,
53
117
  };
54
118
  /** The whole set, counted from the table.
55
119
  *
@@ -203,7 +267,7 @@ export const KUMIMOJI_TRADE = { give: 1, take: 3 };
203
267
  * console.log(KUMIMOJI_SCORE);
204
268
  * ```
205
269
  */
206
- export const KUMIMOJI_SCORE = { tile: 10, slowestMsATile: 30000 };
270
+ export const KUMIMOJI_SCORE = { tile: 10, slowestMsATile: 30_000 };
207
271
  /**
208
272
  * PASS AND PLAY: up to eight people round one device, each with a hand and a
209
273
  * table of their own from one shared bag (`party.ts`). John, 2026-09-28: "pass
package/dist/turn.js CHANGED
@@ -92,7 +92,12 @@ export function unturnPlace(row, col, turn) {
92
92
  export function turnArea(area, turn) {
93
93
  const a = turnPlace(area.top, area.left, turn);
94
94
  const b = turnPlace(area.top + area.rows - 1, area.left + area.cols - 1, turn);
95
- 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 };
95
+ return {
96
+ top: Math.min(a.row, b.row),
97
+ left: Math.min(a.col, b.col),
98
+ rows: Math.abs(a.row - b.row) + 1,
99
+ cols: Math.abs(a.col - b.col) + 1,
100
+ };
96
101
  }
97
102
  /**
98
103
  * A view the player zoomed or panned, turned a quarter clockwise with the
package/dist/ui/board.js CHANGED
@@ -55,8 +55,22 @@ export function boardModel(tiles, verdict, glyphOf = (tile) => tileFace(tile).gl
55
55
  for (let col = left; col < left + cols; col += 1) {
56
56
  const square = squareAt(row, col);
57
57
  const tile = tiles.get(square) ?? null;
58
- const mark = tile === null || verdict === null ? null : verdict.misspelt.has(square) ? "misspelt" : verdict.apart.has(square) ? "apart" : "sound";
59
- squares.push({ square, row, col, tile, glyph: tile === null ? "" : glyphOf(tile), wild: tile !== null && tileFace(tile).wild, mark });
58
+ const mark = tile === null || verdict === null
59
+ ? null
60
+ : verdict.misspelt.has(square)
61
+ ? "misspelt"
62
+ : verdict.apart.has(square)
63
+ ? "apart"
64
+ : "sound";
65
+ squares.push({
66
+ square,
67
+ row,
68
+ col,
69
+ tile,
70
+ glyph: tile === null ? "" : glyphOf(tile),
71
+ wild: tile !== null && tileFace(tile).wild,
72
+ mark,
73
+ });
60
74
  }
61
75
  }
62
76
  return { rows, cols, squares };
@@ -0,0 +1,62 @@
1
+ import type { GridVerdict } from "../grid.ts";
2
+ import { type TilePlay } from "../play.ts";
3
+ import { type KumimojiLocale, type KumimojiStrings } from "../strings.ts";
4
+ import type { TileWords } from "../tileWords.ts";
5
+ /** What is picked up: a tile in the hand, or one on the table. */
6
+ export type Held = {
7
+ from: "hand";
8
+ at: number;
9
+ } | {
10
+ from: "table";
11
+ square: string;
12
+ } | null;
13
+ export declare function node<K extends keyof HTMLElementTagNameMap>(name: K, className?: string, text?: string): HTMLElementTagNameMap[K];
14
+ export declare function button(text: string, onClick: () => void, primary?: boolean, testId?: string): HTMLButtonElement;
15
+ /** The game as the drawing reads it, shared with the mount so a move and a redraw see the same table. */
16
+ export type TableSession = {
17
+ play: TilePlay | null;
18
+ finishedAt: number | null;
19
+ started: number;
20
+ words: TileWords | null;
21
+ held: Held;
22
+ onSquare: string | null;
23
+ onHand: number;
24
+ say: KumimojiStrings;
25
+ locale: KumimojiLocale;
26
+ note: string;
27
+ destroyed: boolean;
28
+ };
29
+ /** The elements the mount built once; a redraw fills them and never replaces them. */
30
+ export type TableParts = {
31
+ root: HTMLElement;
32
+ status: HTMLElement;
33
+ board: HTMLElement;
34
+ notes: HTMLElement;
35
+ hand: HTMLElement;
36
+ picker: HTMLElement;
37
+ controls: HTMLElement;
38
+ keepTitle: HTMLElement;
39
+ saveJson: HTMLButtonElement;
40
+ saveText: HTMLButtonElement;
41
+ load: HTMLButtonElement;
42
+ keepNote: HTMLElement;
43
+ };
44
+ /** What a button does to the game. The mount owns the rules; the drawing only asks. */
45
+ export type TableActions = {
46
+ verdictOf: (now: TilePlay) => GridVerdict;
47
+ change: (next: TilePlay) => void;
48
+ newGame: () => void;
49
+ };
50
+ /**
51
+ * Draw the table from the session and the elements the mount built: the status
52
+ * line, the board, the hand, the wild-tile picker, the play buttons and the
53
+ * save labels, then put the keyboard focus back on the control it was on.
54
+ * @param session - The game, the words and what is held, shared with the mount.
55
+ * @param parts - The elements the drawing fills.
56
+ * @param actions - How a button changes the game or starts another.
57
+ * @returns The redraw, and the status line alone for the clock.
58
+ */
59
+ export declare function tableDrawing(session: TableSession, parts: TableParts, actions: TableActions): {
60
+ render: () => void;
61
+ renderStatus: () => void;
62
+ };
@@ -0,0 +1,229 @@
1
+ import { kumimojiClock } from "../export.js";
2
+ import { assignHandTile, draw, liftAll, mayDraw, mayTrade, sortHand, tilesLeft, trade, } from "../play.js";
3
+ import { kumimojiSay } from "../strings.js";
4
+ import { tileFace } from "../tileFace.js";
5
+ import { boardModel } from "./board.js";
6
+ /** What a wild nobody has given a letter yet shows in the hand. */
7
+ const WILD_MARK = "★";
8
+ export function node(name, className, text) {
9
+ const made = document.createElement(name);
10
+ if (className !== undefined)
11
+ made.className = className;
12
+ if (text !== undefined)
13
+ made.textContent = text;
14
+ return made;
15
+ }
16
+ export function button(text, onClick, primary = false, testId) {
17
+ const made = node("button", primary ? "km-button km-primary" : "km-button", text);
18
+ made.type = "button";
19
+ if (testId !== undefined)
20
+ made.dataset.testid = testId;
21
+ made.addEventListener("click", onClick);
22
+ return made;
23
+ }
24
+ /**
25
+ * Draw the table from the session and the elements the mount built: the status
26
+ * line, the board, the hand, the wild-tile picker, the play buttons and the
27
+ * save labels, then put the keyboard focus back on the control it was on.
28
+ * @param session - The game, the words and what is held, shared with the mount.
29
+ * @param parts - The elements the drawing fills.
30
+ * @param actions - How a button changes the game or starts another.
31
+ * @returns The redraw, and the status line alone for the clock.
32
+ */
33
+ export function tableDrawing(session, parts, actions) {
34
+ function renderStatus() {
35
+ const now = session.play;
36
+ parts.root.dataset.state = now === null ? "dealing" : session.finishedAt !== null ? "finished" : "playing";
37
+ if (now === null) {
38
+ parts.status.textContent = session.say.dealing;
39
+ return;
40
+ }
41
+ const left = tilesLeft(now);
42
+ parts.root.dataset.left = String(left);
43
+ const time = kumimojiClock((session.finishedAt ?? Date.now()) - session.started);
44
+ parts.status.textContent =
45
+ session.finishedAt !== null
46
+ ? kumimojiSay(session.say.finished, { time })
47
+ : kumimojiSay(left === 1 ? session.say.leftOne : session.say.left, { n: left, time });
48
+ }
49
+ function renderBoard(verdict) {
50
+ const playing = session.play;
51
+ const words = session.words;
52
+ const model = boardModel(playing.tiles, playing.tiles.size === 0 ? null : verdict, words.glyphOf);
53
+ const grid = node("div", "km-grid");
54
+ grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
55
+ grid.setAttribute("role", "group");
56
+ grid.setAttribute("aria-label", session.say.tableLabel);
57
+ grid.setAttribute("aria-description", session.say.tableKeys);
58
+ grid.dataset.cols = String(model.cols);
59
+ const cells = [];
60
+ for (const square of model.squares) {
61
+ const cell = node("button", "km-square");
62
+ cell.type = "button";
63
+ cell.dataset.square = square.square;
64
+ if (square.tile !== null) {
65
+ cell.classList.add("km-tile");
66
+ if (square.wild)
67
+ cell.classList.add("km-wild");
68
+ if (square.mark !== null)
69
+ cell.classList.add(`km-${square.mark}`);
70
+ cell.textContent = square.glyph;
71
+ cell.dataset.tile = square.glyph;
72
+ cell.setAttribute("aria-label", square.wild ? kumimojiSay(session.say.wildTile, { tile: square.glyph }) : square.glyph);
73
+ }
74
+ else {
75
+ cell.setAttribute("aria-label", session.say.emptySquare);
76
+ }
77
+ if (session.held?.from === "table" && session.held.square === square.square)
78
+ cell.classList.add("km-held");
79
+ cell.tabIndex = -1;
80
+ cells.push(cell);
81
+ grid.append(cell);
82
+ }
83
+ // The one Tab lands on: where the keyboard was, else a tile, else the middle.
84
+ const stop = cells.find((cell) => cell.dataset.square === session.onSquare) ??
85
+ cells.find((cell) => cell.classList.contains("km-tile")) ??
86
+ cells[Math.floor(cells.length / 2)];
87
+ if (stop !== undefined) {
88
+ stop.tabIndex = 0;
89
+ session.onSquare = stop.dataset.square ?? null;
90
+ }
91
+ parts.board.replaceChildren(grid);
92
+ parts.notes.textContent =
93
+ verdict.notWords.length > 0
94
+ ? kumimojiSay(session.say.notWords, { words: verdict.notWords.join(", ") })
95
+ : verdict.apart.size > 0
96
+ ? session.say.apart
97
+ : "";
98
+ }
99
+ function renderHand() {
100
+ const playing = session.play;
101
+ const words = session.words;
102
+ parts.hand.replaceChildren();
103
+ parts.hand.setAttribute("aria-label", session.say.handLabel);
104
+ playing.hand.forEach((tile, at) => {
105
+ const face = tileFace(tile);
106
+ const made = node("button", "km-tile km-in-hand");
107
+ made.type = "button";
108
+ made.dataset.hand = String(at);
109
+ made.textContent = face.blank ? WILD_MARK : words.glyphOf(tile);
110
+ if (face.wild)
111
+ made.classList.add("km-wild");
112
+ if (session.held?.from === "hand" && session.held.at === at)
113
+ made.classList.add("km-held");
114
+ made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
115
+ made.setAttribute("aria-label", face.blank
116
+ ? session.say.wildBlank
117
+ : face.wild
118
+ ? kumimojiSay(session.say.wildTile, { tile: words.glyphOf(tile) })
119
+ : words.glyphOf(tile));
120
+ made.tabIndex = at === Math.min(session.onHand, playing.hand.length - 1) ? 0 : -1;
121
+ parts.hand.append(made);
122
+ });
123
+ if (playing.hand.length === 0 && session.finishedAt === null)
124
+ parts.hand.append(node("span", "km-empty", session.say.noTiles));
125
+ }
126
+ function renderPicker() {
127
+ parts.picker.replaceChildren();
128
+ if (session.held?.from !== "hand" || session.play === null)
129
+ return;
130
+ const at = session.held.at;
131
+ const tile = session.play.hand[at];
132
+ if (tile === undefined || !tileFace(tile).wild)
133
+ return;
134
+ parts.picker.append(node("span", "km-picker-title", tile === "*" ? session.say.giveWild : session.say.changeWild));
135
+ const words = session.words;
136
+ for (const sound of words.wildOptions) {
137
+ const code = words.wildFor(sound);
138
+ if (code === null)
139
+ continue;
140
+ parts.picker.append(button(sound, () => {
141
+ session.held = { from: "hand", at };
142
+ actions.change(assignHandTile(session.play, at, code));
143
+ }));
144
+ }
145
+ }
146
+ function renderControls(verdict) {
147
+ parts.controls.replaceChildren();
148
+ if (session.finishedAt !== null) {
149
+ parts.controls.append(button(session.say.newGame, () => actions.newGame(), true, "km-new"));
150
+ return;
151
+ }
152
+ const now = session.play;
153
+ const drawButton = button(session.say.draw, () => {
154
+ session.held = null;
155
+ actions.change(draw(now));
156
+ }, true, "km-draw");
157
+ drawButton.disabled = !mayDraw(now, verdict);
158
+ const tradeButton = button(session.say.trade, () => {
159
+ if (session.held?.from !== "hand")
160
+ return;
161
+ const at = session.held.at;
162
+ session.held = null;
163
+ actions.change(trade(now, at));
164
+ }, false, "km-trade");
165
+ tradeButton.disabled = session.held?.from !== "hand" || !mayTrade(now);
166
+ parts.controls.append(drawButton, tradeButton, button(session.say.sort, () => actions.change(sortHand(now)), false, "km-sort"), button(session.say.liftAll, () => {
167
+ session.held = null;
168
+ actions.change(liftAll(now));
169
+ }, false, "km-lift"), button(session.say.newGame, () => actions.newGame(), false, "km-new"));
170
+ }
171
+ function renderKeep() {
172
+ parts.keepTitle.textContent = session.say.keep;
173
+ parts.saveJson.textContent = session.say.saveJson;
174
+ parts.saveText.textContent = session.say.saveText;
175
+ parts.load.textContent = session.say.load;
176
+ parts.saveJson.disabled = session.play === null;
177
+ parts.saveText.disabled = session.play === null;
178
+ parts.keepNote.textContent = session.note;
179
+ }
180
+ /** Where the keyboard is, so that it can be put back after everything is drawn again: the zone, and which of it. */
181
+ function keyboardAt() {
182
+ const on = document.activeElement;
183
+ if (on === null || !parts.root.contains(on))
184
+ return null;
185
+ const mark = (zone, id) => id === undefined ? null : { zone, id };
186
+ if (parts.board.contains(on))
187
+ return mark("board", on.dataset.square);
188
+ if (parts.hand.contains(on))
189
+ return mark("hand", on.dataset.hand);
190
+ if (parts.controls.contains(on))
191
+ return mark("controls", on.dataset.testid);
192
+ if (parts.picker.contains(on))
193
+ return mark("picker", String([...parts.picker.querySelectorAll("button")].indexOf(on)));
194
+ return null;
195
+ }
196
+ function keyboardBack(at) {
197
+ if (at === null)
198
+ return;
199
+ const zone = { board: parts.board, hand: parts.hand, controls: parts.controls, picker: parts.picker }[at.zone];
200
+ const found = at.zone === "board"
201
+ ? zone.querySelector(`[data-square="${at.id}"]`)
202
+ : at.zone === "hand"
203
+ ? zone.querySelector(`[data-hand="${at.id}"]`)
204
+ : at.zone === "controls"
205
+ ? zone.querySelector(`[data-testid="${at.id}"]`)
206
+ : zone.querySelectorAll("button")[Number(at.id)];
207
+ // A button that has become disabled cannot hold the focus; the table keeps it rather than dropping it on the page.
208
+ if (found !== undefined && found !== null && !found.disabled)
209
+ found.focus({ preventScroll: true });
210
+ }
211
+ function render() {
212
+ if (session.destroyed)
213
+ return;
214
+ const where = keyboardAt();
215
+ parts.root.lang = session.locale;
216
+ renderStatus();
217
+ renderKeep();
218
+ const now = session.play;
219
+ if (now === null || session.words === null)
220
+ return;
221
+ const verdict = actions.verdictOf(now);
222
+ renderBoard(verdict);
223
+ renderPicker();
224
+ renderHand();
225
+ renderControls(verdict);
226
+ keyboardBack(where);
227
+ }
228
+ return { render, renderStatus };
229
+ }