@uzuhq/code-cli 0.3.20 → 0.4.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.
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { randomUUID } from 'crypto';
|
|
15
15
|
import { compare, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
|
|
16
16
|
import { SeededRandomImpl } from './random.js';
|
|
17
|
+
import { SCHEDULED_ACTOR } from './game-types.js';
|
|
18
|
+
import { ScheduleDraft, loadQueue, popDue, saveQueue, } from './scheduler.js';
|
|
17
19
|
/**
|
|
18
20
|
* serverOnly() で wrap された handler かを判定する (SDK の isServerOnlyAction と同じ brand)。
|
|
19
21
|
* dev-server は SDK を import できないため実装を持つ。
|
|
@@ -83,6 +85,8 @@ export class GameRoom {
|
|
|
83
85
|
}
|
|
84
86
|
broadcastStateDelta(events, extra) {
|
|
85
87
|
this.seq++;
|
|
88
|
+
// クライアントはこの値でクロックオフセットを合わせる (ctx.now の推定に使う)。
|
|
89
|
+
const serverTime = Date.now();
|
|
86
90
|
const fullType = extra.ack !== undefined ? '__action_result' : '__tick';
|
|
87
91
|
const deltaType = extra.ack !== undefined ? '__action_result_delta' : '__tick_delta';
|
|
88
92
|
const needFull = this.prevBroadcastState === null || this.seq % GameRoom.SNAPSHOT_INTERVAL === 0;
|
|
@@ -92,6 +96,7 @@ export class GameRoom {
|
|
|
92
96
|
state: this.gameState,
|
|
93
97
|
events,
|
|
94
98
|
seq: this.seq,
|
|
99
|
+
serverTime,
|
|
95
100
|
...extra,
|
|
96
101
|
});
|
|
97
102
|
}
|
|
@@ -102,6 +107,7 @@ export class GameRoom {
|
|
|
102
107
|
patches,
|
|
103
108
|
events,
|
|
104
109
|
seq: this.seq,
|
|
110
|
+
serverTime,
|
|
105
111
|
...extra,
|
|
106
112
|
});
|
|
107
113
|
const fullPayload = JSON.stringify({
|
|
@@ -109,6 +115,7 @@ export class GameRoom {
|
|
|
109
115
|
state: this.gameState,
|
|
110
116
|
events,
|
|
111
117
|
seq: this.seq,
|
|
118
|
+
serverTime,
|
|
112
119
|
...extra,
|
|
113
120
|
});
|
|
114
121
|
const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
|
|
@@ -197,10 +204,17 @@ export class GameRoom {
|
|
|
197
204
|
return;
|
|
198
205
|
const events = [];
|
|
199
206
|
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
207
|
+
// ctx.now と予約の基準時刻は同じ値にする。別々に Date.now() を呼ぶと
|
|
208
|
+
// 「schedule の戻り値 = ctx.now + after * 1000」という約束が数 ms 崩れる。
|
|
209
|
+
const tickNow = Date.now();
|
|
210
|
+
const tickDraft = new ScheduleDraft(tickNow, (a) => this.hasAction(a));
|
|
200
211
|
try {
|
|
201
212
|
this.logic.update(this.gameState, {
|
|
202
213
|
random: this.random,
|
|
203
214
|
tick: this.tickCount,
|
|
215
|
+
now: tickNow,
|
|
216
|
+
schedule: (o) => tickDraft.schedule(o),
|
|
217
|
+
unschedule: (k) => tickDraft.unschedule(k),
|
|
204
218
|
emit,
|
|
205
219
|
playerInputs: this.playerInputs,
|
|
206
220
|
});
|
|
@@ -211,6 +225,8 @@ export class GameRoom {
|
|
|
211
225
|
return;
|
|
212
226
|
}
|
|
213
227
|
this.tickCount++;
|
|
228
|
+
if (tickDraft.dirty)
|
|
229
|
+
void this.flushSchedule(tickDraft);
|
|
214
230
|
this.broadcastStateDelta(events, { tick: this.tickCount });
|
|
215
231
|
}
|
|
216
232
|
// ─── Connection ────────────────────────────────────────
|
|
@@ -266,6 +282,7 @@ export class GameRoom {
|
|
|
266
282
|
state: this.gameState,
|
|
267
283
|
tick: this.tickCount,
|
|
268
284
|
seq: this.seq,
|
|
285
|
+
serverTime: Date.now(),
|
|
269
286
|
});
|
|
270
287
|
}
|
|
271
288
|
this.maybeStartGame();
|
|
@@ -322,6 +339,7 @@ export class GameRoom {
|
|
|
322
339
|
state: this.gameState,
|
|
323
340
|
tick: this.tickCount,
|
|
324
341
|
seq: this.seq,
|
|
342
|
+
serverTime: Date.now(),
|
|
325
343
|
});
|
|
326
344
|
}
|
|
327
345
|
return;
|
|
@@ -344,27 +362,37 @@ export class GameRoom {
|
|
|
344
362
|
if (!this.gameState) {
|
|
345
363
|
throw new Error('Game not started');
|
|
346
364
|
}
|
|
347
|
-
// actions
|
|
348
|
-
//
|
|
365
|
+
// actions 由来か serverActions 由来かは区別せず 1 本のリストで送る。
|
|
366
|
+
// 先読みで発火済みかどうかの判定はクライアントが自分の記録と突き合わせて行う。
|
|
349
367
|
const events = [];
|
|
350
|
-
const serverEvents = [];
|
|
351
368
|
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
352
|
-
const serverEmit =
|
|
369
|
+
const serverEmit = emit;
|
|
353
370
|
// action は「全部成功か全部失敗か」にする。plain が state を変更したあと
|
|
354
371
|
// serverActions が throw すると、caller の catch が __action_error を返して
|
|
355
372
|
// broadcast されないまま変更が gameState に残り、次の無関係な broadcast の diff に
|
|
356
373
|
// 紛れて漏れる。クライアントは __action_error で予測を rollback するので、
|
|
357
374
|
// 巻き戻さないとサーバー真実と表示が食い違ったままになる。
|
|
358
375
|
const snapshot = structuredClone(this.gameState);
|
|
376
|
+
// actions と serverActions には同じ時刻を渡す。同一 dispatch の前半と後半で
|
|
377
|
+
// 値が変わると、片方だけを見た state が一瞬矛盾する。
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
// handler 内では配列を触るだけにして、反映は dispatch 後にまとめて行う。
|
|
380
|
+
const draft = new ScheduleDraft(now, (a) => this.hasAction(a));
|
|
381
|
+
const scheduleCtx = {
|
|
382
|
+
schedule: (o) => draft.schedule(o),
|
|
383
|
+
unschedule: (k) => draft.unschedule(k),
|
|
384
|
+
};
|
|
359
385
|
try {
|
|
360
386
|
// 決定的な部分を先に走らせ、serverActions がその結果を見られるようにする。
|
|
361
387
|
if (plain && !legacyServerOnly) {
|
|
362
|
-
plain(this.gameState, payload ?? {}, senderId, emit, {});
|
|
388
|
+
plain(this.gameState, payload ?? {}, senderId, emit, { now, ...scheduleCtx });
|
|
363
389
|
}
|
|
364
390
|
if (serverHandler) {
|
|
365
391
|
await serverHandler(this.gameState, payload ?? {}, senderId, serverEmit, {
|
|
366
392
|
tick: this.tickCount,
|
|
367
393
|
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
394
|
+
now,
|
|
395
|
+
...scheduleCtx,
|
|
368
396
|
});
|
|
369
397
|
}
|
|
370
398
|
}
|
|
@@ -372,7 +400,78 @@ export class GameRoom {
|
|
|
372
400
|
this.gameState = snapshot;
|
|
373
401
|
throw err;
|
|
374
402
|
}
|
|
375
|
-
|
|
403
|
+
if (draft.dirty)
|
|
404
|
+
await this.flushSchedule(draft);
|
|
405
|
+
this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
|
|
406
|
+
}
|
|
407
|
+
// ─── Scheduler (本番の Durable Object alarm 相当) ──────────
|
|
408
|
+
/** dev はメモリ + setTimeout。本番は storage + alarm。 */
|
|
409
|
+
scheduled = [];
|
|
410
|
+
wakeupTimer = null;
|
|
411
|
+
get schedulerStore() {
|
|
412
|
+
return {
|
|
413
|
+
get: async () => this.scheduled,
|
|
414
|
+
put: async (entries) => {
|
|
415
|
+
this.scheduled = entries;
|
|
416
|
+
},
|
|
417
|
+
setWakeup: async (at) => {
|
|
418
|
+
if (this.wakeupTimer)
|
|
419
|
+
clearTimeout(this.wakeupTimer);
|
|
420
|
+
this.wakeupTimer = null;
|
|
421
|
+
if (at == null)
|
|
422
|
+
return;
|
|
423
|
+
this.wakeupTimer = setTimeout(() => void this.fireDue(), Math.max(0, at - Date.now()));
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
hasAction(name) {
|
|
428
|
+
return Boolean(this.logic.actions[name] || this.logic.serverActions?.[name]);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* 予約の書き込みを直列化する。
|
|
432
|
+
*
|
|
433
|
+
* tick / action / 発火処理が同時に走ると、後着が先着を上書きして予約が消える。
|
|
434
|
+
* 呼び出し順に 1 本の鎖へ並べる (play-server 版と同じ)。
|
|
435
|
+
*/
|
|
436
|
+
scheduleChain = Promise.resolve();
|
|
437
|
+
queueScheduleWrite(mutate) {
|
|
438
|
+
this.scheduleChain = this.scheduleChain
|
|
439
|
+
.then(async () => {
|
|
440
|
+
const current = await loadQueue(this.schedulerStore);
|
|
441
|
+
await saveQueue(this.schedulerStore, mutate(current));
|
|
442
|
+
})
|
|
443
|
+
.catch((err) => {
|
|
444
|
+
console.error('[Scheduler] ❌ 予約の書き込みに失敗', err);
|
|
445
|
+
});
|
|
446
|
+
return this.scheduleChain;
|
|
447
|
+
}
|
|
448
|
+
flushSchedule(draft) {
|
|
449
|
+
return this.queueScheduleWrite((current) => draft.apply(current));
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 期限の来た予約を実行する。本番の alarm() と同じ意味論。
|
|
453
|
+
*
|
|
454
|
+
* 失敗は握って捨てる (決定的バグでクラッシュを繰り返さない)。ログには必ず残す。
|
|
455
|
+
*/
|
|
456
|
+
async fireDue() {
|
|
457
|
+
let due = [];
|
|
458
|
+
// 先に残りを確定させる。実行中に新しい予約が積まれても失われない。
|
|
459
|
+
await this.queueScheduleWrite((current) => {
|
|
460
|
+
const split = popDue(current, Date.now());
|
|
461
|
+
due = split.due;
|
|
462
|
+
return split.rest;
|
|
463
|
+
});
|
|
464
|
+
if (due.length > 0) {
|
|
465
|
+
console.log(`[Scheduler] ⏰ ${due.length} 件発火: ${due.map((e) => e.key).join(', ')}`);
|
|
466
|
+
}
|
|
467
|
+
for (const entry of due) {
|
|
468
|
+
try {
|
|
469
|
+
await this.dispatchAction(entry.action, entry.payload, SCHEDULED_ACTOR);
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
console.error(`[Scheduler] ❌ key=${entry.key} action=${entry.action}:`, err);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
376
475
|
}
|
|
377
476
|
handleClose(ws) {
|
|
378
477
|
const attachment = this.attachments.get(ws);
|
|
@@ -8,4 +8,10 @@
|
|
|
8
8
|
* SDK を直接 import できず、game-worker-template と同じく copy を持つ。
|
|
9
9
|
* 3 か所いずれかを変更したら他 2 か所も同期させること。
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* 予約から発火した action に渡る playerId。
|
|
13
|
+
*
|
|
14
|
+
* 送信者がいないので、人間の操作と区別するための定数。
|
|
15
|
+
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
16
|
+
*/
|
|
17
|
+
export const SCHEDULED_ACTOR = '__scheduled';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
|
+
*
|
|
5
|
+
* サーバー権威のタイマー。
|
|
6
|
+
*
|
|
7
|
+
* これは dev-server (Node) 用のコピー。本番の Durable Object 版
|
|
8
|
+
* (play-server/src/game-worker-template/scheduler.ts) と**同一内容に保つこと**。
|
|
9
|
+
* 意味論がずれると「dev では動くが本番で止まる」という一番タチの悪い差になる。
|
|
10
|
+
*
|
|
11
|
+
* dev では alarm の代わりに setTimeout、storage の代わりにメモリを使う (SchedulerStore
|
|
12
|
+
* の実装差だけで吸収する)。
|
|
13
|
+
*/
|
|
14
|
+
const STORAGE_KEY = '__scheduled';
|
|
15
|
+
/**
|
|
16
|
+
* 同時に持てる予約の上限。
|
|
17
|
+
*
|
|
18
|
+
* key で置き換わるので通常は増えないが、キーを動的に生成する (`narration:${i++}` 等)
|
|
19
|
+
* バグを踏むと際限なく積み上がる。その歯止め。
|
|
20
|
+
*
|
|
21
|
+
* NOTE: 値そのものに根拠は無い。マダミスの実データ (フェーズ数 + ナレーション行列で
|
|
22
|
+
* 数十件) から見て十分に余裕がある数字を置いているだけ。
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_SCHEDULED = 256;
|
|
25
|
+
/**
|
|
26
|
+
* 予約できる先の上限 (ms)。
|
|
27
|
+
*
|
|
28
|
+
* 狙いは単位の取り違え。`after: 1200 * 1000` (ms のつもり) を書くと 13.9 日後になるが、
|
|
29
|
+
* 型では防げない。ゲームは長くても数時間なので、これを超える予約は事実上バグ。
|
|
30
|
+
*
|
|
31
|
+
* NOTE: 値そのものに根拠は無い。
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_HORIZON_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
/**
|
|
35
|
+
* 1 dispatch ぶんの予約変更を溜めるバッファ。
|
|
36
|
+
*
|
|
37
|
+
* action handler の中では同期的に呼ばれるので、ここでは配列を触るだけにして、
|
|
38
|
+
* dispatch が終わってからまとめてストレージへ書き戻す (I/O を handler の外へ出す)。
|
|
39
|
+
*/
|
|
40
|
+
export class ScheduleDraft {
|
|
41
|
+
now;
|
|
42
|
+
knownActions;
|
|
43
|
+
added = [];
|
|
44
|
+
removed = new Set();
|
|
45
|
+
/** 予約 / 取り消しが 1 件でもあったか。無ければストレージに触らない。 */
|
|
46
|
+
dirty = false;
|
|
47
|
+
constructor(now, knownActions) {
|
|
48
|
+
this.now = now;
|
|
49
|
+
this.knownActions = knownActions;
|
|
50
|
+
}
|
|
51
|
+
schedule(options) {
|
|
52
|
+
const { key, action } = options;
|
|
53
|
+
if (options.at != null && options.after != null) {
|
|
54
|
+
throw new Error(`schedule: key=${key} — at と after は排他です`);
|
|
55
|
+
}
|
|
56
|
+
const at = options.at != null ? Number(options.at) : this.now + Number(options.after ?? 0) * 1000;
|
|
57
|
+
// IMPORTANT: 検査に引っかかったら握り潰さず throw する。
|
|
58
|
+
//
|
|
59
|
+
// 黙って捨てると、呼び出し側は予約できたつもりで戻り値を endsAt として state に入れ、
|
|
60
|
+
// タイマーが表示されたまま永久に発火しない = ゲームが静かに止まる。予約方式は
|
|
61
|
+
// 「何も起きないこと」に気づけないので、その場で失敗させるしかない。
|
|
62
|
+
// ここに来るのはバグのときだけなので、action 全体がロールバックされて構わない。
|
|
63
|
+
if (!key)
|
|
64
|
+
throw new Error('schedule: key は必須です');
|
|
65
|
+
if (!Number.isFinite(at))
|
|
66
|
+
throw new Error(`schedule: key=${key} の時刻が不正です`);
|
|
67
|
+
if (!this.knownActions(action)) {
|
|
68
|
+
throw new Error(`schedule: key=${key} の action "${action}" が logic に存在しません`);
|
|
69
|
+
}
|
|
70
|
+
if (at - this.now > MAX_HORIZON_MS) {
|
|
71
|
+
const days = ((at - this.now) / 86_400_000).toFixed(1);
|
|
72
|
+
throw new Error(`schedule: key=${key} の予約が上限 (24h) を超えています (${days} 日後)。` +
|
|
73
|
+
' after は「秒」です。ms を渡していませんか');
|
|
74
|
+
}
|
|
75
|
+
this.removed.add(key);
|
|
76
|
+
const idx = this.added.findIndex((e) => e.key === key);
|
|
77
|
+
const entry = { key, at, action, payload: options.payload ?? {} };
|
|
78
|
+
if (idx >= 0)
|
|
79
|
+
this.added[idx] = entry;
|
|
80
|
+
else
|
|
81
|
+
this.added.push(entry);
|
|
82
|
+
this.dirty = true;
|
|
83
|
+
return at;
|
|
84
|
+
}
|
|
85
|
+
unschedule(key) {
|
|
86
|
+
this.removed.add(key);
|
|
87
|
+
const idx = this.added.findIndex((e) => e.key === key);
|
|
88
|
+
if (idx >= 0)
|
|
89
|
+
this.added.splice(idx, 1);
|
|
90
|
+
this.dirty = true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 既存キューへ変更を適用する。key 単位で必ず 1 件に畳まれる。
|
|
94
|
+
*
|
|
95
|
+
* 上限超過は throw する。どれを捨てるかを SDK が決めると、フェーズタイマー (遠い) を
|
|
96
|
+
* 捨ててナレーション (近い) を残す、といった優先順位の判断を勝手にすることになる。
|
|
97
|
+
*/
|
|
98
|
+
apply(current) {
|
|
99
|
+
const next = current.filter((e) => !this.removed.has(e.key));
|
|
100
|
+
next.push(...this.added);
|
|
101
|
+
if (next.length > MAX_SCHEDULED) {
|
|
102
|
+
throw new Error(`schedule: 予約が上限 ${MAX_SCHEDULED} 件を超えました (${next.length} 件)。` +
|
|
103
|
+
' key を動的に生成していませんか');
|
|
104
|
+
}
|
|
105
|
+
next.sort((a, b) => a.at - b.at);
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** ストレージのキューを読む。 */
|
|
110
|
+
export async function loadQueue(store) {
|
|
111
|
+
return (await store.get()) ?? [];
|
|
112
|
+
}
|
|
113
|
+
/** キューを書き戻し、最も早い 1 件へ起床時刻を張り替える。 */
|
|
114
|
+
export async function saveQueue(store, entries) {
|
|
115
|
+
await store.put(entries);
|
|
116
|
+
const next = entries.length > 0 ? entries[0] : null;
|
|
117
|
+
await store.setWakeup(next ? next.at : null);
|
|
118
|
+
// 予約方式は「静かに何も起きない」ので、状態がログに出ないと調査手段が無くなる。
|
|
119
|
+
if (next) {
|
|
120
|
+
const inSec = Math.round((next.at - Date.now()) / 1000);
|
|
121
|
+
console.log(`[Scheduler] 📅 ${entries.length} 件 / 次は ${next.key} (${next.action}) ${inSec}s 後`);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
console.log('[Scheduler] 📭 予約なし');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** 期限が来た予約を取り出し、残りを返す。 */
|
|
128
|
+
export function popDue(entries, now) {
|
|
129
|
+
const due = [];
|
|
130
|
+
const rest = [];
|
|
131
|
+
for (const e of entries)
|
|
132
|
+
(e.at <= now ? due : rest).push(e);
|
|
133
|
+
return { due, rest };
|
|
134
|
+
}
|
|
135
|
+
export const SCHEDULER_STORAGE_KEY = STORAGE_KEY;
|
|
@@ -17,7 +17,7 @@ function isInspectorOpen() {
|
|
|
17
17
|
* UZU ボタン (button 要素) にタップメニューを配線する。 メニューはボタン直下に出る。
|
|
18
18
|
*/
|
|
19
19
|
export function attachUzuMenu(button, opts = {}) {
|
|
20
|
-
const { stateGetter, playerCount, adminEnabled, resetGame, onOpenMobile } = opts;
|
|
20
|
+
const { stateGetter, playerCount, adminEnabled, resetGame, onOpenMobile, onOpenSingleView } = opts;
|
|
21
21
|
let popup = null;
|
|
22
22
|
const closePopup = () => {
|
|
23
23
|
popup?.remove();
|
|
@@ -81,6 +81,12 @@ export function attachUzuMenu(button, opts = {}) {
|
|
|
81
81
|
el.appendChild(b);
|
|
82
82
|
return b;
|
|
83
83
|
};
|
|
84
|
+
if (onOpenSingleView) {
|
|
85
|
+
addAction('🖥 この画面だけ開く', '#0984e3', '#74b9ff', () => {
|
|
86
|
+
onOpenSingleView();
|
|
87
|
+
closePopup();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
84
90
|
if (onOpenMobile) {
|
|
85
91
|
addAction('📱 スマホで開く', '#0984e3', '#74b9ff', () => {
|
|
86
92
|
onOpenMobile();
|
package/dist/harness/mount.js
CHANGED
|
@@ -320,6 +320,15 @@ function resolveMobileUrl(opts, playerIndex) {
|
|
|
320
320
|
const port = location.port ? `:${location.port}` : '';
|
|
321
321
|
return `http://${host}${port}/?player=${playerIndex}`;
|
|
322
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* その player の単体表示 (`?player=N`) URL。 実機リンクと違い LAN host には
|
|
325
|
+
* 読み替えず、 いま harness を見ている origin をそのまま使う。
|
|
326
|
+
*/
|
|
327
|
+
function singleViewUrl(playerIndex) {
|
|
328
|
+
const url = new URL(location.href);
|
|
329
|
+
url.searchParams.set('player', String(playerIndex));
|
|
330
|
+
return url.toString();
|
|
331
|
+
}
|
|
323
332
|
/** 携帯リンクの QR ポップアップ (カメラで読めばそのキャラの画面がスマホで開く)。 */
|
|
324
333
|
function openMobileQrPopup(url, label) {
|
|
325
334
|
const backdrop = document.createElement('div');
|
|
@@ -467,6 +476,9 @@ onUzuClick) {
|
|
|
467
476
|
resetGame: opts.resetGame,
|
|
468
477
|
playerCount: opts.playerCount,
|
|
469
478
|
adminEnabled: opts.seats.some((s) => s.kind === 'admin'),
|
|
479
|
+
onOpenSingleView: () => {
|
|
480
|
+
window.open(singleViewUrl(playerIndex), '_blank', 'noopener');
|
|
481
|
+
},
|
|
470
482
|
});
|
|
471
483
|
}
|
|
472
484
|
wrap.appendChild(uzu);
|