@mideind/netskrafl-react 1.0.0-beta.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 (68) hide show
  1. package/.eslintignore +8 -0
  2. package/.eslintrc.json +13 -0
  3. package/README.md +63 -0
  4. package/dist/cjs/index.css +6837 -0
  5. package/dist/cjs/index.js +3046 -0
  6. package/dist/cjs/index.js.map +1 -0
  7. package/dist/esm/index.css +6837 -0
  8. package/dist/esm/index.js +3046 -0
  9. package/dist/esm/index.js.map +1 -0
  10. package/dist/types.d.ts +41 -0
  11. package/package.json +63 -0
  12. package/rollup.config.js +60 -0
  13. package/src/components/index.ts +2 -0
  14. package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
  15. package/src/components/netskrafl/Netskrafl.tsx +135 -0
  16. package/src/components/netskrafl/Netskrafl.types.ts +7 -0
  17. package/src/components/netskrafl/index.ts +2 -0
  18. package/src/css/fonts.css +4 -0
  19. package/src/css/glyphs.css +224 -0
  20. package/src/css/skrafl-explo.css +6616 -0
  21. package/src/fonts/glyphicons-regular.eot +0 -0
  22. package/src/fonts/glyphicons-regular.ttf +0 -0
  23. package/src/fonts/glyphicons-regular.woff +0 -0
  24. package/src/index.ts +2 -0
  25. package/src/messages/messages.json +1576 -0
  26. package/src/mithril/actions.ts +319 -0
  27. package/src/mithril/bag.ts +65 -0
  28. package/src/mithril/bestdisplay.ts +74 -0
  29. package/src/mithril/blankdialog.ts +94 -0
  30. package/src/mithril/board.ts +336 -0
  31. package/src/mithril/buttons.ts +303 -0
  32. package/src/mithril/challengedialog.ts +186 -0
  33. package/src/mithril/channel.ts +162 -0
  34. package/src/mithril/chat.ts +228 -0
  35. package/src/mithril/components.ts +496 -0
  36. package/src/mithril/dragdrop.ts +219 -0
  37. package/src/mithril/elopage.ts +180 -0
  38. package/src/mithril/friend.ts +227 -0
  39. package/src/mithril/game.ts +1378 -0
  40. package/src/mithril/gameview.ts +111 -0
  41. package/src/mithril/globalstate.ts +33 -0
  42. package/src/mithril/i18n.ts +186 -0
  43. package/src/mithril/localstorage.ts +133 -0
  44. package/src/mithril/login.ts +122 -0
  45. package/src/mithril/logo.ts +270 -0
  46. package/src/mithril/main.ts +737 -0
  47. package/src/mithril/mithril.ts +29 -0
  48. package/src/mithril/model.ts +817 -0
  49. package/src/mithril/movelistitem.ts +226 -0
  50. package/src/mithril/page.ts +852 -0
  51. package/src/mithril/playername.ts +91 -0
  52. package/src/mithril/promodialog.ts +82 -0
  53. package/src/mithril/recentlist.ts +148 -0
  54. package/src/mithril/request.ts +52 -0
  55. package/src/mithril/review.ts +634 -0
  56. package/src/mithril/rightcolumn.ts +398 -0
  57. package/src/mithril/searchbutton.ts +118 -0
  58. package/src/mithril/statsdisplay.ts +109 -0
  59. package/src/mithril/tabs.ts +169 -0
  60. package/src/mithril/tile.ts +145 -0
  61. package/src/mithril/twoletter.ts +76 -0
  62. package/src/mithril/types.ts +379 -0
  63. package/src/mithril/userinfodialog.ts +171 -0
  64. package/src/mithril/util.ts +304 -0
  65. package/src/mithril/wait.ts +246 -0
  66. package/src/mithril/wordcheck.ts +102 -0
  67. package/tsconfig.json +28 -0
  68. package/vite.config.ts +12 -0
