@yyyu-star/arcade-kit 0.0.0-stage → 0.10.2
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 +256 -0
- package/LICENSE +21 -0
- package/README.md +441 -2
- package/dist/auth.d.ts +66 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +86 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +194 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +379 -0
- package/dist/client.js.map +1 -0
- package/dist/fun-art.d.ts +41 -0
- package/dist/fun-art.d.ts.map +1 -0
- package/dist/fun-art.js +141 -0
- package/dist/fun-art.js.map +1 -0
- package/dist/fun.d.ts +87 -0
- package/dist/fun.d.ts.map +1 -0
- package/dist/fun.js +512 -0
- package/dist/fun.js.map +1 -0
- package/dist/game.d.ts +160 -0
- package/dist/game.d.ts.map +1 -0
- package/dist/game.js +41 -0
- package/dist/game.js.map +1 -0
- package/dist/guest.d.ts +60 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +87 -0
- package/dist/guest.js.map +1 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +53 -0
- package/dist/internal.js.map +1 -0
- package/dist/meter.d.ts +102 -0
- package/dist/meter.d.ts.map +1 -0
- package/dist/meter.js +207 -0
- package/dist/meter.js.map +1 -0
- package/dist/protocol.d.ts +227 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +65 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rng.d.ts +40 -0
- package/dist/rng.d.ts.map +1 -0
- package/dist/rng.js +119 -0
- package/dist/rng.js.map +1 -0
- package/dist/room.d.ts +352 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +1563 -0
- package/dist/room.js.map +1 -0
- package/dist/testing.d.ts +125 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +272 -0
- package/dist/testing.js.map +1 -0
- package/dist/worker.d.ts +92 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +184 -0
- package/dist/worker.js.map +1 -0
- package/package.json +107 -4
- package/src/auth.ts +100 -0
- package/src/client.ts +438 -0
- package/src/fun-art.ts +184 -0
- package/src/fun.ts +626 -0
- package/src/game.ts +200 -0
- package/src/guest.ts +122 -0
- package/src/internal.ts +64 -0
- package/src/meter.ts +255 -0
- package/src/protocol.ts +243 -0
- package/src/rng.ts +131 -0
- package/src/room.ts +1677 -0
- package/src/testing.ts +300 -0
- package/src/worker.ts +234 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 测试和本地试用的假平台钱包(0.8.0,不部署):游戏的 workerd 测试、冒烟、本地 `wrangler dev` 里代替平台的钱包,各游戏共用这一份。
|
|
3
|
+
*
|
|
4
|
+
* 只复刻游戏碰得到的接口形状和关键规则,不复刻平台的细枝末节(计量、熔断档、流水、战绩怎么存、管理员调规则):
|
|
5
|
+
* - `WalletService`:游戏经服务绑定调的四个方法(docs/platform-backend.md 5.9"给游戏的 RPC")。绑定的 `props.game` 必须写,
|
|
6
|
+
* 参数里的 `game` 要和它一致,不然 `bad_game`。这里不维护游戏名单:平台认不认这个游戏是平台的事,假钱包只核对两边一致;
|
|
7
|
+
* - `FakeBank`:一人一个 DO,存钱包(5.9"存储"的简化)。带入、结回、放弃按 `游戏:ref` 一份一份(5.9"带入""结回"):
|
|
8
|
+
* 只结一次(结完再结 `no_stake`)、余额够这次才能带(`no_funds`)、同一个 `ref` 补码要同档同 mode、操作 id 按游戏去重(`dup`)、
|
|
9
|
+
* 到期原样退回(`expired`)、client 模式每个游戏每天净赢最多 `WIN_UNITS` 份(`capped`);
|
|
10
|
+
* - 结回带的小结 `stats` 按 5.10 的上限检查(JSON ≤ 256 字节、必填字段、称号和自定义数的格式),不合格回 `statsIgnored`、钱照常结;
|
|
11
|
+
* 收下的记在 `FakeBank.stats` 里给测试核对;
|
|
12
|
+
* - `walletHttp`:浏览器的 `GET /api/wallet`、`POST /api/wallet/relief`(15.9),认平台的会话 cookie,cookie 的值就当 userId。
|
|
13
|
+
*
|
|
14
|
+
* 平台改了钱包接口(参数、错误码、规则),平台 PR 的描述里写"影响 kit/testing",由维护者安排 kit 跟上;一个 PR 只改一个模块。
|
|
15
|
+
*
|
|
16
|
+
* 游戏怎么接:测试 Worker 的主模块 `export { FakeBank, WalletService } from 'arcade-kit/testing'`,wrangler 配置里
|
|
17
|
+
* 加 DO 绑定 `FAKE_BANK`(class_name `FakeBank`),`WALLET` 服务绑定指回这个 Worker 自己:
|
|
18
|
+
* `{ "binding": "WALLET", "service": "<测试 Worker 名>", "entrypoint": "WalletService", "props": { "game": "<游戏 id>" } }`。
|
|
19
|
+
*/
|
|
20
|
+
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
|
|
21
|
+
import { SESSION_COOKIE } from './internal.js';
|
|
22
|
+
|
|
23
|
+
/** 新号送多少、补给补到多少、client 模式每天净赢最多几份:平台的默认值(5.9,管理员可调的那几个数按默认算) */
|
|
24
|
+
export const NEW_BALANCE = 10_000;
|
|
25
|
+
export const RELIEF_TO = 2_000;
|
|
26
|
+
export const WIN_UNITS = 10;
|
|
27
|
+
/** 各档一份多少(economy.md 第 2 节) */
|
|
28
|
+
export const TIERS = { small: 2_000, mid: 10_000, big: 50_000, lux: 200_000 } as const;
|
|
29
|
+
export type Tier = keyof typeof TIERS;
|
|
30
|
+
export type Mode = 'server' | 'client';
|
|
31
|
+
|
|
32
|
+
const DAY_MS = 86_400_000;
|
|
33
|
+
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
34
|
+
/** 结回最多是带上桌的加 20 份(平台只防写错) */
|
|
35
|
+
const SETTLE_EXTRA_UNITS = 20;
|
|
36
|
+
|
|
37
|
+
export interface Stake {
|
|
38
|
+
game: string;
|
|
39
|
+
ref: string;
|
|
40
|
+
tier: Tier;
|
|
41
|
+
amount: number;
|
|
42
|
+
mode: Mode;
|
|
43
|
+
at: number;
|
|
44
|
+
until: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WalletView {
|
|
48
|
+
balance: number;
|
|
49
|
+
stakes: Stake[];
|
|
50
|
+
canRelief: boolean;
|
|
51
|
+
writesLeft: number;
|
|
52
|
+
caps: Record<string, { net: number; cap: number }>;
|
|
53
|
+
fresh: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type WalletReply =
|
|
57
|
+
| { ok: true; wallet: WalletView; credited?: number; capped?: number; dup?: true; statsIgnored?: StatsProblem }
|
|
58
|
+
| { ok: false; error: string; wallet?: WalletView };
|
|
59
|
+
|
|
60
|
+
export type StatsProblem = 'too_big' | 'bad_stats';
|
|
61
|
+
|
|
62
|
+
export interface FakeEnv {
|
|
63
|
+
FAKE_BANK: DurableObjectNamespace<FakeBank>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface State {
|
|
67
|
+
balance: number;
|
|
68
|
+
/** 桌上的筹码,key 是 `游戏:ref` */
|
|
69
|
+
stakes: Record<string, Stake>;
|
|
70
|
+
reliefDay: string;
|
|
71
|
+
/** client 模式当天每个游戏的带入、结回(算净赢)和带入过的最高档 */
|
|
72
|
+
day: string;
|
|
73
|
+
cin: Record<string, number>;
|
|
74
|
+
cout: Record<string, number>;
|
|
75
|
+
top: Record<string, Tier>;
|
|
76
|
+
/** `游戏:op` → 第一次的结果 */
|
|
77
|
+
ops: Record<string, Done>;
|
|
78
|
+
/** 到期退回过的 `游戏:ref` */
|
|
79
|
+
expired: string[];
|
|
80
|
+
fresh: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Done = { ok: true; credited?: number; capped?: number; statsIgnored?: StatsProblem };
|
|
84
|
+
type Cmd = { t: 'buyin' | 'settle' | 'refund'; game: string; ref: string; tier?: Tier; amount?: number; until?: number; mode: Mode; op: string; stats?: unknown };
|
|
85
|
+
|
|
86
|
+
const beijingDay = (now: number): string => new Date(now + 8 * 3_600_000).toISOString().slice(0, 10);
|
|
87
|
+
const onTable = (s: State): number => Object.values(s.stakes).reduce((n, k) => n + k.amount, 0);
|
|
88
|
+
|
|
89
|
+
export class FakeBank extends DurableObject {
|
|
90
|
+
/** 被调了几次、写了几次:测试数游戏调平台几次、平台写几行 */
|
|
91
|
+
calls = 0;
|
|
92
|
+
writes = 0;
|
|
93
|
+
/** 结回带上来的小结,按先后记在内存里;不合格的记 ignored */
|
|
94
|
+
stats: { game: string; ref: string; stats: unknown; ignored?: StatsProblem }[] = [];
|
|
95
|
+
|
|
96
|
+
private async load(now: number): Promise<State> {
|
|
97
|
+
const s =
|
|
98
|
+
(await this.ctx.storage.get<State>('w')) ??
|
|
99
|
+
({ balance: NEW_BALANCE, stakes: {}, reliefDay: '', day: '', cin: {}, cout: {}, top: {}, ops: {}, expired: [], fresh: true } satisfies State);
|
|
100
|
+
const day = beijingDay(now);
|
|
101
|
+
if (s.day !== day) Object.assign(s, { day, cin: {}, cout: {}, top: {} });
|
|
102
|
+
// 到期的那一份原样退回,记下 `游戏:ref`(最近 16 个)
|
|
103
|
+
for (const [key, k] of Object.entries(s.stakes)) {
|
|
104
|
+
if (k.until > now) continue;
|
|
105
|
+
s.balance += k.amount;
|
|
106
|
+
if (k.mode === 'client') s.cin[k.game] = Math.max(0, (s.cin[k.game] ?? 0) - k.amount);
|
|
107
|
+
s.expired = [...s.expired, key].slice(-16);
|
|
108
|
+
delete s.stakes[key];
|
|
109
|
+
}
|
|
110
|
+
return s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async save(s: State): Promise<void> {
|
|
114
|
+
this.writes++;
|
|
115
|
+
// 操作 id 只留最近 32 个
|
|
116
|
+
const ops = Object.entries(s.ops);
|
|
117
|
+
if (ops.length > 32) s.ops = Object.fromEntries(ops.slice(-32));
|
|
118
|
+
await this.ctx.storage.put('w', { ...s, fresh: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private view(s: State): WalletView {
|
|
122
|
+
const caps: WalletView['caps'] = {};
|
|
123
|
+
for (const g of Object.keys(s.cin)) caps[g] = { net: (s.cout[g] ?? 0) - (s.cin[g] ?? 0), cap: WIN_UNITS * TIERS[s.top[g] ?? 'small'] };
|
|
124
|
+
return {
|
|
125
|
+
balance: s.balance,
|
|
126
|
+
stakes: Object.values(s.stakes),
|
|
127
|
+
canRelief: s.balance + onTable(s) < RELIEF_TO && s.reliefDay !== s.day,
|
|
128
|
+
writesLeft: 1_000,
|
|
129
|
+
caps,
|
|
130
|
+
fresh: s.fresh,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async look(now: number): Promise<WalletView> {
|
|
135
|
+
this.calls++;
|
|
136
|
+
return this.view(await this.load(now));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 只给测试用:直接把余额设成这个数(比如造一个只有 500 的人),不算调用和写入 */
|
|
140
|
+
async setBalance(balance: number, now: number): Promise<void> {
|
|
141
|
+
const s = await this.load(now);
|
|
142
|
+
s.balance = balance;
|
|
143
|
+
await this.ctx.storage.put('w', { ...s, fresh: false });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async run(c: Cmd, now: number): Promise<WalletReply> {
|
|
147
|
+
this.calls++;
|
|
148
|
+
const s = await this.load(now);
|
|
149
|
+
const key = `${c.game}:${c.op}`;
|
|
150
|
+
const before = s.ops[key];
|
|
151
|
+
if (before) return { ...before, wallet: this.view(s), dup: true };
|
|
152
|
+
const r = this.apply(s, c, now);
|
|
153
|
+
if (!r.ok) return { ...r, wallet: this.view(s) };
|
|
154
|
+
if (c.t === 'settle' && c.stats !== undefined) {
|
|
155
|
+
const ignored = statsProblem(c.stats);
|
|
156
|
+
this.stats.push({ game: c.game, ref: c.ref, stats: c.stats, ...(ignored ? { ignored } : {}) });
|
|
157
|
+
if (ignored) r.statsIgnored = ignored;
|
|
158
|
+
}
|
|
159
|
+
s.ops[key] = r;
|
|
160
|
+
await this.save(s);
|
|
161
|
+
return { ...r, wallet: this.view(s) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private apply(s: State, c: Cmd, now: number): Done | { ok: false; error: string } {
|
|
165
|
+
const sk = `${c.game}:${c.ref}`;
|
|
166
|
+
const k = s.stakes[sk];
|
|
167
|
+
if (c.t === 'buyin') {
|
|
168
|
+
const tier = c.tier!;
|
|
169
|
+
const amount = c.amount!;
|
|
170
|
+
if (amount < 1 || amount > TIERS[tier]) return { ok: false, error: 'bad_amount' };
|
|
171
|
+
if (c.until! <= now) return { ok: false, error: 'bad_until' };
|
|
172
|
+
if (s.expired.includes(sk)) return { ok: false, error: 'expired' };
|
|
173
|
+
if (k && k.tier !== tier) return { ok: false, error: 'at_table' };
|
|
174
|
+
if (k && k.mode !== c.mode) return { ok: false, error: 'bad_mode' };
|
|
175
|
+
// 新开一份、补码都只要余额够这次的数(平台 0.10.2)
|
|
176
|
+
if (s.balance < amount) return { ok: false, error: 'no_funds' };
|
|
177
|
+
const until = Math.min(c.until!, now + DAY_MS);
|
|
178
|
+
if (k) {
|
|
179
|
+
k.amount += amount;
|
|
180
|
+
k.until = Math.max(k.until, until);
|
|
181
|
+
} else {
|
|
182
|
+
s.stakes[sk] = { game: c.game, ref: c.ref, tier, amount, mode: c.mode, at: now, until };
|
|
183
|
+
}
|
|
184
|
+
s.balance -= amount;
|
|
185
|
+
if (!s.top[c.game] || TIERS[tier] > TIERS[s.top[c.game]!]) s.top[c.game] = tier;
|
|
186
|
+
if (c.mode === 'client') s.cin[c.game] = (s.cin[c.game] ?? 0) + amount;
|
|
187
|
+
return { ok: true };
|
|
188
|
+
}
|
|
189
|
+
if (!k) return { ok: false, error: s.expired.includes(sk) ? 'expired' : 'no_stake' };
|
|
190
|
+
if (k.mode !== c.mode) return { ok: false, error: 'bad_mode' };
|
|
191
|
+
if (c.t === 'refund') {
|
|
192
|
+
delete s.stakes[sk];
|
|
193
|
+
s.balance += k.amount;
|
|
194
|
+
if (k.mode === 'client') s.cin[c.game] = Math.max(0, (s.cin[c.game] ?? 0) - k.amount);
|
|
195
|
+
return { ok: true, credited: k.amount };
|
|
196
|
+
}
|
|
197
|
+
const amount = c.amount!;
|
|
198
|
+
if (amount > k.amount + SETTLE_EXTRA_UNITS * TIERS[k.tier]) return { ok: false, error: 'bad_amount' };
|
|
199
|
+
delete s.stakes[sk];
|
|
200
|
+
let credited = amount;
|
|
201
|
+
if (k.mode === 'client') {
|
|
202
|
+
// 当天净赢最多 WIN_UNITS 份;带上桌的那一份总能拿回来
|
|
203
|
+
const room = WIN_UNITS * TIERS[s.top[c.game] ?? k.tier] - ((s.cout[c.game] ?? 0) - (s.cin[c.game] ?? 0));
|
|
204
|
+
credited = Math.min(amount, Math.max(room, Math.min(amount, k.amount)));
|
|
205
|
+
s.cout[c.game] = (s.cout[c.game] ?? 0) + credited;
|
|
206
|
+
}
|
|
207
|
+
s.balance += credited;
|
|
208
|
+
return credited < amount ? { ok: true, credited, capped: amount - credited } : { ok: true, credited };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 领补给(15.9):余额加桌上的不到补给额、今天没领过,补到补给额(平台 0.10.2 起桌上有筹码也能领) */
|
|
212
|
+
async relief(now: number): Promise<{ status: number; body: { error?: string; wallet: WalletView } }> {
|
|
213
|
+
this.calls++;
|
|
214
|
+
const s = await this.load(now);
|
|
215
|
+
const total = s.balance + onTable(s);
|
|
216
|
+
if (total >= RELIEF_TO) return { status: 409, body: { error: 'relief_not_needed', wallet: this.view(s) } };
|
|
217
|
+
if (s.reliefDay === s.day) return { status: 409, body: { error: 'relief_used', wallet: this.view(s) } };
|
|
218
|
+
s.balance += RELIEF_TO - total;
|
|
219
|
+
s.reliefDay = s.day;
|
|
220
|
+
await this.save(s);
|
|
221
|
+
return { status: 200, body: { wallet: this.view({ ...s, fresh: false }) } };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const bank = (env: FakeEnv, userId: string) => env.FAKE_BANK.get(env.FAKE_BANK.idFromName(userId));
|
|
226
|
+
|
|
227
|
+
/** 平台给游戏的 RPC(5.9):绑定写 `entrypoint: "WalletService"`、`props: { game }` */
|
|
228
|
+
export class WalletService extends WorkerEntrypoint<FakeEnv, { game?: string }> {
|
|
229
|
+
async view(userId: string): Promise<WalletReply> {
|
|
230
|
+
if (!this.bound()) return { ok: false, error: 'bad_game' };
|
|
231
|
+
return { ok: true, wallet: await bank(this.env, userId).look(Date.now()) };
|
|
232
|
+
}
|
|
233
|
+
buyin(req: unknown): Promise<WalletReply> {
|
|
234
|
+
return this.op('buyin', req);
|
|
235
|
+
}
|
|
236
|
+
settle(req: unknown): Promise<WalletReply> {
|
|
237
|
+
return this.op('settle', req);
|
|
238
|
+
}
|
|
239
|
+
refund(req: unknown): Promise<WalletReply> {
|
|
240
|
+
return this.op('refund', req);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private bound(): string | null {
|
|
244
|
+
const g = this.ctx.props?.game;
|
|
245
|
+
return typeof g === 'string' && g !== '' ? g : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async op(t: Cmd['t'], req: unknown): Promise<WalletReply> {
|
|
249
|
+
const game = this.bound();
|
|
250
|
+
// 平台先查绑定和 game,参数不对也先回这个
|
|
251
|
+
if (!game || (req as { game?: unknown } | null)?.game !== game) return { ok: false, error: 'bad_game' };
|
|
252
|
+
const q = req as Record<string, unknown>;
|
|
253
|
+
const okId = (x: unknown) => typeof x === 'string' && ID.test(x);
|
|
254
|
+
const okNum = (x: unknown) => typeof x === 'number' && Number.isSafeInteger(x) && x >= 0;
|
|
255
|
+
if (typeof q.userId !== 'string' || q.userId === '' || !okId(q.ref) || !okId(q.op) || (q.mode !== 'server' && q.mode !== 'client')) return { ok: false, error: 'bad_request' };
|
|
256
|
+
if (t === 'buyin' && (!(typeof q.tier === 'string' && Object.hasOwn(TIERS, q.tier)) || !okNum(q.amount) || !okNum(q.until))) return { ok: false, error: 'bad_request' };
|
|
257
|
+
if (t === 'settle' && !okNum(q.amount)) return { ok: false, error: 'bad_request' };
|
|
258
|
+
const cmd: Cmd = { t, game, ref: q.ref as string, mode: q.mode as Mode, op: q.op as string };
|
|
259
|
+
if (t === 'buyin') Object.assign(cmd, { tier: q.tier as Tier, amount: q.amount as number, until: q.until as number });
|
|
260
|
+
if (t === 'settle') Object.assign(cmd, { amount: q.amount as number, ...(q.stats !== undefined ? { stats: q.stats } : {}) });
|
|
261
|
+
return bank(this.env, q.userId).run(cmd, Date.now());
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 5.10 的检查:JSON 后 ≤ 256 字节;rounds、wins、best 非负整数;称号最多 5 个、格式;max + sum 最多 3 个 key、值是安全整数 */
|
|
266
|
+
export function statsProblem(x: unknown): StatsProblem | undefined {
|
|
267
|
+
if (JSON.stringify(x).length > 256) return 'too_big';
|
|
268
|
+
const o = x as Record<string, unknown> | null;
|
|
269
|
+
const nat = (v: unknown) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
270
|
+
if (!o || typeof o !== 'object' || !nat(o.rounds) || !nat(o.wins) || !nat(o.best)) return 'bad_stats';
|
|
271
|
+
const id = /^[a-z][a-z0-9_]{0,23}$/;
|
|
272
|
+
if (o.titles !== undefined && !(Array.isArray(o.titles) && o.titles.length <= 5 && o.titles.every((t) => typeof t === 'string' && id.test(t)))) return 'bad_stats';
|
|
273
|
+
let keys = 0;
|
|
274
|
+
for (const k of ['max', 'sum'] as const) {
|
|
275
|
+
const m = o[k];
|
|
276
|
+
if (m === undefined) continue;
|
|
277
|
+
if (!m || typeof m !== 'object') return 'bad_stats';
|
|
278
|
+
for (const [key, v] of Object.entries(m)) {
|
|
279
|
+
if (!/^[a-z][a-z0-9_]{0,15}$/.test(key) || typeof v !== 'number' || !Number.isSafeInteger(v)) return 'bad_stats';
|
|
280
|
+
keys++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return keys > 3 ? 'bad_stats' : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 浏览器的钱包接口(15.9):认平台的会话 cookie(值就当 userId);不是这两条路回 null,调用方接着处理别的 */
|
|
287
|
+
export async function walletHttp(request: Request, env: FakeEnv): Promise<Response | null> {
|
|
288
|
+
const url = new URL(request.url);
|
|
289
|
+
if (url.pathname !== '/api/wallet' && url.pathname !== '/api/wallet/relief') return null;
|
|
290
|
+
const sid = new RegExp(`${SESSION_COOKIE}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
291
|
+
if (!sid) return Response.json({ error: 'login' }, { status: 401 });
|
|
292
|
+
if (url.pathname === '/api/wallet') {
|
|
293
|
+
if (request.method !== 'GET') return Response.json({ error: 'method' }, { status: 405 });
|
|
294
|
+
return Response.json({ wallet: await bank(env, sid).look(Date.now()), tiers: TIERS, reliefTo: RELIEF_TO, winUnits: WIN_UNITS });
|
|
295
|
+
}
|
|
296
|
+
if (request.method !== 'POST') return Response.json({ error: 'method' }, { status: 405 });
|
|
297
|
+
if (request.headers.get('x-arcade') !== '1') return Response.json({ error: 'forbidden' }, { status: 403 });
|
|
298
|
+
const r = await bank(env, sid).relief(Date.now());
|
|
299
|
+
return Response.json(r.body, { status: r.status });
|
|
300
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 游戏 Worker 里开房、进房的两个入口:校验房间码、查 Origin、经平台 /api/me 认人、再找房间,打包在一起。
|
|
3
|
+
* 游戏只要把路由接到这两个函数上,不用自己碰浏览器的原始请求,也就没有机会把它(连同浏览器自己带的头)原样转给房间。
|
|
4
|
+
*
|
|
5
|
+
* 进房被拒时不回 4xx:浏览器的 WebSocket 拿不到 HTTP 状态码,只会看到"连不上",然后一直重连。
|
|
6
|
+
* 这里先接受升级、马上用关闭码关掉,客户端认得这些码:4401 没登录、4403 别的网站、4004 没有这个房间都不再重连
|
|
7
|
+
* (房间自己还会回 4503:今天联机额度快满了,不进新人);
|
|
8
|
+
* 平台暂时不可用是 1013(稍后再试),客户端按退避重连。
|
|
9
|
+
* 独立模式(0.10.0,guest.ts):没有 ARCADE 绑定、GATE 也不是 login 时认游客 cookie(开房时没有就现发一个),
|
|
10
|
+
* 浏览器可以在开房、进房的地址上带 ?name=<昵称>;有 ARCADE 绑定时和以前完全一样。
|
|
11
|
+
* 这是普通 Worker,不是房间,没有 ctx.acceptWebSocket,只能用 server.accept();连接接受了马上关,不留着。
|
|
12
|
+
*/
|
|
13
|
+
import { identify, sameOrigin } from './auth.js';
|
|
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, newRoomCode, roomStub } from './internal.js';
|
|
17
|
+
import type { MeterEnv } from './meter.js';
|
|
18
|
+
import type { CreateOutcome } from './room.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 游戏 Worker 的绑定名固定:房间 ROOM,平台 ARCADE(登录闸门已经用这个名字),计量 METER、门槛 KIT_QUOTA(0.2.0,meter.ts)。
|
|
22
|
+
* METER 是房间自己用的(开房占名额、报写入),这里列出来是为了游戏的 Env 一眼看全
|
|
23
|
+
*/
|
|
24
|
+
export interface KitEnv extends MeterEnv {
|
|
25
|
+
ROOM: DurableObjectNamespace;
|
|
26
|
+
ARCADE?: Fetcher;
|
|
27
|
+
/** "login" 表示这次部署必须接平台(0.10.0):没绑 ARCADE 时按平台不可用拒绝,不退成游客模式 */
|
|
28
|
+
GATE?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GateOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Origin 只认这个站点(比如 https://<你的站点>)。不给就认请求自己的 origin(0.9.0):浏览器从哪个站点打开页面、连的就是哪个站点,
|
|
34
|
+
* 别的网站的页面带来的 Origin 对不上。游戏可以把部署时注入的 SITE_ORIGIN 变量传进来,设了就按它
|
|
35
|
+
*/
|
|
36
|
+
siteOrigin?: string;
|
|
37
|
+
/** 默认是请求所在站点的 /api/me */
|
|
38
|
+
meUrl?: string;
|
|
39
|
+
/** 独立模式的游客设置(cookie 名、游戏电脑人物的名字当保留昵称,0.10.0);平台模式不看 */
|
|
40
|
+
guest?: GuestOptions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 认人:按部署方式(identify),昵称取地址里的 ?name= */
|
|
44
|
+
function who(request: Request, env: KitEnv, opts: GateOptions, mint: boolean) {
|
|
45
|
+
return identify(request, env, {
|
|
46
|
+
...(opts.meUrl ? { meUrl: opts.meUrl } : {}),
|
|
47
|
+
...(opts.guest ? { guest: opts.guest } : {}),
|
|
48
|
+
mint,
|
|
49
|
+
nickname: new URL(request.url).searchParams.get('name'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateOptions extends GateOptions {
|
|
54
|
+
/**
|
|
55
|
+
* 开房时就定下的开局选项(0.4.0,可选):游戏 Worker 从开房请求里取出来原样传进来,房间用 RoomGame.options 校验,
|
|
56
|
+
* 不合格回 400 bad_options、不占名额也不建房间。定下以后不能再改:进房的人都看得到(RoomInfo.opts),开打时 start 带的 opts 不看。
|
|
57
|
+
* 不带就和以前一样,由房主开打时的 start 定
|
|
58
|
+
*/
|
|
59
|
+
options?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 开房:POST。成功 201 { code };失败回 JSON { error, code? }(protocol.ts 的 CreateError):
|
|
64
|
+
* 别的网站 403 forbidden;没登录 401 login;这个人已经有开着的房间 409 has_room(带那个房间的码);今天开房次数用完 429 daily_limit;
|
|
65
|
+
* 今天联机的人太多 503 busy;开房选项不合格 400 bad_options(0.4.0);平台或计量连不上、房间码连撞 5 次、房间出错 503 unavailable。
|
|
66
|
+
* 额度由房间开房时向计量(env.METER,meter.ts)去占;没绑计量就不限额。
|
|
67
|
+
* 独立模式下没有游客 cookie 就现发一个(任何结果的回话都带上这条 Set-Cookie)。
|
|
68
|
+
*/
|
|
69
|
+
export async function acceptCreate(request: Request, env: KitEnv, opts: CreateOptions = {}): Promise<Response> {
|
|
70
|
+
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
|
|
71
|
+
if (!sameOrigin(request, opts.siteOrigin)) return fail(403, 'forbidden');
|
|
72
|
+
const w = await who(request, env, opts, true);
|
|
73
|
+
if ('error' in w) return w.error === 'anonymous' ? fail(401, 'login') : fail(503, 'unavailable');
|
|
74
|
+
const me = w.user;
|
|
75
|
+
// 平台模式不转 Set-Cookie(和以前一样);独立模式现发的游客 cookie 要带回去,不然下次进房认不出这个人
|
|
76
|
+
const cookies = isStandalone(env) ? w.setCookie : [];
|
|
77
|
+
const res = await create(env, me, opts);
|
|
78
|
+
return cookies.length ? withCookies(res, cookies) : res;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function create(env: KitEnv, me: Identity, opts: CreateOptions): Promise<Response> {
|
|
82
|
+
for (let i = 0; i < 5; i++) {
|
|
83
|
+
const code = newRoomCode();
|
|
84
|
+
// 走 RPC,不走 fetch:身份是参数,不在任何请求头里
|
|
85
|
+
const stub = roomStub(env.ROOM, code) as unknown as { kitCreate(code: string, host: Identity, opts?: unknown): Promise<CreateOutcome> };
|
|
86
|
+
const out = await stub.kitCreate(code, me, opts.options);
|
|
87
|
+
switch (out.r) {
|
|
88
|
+
case 'created':
|
|
89
|
+
return Response.json({ code }, { status: 201 });
|
|
90
|
+
case 'exists':
|
|
91
|
+
continue;
|
|
92
|
+
case 'has_room':
|
|
93
|
+
return fail(409, 'has_room', out.code);
|
|
94
|
+
case 'daily_limit':
|
|
95
|
+
return fail(429, 'daily_limit');
|
|
96
|
+
case 'bad_options':
|
|
97
|
+
return fail(400, 'bad_options');
|
|
98
|
+
case 'busy':
|
|
99
|
+
return fail(503, 'busy');
|
|
100
|
+
default:
|
|
101
|
+
return fail(503, 'unavailable');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return fail(503, 'unavailable');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fail(status: number, error: CreateError, code?: string): Response {
|
|
108
|
+
return Response.json(code ? { error, code } : { error }, { status });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 进房:WebSocket 升级。认不出人、别的网站、码不对、平台不可用,都是"接受后马上用关闭码关掉"。
|
|
113
|
+
* 独立模式下进房不发游客 cookie(101 回话上的 Set-Cookie 浏览器不一定收):没有就 4401,页面刷新一下首页(loginGate 会发)再来
|
|
114
|
+
*/
|
|
115
|
+
export async function acceptJoin(request: Request, env: KitEnv, code: string, opts: GateOptions = {}): Promise<Response> {
|
|
116
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
117
|
+
if (!ROOM_CODE_RE.test(code)) return refuse(CLOSE.noRoom, 'no room');
|
|
118
|
+
if (!sameOrigin(request, opts.siteOrigin)) return refuse(CLOSE.forbidden, 'bad origin');
|
|
119
|
+
const w = await who(request, env, opts, false);
|
|
120
|
+
if ('error' in w) return w.error === 'anonymous' ? refuse(CLOSE.unauthorized, 'login first') : refuse(CLOSE.tryLater, 'try later');
|
|
121
|
+
const me = w.user;
|
|
122
|
+
// 新建请求,只带认好的身份和客户端版本;浏览器带来的 x-kit-user 之类的头一律不转
|
|
123
|
+
const cv = new URL(request.url).searchParams.get('cv') ?? '0';
|
|
124
|
+
return roomStub(env.ROOM, code).fetch(`https://${KIT_HOST}/ws?cv=${encodeURIComponent(cv)}`, {
|
|
125
|
+
headers: { upgrade: 'websocket', 'x-kit-user': identityHeader(me) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function refuse(code: number, reason: string): Response {
|
|
130
|
+
const pair = new WebSocketPair();
|
|
131
|
+
pair[1].accept();
|
|
132
|
+
pair[1].close(code, reason);
|
|
133
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 登录闸门要的绑定:页面的静态资源 ASSETS、平台 ARCADE;GATE 写 "off" 时不拦(本地开发试页面用)。
|
|
138
|
+
* 没有 ARCADE、GATE 也不是 "login" 是独立模式(0.10.0):不拦,补发游客 cookie
|
|
139
|
+
*/
|
|
140
|
+
export interface GateEnv {
|
|
141
|
+
ASSETS: Fetcher;
|
|
142
|
+
ARCADE?: Fetcher;
|
|
143
|
+
GATE?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface LoginGateOptions {
|
|
147
|
+
/** 默认是请求所在站点的 /api/me */
|
|
148
|
+
meUrl?: string;
|
|
149
|
+
/** 平台连不上时回的整页 HTML(503);默认一个不带游戏名的通用页,游戏可以换成自己的标题和配色 */
|
|
150
|
+
downPage?: string;
|
|
151
|
+
/** 独立模式的游客设置(0.10.0):cookie 名要和开房、进房传的一样 */
|
|
152
|
+
guest?: GuestOptions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 平台连不上时的默认页:不写游戏名,回大厅的链接指向平台首页 */
|
|
156
|
+
export const DOWN_PAGE = `<!doctype html>
|
|
157
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
158
|
+
<title>平台暂时连不上</title>
|
|
159
|
+
<style>body{margin:0;min-height:100vh;min-height:100dvh;display:grid;place-items:center;background:#1b1f2a;color:#eef0f4;font:16px/1.6 system-ui,sans-serif;text-align:center}
|
|
160
|
+
a{color:#f3c86b}</style></head>
|
|
161
|
+
<body><main><h1>平台暂时连不上</h1><p>稍等一会儿再刷新试试。</p><p><a href="/">回大厅</a></p></main></body></html>`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 游戏首页的登录闸门(0.8.0,docs/platform-backend.md 5.8、15.8):游戏 Worker 把首页(`/games/<id>/`、`index.html`)交给它。
|
|
165
|
+
* - 登录了:放行,回 ASSETS 的页面,带上平台续期的 Set-Cookie,不缓存;
|
|
166
|
+
* - 没登录:302 到 `/login?next=<原路径和查询参数>`(平台 0.10.1 的 next,登录完整页跳回来,好友房链接的 ?room= 原样带回),同样带上 Set-Cookie;
|
|
167
|
+
* - 平台不可用:503 整页(`downPage`),`Retry-After: 30`;
|
|
168
|
+
* - `GATE` 是 "off":不问平台,直接回页面;
|
|
169
|
+
* - 独立模式(0.10.0,没有 ARCADE、GATE 不是 "login"):不跳登录,回页面;没有游客 cookie 就补发一个(开房、进房靠它认人)。
|
|
170
|
+
*/
|
|
171
|
+
export async function loginGate(request: Request, env: GateEnv, opts: LoginGateOptions = {}): Promise<Response> {
|
|
172
|
+
if (isStandalone(env)) {
|
|
173
|
+
const g = await identify(request, env, { ...(opts.guest ? { guest: opts.guest } : {}), mint: true });
|
|
174
|
+
return withCookies(await env.ASSETS.fetch(request), 'user' in g ? g.setCookie : []);
|
|
175
|
+
}
|
|
176
|
+
if (env.GATE === 'off') return env.ASSETS.fetch(request);
|
|
177
|
+
const who = await identify(request, env, opts.meUrl ? { meUrl: opts.meUrl } : {});
|
|
178
|
+
if ('user' in who) return withCookies(await env.ASSETS.fetch(request), who.setCookie);
|
|
179
|
+
if (who.error === 'anonymous') {
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
const next = encodeURIComponent(url.pathname + url.search);
|
|
182
|
+
return withCookies(new Response(null, { status: 302, headers: { location: `/login?next=${next}` } }), who.setCookie);
|
|
183
|
+
}
|
|
184
|
+
return new Response(opts.downPage ?? DOWN_PAGE, {
|
|
185
|
+
status: 503,
|
|
186
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 独立部署时站点根上本该归平台的两个路径(0.10.0;`/api/me` 0.10.2)。只在独立模式下回话,其余回 null,游戏接着走自己的路由:
|
|
192
|
+
* - `/`:游戏挂在 `/games/<id>/`,单独部署到 workers.dev 时有人直接打开 `/`,302 到游戏首页(查询参数原样带上);
|
|
193
|
+
* - `/api/me`:页面的 `detectPlatform` 开局问一次。回 200 `{ standalone: true }`(没有 `user`,认作没有平台),
|
|
194
|
+
* 不让它落到静态资源回 404:404 在浏览器控制台每次都是一条红的资源加载错误。
|
|
195
|
+
* 两个路径都要先过 Worker:把 "/"、"/api/me" 加进 wrangler.jsonc 的 `assets.run_worker_first`。
|
|
196
|
+
* 本站部署时它们由平台的路由接,到不了游戏 Worker;就算到了,有平台绑定时这里也回 null
|
|
197
|
+
*/
|
|
198
|
+
export function standaloneRoot(request: Request, env: ModeEnv, gameId: string): Response | null {
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
if (!isStandalone(env) || !/^[a-z0-9-]+$/.test(gameId)) return null;
|
|
201
|
+
if (url.pathname === '/api/me') return Response.json({ standalone: true }, { headers: { 'cache-control': 'no-store' } });
|
|
202
|
+
if (url.pathname !== '/') return null;
|
|
203
|
+
return new Response(null, { status: 302, headers: { location: `/games/${gameId}/${url.search}`, 'cache-control': 'no-store' } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { isStandalone };
|
|
207
|
+
|
|
208
|
+
/** 带上平台的 Set-Cookie(whoIs 给的 setCookie);按人回的内容一律不缓存 */
|
|
209
|
+
export function withCookies(res: Response, setCookie: readonly string[]): Response {
|
|
210
|
+
const out = new Response(res.body, res);
|
|
211
|
+
out.headers.set('cache-control', 'private, no-store');
|
|
212
|
+
for (const c of setCookie) out.headers.append('set-cookie', c);
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** JSON 回话,带上 Set-Cookie、不缓存 */
|
|
217
|
+
export function json(body: unknown, status: number, setCookie: readonly string[] = []): Response {
|
|
218
|
+
return withCookies(Response.json(body, { status }), setCookie);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 读 JSON 请求体,读不了回 null。游戏自己的接口读请求体往往在认人之前:必须带 content-length 且 1 到 maxBytes 字节才读
|
|
223
|
+
* (分块传的大请求体不带长度,没登录的人也能让这里解析一大段)。浏览器 fetch 发字符串请求体总带 content-length
|
|
224
|
+
*/
|
|
225
|
+
export async function readJson(request: Request, maxBytes: number): Promise<unknown> {
|
|
226
|
+
const len = request.headers.get('content-length');
|
|
227
|
+
const n = len === null ? NaN : Number(len);
|
|
228
|
+
if (!(n > 0 && n <= maxBytes)) return null;
|
|
229
|
+
try {
|
|
230
|
+
return await request.json();
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|