@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,304 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Util.ts
|
|
4
|
+
|
|
5
|
+
Utility functions for the Explo/Netskrafl user interface
|
|
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
|
+
The following code is based on
|
|
15
|
+
https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
|
|
16
|
+
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { m, MithrilDragEvent, Vnode } from "./mithril";
|
|
20
|
+
import { BOARD_SIZE, ROWIDS } from "./types";
|
|
21
|
+
|
|
22
|
+
type UrlParams = "uuid" | "move" | "tab" | "faq" | "zombie";
|
|
23
|
+
|
|
24
|
+
// Possible URL parameters that are passed to routes
|
|
25
|
+
export type Params = Partial<Record<UrlParams, string>>;
|
|
26
|
+
|
|
27
|
+
type ZoomFunc = () => void;
|
|
28
|
+
|
|
29
|
+
// Global vars to cache event state
|
|
30
|
+
var evCache: PointerEvent[] = [];
|
|
31
|
+
var origDistance = -1;
|
|
32
|
+
const PINCH_THRESHOLD = 10; // Minimum pinch movement
|
|
33
|
+
var hasZoomed = false;
|
|
34
|
+
|
|
35
|
+
// Old-style (non-single-page) game URL prefix
|
|
36
|
+
const BOARD_PREFIX = "/board?game=";
|
|
37
|
+
const BOARD_PREFIX_LEN = BOARD_PREFIX.length;
|
|
38
|
+
|
|
39
|
+
export function addPinchZoom(attrs: any, funcZoomIn: ZoomFunc, funcZoomOut: ZoomFunc) {
|
|
40
|
+
// Install event handlers for the pointer target
|
|
41
|
+
attrs.onpointerdown = pointerdown_handler;
|
|
42
|
+
attrs.onpointermove = pointermove_handler.bind(null, funcZoomIn, funcZoomOut);
|
|
43
|
+
// Use same handler for pointer{up,cancel,out,leave} events since
|
|
44
|
+
// the semantics for these events - in this app - are the same.
|
|
45
|
+
attrs.onpointerup = pointerup_handler;
|
|
46
|
+
attrs.onpointercancel = pointerup_handler;
|
|
47
|
+
attrs.onpointerout = pointerup_handler;
|
|
48
|
+
attrs.onpointerleave = pointerup_handler;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pointerdown_handler(ev: PointerEvent) {
|
|
52
|
+
// The pointerdown event signals the start of a touch interaction.
|
|
53
|
+
// This event is cached to support 2-finger gestures
|
|
54
|
+
evCache.push(ev);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pointermove_handler(funcZoomIn: ZoomFunc, funcZoomOut: ZoomFunc, ev: PointerEvent) {
|
|
58
|
+
// This function implements a 2-pointer horizontal pinch/zoom gesture.
|
|
59
|
+
//
|
|
60
|
+
// If the distance between the two pointers has increased (zoom in),
|
|
61
|
+
// the target element's background is changed to "pink" and if the
|
|
62
|
+
// distance is decreasing (zoom out), the color is changed to "lightblue".
|
|
63
|
+
//
|
|
64
|
+
// Find this event in the cache and update its record with this event
|
|
65
|
+
for (let i = 0; i < evCache.length; i++) {
|
|
66
|
+
if (ev.pointerId === evCache[i].pointerId) {
|
|
67
|
+
evCache[i] = ev;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If two pointers are down, check for pinch gestures
|
|
73
|
+
if (evCache.length == 2) {
|
|
74
|
+
// Calculate the distance between the two pointers
|
|
75
|
+
const curDistance = Math.sqrt(
|
|
76
|
+
Math.pow(evCache[0].clientX - evCache[1].clientX, 2) +
|
|
77
|
+
Math.pow(evCache[0].clientY - evCache[1].clientY, 2)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (origDistance > 0) {
|
|
81
|
+
if (curDistance - origDistance >= PINCH_THRESHOLD) {
|
|
82
|
+
// The distance between the two pointers has increased
|
|
83
|
+
if (!hasZoomed)
|
|
84
|
+
funcZoomIn();
|
|
85
|
+
hasZoomed = true;
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
if (origDistance - curDistance >= PINCH_THRESHOLD) {
|
|
89
|
+
// The distance between the two pointers has decreased
|
|
90
|
+
if (!hasZoomed)
|
|
91
|
+
funcZoomOut();
|
|
92
|
+
hasZoomed = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
if (origDistance < 0) {
|
|
97
|
+
// Note the original difference between two pointers
|
|
98
|
+
origDistance = curDistance;
|
|
99
|
+
hasZoomed = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pointerup_handler(ev: PointerEvent) {
|
|
105
|
+
// Remove this pointer from the cache and reset the target's
|
|
106
|
+
// background and border
|
|
107
|
+
remove_event(ev);
|
|
108
|
+
// If the number of pointers down is less than two then reset diff tracker
|
|
109
|
+
if (evCache.length < 2) {
|
|
110
|
+
origDistance = -1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function remove_event(ev: PointerEvent) {
|
|
115
|
+
// Remove this event from the target's cache
|
|
116
|
+
for (let i = 0; i < evCache.length; i++) {
|
|
117
|
+
if (evCache[i].pointerId === ev.pointerId) {
|
|
118
|
+
evCache.splice(i, 1);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buttonOver(ev: Event) {
|
|
125
|
+
const clist = (ev.currentTarget as HTMLElement).classList;
|
|
126
|
+
if (clist !== undefined && !clist.contains("disabled"))
|
|
127
|
+
clist.add("over");
|
|
128
|
+
(ev as MithrilDragEvent).redraw = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buttonOut(ev: Event) {
|
|
132
|
+
const clist = (ev.currentTarget as HTMLElement).classList;
|
|
133
|
+
if (clist !== undefined)
|
|
134
|
+
clist.remove("over");
|
|
135
|
+
(ev as MithrilDragEvent).redraw = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Glyphicon utility function: inserts a glyphicon span
|
|
139
|
+
export function glyph(icon: string, attrs?: any, grayed?: boolean): Vnode {
|
|
140
|
+
return m("span.glyphicon.glyphicon-" + icon + (grayed ? ".grayed" : ""), attrs);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function glyphGrayed(icon: string, attrs?: any): Vnode {
|
|
144
|
+
return m("span.glyphicon.glyphicon-" + icon + ".grayed", attrs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Utility function: inserts non-breaking space
|
|
148
|
+
export function nbsp(n?: number): Vnode {
|
|
149
|
+
if (!n || n === 1)
|
|
150
|
+
return m.trust(" ");
|
|
151
|
+
let r: Vnode[] = [];
|
|
152
|
+
for (let i = 0; i < n; i++)
|
|
153
|
+
r.push(m.trust(" "));
|
|
154
|
+
return m.fragment({}, r);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Utility functions
|
|
158
|
+
|
|
159
|
+
export function escapeHtml(string: string): string {
|
|
160
|
+
/* Utility function to properly encode a string into HTML */
|
|
161
|
+
const entityMap: Record<string, string> = {
|
|
162
|
+
"&": "&",
|
|
163
|
+
"<": "<",
|
|
164
|
+
">": ">",
|
|
165
|
+
'"': '"',
|
|
166
|
+
"'": ''',
|
|
167
|
+
"/": '/'
|
|
168
|
+
};
|
|
169
|
+
return String(string).replace(/[&<>"'/]/g, (s) => entityMap[s] ?? "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function getUrlVars(url: string): Params {
|
|
173
|
+
// Get values from a URL query string
|
|
174
|
+
const hashes = url.split('&');
|
|
175
|
+
const vars: Params = {};
|
|
176
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
177
|
+
const hash = hashes[i].split('=');
|
|
178
|
+
if (hash.length == 2)
|
|
179
|
+
vars[hash[0] as keyof Params] = decodeURIComponent(hash[1]);
|
|
180
|
+
}
|
|
181
|
+
return vars;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getInput(id: string): string {
|
|
185
|
+
// Return the current value of a text input field
|
|
186
|
+
const elem = document.getElementById(id) as HTMLInputElement;
|
|
187
|
+
return elem.value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function setInput(id: string, val: string) {
|
|
191
|
+
// Set the current value of a text input field
|
|
192
|
+
const elem = document.getElementById(id) as HTMLInputElement;
|
|
193
|
+
elem.value = val;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function playAudio(elemId: string) {
|
|
197
|
+
// Play an audio file
|
|
198
|
+
const sound = document.getElementById(elemId) as HTMLMediaElement;
|
|
199
|
+
if (sound)
|
|
200
|
+
sound.play();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function arrayEqual(a: any[], b: any[]): boolean {
|
|
204
|
+
// Return true if arrays a and b are equal
|
|
205
|
+
if (a.length != b.length)
|
|
206
|
+
return false;
|
|
207
|
+
for (let i = 0; i < a.length; i++)
|
|
208
|
+
if (a[i] != b[i])
|
|
209
|
+
return false;
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function gameUrl(url: string): string {
|
|
214
|
+
// Convert old-style game URL to new-style single-page URL
|
|
215
|
+
// The URL format is "/board?game=ed27b9f0-d429-11eb-8bc7-d43d7ee303b2&zombie=1"
|
|
216
|
+
if (url.slice(0, BOARD_PREFIX_LEN) == BOARD_PREFIX)
|
|
217
|
+
// Cut off "/board?game="
|
|
218
|
+
url = url.slice(BOARD_PREFIX_LEN);
|
|
219
|
+
// Isolate the game UUID
|
|
220
|
+
const uuid = url.slice(0, 36);
|
|
221
|
+
// Isolate the other parameters, if any
|
|
222
|
+
let params = url.slice(36);
|
|
223
|
+
// Start parameter section of URL with a ? sign
|
|
224
|
+
if (params.length > 0 && params.charAt(0) == "&")
|
|
225
|
+
params = "?" + params.slice(1);
|
|
226
|
+
// Return the single-page URL, to be consumed by m.route.Link()
|
|
227
|
+
return "/game/" + uuid + params;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function scrollMovelistToBottom(): void {
|
|
231
|
+
// If the length of the move list has changed,
|
|
232
|
+
// scroll the last move into view
|
|
233
|
+
let movelist = document.querySelectorAll("div.movelist .move");
|
|
234
|
+
if (!movelist || !movelist.length)
|
|
235
|
+
return;
|
|
236
|
+
let target = movelist[movelist.length - 1] as HTMLElement;
|
|
237
|
+
let parent = target.parentNode as HTMLElement;
|
|
238
|
+
let len = parent.getAttribute("data-len");
|
|
239
|
+
let intLen = (!len) ? 0 : parseInt(len);
|
|
240
|
+
if (movelist.length > intLen) {
|
|
241
|
+
// The list has grown since we last updated it:
|
|
242
|
+
// scroll to the bottom and mark its length
|
|
243
|
+
parent.scrollTop = target.offsetTop;
|
|
244
|
+
}
|
|
245
|
+
parent.setAttribute("data-len", movelist.length.toString());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function coord(row: number, col: number): string | null {
|
|
249
|
+
// Return the co-ordinate string for the given 0-based row and col
|
|
250
|
+
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE)
|
|
251
|
+
return null;
|
|
252
|
+
return ROWIDS[row] + (col + 1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function toVector(co: string): { col: number, row: number, dx: number, dy: number } {
|
|
256
|
+
// Convert a co-ordinate string to a 0-based row, col and direction vector
|
|
257
|
+
var dx = 0, dy = 0;
|
|
258
|
+
var col = 0;
|
|
259
|
+
var row = ROWIDS.indexOf(co[0]);
|
|
260
|
+
if (row >= 0) {
|
|
261
|
+
/* Horizontal move */
|
|
262
|
+
col = parseInt(co.slice(1)) - 1;
|
|
263
|
+
dx = 1;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
/* Vertical move */
|
|
267
|
+
row = ROWIDS.indexOf(co.slice(-1));
|
|
268
|
+
col = parseInt(co) - 1;
|
|
269
|
+
dy = 1;
|
|
270
|
+
}
|
|
271
|
+
return { col: col, row: row, dx: dx, dy: dy };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function valueOrK(value: number, breakpoint: number = 10000): string {
|
|
275
|
+
// Return a numeric value as a string, but in kilos (thousands)
|
|
276
|
+
// if it exceeds a breakpoint, in that case suffixed by "K"
|
|
277
|
+
const sign = value < 0 ? "-" : "";
|
|
278
|
+
value = Math.abs(value);
|
|
279
|
+
if (value < breakpoint)
|
|
280
|
+
return `${sign}${value}`;
|
|
281
|
+
value = Math.round(value / 1000);
|
|
282
|
+
return `${sign}${value}K`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// SalesCloud stuff
|
|
286
|
+
function doRegisterSalesCloud(i: any,s: any,o: any,g: any,r: any,a?:any,m?:any) {
|
|
287
|
+
i.SalesCloudObject=r;
|
|
288
|
+
i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments);};
|
|
289
|
+
i[r].l=1*(new Date() as any);
|
|
290
|
+
a=s.createElement(o);
|
|
291
|
+
m=s.getElementsByTagName(o)[0];
|
|
292
|
+
a.src=g;
|
|
293
|
+
m.parentNode.insertBefore(a,m);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function registerSalesCloud() {
|
|
297
|
+
doRegisterSalesCloud(
|
|
298
|
+
window,
|
|
299
|
+
document,
|
|
300
|
+
'script',
|
|
301
|
+
'https://cdn.salescloud.is/js/salescloud.min.js',
|
|
302
|
+
'salescloud'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Wait.ts
|
|
4
|
+
|
|
5
|
+
Code for the WaitDialog and AcceptDialog components,
|
|
6
|
+
used in the UI flow for timed games
|
|
7
|
+
|
|
8
|
+
Copyright (C) 2024 Miðeind ehf.
|
|
9
|
+
Original author: Vilhjálmur Þorsteinsson
|
|
10
|
+
|
|
11
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
12
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
13
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
14
|
+
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { IView } from "./types";
|
|
18
|
+
import { m, ComponentFunc } from "./mithril";
|
|
19
|
+
import { mt, ts } from "./i18n";
|
|
20
|
+
import { request } from "./request";
|
|
21
|
+
import { DialogButton, OnlinePresence } from "./components";
|
|
22
|
+
import { glyph } from "./util";
|
|
23
|
+
import { attachFirebaseListener, detachFirebaseListener } from "./channel";
|
|
24
|
+
|
|
25
|
+
export const WaitDialog: ComponentFunc<{
|
|
26
|
+
view: IView;
|
|
27
|
+
oppId: string;
|
|
28
|
+
oppNick: string;
|
|
29
|
+
oppName: string;
|
|
30
|
+
duration: number;
|
|
31
|
+
challengeKey: string;
|
|
32
|
+
}> = (initialVnode) => {
|
|
33
|
+
|
|
34
|
+
// A dialog that is shown while the user waits for the opponent,
|
|
35
|
+
// who issued a timed game challenge, to be ready
|
|
36
|
+
|
|
37
|
+
const attrs = initialVnode.attrs;
|
|
38
|
+
const view = attrs.view;
|
|
39
|
+
const model = view.model;
|
|
40
|
+
const duration = attrs.duration;
|
|
41
|
+
const oppId = attrs.oppId;
|
|
42
|
+
const key = attrs.challengeKey;
|
|
43
|
+
let oppNick = attrs.oppNick;
|
|
44
|
+
let oppName = attrs.oppName;
|
|
45
|
+
let oppOnline = false;
|
|
46
|
+
const userId = model.state?.userId ?? "";
|
|
47
|
+
// Firebase path
|
|
48
|
+
const path = 'user/' + userId + "/wait/" + oppId;
|
|
49
|
+
// Flag set when the new game has been initiated
|
|
50
|
+
let pointOfNoReturn = false;
|
|
51
|
+
|
|
52
|
+
async function updateOnline() {
|
|
53
|
+
// Initiate an online check on the opponent
|
|
54
|
+
try {
|
|
55
|
+
if (!oppId || !key) return;
|
|
56
|
+
const json: { online: boolean; waiting: boolean; } = await request({
|
|
57
|
+
method: "POST",
|
|
58
|
+
url: "/initwait",
|
|
59
|
+
body: { opp: oppId, key }
|
|
60
|
+
});
|
|
61
|
+
// If json.waiting is false, the initiation failed
|
|
62
|
+
// and there is really no point in continuing to wait
|
|
63
|
+
if (json && json.online && json.waiting)
|
|
64
|
+
// The user is online
|
|
65
|
+
oppOnline = true;
|
|
66
|
+
}
|
|
67
|
+
catch(e) {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cancelWait() {
|
|
72
|
+
// Cancel a pending wait for a timed game
|
|
73
|
+
try {
|
|
74
|
+
await request({
|
|
75
|
+
method: "POST",
|
|
76
|
+
url: "/cancelwait",
|
|
77
|
+
body: {
|
|
78
|
+
user: userId,
|
|
79
|
+
opp: oppId,
|
|
80
|
+
key
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch(e) {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
oninit: () => {
|
|
90
|
+
if (!userId || !oppId) return; // Should not happen
|
|
91
|
+
updateOnline();
|
|
92
|
+
// Attach a Firebase listener to the wait path
|
|
93
|
+
attachFirebaseListener(path, (json: true | { game: string }) => {
|
|
94
|
+
if (json !== true && json.game) {
|
|
95
|
+
// A new game has been created and initiated by the server
|
|
96
|
+
pointOfNoReturn = true;
|
|
97
|
+
detachFirebaseListener(path);
|
|
98
|
+
// We don't need to pop the dialog; that is done automatically
|
|
99
|
+
// by the route resolver upon m.route.set()
|
|
100
|
+
// Navigate to the newly initiated game
|
|
101
|
+
m.route.set("/game/" + json.game);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
view: () => {
|
|
106
|
+
return m(".modal-dialog",
|
|
107
|
+
{ id: "wait-dialog", style: { visibility: "visible" } },
|
|
108
|
+
m(".ui-widget.ui-widget-content.ui-corner-all", { "id": "wait-form" },
|
|
109
|
+
[
|
|
110
|
+
m(".chall-hdr",
|
|
111
|
+
m("table",
|
|
112
|
+
m("tbody",
|
|
113
|
+
m("tr", [
|
|
114
|
+
m("td", m("h1.chall-icon", glyph("time"))),
|
|
115
|
+
m("td.l-border", [
|
|
116
|
+
m(OnlinePresence, { id: "chall-online", userId: oppId, online: oppOnline }),
|
|
117
|
+
m("h1", oppNick),
|
|
118
|
+
m("h2", oppName)
|
|
119
|
+
])
|
|
120
|
+
])
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
),
|
|
124
|
+
m(".wait-explain", [
|
|
125
|
+
mt("p", [
|
|
126
|
+
"Þú ert reiðubúin(n) að taka áskorun um viðureign með klukku, ",
|
|
127
|
+
m("strong", [ "2 x ", duration.toString(), ts(" mínútur.") ])
|
|
128
|
+
]),
|
|
129
|
+
mt("p", [
|
|
130
|
+
"Beðið er eftir að áskorandinn ", m("strong", oppNick),
|
|
131
|
+
" sé ", oppOnline ? "" : mt("span#chall-is-online", "álínis og "), "til í tuskið."
|
|
132
|
+
]),
|
|
133
|
+
mt("p", "Leikur hefst um leið og áskorandinn bregst við. Handahóf ræður hvor byrjar."),
|
|
134
|
+
mt("p", "Ef þér leiðist biðin geturðu hætt við og reynt aftur síðar.")
|
|
135
|
+
]),
|
|
136
|
+
m(DialogButton,
|
|
137
|
+
{
|
|
138
|
+
id: "wait-cancel",
|
|
139
|
+
title: ts("Hætta við"),
|
|
140
|
+
onclick: (ev: Event) => {
|
|
141
|
+
// Cancel the wait status and navigate back to the main page
|
|
142
|
+
if (pointOfNoReturn) {
|
|
143
|
+
// Actually, it's too late to cancel
|
|
144
|
+
ev.preventDefault();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
detachFirebaseListener(path);
|
|
148
|
+
cancelWait();
|
|
149
|
+
view.popDialog();
|
|
150
|
+
ev.preventDefault();
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
glyph("remove")
|
|
154
|
+
)
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const AcceptDialog: ComponentFunc<{
|
|
163
|
+
view: IView; oppId: string; oppNick: string; challengeKey: string;
|
|
164
|
+
}> = (initialVnode) => {
|
|
165
|
+
|
|
166
|
+
// A dialog that is shown (usually briefly) while
|
|
167
|
+
// the user who originated a timed game challenge
|
|
168
|
+
// is linked up with her opponent and a new game is started
|
|
169
|
+
|
|
170
|
+
const attrs = initialVnode.attrs;
|
|
171
|
+
const view = attrs.view;
|
|
172
|
+
const model = view.model;
|
|
173
|
+
const oppId = attrs.oppId;
|
|
174
|
+
const key = attrs.challengeKey;
|
|
175
|
+
let oppNick = attrs.oppNick;
|
|
176
|
+
let oppReady = true;
|
|
177
|
+
|
|
178
|
+
async function waitCheck() {
|
|
179
|
+
// Initiate a wait status check on the opponent
|
|
180
|
+
try {
|
|
181
|
+
const json: { waiting: boolean; } = await request({
|
|
182
|
+
method: "POST",
|
|
183
|
+
url: "/waitcheck",
|
|
184
|
+
body: { user: oppId, key }
|
|
185
|
+
});
|
|
186
|
+
if (json?.waiting) {
|
|
187
|
+
// Both players are now ready: Start the timed game.
|
|
188
|
+
// The newGame() call switches to a new route (/game),
|
|
189
|
+
// and all open dialogs are thereby closed automatically.
|
|
190
|
+
model.newGame(oppId, true);
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
// Something didn't check out: keep the dialog open
|
|
194
|
+
// until the user manually closes it
|
|
195
|
+
oppReady = false;
|
|
196
|
+
}
|
|
197
|
+
catch(e) {
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
oninit: () => waitCheck(),
|
|
203
|
+
view: () => {
|
|
204
|
+
return m(".modal-dialog",
|
|
205
|
+
{ id: "accept-dialog", style: { visibility: "visible" } },
|
|
206
|
+
m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" },
|
|
207
|
+
[
|
|
208
|
+
m(".chall-hdr",
|
|
209
|
+
m("table",
|
|
210
|
+
m("tbody",
|
|
211
|
+
m("tr",
|
|
212
|
+
[
|
|
213
|
+
m("td", m("h1.chall-icon", glyph("time"))),
|
|
214
|
+
m("td.l-border", m("h1", oppNick))
|
|
215
|
+
]
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
),
|
|
220
|
+
m("div", { "style": { "text-align": "center", "padding-top": "32px" }},
|
|
221
|
+
[
|
|
222
|
+
m("p", mt("strong", "Viðureign með klukku")),
|
|
223
|
+
mt("p",
|
|
224
|
+
oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
|
|
225
|
+
: ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"]
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
),
|
|
229
|
+
m(DialogButton,
|
|
230
|
+
{
|
|
231
|
+
id: 'accept-cancel',
|
|
232
|
+
title: ts('Reyna síðar'),
|
|
233
|
+
onclick: (ev: Event) => {
|
|
234
|
+
// Abort mission
|
|
235
|
+
view.popDialog();
|
|
236
|
+
ev.preventDefault();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
glyph("remove")
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
)
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Wordcheck.ts
|
|
4
|
+
|
|
5
|
+
Wrapper for word checking and caching
|
|
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 { requestMoves } from "./request";
|
|
17
|
+
|
|
18
|
+
interface WordCheckResult {
|
|
19
|
+
word: string;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
valid: [string, boolean][];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class WordChecker {
|
|
25
|
+
|
|
26
|
+
// Global cache for word check results. The cache is first indexed
|
|
27
|
+
// by locale, and then by word.
|
|
28
|
+
|
|
29
|
+
private wordCheckCache: Record<string, Record<string, boolean>> = {};
|
|
30
|
+
|
|
31
|
+
constructor() {}
|
|
32
|
+
|
|
33
|
+
ingestTwoLetterWords(locale: string, twoLetterWords: string[][]) {
|
|
34
|
+
// Initialize the cache with a list of known two-letter words
|
|
35
|
+
// The two-letter word list contains a list of entries where each
|
|
36
|
+
// entry is an initial letter followed by a list of two-letter words
|
|
37
|
+
// starting with that letter.
|
|
38
|
+
const cache = this.wordCheckCache[locale] ?? (this.wordCheckCache[locale] = {});
|
|
39
|
+
for (const [/* firstLetter */, wordList] of twoLetterWords) {
|
|
40
|
+
for (const word of wordList) {
|
|
41
|
+
cache[word] = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async checkWords(locale: string, words: string[]): Promise<boolean> {
|
|
47
|
+
// Return true if all words are valid in the given locale,
|
|
48
|
+
// or false otherwise. Lookups are cached for efficiency.
|
|
49
|
+
let cache = this.wordCheckCache[locale];
|
|
50
|
+
if (cache) {
|
|
51
|
+
let allValid = true;
|
|
52
|
+
for (const word of words) {
|
|
53
|
+
if (cache[word] === undefined) {
|
|
54
|
+
if (word.length === 2)
|
|
55
|
+
// Special case for two-letter words: if they are not
|
|
56
|
+
// in the cache, they are not valid - no roundtrip needed
|
|
57
|
+
return false;
|
|
58
|
+
// Word not found in cache; we need a server roundtrip
|
|
59
|
+
allValid = false;
|
|
60
|
+
break;
|
|
61
|
+
} else if (cache[word] === false) {
|
|
62
|
+
// Word is known to be invalid
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (allValid) {
|
|
67
|
+
// All words are known to be valid
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// We need a server roundtrip
|
|
73
|
+
try {
|
|
74
|
+
const response = await requestMoves<WordCheckResult>({
|
|
75
|
+
url: "/wordcheck",
|
|
76
|
+
body: {
|
|
77
|
+
locale,
|
|
78
|
+
word: words[0],
|
|
79
|
+
words,
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (response?.word === words[0] && response?.valid) {
|
|
83
|
+
// Looks like a valid response
|
|
84
|
+
if (!cache) {
|
|
85
|
+
// We didn't already have a cache for this locale
|
|
86
|
+
cache = this.wordCheckCache[locale] = {};
|
|
87
|
+
}
|
|
88
|
+
// Harvest the returned data into the cache
|
|
89
|
+
for (const [word, result] of response.valid) {
|
|
90
|
+
cache[word] = result;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return !!response.ok;
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Global word checker cache
|
|
102
|
+
export const wordChecker = new WordChecker();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": ["src"],
|
|
3
|
+
"exclude": ["node_modules", "dist", "rollup.config.js"],
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2018",
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"module": "esnext",
|
|
14
|
+
"moduleResolution": "node",
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"lib": [
|
|
20
|
+
"dom",
|
|
21
|
+
"dom.iterable",
|
|
22
|
+
"esnext"
|
|
23
|
+
],
|
|
24
|
+
"paths": {
|
|
25
|
+
"@/*": ["./src/*"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|