@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/room.ts
CHANGED
|
@@ -103,6 +103,11 @@ export interface RoomLimits {
|
|
|
103
103
|
* 到了还办不完就记日志照样删(办事的一方要有自己的兜底,比如钱包到期退回买入)
|
|
104
104
|
*/
|
|
105
105
|
closingMs: number;
|
|
106
|
+
/**
|
|
107
|
+
* 快速匹配的桌(0.20.0):建好以后最多等排进来的人这么久,到了还没连上的座位由电脑代打、照样开打(人后来到了交还给他)。
|
|
108
|
+
* 都连上了就不等,马上开
|
|
109
|
+
*/
|
|
110
|
+
matchJoinMs: number;
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
/** 4.5、4.6 的默认值。 */
|
|
@@ -127,6 +132,7 @@ export const DEFAULT_LIMITS: RoomLimits = {
|
|
|
127
132
|
chatPerMin: 10,
|
|
128
133
|
meterBatch: 200,
|
|
129
134
|
closingMs: 60 * 60_000,
|
|
135
|
+
matchJoinMs: 20_000,
|
|
130
136
|
};
|
|
131
137
|
|
|
132
138
|
/** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
|
|
@@ -225,6 +231,11 @@ interface Persist<S> {
|
|
|
225
231
|
ending?: { reason: string; at: number } | null;
|
|
226
232
|
/** 开房时定下的开局选项(0.4.0,RoomGame.options 校验过的);没有就是开打时由 start 定 */
|
|
227
233
|
opts?: unknown;
|
|
234
|
+
/**
|
|
235
|
+
* 快速匹配的桌(0.20.0):排进来的人(建房时已经坐在各自的座位上)和最晚什么时候开打。
|
|
236
|
+
* 只有这几个人能进;没有房主(host 只是第一个人,用来还名额以外不起作用),start 一律 not_host
|
|
237
|
+
*/
|
|
238
|
+
match?: { users: string[]; joinBy: number };
|
|
228
239
|
}
|
|
229
240
|
|
|
230
241
|
interface Attachment {
|
|
@@ -241,6 +252,16 @@ export type CreateOutcome =
|
|
|
241
252
|
| { r: 'created' | 'exists' | 'bad' | 'bad_options' | 'error' | 'unavailable' | 'busy' | 'daily_limit' }
|
|
242
253
|
| { r: 'has_room'; code: string };
|
|
243
254
|
|
|
255
|
+
/**
|
|
256
|
+
* 匹配开桌的结果(kitCreateMatch → match.ts 的 MatchQueue,0.20.0)。refused:计量挡了某个人(has_room 带他那个房间的码),
|
|
257
|
+
* userId 为 null 是全站忙(busy);这一桌一个名额都没占,排队那边把这个人请出去再凑
|
|
258
|
+
*/
|
|
259
|
+
export type MatchOutcome =
|
|
260
|
+
| { r: 'created' | 'exists' | 'bad' | 'bad_options' | 'error' | 'unavailable' }
|
|
261
|
+
| { r: 'refused'; userId: string; why: 'has_room'; code: string }
|
|
262
|
+
| { r: 'refused'; userId: string; why: 'daily_limit' }
|
|
263
|
+
| { r: 'refused'; userId: null; why: 'busy' };
|
|
264
|
+
|
|
244
265
|
/** 房间里一步的结果:要广播的帧、给做这步的连接回 ack。 */
|
|
245
266
|
interface StepOut<S> {
|
|
246
267
|
/** 广播的帧:每个元素是一个状态,广播时按座位换成视图 */
|
|
@@ -343,8 +364,11 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
343
364
|
lastStepFailed = false;
|
|
344
365
|
/** 写了还没报给计量的行数 */
|
|
345
366
|
private unmetered = 0;
|
|
346
|
-
/**
|
|
347
|
-
|
|
367
|
+
/**
|
|
368
|
+
* 这一步做完以后要还的名额(打完、关房;房主一个人,匹配桌是排进来的每个人),entry 最后去找计量。
|
|
369
|
+
* refund:匹配桌开打时没来的人(0.20.0),连当天的开房次数一起退给他(全站开房数不退,桌子开成了)
|
|
370
|
+
*/
|
|
371
|
+
private release: { users: string[]; code: string; refund?: true } | null = null;
|
|
348
372
|
/** 计量最近一次说的门槛(内存;被驱逐就重新问) */
|
|
349
373
|
private level: { value: MeterLevel; at: number } | null = null;
|
|
350
374
|
/** 计量连不上时,什么时候再试着报数 */
|
|
@@ -399,6 +423,45 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
399
423
|
return { r: out instanceof Response ? 'error' : out };
|
|
400
424
|
}
|
|
401
425
|
|
|
426
|
+
/**
|
|
427
|
+
* 快速匹配开桌(0.20.0,只有 match.ts 的排队对象经 RPC 调):players 按排队先后,每人占一个开房名额(计量一次占齐,占不齐一个都不占),
|
|
428
|
+
* 建房时把他们按 matchSeats 坐好。和 kitCreate 一样:码撞了回 exists、什么都不写;占到了却没建成就退回去。
|
|
429
|
+
*/
|
|
430
|
+
async kitCreateMatch(code: string, players: Identity[], rawOpts?: unknown): Promise<MatchOutcome> {
|
|
431
|
+
const fixed = this.checkOpts(rawOpts);
|
|
432
|
+
if (fixed === undefined || (rawOpts !== undefined && !this.game.options)) return { r: 'bad_options' };
|
|
433
|
+
if (!validPlayers(code, players, fixed.count)) return { r: 'bad' };
|
|
434
|
+
const meter = this.meter();
|
|
435
|
+
const users = players.map((p) => p.userId);
|
|
436
|
+
let reserved = false;
|
|
437
|
+
if (meter && !this.load()) {
|
|
438
|
+
try {
|
|
439
|
+
const r = await meter.kitReserveMany(users, code, Date.now() + this.limits.maxLifeMs);
|
|
440
|
+
if (!r.ok) {
|
|
441
|
+
if (r.error === 'has_room') return { r: 'refused', userId: r.userId, why: 'has_room', code: r.code };
|
|
442
|
+
return r.error === 'daily_limit' ? { r: 'refused', userId: r.userId, why: 'daily_limit' } : { r: 'refused', userId: null, why: 'busy' };
|
|
443
|
+
}
|
|
444
|
+
reserved = true;
|
|
445
|
+
} catch (e) {
|
|
446
|
+
console.error('meter unreachable, refusing match room', e instanceof Error ? e.message : e);
|
|
447
|
+
return { r: 'unavailable' };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const before = this.maxStepWrites;
|
|
451
|
+
const out = await this.entry(() => this.createMatch(code, players, fixed));
|
|
452
|
+
if (out === 'created') this.maxStepWrites = before;
|
|
453
|
+
else if (reserved && meter) await meter.kitReleaseMany(users, code, 0, true).catch(() => console.error('meter release failed', code));
|
|
454
|
+
return { r: out instanceof Response ? 'error' : out };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 快速匹配时 n 个人坐哪几个座位(0.20.0,可选覆盖):count 是这一局用几个座位,返回 n 个不重复的座位号(排队先后的顺序)。
|
|
459
|
+
* 默认随机(crypto,不在规则核心里);比如两个真人要坐对家,游戏在这里给。给的不合格(重复、越界、个数不对)就按默认的随机
|
|
460
|
+
*/
|
|
461
|
+
protected matchSeats(n: number, count: number): number[] {
|
|
462
|
+
return randomSeats(n, count);
|
|
463
|
+
}
|
|
464
|
+
|
|
402
465
|
/** 进房:只认 kit 自己拼的内部地址(worker.ts 的 acceptJoin),身份在 x-kit-user 头里。 */
|
|
403
466
|
override async fetch(request: Request): Promise<Response> {
|
|
404
467
|
const url = new URL(request.url);
|
|
@@ -569,7 +632,9 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
569
632
|
this.release = null;
|
|
570
633
|
this.unmetered = 0;
|
|
571
634
|
try {
|
|
572
|
-
const level = release
|
|
635
|
+
const level = release
|
|
636
|
+
? await meter.kitReleaseMany(release.users, release.code, n, release.refund === true, false)
|
|
637
|
+
: await meter.kitReport(n);
|
|
573
638
|
this.level = { value: level, at: Date.now() };
|
|
574
639
|
this.meterRetryAt = 0;
|
|
575
640
|
} catch (e) {
|
|
@@ -606,7 +671,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
606
671
|
private async newcomerGate(userId: string): Promise<number | null> {
|
|
607
672
|
const meter = this.meter();
|
|
608
673
|
const st = this.load();
|
|
609
|
-
if (!meter || !st || st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null) return null;
|
|
674
|
+
if (!meter || !st || st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null || st.match?.users.includes(userId)) return null;
|
|
610
675
|
if (this.sockets().some((o) => attachmentOf(o)?.userId === userId)) return null;
|
|
611
676
|
const now = Date.now();
|
|
612
677
|
if (!this.level || now - this.level.at >= LEVEL_TTL_MS) {
|
|
@@ -650,6 +715,32 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
650
715
|
return 'created';
|
|
651
716
|
}
|
|
652
717
|
|
|
718
|
+
/** 匹配桌(0.20.0):和 create 一样建表、存一行、设闹钟,只是排进来的人都先坐好(算掉线,连上就"回来"),记下最晚开打的时间。 */
|
|
719
|
+
private createMatch(code: string, players: Identity[], fixed: { opts: O | undefined; count: number }): 'created' | 'exists' | 'bad' {
|
|
720
|
+
if (this.load()) return 'exists';
|
|
721
|
+
this.writes += this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (k INTEGER PRIMARY KEY, v TEXT NOT NULL)`).rowsWritten;
|
|
722
|
+
const now = Date.now();
|
|
723
|
+
const seats: SeatState[] = Array.from({ length: this.game.seats }, () => emptySeat());
|
|
724
|
+
let order: number[];
|
|
725
|
+
try {
|
|
726
|
+
order = this.matchSeats(players.length, fixed.count);
|
|
727
|
+
} catch (e) {
|
|
728
|
+
console.error('matchSeats threw', e instanceof Error ? e.message : e);
|
|
729
|
+
order = [];
|
|
730
|
+
}
|
|
731
|
+
if (!validSeats(order, players.length, fixed.count)) order = randomSeats(players.length, fixed.count);
|
|
732
|
+
players.forEach((p, i) => (seats[order[i]!] = humanSeat(p.userId, p.name, now)));
|
|
733
|
+
this.st = {
|
|
734
|
+
sv: 1, code, host: players[0]!.userId, createdAt: now, status: 'waiting', seq: 0, gameSeq: 0, turnSeq: 0, seats, game: null, rulesVersion: null,
|
|
735
|
+
turnSince: null, aiAt: null, noHumanSince: now, inbound: 0, inboundBy: {}, joins: {}, owners: seats.map(() => null),
|
|
736
|
+
...(fixed.opts !== undefined ? { opts: fixed.opts } : {}),
|
|
737
|
+
match: { users: players.map((p) => p.userId), joinBy: now + this.limits.matchJoinMs },
|
|
738
|
+
};
|
|
739
|
+
this.planAlarm();
|
|
740
|
+
this.save();
|
|
741
|
+
return 'created';
|
|
742
|
+
}
|
|
743
|
+
|
|
653
744
|
/** 进房(含重连):接受、记座位、发快照,中间没有 await(4.5 续局的写法)。 */
|
|
654
745
|
private join(me: Identity, rawCv: string | null, chat = false): Response {
|
|
655
746
|
const pair = new WebSocketPair();
|
|
@@ -671,6 +762,11 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
671
762
|
server.close(CLOSE.tooOld, 'client too old');
|
|
672
763
|
return done();
|
|
673
764
|
}
|
|
765
|
+
// 匹配桌只认排进来的人(0.20.0):房间码不给页面看,别人拿到码也进不来、看不了,回的和"没有这个房间"一样
|
|
766
|
+
if (st.match && !st.match.users.includes(me.userId)) {
|
|
767
|
+
server.close(CLOSE.noRoom, 'no room');
|
|
768
|
+
return done();
|
|
769
|
+
}
|
|
674
770
|
const seat = this.seatOf(me.userId);
|
|
675
771
|
const others = this.sockets().filter((o) => o !== server && attachmentOf(o));
|
|
676
772
|
const mine = others.filter((o) => attachmentOf(o)!.userId === me.userId);
|
|
@@ -727,8 +823,10 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
727
823
|
st.joins[me.userId] = used + 1;
|
|
728
824
|
st.noHumanSince = null;
|
|
729
825
|
}
|
|
826
|
+
// 匹配桌的人到齐了:不等,和这次进房同一步开打(还是只写这一行和闹钟)
|
|
827
|
+
const frames = changed && this.matchReady(st) ? this.beginPlay(st, Date.now()) : [{ state: st.game, delay: 0 }];
|
|
730
828
|
// 状态没变(没入座的人进来、在线时又从别的设备连一次)不写库、不广播
|
|
731
|
-
if (changed) this.commit({ frames
|
|
829
|
+
if (changed) this.commit({ frames }, server);
|
|
732
830
|
this.send(server, this.welcome(seat, this.boundOf(me.userId)));
|
|
733
831
|
return done();
|
|
734
832
|
}
|
|
@@ -856,6 +954,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
856
954
|
if (current === raw) return this.reject(ws, 'already_seated');
|
|
857
955
|
const target = st.seats[raw]!;
|
|
858
956
|
if (st.status === 'over') return this.reject(ws, 'room_over');
|
|
957
|
+
// 匹配桌等人时座位是排好的,不换(开打以后离座、回座照常)
|
|
958
|
+
if (st.match && st.status === 'waiting') return this.reject(ws, 'seat_taken');
|
|
859
959
|
if (st.status === 'waiting') {
|
|
860
960
|
// 开房时定了选项的(0.4.0):这一局用不到的座位现在就不让坐,免得房主开打时才被 seat_closed 拒
|
|
861
961
|
if (st.opts !== undefined && raw >= this.countOf(st.opts as O)) return this.reject(ws, 'seat_closed');
|
|
@@ -890,7 +990,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
890
990
|
|
|
891
991
|
private leave(seat: number | null): void {
|
|
892
992
|
const st = this.st!;
|
|
893
|
-
if (seat === null) return;
|
|
993
|
+
if (seat === null || (st.match && st.status === 'waiting')) return;
|
|
894
994
|
// 等人时空出来;开打后由电脑接手(不判负),轮到它就接着出。座位还绑着他,留下昵称给牌桌显示"电脑代某某打"
|
|
895
995
|
st.seats[seat] = st.status === 'playing' ? { ...aiSeat(), name: st.seats[seat]!.name } : emptySeat();
|
|
896
996
|
if (!this.seatedOnline()) st.noHumanSince ??= Date.now();
|
|
@@ -902,8 +1002,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
902
1002
|
|
|
903
1003
|
private start(ws: WebSocket, me: Attachment, seat: number | null, rawOpts: unknown): void {
|
|
904
1004
|
const st = this.st!;
|
|
905
|
-
// 房主选择开打(9.1)
|
|
906
|
-
if (me.userId !== st.host) return this.reject(ws, 'not_host');
|
|
1005
|
+
// 房主选择开打(9.1);匹配桌没有房主,到齐或到点自动开(0.20.0)
|
|
1006
|
+
if (me.userId !== st.host || st.match) return this.reject(ws, 'not_host');
|
|
907
1007
|
if (st.status !== 'waiting') return this.reject(ws, 'already_started');
|
|
908
1008
|
if (seat === null) return this.reject(ws, 'not_seated');
|
|
909
1009
|
// 开局选项(0.3.0):游戏校验;这一局用几个座位,后面的座位一直空着。
|
|
@@ -911,20 +1011,42 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
911
1011
|
const fixed = st.opts !== undefined ? { opts: st.opts as O, count: this.countOf(st.opts as O) } : null;
|
|
912
1012
|
const checked = fixed ?? this.checkOpts(rawOpts);
|
|
913
1013
|
if (!checked) return this.reject(ws, 'bad_options');
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1014
|
+
if (st.seats.some((s, i) => i >= checked.count && s.kind === 'human')) return this.reject(ws, 'seat_closed');
|
|
1015
|
+
this.commit({ frames: this.beginPlay(st, Date.now(), checked) });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* 开打:空座位由电脑补满,入座的人从这一刻起绑在自己的座位上,返回要广播的帧。
|
|
1020
|
+
* 匹配桌(0.20.0)到点还没连上的人:座位照样绑给他,电脑先代打(auto),他连上就交还
|
|
1021
|
+
*/
|
|
1022
|
+
private beginPlay(st: Persist<S>, now: number, checked?: { opts: O | undefined; count: number }): { state: S | null; delay: number }[] {
|
|
1023
|
+
const { opts, count } = checked ?? { opts: st.opts as O | undefined, count: this.countOf(st.opts as O | undefined) };
|
|
917
1024
|
st.seats = st.seats.map((s, i) => (s.kind === 'empty' && i < count ? aiSeat() : s));
|
|
1025
|
+
if (st.match) {
|
|
1026
|
+
const absent: string[] = [];
|
|
1027
|
+
for (const s of st.seats) {
|
|
1028
|
+
if (s.kind !== 'human' || s.awaySince === null) continue;
|
|
1029
|
+
s.auto = true;
|
|
1030
|
+
absent.push(s.userId!);
|
|
1031
|
+
}
|
|
1032
|
+
// 没来的人(关了页面、网断了):他的开房名额和当天次数退给他,不然白扣一个、还被 has_room 卡到这桌打完。
|
|
1033
|
+
// 他后来回来照样能接着打这个座位(座位还绑着他)
|
|
1034
|
+
if (absent.length) this.release = { users: absent, code: st.code, refund: true };
|
|
1035
|
+
}
|
|
918
1036
|
st.owners = st.seats.map((s) => (s.kind === 'human' ? s.userId : null));
|
|
919
1037
|
st.rulesVersion = this.game.rulesVersion;
|
|
920
1038
|
st.game = this.begin(st.code, { opts, seats: st.seats.map((s) => s.kind) });
|
|
921
1039
|
st.gameSeq = st.seq + 1;
|
|
922
1040
|
st.turnSeq = st.gameSeq;
|
|
923
1041
|
st.status = 'playing';
|
|
924
|
-
const now = Date.now();
|
|
925
1042
|
st.turnSince = now;
|
|
926
1043
|
if (!this.game.over(st.game)) this.stalled(this.actorsOf(st.game));
|
|
927
|
-
|
|
1044
|
+
return [{ state: st.game, delay: 0 }, ...this.runAuto(now)];
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** 匹配桌在等人,排进来的人都连上了 */
|
|
1048
|
+
private matchReady(st: Persist<S>): boolean {
|
|
1049
|
+
return !!st.match && st.status === 'waiting' && st.seats.every((s) => s.kind !== 'human' || s.awaySince === null);
|
|
928
1050
|
}
|
|
929
1051
|
|
|
930
1052
|
/**
|
|
@@ -1033,6 +1155,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1033
1155
|
|
|
1034
1156
|
const frames: { state: S | null; delay: number }[] = [];
|
|
1035
1157
|
let changed = false;
|
|
1158
|
+
// 匹配桌等够了(0.20.0):没来的由电脑代打,照样开
|
|
1159
|
+
if (st.match && st.status === 'waiting' && now >= st.match.joinBy) return this.commit({ frames: this.beginPlay(st, now) });
|
|
1036
1160
|
// 掉线超过宽限:电脑托管(只在开打以后有意义,等人时不管)
|
|
1037
1161
|
for (const s of st.status === 'playing' ? st.seats : []) {
|
|
1038
1162
|
if (s.kind === 'human' && s.awaySince !== null && !s.auto && now >= s.awaySince + this.limits.graceMs) {
|
|
@@ -1087,6 +1211,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1087
1211
|
const ds = [st.createdAt + this.limits.maxLifeMs];
|
|
1088
1212
|
if (st.effectsAt != null) ds.push(st.effectsAt);
|
|
1089
1213
|
if (st.noHumanSince !== null) ds.push(st.noHumanSince + this.limits.noHumanMs);
|
|
1214
|
+
if (st.match && st.status === 'waiting') ds.push(st.match.joinBy);
|
|
1090
1215
|
if (st.status === 'playing') {
|
|
1091
1216
|
for (const s of st.seats) if (s.kind === 'human' && s.awaySince !== null && !s.auto) ds.push(s.awaySince + this.limits.graceMs);
|
|
1092
1217
|
const due = this.deadline();
|
|
@@ -1143,7 +1268,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1143
1268
|
if (over) {
|
|
1144
1269
|
st.status = 'over';
|
|
1145
1270
|
st.turnSince = null;
|
|
1146
|
-
this.release =
|
|
1271
|
+
this.release = releaseOf(st);
|
|
1147
1272
|
return;
|
|
1148
1273
|
}
|
|
1149
1274
|
const after = this.turnOf(next);
|
|
@@ -1190,7 +1315,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1190
1315
|
console.error('game has nobody to act but is not over', st.code);
|
|
1191
1316
|
st.status = 'over';
|
|
1192
1317
|
st.turnSince = null;
|
|
1193
|
-
this.release =
|
|
1318
|
+
this.release = releaseOf(st);
|
|
1194
1319
|
return true;
|
|
1195
1320
|
}
|
|
1196
1321
|
|
|
@@ -1571,7 +1696,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1571
1696
|
st.ending = { reason, at: Date.now() };
|
|
1572
1697
|
st.effectsAt = null;
|
|
1573
1698
|
st.effectTries = 0;
|
|
1574
|
-
this.release =
|
|
1699
|
+
this.release = releaseOf(st);
|
|
1575
1700
|
this.closing = false;
|
|
1576
1701
|
this.planAlarm();
|
|
1577
1702
|
this.save();
|
|
@@ -1587,7 +1712,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1587
1712
|
ws.serializeAttachment(null);
|
|
1588
1713
|
ws.close(CLOSE.roomClosed, reason);
|
|
1589
1714
|
}
|
|
1590
|
-
if (this.st) this.release =
|
|
1715
|
+
if (this.st) this.release = releaseOf(this.st);
|
|
1591
1716
|
this.st = null;
|
|
1592
1717
|
this.alarmToSet = null;
|
|
1593
1718
|
this.alarmAt = null;
|
|
@@ -1655,13 +1780,14 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1655
1780
|
turn,
|
|
1656
1781
|
...(st.opts !== undefined ? { opts: st.opts } : {}),
|
|
1657
1782
|
...(until !== null ? { pause: { until } } : {}),
|
|
1783
|
+
...(st.match ? { match: true as const } : {}),
|
|
1658
1784
|
};
|
|
1659
1785
|
}
|
|
1660
1786
|
|
|
1661
1787
|
/** 这个房间的自己人:房主、坐着的、开打后绑过座位的。猜码超限时只放他们进(0.14.0) */
|
|
1662
1788
|
private insider(userId: string): boolean {
|
|
1663
1789
|
const st = this.load();
|
|
1664
|
-
return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null);
|
|
1790
|
+
return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null || !!st.match?.users.includes(userId));
|
|
1665
1791
|
}
|
|
1666
1792
|
|
|
1667
1793
|
/** 房主,或者开打后绑过座位的人:旁观名额不算他们(0.14.0) */
|
|
@@ -1778,6 +1904,33 @@ function validHost(code: string, host: Identity): boolean {
|
|
|
1778
1904
|
return ROOM_CODE_RE.test(code) && !!host && typeof host.userId === 'string' && !!host.userId && typeof host.name === 'string';
|
|
1779
1905
|
}
|
|
1780
1906
|
|
|
1907
|
+
/** 打完、关房时要还谁的名额:匹配桌是排进来的每个人,好友房是房主 */
|
|
1908
|
+
function releaseOf(st: { host: string; code: string; match?: { users: string[] } }): { users: string[]; code: string } {
|
|
1909
|
+
return { users: st.match ? [...st.match.users] : [st.host], code: st.code };
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** 匹配桌的人:1 到 count 个、userId 不重复、各自像个身份 */
|
|
1913
|
+
function validPlayers(code: string, players: unknown, count: number): players is Identity[] {
|
|
1914
|
+
if (!Array.isArray(players) || players.length < 1 || players.length > count) return false;
|
|
1915
|
+
if (!players.every((p: Identity) => validHost(code, p))) return false;
|
|
1916
|
+
return new Set(players.map((p: Identity) => p.userId)).size === players.length;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function validSeats(order: unknown, n: number, count: number): order is number[] {
|
|
1920
|
+
return Array.isArray(order) && order.length === n && new Set(order).size === n && order.every((x) => Number.isInteger(x) && x >= 0 && x < count);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** 从 0..count-1 里随机挑 n 个座位(crypto;房间里的随机,不是规则核心) */
|
|
1924
|
+
function randomSeats(n: number, count: number): number[] {
|
|
1925
|
+
const all = Array.from({ length: count }, (_, i) => i);
|
|
1926
|
+
const r = crypto.getRandomValues(new Uint32Array(count));
|
|
1927
|
+
for (let i = count - 1; i > 0; i--) {
|
|
1928
|
+
const j = r[i]! % (i + 1);
|
|
1929
|
+
[all[i], all[j]] = [all[j]!, all[i]!];
|
|
1930
|
+
}
|
|
1931
|
+
return all.slice(0, n);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1781
1934
|
function emptySeat(): SeatState {
|
|
1782
1935
|
return { kind: 'empty', userId: null, name: null, awaySince: null, auto: false, lastMoveId: null };
|
|
1783
1936
|
}
|
package/src/table-chat.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 和 table-fun.ts 同样的规矩:import 时不碰 DOM;动画只动 transform、opacity;量位置只在摆放(refresh)和出一条弹幕时。
|
|
5
5
|
*/
|
|
6
6
|
import { CHAT_MAX, chatRejectText, type ChatBlockWhy } from './chat.js';
|
|
7
|
-
import { DANMAKU, barBottom, chatFont, chatMode, danmakuLanes, danmakuSpeed, pickLane, placeBeside, placeDock, seatColor, type Box, type ChatMode } from './fun-logic.js';
|
|
7
|
+
import { DANMAKU, barBottom, chatFont, chatMode, danmakuLanes, danmakuSpeed, dockFits, pickLane, placeBeside, placeDock, seatColor, type Box, type ChatMode } from './fun-logic.js';
|
|
8
8
|
|
|
9
9
|
export type ChatResultLike = { ok: true } | { ok: false; code: string; why?: ChatBlockWhy; waitMs?: number };
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@ export interface ChatLine {
|
|
|
20
20
|
interface ChatPrefs {
|
|
21
21
|
/** 弹幕开关;没设过:系统要求减少动态效果、或省内存模式时默认关,其余默认开 */
|
|
22
22
|
danmaku?: boolean;
|
|
23
|
-
/**
|
|
23
|
+
/** 电脑版聊天框收起来了;没设过:四个角都会压到东西(dockFits)时默认收着(0.20.0),其余默认展开 */
|
|
24
24
|
folded?: boolean;
|
|
25
25
|
/** 电脑版聊天框的记录展开成长条 */
|
|
26
26
|
long?: boolean;
|
|
@@ -230,7 +230,15 @@ export class ChatUi {
|
|
|
230
230
|
private drawDesk(view: { width: number; height: number }): void {
|
|
231
231
|
const w = Math.round(this.font * 17);
|
|
232
232
|
const fixed = this.h.dock?.() ?? null;
|
|
233
|
-
|
|
233
|
+
// 没自己收起、展开过:四个角都会压到东西(dockFits,0.20.0)就默认收着。先排出来量真实高度再判断,不靠估
|
|
234
|
+
let folded = this.prefs.folded === true;
|
|
235
|
+
if (this.prefs.folded === undefined && !fixed) {
|
|
236
|
+
const d = this.layoutDock(view, w, null);
|
|
237
|
+
folded = !dockFits(view, w, d.offsetHeight || 260, this.h.blocked());
|
|
238
|
+
if (folded) this.clearDesk();
|
|
239
|
+
else return;
|
|
240
|
+
}
|
|
241
|
+
if (folded) {
|
|
234
242
|
if (this.dockEl) this.clearDesk();
|
|
235
243
|
if (!this.pill) {
|
|
236
244
|
const p = this.el('button', 'atc-pill');
|
|
@@ -251,6 +259,15 @@ export class ChatUi {
|
|
|
251
259
|
this.pill.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
252
260
|
return;
|
|
253
261
|
}
|
|
262
|
+
this.layoutDock(view, w, fixed);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 排出电脑版聊天框(大小、记录高度),放到 fixed 或 placeDock 挑的角上,返回它 */
|
|
266
|
+
private layoutDock(view: { width: number; height: number }, w: number, fixed: Box | null): HTMLDivElement {
|
|
267
|
+
if (this.pill) {
|
|
268
|
+
this.pill.remove();
|
|
269
|
+
this.pill = null;
|
|
270
|
+
}
|
|
254
271
|
if (!this.dockEl) this.dockEl = this.buildDock();
|
|
255
272
|
const d = this.dockEl;
|
|
256
273
|
d.style.fontSize = `${this.font - 2}px`;
|
|
@@ -265,6 +282,7 @@ export class ChatUi {
|
|
|
265
282
|
const at = fixed ? { left: fixed.left, top: fixed.top } : placeDock(view, w, d.offsetHeight || 260, this.h.blocked());
|
|
266
283
|
d.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
267
284
|
log.scrollTop = log.scrollHeight;
|
|
285
|
+
return d;
|
|
268
286
|
}
|
|
269
287
|
|
|
270
288
|
private buildDock(): HTMLDivElement {
|
|
@@ -565,6 +583,10 @@ export class ChatUi {
|
|
|
565
583
|
/** 找空轨道飞一条;没有空的返回 false */
|
|
566
584
|
private fly(line: ChatLine): boolean {
|
|
567
585
|
const area = this.area();
|
|
586
|
+
// 弹幕层裁在弹幕区里(0.19.1):从区的右沿出现、左沿消失,不会飘出去擦过两边的座位
|
|
587
|
+
const vw = window.innerWidth;
|
|
588
|
+
const vh = window.innerHeight;
|
|
589
|
+
this.dmLayer().style.clipPath = `inset(${Math.max(0, area.top)}px ${Math.max(0, vw - area.left - area.width)}px ${Math.max(0, vh - area.top - area.height)}px ${Math.max(0, area.left)}px)`;
|
|
568
590
|
const lineH = Math.round(this.font * 1.5) + 10;
|
|
569
591
|
const lanes = danmakuLanes(area, lineH, [...this.h.blocked(), ...this.h.bubbles(), ...this.rects()], this.mode === 'desk' ? DANMAKU.lanesDesk : DANMAKU.lanesPhone);
|
|
570
592
|
if (lanes.length === 0) return true;
|
package/src/table-fun.ts
CHANGED
|
@@ -894,14 +894,15 @@ export class TableFun {
|
|
|
894
894
|
const w = Math.min(maxW, b.offsetWidth || text.length * fs + 24);
|
|
895
895
|
const h = b.offsetHeight || fs * 1.6;
|
|
896
896
|
// 避开"互动"按钮、各家的名牌筹码和别人的头像(0.18.2):原来总往朝中心那侧放,竖屏自己的气泡被按钮压住半截
|
|
897
|
-
|
|
898
|
-
|
|
897
|
+
// 按钮、聊天框叠在气泡上面(DOM 层在效果层之上),压到了字就看不见:单独当"硬"禁区,代价 ×3(0.19.1)
|
|
898
|
+
const avoid: RectLike[] = [...(this.o.avoid?.() ?? []), ...(this.o.center?.() ?? []), ...[...this.live].map((e) => e.getBoundingClientRect())];
|
|
899
|
+
const hard: RectLike[] = [...this.cui.rects(), ...(this.btn ? [this.btn.getBoundingClientRect()] : [])];
|
|
899
900
|
this.o.seats().forEach((o, i) => {
|
|
900
901
|
if (!o) return;
|
|
901
902
|
if (o.plate) avoid.push(o.plate);
|
|
902
903
|
if (i !== seat) avoid.push(o.rect);
|
|
903
904
|
});
|
|
904
|
-
const at = placeBubble(s.rect, w, h, { width: window.innerWidth, height: window.innerHeight }, avoid);
|
|
905
|
+
const at = placeBubble(s.rect, w, h, { width: window.innerWidth, height: window.innerHeight }, avoid, hard);
|
|
905
906
|
b.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
906
907
|
this.live.add(b);
|
|
907
908
|
void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160)
|
package/src/worker.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { identify, sameOrigin } from './auth.js';
|
|
14
14
|
import { type GuestOptions, isStandalone, type ModeEnv } from './guest.js';
|
|
15
|
-
import { CLOSE, type CreateError, ROOM_CODE_RE } from './protocol.js';
|
|
16
|
-
import { identityHeader, type Identity, KIT_HOST, LIMITED_HEADER, meterStub, MISS_HEADER, missStub, newRoomCode, roomStub } from './internal.js';
|
|
15
|
+
import { CLOSE, type CreateError, type MatchDownMsg, ROOM_CODE_RE } from './protocol.js';
|
|
16
|
+
import { identityHeader, type Identity, KIT_HOST, LIMITED_HEADER, matchStub, meterStub, MISS_HEADER, missStub, newRoomCode, roomStub, TIER_RE } from './internal.js';
|
|
17
17
|
// 只引类型:meter.ts 依赖 cloudflare:workers,游戏会在普通 Node 的测试里加载本文件
|
|
18
18
|
import type { MeterEnv, RoomMeter } from './meter.js';
|
|
19
19
|
import type { CreateOutcome } from './room.js';
|
|
@@ -144,6 +144,48 @@ export async function acceptJoin(request: Request, env: KitEnv, code: string, op
|
|
|
144
144
|
return new Response(null, { status: 101, webSocket: res.webSocket });
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
export interface MatchOptions extends GateOptions {
|
|
148
|
+
/** 开放快速匹配的档位(只开有单机能接住的档位);不在里面的回 4004 */
|
|
149
|
+
tiers: readonly string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 快速匹配(0.20.0,docs/match.md):WebSocket 升级,连上就是在 tier 这个档位排队(游戏 Worker 绑 MATCH,类继承 match.ts 的 MatchQueue)。
|
|
154
|
+
* 认人、查 Origin 和 acceptJoin 一样;入队前先问计量一次(只读):已经有开着的房间回 { t: 'has_room', code },
|
|
155
|
+
* 今天的开房次数用完了、全站忙、计量连不上回 { t: 'solo', why },都不进队。没绑计量(单独部署)不问
|
|
156
|
+
*/
|
|
157
|
+
export async function acceptMatch(request: Request, env: KitEnv & { MATCH: DurableObjectNamespace }, tier: string, opts: MatchOptions): Promise<Response> {
|
|
158
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
159
|
+
if (!TIER_RE.test(tier) || !opts.tiers.includes(tier)) return refuse(CLOSE.noRoom, 'no such tier');
|
|
160
|
+
if (!sameOrigin(request, opts.siteOrigin)) return refuse(CLOSE.forbidden, 'bad origin');
|
|
161
|
+
const w = await who(request, env, opts, false);
|
|
162
|
+
if ('error' in w) return w.error === 'anonymous' ? refuse(CLOSE.unauthorized, 'login first') : refuse(CLOSE.tryLater, 'try later');
|
|
163
|
+
const me = w.user;
|
|
164
|
+
if (env.METER) {
|
|
165
|
+
try {
|
|
166
|
+
const p = await (meterStub(env.METER) as DurableObjectStub<RoomMeter>).kitPeek(me.userId);
|
|
167
|
+
if (p.room !== null) return answer({ t: 'has_room', code: p.room });
|
|
168
|
+
if (p.daily) return answer({ t: 'solo', why: 'daily_limit' });
|
|
169
|
+
if (p.level !== 'ok') return answer({ t: 'solo', why: 'busy' });
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.error('meter unreachable, match goes solo', e instanceof Error ? e.message : e);
|
|
172
|
+
return answer({ t: 'solo', why: 'unavailable' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return matchStub(env.MATCH, tier).fetch(`https://${KIT_HOST}/match?tier=${tier}`, {
|
|
176
|
+
headers: { upgrade: 'websocket', 'x-kit-user': identityHeader(me) },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 接受升级、发一条排队的结果、正常关掉(页面按消息走,不看关闭码) */
|
|
181
|
+
function answer(msg: MatchDownMsg): Response {
|
|
182
|
+
const pair = new WebSocketPair();
|
|
183
|
+
pair[1].accept();
|
|
184
|
+
pair[1].send(JSON.stringify(msg));
|
|
185
|
+
pair[1].close(1000, msg.t);
|
|
186
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
187
|
+
}
|
|
188
|
+
|
|
147
189
|
/**
|
|
148
190
|
* 按人、按 IP 数"码不存在"(0.14.0,第三轮审计:6 位房间码能一个个试)。没绑 METER(单独部署、测试里没配)就不管,和开房额度一样。
|
|
149
191
|
* 没有 IP 头(本地开发)只按人算
|