@yyyu-star/arcade-kit 0.13.1 → 0.15.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 +25 -0
- package/README.md +16 -5
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +8 -0
- package/dist/internal.js.map +1 -1
- package/dist/meter.d.ts +15 -0
- package/dist/meter.d.ts.map +1 -1
- package/dist/meter.js +27 -0
- package/dist/meter.js.map +1 -1
- package/dist/qr.d.ts +15 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +264 -0
- package/dist/qr.js.map +1 -0
- package/dist/room.d.ts +12 -2
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +31 -12
- package/dist/room.js.map +1 -1
- package/dist/share.d.ts +33 -14
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +99 -22
- package/dist/share.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +44 -3
- package/dist/worker.js.map +1 -1
- package/package.json +2 -1
- package/src/internal.ts +11 -0
- package/src/meter.ts +37 -0
- package/src/qr.ts +259 -0
- package/src/room.ts +35 -13
- package/src/share.ts +104 -24
- package/src/worker.ts +42 -4
package/src/qr.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最小的二维码编码器(0.15.0,分享卡右下角的码):只做字节模式、纠错等级 M、版本 1–10(最多 213 字节,链接够用)。
|
|
3
|
+
* 自己写而不引依赖:kit 没有运行时依赖,引一个包就进了每个游戏的 lockfile 和审阅范围;要的子集只有这么点,规范见 ISO/IEC 18004。
|
|
4
|
+
* 纯函数,不碰 DOM;画在哪、画多大由 share.ts 决定。测试用解码库把码解回原文(test/qr.test.ts)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** size × size 的格子,true 是深色。外面的留白(4 格)不在里面,画的时候自己留 */
|
|
8
|
+
export interface QrMatrix {
|
|
9
|
+
size: number;
|
|
10
|
+
version: number;
|
|
11
|
+
dark: boolean[][];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 纠错 M:每个版本的 [每块纠错码字数, 第一组块数, 第一组每块数据码字数, 第二组块数](第二组每块多 1 个数据码字) */
|
|
15
|
+
const BLOCKS_M: readonly (readonly [number, number, number, number])[] = [
|
|
16
|
+
[10, 1, 16, 0],
|
|
17
|
+
[16, 1, 28, 0],
|
|
18
|
+
[26, 1, 44, 0],
|
|
19
|
+
[18, 2, 32, 0],
|
|
20
|
+
[24, 2, 43, 0],
|
|
21
|
+
[16, 4, 27, 0],
|
|
22
|
+
[18, 4, 31, 0],
|
|
23
|
+
[22, 2, 38, 2],
|
|
24
|
+
[22, 3, 36, 2],
|
|
25
|
+
[26, 4, 43, 1],
|
|
26
|
+
];
|
|
27
|
+
export const QR_MAX_VERSION = BLOCKS_M.length;
|
|
28
|
+
|
|
29
|
+
/** 校正图形的中心坐标(版本 2 起) */
|
|
30
|
+
const ALIGN: readonly (readonly number[])[] = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
|
|
31
|
+
|
|
32
|
+
function dataCodewords(v: number): number {
|
|
33
|
+
const [, b1, d1, b2] = BLOCKS_M[v - 1]!;
|
|
34
|
+
return b1 * d1 + b2 * (d1 + 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 编码一段文字(UTF-8)。放不下(超过版本 10)返回 null,调用方就不画码 */
|
|
38
|
+
export function qrEncode(text: string): QrMatrix | null {
|
|
39
|
+
const bytes = new TextEncoder().encode(text);
|
|
40
|
+
let version = 0;
|
|
41
|
+
for (let v = 1; v <= QR_MAX_VERSION; v++) {
|
|
42
|
+
const countBits = v < 10 ? 8 : 16;
|
|
43
|
+
if (4 + countBits + bytes.length * 8 <= dataCodewords(v) * 8) {
|
|
44
|
+
version = v;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!version) return null;
|
|
49
|
+
const data = interleave(version, pack(bytes, version));
|
|
50
|
+
return place(version, data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 模式指示 0100(字节)+ 长度 + 数据 + 结束符 + 补齐 */
|
|
54
|
+
function pack(bytes: Uint8Array, v: number): number[] {
|
|
55
|
+
const bits: number[] = [];
|
|
56
|
+
const put = (val: number, n: number): void => {
|
|
57
|
+
for (let i = n - 1; i >= 0; i--) bits.push((val >>> i) & 1);
|
|
58
|
+
};
|
|
59
|
+
put(4, 4);
|
|
60
|
+
put(bytes.length, v < 10 ? 8 : 16);
|
|
61
|
+
for (const b of bytes) put(b, 8);
|
|
62
|
+
const cap = dataCodewords(v) * 8;
|
|
63
|
+
put(0, Math.min(4, cap - bits.length));
|
|
64
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
65
|
+
const out: number[] = [];
|
|
66
|
+
for (let i = 0; i < bits.length; i += 8) out.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
|
67
|
+
for (let pad = 0xec; out.length < cap / 8; pad ^= 0xec ^ 0x11) out.push(pad);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// GF(256),本原多项式 0x11d
|
|
72
|
+
function gfMul(a: number, b: number): number {
|
|
73
|
+
let r = 0;
|
|
74
|
+
for (let i = 7; i >= 0; i--) {
|
|
75
|
+
r = (r << 1) ^ ((r >>> 7) * 0x11d);
|
|
76
|
+
r ^= ((b >>> i) & 1) * a;
|
|
77
|
+
}
|
|
78
|
+
return r & 0xff;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** RS 生成多项式(最高次项系数 1 省略) */
|
|
82
|
+
function rsGenerator(degree: number): number[] {
|
|
83
|
+
const g = new Array<number>(degree).fill(0);
|
|
84
|
+
g[degree - 1] = 1;
|
|
85
|
+
let root = 1;
|
|
86
|
+
for (let i = 0; i < degree; i++) {
|
|
87
|
+
for (let j = 0; j < degree; j++) {
|
|
88
|
+
g[j] = gfMul(g[j]!, root) ^ (j + 1 < degree ? g[j + 1]! : 0);
|
|
89
|
+
}
|
|
90
|
+
root = gfMul(root, 2);
|
|
91
|
+
}
|
|
92
|
+
return g;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rsRemainder(data: readonly number[], gen: readonly number[]): number[] {
|
|
96
|
+
const r = new Array<number>(gen.length).fill(0);
|
|
97
|
+
for (const b of data) {
|
|
98
|
+
const f = b ^ r.shift()!;
|
|
99
|
+
r.push(0);
|
|
100
|
+
gen.forEach((c, i) => (r[i] = r[i]! ^ gfMul(c, f)));
|
|
101
|
+
}
|
|
102
|
+
return r;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 分块、算纠错码、交错排列 */
|
|
106
|
+
function interleave(v: number, data: readonly number[]): number[] {
|
|
107
|
+
const [ec, b1, d1, b2] = BLOCKS_M[v - 1]!;
|
|
108
|
+
const gen = rsGenerator(ec);
|
|
109
|
+
const blocks: number[][] = [];
|
|
110
|
+
let k = 0;
|
|
111
|
+
for (let i = 0; i < b1 + b2; i++) {
|
|
112
|
+
const len = d1 + (i < b1 ? 0 : 1);
|
|
113
|
+
blocks.push(data.slice(k, k + len));
|
|
114
|
+
k += len;
|
|
115
|
+
}
|
|
116
|
+
const ecs = blocks.map((b) => rsRemainder(b, gen));
|
|
117
|
+
const out: number[] = [];
|
|
118
|
+
for (let i = 0; i <= d1; i++) for (const b of blocks) if (i < b.length) out.push(b[i]!);
|
|
119
|
+
for (let i = 0; i < ec; i++) for (const e of ecs) out.push(e[i]!);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const MASKS: readonly ((x: number, y: number) => boolean)[] = [
|
|
124
|
+
(x, y) => (x + y) % 2 === 0,
|
|
125
|
+
(_x, y) => y % 2 === 0,
|
|
126
|
+
(x) => x % 3 === 0,
|
|
127
|
+
(x, y) => (x + y) % 3 === 0,
|
|
128
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
129
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
130
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
131
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** 画功能图形、填数据、挑罚分最低的掩码 */
|
|
135
|
+
function place(version: number, data: readonly number[]): QrMatrix {
|
|
136
|
+
const size = version * 4 + 17;
|
|
137
|
+
const dark = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
138
|
+
const fixed = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
139
|
+
const set = (x: number, y: number, on: boolean): void => {
|
|
140
|
+
dark[y]![x] = on;
|
|
141
|
+
fixed[y]![x] = true;
|
|
142
|
+
};
|
|
143
|
+
for (let i = 0; i < size; i++) {
|
|
144
|
+
set(6, i, i % 2 === 0);
|
|
145
|
+
set(i, 6, i % 2 === 0);
|
|
146
|
+
}
|
|
147
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]] as const) {
|
|
148
|
+
for (let dy = -4; dy <= 4; dy++) {
|
|
149
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
150
|
+
const x = cx + dx;
|
|
151
|
+
const y = cy + dy;
|
|
152
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
153
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const al = ALIGN[version - 1]!;
|
|
158
|
+
al.forEach((ay, i) =>
|
|
159
|
+
al.forEach((ax, j) => {
|
|
160
|
+
const last = al.length - 1;
|
|
161
|
+
if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) return;
|
|
162
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
formatBits(set, size, 0); // 先占位,挑好掩码再写真的
|
|
166
|
+
if (version >= 7) {
|
|
167
|
+
let rem = version;
|
|
168
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
169
|
+
const bits = (version << 12) | rem;
|
|
170
|
+
for (let i = 0; i < 18; i++) {
|
|
171
|
+
const on = ((bits >>> i) & 1) === 1;
|
|
172
|
+
const a = size - 11 + (i % 3);
|
|
173
|
+
const b = Math.floor(i / 3);
|
|
174
|
+
set(a, b, on);
|
|
175
|
+
set(b, a, on);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 数据:从右下角起两列一组,蛇形上下走,跳过第 6 列(竖的定时线)
|
|
179
|
+
let i = 0;
|
|
180
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
181
|
+
if (right === 6) right = 5;
|
|
182
|
+
for (let vert = 0; vert < size; vert++) {
|
|
183
|
+
for (let j = 0; j < 2; j++) {
|
|
184
|
+
const x = right - j;
|
|
185
|
+
const y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
|
|
186
|
+
if (!fixed[y]![x] && i < data.length * 8) {
|
|
187
|
+
dark[y]![x] = ((data[i >>> 3]! >>> (7 - (i & 7))) & 1) === 1;
|
|
188
|
+
i++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
let best = 0;
|
|
194
|
+
let bestScore = Infinity;
|
|
195
|
+
for (let m = 0; m < MASKS.length; m++) {
|
|
196
|
+
const trial = dark.map((row, y) => row.map((on, x) => (fixed[y]![x] ? on : on !== MASKS[m]!(x, y))));
|
|
197
|
+
formatBits((x, y, on) => (trial[y]![x] = on), size, m);
|
|
198
|
+
const s = penalty(trial);
|
|
199
|
+
if (s < bestScore) {
|
|
200
|
+
bestScore = s;
|
|
201
|
+
best = m;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const out = dark.map((row, y) => row.map((on, x) => (fixed[y]![x] ? on : on !== MASKS[best]!(x, y))));
|
|
205
|
+
formatBits((x, y, on) => (out[y]![x] = on), size, best);
|
|
206
|
+
return { size, version, dark: out };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 格式信息(纠错 M = 00 + 掩码号,BCH 后异或 0x5412),两份;外加那个固定的深色格 */
|
|
210
|
+
function formatBits(set: (x: number, y: number, on: boolean) => void, size: number, mask: number): void {
|
|
211
|
+
const data = mask; // M 的两位是 00
|
|
212
|
+
let rem = data;
|
|
213
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
214
|
+
const bits = ((data << 10) | rem) ^ 0x5412;
|
|
215
|
+
const bit = (i: number): boolean => ((bits >>> i) & 1) === 1;
|
|
216
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
|
217
|
+
set(8, 7, bit(6));
|
|
218
|
+
set(8, 8, bit(7));
|
|
219
|
+
set(7, 8, bit(8));
|
|
220
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
|
221
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
|
222
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
|
223
|
+
set(8, size - 8, true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 掩码罚分(规范的四条),越低越好扫 */
|
|
227
|
+
function penalty(m: boolean[][]): number {
|
|
228
|
+
const size = m.length;
|
|
229
|
+
let score = 0;
|
|
230
|
+
const lines: boolean[][] = [];
|
|
231
|
+
for (let y = 0; y < size; y++) lines.push(m[y]!, m.map((row) => row[y]!));
|
|
232
|
+
const finderLike = /10111010000|00001011101/g;
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
let run = 1;
|
|
235
|
+
for (let i = 1; i <= size; i++) {
|
|
236
|
+
if (i < size && line[i] === line[i - 1]) run++;
|
|
237
|
+
else {
|
|
238
|
+
if (run >= 5) score += run - 2;
|
|
239
|
+
run = 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// 外面算浅色:两头各补 4 个浅格再找像定位图形的那一串
|
|
243
|
+
const s = `0000${line.map((on) => (on ? 1 : 0)).join('')}0000`;
|
|
244
|
+
score += (s.match(finderLike)?.length ?? 0) * 40;
|
|
245
|
+
}
|
|
246
|
+
let darkCount = 0;
|
|
247
|
+
for (let y = 0; y < size; y++) {
|
|
248
|
+
for (let x = 0; x < size; x++) {
|
|
249
|
+
if (m[y]![x]) darkCount++;
|
|
250
|
+
if (x + 1 < size && y + 1 < size) {
|
|
251
|
+
const c = m[y]![x];
|
|
252
|
+
if (m[y]![x + 1] === c && m[y + 1]![x] === c && m[y + 1]![x + 1] === c) score += 3;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const total = size * size;
|
|
257
|
+
score += Math.max(0, Math.ceil(Math.abs(darkCount * 20 - total * 10) / total) - 1) * 10;
|
|
258
|
+
return score;
|
|
259
|
+
}
|
package/src/room.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { DurableObject } from 'cloudflare:workers';
|
|
20
20
|
import type { RoomEffect, RoomGame, RoomSetup } from './game.js';
|
|
21
|
-
import { type Identity, KIT_HOST, meterStub, parseIdentity } from './internal.js';
|
|
21
|
+
import { type Identity, KIT_HOST, LIMITED_HEADER, MISS_HEADER, meterStub, parseIdentity } from './internal.js';
|
|
22
22
|
import type { MeterEnv, MeterLevel, RoomMeter } from './meter.js';
|
|
23
23
|
import { ENTROPY_WORDS } from './rng.js';
|
|
24
24
|
import {
|
|
@@ -60,7 +60,9 @@ export interface RoomLimits {
|
|
|
60
60
|
* 这些重连同时算进 maxInbound,房间一生的写入总数有上限
|
|
61
61
|
*/
|
|
62
62
|
maxJoins: number;
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* 没入座的人最多几个(来看的、还没选座位的),超了 4003。按账号算(0.14.0,以前按连接);入座的人、房主、开打后绑过座位的人不占这个数,掉线回来总能进
|
|
65
|
+
*/
|
|
64
66
|
extraConnections: number;
|
|
65
67
|
/** 房间最长寿命 */
|
|
66
68
|
maxLifeMs: number;
|
|
@@ -311,7 +313,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
311
313
|
private alarmToSet: number | null = null;
|
|
312
314
|
private pendingInbound = 0;
|
|
313
315
|
private readonly pendingBy = new Map<string, number>();
|
|
314
|
-
|
|
316
|
+
/** 入站消息的令牌桶,按 userId 记(0.14.0;以前按连接,断开重连就换一个满的桶,第三轮审计) */
|
|
317
|
+
private readonly buckets = new Map<string, { tokens: number; at: number }>();
|
|
315
318
|
/** 每个座位快捷短语的令牌桶(内存里;被驱逐就重置成满的,驱逐前至少空闲了一阵,本来也该满了) */
|
|
316
319
|
private readonly emoteBuckets = new Map<number, { tokens: number; at: number }>();
|
|
317
320
|
private emoteRateCache: EmoteRate | null = null;
|
|
@@ -389,6 +392,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
389
392
|
}
|
|
390
393
|
const me = parseIdentity(request.headers.get('x-kit-user'));
|
|
391
394
|
if (!me) return new Response('no identity', { status: 401 });
|
|
395
|
+
// 猜码超了门槛(0.14.0):自己人照常进(断线重连不受牵连),别人不管房间在不在都回 1013,说不出房间在不在
|
|
396
|
+
if (request.headers.has(LIMITED_HEADER) && !this.insider(me.userId)) return this.refuse(CLOSE.tryLater);
|
|
392
397
|
const gate = await this.newcomerGate(me.userId);
|
|
393
398
|
if (gate !== null) return this.refuse(gate);
|
|
394
399
|
return this.entry(() => this.join(me, url.searchParams.get('cv')));
|
|
@@ -639,7 +644,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
639
644
|
const st = this.load();
|
|
640
645
|
if (!st) {
|
|
641
646
|
server.close(CLOSE.noRoom, 'no room');
|
|
642
|
-
|
|
647
|
+
// 告诉 acceptJoin 这是一次"码不存在"(0.14.0,进房猜码按人、按 IP 计数);它转给浏览器前去掉这个头
|
|
648
|
+
return new Response(null, { status: 101, webSocket: client, headers: { [MISS_HEADER]: '1' } });
|
|
643
649
|
}
|
|
644
650
|
if (st.ending) {
|
|
645
651
|
server.close(CLOSE.roomClosed, st.ending.reason);
|
|
@@ -653,10 +659,12 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
653
659
|
const seat = this.seatOf(me.userId);
|
|
654
660
|
const others = this.sockets().filter((o) => o !== server && attachmentOf(o));
|
|
655
661
|
const mine = others.filter((o) => attachmentOf(o)!.userId === me.userId);
|
|
656
|
-
if (seat === null) {
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
662
|
+
if (seat === null && !this.ownsRoom(st, me.userId)) {
|
|
663
|
+
// 没入座的单独封顶;入座的人不占这个数,旁观的再多也挡不住座位的主人回来。
|
|
664
|
+
// 0.14.0(第三轮审计):按账号数不按连接数(一个人开几个标签页只占一个),房主和开打后绑过座位的人(离座去看的)不占也不受这个限,
|
|
665
|
+
// 不然几个陌生账号就能占满名额,让房主的朋友连进来选座都进不来
|
|
666
|
+
const watchers = new Set(others.map((o) => attachmentOf(o)!.userId).filter((u) => u !== me.userId && this.seatOf(u) === null && !this.ownsRoom(st, u)));
|
|
667
|
+
if (watchers.size >= this.limits.extraConnections) {
|
|
660
668
|
server.close(CLOSE.full, 'room full');
|
|
661
669
|
return done();
|
|
662
670
|
}
|
|
@@ -718,7 +726,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
718
726
|
if (!st || !me || st.ending) return void ws.close(CLOSE.roomClosed, 'room closed');
|
|
719
727
|
const bytes = typeof message === 'string' ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
720
728
|
if (bytes > this.limits.maxMessageBytes) return void ws.close(CLOSE.tooBig, 'message too big');
|
|
721
|
-
if (!this.takeToken(
|
|
729
|
+
if (!this.takeToken(me.userId)) return void ws.close(CLOSE.policy, 'too fast');
|
|
722
730
|
const counted = this.countInbound(st, me.userId);
|
|
723
731
|
if (counted === 'user') return void ws.close(CLOSE.userQuota, 'too many messages');
|
|
724
732
|
if (counted === 'room') return this.closeRoom();
|
|
@@ -1600,6 +1608,17 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1600
1608
|
};
|
|
1601
1609
|
}
|
|
1602
1610
|
|
|
1611
|
+
/** 这个房间的自己人:房主、坐着的、开打后绑过座位的。猜码超限时只放他们进(0.14.0) */
|
|
1612
|
+
private insider(userId: string): boolean {
|
|
1613
|
+
const st = this.load();
|
|
1614
|
+
return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** 房主,或者开打后绑过座位的人:旁观名额不算他们(0.14.0) */
|
|
1618
|
+
private ownsRoom(st: Persist<S>, userId: string): boolean {
|
|
1619
|
+
return st.host === userId || st.owners.includes(userId);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1603
1622
|
/** 这个人开打后绑定的座位(离座了也还是它);还没开打、没坐过为 null。 */
|
|
1604
1623
|
private boundOf(userId: string): number | null {
|
|
1605
1624
|
const i = this.st!.owners.indexOf(userId);
|
|
@@ -1660,15 +1679,18 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
1660
1679
|
this.send(ws, { t: 'error', code, ...(moveId ? { moveId } : {}) });
|
|
1661
1680
|
}
|
|
1662
1681
|
|
|
1663
|
-
/**
|
|
1664
|
-
|
|
1682
|
+
/**
|
|
1683
|
+
* 每个人一个令牌桶(内存里;被驱逐后重置成满的,驱逐前至少空闲了一阵,本来也该满了)。
|
|
1684
|
+
* 按人不按连接(0.14.0):断开重连、多开几个标签页都还是同一个桶,刷不出新的 burst
|
|
1685
|
+
*/
|
|
1686
|
+
private takeToken(userId: string): boolean {
|
|
1665
1687
|
const now = Date.now();
|
|
1666
|
-
const b = this.buckets.get(
|
|
1688
|
+
const b = this.buckets.get(userId) ?? { tokens: this.limits.rateBurst, at: now };
|
|
1667
1689
|
b.tokens = Math.min(this.limits.rateBurst, b.tokens + ((now - b.at) / 1000) * this.limits.ratePerSec);
|
|
1668
1690
|
b.at = now;
|
|
1669
1691
|
if (b.tokens < 1) return false;
|
|
1670
1692
|
b.tokens -= 1;
|
|
1671
|
-
this.buckets.set(
|
|
1693
|
+
this.buckets.set(userId, b);
|
|
1672
1694
|
return true;
|
|
1673
1695
|
}
|
|
1674
1696
|
}
|
package/src/share.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* - 游戏只交数据(`ShareCard`),画法统一在这里,几个游戏风格一致;kit 不认识任何游戏,名字、图标、主题色都由游戏给。
|
|
6
6
|
* - 这个文件只在调用时碰 DOM,import 时不碰(房间、测试里 import 没事);排版的算法是纯函数,测试在 test/share.test.ts。
|
|
7
7
|
* - 图上只有游戏给的数据:别人的隐藏信息(没摊的底牌、手牌)游戏自己别放进来。
|
|
8
|
+
* - 右下角的二维码(0.15.0):朋友圈里的图不能点,扫码是唯一入口。内容是运行时的 location.origin 加上 opts.url 的路径,
|
|
9
|
+
* kit 和游戏都不写域名;编码器是 qr.ts(自己写的最小子集,不引依赖)。
|
|
10
|
+
* - 微信内置浏览器(0.15.0):没有 navigator.share,下载链接也常常不生效,预览图改用 data: URL(blob: 的图长按经常存不下来),提示改成长按。
|
|
8
11
|
*/
|
|
12
|
+
import { qrEncode } from './qr.js';
|
|
9
13
|
|
|
10
14
|
/** 卡的尺寸:竖版 4:5,社交软件里不被裁 */
|
|
11
15
|
export const CARD_W = 1080;
|
|
@@ -161,6 +165,39 @@ export function artRect(
|
|
|
161
165
|
return [sx, sy, sw, sh, box.x + (box.w - dw) / 2, box.y + (box.h - dh) / 2, dw, dh];
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
/**
|
|
169
|
+
* 码里和分享出去的链接(0.15.0):只认运行时的 origin,游戏给的 url 只取路径、查询和 # 后面的部分。
|
|
170
|
+
* 这样 kit 和游戏里都不写域名,单独部署的游戏(workers.dev)扫出来也是它自己
|
|
171
|
+
*/
|
|
172
|
+
export function shareLink(url: string, origin: string): string {
|
|
173
|
+
const u = new URL(url, `${origin}/`);
|
|
174
|
+
return `${origin}${u.pathname}${u.search}${u.hash}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 系统分享的文字末尾带上链接(0.15.0):有的 App 只取 text、不取 url */
|
|
178
|
+
export function shareText(text: string, link: string): string {
|
|
179
|
+
return text.includes(link) ? text : `${text} ${link}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 微信内置浏览器(UA 带 MicroMessenger,含企业微信) */
|
|
183
|
+
export function isWeChat(ua: string): boolean {
|
|
184
|
+
return /MicroMessenger/i.test(ua);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 预览层怎么出(0.15.0):微信里用 data: URL、提示长按、不给"保存图片";别的浏览器照旧 */
|
|
188
|
+
export function previewMode(ua: string): { dataUrl: boolean; tip: string; save: boolean } {
|
|
189
|
+
return isWeChat(ua)
|
|
190
|
+
? { dataUrl: true, tip: '长按图片 → 发送给朋友,或保存后发朋友圈', save: false }
|
|
191
|
+
: { dataUrl: false, tip: '手机上也可以长按图片保存', save: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 二维码在卡上的框:白底留白 ≥ 4 格(扫码的规范要求),每格整数像素、对齐像素,截图后也清楚 */
|
|
195
|
+
export const QR_BOX = 248;
|
|
196
|
+
export function qrLayout(size: number, box = QR_BOX): { cell: number; pad: number } {
|
|
197
|
+
const cell = Math.max(1, Math.floor(box / (size + 8)));
|
|
198
|
+
return { cell, pad: Math.floor((box - cell * size) / 2) };
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
// ───────────── 画(只在调用时碰 DOM) ─────────────
|
|
165
202
|
|
|
166
203
|
const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif';
|
|
@@ -211,9 +248,16 @@ function fitFont(ctx: CanvasRenderingContext2D, text: string, weight: string, si
|
|
|
211
248
|
return s;
|
|
212
249
|
}
|
|
213
250
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
251
|
+
/**
|
|
252
|
+
* 画一张卡。canvas 不给就新建一个 1080×1350 的。link 给了(0.15.0,prepareShare 用 shareLink 算好的)就在右下角画二维码,
|
|
253
|
+
* 太长编不了码就不画
|
|
254
|
+
*/
|
|
255
|
+
export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement, link?: string): Promise<HTMLCanvasElement> {
|
|
216
256
|
const c = normalizeCard(input);
|
|
257
|
+
const qr = link ? qrEncode(link) : null;
|
|
258
|
+
// 二维码的白框:右下角,压在底栏上;名场面和日期往左让
|
|
259
|
+
const qx = CARD_W - 72 - QR_BOX;
|
|
260
|
+
const qy = CARD_H - 40 - QR_BOX - 44;
|
|
217
261
|
const cv = canvas ?? document.createElement('canvas');
|
|
218
262
|
cv.width = CARD_W;
|
|
219
263
|
cv.height = CARD_H;
|
|
@@ -302,24 +346,28 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
|
|
|
302
346
|
if (c.line) {
|
|
303
347
|
ctx.fillStyle = '#ffffff';
|
|
304
348
|
ctx.font = `700 50px ${FONT}`;
|
|
305
|
-
const
|
|
306
|
-
lines.
|
|
349
|
+
const lw = qr ? qx - 32 - M : W - 2 * M - 40;
|
|
350
|
+
const lines = wrapLines(`“${c.line}”`, lw, (s) => ctx.measureText(s).width, 2);
|
|
351
|
+
lines.forEach((l, i) => ctx.fillText(l, qr ? M + lw / 2 : W / 2, 1065 + i * 66));
|
|
307
352
|
}
|
|
308
353
|
|
|
309
354
|
// 底:昵称、称号、日期
|
|
310
355
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
|
311
356
|
ctx.fillRect(0, CARD_H - 150, W, 150);
|
|
357
|
+
ctx.font = `600 38px ${FONT}`;
|
|
358
|
+
const dateRight = qr ? qx - 28 : W - M;
|
|
359
|
+
const dateLeft = dateRight - ctx.measureText(cardDate(c.date)).width;
|
|
312
360
|
ctx.textAlign = 'left';
|
|
313
361
|
ctx.fillStyle = '#ffffff';
|
|
314
362
|
if (c.player) {
|
|
315
|
-
fitFont(ctx, c.player.name, '800', 50, W * 0.5, 32);
|
|
363
|
+
fitFont(ctx, c.player.name, '800', 50, Math.min(W * 0.5, dateLeft - 24 - M), 32);
|
|
316
364
|
ctx.fillText(c.player.name, M, CARD_H - 62);
|
|
317
365
|
if (c.player.badge) {
|
|
318
366
|
const nw = ctx.measureText(c.player.name).width;
|
|
319
367
|
ctx.font = `700 34px ${FONT}`;
|
|
320
368
|
const bw = ctx.measureText(c.player.badge).width + 36;
|
|
321
369
|
const bx = M + nw + 24;
|
|
322
|
-
if (bx + bw < W * 0.72) {
|
|
370
|
+
if (bx + bw < Math.min(W * 0.72, dateLeft - 16)) {
|
|
323
371
|
round(ctx, bx, CARD_H - 104, bw, 54, 27);
|
|
324
372
|
ctx.fillStyle = shade(c.accent, 0.75);
|
|
325
373
|
ctx.fill();
|
|
@@ -331,20 +379,39 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
|
|
|
331
379
|
ctx.textAlign = 'right';
|
|
332
380
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
333
381
|
ctx.font = `600 38px ${FONT}`;
|
|
334
|
-
ctx.fillText(cardDate(c.date),
|
|
382
|
+
ctx.fillText(cardDate(c.date), dateRight, CARD_H - 62);
|
|
383
|
+
|
|
384
|
+
if (qr) {
|
|
385
|
+
// 纯白底、近黑的格,不叠阴影和渐变:对比度够,手机截图压缩后照样扫得出
|
|
386
|
+
ctx.fillStyle = '#ffffff';
|
|
387
|
+
round(ctx, qx, qy, QR_BOX, QR_BOX + 44, 24);
|
|
388
|
+
ctx.fill();
|
|
389
|
+
const { cell, pad } = qrLayout(qr.size);
|
|
390
|
+
ctx.fillStyle = '#111111';
|
|
391
|
+
for (let y = 0; y < qr.size; y++) {
|
|
392
|
+
for (let x = 0; x < qr.size; x++) if (qr.dark[y]![x]) ctx.fillRect(qx + pad + x * cell, qy + pad + y * cell, cell, cell);
|
|
393
|
+
}
|
|
394
|
+
ctx.fillStyle = '#444444';
|
|
395
|
+
ctx.textAlign = 'center';
|
|
396
|
+
ctx.font = `700 28px ${FONT}`;
|
|
397
|
+
ctx.fillText('扫码来玩', qx + QR_BOX / 2, qy + QR_BOX + 22);
|
|
398
|
+
}
|
|
335
399
|
return cv;
|
|
336
400
|
}
|
|
337
401
|
|
|
338
|
-
/** 画成 PNG */
|
|
339
|
-
export async function shareCardBlob(card: ShareCard): Promise<Blob> {
|
|
340
|
-
const cv = await drawShareCard(card);
|
|
402
|
+
/** 画成 PNG;link 见 drawShareCard */
|
|
403
|
+
export async function shareCardBlob(card: ShareCard, link?: string): Promise<Blob> {
|
|
404
|
+
const cv = await drawShareCard(card, undefined, link);
|
|
341
405
|
return new Promise((resolve, reject) => cv.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'));
|
|
342
406
|
}
|
|
343
407
|
|
|
344
408
|
export interface ShareOptions {
|
|
345
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* 分享出去的链接:游戏地址,好友房结算带房间码。只用它的路径、查询和 # 后面的部分,前面一律换成运行时的 location.origin(0.15.0),
|
|
411
|
+
* 写相对路径(`./?room=123456`)或绝对地址都行;卡上的二维码、系统分享、"复制链接"用的都是换过的这一个
|
|
412
|
+
*/
|
|
346
413
|
url: string;
|
|
347
|
-
/**
|
|
414
|
+
/** 系统分享里的一句话,默认用卡的游戏名和大数字拼;末尾自动带上链接(0.15.0) */
|
|
348
415
|
text?: string;
|
|
349
416
|
/** 存图的文件名,默认 share.png */
|
|
350
417
|
fileName?: string;
|
|
@@ -375,10 +442,13 @@ export interface PreparedShare {
|
|
|
375
442
|
*/
|
|
376
443
|
export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare {
|
|
377
444
|
const name = opts.fileName ?? 'share.png';
|
|
378
|
-
|
|
445
|
+
// 没有 DOM(房间、测试里)时 location 也没有:链接照原样,画卡那一步本来就会失败
|
|
446
|
+
const link = typeof location === 'undefined' ? opts.url : shareLink(opts.url, location.origin);
|
|
447
|
+
const text = shareText(opts.text ?? `${card.game} · ${card.big.label} ${card.big.value}`, link);
|
|
448
|
+
const o: ShareOptions = { ...opts, url: link };
|
|
379
449
|
let blob: Blob | null = null;
|
|
380
450
|
let failed: unknown = null;
|
|
381
|
-
const ready = shareCardBlob(card).then(
|
|
451
|
+
const ready = shareCardBlob(card, link).then(
|
|
382
452
|
(b) => {
|
|
383
453
|
blob = b;
|
|
384
454
|
},
|
|
@@ -388,8 +458,9 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
|
|
|
388
458
|
);
|
|
389
459
|
const attempt = (b: Blob): Promise<ShareResult> => {
|
|
390
460
|
const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
|
|
391
|
-
|
|
392
|
-
|
|
461
|
+
// 微信里不试系统分享(没有,或者有也发不出图),直接出长按的预览
|
|
462
|
+
if (!isWeChat(nav.userAgent) && typeof File === 'function' && typeof nav.share === 'function') {
|
|
463
|
+
const data: ShareData = { files: [new File([b], name, { type: 'image/png' })], title: card.game, text, url: link };
|
|
393
464
|
if (nav.canShare?.(data)) {
|
|
394
465
|
// 同步调(这一行之前没有 await):点击的激活还在
|
|
395
466
|
return nav.share(data).then(
|
|
@@ -400,13 +471,13 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
|
|
|
400
471
|
(e: { name?: string }) => {
|
|
401
472
|
if (e?.name === 'AbortError') return 'cancelled' as const;
|
|
402
473
|
// 别的错(激活过期、权限、不支持的类型):退回预览
|
|
403
|
-
showPreview(b, name,
|
|
474
|
+
showPreview(b, name, o);
|
|
404
475
|
return 'preview' as const;
|
|
405
476
|
},
|
|
406
477
|
);
|
|
407
478
|
}
|
|
408
479
|
}
|
|
409
|
-
showPreview(b, name,
|
|
480
|
+
showPreview(b, name, o);
|
|
410
481
|
return Promise.resolve('preview');
|
|
411
482
|
};
|
|
412
483
|
return {
|
|
@@ -431,27 +502,36 @@ export async function shareCard(card: ShareCard, opts: ShareOptions): Promise<Sh
|
|
|
431
502
|
return p.share();
|
|
432
503
|
}
|
|
433
504
|
|
|
434
|
-
/** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"
|
|
505
|
+
/** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"收起。微信里见 previewMode */
|
|
435
506
|
function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
436
507
|
document.getElementById(PREVIEW_ID)?.remove();
|
|
437
|
-
const
|
|
508
|
+
const mode = previewMode(navigator.userAgent);
|
|
509
|
+
// 微信里不用 blob:(长按存不下来),也就不建对象 URL
|
|
510
|
+
const url = mode.dataUrl ? '' : URL.createObjectURL(blob);
|
|
438
511
|
const root = document.createElement('div');
|
|
439
512
|
root.id = PREVIEW_ID;
|
|
440
513
|
root.setAttribute('role', 'dialog');
|
|
441
514
|
root.style.cssText = `position:fixed;inset:0;z-index:${opts.zIndex ?? 11000};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:16px;box-sizing:border-box;background:rgba(0,0,0,.72);font:600 16px ${FONT};color:#fff`;
|
|
442
515
|
const close = (): void => {
|
|
443
516
|
root.remove();
|
|
444
|
-
URL.revokeObjectURL(url);
|
|
517
|
+
if (url) URL.revokeObjectURL(url);
|
|
445
518
|
};
|
|
446
519
|
root.addEventListener('click', (e) => {
|
|
447
520
|
if (e.target === root) close();
|
|
448
521
|
});
|
|
449
522
|
const img = document.createElement('img');
|
|
450
|
-
img.src = url;
|
|
523
|
+
if (url) img.src = url;
|
|
524
|
+
else {
|
|
525
|
+
const reader = new FileReader();
|
|
526
|
+
reader.onload = () => {
|
|
527
|
+
if (typeof reader.result === 'string') img.src = reader.result;
|
|
528
|
+
};
|
|
529
|
+
reader.readAsDataURL(blob);
|
|
530
|
+
}
|
|
451
531
|
img.alt = '战绩图';
|
|
452
532
|
img.style.cssText = 'max-width:min(92vw,540px);max-height:calc(100% - 140px);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.5)';
|
|
453
533
|
const tip = document.createElement('div');
|
|
454
|
-
tip.textContent =
|
|
534
|
+
tip.textContent = mode.tip;
|
|
455
535
|
tip.style.cssText = 'font-size:13px;opacity:.75';
|
|
456
536
|
const row = document.createElement('div');
|
|
457
537
|
row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center';
|
|
@@ -486,7 +566,7 @@ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
|
486
566
|
else done(false);
|
|
487
567
|
});
|
|
488
568
|
const shut = btn('关闭', 'share:close', close);
|
|
489
|
-
row.appendChild(save);
|
|
569
|
+
if (mode.save) row.appendChild(save);
|
|
490
570
|
row.appendChild(copy);
|
|
491
571
|
row.appendChild(shut);
|
|
492
572
|
root.appendChild(img);
|