@yyyu-star/arcade-kit 0.19.0 → 0.20.0
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 +29 -0
- package/README.md +43 -0
- package/dist/client.d.ts +44 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +103 -0
- package/dist/client.js.map +1 -1
- package/dist/fun-logic.d.ts +14 -2
- package/dist/fun-logic.d.ts.map +1 -1
- package/dist/fun-logic.js +19 -4
- package/dist/fun-logic.js.map +1 -1
- package/dist/game.d.ts +7 -0
- package/dist/game.d.ts.map +1 -1
- package/dist/game.js +38 -0
- package/dist/game.js.map +1 -1
- package/dist/internal.d.ts +4 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +6 -0
- package/dist/internal.js.map +1 -1
- package/dist/match.d.ts +85 -0
- package/dist/match.d.ts.map +1 -0
- package/dist/match.js +361 -0
- package/dist/match.js.map +1 -0
- package/dist/meter.d.ts +38 -0
- package/dist/meter.d.ts.map +1 -1
- package/dist/meter.js +56 -14
- package/dist/meter.js.map +1 -1
- package/dist/protocol.d.ts +35 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/room.d.ts +48 -1
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +157 -17
- package/dist/room.js.map +1 -1
- package/dist/table-chat.d.ts +2 -0
- package/dist/table-chat.d.ts.map +1 -1
- package/dist/table-chat.js +25 -2
- package/dist/table-chat.js.map +1 -1
- package/dist/table-fun.d.ts.map +1 -1
- package/dist/table-fun.js +4 -4
- package/dist/table-fun.js.map +1 -1
- package/dist/worker.d.ts +12 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +44 -1
- package/dist/worker.js.map +1 -1
- package/package.json +5 -1
- package/src/client.ts +118 -1
- package/src/fun-logic.ts +31 -4
- package/src/game.ts +40 -0
- package/src/internal.ts +8 -0
- package/src/match.ts +371 -0
- package/src/meter.ts +68 -12
- package/src/protocol.ts +41 -0
- package/src/room.ts +171 -18
- package/src/table-chat.ts +25 -3
- package/src/table-fun.ts +4 -3
- package/src/worker.ts +44 -2
package/src/internal.ts
CHANGED
|
@@ -73,3 +73,11 @@ export const LIMITED_HEADER = 'x-kit-limited';
|
|
|
73
73
|
export function meterStub(ns: DurableObjectNamespace): DurableObjectStub {
|
|
74
74
|
return ns.get(ns.idFromName(METER_NAME), { locationHint: 'apac' });
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/** 快速匹配的排队(0.20.0):每个档位一个实例,和房间一样放在亚太 */
|
|
78
|
+
export function matchStub(ns: DurableObjectNamespace, tier: string): DurableObjectStub {
|
|
79
|
+
return ns.get(ns.idFromName(`kit-match:${tier}`), { locationHint: 'apac' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 档位 key 的格式:游戏给的短字符串(如 real-2、fun-normal) */
|
|
83
|
+
export const TIER_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快速匹配的排队(0.20.0,设计 docs/match.md):游戏 Worker 里的 Durable Object 继承它,填上一桌几个真人,就能按档位排队凑桌。
|
|
3
|
+
* 每个档位一个实例(matchStub 按档位取),浏览器经 worker.ts 的 acceptMatch 连进来,连接本身就是"排着"。
|
|
4
|
+
*
|
|
5
|
+
* 守的规矩(和房间同一套,docs/platform-backend.md 第 4 节):
|
|
6
|
+
* - 0 行存储:排着的人就是 ctx.getWebSockets(),附件里只放 userId、昵称、开始排的时间;被驱逐后名单还在;
|
|
7
|
+
* - 只用一个闹钟:最早那个人等满 waitMs 的时刻,只往早了调(setAlarm 也算一行写入);
|
|
8
|
+
* - 只用 ctx.acceptWebSocket,不用 setTimeout / setInterval;排队连接只收不发,浏览器发什么都 1008 断开;
|
|
9
|
+
* - 不碰牌局:不 import 规则、不读房间状态。电脑补位、公平底线全在房间里,和好友房一样;
|
|
10
|
+
* - 名额由房间开桌时向计量一次占齐(kitCreateMatch),排队本身不占。
|
|
11
|
+
*/
|
|
12
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
13
|
+
import { type Identity, KIT_HOST, newRoomCode, parseIdentity, roomStub } from './internal.js';
|
|
14
|
+
import { CLOSE, type MatchDownMsg, type MatchSoloWhy } from './protocol.js';
|
|
15
|
+
import type { MatchOutcome } from './room.js';
|
|
16
|
+
|
|
17
|
+
export type { MatchDownMsg, MatchSoloWhy } from './protocol.js';
|
|
18
|
+
|
|
19
|
+
/** 排队对象要的绑定:房间 ROOM(开桌),平台 ARCADE(可选,屏蔽名单;单独部署没有) */
|
|
20
|
+
export interface MatchEnv {
|
|
21
|
+
ROOM: DurableObjectNamespace;
|
|
22
|
+
ARCADE?: Fetcher;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Waiter {
|
|
26
|
+
userId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
/** 开始排的时间(毫秒) */
|
|
29
|
+
since: number;
|
|
30
|
+
/** 档位(一个实例只有一个档位;放在附件里,被驱逐后闹钟醒来还知道) */
|
|
31
|
+
tier: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 开桌时房间码撞了最多再换几次 */
|
|
35
|
+
const CODE_TRIES = 5;
|
|
36
|
+
/** 一次凑桌最多重来几轮(有人被计量挡掉、请出去以后接着凑) */
|
|
37
|
+
const FORM_ROUNDS = 8;
|
|
38
|
+
/** 平台屏蔽名单一次最多问几个人(平台 blockedAmong 的上限,德州 9 人桌) */
|
|
39
|
+
const BLOCK_MAX = 9;
|
|
40
|
+
/**
|
|
41
|
+
* 凑桌时找平台、找房间最多等多久(毫秒,安全审阅 0.20.0):凑桌持着"正在凑"的锁,平台或房间卡住时整个档位都会一直"等待中"。
|
|
42
|
+
* 超时的屏蔽名单按空的算,建房按 unavailable(这一桌的人转单机)
|
|
43
|
+
*/
|
|
44
|
+
export const MATCH_CALL_MS = 3_000;
|
|
45
|
+
|
|
46
|
+
export abstract class MatchQueue<Env extends MatchEnv = MatchEnv> extends DurableObject<Env> {
|
|
47
|
+
/** 一桌最多几个真人(凑满就开)。按档位不同的游戏覆盖 tableSize */
|
|
48
|
+
protected abstract readonly seats: number;
|
|
49
|
+
/** 最多等多久(毫秒):到点 ≥ 2 人就开,只剩自己就转单机 */
|
|
50
|
+
protected readonly waitMs: number = 15_000;
|
|
51
|
+
/** 一个档位最多排几个人,满了 1013(挡刷连接) */
|
|
52
|
+
protected readonly maxQueue: number = 200;
|
|
53
|
+
/** 每人每小时最多排几次,超了 4029(内存里记,被驱逐就清零,接受) */
|
|
54
|
+
protected readonly perHour: number = 30;
|
|
55
|
+
/** 凑桌时找平台、找房间最多等多久(毫秒,见 MATCH_CALL_MS) */
|
|
56
|
+
protected readonly callMs: number = MATCH_CALL_MS;
|
|
57
|
+
|
|
58
|
+
/** 这个档位一桌最多几个真人;默认 seats */
|
|
59
|
+
protected tableSize(_tier: string): number {
|
|
60
|
+
return this.seats;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 这个档位开桌带的开局选项(交给房间的 RoomGame.options 校验);默认不带 */
|
|
64
|
+
protected roomOptions(_tier: string): unknown {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 这几个人之间谁屏蔽了谁(平台 0.25.0 的 ARCADE.blockedAmong,最多 9 人,只读)。没有平台(单独部署)、出错都当没有,回 []。
|
|
70
|
+
* 测试覆盖它换成假名单
|
|
71
|
+
*/
|
|
72
|
+
protected async blockedAmong(userIds: string[]): Promise<[string, string][]> {
|
|
73
|
+
const arcade = this.env.ARCADE as unknown as { blockedAmong?: (ids: unknown) => Promise<unknown> } | undefined;
|
|
74
|
+
if (!arcade || userIds.length < 2) return [];
|
|
75
|
+
try {
|
|
76
|
+
const out = await arcade.blockedAmong!(userIds.slice(0, BLOCK_MAX));
|
|
77
|
+
return Array.isArray(out) ? out.filter((p): p is [string, string] => Array.isArray(p) && typeof p[0] === 'string' && typeof p[1] === 'string') : [];
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error('blockedAmong failed, matching without it', e instanceof Error ? e.message : e);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 开一张匹配桌(房间的 kitCreateMatch)。游戏不用覆盖;kit 的测试用它模拟房间卡住 */
|
|
85
|
+
protected createMatchRoom(code: string, players: Identity[], opts: unknown): Promise<MatchOutcome> {
|
|
86
|
+
const stub = roomStub(this.env.ROOM, code) as unknown as { kitCreateMatch(code: string, players: Identity[], opts?: unknown): Promise<MatchOutcome> };
|
|
87
|
+
return stub.kitCreateMatch(code, players, opts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 现在设着的闹钟;undefined 是还不知道,用前 getAlarm() 读 */
|
|
91
|
+
private alarmAt: number | null | undefined;
|
|
92
|
+
/** 正在凑桌(中间 await 了平台和房间):这时进来的人、到点的闹钟只记一笔,凑完再来一轮 */
|
|
93
|
+
private forming = false;
|
|
94
|
+
private again = false;
|
|
95
|
+
/** 凑桌途中已经选中的人:别的一轮不再选他们 */
|
|
96
|
+
private readonly taken = new Set<string>();
|
|
97
|
+
/** 每人这一小时排了几次 */
|
|
98
|
+
private readonly hourly = new Map<string, number[]>();
|
|
99
|
+
|
|
100
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
101
|
+
super(ctx, env);
|
|
102
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 入队:只认 kit 自己拼的内部地址(worker.ts 的 acceptMatch),身份在 x-kit-user 头里。 */
|
|
106
|
+
override async fetch(request: Request): Promise<Response> {
|
|
107
|
+
const url = new URL(request.url);
|
|
108
|
+
if (url.hostname !== KIT_HOST || url.pathname !== '/match' || request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
109
|
+
return new Response('not a kit request', { status: 400 });
|
|
110
|
+
}
|
|
111
|
+
const me = parseIdentity(request.headers.get('x-kit-user'));
|
|
112
|
+
const tier = url.searchParams.get('tier');
|
|
113
|
+
if (!me || !tier) return new Response('no identity', { status: 401 });
|
|
114
|
+
const pair = new WebSocketPair();
|
|
115
|
+
const [client, server] = [pair[0], pair[1]];
|
|
116
|
+
this.ctx.acceptWebSocket(server);
|
|
117
|
+
const done = new Response(null, { status: 101, webSocket: client });
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
// 只数还排着的(附件还在):已经出队、正在关的连接不占队长
|
|
120
|
+
const others = this.waiting().filter((o) => o !== server);
|
|
121
|
+
const mine = others.filter((o) => waiterOf(o)?.userId === me.userId);
|
|
122
|
+
if (others.length - mine.length >= this.maxQueue) {
|
|
123
|
+
server.close(CLOSE.tryLater, 'queue full');
|
|
124
|
+
return done;
|
|
125
|
+
}
|
|
126
|
+
// 一小时前的旧条目顺手清掉(排过一次就走的人不能让这张表一直涨)
|
|
127
|
+
for (const [u, ts] of this.hourly) if (now - ts[ts.length - 1]! >= 3_600_000) this.hourly.delete(u);
|
|
128
|
+
const times = (this.hourly.get(me.userId) ?? []).filter((t) => now - t < 3_600_000);
|
|
129
|
+
if (times.length >= this.perHour) {
|
|
130
|
+
this.hourly.set(me.userId, times);
|
|
131
|
+
server.close(CLOSE.tooManyJoins, 'too often');
|
|
132
|
+
return done;
|
|
133
|
+
}
|
|
134
|
+
times.push(now);
|
|
135
|
+
this.hourly.set(me.userId, times);
|
|
136
|
+
// 同一个人只排一次:旧的那条让位
|
|
137
|
+
for (const o of mine) this.drop(o, CLOSE.replaced, 'replaced');
|
|
138
|
+
server.serializeAttachment({ userId: me.userId, name: me.name, since: now, tier } satisfies Waiter);
|
|
139
|
+
// 先回 101 再凑桌:凑桌要找平台、房间,不能让新来的人连连接都拿不到
|
|
140
|
+
this.ctx.waitUntil(this.form());
|
|
141
|
+
return done;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 排队连接只收不发:发什么都断开(不让它变成能刷的入口) */
|
|
145
|
+
override async webSocketMessage(ws: WebSocket): Promise<void> {
|
|
146
|
+
this.drop(ws, CLOSE.policy, 'queue is receive-only');
|
|
147
|
+
this.pushWaiting();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
ws.close(code === 1005 || code === 1006 ? 1000 : code, 'bye');
|
|
153
|
+
} catch {
|
|
154
|
+
// 已经关了
|
|
155
|
+
}
|
|
156
|
+
ws.serializeAttachment(null);
|
|
157
|
+
this.pushWaiting();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
override async webSocketError(ws: WebSocket): Promise<void> {
|
|
161
|
+
ws.serializeAttachment(null);
|
|
162
|
+
this.pushWaiting();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override async alarm(): Promise<void> {
|
|
166
|
+
this.alarmAt = null;
|
|
167
|
+
try {
|
|
168
|
+
await this.form();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// 闹钟里不往外抛(会自动重试、每次计费)
|
|
171
|
+
console.error('match alarm failed', e instanceof Error ? e.message : e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- 凑桌 ----------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 凑一轮:凑满一桌立刻开;最早的人等够了:≥ 2 人就开,只剩自己转单机。凑完还有人就把闹钟对到下一个人。
|
|
179
|
+
* 凑桌中间要 await(平台、房间),这时别的入口进来只记 again,凑完再来一轮
|
|
180
|
+
*/
|
|
181
|
+
private async form(): Promise<void> {
|
|
182
|
+
if (this.forming) {
|
|
183
|
+
this.again = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.forming = true;
|
|
187
|
+
try {
|
|
188
|
+
let rounds = 0;
|
|
189
|
+
do {
|
|
190
|
+
this.again = false;
|
|
191
|
+
while (rounds++ < FORM_ROUNDS && (await this.formOnce()));
|
|
192
|
+
} while (this.again && rounds < FORM_ROUNDS);
|
|
193
|
+
} finally {
|
|
194
|
+
this.forming = false;
|
|
195
|
+
this.taken.clear();
|
|
196
|
+
}
|
|
197
|
+
this.pushWaiting();
|
|
198
|
+
await this.planAlarm();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 凑一桌;凑出了(或者有人被请出去了)回 true,接着凑下一桌 */
|
|
202
|
+
private async formOnce(): Promise<boolean> {
|
|
203
|
+
const list = this.queue().filter((w) => !this.taken.has(w.me.userId));
|
|
204
|
+
if (!list.length) return false;
|
|
205
|
+
const tier = list[0]!.me.tier;
|
|
206
|
+
const size = Math.max(1, Math.min(this.tableSize(tier), BLOCK_MAX));
|
|
207
|
+
const due = Date.now() >= list[0]!.me.since + this.waitMs;
|
|
208
|
+
if (list.length < size && !due) return false;
|
|
209
|
+
const blocks = await withTimeout(this.blockedAmong(list.slice(0, BLOCK_MAX).map((w) => w.me.userId)), this.callMs).catch((e: unknown) => {
|
|
210
|
+
console.error('blockedAmong timed out, matching without it', e instanceof Error ? e.message : e);
|
|
211
|
+
return [] as [string, string][];
|
|
212
|
+
});
|
|
213
|
+
const chosen: typeof list = [];
|
|
214
|
+
for (const w of list.slice(0, BLOCK_MAX)) {
|
|
215
|
+
if (chosen.length >= size) break;
|
|
216
|
+
if (chosen.some((c) => blocks.some(([a, b]) => (a === c.me.userId && b === w.me.userId) || (a === w.me.userId && b === c.me.userId)))) continue;
|
|
217
|
+
chosen.push(w);
|
|
218
|
+
}
|
|
219
|
+
if (chosen.length < size && !due) return false;
|
|
220
|
+
if (chosen.length < 2) {
|
|
221
|
+
// 等够了却只有自己(或者在的人都和他互相屏蔽):转单机
|
|
222
|
+
this.solo(chosen[0]!.ws, 'alone');
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
for (const c of chosen) this.taken.add(c.me.userId);
|
|
226
|
+
const players: Identity[] = chosen.map((c) => ({ userId: c.me.userId, name: c.me.name }));
|
|
227
|
+
let out: MatchOutcome = { r: 'unavailable' };
|
|
228
|
+
let code = '';
|
|
229
|
+
for (let i = 0; i < CODE_TRIES; i++) {
|
|
230
|
+
code = newRoomCode();
|
|
231
|
+
try {
|
|
232
|
+
out = await withTimeout(this.createMatchRoom(code, players, this.roomOptions(tier)), this.callMs);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error('kitCreateMatch failed', e instanceof Error ? e.message : e);
|
|
235
|
+
out = { r: 'unavailable' };
|
|
236
|
+
}
|
|
237
|
+
if (out.r !== 'exists') break;
|
|
238
|
+
}
|
|
239
|
+
for (const c of chosen) this.taken.delete(c.me.userId);
|
|
240
|
+
// 等的这一会儿可能有人走了、换了页面:按 userId 找他现在的连接
|
|
241
|
+
const sockOf = (userId: string) => this.waiting().find((ws) => waiterOf(ws)?.userId === userId);
|
|
242
|
+
if (out.r === 'created') {
|
|
243
|
+
for (const c of chosen) {
|
|
244
|
+
const ws = sockOf(c.me.userId);
|
|
245
|
+
if (ws) this.finish(ws, { t: 'matched', code });
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (out.r === 'refused') {
|
|
250
|
+
if (out.userId === null) {
|
|
251
|
+
// 全站忙:这一轮的人都转单机,其他人留着(到点还会被同样挡掉)
|
|
252
|
+
for (const c of chosen) {
|
|
253
|
+
const ws = sockOf(c.me.userId);
|
|
254
|
+
if (ws) this.solo(ws, 'busy');
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const ws = sockOf(out.userId);
|
|
259
|
+
if (ws) {
|
|
260
|
+
if (out.why === 'has_room') this.finish(ws, { t: 'has_room', code: out.code });
|
|
261
|
+
else this.solo(ws, 'daily_limit');
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
// 房间那边出错、超时、选项不对:这一轮的人转单机,不让他们一直等。
|
|
267
|
+
// bad / bad_options 是游戏配错了(tableSize 比这一局的座位多、roomOptions 过不了 RoomGame.options),每桌都会这样:
|
|
268
|
+
// 大声记日志;游戏在自己的测试里用 game.ts 的 matchConfigErrors 先查
|
|
269
|
+
if (out.r === 'bad' || out.r === 'bad_options') console.error(`MatchQueue misconfigured for tier ${tier}: room said ${out.r} (check tableSize / roomOptions with matchConfigErrors)`);
|
|
270
|
+
else console.error('match room not created', out.r);
|
|
271
|
+
for (const c of chosen) {
|
|
272
|
+
const ws = sockOf(c.me.userId);
|
|
273
|
+
if (ws) this.solo(ws, 'unavailable');
|
|
274
|
+
}
|
|
275
|
+
// 房间那边不好(超时、出错):这一次不接着凑,剩下的人等下一次闹钟,不让锁一直被连着的超时占着
|
|
276
|
+
this.again = false;
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 只往早了调闹钟:最早那个人等够的时刻 */
|
|
281
|
+
private async planAlarm(): Promise<void> {
|
|
282
|
+
const list = this.queue();
|
|
283
|
+
if (!list.length) return;
|
|
284
|
+
const next = list[0]!.me.since + this.waitMs;
|
|
285
|
+
if (this.alarmAt === undefined) this.alarmAt = await this.ctx.storage.getAlarm();
|
|
286
|
+
if (this.alarmAt !== null && this.alarmAt <= next && this.alarmAt > Date.now() - 1_000) return;
|
|
287
|
+
this.alarmAt = next;
|
|
288
|
+
try {
|
|
289
|
+
await this.ctx.storage.setAlarm(next);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.error('match setAlarm failed', e instanceof Error ? e.message : e);
|
|
292
|
+
this.alarmAt = undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- 连接 ----------
|
|
297
|
+
|
|
298
|
+
private sockets(): WebSocket[] {
|
|
299
|
+
return this.ctx.getWebSockets();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private waiting(): WebSocket[] {
|
|
303
|
+
return this.sockets().filter((ws) => waiterOf(ws) !== null);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 排着的人,按开始排的先后;同一个人只算最新的那条 */
|
|
307
|
+
private queue(): { ws: WebSocket; me: Waiter }[] {
|
|
308
|
+
const by = new Map<string, { ws: WebSocket; me: Waiter }>();
|
|
309
|
+
for (const ws of this.waiting()) {
|
|
310
|
+
const me = waiterOf(ws)!;
|
|
311
|
+
const had = by.get(me.userId);
|
|
312
|
+
if (!had || had.me.since <= me.since) by.set(me.userId, { ws, me });
|
|
313
|
+
}
|
|
314
|
+
return [...by.values()].sort((a, b) => a.me.since - b.me.since);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 人数变了就推一次(不每秒推,页面自己倒计时) */
|
|
318
|
+
private pushWaiting(): void {
|
|
319
|
+
const list = this.queue();
|
|
320
|
+
if (!list.length) return;
|
|
321
|
+
// 倒计时按最早的那个人算:他到点时大家一起凑,后来的人等不满 waitMs
|
|
322
|
+
const left = Math.max(0, list[0]!.me.since + this.waitMs - Date.now());
|
|
323
|
+
const msg: MatchDownMsg = { t: 'waiting', n: list.length, seats: this.tableSize(list[0]!.me.tier), left };
|
|
324
|
+
for (const { ws } of list) send(ws, msg);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private solo(ws: WebSocket, why: MatchSoloWhy): void {
|
|
328
|
+
this.finish(ws, { t: 'solo', why });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 发最后一条,出队、关掉 */
|
|
332
|
+
private finish(ws: WebSocket, msg: MatchDownMsg): void {
|
|
333
|
+
send(ws, msg);
|
|
334
|
+
this.drop(ws, 1000, msg.t);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private drop(ws: WebSocket, code: number, reason: string): void {
|
|
338
|
+
ws.serializeAttachment(null);
|
|
339
|
+
try {
|
|
340
|
+
ws.close(code, reason);
|
|
341
|
+
} catch {
|
|
342
|
+
// 已经关了
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function waiterOf(ws: WebSocket): Waiter | null {
|
|
348
|
+
const a = ws.deserializeAttachment() as Waiter | null | undefined;
|
|
349
|
+
return a && typeof a.userId === 'string' ? a : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function send(ws: WebSocket, msg: MatchDownMsg): void {
|
|
353
|
+
try {
|
|
354
|
+
ws.send(JSON.stringify(msg));
|
|
355
|
+
} catch {
|
|
356
|
+
// 连接正在关
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 等一个外部调用最多 ms 毫秒,超时抛错(调用方按失败处理)。只在凑桌这一次请求里等,不是房间的状态计时,
|
|
362
|
+
* 所以用运行时的 scheduler.wait(不是 setTimeout、也不占那一个闹钟)
|
|
363
|
+
*/
|
|
364
|
+
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
365
|
+
// 超时以后原来的调用照样可能失败:先接住它的错,免得变成没人处理的 rejection
|
|
366
|
+
const settled = p.then((v) => ({ ok: true as const, v }), (e: unknown) => ({ ok: false as const, e }));
|
|
367
|
+
const out = await Promise.race([settled, scheduler.wait(ms).then(() => null)]);
|
|
368
|
+
if (out === null) throw new Error(`timed out after ${ms} ms`);
|
|
369
|
+
if (!out.ok) throw out.e;
|
|
370
|
+
return out.v;
|
|
371
|
+
}
|
package/src/meter.ts
CHANGED
|
@@ -65,6 +65,20 @@ export type MeterLevel = 'ok' | 'busy' | 'stop';
|
|
|
65
65
|
|
|
66
66
|
export type Reserve = { ok: true } | { ok: false; error: 'has_room'; code: string } | { ok: false; error: 'daily_limit' | 'busy' };
|
|
67
67
|
|
|
68
|
+
/** 几个人一起占(0.20.0):被挡时 userId 是第一个被挡的人;全站忙(busy)不是哪一个人的事,userId 为 null */
|
|
69
|
+
export type ReserveMany =
|
|
70
|
+
| { ok: true }
|
|
71
|
+
| { ok: false; userId: string; error: 'has_room'; code: string }
|
|
72
|
+
| { ok: false; userId: string; error: 'daily_limit' }
|
|
73
|
+
| { ok: false; userId: null; error: 'busy' };
|
|
74
|
+
|
|
75
|
+
/** kitPeek 的结果(0.20.0):room 是这个人开着的房间码,daily 是今天的开房次数用完了 */
|
|
76
|
+
export interface Peek {
|
|
77
|
+
room: string | null;
|
|
78
|
+
daily: boolean;
|
|
79
|
+
level: MeterLevel;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export interface Usage {
|
|
69
83
|
day: string;
|
|
70
84
|
writes: number;
|
|
@@ -90,17 +104,29 @@ export class RoomMeter extends DurableObject<MeterEnv> {
|
|
|
90
104
|
|
|
91
105
|
/** 开房前占一个名额(房间调)。房间没建成要调 kitRelease(…, cancel) 退回。 */
|
|
92
106
|
async kitReserve(userId: string, code: string, until: number): Promise<Reserve> {
|
|
107
|
+
const r = await this.kitReserveMany([userId], code, until);
|
|
108
|
+
return r.ok ? r : r.error === 'has_room' ? { ok: false, error: 'has_room', code: r.code } : { ok: false, error: r.error };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 一张桌给几个人一起占名额(0.20.0,快速匹配):要么都占上,要么一个都不占,占不上的回第一个被挡的人。
|
|
113
|
+
* 全站开房数只记 1(一张桌一个房间)。一次写 1 + 人数 行(每人那一行加当天那一行),写在计量里、记进当天的数,不算房间的"每步 ≤ 2 行"
|
|
114
|
+
*/
|
|
115
|
+
async kitReserveMany(userIds: readonly string[], code: string, until: number): Promise<ReserveMany> {
|
|
93
116
|
const now = Date.now();
|
|
94
117
|
const lim = this.limits();
|
|
95
118
|
const day = this.day(now);
|
|
96
|
-
const
|
|
119
|
+
const users = userIds.map((id) => ({ id, row: this.user(id, day.day, now) }));
|
|
97
120
|
// 先说"回你的房间":在打的房间不受全站门槛影响,回去照样能玩
|
|
98
|
-
if (
|
|
99
|
-
if (
|
|
100
|
-
if (levelOf(day.writes, lim) !== 'ok' || day.rooms >= lim.dailyRooms) return { ok: false, error: 'busy' };
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
for (const { id, row } of users) if (row.live.length >= lim.roomsPerUser) return { ok: false, userId: id, error: 'has_room', code: row.live[row.live.length - 1]!.code };
|
|
122
|
+
for (const { id, row } of users) if (row.count >= lim.dailyRoomsPerUser) return { ok: false, userId: id, error: 'daily_limit' };
|
|
123
|
+
if (levelOf(day.writes, lim) !== 'ok' || day.rooms >= lim.dailyRooms) return { ok: false, userId: null, error: 'busy' };
|
|
124
|
+
let w = 0;
|
|
125
|
+
for (const { id, row } of users) {
|
|
126
|
+
row.count++;
|
|
127
|
+
row.live.push({ code, until: until + EXPIRY_SLACK_MS });
|
|
128
|
+
w += this.saveUser(id, row);
|
|
129
|
+
}
|
|
104
130
|
this.saveDay(day.day, day.writes + w + 1, day.rooms + 1);
|
|
105
131
|
return { ok: true };
|
|
106
132
|
}
|
|
@@ -110,22 +136,52 @@ export class RoomMeter extends DurableObject<MeterEnv> {
|
|
|
110
136
|
* 名额已经不在(还过一次、或者已经到点作废)就只记写入,可以重复调。
|
|
111
137
|
*/
|
|
112
138
|
async kitRelease(userId: string, code: string, writes: number, cancel = false): Promise<MeterLevel> {
|
|
139
|
+
return this.kitReleaseMany([userId], code, writes, cancel);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 一张桌的几个人一起还(0.20.0)。cancel:开房次数也退回;room:全站开房数也退 1(默认跟着 cancel:整桌没建成)。
|
|
144
|
+
* 匹配桌开打时没来的人(0.20.0)是 cancel 但不退全站数:桌子开成了,只是他的那一份退给他
|
|
145
|
+
*/
|
|
146
|
+
async kitReleaseMany(userIds: readonly string[], code: string, writes: number, cancel = false, room = cancel): Promise<MeterLevel> {
|
|
113
147
|
const now = Date.now();
|
|
114
148
|
const day = this.day(now);
|
|
115
|
-
const user = this.user(userId, day.day, now);
|
|
116
|
-
const i = user.live.findIndex((r) => r.code === code);
|
|
117
149
|
let w = 0;
|
|
118
|
-
|
|
150
|
+
let found = false;
|
|
151
|
+
for (const id of userIds) {
|
|
152
|
+
const user = this.user(id, day.day, now);
|
|
153
|
+
const i = user.live.findIndex((r) => r.code === code);
|
|
154
|
+
if (i < 0) continue;
|
|
155
|
+
found = true;
|
|
119
156
|
user.live.splice(i, 1);
|
|
120
157
|
if (cancel && user.count > 0) user.count--;
|
|
121
|
-
w
|
|
158
|
+
w += this.saveUser(id, user);
|
|
122
159
|
}
|
|
123
|
-
const rooms =
|
|
160
|
+
const rooms = room && found ? Math.max(0, day.rooms - 1) : day.rooms;
|
|
124
161
|
const total = day.writes + clean(writes) + w + 1;
|
|
125
162
|
this.saveDay(day.day, total, rooms);
|
|
126
163
|
return levelOf(total, this.limits());
|
|
127
164
|
}
|
|
128
165
|
|
|
166
|
+
/**
|
|
167
|
+
* 只读(0.20.0,快速匹配入队前问):这个人现在开着的房间(没有为 null)、今天的开房次数用完没有、全站的门槛。
|
|
168
|
+
* 入队前就挡掉"有房间""今天用完了""联机忙"的人,不让他们白等 15 秒
|
|
169
|
+
*/
|
|
170
|
+
async kitPeek(userId: string): Promise<Peek> {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const lim = this.limits();
|
|
173
|
+
const day = this.day(now);
|
|
174
|
+
const user = this.user(userId, day.day, now);
|
|
175
|
+
const lv = levelOf(day.writes, lim);
|
|
176
|
+
// 全站开房数到了也开不了新桌,对排队的人和"联机忙"一样
|
|
177
|
+
const level = lv === 'ok' && day.rooms >= lim.dailyRooms ? 'busy' : lv;
|
|
178
|
+
return {
|
|
179
|
+
room: user.live.length >= lim.roomsPerUser ? user.live[user.live.length - 1]!.code : null,
|
|
180
|
+
daily: user.count >= lim.dailyRoomsPerUser,
|
|
181
|
+
level,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
129
185
|
/** 报一批写入(房间攒够一批时调),回现在的门槛。 */
|
|
130
186
|
async kitReport(writes: number): Promise<MeterLevel> {
|
|
131
187
|
const day = this.day(Date.now());
|
package/src/protocol.ts
CHANGED
|
@@ -69,6 +69,42 @@ export const CLOSE_REASON = {
|
|
|
69
69
|
*/
|
|
70
70
|
export type CreateError = 'login' | 'forbidden' | 'has_room' | 'daily_limit' | 'busy' | 'unavailable' | 'bad_options';
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 快速匹配(0.20.0,docs/match.md)转单机的原因:
|
|
74
|
+
* - alone 等到时间只有自己在排;daily_limit 这个人今天的联机局数用完了;busy 今天联机的人太多(全站门槛);
|
|
75
|
+
* - unavailable 计量或房间暂时连不上;too_often 这一小时排得太多了;replaced 同一个人在别的页面又排了一次(这一页不用管了)。
|
|
76
|
+
*/
|
|
77
|
+
export type MatchSoloWhy = 'alone' | 'daily_limit' | 'busy' | 'unavailable' | 'too_often' | 'replaced';
|
|
78
|
+
|
|
79
|
+
/** 排队时的人数(0.20.0):n 是现在排着几个人(含自己),seats 是一桌最多几个真人,left 是离"不等了"还剩几毫秒(按房间的钟算好的差值) */
|
|
80
|
+
export interface MatchWaitingMsg {
|
|
81
|
+
t: 'waiting';
|
|
82
|
+
n: number;
|
|
83
|
+
seats: number;
|
|
84
|
+
left: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 凑成一桌了(0.20.0):用 code 照常进房(RoomClient),房间只认排进来的这几个人 */
|
|
88
|
+
export interface MatchedMsg {
|
|
89
|
+
t: 'matched';
|
|
90
|
+
code: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 不排了,转本机单机(0.20.0) */
|
|
94
|
+
export interface MatchSoloMsg {
|
|
95
|
+
t: 'solo';
|
|
96
|
+
why: MatchSoloWhy;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 这个人已经有一个开着的房间(0.20.0):不排队,页面带他回那个房间 */
|
|
100
|
+
export interface MatchHasRoomMsg {
|
|
101
|
+
t: 'has_room';
|
|
102
|
+
code: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 排队连接上的下行消息(0.20.0)。排队连接只收不发,浏览器发什么都会被 1008 断开 */
|
|
106
|
+
export type MatchDownMsg = MatchWaitingMsg | MatchedMsg | MatchSoloMsg | MatchHasRoomMsg;
|
|
107
|
+
|
|
72
108
|
/** 不重连的关闭码。 */
|
|
73
109
|
export function isFinalClose(code: number): boolean {
|
|
74
110
|
return code === CLOSE.policy || code === CLOSE.tooBig || (code >= 4000 && code < 5000);
|
|
@@ -176,6 +212,11 @@ export interface RoomInfo {
|
|
|
176
212
|
* 只有在停时才有,这时 turn 是 null;游戏的 resume 出错时房间会把它往后推
|
|
177
213
|
*/
|
|
178
214
|
pause?: { until: number };
|
|
215
|
+
/**
|
|
216
|
+
* 快速匹配凑出来的桌(0.20.0):只有排进来的人能进、没有房主、人到齐(或等满 RoomLimits.matchJoinMs)自动开打。
|
|
217
|
+
* 页面据此藏起房间码、邀请、叫人和"开打"按钮
|
|
218
|
+
*/
|
|
219
|
+
match?: true;
|
|
179
220
|
}
|
|
180
221
|
|
|
181
222
|
/** 一帧画面:按座位过滤过的游戏视图,delay 是相对上一帧的播放延迟(电脑连续出牌时用,4.4)。 */
|