@@ -0,0 +1,1378 @@
1
+ /*
2
+
3
+ Game.ts
4
+
5
+ The Game class, as used in the single-page UI
6
+
7
+ Copyright (C) 2024 Miðeind ehf.
8
+ Author: Vilhjalmur Thorsteinsson
9
+
10
+ The Creative Commons Attribution-NonCommercial 4.0
11
+ International Public License (CC-BY-NC 4.0) applies to this software.
12
+ For further information, see https://github.com/mideind/Netskrafl
13
+
14
+ */
15
+
16
+ import { m } from "./mithril";
17
+ import { request } from "./request";
18
+ import { wordChecker } from "./wordcheck";
19
+ import {
20
+ BOARD_SIZE,
21
+ IGame, Message, Move, MoveDetail,
22
+ MoveListener, RACK_SIZE, RackTile, ROWIDS, SavedTile,
23
+ ServerGame, StatsType, TileData, TileDict,
24
+ TileScoreDict,
25
+ } from "./types";
26
+ import { arrayEqual, coord, toVector } from "./util";
27
+ import { LocalStorage, getLocalStorage } from "./localstorage";
28
+
29
+ // Maximum overtime before a player loses the game, 10 minutes in seconds
30
+ export const MAX_OVERTIME = 10 * 60.0;
31
+ export const DEBUG_OVERTIME = 1 * 60.0;
32
+
33
+ const GAME_OVER = 99; // Error code corresponding to the Error class in skraflmechanics.py
34
+
35
+ type BoardType = "explo" | "standard";
36
+
37
+ const START_SQUARE: Record<BoardType, string> = { explo: "D4", standard: "H8" };
38
+ const START_COORD: Record<BoardType, [number, number]> = { explo: [3, 3], standard: [7, 7] };
39
+
40
+ const BOARD = {
41
+ standard: {
42
+ WORDSCORE: [
43
+ "3 3 3",
44
+ " 2 2 ",
45
+ " 2 2 ",
46
+ " 2 2 ",
47
+ " 2 2 ",
48
+ " ",
49
+ " ",
50
+ "3 2 3",
51
+ " ",
52
+ " ",
53
+ " 2 2 ",
54
+ " 2 2 ",
55
+ " 2 2 ",
56
+ " 2 2 ",
57
+ "3 3 3"
58
+ ],
59
+ LETTERSCORE: [
60
+ " 2 2 ",
61
+ " 3 3 ",
62
+ " 2 2 ",
63
+ "2 2 2",
64
+ " ",
65
+ " 3 3 3 3 ",
66
+ " 2 2 2 2 ",
67
+ " 2 2 ",
68
+ " 2 2 2 2 ",
69
+ " 3 3 3 3 ",
70
+ " ",
71
+ "2 2 2",
72
+ " 2 2 ",
73
+ " 3 3 ",
74
+ " 2 2 "
75
+ ]
76
+ },
77
+ explo: {
78
+ WORDSCORE: [
79
+ "3 3 3",
80
+ " 2 ",
81
+ " 2 ",
82
+ " 2 ",
83
+ " 2 ",
84
+ " 2 2 ",
85
+ " 2 2 ",
86
+ "3 2 3",
87
+ " 2 2 ",
88
+ " 2 2 ",
89
+ " 2 ",
90
+ " 2 ",
91
+ " 2 ",
92
+ " 2 ",
93
+ "3 3 3"
94
+ ],
95
+ LETTERSCORE: [
96
+ " 2 2 ",
97
+ " 3 2 3 ",
98
+ " 2 3 2 ",
99
+ " 2 3 2",
100
+ "2 3 ",
101
+ " 2 2 ",
102
+ " 3 2 ",
103
+ " 2 2 ",
104
+ " 2 3 ",
105
+ " 2 2 ",
106
+ " 3 2",
107
+ "2 3 2 ",
108
+ " 2 3 2 ",
109
+ " 3 2 3 ",
110
+ " 2 2 "
111
+ ]
112
+ }
113
+ };
114
+
115
+ export class Game implements IGame {
116
+
117
+ // A class that represents a Game instance on the client
118
+
119
+ uuid: string;
120
+
121
+ locale = "is_IS";
122
+ alphabet = "";
123
+ tile_scores: TileScoreDict = {};
124
+ // Default to the standard board for the Icelandic locale
125
+ board_type: BoardType = "standard";
126
+ startSquare = "H8";
127
+ startCoord: [number, number] = [7, 7]; // row, col
128
+ two_letter_words: string[][][] = [[], []];
129
+
130
+ userid: [string, string] = ["", ""];
131
+ nickname: [string, string] = ["", ""];
132
+ fullname: [string, string] = ["", ""];
133
+ autoplayer: [boolean, boolean] = [false, false];
134
+ maxOvertime: number = MAX_OVERTIME;
135
+
136
+ scores: [number, number] = [0, 0];
137
+ moves: Move[] = [];
138
+ newmoves: Move[] = [];
139
+ lastmove: MoveDetail[] | undefined = undefined;
140
+ tiles: TileDict = {};
141
+ rack: RackTile[] = [];
142
+ racks: string[] = []; // Racks at various points in the game, for game review
143
+ num_moves: number = 0;
144
+ bag = "";
145
+ newbag: boolean = true;
146
+ localturn: boolean = false;
147
+ player: number | null = null; // null means that the current user is not a participant
148
+ stats: StatsType | null | undefined = null; // Game review statistics
149
+
150
+ over: boolean = false;
151
+ manual: boolean = false;
152
+ fairplay: boolean = false;
153
+ zombie: boolean = false; // !!! FIXME
154
+ overdue: boolean = false; // > 14 days since last move without reply from opponent
155
+ currentScore: number | undefined = undefined;
156
+
157
+ messages: Message[] | null = null; // Chat messages associated with this game
158
+ wordBad: boolean = false;
159
+ wordGood: boolean = false;
160
+ xchg: boolean = false; // Exchange allowed?
161
+ chall: boolean = false; // Challenge allowed?
162
+ last_chall: boolean = false; // True if last move laid down and asking for challenge
163
+ succ_chall: boolean = false;
164
+ showingDialog: string | null = null; // Below-the-board dialog (question)
165
+ moveInProgress: boolean = false; // Is the server processing a move?
166
+ askingForBlank: { from: string; to: string; } | null = null;
167
+ currentError: string | number | null = null;
168
+ currentMessage: string | null = null;
169
+ isFresh: boolean = false;
170
+ numTileMoves: number = 0;
171
+ chatLoading: boolean = false; // True while the chat messages are being loaded
172
+ chatSeen: boolean = true; // False if the user has not seen all chat messages
173
+ congratulate: boolean = false; // Show congratulation message if true
174
+ selectedSq: string | null = null; // Currently selected (blinking) square
175
+ sel: string = "movelist"; // By default, show the movelist tab
176
+
177
+ // Timed game clock stuff
178
+ interval: number | null = null; // Game clock interval timer
179
+ time_info: { duration: number; elapsed: [number, number] } | null = null; // Information about elapsed time
180
+ penalty0 = 0;
181
+ penalty1 = 0;
182
+ timeBase: Date | null = null; // Game time base
183
+ runningOut0 = false;
184
+ runningOut1 = false;
185
+ blinking0 = false;
186
+ blinking1 = false;
187
+ clockText0 = "";
188
+ clockText1 = "";
189
+
190
+ // Create a local storage object for this game
191
+ localStorage: LocalStorage | null = null;
192
+
193
+ // Plug-in point for parties that want to watch moves being made in the game
194
+ moveListener: MoveListener;
195
+
196
+ constructor(uuid: string, srvGame: ServerGame, moveListener: MoveListener, maxOvertime?: number) {
197
+ // Game constructor
198
+ // Add extra data and methods to our game model object
199
+ this.uuid = uuid;
200
+ this.moveListener = moveListener;
201
+
202
+ if (maxOvertime !== undefined)
203
+ // Maximum time override, for debugging purposes
204
+ this.maxOvertime = maxOvertime;
205
+
206
+ // Choose and return a constructor function depending on
207
+ // whether HTML5 local storage is available
208
+ this.localStorage = getLocalStorage(uuid);
209
+
210
+ // Load previously saved tile positions from
211
+ // local storage, if any
212
+ let savedTiles = this.localStorage.loadTiles();
213
+ this.init(srvGame);
214
+ // Put tiles in the same position as they were
215
+ // when the player left the game
216
+ this.restoreTiles(savedTiles);
217
+ if (!this.over && this.isTimed())
218
+ // Ongoing timed game: start the clock
219
+ this.startClock();
220
+ // Kick off loading of chat messages, if this is not a robot game
221
+ if (!this.autoplayer[0] && !this.autoplayer[1])
222
+ this.loadMessages();
223
+ }
224
+
225
+ init(srvGame: ServerGame) {
226
+ // Initialize the game state with data from the server
227
+ // Check whether the game is over, or whether there was an error
228
+ this.over = srvGame.result === GAME_OVER;
229
+ if (this.over || srvGame.result === 0) {
230
+ this.currentError = this.currentMessage = null;
231
+ } else {
232
+ // Nonzero srvGame.result: something is wrong
233
+ this.currentError = srvGame.result || "server";
234
+ this.currentMessage = srvGame.msg || "";
235
+ return;
236
+ }
237
+ // Copy srvGame JSON properties over to this object
238
+ Object.assign(this, srvGame);
239
+ if (srvGame.newmoves) {
240
+ // Add the newmoves list, if any, to the list of moves
241
+ this.moves = this.moves.concat(srvGame.newmoves);
242
+ }
243
+ // Don't keep the new moves lying around
244
+ this.newmoves = [];
245
+ this.localturn = !this.over && ((this.moves.length % 2) === this.player);
246
+ this.isFresh = true;
247
+ this.startSquare = START_SQUARE[this.board_type];
248
+ this.startCoord = START_COORD[this.board_type];
249
+ // If the game is over and this player has more points than
250
+ // the opponent, congratulations are in order
251
+ this.congratulate = this.over && this.player !== null &&
252
+ (this.scores[this.player] > this.scores[1 - this.player]);
253
+ if (this.currentError === null)
254
+ // Generate a dictionary of tiles currently on the board,
255
+ // from the moves already made. Also highlights the most recent
256
+ // opponent move (contained in this.lastmove)
257
+ this.placeTiles();
258
+ // Initialize the word cache with two-letter words.
259
+ // Note that this.two_letter_words contains two lists, ordered
260
+ // by the first letter and by the second letter. We only need
261
+ // to process one of them.
262
+ wordChecker.ingestTwoLetterWords(this.locale, this.two_letter_words[0]);
263
+ };
264
+
265
+ update(srvGame: ServerGame) {
266
+ // Update the srvGame state with data from the server,
267
+ // either after submitting a move to the server or
268
+ // after receiving a move notification via the Firebase listener
269
+ // Stop highlighting the previous opponent move, if any
270
+ for (let sq in this.tiles)
271
+ if (this.tiles.hasOwnProperty(sq))
272
+ this.tiles[sq].freshtile = false;
273
+ this.init(srvGame);
274
+ if (this.currentError === null) {
275
+ if (this.succ_chall) {
276
+ // Successful challenge: reset the rack
277
+ // (this updates the score as well)
278
+ this.resetRack();
279
+ }
280
+ else {
281
+ this.updateScore();
282
+ }
283
+ }
284
+ this.saveTiles();
285
+ if (this.isTimed())
286
+ // The call to resetClock() clears any outstanding interval timers
287
+ // if the srvGame is now over
288
+ this.resetClock();
289
+ };
290
+
291
+ async refresh() {
292
+ // Force a refresh of the current game state from the server
293
+ // Before calling refresh(), this.moveInProgress is typically
294
+ // set to true, so we reset it here
295
+ try {
296
+ if (!this.uuid)
297
+ return;
298
+ const result = await request<{ ok: boolean; game: ServerGame; }>({
299
+ method: "POST",
300
+ url: "/gamestate",
301
+ body: { game: this.uuid } // !!! FIXME: Add delete_zombie parameter
302
+ });
303
+ if (!result?.ok) {
304
+ // console.log("Game " + uuid + " could not be loaded");
305
+ }
306
+ else {
307
+ this.update(result.game);
308
+ }
309
+ }
310
+ catch(e) {
311
+ }
312
+ finally {
313
+ this.moveInProgress = false;
314
+ }
315
+ }
316
+
317
+ notifyUserChange(newNick: string) {
318
+ // The user information may have been changed:
319
+ // perform any updates that may be necessary
320
+ if (this.player !== null)
321
+ // The player nickname may have been changed
322
+ this.nickname[this.player] = newNick;
323
+ };
324
+
325
+ buttonState(): Record<string, boolean> {
326
+ // Calculate a set of booleans describing the state of the game
327
+ let s: Record<string, boolean> = {};
328
+ s.tilesPlaced = this.tilesPlaced().length > 0;
329
+ s.gameOver = this.over;
330
+ s.congratulate = this.congratulate;
331
+ s.localTurn = this.localturn;
332
+ s.gameIsManual = this.manual;
333
+ s.challengeAllowed = this.chall;
334
+ s.lastChallenge = this.last_chall;
335
+ s.showingDialog = this.showingDialog !== null;
336
+ s.exchangeAllowed = this.xchg;
337
+ s.wordGood = this.wordGood;
338
+ s.wordBad = this.wordBad;
339
+ s.canPlay = false;
340
+ s.tardyOpponent = !s.localTurn && !s.gameOver && this.overdue;
341
+ s.showResign = false;
342
+ s.showExchange = false;
343
+ s.showPass = false;
344
+ s.showRecall = false;
345
+ s.showScramble = false;
346
+ s.showMove = false;
347
+ s.showMoveMobile = false; // Versatile move button for mobile UI
348
+ s.showForceResignMobile = false; // Force resignation button for mobile UI
349
+ s.showChallenge = false;
350
+ s.showChallengeInfo = false;
351
+ if (this.moveInProgress)
352
+ // While a move is in progress (en route to the server)
353
+ // no buttons are shown
354
+ return s;
355
+ if (s.localTurn && !s.gameOver) {
356
+ // This player's turn
357
+ if (s.lastChallenge) {
358
+ s.showChallenge = true;
359
+ s.showPass = true;
360
+ s.showChallengeInfo = true;
361
+ }
362
+ else {
363
+ s.showMove = s.tilesPlaced;
364
+ s.showExchange = !s.tilesPlaced;
365
+ s.showPass = !s.tilesPlaced;
366
+ s.showResign = !s.tilesPlaced;
367
+ s.showChallenge = !s.tilesPlaced && s.gameIsManual && s.challengeAllowed;
368
+ }
369
+ }
370
+ if (s.showMove && (s.wordGood || s.gameIsManual))
371
+ s.canPlay = true;
372
+ if (!s.gameOver)
373
+ if (s.tilesPlaced) {
374
+ s.showRecall = true;
375
+ s.showMoveMobile = true;
376
+ } else {
377
+ s.showScramble = true;
378
+ if (s.tardyOpponent)
379
+ // Not showing the move button: show the Force resignation button
380
+ s.showForceResignMobile = true;
381
+ }
382
+ return s;
383
+ }
384
+
385
+ setSelectedTab(sel: string): boolean {
386
+ // Set the currently selected tab; return true if it was actually changed
387
+ if (this.sel == sel)
388
+ return false;
389
+ this.sel = sel;
390
+ return true;
391
+ };
392
+
393
+ tilescore(tile: string) {
394
+ // Note: The Python naming convention of tile_scores is intentional
395
+ return this.tile_scores[tile];
396
+ };
397
+
398
+ twoLetterWords() {
399
+ // Note: The Python naming convention of two_letter_words is intentional
400
+ return this.two_letter_words;
401
+ };
402
+
403
+ isTimed(): boolean {
404
+ // Return True if this is a timed game
405
+ return this.time_info !== null && this.time_info.duration >= 1.0;
406
+ };
407
+
408
+ showClock(): boolean {
409
+ // Return true if the clock should be shown in the right-hand column
410
+ if (!this.isTimed())
411
+ // Only show the clock for a timed game, obviously
412
+ return false;
413
+ if (!this.over)
414
+ // If the game is still ongoing, always show the clock
415
+ return true;
416
+ // If the game is over, only show the clock if there is something to
417
+ // show, i.e. at least one clock text
418
+ return !!this.clockText0 || !!this.clockText1;
419
+ }
420
+
421
+ updateClock() {
422
+ var txt0 = this.calcTimeToGo(0);
423
+ var txt1 = this.calcTimeToGo(1);
424
+ this.clockText0 = txt0;
425
+ this.clockText1 = txt1;
426
+ // If less than two minutes left, indicate that time is running out
427
+ this.runningOut0 = (txt0[0] == "-" || txt0 <= "02:00");
428
+ this.runningOut1 = (txt1[0] == "-" || txt1 <= "02:00");
429
+ // If less than 30 seconds left, make the clock digits blink
430
+ this.blinking0 = (this.runningOut0 && txt0 >= "00:00" && txt0 <= "00:30" && this.player === 0);
431
+ this.blinking1 = (this.runningOut1 && txt1 >= "00:00" && txt1 <= "00:30" && this.player === 1);
432
+ m.redraw();
433
+ }
434
+
435
+ resetClock() {
436
+ // Set a new time base after receiving an update from the server
437
+ this.timeBase = new Date();
438
+ this.updateClock();
439
+ if (this.over) {
440
+ // Game over: reset stuff
441
+ if (this.interval) {
442
+ window.clearInterval(this.interval);
443
+ this.interval = null;
444
+ }
445
+ this.blinking0 = false;
446
+ this.blinking1 = false;
447
+ this.runningOut0 = false;
448
+ this.runningOut1 = false;
449
+ }
450
+ }
451
+
452
+ startClock() {
453
+ // Start the clock running, after loading a timed game
454
+ this.resetClock();
455
+ if (!this.interval) {
456
+ this.interval = window.setInterval(
457
+ () => { this.updateClock(); },
458
+ 500 // milliseconds, i.e. 0.5 seconds
459
+ );
460
+ }
461
+ }
462
+
463
+ cleanup() {
464
+ // Clean up any resources owned by this game object
465
+ if (this.interval) {
466
+ window.clearInterval(this.interval);
467
+ this.interval = null;
468
+ }
469
+ }
470
+
471
+ calcTimeToGo(player: 0 | 1): string {
472
+ /* Return the time left for a player in a nice MM:SS format */
473
+ let gameTime = this.time_info;
474
+ if (gameTime === null || this.timeBase === null) return ""; // Should not happen
475
+ let elapsed = gameTime.elapsed[player];
476
+ let gameOver = this.over;
477
+ if (!gameOver && (this.moves.length % 2) === player) {
478
+ // This player's turn: add the local elapsed time
479
+ let now = new Date();
480
+ elapsed += (now.getTime() - this.timeBase.getTime()) / 1000;
481
+ if (elapsed - gameTime.duration * 60.0 > this.maxOvertime) {
482
+ // 10 minutes overtime has passed: The client now believes
483
+ // that the player has lost. Refresh the game from the server
484
+ // to get its final verdict.
485
+ if (!this.moveInProgress) {
486
+ this.moveInProgress = true;
487
+ // Refresh from the server in half a sec, to be a little
488
+ // more confident that it agrees with us
489
+ window.setTimeout(
490
+ () => { this.refresh(); }, 500
491
+ );
492
+ }
493
+ }
494
+ }
495
+ // The overtime is max 10 minutes - at that point you lose
496
+ const timeToGo = Math.max(gameTime.duration * 60.0 - elapsed, -this.maxOvertime);
497
+ const absTime = Math.abs(timeToGo);
498
+ const min = Math.floor(absTime / 60.0);
499
+ const sec = Math.floor(absTime - min * 60.0);
500
+ if (gameOver) {
501
+ // We already got a correct score from the server
502
+ this.penalty0 = 0;
503
+ this.penalty1 = 0;
504
+ }
505
+ else
506
+ if (timeToGo < 0.0) {
507
+ // We're into overtime: calculate the score penalty
508
+ if (player === 0)
509
+ this.penalty0 = -10 * Math.floor((min * 60 + sec + 59) / 60);
510
+ else
511
+ this.penalty1 = -10 * Math.floor((min * 60 + sec + 59) / 60);
512
+ }
513
+ return (timeToGo < 0.0 ? "-" : "") +
514
+ ("0" + min.toString()).slice(-2) + ":" + ("0" + sec.toString()).slice(-2);
515
+ }
516
+
517
+ displayScore(player: 0 | 1): number {
518
+ // Return the score to be displayed, which is the current
519
+ // actual game score minus accrued time penalty, if any, in a timed game
520
+ return Math.max(
521
+ this.scores[player] + (player === 0 ? this.penalty0 : this.penalty1), 0
522
+ )
523
+ }
524
+
525
+ async loadMessages() {
526
+ // Load chat messages for this game
527
+ if (this.chatLoading)
528
+ // Already loading
529
+ return;
530
+ this.chatLoading = true;
531
+ this.messages = [];
532
+ try {
533
+ const result = await request<{ ok: boolean; messages: Message[]; seen?: boolean }>(
534
+ {
535
+ method: "POST",
536
+ url: "/chatload",
537
+ body: { channel: "game:" + this.uuid }
538
+ }
539
+ );
540
+ if (result.ok)
541
+ this.messages = result.messages || [];
542
+ else
543
+ this.messages = [];
544
+ // Note whether the user has seen all chat messages
545
+ if (result.seen === undefined)
546
+ this.chatSeen = true;
547
+ else
548
+ this.chatSeen = result.seen;
549
+ }
550
+ catch (e) {
551
+ // Just leave this.messages as an empty list
552
+ }
553
+ finally {
554
+ this.chatLoading = false;
555
+ }
556
+ }
557
+
558
+ async loadStats() {
559
+ // Load statistics about a game
560
+ this.stats = undefined; // Error/in-progress status
561
+ try {
562
+ const json = await request<{ result?: number }>(
563
+ {
564
+ method: "POST",
565
+ url: "/gamestats",
566
+ body: { game: this.uuid }
567
+ }
568
+ );
569
+ // Save the incoming game statistics in the stats property
570
+ if (!json || json.result === undefined)
571
+ return;
572
+ if (json.result !== 0 && json.result !== GAME_OVER)
573
+ return;
574
+ // Success: assign the stats
575
+ this.stats = json as StatsType;
576
+ }
577
+ catch(e) {
578
+ // Just leave this.stats undefined
579
+ }
580
+ }
581
+
582
+ async sendMessage(msg: string) {
583
+ // Send a chat message
584
+ try {
585
+ await request(
586
+ {
587
+ method: "POST",
588
+ url: "/chatmsg",
589
+ body: { channel: "game:" + this.uuid, msg: msg }
590
+ }
591
+ );
592
+ }
593
+ catch(e) {
594
+ // No big deal
595
+ // A TODO might be to add some kind of error icon to the UI
596
+ }
597
+ }
598
+
599
+ sendChatSeenMarker() {
600
+ // Send a 'chat message seen' marker to the server
601
+ this.sendMessage("");
602
+ // The user has now seen all chat messages
603
+ this.chatSeen = true;
604
+ }
605
+
606
+ addChatMessage(from_userid: string, msg: string, ts: string, ownMessage: boolean) {
607
+ // Add a new chat message, received via a Firebase notification,
608
+ // to the message list
609
+ if (this.chatLoading || msg === "")
610
+ // Loading of the message list is underway: assume that this message
611
+ // will be contained in the list, once it has been read
612
+ return;
613
+ if (this.messages === null) this.messages = [];
614
+ this.messages.push({ from_userid: from_userid, msg: msg, ts: ts });
615
+ if (this.sel == "chat") {
616
+ // Chat already open, so the player has seen the message: send a read receipt
617
+ this.sendChatSeenMarker();
618
+ } else if (!ownMessage) {
619
+ // Chat not open, and we have a new chat message from the other player:
620
+ // note that this player hasn't seen it
621
+ this.chatSeen = false;
622
+ }
623
+ }
624
+
625
+ markChatShown(): boolean {
626
+ // Note that the user has seen all pending chat messages
627
+ if (!this.chatSeen) {
628
+ this.sendChatSeenMarker();
629
+ return true;
630
+ }
631
+ return false;
632
+ }
633
+
634
+ placeMove(player: 0 | 1, co: string, tiles: string, highlight: boolean) {
635
+ // Place an entire move into the tiles dictionary
636
+ const vec = toVector(co);
637
+ let col = vec.col;
638
+ let row = vec.row;
639
+ let nextBlank = false;
640
+ let index = 0;
641
+ for (let i = 0; i < tiles.length; i++) {
642
+ let tile = tiles[i];
643
+ if (tile == '?') {
644
+ nextBlank = true;
645
+ continue;
646
+ }
647
+ const sq = coord(row, col);
648
+ if (sq === null) continue; // Should not happen
649
+ let letter = tile;
650
+ if (nextBlank)
651
+ tile = '?';
652
+ const tscore = this.tilescore(tile);
653
+ // Place the tile, if it isn't there already
654
+ if (!(sq in this.tiles)) {
655
+ this.tiles[sq] = {
656
+ player,
657
+ tile,
658
+ letter,
659
+ score: tscore,
660
+ draggable: false,
661
+ freshtile: false,
662
+ index, // Index of this tile within the move, for animation purposes
663
+ xchg: false,
664
+ };
665
+ if (highlight) {
666
+ // Highlight the tile
667
+ if (player === this.player)
668
+ this.tiles[sq].highlight = 0; // Local player color
669
+ else if ((1 - player) === this.player)
670
+ this.tiles[sq].highlight = 1; // Remote player color
671
+ index++;
672
+ }
673
+ }
674
+ col += vec.dx;
675
+ row += vec.dy;
676
+ nextBlank = false;
677
+ }
678
+ }
679
+
680
+ setRack(rack: RackTile[]) {
681
+ // Set the current rack
682
+ this.rack = rack;
683
+ }
684
+
685
+ rackAtMove(moveIndex: number): string {
686
+ const numRacks = this.racks?.length || 0;
687
+ if (!numRacks) return "";
688
+ return this.racks[moveIndex >= numRacks ? numRacks - 1 : moveIndex];
689
+ }
690
+
691
+ isFinalMove(moveIndex: number): boolean {
692
+ // Return true if the indicated move is a final (adjustment) move
693
+ // in the game, i.e. a rack leave or a game 'OVER' move
694
+ return moveIndex >= this.num_moves;
695
+ }
696
+
697
+ boardAsStrings(): string[] {
698
+ // Return an array of strings, each representing a row of the board
699
+ const board = [];
700
+ for (const row of ROWIDS) {
701
+ let line = "";
702
+ for (let col = 0; col < BOARD_SIZE; col++) {
703
+ let sq = `${row}${col + 1}`;
704
+ if (sq in this.tiles) {
705
+ const {tile, letter} = this.tiles[sq];
706
+ // We indicate a blank tile meaning with an uppercase letter
707
+ line += tile === "?" ? letter.toLocaleUpperCase() : letter;
708
+ } else
709
+ line += ".";
710
+ }
711
+ board.push(line);
712
+ }
713
+ return board;
714
+ }
715
+
716
+ placeTiles(move?: number, noHighlight?: boolean) {
717
+ // Make a tile dictionary for the game.
718
+ // If move is given, it is an index of the
719
+ // last move in the move list that should be
720
+ // shown on the board.
721
+ this.tiles = {};
722
+ this.numTileMoves = 0;
723
+ const mlist = this.moves;
724
+ // We highlight the last move placed (a) if we're in a game
725
+ // review (move !== undefined) or (b) if this is a normal game
726
+ // view, we don't have an explicit this.lastmove (which is treated
727
+ // separately) and the last move is an opponent move.
728
+ const highlightReview = (move !== undefined);
729
+ const highlightLast = !highlightReview && !this.lastmove && this.localturn;
730
+ const highlight = !noHighlight && (highlightLast || highlightReview);
731
+ const last: number = (move !== undefined) ? move : mlist.length;
732
+
733
+ function successfullyChallenged(ix: number): boolean {
734
+ // Was the move with index ix successfully challenged?
735
+ if (ix + 2 >= last)
736
+ // The move list is too short for a response move
737
+ return false;
738
+ let [ _, [ co, tiles, score ] ] = mlist[ix + 2];
739
+ if (co !== "")
740
+ // The player's next move is a normal tile move
741
+ return false;
742
+ // Return true if this was a challenge response with a negative score
743
+ // (i.e. a successful challenge)
744
+ return (tiles === "RESP") && (score < 0);
745
+ }
746
+
747
+ // Loop through the move list, placing each move
748
+ for (let i = 0; i < last; i++) {
749
+ let [ player, [co, tiles] ] = mlist[i];
750
+ if (co != "" && !successfullyChallenged(i)) {
751
+ // Unchallenged tile move: place it on the board
752
+ this.placeMove(player, co, tiles, (i === last - 1) && highlight);
753
+ this.numTileMoves++;
754
+ }
755
+ }
756
+ // If it's our turn, mark the opponent's last move
757
+ // The type of this.lastmove corresponds to DetailTuple on the server side
758
+ let dlist = this.lastmove;
759
+ if (dlist && this.localturn)
760
+ for (let i = 0; i < dlist.length; i++) {
761
+ let sq = dlist[i][0];
762
+ if (!(sq in this.tiles))
763
+ throw "Tile from lastmove not in square " + sq;
764
+ this.tiles[sq].freshtile = true;
765
+ this.tiles[sq].index = i; // Index of tile within move, for animation purposes
766
+ }
767
+ // Also put the rack tiles into this.tiles
768
+ for (let i = 0; i < this.rack.length; i++) {
769
+ const sq = 'R' + (i + 1);
770
+ const [tile, tscore] = this.rack[i];
771
+ const letter = (tile === '?') ? ' ' : tile;
772
+ this.tiles[sq] = {
773
+ player: this.player ? 1 : 0,
774
+ tile,
775
+ letter,
776
+ score: tscore,
777
+ draggable: true,
778
+ freshtile: false,
779
+ index: 0,
780
+ xchg: false
781
+ };
782
+ }
783
+ };
784
+
785
+ private _moveTile(from: string, to: string) {
786
+ // Low-level function to move a tile between cells/slots
787
+ if (from == to)
788
+ // Nothing to do
789
+ return;
790
+ let fromTile = this.tiles[from];
791
+ if (fromTile === undefined)
792
+ throw "Moving from an empty square";
793
+ delete this.tiles[from];
794
+ if (to in this.tiles) {
795
+ if (to.charAt(0) != "R")
796
+ throw "Dropping to an occupied square";
797
+ // Dropping to an occupied slot in the rack:
798
+ // create space in the rack
799
+ let dest = parseInt(to.slice(1));
800
+ let empty = dest + 1;
801
+ // Try to find an empty slot to the right of the drop destination
802
+ while (('R' + empty) in this.tiles)
803
+ empty++;
804
+ if (empty <= RACK_SIZE) {
805
+ // Found empty slot after the tile:
806
+ // move the intervening tiles to the right
807
+ for (let j = empty; j > dest; j--)
808
+ this.tiles['R' + j] = this.tiles['R' + (j - 1)];
809
+ }
810
+ else {
811
+ // No empty slots after the tile: try to find one to the left
812
+ empty = dest - 1;
813
+ while (('R' + empty) in this.tiles)
814
+ empty--;
815
+ if (empty < 1)
816
+ throw "No place in rack to drop tile";
817
+ for (let j = empty; j < dest; j++)
818
+ this.tiles['R' + j] = this.tiles['R' + (j + 1)];
819
+ }
820
+ }
821
+ if (to[0] == 'R' && fromTile.tile == '?')
822
+ // Putting a blank tile back into the rack: erase its meaning
823
+ fromTile.letter = ' ';
824
+ this.tiles[to] = fromTile;
825
+ };
826
+
827
+ moveTile(from: string, to: string) {
828
+ // High-level function to move a tile between cells/slots
829
+ this._moveTile(from, to);
830
+ // Clear error message, if any
831
+ this.currentError = this.currentMessage = null;
832
+ // Update the current word score
833
+ this.updateScore();
834
+ // Update the local storage
835
+ this.saveTiles();
836
+ };
837
+
838
+ attemptMove(from: string, to: string) {
839
+ if (to == from)
840
+ // No move
841
+ return;
842
+ if (to in this.tiles && to[0] != 'R')
843
+ throw "Square " + to + " occupied";
844
+ if (!(from in this.tiles))
845
+ throw "No tile at " + from;
846
+ const tile = this.tiles[from];
847
+ if (to[0] != 'R' && tile.tile == '?' && tile.letter == ' ') {
848
+ // Dropping a blank tile on the board:
849
+ // postpone the move and ask for its meaning
850
+ this.askingForBlank = { from: from, to: to };
851
+ return;
852
+ }
853
+ // Complete the move
854
+ this.moveTile(from, to);
855
+ };
856
+
857
+ cancelBlankDialog() {
858
+ // Cancel the dialog asking for the meaning of the blank tile
859
+ this.askingForBlank = null;
860
+ };
861
+
862
+ placeBlank(letter: string) {
863
+ // Assign a meaning to a blank tile that is being placed on the board
864
+ if (this.askingForBlank === null)
865
+ return;
866
+ const { from, to } = this.askingForBlank;
867
+ // We must assign the tile letter before moving it
868
+ // since moveTile() calls updateScore() which in turn does a /wordcheck
869
+ this.tiles[from].letter = letter;
870
+ this.moveTile(from, to);
871
+ this.askingForBlank = null;
872
+ };
873
+
874
+ tilesPlaced(): string[] {
875
+ // Return a list of coordinates of tiles that the user has
876
+ // placed on the board by dragging from the rack
877
+ const r: string[] = [];
878
+ for (let sq in this.tiles)
879
+ if (this.tiles.hasOwnProperty(sq) &&
880
+ sq[0] != 'R' && this.tiles[sq].draggable)
881
+ // Found a non-rack tile that is not glued to the board
882
+ r.push(sq);
883
+ return r;
884
+ };
885
+
886
+ async sendMove(moves: string[]) {
887
+ // Send a move to the server
888
+ this.moveInProgress = true;
889
+ try {
890
+ const result = await request<ServerGame>(
891
+ {
892
+ method: "POST",
893
+ url: "/submitmove",
894
+ body: { moves: moves, mcount: this.moves.length, uuid: this.uuid }
895
+ }
896
+ );
897
+ // The update() function also handles error results
898
+ this.update(result);
899
+ // Notify eventual listeners that a (local) move has been made
900
+ if (this.moveListener)
901
+ this.moveListener.notifyMove();
902
+ } catch (e) {
903
+ this.currentError = "server";
904
+ if (e instanceof Error) {
905
+ this.currentMessage = e.message;
906
+ } else {
907
+ this.currentMessage = String(e);
908
+ }
909
+ }
910
+ finally {
911
+ this.moveInProgress = false;
912
+ }
913
+ };
914
+
915
+ async forceResign() {
916
+ // Force resignation by a tardy opponent
917
+ this.moveInProgress = true;
918
+ try {
919
+ const result = await request<ServerGame>(
920
+ {
921
+ method: "POST",
922
+ url: "/forceresign",
923
+ body: { mcount: this.moves.length, game: this.uuid }
924
+ }
925
+ );
926
+ // The update() function also handles error results
927
+ this.update(result);
928
+ } catch (e) {
929
+ this.currentError = "server";
930
+ if (e instanceof Error) {
931
+ this.currentMessage = e.message;
932
+ } else {
933
+ this.currentMessage = String(e);
934
+ }
935
+ }
936
+ finally {
937
+ this.moveInProgress = false;
938
+ }
939
+ };
940
+
941
+ submitMove() {
942
+ // Send a tile move to the server
943
+ const t = this.tilesPlaced();
944
+ let moves: string[] = [];
945
+ this.selectedSq = null; // Currently selected (blinking) square
946
+ for (let i = 0; i < t.length; i++) {
947
+ const sq = t[i];
948
+ const { tile, letter } = this.tiles[sq];
949
+ moves.push(sq + "=" + tile + (tile === '?' ? letter : ""));
950
+ }
951
+ if (moves.length > 0)
952
+ this.sendMove(moves);
953
+ };
954
+
955
+ submitPass() {
956
+ // Show a pass confirmation prompt
957
+ this.showingDialog = "pass";
958
+ this.selectedSq = null; // Currently selected (blinking) square
959
+ };
960
+
961
+ submitChallenge() {
962
+ // Show a challenge confirmation prompt
963
+ this.showingDialog = "chall";
964
+ this.selectedSq = null; // Currently selected (blinking) square
965
+ };
966
+
967
+ submitExchange() {
968
+ // Show an exchange prompt
969
+ this.showingDialog = "exchange";
970
+ this.selectedSq = null; // Currently selected (blinking) square
971
+ // Remove the xchg flag from all tiles in the rack
972
+ for (let i = 1; i <= RACK_SIZE; i++) {
973
+ const sq = "R" + i;
974
+ if (sq in this.tiles)
975
+ this.tiles[sq].xchg = false;
976
+ }
977
+ };
978
+
979
+ submitResign() {
980
+ // Show a resign prompt
981
+ this.showingDialog = "resign";
982
+ this.selectedSq = null; // Currently selected (blinking) square
983
+ };
984
+
985
+ confirmPass(yes: boolean) {
986
+ // Handle reply to pass confirmation prompt
987
+ this.showingDialog = null;
988
+ if (yes)
989
+ this.sendMove([ "pass" ]);
990
+ };
991
+
992
+ confirmChallenge(yes: boolean) {
993
+ // Handle reply to challenge confirmation prompt
994
+ this.showingDialog = null;
995
+ if (yes)
996
+ this.sendMove([ "chall" ]);
997
+ };
998
+
999
+ confirmExchange(yes: boolean) {
1000
+ // Handle reply to exchange confirmation prompt
1001
+ let exch = "";
1002
+ this.showingDialog = null;
1003
+ for (let i = 1; i <= RACK_SIZE; i++) {
1004
+ const sq = "R" + i;
1005
+ if (sq in this.tiles && this.tiles[sq].xchg) {
1006
+ // This tile is marked for exchange
1007
+ exch += this.tiles[sq].tile;
1008
+ this.tiles[sq].xchg = false;
1009
+ }
1010
+ }
1011
+ if (yes && exch.length > 0)
1012
+ // Send the exchange move to the server
1013
+ this.sendMove([ "exch=" + exch ]);
1014
+ };
1015
+
1016
+ confirmResign(yes: boolean) {
1017
+ // Handle reply to resignation confirmation prompt
1018
+ this.showingDialog = null;
1019
+ if (yes)
1020
+ this.sendMove([ "rsgn" ]);
1021
+ };
1022
+
1023
+ rescrambleRack() {
1024
+ // Reorder the rack randomly. Bound to the Backspace key.
1025
+ this.selectedSq = null; // Currently selected (blinking) square
1026
+ if (this.showingDialog !== null)
1027
+ // Already showing a bottom-of-page dialog
1028
+ return;
1029
+ this._resetRack();
1030
+ const array: (TileData | null)[] = [];
1031
+ for (let i = 1; i <= RACK_SIZE; i++) {
1032
+ const rackTileId = "R" + i;
1033
+ if (rackTileId in this.tiles)
1034
+ array.push(this.tiles[rackTileId]);
1035
+ else
1036
+ array.push(null);
1037
+ }
1038
+ let currentIndex = array.length;
1039
+ // Fisher-Yates (Knuth) shuffle algorithm
1040
+ while (0 !== currentIndex) {
1041
+ const randomIndex = Math.floor(Math.random() * currentIndex);
1042
+ currentIndex -= 1;
1043
+ const temporaryValue = array[currentIndex];
1044
+ array[currentIndex] = array[randomIndex];
1045
+ array[randomIndex] = temporaryValue;
1046
+ }
1047
+ // Fill the resulting rack from left to right
1048
+ let empty = 0; // Destination rack cell
1049
+ for (let i = 1; i <= RACK_SIZE; i++) {
1050
+ const a = array[i-1];
1051
+ if (a !== null)
1052
+ // Nonempty result cell: copy it
1053
+ this.tiles["R" + (i - empty)] = a;
1054
+ else {
1055
+ // Empty result cell: empty a rack cell from the right-hand side
1056
+ delete this.tiles["R" + (RACK_SIZE - empty)];
1057
+ empty++;
1058
+ }
1059
+ }
1060
+ this.saveTiles();
1061
+ };
1062
+
1063
+ saveTiles() {
1064
+ // Save the current unglued tile configuration to local storage
1065
+ let tp: { sq: string; tile: string; }[] = [];
1066
+ const tilesPlaced = this.tilesPlaced();
1067
+ for (const sq of tilesPlaced) {
1068
+ const { tile, letter } = this.tiles[sq];
1069
+ // For blank tiles, store their meaning as well
1070
+ tp.push({sq, tile: tile === "?" ? tile + letter : tile});
1071
+ }
1072
+ // Also save tiles remaining in the rack
1073
+ for (let i = 1; i <= RACK_SIZE; i++) {
1074
+ const sq = `R${i}`;
1075
+ if (sq in this.tiles)
1076
+ tp.push({sq, tile: this.tiles[sq].tile});
1077
+ }
1078
+ this.localStorage?.saveTiles(tp);
1079
+ };
1080
+
1081
+ restoreTiles(savedTiles: { sq: string; tile: string}[]) {
1082
+ // Restore the tile positions that were previously stored
1083
+ // in local storage
1084
+ if (!savedTiles.length)
1085
+ // Nothing to do
1086
+ return;
1087
+ let savedLetters: string[] = [];
1088
+ let rackLetters: string[] = [];
1089
+ let rackTiles: TileDict = {};
1090
+ // First, check that the saved tiles match the current rack
1091
+ for (let i = 0; i < savedTiles.length; i++)
1092
+ savedLetters.push(savedTiles[i].tile.charAt(0));
1093
+ for (let i = 1; i <= RACK_SIZE; i++)
1094
+ if (("R" + i) in this.tiles)
1095
+ rackLetters.push(this.tiles["R" + i].tile.charAt(0));
1096
+ savedLetters.sort();
1097
+ rackLetters.sort();
1098
+ if (!arrayEqual(savedLetters, rackLetters))
1099
+ // We don't have the same rack as when the state was saved:
1100
+ // give up
1101
+ return;
1102
+ // Save the original rack and delete the rack tiles
1103
+ // from the board
1104
+ for (let j = 1; j <= RACK_SIZE; j++)
1105
+ if (("R" + j) in this.tiles) {
1106
+ rackTiles["R" + j] = this.tiles["R" + j];
1107
+ delete this.tiles["R" + j];
1108
+ }
1109
+ // Attempt to move the saved tiles from the saved rack to
1110
+ // their saved positions. Note that there are several corner
1111
+ // cases, for instance multiple instances of the same letter tile,
1112
+ // that make this code less than straightforward.
1113
+ for (let i = 0; i < savedTiles.length; i++) {
1114
+ const saved_sq = savedTiles[i].sq;
1115
+ if (!(saved_sq in this.tiles)) {
1116
+ // The saved destination square is empty:
1117
+ // find the tile in the saved rack and move it there
1118
+ const tile = savedTiles[i].tile;
1119
+ for (let sq in rackTiles)
1120
+ if (rackTiles.hasOwnProperty(sq) &&
1121
+ rackTiles[sq].tile == tile.charAt(0)) {
1122
+ // Found the tile (or its equivalent) in the rack: move it
1123
+ if (tile.charAt(0) === "?")
1124
+ if (saved_sq.charAt(0) === "R")
1125
+ // Going to the rack: no associated letter
1126
+ rackTiles[sq].letter = " ";
1127
+ else
1128
+ // Going to a board square: associate the originally
1129
+ // chosen and saved letter
1130
+ rackTiles[sq].letter = tile.charAt(1);
1131
+ // ...and assign it
1132
+ this.tiles[saved_sq] = rackTiles[sq];
1133
+ delete rackTiles[sq];
1134
+ break;
1135
+ }
1136
+ }
1137
+ }
1138
+ // Allocate any remaining tiles to free slots in the rack
1139
+ let j = 1;
1140
+ for (let sq in rackTiles)
1141
+ if (rackTiles.hasOwnProperty(sq)) {
1142
+ // Look for a free slot in the rack
1143
+ while(("R" + j) in this.tiles)
1144
+ j++;
1145
+ if (j <= RACK_SIZE)
1146
+ // Should always be true unless something is very wrong
1147
+ this.tiles["R" + j] = rackTiles[sq];
1148
+ }
1149
+ // The local storage may have been cleared before calling
1150
+ // restoreTiles() so we must ensure that it is updated
1151
+ this.saveTiles();
1152
+ // Show an updated word status and score
1153
+ this.updateScore();
1154
+ };
1155
+
1156
+ _resetRack() {
1157
+ // Recall all unglued tiles into the rack
1158
+ const t = this.tilesPlaced();
1159
+ if (t.length) {
1160
+ let i = 1;
1161
+ for (let j = 0; j < t.length; j++) {
1162
+ // Find a free slot in the rack
1163
+ while (("R" + i) in this.tiles)
1164
+ i++;
1165
+ const sq = "R" + i;
1166
+ // Recall the tile
1167
+ this.tiles[sq] = this.tiles[t[j]];
1168
+ delete this.tiles[t[j]];
1169
+ if (this.tiles[sq].tile === '?')
1170
+ // Erase the meaning of the blank tile
1171
+ this.tiles[sq].letter = ' ';
1172
+ i++;
1173
+ }
1174
+ // Update score
1175
+ this.updateScore();
1176
+ }
1177
+ // Reset current error message, if any
1178
+ this.currentError = null;
1179
+ };
1180
+
1181
+ resetError() {
1182
+ // Reset the current error message, if any
1183
+ this.currentError = this.currentMessage = null;
1184
+ }
1185
+
1186
+ resetRack() {
1187
+ // Recall all unglued tiles into the rack
1188
+ this.selectedSq = null; // Currently selected (blinking) square
1189
+ this._resetRack();
1190
+ this.saveTiles();
1191
+ };
1192
+
1193
+ async updateScore() {
1194
+ // Re-calculate the current word score
1195
+ const scoreResult = this.calcScore();
1196
+ this.wordGood = false;
1197
+ this.wordBad = false;
1198
+ if (scoreResult === undefined || !scoreResult.word)
1199
+ this.currentScore = undefined;
1200
+ else {
1201
+ this.currentScore = scoreResult.score;
1202
+ if (!this.manual) {
1203
+ // This is not a manual-wordcheck game:
1204
+ // Check the word that has been laid down
1205
+ const found = await wordChecker.checkWords(this.locale, scoreResult.words);
1206
+ this.wordGood = found;
1207
+ this.wordBad = !found;
1208
+ }
1209
+ }
1210
+ };
1211
+
1212
+ wordScore(row: number, col: number): number {
1213
+ // Return the word score multiplier at the given coordinate
1214
+ // on the game's board
1215
+ const wsc = BOARD[this.board_type].WORDSCORE;
1216
+ return parseInt(wsc[row].charAt(col)) || 1;
1217
+ };
1218
+
1219
+ letterScore(row: number, col: number): number {
1220
+ // Return the letter score multiplier at the given coordinate
1221
+ // on the game's board
1222
+ const lsc = BOARD[this.board_type].LETTERSCORE;
1223
+ return parseInt(lsc[row].charAt(col)) || 1;
1224
+ };
1225
+
1226
+ squareType(row: number, col: number): string {
1227
+ // Return the square type, or "" if none
1228
+ const wsc = this.wordScore(row, col);
1229
+ if (wsc === 2)
1230
+ return "dw"; // Double word
1231
+ if (wsc === 3)
1232
+ return "tw"; // Triple word
1233
+ const lsc = this.letterScore(row, col);
1234
+ if (lsc === 2)
1235
+ return "dl"; // Double letter
1236
+ if (lsc === 3)
1237
+ return "tl"; // Triple letter
1238
+ return ""; // Plain square
1239
+ };
1240
+
1241
+ squareClass(coord: string): string | undefined {
1242
+ // Given a coordinate in string form, return the square's type/class
1243
+ if (!coord || coord[0] === "R")
1244
+ return undefined;
1245
+ const {row, col} = toVector(coord);
1246
+ return this.squareType(row, col) || undefined;
1247
+ };
1248
+
1249
+ tileAt(row: number, col: number): TileData | null {
1250
+ const c = coord(row, col);
1251
+ return c ? this.tiles[c] || null : null;
1252
+ };
1253
+
1254
+ calcScore() {
1255
+ // Calculate the score for the tiles that have been laid on the board in the current move
1256
+ let score = 0, crossScore = 0;
1257
+ let wsc = 1;
1258
+ let minrow = BOARD_SIZE, mincol = BOARD_SIZE;
1259
+ let maxrow = 0, maxcol = 0;
1260
+ let numtiles = 0, numcrosses = 0;
1261
+ let word = "";
1262
+ let words: string[] = [];
1263
+ this.tilesPlaced().forEach((sq) => {
1264
+ // Tile on the board
1265
+ const row = ROWIDS.indexOf(sq.charAt(0));
1266
+ const col = parseInt(sq.slice(1)) - 1;
1267
+ const t = this.tiles[sq];
1268
+ score += t.score * this.letterScore(row, col);
1269
+ numtiles++;
1270
+ wsc *= this.wordScore(row, col);
1271
+ if (row < minrow)
1272
+ minrow = row;
1273
+ if (col < mincol)
1274
+ mincol = col;
1275
+ if (row > maxrow)
1276
+ maxrow = row;
1277
+ if (col > maxcol)
1278
+ maxcol = col;
1279
+ });
1280
+ if (!numtiles)
1281
+ return undefined;
1282
+ if (minrow !== maxrow && mincol !== maxcol)
1283
+ // Not a pure horizontal or vertical move
1284
+ return undefined;
1285
+ let x = mincol, y = minrow;
1286
+ let dx: -1 | 0 | 1 = 0, dy: -1 | 0 | 1 = 0;
1287
+ if (minrow !== maxrow)
1288
+ dy = 1; // Vertical
1289
+ else
1290
+ if (mincol === maxcol &&
1291
+ (this.tileAt(minrow - 1, mincol) !== null || this.tileAt(minrow + 1, mincol) !== null))
1292
+ // Single tile: if it has tiles above or below, consider this a vertical move
1293
+ dy = 1;
1294
+ else
1295
+ dx = 1; // Horizontal
1296
+ // Find the beginning of the word
1297
+ while (this.tileAt(y - dy, x - dx) !== null) {
1298
+ x -= dx;
1299
+ y -= dy;
1300
+ }
1301
+ let t: TileData | null;
1302
+ // Find the end of the word
1303
+ while ((t = this.tileAt(y, x)) !== null) {
1304
+ if (t.draggable) {
1305
+ // Add score for cross words
1306
+ const csc = this.calcCrossScore(y, x, 1 - dy, 1 - dx);
1307
+ if (csc.score >= 0) {
1308
+ // There was a cross word there (it can score 0 if blank)
1309
+ crossScore += csc.score;
1310
+ numcrosses++;
1311
+ words.push(csc.word);
1312
+ }
1313
+ }
1314
+ else {
1315
+ // This is a tile that was previously on the board
1316
+ score += t.score;
1317
+ numcrosses++;
1318
+ }
1319
+ // Accumulate the word being formed
1320
+ word += t.letter;
1321
+ x += dx;
1322
+ y += dy;
1323
+ }
1324
+ if (this.numTileMoves === 0) {
1325
+ // First move that actually lays down tiles must go through start square
1326
+ const c = this.startCoord;
1327
+ if (null === this.tileAt(c[0], c[1]))
1328
+ // No tile in the start square
1329
+ return undefined;
1330
+ }
1331
+ else
1332
+ if (!numcrosses)
1333
+ // Not first move, and not linked with any word on the board
1334
+ return undefined;
1335
+ // Check whether word is consecutive
1336
+ // (which it is not if there is an empty square before the last tile)
1337
+ if (dx && (x <= maxcol))
1338
+ return undefined;
1339
+ if (dy && (y <= maxrow))
1340
+ return undefined;
1341
+ words.push(word);
1342
+ return {
1343
+ word: word,
1344
+ words: words,
1345
+ score: score * wsc + crossScore + (numtiles === RACK_SIZE ? 50 : 0),
1346
+ };
1347
+ };
1348
+
1349
+ calcCrossScore(oy: number, ox: number, dy: number, dx: number) {
1350
+ // Calculate the score contribution of a cross word
1351
+ let score = 0;
1352
+ let hascross = false;
1353
+ let x = ox, y = oy;
1354
+ let word = "";
1355
+ // Find the beginning of the word
1356
+ while (this.tileAt(y - dy, x - dx) !== null) {
1357
+ x -= dx;
1358
+ y -= dy;
1359
+ }
1360
+ let t: TileData | null;
1361
+ // Find the end of the word
1362
+ while ((t = this.tileAt(y, x)) !== null) {
1363
+ let sc = t.score;
1364
+ if (x === ox && y === oy)
1365
+ sc *= this.letterScore(y, x);
1366
+ else
1367
+ hascross = true;
1368
+ word += t.letter;
1369
+ score += sc;
1370
+ x += dx;
1371
+ y += dy;
1372
+ }
1373
+ if (!hascross)
1374
+ return { score: -1, word: "" };
1375
+ return { score: score * this.wordScore(oy, ox), word: word };
1376
+ };
1377
+
1378
+ } // class Game