@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,119 @@
1
+ import { seededRandom, shuffled } from "./random.js";
2
+ import { computerName, isComputer, nextIn, partyTilesLeft, stillIn, tableTally, tidyName } from "./party.js";
3
+ import { tileFace } from "./tileFace.js";
4
+ import { KUMIMOJI_PARTY } from "./tiles.constants.js";
5
+ export function joinRefused(game) {
6
+ if (game.ending !== null)
7
+ return "over";
8
+ if (game.players.length >= KUMIMOJI_PARTY.most)
9
+ return "full";
10
+ if (game.lastTurns !== null)
11
+ return "lastRound";
12
+ if (partyTilesLeft(game) < game.settings.size)
13
+ return "bag";
14
+ return null;
15
+ }
16
+ /** A player, or a computer, sat down after the last seat with the next hand out of the bag. */
17
+ export function joinParty(game, seat) {
18
+ if (joinRefused(game) !== null)
19
+ return game;
20
+ const hand = [...(game.bag + game.returned).slice(game.taken, game.taken + game.settings.size)];
21
+ const tiles = new Map();
22
+ const player = seat.computer === true ? { name: computerName(game.players), computer: true, hand, tiles } : { name: tidyName(seat.name) || freePlayerName(game.players), hand, tiles };
23
+ return { ...game, players: [...game.players, player], taken: game.taken + hand.length };
24
+ }
25
+ /** "Player" and the next seat's number, or the lowest number nobody at the table goes by. */
26
+ function freePlayerName(players) {
27
+ const taken = new Set(players.map((player, at) => player.name || `Player ${at + 1}`));
28
+ const next = `Player ${players.length + 1}`;
29
+ if (!taken.has(next))
30
+ return next;
31
+ for (let number = 1;; number += 1)
32
+ if (!taken.has(`Player ${number}`))
33
+ return `Player ${number}`;
34
+ }
35
+ export function leaveRefused(game, at) {
36
+ if (game.ending !== null || game.players[at] === undefined)
37
+ return "over";
38
+ if (game.out.includes(at))
39
+ return "out";
40
+ if (!isComputer(game, at) && game.players.filter((player) => player.computer !== true).length <= 1)
41
+ return "lastPerson";
42
+ return null;
43
+ }
44
+ /** How many tiles a player would put back in the bag by leaving: their hand and their table. */
45
+ export function tilesHeldBy(game, at) {
46
+ const player = game.players[at];
47
+ return player === undefined ? 0 : player.hand.length + player.tiles.size;
48
+ }
49
+ /**
50
+ * A PLAYER LEAVES. Their tiles go back into the bag and the bag is shuffled
51
+ * — with the game's own seed and how far it has gone, so the same game left
52
+ * the same way deals the same tiles after a reload. A wild goes back blank.
53
+ *
54
+ * The bag is kept as a line whose first `taken` tiles are the ones held, less
55
+ * what was traded back (`holdsItsBag`); after a leave it is written afresh as
56
+ * exactly that: the tiles everybody still holds, then the shuffled rest, with
57
+ * nothing traded back. Every seat after the leaver's moves up one, and the
58
+ * unnamed players keep the names they were shown ("Player 3" stays Player 3).
59
+ *
60
+ * If it was their turn, the turn passes to the next still in, or in the last
61
+ * round to the next still to have their last turn; a last round with nobody
62
+ * left to play ends. With nobody still in — everybody left has resigned —
63
+ * the game ends tied between them.
64
+ */
65
+ export function leaveParty(game, at) {
66
+ if (leaveRefused(game, at) !== null)
67
+ return game;
68
+ const leaver = game.players[at];
69
+ const back = [...leaver.hand, ...leaver.tiles.values()].map((tile) => (tileFace(tile).wild ? "*" : tile));
70
+ const players = game.players.flatMap((player, seat) => (seat === at ? [] : [{ ...player, name: player.name || `Player ${seat + 1}` }]));
71
+ const seatOf = (seat) => (seat > at ? seat - 1 : seat);
72
+ const without = (seats) => seats.filter((seat) => seat !== at).map(seatOf);
73
+ const held = players.flatMap((player) => [...player.hand, ...player.tiles.values()]);
74
+ const rest = [...(game.bag + game.returned).slice(game.taken), ...back];
75
+ const pot = shuffled(rest, seededRandom(potSeed(game)));
76
+ const lastTurns = game.lastTurns === null ? null : without(game.lastTurns);
77
+ let turn = seatOf(game.turn);
78
+ let ending = null;
79
+ if (at === game.turn) {
80
+ if (lastTurns !== null) {
81
+ // The leaver was first of the last round's list: the next on it plays, or the round is over.
82
+ if (lastTurns.length > 0)
83
+ turn = lastTurns[0];
84
+ else {
85
+ turn = seatOf(game.out[0] ?? 0);
86
+ ending = "out";
87
+ }
88
+ }
89
+ else {
90
+ const next = nextIn(game, at);
91
+ turn = next === at ? 0 : seatOf(next);
92
+ }
93
+ }
94
+ const after = {
95
+ ...game,
96
+ bag: held.join("") + pot.join(""),
97
+ returned: "",
98
+ taken: held.length,
99
+ players,
100
+ turn,
101
+ turns: game.turns + 1,
102
+ out: without(game.out),
103
+ lastTurns,
104
+ resigned: without(game.resigned),
105
+ resignRun: without(game.resignRun),
106
+ ending,
107
+ passedBy: game.passedBy === null || game.passedBy === at ? null : seatOf(game.passedBy),
108
+ };
109
+ const moved = at === game.turn ? { startTable: tableTally(players[turn].tiles), traded: false } : {};
110
+ if (ending === null && lastTurns === null && stillIn(after).length === 0) {
111
+ // Only players who resigned are left: tied, the last run of them, or all of them where that run has left.
112
+ return { ...after, ...moved, ending: "tied", resignRun: after.resignRun.length > 0 ? after.resignRun : after.resigned };
113
+ }
114
+ return { ...after, ...moved };
115
+ }
116
+ /** The seed a leave shuffles with: the game's own, stirred by how far it has gone. */
117
+ function potSeed(game) {
118
+ return (game.settings.seed ^ Math.imul(game.turns + 1, 0x9e3779b1) ^ Math.imul(game.taken + 1, 0x85ebca6b)) >>> 0;
119
+ }
@@ -0,0 +1,75 @@
1
+ import type { GridVerdict } from "./grid.ts";
2
+ import type { PartyGame } from "./party.types.ts";
3
+ import type { TileWords } from "./tileWords.ts";
4
+ /**
5
+ * HOW A PASS-AND-PLAY TURN ENDS, AND HOW THE GAME DOES: Done, going out, the
6
+ * last round, resigning, and who won. The rules of the table itself are the
7
+ * solo game's (`play.ts`), and the deal and the draw are `party.ts`'s. Pure:
8
+ * each returns a new game, or the one it was given where the move is refused.
9
+ */
10
+ /** Whether the game is over. */
11
+ export declare function isOver(game: PartyGame): boolean;
12
+ /**
13
+ * Whether the player whose turn it is would go out with Done: their hand
14
+ * used, their grid sound, and too few tiles in the bag for everybody still in
15
+ * to draw.
16
+ */
17
+ export declare function goesOut(game: PartyGame, verdict: GridVerdict): boolean;
18
+ /** Whether the player whose turn it is has laid a tile on their table this turn: more tiles there than when it began. */
19
+ export declare function laidThisTurn(game: PartyGame): boolean;
20
+ /** Whether the player whose turn it is is the only one still in: everybody else has resigned. */
21
+ export declare function lastStanding(game: PartyGame): boolean;
22
+ /**
23
+ * A HAND THAT SPELLS NOTHING MUST BE TRADED. John, 2026-09-28: "If no words
24
+ * can be formed which is possible, they must dump a tile to get 3 more. Easy
25
+ * rule." So Done waits while the player whose turn it is holds tiles that
26
+ * spell no word, has laid nothing on (or lifted nothing from) their table this
27
+ * turn, and has not traded — unless the bag cannot give three, when there is
28
+ * nothing to trade for. `handSpells` answers for a hand (`handCanSpell`).
29
+ */
30
+ export declare function mustTradeFirst(game: PartyGame, handSpells: (hand: readonly string[]) => boolean): boolean;
31
+ /** Whether a hand can spell a word from the list: any word of two tiles or more, a wild counting as able to be anything. */
32
+ export declare function handCanSpell(hand: readonly string[], words: TileWords): boolean;
33
+ /**
34
+ * Why Done cannot be pressed now, or null when it can: a hand to trade first,
35
+ * or, for the last one standing, no tile laid on a sound grid yet (a hand
36
+ * already used on a sound grid has nothing left to lay, and wins as it is).
37
+ */
38
+ export declare function doneRefused(game: PartyGame, verdict: GridVerdict, handSpells: (hand: readonly string[]) => boolean): "trade" | "standing" | null;
39
+ /**
40
+ * DONE: the end of a turn, and the device passed on. Before anybody is out,
41
+ * to the next player still in. A player who goes out with it starts the last
42
+ * round: everybody else still in, in order from the next, has one last turn,
43
+ * and any of them who goes out on it shares the win (John, 2026-09-28:
44
+ * "Sharing the win is correct"). After the last of those, the game is over.
45
+ *
46
+ * The last one standing, everybody else resigned, wins by laying a tile on a
47
+ * sound grid and pressing Done. `verdict` is the grid of the player whose turn
48
+ * it is. Refused, the game given back as it was, while `doneRefused` says so.
49
+ */
50
+ export declare function endTurn(game: PartyGame, verdict: GridVerdict, handSpells: (hand: readonly string[]) => boolean): PartyGame;
51
+ /**
52
+ * Whether the player whose turn it is may resign: only once the bag cannot
53
+ * give a trade, so nothing more can be got from it. John, 2026-09-28:
54
+ * "Perhaps we offer a resign button only when there are no remaining tiles
55
+ * they can get from the bag."
56
+ */
57
+ export declare function mayResign(game: PartyGame): boolean;
58
+ /**
59
+ * RESIGN: the player whose turn it is is out of the game, their table kept
60
+ * for the finish, and their turn passed over from then on. In the last round
61
+ * it is their last turn ended without going out. Otherwise the game goes on
62
+ * with whoever is still in; and when nobody is — the last one standing
63
+ * resigned too — it ends tied between everybody who resigned since a player
64
+ * still in last pressed Done. John: "if you resign, as the turns pass, if
65
+ * someone else can't go and has to resign then they are tied."
66
+ */
67
+ export declare function resign(game: PartyGame): PartyGame;
68
+ /**
69
+ * The winners of a finished game, by place: everybody who went out; the last
70
+ * one standing; or, tied, the final run of resignations. Empty while it is
71
+ * played.
72
+ */
73
+ export declare function winnersOf(game: PartyGame): readonly number[];
74
+ /** Whether the turn now being played is a player's last. */
75
+ export declare function isLastTurn(game: PartyGame): boolean;
@@ -0,0 +1,153 @@
1
+ import { wordsInHand } from "./help.js";
2
+ import { nextIn, partyTilesLeft, seatPlay, stillIn, tableTally } from "./party.js";
3
+ import { mayTrade } from "./play.js";
4
+ import { KUMIMOJI_TRADE } from "./tiles.constants.js";
5
+ /**
6
+ * HOW A PASS-AND-PLAY TURN ENDS, AND HOW THE GAME DOES: Done, going out, the
7
+ * last round, resigning, and who won. The rules of the table itself are the
8
+ * solo game's (`play.ts`), and the deal and the draw are `party.ts`'s. Pure:
9
+ * each returns a new game, or the one it was given where the move is refused.
10
+ */
11
+ /** Whether the game is over. */
12
+ export function isOver(game) {
13
+ return game.ending !== null;
14
+ }
15
+ /**
16
+ * Whether the player whose turn it is would go out with Done: their hand
17
+ * used, their grid sound, and too few tiles in the bag for everybody still in
18
+ * to draw.
19
+ */
20
+ export function goesOut(game, verdict) {
21
+ return !isOver(game) && game.players[game.turn].hand.length === 0 && verdict.sound && partyTilesLeft(game) < stillIn(game).length;
22
+ }
23
+ /** Whether the player whose turn it is has laid a tile on their table this turn: more tiles there than when it began. */
24
+ export function laidThisTurn(game) {
25
+ return game.players[game.turn].tiles.size > [...game.startTable].length;
26
+ }
27
+ /** Whether the player whose turn it is is the only one still in: everybody else has resigned. */
28
+ export function lastStanding(game) {
29
+ return !isOver(game) && game.lastTurns === null && stillIn(game).length === 1;
30
+ }
31
+ /**
32
+ * A HAND THAT SPELLS NOTHING MUST BE TRADED. John, 2026-09-28: "If no words
33
+ * can be formed which is possible, they must dump a tile to get 3 more. Easy
34
+ * rule." So Done waits while the player whose turn it is holds tiles that
35
+ * spell no word, has laid nothing on (or lifted nothing from) their table this
36
+ * turn, and has not traded — unless the bag cannot give three, when there is
37
+ * nothing to trade for. `handSpells` answers for a hand (`handCanSpell`).
38
+ */
39
+ export function mustTradeFirst(game, handSpells) {
40
+ if (isOver(game) || game.traded)
41
+ return false;
42
+ const play = seatPlay(game);
43
+ if (play.hand.length === 0 || tableTally(play.tiles) !== game.startTable || !mayTrade(play))
44
+ return false;
45
+ return !handSpells(play.hand);
46
+ }
47
+ /** Whether a hand can spell a word from the list: any word of two tiles or more, a wild counting as able to be anything. */
48
+ export function handCanSpell(hand, words) {
49
+ return hand.some(words.isWild) || wordsInHand(hand, words).length > 0;
50
+ }
51
+ /**
52
+ * Why Done cannot be pressed now, or null when it can: a hand to trade first,
53
+ * or, for the last one standing, no tile laid on a sound grid yet (a hand
54
+ * already used on a sound grid has nothing left to lay, and wins as it is).
55
+ */
56
+ export function doneRefused(game, verdict, handSpells) {
57
+ if (mustTradeFirst(game, handSpells))
58
+ return "trade";
59
+ const used = game.players[game.turn].hand.length === 0;
60
+ if (lastStanding(game) && !(verdict.sound && (laidThisTurn(game) || used)))
61
+ return "standing";
62
+ return null;
63
+ }
64
+ /** The game passed to `turn`, a new turn with nothing traded and its table as it stands. */
65
+ function passTo(game, turn, rest) {
66
+ return { ...game, ...rest, turns: game.turns + 1, turn, traded: false, startTable: tableTally(game.players[turn].tiles), passedBy: game.turn };
67
+ }
68
+ /**
69
+ * DONE: the end of a turn, and the device passed on. Before anybody is out,
70
+ * to the next player still in. A player who goes out with it starts the last
71
+ * round: everybody else still in, in order from the next, has one last turn,
72
+ * and any of them who goes out on it shares the win (John, 2026-09-28:
73
+ * "Sharing the win is correct"). After the last of those, the game is over.
74
+ *
75
+ * The last one standing, everybody else resigned, wins by laying a tile on a
76
+ * sound grid and pressing Done. `verdict` is the grid of the player whose turn
77
+ * it is. Refused, the game given back as it was, while `doneRefused` says so.
78
+ */
79
+ export function endTurn(game, verdict, handSpells) {
80
+ if (isOver(game) || doneRefused(game, verdict, handSpells) !== null)
81
+ return game;
82
+ if (lastStanding(game))
83
+ return passTo(game, game.turn, { ending: "standing", resignRun: [] });
84
+ const out = goesOut(game, verdict) ? [...game.out, game.turn] : game.out;
85
+ if (game.lastTurns === null) {
86
+ if (out.length === 0)
87
+ return passTo(game, nextIn(game, game.turn), { resignRun: [] });
88
+ const lastTurns = stillIn(game).filter((at) => at !== game.turn).sort((a, b) => fromTurn(game, a) - fromTurn(game, b));
89
+ if (lastTurns.length === 0)
90
+ return passTo(game, game.turn, { out, lastTurns, ending: "out" });
91
+ return passTo(game, lastTurns[0], { out, lastTurns, resignRun: [] });
92
+ }
93
+ return nextLastTurn(game, out);
94
+ }
95
+ /** How far round the table from the player whose turn it is. */
96
+ function fromTurn(game, at) {
97
+ return (at - game.turn + game.players.length) % game.players.length;
98
+ }
99
+ /** The last round moved on past the player whose turn it was: to the next still to play, or the end. */
100
+ function nextLastTurn(game, out, rest = {}) {
101
+ const lastTurns = (game.lastTurns ?? []).filter((at) => at !== game.turn);
102
+ if (lastTurns.length === 0)
103
+ return passTo(game, game.turn, { ...rest, out, lastTurns, ending: "out" });
104
+ return passTo(game, lastTurns[0], { ...rest, out, lastTurns });
105
+ }
106
+ /**
107
+ * Whether the player whose turn it is may resign: only once the bag cannot
108
+ * give a trade, so nothing more can be got from it. John, 2026-09-28:
109
+ * "Perhaps we offer a resign button only when there are no remaining tiles
110
+ * they can get from the bag."
111
+ */
112
+ export function mayResign(game) {
113
+ return !isOver(game) && partyTilesLeft(game) < KUMIMOJI_TRADE.take;
114
+ }
115
+ /**
116
+ * RESIGN: the player whose turn it is is out of the game, their table kept
117
+ * for the finish, and their turn passed over from then on. In the last round
118
+ * it is their last turn ended without going out. Otherwise the game goes on
119
+ * with whoever is still in; and when nobody is — the last one standing
120
+ * resigned too — it ends tied between everybody who resigned since a player
121
+ * still in last pressed Done. John: "if you resign, as the turns pass, if
122
+ * someone else can't go and has to resign then they are tied."
123
+ */
124
+ export function resign(game) {
125
+ if (!mayResign(game))
126
+ return game;
127
+ const resigned = [...game.resigned, game.turn];
128
+ const resignRun = [...game.resignRun, game.turn];
129
+ const after = { ...game, resigned, resignRun };
130
+ if (game.lastTurns !== null)
131
+ return nextLastTurn(after, game.out);
132
+ if (stillIn(after).length === 0)
133
+ return passTo(after, game.turn, { ending: "tied" });
134
+ return passTo(after, nextIn(after, game.turn), {});
135
+ }
136
+ /**
137
+ * The winners of a finished game, by place: everybody who went out; the last
138
+ * one standing; or, tied, the final run of resignations. Empty while it is
139
+ * played.
140
+ */
141
+ export function winnersOf(game) {
142
+ if (game.ending === "out")
143
+ return game.out;
144
+ if (game.ending === "standing")
145
+ return stillIn(game);
146
+ if (game.ending === "tied")
147
+ return game.resignRun;
148
+ return [];
149
+ }
150
+ /** Whether the turn now being played is a player's last. */
151
+ export function isLastTurn(game) {
152
+ return !isOver(game) && game.lastTurns !== null;
153
+ }
@@ -0,0 +1,26 @@
1
+ export type Square = {
2
+ row: number;
3
+ col: number;
4
+ };
5
+ /** A word's place: its first square, and the squares it lays a new tile on, in the word's order. */
6
+ export type CrossingFit = {
7
+ first: Square;
8
+ fresh: Square[];
9
+ };
10
+ /**
11
+ * Where `word` stands with its letter `at` on the tile at `anchor`, running
12
+ * across or down, or null where it cannot: a square outside what may be used,
13
+ * a tile at either end, a different letter in its way, a new tile with a
14
+ * neighbour at its side, or no new tile at all — and, given `diagonalWord`
15
+ * (a game with Diagonals), a new tile standing in a diagonal run of three or
16
+ * more that `diagonalWord` does not take (`diagonalsRead`).
17
+ */
18
+ export declare function crossingFit(letterAt: (row: number, col: number) => string, inside: (row: number, col: number) => boolean, word: string, anchor: Square, at: number, across: boolean, diagonalWord?: ((run: string) => boolean) | null): CrossingFit | null;
19
+ /**
20
+ * Whether every diagonal run a laying's new tiles would stand in is taken by
21
+ * `isWord`: each walked from its top end down, with the word's own letters on
22
+ * its new squares. Only a run through a new tile can change, and two new
23
+ * tiles of one word never share a diagonal (they share a row or a column), so
24
+ * each is read on its own. A run of two, a corner touch, is never read.
25
+ */
26
+ export declare function diagonalsRead(letterAt: (row: number, col: number) => string, inside: (row: number, col: number) => boolean, word: string, fit: CrossingFit, isWord: (run: string) => boolean): boolean;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * WHERE A WORD CAN CROSS A CROSSWORD WITHOUT SPOILING IT: the one rule the
3
+ * generator lays its bags by (`generate.ts`) and the computer player lays its
4
+ * own table by (`computerPlay.ts`), written once.
5
+ *
6
+ * A word laid across or down through a tile already down, with nothing at
7
+ * either end of it and no new tile touching anything at its sides, makes one
8
+ * new run — the word itself — and leaves every other run as it was. So on a
9
+ * grid that was sound, the grid after it is sound too: that is what lets the
10
+ * generator promise every bag can be finished, and what keeps the computer
11
+ * from ever leaving a misspelt table.
12
+ *
13
+ * With DIAGONALS (`diagonalWord`), a new tile may also make or lengthen a
14
+ * diagonal run of three or more (`DIAGONAL_RUN_LEAST`), which is then read
15
+ * too; a word is laid only where each such run is a word, so a sound grid
16
+ * stays sound under that rule as well.
17
+ *
18
+ * Pure, and blind to what the squares are kept in: the caller answers what
19
+ * letter stands at a square (`""` for none) and whether a square may be used
20
+ * at all, so the generator's fixed laying square and the computer's table
21
+ * with no edges ask the same question.
22
+ */
23
+ import { DIAGONAL_RUN_LEAST } from "./grid.js";
24
+ /**
25
+ * Where `word` stands with its letter `at` on the tile at `anchor`, running
26
+ * across or down, or null where it cannot: a square outside what may be used,
27
+ * a tile at either end, a different letter in its way, a new tile with a
28
+ * neighbour at its side, or no new tile at all — and, given `diagonalWord`
29
+ * (a game with Diagonals), a new tile standing in a diagonal run of three or
30
+ * more that `diagonalWord` does not take (`diagonalsRead`).
31
+ */
32
+ export function crossingFit(letterAt, inside, word, anchor, at, across, diagonalWord = null) {
33
+ const dr = across ? 0 : 1;
34
+ const dc = across ? 1 : 0;
35
+ const row = anchor.row - at * dr;
36
+ const col = anchor.col - at * dc;
37
+ const length = word.length;
38
+ if (!inside(row, col) || !inside(row + (length - 1) * dr, col + (length - 1) * dc))
39
+ return null;
40
+ const taken = (r, c) => inside(r, c) && letterAt(r, c) !== "";
41
+ if (taken(row - dr, col - dc) || taken(row + length * dr, col + length * dc))
42
+ return null;
43
+ const fresh = [];
44
+ for (let k = 0; k < length; k += 1) {
45
+ const r = row + k * dr;
46
+ const c = col + k * dc;
47
+ const there = letterAt(r, c);
48
+ if (there !== "") {
49
+ if (there !== word[k])
50
+ return null;
51
+ continue;
52
+ }
53
+ // Its sides: above and below a word across, left and right of one down.
54
+ if (taken(r - dc, c - dr) || taken(r + dc, c + dr))
55
+ return null;
56
+ fresh.push({ row: r, col: c });
57
+ }
58
+ if (fresh.length === 0)
59
+ return null;
60
+ if (diagonalWord !== null && !diagonalsRead(letterAt, inside, word, { first: { row, col }, fresh }, diagonalWord))
61
+ return null;
62
+ return { first: { row, col }, fresh };
63
+ }
64
+ /**
65
+ * Whether every diagonal run a laying's new tiles would stand in is taken by
66
+ * `isWord`: each walked from its top end down, with the word's own letters on
67
+ * its new squares. Only a run through a new tile can change, and two new
68
+ * tiles of one word never share a diagonal (they share a row or a column), so
69
+ * each is read on its own. A run of two, a corner touch, is never read.
70
+ */
71
+ export function diagonalsRead(letterAt, inside, word, fit, isWord) {
72
+ const freshAt = new Map(fit.fresh.map((square) => [`${square.row},${square.col}`, word[(square.row - fit.first.row) + (square.col - fit.first.col)]]));
73
+ const at = (row, col) => (inside(row, col) ? letterAt(row, col) || (freshAt.get(`${row},${col}`) ?? "") : "");
74
+ for (const { row, col } of fit.fresh) {
75
+ for (const lean of [1, -1]) {
76
+ let top = 0;
77
+ while (at(row - top - 1, col - (top + 1) * lean) !== "")
78
+ top += 1;
79
+ let run = "";
80
+ for (let step = -top; at(row + step, col + step * lean) !== ""; step += 1)
81
+ run += at(row + step, col + step * lean);
82
+ if (run.length >= DIAGONAL_RUN_LEAST && !isWord(run))
83
+ return false;
84
+ }
85
+ }
86
+ return true;
87
+ }
package/dist/play.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { type GridVerdict, type Tiles } from "./grid.ts";
2
+ /**
3
+ * A KUMIMOJI BEING PLAYED: the bag, the tiles traded back into it, how many
4
+ * have been taken out, the tiles on the table and the hand. Every move is a
5
+ * function that returns a new state and leaves the one it was given alone,
6
+ * the way the engine's moves are. Nothing here knows how the table is being
7
+ * looked at — zoom and pan are the view's (`tableView.ts`), never the game's.
8
+ *
9
+ * THE BAG IS A LINE, not a heap. Its tiles come out in the order the seed dealt
10
+ * them (`givens`), and a tile traded back joins the END of the line, so the
11
+ * tiles still to come are always `bag + returned` from `taken` on. That keeps
12
+ * a game the same for everybody at a seed, trades and all, and lets a kept
13
+ * game be checked: what is held — hand and table — is exactly what was taken,
14
+ * less what was given back.
15
+ */
16
+ export type TilePlay = {
17
+ /** The tiles in the order they come out: the puzzle's givens. */
18
+ bag: string;
19
+ /** Tiles traded back, in order; they come out after the bag. */
20
+ returned: string;
21
+ /** How many tiles have been taken from `bag + returned`. */
22
+ taken: number;
23
+ /** The tiles on the table, by square ("row,col"). */
24
+ tiles: Tiles;
25
+ /** The hand, in the order the player keeps it. */
26
+ hand: readonly string[];
27
+ };
28
+ /** The opening hand: the first `handSize` tiles of the bag, and an empty table. */
29
+ export declare function deal(bag: string, handSize: number): TilePlay;
30
+ /** How many tiles are still in the bag. */
31
+ export declare function tilesLeft(play: TilePlay): number;
32
+ /** A tile from the hand onto an empty square. */
33
+ export declare function placeFromHand(play: TilePlay, handAt: number, square: string): TilePlay;
34
+ /** Give a hand tile its chosen reading, without changing which physical tile it is. */
35
+ export declare function assignHandTile(play: TilePlay, handAt: number, face: string): TilePlay;
36
+ /** Give a tile on the table its chosen reading, without changing its square. */
37
+ export declare function assignTableTile(play: TilePlay, square: string, face: string): TilePlay;
38
+ /** A tile on the table to another square: to an empty one it moves, onto a tile the two change places. */
39
+ export declare function moveOnTable(play: TilePlay, from: string, to: string): TilePlay;
40
+ /** A tile from the hand onto a square that holds one: they change places, the table's tile going to the hand where the other was. */
41
+ export declare function swapWithHand(play: TilePlay, handAt: number, square: string): TilePlay;
42
+ /** A tile on the table back to the end of the hand. */
43
+ export declare function liftToHand(play: TilePlay, square: string): TilePlay;
44
+ /** Every tile on the table back to the hand. */
45
+ export declare function liftAll(play: TilePlay): TilePlay;
46
+ /**
47
+ * The hand in order (John, 2026-09-28: "it gets sorted for letters
48
+ * alphabetically"): English A to Z, Japanese あいうえお, which is the order
49
+ * of the kana's tile codes, and the wilds last as they came.
50
+ */
51
+ export declare function sortHand(play: TilePlay): TilePlay;
52
+ /** Whether Draw may be pressed: the hand used, the grid sound, and a tile left to draw. */
53
+ export declare function mayDraw(play: TilePlay, verdict: GridVerdict): boolean;
54
+ /** The next tile or tiles out of the bag into the hand. */
55
+ export declare function draw(play: TilePlay, count?: number): TilePlay;
56
+ /** Whether a trade may be made: the bag holds as many as a trade takes. */
57
+ export declare function mayTrade(play: TilePlay): boolean;
58
+ /** One tile from the hand to the bottom of the bag, and the next three out of it. */
59
+ export declare function trade(play: TilePlay, handAt: number): TilePlay;
60
+ /** Whether the game is over: the bag empty, the hand used, and the grid sound. */
61
+ export declare function isFinished(play: TilePlay, verdict: GridVerdict): boolean;
62
+ export declare function encodeTileProgress(play: TilePlay): string;
63
+ /** The parts of a kept game, or null for a string that is not one: its shape only, with no bag to check it against. */
64
+ export declare function readTileProgress(code: string): {
65
+ taken: number;
66
+ returned: string;
67
+ hand: string[];
68
+ tiles: Map<string, string>;
69
+ } | null;
70
+ /**
71
+ * A kept game opened again on its own bag, or null where it does not belong
72
+ * to it: every tile held — hand and table — must be one taken from the bag
73
+ * and not given back. Null opens the game fresh rather than on a grid that
74
+ * never came out of this bag.
75
+ */
76
+ export declare function decodeTileProgress(code: string, bag: string, language?: "english" | "japanese"): TilePlay | null;