@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.
- package/.eslintignore +8 -0
- package/.eslintrc.json +13 -0
- package/README.md +63 -0
- package/dist/cjs/index.css +6837 -0
- package/dist/cjs/index.js +3046 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.css +6837 -0
- package/dist/esm/index.js +3046 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +63 -0
- package/rollup.config.js +60 -0
- package/src/components/index.ts +2 -0
- package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
- package/src/components/netskrafl/Netskrafl.tsx +135 -0
- package/src/components/netskrafl/Netskrafl.types.ts +7 -0
- package/src/components/netskrafl/index.ts +2 -0
- package/src/css/fonts.css +4 -0
- package/src/css/glyphs.css +224 -0
- package/src/css/skrafl-explo.css +6616 -0
- package/src/fonts/glyphicons-regular.eot +0 -0
- package/src/fonts/glyphicons-regular.ttf +0 -0
- package/src/fonts/glyphicons-regular.woff +0 -0
- package/src/index.ts +2 -0
- package/src/messages/messages.json +1576 -0
- package/src/mithril/actions.ts +319 -0
- package/src/mithril/bag.ts +65 -0
- package/src/mithril/bestdisplay.ts +74 -0
- package/src/mithril/blankdialog.ts +94 -0
- package/src/mithril/board.ts +336 -0
- package/src/mithril/buttons.ts +303 -0
- package/src/mithril/challengedialog.ts +186 -0
- package/src/mithril/channel.ts +162 -0
- package/src/mithril/chat.ts +228 -0
- package/src/mithril/components.ts +496 -0
- package/src/mithril/dragdrop.ts +219 -0
- package/src/mithril/elopage.ts +180 -0
- package/src/mithril/friend.ts +227 -0
- package/src/mithril/game.ts +1378 -0
- package/src/mithril/gameview.ts +111 -0
- package/src/mithril/globalstate.ts +33 -0
- package/src/mithril/i18n.ts +186 -0
- package/src/mithril/localstorage.ts +133 -0
- package/src/mithril/login.ts +122 -0
- package/src/mithril/logo.ts +270 -0
- package/src/mithril/main.ts +737 -0
- package/src/mithril/mithril.ts +29 -0
- package/src/mithril/model.ts +817 -0
- package/src/mithril/movelistitem.ts +226 -0
- package/src/mithril/page.ts +852 -0
- package/src/mithril/playername.ts +91 -0
- package/src/mithril/promodialog.ts +82 -0
- package/src/mithril/recentlist.ts +148 -0
- package/src/mithril/request.ts +52 -0
- package/src/mithril/review.ts +634 -0
- package/src/mithril/rightcolumn.ts +398 -0
- package/src/mithril/searchbutton.ts +118 -0
- package/src/mithril/statsdisplay.ts +109 -0
- package/src/mithril/tabs.ts +169 -0
- package/src/mithril/tile.ts +145 -0
- package/src/mithril/twoletter.ts +76 -0
- package/src/mithril/types.ts +379 -0
- package/src/mithril/userinfodialog.ts +171 -0
- package/src/mithril/util.ts +304 -0
- package/src/mithril/wait.ts +246 -0
- package/src/mithril/wordcheck.ts +102 -0
- package/tsconfig.json +28 -0
- 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
|