@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,817 @@
1
+ /*
2
+
3
+ Model.ts
4
+
5
+ Single page UI for Explo/Netskrafl using the Mithril library
6
+
7
+ Copyright (C) 2024 Miðeind ehf.
8
+ Author: Vilhjálmur Þorsteinsson
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
+ This file implements the Model class and related global state.
15
+ A Model encapsulates the data, including the Game instance,
16
+ that is being displayed live by the current view.
17
+
18
+ */
19
+
20
+ import { GlobalState } from "./globalstate";
21
+ import {
22
+ ServerGame, Move, IModel, UserListItem,
23
+ ChallengeParameters, GameListItem, ChallengeListItem, RecentListItem,
24
+ UserPrefs, MovesRequest, BestMoves,
25
+ UserErrors,
26
+ UserListCriteria,
27
+ UserStats,
28
+ } from "./types";
29
+ import { m } from "./mithril";
30
+ import { logEvent } from "./channel";
31
+ import { loadMessages } from "./i18n";
32
+ import { request, requestMoves } from "./request";
33
+ import { Game, MAX_OVERTIME, DEBUG_OVERTIME } from "./game";
34
+ import { Params } from "./util";
35
+
36
+ // Maximum number of concurrent games per user
37
+ const MAX_GAMES = 50;
38
+ // Maximum number of concurrent games for non-paying users
39
+ const MAX_FREE_EXPLO = 3;
40
+ const MAX_FREE_NETSKRAFL = 8;
41
+ // Number of best moves to show in the review screen
42
+ const NUM_BEST_MOVES = 19;
43
+
44
+ // Basic Mithril routing settings
45
+ interface Path {
46
+ name: string;
47
+ route: string;
48
+ mustLogin: boolean;
49
+ }
50
+
51
+ type Paths = Path[];
52
+
53
+ interface Settings {
54
+ paths: Paths;
55
+ defaultRoute: string;
56
+ }
57
+
58
+ export function getSettings(): Settings {
59
+ // Returns an app-wide settings object, used by Mithril for routing
60
+ const
61
+ paths: Paths = [
62
+ { name: "main", route: "/main", mustLogin: true },
63
+ { name: "help", route: "/help", mustLogin: false },
64
+ { name: "thanks", route: "/thanks", mustLogin: true },
65
+ { name: "cancel", route: "/cancel", mustLogin: true },
66
+ { name: "confirm", route: "/confirm", mustLogin: true },
67
+ { name: "game", route: "/game/:uuid", mustLogin: true },
68
+ { name: "review", route: "/review/:uuid", mustLogin: true },
69
+ { name: "login", route: "/login", mustLogin: false },
70
+ { name: "loginerror", route: "/loginerror", mustLogin: false },
71
+ ];
72
+ return {
73
+ paths: paths,
74
+ defaultRoute: paths[0].route
75
+ };
76
+ }
77
+
78
+ export class Model implements IModel {
79
+
80
+ // A class for the underlying data model, displayed by the current view
81
+
82
+ state: GlobalState | null = null;
83
+ paths: Paths = [];
84
+ // The routeName will be "login", "main", "game"...
85
+ routeName?: string = undefined;
86
+ // Eventual parameters within the route URL, such as the game uuid
87
+ params?: Params = undefined;
88
+ // The current game being displayed, if any
89
+ game: Game | null = null;
90
+ // The current game list
91
+ gameList: GameListItem[] | null = null;
92
+ // Number of games where it's the player's turn, plus count of zombie games
93
+ numGames = 0;
94
+ loadingGameList = false;
95
+ // The current challenge list
96
+ challengeList: ChallengeListItem[] | null = null;
97
+ // Sum up received challenges and issued timed challenges where the opponent is ready
98
+ numChallenges = 0;
99
+ loadingChallengeList = false;
100
+ // Number of opponents who are ready and waiting for a timed game
101
+ oppReady = 0;
102
+ // Recent games
103
+ recentList: RecentListItem[] | null = null;
104
+ loadingRecentList = false;
105
+ // The currently displayed user list
106
+ userListCriteria: { query: string; spec: string; } | null | undefined = null;
107
+ userList: UserListItem[] | null | undefined = null;
108
+ loadingUserList = false;
109
+ // The user's own statistics
110
+ ownStats: Record<string, any> | null = null;
111
+ // The current user information being edited, if any
112
+ user: UserPrefs | null | undefined = null;
113
+ userErrors: UserErrors | null = null;
114
+ userLoadError: boolean = false;
115
+ // The (cached) help screen contents
116
+ helpHTML: string | null = null;
117
+ // The (cached) friend promo screen contents
118
+ friendHTML: string | null = null;
119
+ // Outstanding server requests
120
+ spinners: number = 0;
121
+ // The index of the game move being reviewed, if any
122
+ reviewMove: number | null = null;
123
+ // The best moves available at this stage, if reviewing game
124
+ bestMoves: Move[] | null = null;
125
+ // The index of the best move being highlighted, if reviewing game
126
+ highlightedMove: number | null = null;
127
+ // Maximum number of free games allowed concurrently
128
+ maxFreeGames = 0;
129
+ isExplo = false;
130
+
131
+ constructor(settings: Settings, state: GlobalState) {
132
+ this.paths = settings.paths.slice();
133
+ this.state = state;
134
+ this.isExplo = state.isExplo;
135
+ this.maxFreeGames = state.isExplo ? MAX_FREE_EXPLO : MAX_FREE_NETSKRAFL;
136
+ // Load localized text messages from the messages.json file
137
+ loadMessages(state.locale);
138
+ }
139
+
140
+ async loadGame(uuid: string, funcComplete: () => void, deleteZombie: boolean = false) {
141
+ // Fetch a game state from the server, given the game's UUID.
142
+ // If deleteZombie is true, we are loading a zombie game for
143
+ // inspection, so we tell the server to remove the zombie marker.
144
+ try {
145
+ if (this.game !== null)
146
+ // We have a prior game in memory:
147
+ // clean it up before allocating the new one
148
+ this.game.cleanup();
149
+ this.game = null;
150
+ this.reviewMove = null;
151
+ this.bestMoves = null;
152
+ this.highlightedMove = null;
153
+ if (!uuid) return; // Should not happen
154
+ const result: { ok: boolean; game: ServerGame; } = await request({
155
+ method: "POST",
156
+ url: "/gamestate",
157
+ body: {
158
+ game: uuid,
159
+ delete_zombie: deleteZombie
160
+ }
161
+ });
162
+ if (!result?.ok) {
163
+ // console.log("Game " + uuid + " could not be loaded");
164
+ }
165
+ else {
166
+ // Create a new game instance and load the state into it
167
+ this.game = new Game(uuid, result.game, this, this.state?.runningLocal ? DEBUG_OVERTIME : MAX_OVERTIME);
168
+ // Successfully loaded: call the completion function, if given
169
+ // (this usually attaches the Firebase event listener)
170
+ if (funcComplete !== undefined)
171
+ funcComplete();
172
+ if (!this.state?.uiFullscreen)
173
+ // Mobile UI: show board tab
174
+ this.game.setSelectedTab("board");
175
+ }
176
+ } catch(e) {
177
+ // If new game cannot be loaded, keep the old one in place
178
+ }
179
+ }
180
+
181
+ async loadGameList(includeZombies: boolean = true) {
182
+ // Load the list of currently active games for this user
183
+ if (this.loadingGameList)
184
+ // Already loading
185
+ return;
186
+ this.loadingGameList = true; // Loading in progress
187
+ this.gameList = [];
188
+ this.numGames = 0;
189
+ this.spinners++;
190
+ try {
191
+ const json: { result: number; gamelist: GameListItem[]; } = await request({
192
+ method: "POST",
193
+ url: "/gamelist",
194
+ body: { zombie: includeZombies }
195
+ });
196
+ if (!json || json.result !== 0) {
197
+ // An error occurred
198
+ this.gameList = [];
199
+ return;
200
+ }
201
+ this.gameList = json.gamelist || [];
202
+ if (this.gameList)
203
+ // Sum up games where it's the player's turn, as well as zombie games
204
+ this.numGames = this.gameList.reduce(
205
+ (acc, item) => acc + (item.my_turn || item.zombie ? 1 : 0), 0
206
+ );
207
+ } catch(e) {
208
+ this.gameList = [];
209
+ } finally {
210
+ this.loadingGameList = false;
211
+ if (this.spinners)
212
+ this.spinners--;
213
+ }
214
+ }
215
+
216
+ async loadChallengeList() {
217
+ // Load the list of current challenges (received and issued)
218
+ if (this.loadingChallengeList)
219
+ return;
220
+ this.loadingChallengeList = true;
221
+ this.challengeList = [];
222
+ this.numChallenges = 0;
223
+ this.oppReady = 0;
224
+ try {
225
+ const json: { result: number; challengelist: ChallengeListItem[]; } = await request({
226
+ method: "POST",
227
+ url: "/challengelist"
228
+ });
229
+ if (!json || json.result !== 0) {
230
+ // An error occurred
231
+ this.challengeList = [];
232
+ return;
233
+ }
234
+ this.challengeList = json.challengelist || [];
235
+ // Count opponents who are ready and waiting for timed games
236
+ for (let ch of this.challengeList) {
237
+ if (ch.opp_ready)
238
+ this.oppReady++;
239
+ }
240
+ this.numChallenges = this.oppReady;
241
+ if (this.challengeList)
242
+ // Sum up received challenges and issued timed challenges where
243
+ // the opponent is ready
244
+ this.numChallenges += this.challengeList.reduce(
245
+ (acc, item) => acc + (item.received ? 1 : 0), 0
246
+ );
247
+ } catch(e) {
248
+ this.challengeList = [];
249
+ } finally {
250
+ this.loadingChallengeList = false;
251
+ }
252
+ }
253
+
254
+ async loadRecentList() {
255
+ // Load the list of recent games for this user
256
+ if (this.loadingRecentList)
257
+ return;
258
+ this.loadingRecentList = true; // Prevent concurrent loading
259
+ this.recentList = [];
260
+ try {
261
+ const json: { result: number; recentlist: RecentListItem[]; } = await request({
262
+ method: "POST",
263
+ url: "/recentlist",
264
+ body: { versus: null, count: 40 }
265
+ });
266
+ if (!json || json.result !== 0) {
267
+ // An error occurred
268
+ this.recentList = [];
269
+ return;
270
+ }
271
+ this.recentList = json.recentlist || [];
272
+ } catch(e) {
273
+ this.recentList = [];
274
+ } finally {
275
+ this.loadingRecentList = false;
276
+ }
277
+ }
278
+
279
+ async loadUserRecentList(userid: string, versus: string | null, readyFunc: (json: any) => void) {
280
+ // Load the list of recent games for the given user
281
+ const json: any = await request({
282
+ method: "POST",
283
+ url: "/recentlist",
284
+ body: { user: userid, versus: versus, count: 40 }
285
+ });
286
+ readyFunc(json);
287
+ }
288
+
289
+ async loadUserList(
290
+ criteria: UserListCriteria,
291
+ activateSpinner: boolean
292
+ ) {
293
+ // Load a list of users according to the given criteria
294
+ if (criteria.query === "search" && criteria.spec === "") {
295
+ // Optimize by not sending an empty search query to the server,
296
+ // since it always returns an empty list
297
+ this.userList = [];
298
+ this.userListCriteria = criteria;
299
+ m.redraw(); // Call this explicitly as we're not calling request()
300
+ return;
301
+ }
302
+ this.userList = undefined;
303
+ this.userListCriteria = undefined; // Marker to prevent concurrent loading
304
+ if (activateSpinner) {
305
+ // This will show a spinner overlay, disabling clicks on
306
+ // all underlying controls
307
+ this.spinners++;
308
+ }
309
+ let url = "/userlist";
310
+ let data: { query?: string; spec?: string; kind?: string; } = criteria;
311
+ if (criteria.query === "elo") {
312
+ // Kludge to make the Elo rating list appear as
313
+ // just another type of user list
314
+ url = "/rating";
315
+ data = { kind: criteria.spec };
316
+ }
317
+ try {
318
+ const json = await request<{
319
+ result: number;
320
+ userlist: UserListItem[];
321
+ rating: UserListItem[];
322
+ }>({
323
+ method: "POST",
324
+ url: url,
325
+ body: data,
326
+ });
327
+ if (!json || json.result !== 0) {
328
+ // An error occurred
329
+ this.userList = [];
330
+ this.userListCriteria = criteria;
331
+ return;
332
+ }
333
+ this.userList = json.userlist || json.rating;
334
+ this.userListCriteria = criteria;
335
+ } catch(e) {
336
+ this.userList = [];
337
+ this.userListCriteria = criteria;
338
+ } finally {
339
+ if (activateSpinner && this.spinners)
340
+ // Remove spinner overlay, if present
341
+ this.spinners--;
342
+ }
343
+ }
344
+
345
+ async loadOwnStats() {
346
+ // Load statistics for the current user
347
+ this.ownStats = {};
348
+ try {
349
+ const json: { result: number; } = await request({
350
+ method: "POST",
351
+ url: "/userstats",
352
+ body: {} // Current user is implicit
353
+ });
354
+ if (!json || json.result !== 0) {
355
+ // An error occurred
356
+ return;
357
+ }
358
+ this.ownStats = json;
359
+ } catch(e) {
360
+ }
361
+ }
362
+
363
+ async loadUserStats(userid: string, readyFunc: (json: UserStats) => void) {
364
+ // Load statistics for the given user
365
+ try {
366
+ const json = await request<UserStats>({
367
+ method: "POST",
368
+ url: "/userstats",
369
+ body: { user: userid }
370
+ });
371
+ readyFunc(json);
372
+ } catch(e) {
373
+ // No need to do anything
374
+ }
375
+ }
376
+
377
+ async loadPromoContent(key: string, readyFunc: (html: string) => void) {
378
+ // Load HTML content for promo dialog
379
+ try {
380
+ const html: string = await request({
381
+ method: "POST",
382
+ url: "/promo",
383
+ body: { key: key },
384
+ responseType: "text",
385
+ deserialize: (str: string) => str
386
+ });
387
+ readyFunc(html);
388
+ } catch(e) {
389
+ // No need to do anything
390
+ }
391
+ }
392
+
393
+ async loadBestMoves(moveIndex: number) {
394
+ // Load the best moves available at a given state in a game
395
+ if (!this.game || !this.game.uuid)
396
+ return;
397
+ if (!moveIndex) {
398
+ // No moves to load, but display summary
399
+ this.reviewMove = 0;
400
+ this.bestMoves = null;
401
+ this.highlightedMove = null;
402
+ this.game.setRack([]);
403
+ this.game.placeTiles(0);
404
+ return;
405
+ }
406
+ const tsc = this.game.tile_scores;
407
+ const rack = this.game.rackAtMove(moveIndex - 1);
408
+ if (!rack || this.game.isFinalMove(moveIndex - 1)) {
409
+ // If there is no rack, or if this is a final (adjustment) move,
410
+ // there are no best moves
411
+ this.reviewMove = moveIndex;
412
+ this.bestMoves = [];
413
+ this.highlightedMove = null;
414
+ if (!rack) {
415
+ this.game.setRack([]);
416
+ } else {
417
+ this.game.setRack(rack.split("").map(tile =>
418
+ [tile, tsc[tile]]
419
+ ));
420
+ }
421
+ this.game.placeTiles(moveIndex);
422
+ return;
423
+ }
424
+ // Don't display navigation buttons while fetching best moves
425
+ this.reviewMove = null;
426
+ // ...but do display a spinner, if this takes too long
427
+ this.spinners++;
428
+ try {
429
+ const rq: MovesRequest = {
430
+ locale: this.game.locale,
431
+ board_type: this.isExplo ? "explo" : "standard",
432
+ board: this.game.boardAsStrings(),
433
+ rack,
434
+ limit: NUM_BEST_MOVES,
435
+ };
436
+ const json = await requestMoves<BestMoves>({
437
+ method: "POST",
438
+ url: "/moves",
439
+ body: rq,
440
+ });
441
+ this.highlightedMove = null;
442
+ if (!json || json.moves === undefined) {
443
+ // Something unexpected going on
444
+ this.reviewMove = null;
445
+ this.bestMoves = null;
446
+ return;
447
+ }
448
+ this.reviewMove = moveIndex;
449
+ this.bestMoves = json.moves.map((m) => {
450
+ return [((moveIndex - 1) % 2) as 0 | 1, [m.co, m.w, m.sc], false];
451
+ });
452
+ this.game.setRack(rack.split("").map(tile =>
453
+ [tile, tsc[tile]]
454
+ ));
455
+ // Populate the board cells with only the tiles
456
+ // laid down up and until the indicated moveIndex
457
+ this.game.placeTiles(moveIndex);
458
+ } catch(e) {
459
+ this.highlightedMove = null;
460
+ this.reviewMove = null;
461
+ this.bestMoves = null;
462
+ } finally {
463
+ if (this.spinners)
464
+ this.spinners--;
465
+ }
466
+ }
467
+
468
+ async loadHelp() {
469
+ // Load the help screen HTML from the server
470
+ // (this is done the first time the help is displayed)
471
+ if (this.helpHTML !== null)
472
+ return; // Already loaded
473
+ try {
474
+ const locale = this.state?.locale || "is_IS";
475
+ const result: string = await request({
476
+ method: "GET",
477
+ url: "/rawhelp?locale=" + locale,
478
+ responseType: "text",
479
+ deserialize: (str: string) => str
480
+ });
481
+ this.helpHTML = result;
482
+ } catch(e) {
483
+ this.helpHTML = "";
484
+ }
485
+ }
486
+
487
+ async loadFriendPromo() {
488
+ // Load the friend promo HTML from the server
489
+ // (this is done the first time the dialog is displayed)
490
+ if (this.friendHTML !== null)
491
+ return; // Already loaded
492
+ try {
493
+ const locale = this.state?.locale || "is_IS";
494
+ const result: string = await request({
495
+ method: "GET",
496
+ url: "/friend?locale=" + locale,
497
+ responseType: "text",
498
+ deserialize: (str: string) => str
499
+ });
500
+ this.friendHTML = result;
501
+ } catch(e) {
502
+ this.friendHTML = "";
503
+ }
504
+ }
505
+
506
+ async loadUser(activateSpinner: boolean) {
507
+ // Fetch the preferences of the currently logged in user, if any
508
+ this.user = undefined;
509
+ if (activateSpinner) {
510
+ // This will show a spinner overlay, disabling clicks on
511
+ // all underlying controls
512
+ this.spinners++;
513
+ }
514
+ try {
515
+ const result: { ok: boolean; userprefs: UserPrefs; } = await request({
516
+ method: "POST",
517
+ url: "/loaduserprefs",
518
+ });
519
+ if (!result || !result.ok) {
520
+ this.user = null;
521
+ this.userErrors = null;
522
+ this.userLoadError = true;
523
+ }
524
+ else {
525
+ this.user = result.userprefs;
526
+ this.userErrors = null;
527
+ }
528
+ } catch(e) {
529
+ this.user = null;
530
+ this.userErrors = null;
531
+ this.userLoadError = true;
532
+ } finally {
533
+ if (activateSpinner && this.spinners)
534
+ this.spinners--;
535
+ }
536
+ }
537
+
538
+ async saveUser(successFunc: () => void) {
539
+ // Update the preferences of the currently logged in user, if any
540
+ const user = this.user;
541
+ if (!user) return;
542
+ try {
543
+ const result: { ok: boolean; err?: UserErrors; } = await request({
544
+ method: "POST",
545
+ url: "/saveuserprefs",
546
+ body: user
547
+ });
548
+ if (result?.ok) {
549
+ // User preferences modified successfully on the server:
550
+ // update the state variables that we're caching
551
+ const state = this.state;
552
+ if (state !== null) {
553
+ state.userNick = user.nickname;
554
+ state.beginner = user.beginner;
555
+ state.fairPlay = user.fairplay;
556
+ }
557
+ // Note that state.plan is updated via a Firebase notification
558
+ // Give the game instance a chance to update its state
559
+ if (this.game !== null)
560
+ this.game.notifyUserChange(user.nickname);
561
+ // Complete: call success function
562
+ if (successFunc !== undefined)
563
+ successFunc();
564
+ // Reset errors
565
+ this.userErrors = null;
566
+ // Ensure that a fresh instance is loaded next time
567
+ this.user = null;
568
+ }
569
+ else {
570
+ // Error saving user prefs: show details, if available
571
+ this.userErrors = result.err || null;
572
+ }
573
+ } catch(e) {
574
+ this.userErrors = null;
575
+ }
576
+ }
577
+
578
+ async setUserPref(pref: Record<string, any>) {
579
+ // Set a user preference
580
+ try {
581
+ await request(
582
+ {
583
+ method: "POST",
584
+ url: "/setuserpref",
585
+ body: pref
586
+ }
587
+ ); // No result required or expected
588
+ } catch (e) {
589
+ // A future TODO might be to signal an error in the UI
590
+ }
591
+ }
592
+
593
+ async newGame(oppid: string, reverse: boolean) {
594
+ // Ask the server to initiate a new game against the given opponent
595
+ try {
596
+ const rqBody: {
597
+ opp: string;
598
+ rev: boolean;
599
+ board_type?: string
600
+ } = { opp: oppid, rev: reverse };
601
+ if (this.isExplo) {
602
+ // On an Explo client, always use the Explo board,
603
+ // regardless of the user's locale setting
604
+ rqBody.board_type = "explo";
605
+ }
606
+ const rq = {
607
+ method: "POST",
608
+ url: "/initgame",
609
+ body: rqBody
610
+ };
611
+ const json = await request<{ ok: boolean; uuid: string; }>(rq);
612
+ if (json?.ok) {
613
+ // Log the new game event
614
+ const locale = this.state?.locale || "is_IS";
615
+ logEvent("new_game",
616
+ {
617
+ uuid: json.uuid,
618
+ timed: reverse,
619
+ locale
620
+ }
621
+ );
622
+ // Go to the newly created game
623
+ m.route.set("/game/" + json.uuid);
624
+ }
625
+ } catch(e) {
626
+ // No need to do anything
627
+ }
628
+ }
629
+
630
+ async modifyChallenge(parameters: ChallengeParameters) {
631
+ // Reject or retract a challenge
632
+ try {
633
+ const json: { result: number; } = await request({
634
+ method: "POST",
635
+ url: "/challenge",
636
+ body: parameters
637
+ });
638
+ if (json?.result === 0) {
639
+ // Log the change of challenge status (issue/decline/retract/accept)
640
+ const locale = this.state?.locale || "is_IS";
641
+ var p: any = { locale };
642
+ if (parameters.duration !== undefined)
643
+ p.duration = parameters.duration;
644
+ if (parameters.fairplay !== undefined)
645
+ p.fairplay = parameters.fairplay;
646
+ if (parameters.manual !== undefined)
647
+ p.manual = parameters.manual;
648
+ logEvent("challenge_" + parameters.action, p);
649
+ // Reload list of challenges from server
650
+ this.loadChallengeList();
651
+ if (this.userListCriteria)
652
+ // We are showing a user list: reload it
653
+ this.loadUserList(this.userListCriteria, false);
654
+ }
655
+ } catch(e) {
656
+ // A future TODO is to indicate an error in the UI
657
+ }
658
+ }
659
+
660
+ async markFavorite(userId: string, status: boolean) {
661
+ // Mark or de-mark a user as a favorite
662
+ try {
663
+ await request({
664
+ method: "POST",
665
+ url: "/favorite",
666
+ body: { destuser: userId, action: status ? "add" : "delete" }
667
+ });
668
+ } catch(e) {
669
+ // No need to do anything here - a future TODO is to indicate an error in the UI
670
+ }
671
+ }
672
+
673
+ async cancelFriendship() {
674
+ // Cancel the current user as a friend
675
+ const user = this.user;
676
+ const state = this.state;
677
+ if (!user || !state) return;
678
+ try {
679
+ const json: { ok: boolean; } = await request({
680
+ method: "POST",
681
+ url: "/cancelplan",
682
+ body: { }
683
+ });
684
+ if (json?.ok) {
685
+ // Successfully cancelled: immediately update the friend and hasPaid state
686
+ user.friend = false;
687
+ state.hasPaid = false;
688
+ state.plan = "";
689
+ // Log a friendship cancellation event
690
+ logEvent("cancel_plan",
691
+ {
692
+ userid: state.userId,
693
+ locale: state.locale,
694
+ // Add plan identifiers here
695
+ plan: "friend"
696
+ }
697
+ );
698
+ return true;
699
+ }
700
+ } catch(e) {
701
+ // No need to do anything here - a future TODO is to indicate an error in the UI
702
+ }
703
+ return false;
704
+ }
705
+
706
+ addChatMessage(game: string, from_userid: string, msg: string, ts: string): boolean {
707
+ // Add a chat message to the game's chat message list
708
+ if (this.game && this.game.uuid == game) {
709
+ const userId = this.state?.userId ?? "";
710
+ this.game.addChatMessage(from_userid, msg, ts, from_userid == userId);
711
+ // Returning true triggers a redraw
712
+ return true;
713
+ }
714
+ return false;
715
+ }
716
+
717
+ handleUserMessage(json: any, firstAttach: boolean) {
718
+ // Handle an incoming Firebase user message, i.e. a message
719
+ // on the /user/[userid] path
720
+ if (firstAttach || !this.state)
721
+ return;
722
+ let redraw = false;
723
+ if (json.friend !== undefined) {
724
+ // Potential change of user friendship status
725
+ const newFriend = json.friend ? true : false;
726
+ if (this.user && this.user.friend != newFriend) {
727
+ this.user.friend = newFriend;
728
+ redraw = true;
729
+ }
730
+ }
731
+ if (json.plan !== undefined) {
732
+ // Potential change of user subscription plan
733
+ if (this.state.plan != json.plan) {
734
+ this.state.plan = json.plan;
735
+ redraw = true;
736
+ }
737
+ if (this.user && !this.user.friend && this.state.plan == "friend") {
738
+ // plan == "friend" implies that user.friend should be true
739
+ this.user.friend = true;
740
+ redraw = true;
741
+ }
742
+ if (this.state.plan == "" && this.user?.friend) {
743
+ // Conversely, an empty plan string means that the user is not a friend
744
+ this.user.friend = false;
745
+ redraw = true;
746
+ }
747
+ }
748
+ if (json.hasPaid !== undefined) {
749
+ // Potential change of payment status
750
+ const newHasPaid = (this.state.plan != "" && json.hasPaid) ? true : false;
751
+ if (this.state.hasPaid != newHasPaid) {
752
+ this.state.hasPaid = newHasPaid;
753
+ redraw = true;
754
+ }
755
+ }
756
+ let invalidateGameList = false;
757
+ // The following code is a bit iffy since both json.challenge and json.move
758
+ // are included in the same message on the /user/[userid] path.
759
+ // !!! FIXME: Split this into two separate listeners,
760
+ // !!! one for challenges and one for moves
761
+ if (json.challenge) {
762
+ // Reload challenge list
763
+ this.loadChallengeList();
764
+ if (this.userListCriteria)
765
+ // We are showing a user list: reload it
766
+ this.loadUserList(this.userListCriteria, false);
767
+ // Reload game list
768
+ // !!! FIXME: It is strictly speaking not necessary to reload
769
+ // !!! the game list unless this is an acceptance of a challenge
770
+ // !!! (issuance or rejection don't cause the game list to change)
771
+ invalidateGameList = true;
772
+ } else if (json.move) {
773
+ // A move has been made in one of this user's games:
774
+ // invalidate the game list (will be loaded upon next display)
775
+ invalidateGameList = true;
776
+ }
777
+ if (invalidateGameList && !this.loadingGameList) {
778
+ this.gameList = null;
779
+ redraw = true;
780
+ }
781
+ if (redraw)
782
+ m.redraw();
783
+ }
784
+
785
+ handleMoveMessage(json: ServerGame, firstAttach: boolean) {
786
+ // Handle an incoming Firebase move message
787
+ if (!firstAttach && this.game) {
788
+ this.game.update(json);
789
+ m.redraw();
790
+ }
791
+ }
792
+
793
+ notifyMove() {
794
+ // A move has been made in the game:
795
+ // invalidate the game list, since it may have changed
796
+ if (!this.loadingGameList) {
797
+ this.gameList = null;
798
+ }
799
+ }
800
+
801
+ moreGamesAllowed(): boolean {
802
+ // Return true if the user is allowed to have more games ongoing
803
+ if (!this.state)
804
+ return false;
805
+ if (this.loadingGameList)
806
+ return false;
807
+ if (!this.gameList)
808
+ return true;
809
+ const numGames = this.gameList.length;
810
+ if (numGames >= MAX_GAMES)
811
+ return false;
812
+ if (this.state.hasPaid)
813
+ return true;
814
+ return this.gameList.length < this.maxFreeGames;
815
+ }
816
+
817
+ } // class Model