@yyyu-star/arcade-kit 0.13.0 → 0.13.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/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +35 -2
- package/dist/client.js.map +1 -1
- package/dist/protocol.d.ts +7 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/room.d.ts +11 -2
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +42 -9
- package/dist/room.js.map +1 -1
- package/dist/table-fun.d.ts +7 -1
- package/dist/table-fun.d.ts.map +1 -1
- package/dist/table-fun.js +13 -0
- package/dist/table-fun.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +34 -3
- package/src/protocol.ts +7 -0
- package/src/room.ts +42 -9
- package/src/table-fun.ts +15 -1
package/src/client.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
|
|
18
18
|
*/
|
|
19
|
-
import { CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, PING, PONG, PROTOCOL_VERSION, type UpMsg } from './protocol.js';
|
|
19
|
+
import { CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, PING, PONG, PROTOCOL_VERSION, type RoomInfo, type UpMsg } from './protocol.js';
|
|
20
20
|
|
|
21
21
|
export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'failed';
|
|
22
22
|
|
|
@@ -73,6 +73,13 @@ const HEARTBEAT_MS = 25_000;
|
|
|
73
73
|
export const PONG_WAIT_MS = 8_000;
|
|
74
74
|
/** 建连最多等多久(0.11.1):卡在 CONNECTING 的按一次失败算,重连 */
|
|
75
75
|
export const CONNECT_MS = 10_000;
|
|
76
|
+
/**
|
|
77
|
+
* 看门狗(0.13.1):房间说的出手时限(或停顿的结束时间)过了 STALL_GRACE_MS 再加最多 STALL_JITTER_MS 的随机抖动,还没有新状态,
|
|
78
|
+
* 就当房间的闹钟没设上(存储出错、没人发消息时房间不会自己醒),重连一次把房间叫醒:进房是房间的一个入口,它会按 getAlarm() 重排闹钟、
|
|
79
|
+
* 把过期的那一手照常处理。同一个 seq 只踢一次;抖动让一桌人里先到的那个去踢,别人收到新状态就不踢了
|
|
80
|
+
*/
|
|
81
|
+
export const STALL_GRACE_MS = 20_000;
|
|
82
|
+
export const STALL_JITTER_MS = 10_000;
|
|
76
83
|
/** 快捷短语太快时最多排几个(多的丢最早的) */
|
|
77
84
|
const EMOTE_QUEUE = 2;
|
|
78
85
|
|
|
@@ -97,6 +104,9 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
97
104
|
private probing = false;
|
|
98
105
|
private probeTimer: unknown = null;
|
|
99
106
|
private connectTimer: unknown = null;
|
|
107
|
+
/** 看门狗(0.13.1):到点还停在 watchSeq 就重连一次;kickedSeq 是踢过的,不再踢 */
|
|
108
|
+
private watchTimer: unknown = null;
|
|
109
|
+
private kickedSeq = -1;
|
|
100
110
|
/** 没收到 ack 的操作:重连后按原 moveId 重发 */
|
|
101
111
|
private pending = new Map<string, UpMsg<A>>();
|
|
102
112
|
private moveCounter = 0;
|
|
@@ -254,11 +264,13 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
254
264
|
this.welcomed = true;
|
|
255
265
|
this.attempt = 0;
|
|
256
266
|
this.failures = 0;
|
|
267
|
+
this.watch(ws, msg);
|
|
257
268
|
} else if (msg.t === 'state') {
|
|
258
269
|
const mine = msg.ack !== undefined && this.pending.delete(msg.ack);
|
|
259
270
|
// 旧的、重复的丢掉(ack 已经处理过了)。例外:别人看不出来的一步(0.7.1,quiet),房间只回给我、seq 不加,第一次收到要交给页面
|
|
260
271
|
if (msg.seq < this.lastSeq || (msg.seq === this.lastSeq && !(mine && msg.quiet === true))) return;
|
|
261
272
|
this.lastSeq = msg.seq;
|
|
273
|
+
this.watch(ws, msg);
|
|
262
274
|
} else if (msg.t === 'error' && msg.moveId) {
|
|
263
275
|
// 被拒的操作不再重发
|
|
264
276
|
this.pending.delete(msg.moveId);
|
|
@@ -316,6 +328,25 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
316
328
|
}, PONG_WAIT_MS);
|
|
317
329
|
}
|
|
318
330
|
|
|
331
|
+
/**
|
|
332
|
+
* 看门狗:按房间给的时间(now)算出手时限还剩多久,过了宽限还停在这个 seq,就扔掉连接重连一次(见 STALL_GRACE_MS)。
|
|
333
|
+
* 没在打、没有时限、旧房间不带 now 的不看;同一个 seq 踢过一次就不再踢(房间还是不动,就等真人出手或下一次进出)
|
|
334
|
+
*/
|
|
335
|
+
private watch(ws: WebSocketLike, msg: { seq: number; room: RoomInfo; now?: number }): void {
|
|
336
|
+
if (this.watchTimer !== null) this.opts.clearTimer(this.watchTimer);
|
|
337
|
+
this.watchTimer = null;
|
|
338
|
+
const until = msg.room.turn?.deadline ?? msg.room.pause?.until;
|
|
339
|
+
if (msg.room.status !== 'playing' || until == null || typeof msg.now !== 'number' || msg.seq === this.kickedSeq) return;
|
|
340
|
+
const seq = msg.seq;
|
|
341
|
+
const wait = Math.max(0, until - msg.now) + STALL_GRACE_MS + Math.round(this.opts.random() * STALL_JITTER_MS);
|
|
342
|
+
this.watchTimer = this.opts.setTimer(() => {
|
|
343
|
+
this.watchTimer = null;
|
|
344
|
+
if (this.ws !== ws || this.lastSeq !== seq || ws.readyState !== OPEN) return;
|
|
345
|
+
this.kickedSeq = seq;
|
|
346
|
+
this.abandon(ws);
|
|
347
|
+
}, wait);
|
|
348
|
+
}
|
|
349
|
+
|
|
319
350
|
/** 带上这次的关闭码:游戏据此区分"网络断了"(1006)和"平台有点忙"(1013)。 */
|
|
320
351
|
private scheduleRetry(code: number): void {
|
|
321
352
|
this.setStatus('reconnecting', code);
|
|
@@ -365,8 +396,8 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
365
396
|
}
|
|
366
397
|
|
|
367
398
|
private clearTimers(): void {
|
|
368
|
-
for (const t of [this.retryTimer, this.emoteTimer, this.beatTimer, this.probeTimer, this.connectTimer]) if (t !== null) this.opts.clearTimer(t);
|
|
369
|
-
this.retryTimer = this.emoteTimer = this.beatTimer = this.probeTimer = this.connectTimer = null;
|
|
399
|
+
for (const t of [this.retryTimer, this.emoteTimer, this.beatTimer, this.probeTimer, this.connectTimer, this.watchTimer]) if (t !== null) this.opts.clearTimer(t);
|
|
400
|
+
this.retryTimer = this.emoteTimer = this.beatTimer = this.probeTimer = this.connectTimer = this.watchTimer = null;
|
|
370
401
|
this.probing = false;
|
|
371
402
|
}
|
|
372
403
|
|
package/src/protocol.ts
CHANGED
|
@@ -185,6 +185,11 @@ export interface WelcomeMsg<V = unknown> {
|
|
|
185
185
|
you: You;
|
|
186
186
|
room: RoomInfo;
|
|
187
187
|
view: V | null;
|
|
188
|
+
/**
|
|
189
|
+
* 房间发这条消息时的时间(毫秒,0.13.1):RoomClient 拿它和 room.turn.deadline 算"还剩多久",不用浏览器自己的钟(可能差几分钟)。
|
|
190
|
+
* 用来发现房间的闹钟没设上、到点了却没人推进(见 RoomClient 的看门狗)。旧房间不带
|
|
191
|
+
*/
|
|
192
|
+
now?: number;
|
|
188
193
|
/**
|
|
189
194
|
* 快捷短语的节奏(0.7.0,游戏有短语时才带):每 ms 毫秒补 1 个令牌、最多攒 burst 个。RoomClient 按它在本地排队发,
|
|
190
195
|
* 用户感觉不到限速;运维在 KIT_QUOTA 里改,不用发版
|
|
@@ -201,6 +206,8 @@ export interface StateMsg<V = unknown> {
|
|
|
201
206
|
/** 至少一帧;最后一帧就是当前状态 */
|
|
202
207
|
frames: Frame<V>[];
|
|
203
208
|
ack?: string;
|
|
209
|
+
/** 同 WelcomeMsg.now(0.13.1) */
|
|
210
|
+
now?: number;
|
|
204
211
|
/**
|
|
205
212
|
* 别人看不出来的一步(0.7.1):不轮流的操作没改任何别人能看到的东西(比如声明窗口里真人答"碰"),房间只回给出手的人、seq 不加(和上一条一样)。
|
|
206
213
|
* 只和 ack 一起出现;RoomClient 0.7.1 起认它(seq 一样也交给页面),旧的 RoomClient 当重复丢掉
|
package/src/room.ts
CHANGED
|
@@ -119,6 +119,8 @@ export const DEFAULT_LIMITS: RoomLimits = {
|
|
|
119
119
|
|
|
120
120
|
/** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
|
|
121
121
|
const ALARM_RETRY_MS = 60_000;
|
|
122
|
+
/** setAlarm 一次最多试几回(0.13.1):存储一时出错当场再试一次,不等下一个入口 */
|
|
123
|
+
const SET_ALARM_TRIES = 2;
|
|
122
124
|
/** 每个座位记住最近几个做成的 moveId(0.11.1):客户端断线时攒着没确认的操作,重连后原样补发 */
|
|
123
125
|
const RECENT_MOVES = 8;
|
|
124
126
|
|
|
@@ -461,26 +463,56 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
461
463
|
this.totalWrites += this.writes;
|
|
462
464
|
this.unmetered += this.writes;
|
|
463
465
|
this.maxStepWrites = Math.max(this.maxStepWrites, this.writes);
|
|
464
|
-
if (at !== null && !(await this.setAlarmSafely(at)))
|
|
466
|
+
if (at !== null && !(await this.setAlarmSafely(at))) {
|
|
467
|
+
this.lastStepFailed = true;
|
|
468
|
+
await this.recoverAlarm();
|
|
469
|
+
}
|
|
465
470
|
await this.afterStep();
|
|
466
471
|
return out;
|
|
467
472
|
}
|
|
468
473
|
|
|
469
474
|
/**
|
|
470
|
-
*
|
|
471
|
-
*
|
|
475
|
+
* 这一步要设的闹钟两次都没设上(0.13.1,第三轮审计):按 getAlarm() 核对存储里还有没有闹钟(以前设的更早的那个可能还在);
|
|
476
|
+
* 有就以它为准,没有而房间还在,就补一个 ALARM_RETRY_MS 后的,醒来时 onAlarm 按状态重新算(到点的照常处理)。
|
|
477
|
+
* 以前只靠下一个入口重排:全是电脑、大家都在等时限的房间没人发消息,就一直停着,不关房、结回也不重试。
|
|
478
|
+
* 补设也失败(存储一直坏着)就只剩浏览器的看门狗(RoomClient)重连来叫醒。读不了存储时不抛(入口里不往外抛)
|
|
472
479
|
*/
|
|
473
|
-
private async
|
|
480
|
+
private async recoverAlarm(): Promise<void> {
|
|
474
481
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
482
|
+
if (!this.load()) return;
|
|
483
|
+
const cur = await this.ctx.storage.getAlarm();
|
|
484
|
+
if (cur !== null) {
|
|
485
|
+
this.alarmAt = cur;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// 不另记一行:这一步的那次 setAlarm 已经记过了,它没写成,补设的这次顶替它
|
|
489
|
+
this.alarmAt = Date.now() + ALARM_RETRY_MS;
|
|
490
|
+
await this.setAlarmSafely(this.alarmAt);
|
|
477
491
|
} catch (e) {
|
|
478
|
-
console.error('room
|
|
492
|
+
console.error('room alarm recovery failed', e instanceof Error ? e.message : e);
|
|
479
493
|
this.alarmAt = undefined;
|
|
480
|
-
return false;
|
|
481
494
|
}
|
|
482
495
|
}
|
|
483
496
|
|
|
497
|
+
/**
|
|
498
|
+
* 设闹钟;失败了不往外抛(0.11.1)。存储一时出错大多下一次就好,所以当场再试一次(0.13.1);还不行:
|
|
499
|
+
* planAlarm 已经把 alarmAt 改成了新时间,失败时它就是错的,留着的话以后 planAlarm 看"已经设了更早的"一直不重设,房间就没人收场。
|
|
500
|
+
* 改回"不知道",下一个入口(任何消息、进房、闹钟)按 getAlarm() 重新读、照常重排。
|
|
501
|
+
* 一直没人发消息(全是电脑在打、大家都在等时限)时,下一个入口由浏览器的看门狗给(RoomClient:时限过了很久还没有新状态就重连一次)
|
|
502
|
+
*/
|
|
503
|
+
private async setAlarmSafely(at: number): Promise<boolean> {
|
|
504
|
+
for (let i = 0; i < SET_ALARM_TRIES; i++) {
|
|
505
|
+
try {
|
|
506
|
+
await this.ctx.storage.setAlarm(at);
|
|
507
|
+
return true;
|
|
508
|
+
} catch (e) {
|
|
509
|
+
console.error('room setAlarm failed', i + 1, e instanceof Error ? e.message : e);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.alarmAt = undefined;
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
484
516
|
// ---------- 计量(0.2.0) ----------
|
|
485
517
|
|
|
486
518
|
/**
|
|
@@ -1584,6 +1616,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1584
1616
|
you: { seat, bound },
|
|
1585
1617
|
room: this.roomInfo(),
|
|
1586
1618
|
view: st.game === null ? null : this.game.view(st.game, seat),
|
|
1619
|
+
now: Date.now(),
|
|
1587
1620
|
// 有短语的游戏告诉页面限速的节奏(0.7.0),RoomClient 按它排队,用户感觉不到限速
|
|
1588
1621
|
...(hasEmotes(this.game.emotes) ? { emote: this.emoteRate() } : {}),
|
|
1589
1622
|
};
|
|
@@ -1592,7 +1625,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1592
1625
|
private stateMsg(seat: number | null, bound: number | null, frames: { state: S | null; delay: number }[], ack?: string, quiet = false): DownMsg<V> {
|
|
1593
1626
|
const st = this.st!;
|
|
1594
1627
|
const out: Frame<V>[] = frames.map((f) => ({ view: (f.state === null ? null : this.game.view(f.state, seat)) as V, delay: f.delay }));
|
|
1595
|
-
return { t: 'state', seq: st.seq, you: { seat, bound }, room: this.roomInfo(), frames: out, ...(ack ? { ack } : {}), ...(quiet ? { quiet: true as const } : {}) };
|
|
1628
|
+
return { t: 'state', seq: st.seq, you: { seat, bound }, room: this.roomInfo(), frames: out, now: Date.now(), ...(ack ? { ack } : {}), ...(quiet ? { quiet: true as const } : {}) };
|
|
1596
1629
|
}
|
|
1597
1630
|
|
|
1598
1631
|
/** 按座位过滤后广播:同一个座位(加上绑定的座位,没入座的人各自不同)的消息只算一次。只发给已经收到快照(有 attachment)的连接。 */
|
package/src/table-fun.ts
CHANGED
|
@@ -49,8 +49,20 @@ export interface FunSeat {
|
|
|
49
49
|
temper?: FunTemper;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* storageKey 的检查(0.13.1):它就是游戏 id,kit 拿它拼 `<id>.hint.fun`、`<id>.fun.blocked`。同站的几个游戏和平台只靠 `<id>.` 前缀分开存储,
|
|
54
|
+
* 所以只认游戏 id 的写法(小写字母、数字、连字符,字母或数字开头,最多 32 位);带点("guandan." 会拼出 "guandan..fun")、大写、空串、
|
|
55
|
+
* 平台自己的前缀 arcade 都不行。不合格直接抛错:同一个字符串每次都一样,开发时第一次打开牌桌就看得到,不会带到线上
|
|
56
|
+
*/
|
|
57
|
+
export function checkStorageKey(key: unknown): string {
|
|
58
|
+
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(key) || key === 'arcade') {
|
|
59
|
+
throw new Error(`arcade-kit/table-fun: storageKey 要写游戏 id(比如 "mygame",存成 "mygame.hint.fun"),收到 ${JSON.stringify(key)}`);
|
|
60
|
+
}
|
|
61
|
+
return key;
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
export interface TableFunOptions {
|
|
53
|
-
/** 游戏 id:首次提示、屏蔽名单的存储 key 前缀(`<id>.hint.fun`、`<id>.fun.blocked
|
|
65
|
+
/** 游戏 id:首次提示、屏蔽名单的存储 key 前缀(`<id>.hint.fun`、`<id>.fun.blocked`);不是游戏 id 的写法构造时就抛错(checkStorageKey) */
|
|
54
66
|
storageKey: string;
|
|
55
67
|
/** 本游戏的短语表(编号 0..P-1,见 fun-logic 的 funItem) */
|
|
56
68
|
phrases: readonly string[];
|
|
@@ -165,6 +177,8 @@ export class TableFun {
|
|
|
165
177
|
private readonly onResize = (): void => this.refresh();
|
|
166
178
|
|
|
167
179
|
constructor(opts: TableFunOptions) {
|
|
180
|
+
// 先查 key 再碰 DOM:不合格的不留下半个界面
|
|
181
|
+
checkStorageKey(opts.storageKey);
|
|
168
182
|
this.o = opts;
|
|
169
183
|
this.P = opts.phrases.length;
|
|
170
184
|
this.doc = document;
|