@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,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
|