@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/protocol.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 房间和浏览器之间的消息(docs/platform-backend.md 4.3)。
|
|
3
|
+
*
|
|
4
|
+
* - 服务端说了算:客户端只发"我想做什么"(act 里的 action),合不合法、结果是什么都由房间算;
|
|
5
|
+
* - 每条下行消息带 seq(房间这一局的步数),客户端按 seq 去重、丢掉旧的;
|
|
6
|
+
* - 客户端的每个操作带 moveId,断线重连后重发没确认的操作,房间按 moveId 认出重复的;
|
|
7
|
+
* - 协议带版本号 v,新旧代码会并存(部署后正在打的房间还跑旧代码,4.5),所以消息格式只加字段、不改含义;
|
|
8
|
+
* - 心跳是纯文本 "ping" / "pong",由运行时自动应答(setWebSocketAutoResponse),不唤醒房间、不计费。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 协议版本:只在不兼容时加一。 */
|
|
12
|
+
export const PROTOCOL_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export const PING = 'ping';
|
|
15
|
+
export const PONG = 'pong';
|
|
16
|
+
|
|
17
|
+
/** 房间码:6 位数字(9.1)。 */
|
|
18
|
+
export const ROOM_CODE_RE = /^[0-9]{6}$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 关闭码。1008(违规)和 4000 段客户端都不重连(4.5);1006 等异常断开才重连。
|
|
22
|
+
* 1009 是消息太大(4.6),属于程序错误,也不重连。
|
|
23
|
+
*/
|
|
24
|
+
export const CLOSE = {
|
|
25
|
+
/** 发得太快(令牌桶)或其他违规 */
|
|
26
|
+
policy: 1008,
|
|
27
|
+
/** 入站消息超过大小上限 */
|
|
28
|
+
tooBig: 1009,
|
|
29
|
+
/** 平台暂时认不了人(不可用、超时):稍后再试,客户端照常按退避重连 */
|
|
30
|
+
tryLater: 1013,
|
|
31
|
+
/** 房间关了(打完、超时、没人、超过寿命或消息总数) */
|
|
32
|
+
roomClosed: 4000,
|
|
33
|
+
/** 同一个账号在别处连上了,这条连接让位 */
|
|
34
|
+
replaced: 4001,
|
|
35
|
+
/** 没入座的连接满了(RoomLimits.extraConnections,默认 4 条;入座的人不占这个数) */
|
|
36
|
+
full: 4003,
|
|
37
|
+
/** 没有这个房间(码不对或已经关了) */
|
|
38
|
+
noRoom: 4004,
|
|
39
|
+
/** 客户端太旧,刷新页面 */
|
|
40
|
+
tooOld: 4005,
|
|
41
|
+
/** 这个人在这个房间的重连次数用完了(反复连上再断开) */
|
|
42
|
+
tooManyJoins: 4029,
|
|
43
|
+
/** 这个人在这个房间发的消息太多(超过房间一生入站额度里他那一份):只断开他,房间照常 */
|
|
44
|
+
userQuota: 4030,
|
|
45
|
+
/** 今天联机的额度快用完了,不进新人(0.2.0):已经在这个房间里坐过的人照常能回来 */
|
|
46
|
+
busy: 4503,
|
|
47
|
+
/** 没登录,或者平台暂时认不出人:先登录再来 */
|
|
48
|
+
unauthorized: 4401,
|
|
49
|
+
/** 不是本站页面发起的 */
|
|
50
|
+
forbidden: 4403,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 4000(roomClosed)带的原因(0.2.0),客户端在 onStatus 的第三个参数里拿到。
|
|
55
|
+
* quota:今天全站的写入额度快用完了,房间在这一段(比如这一副)打完时收场。其他原因(打完、超时、没人)不带这个。
|
|
56
|
+
*/
|
|
57
|
+
export const CLOSE_REASON = {
|
|
58
|
+
quota: 'quota',
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 开房(acceptCreate)失败时回的 JSON `{ error, code? }`(0.2.0):
|
|
63
|
+
* - login 401 没登录;forbidden 403 不是本站页面;
|
|
64
|
+
* - has_room 409 这个人已经有一个开着的房间(code 是那个房间的码,页面给"回到房间");
|
|
65
|
+
* - daily_limit 429 这个人今天开房次数用完了;
|
|
66
|
+
* - busy 503 今天联机的人太多了(全站写入或开房数到了门槛),明天再来;
|
|
67
|
+
* - unavailable 503 平台或计量暂时连不上、房间出错,稍后再试;
|
|
68
|
+
* - bad_options 400 开房时带的选项不合格(0.4.0,游戏 Worker 传了 options 时才会有)。
|
|
69
|
+
*/
|
|
70
|
+
export type CreateError = 'login' | 'forbidden' | 'has_room' | 'daily_limit' | 'busy' | 'unavailable' | 'bad_options';
|
|
71
|
+
|
|
72
|
+
/** 不重连的关闭码。 */
|
|
73
|
+
export function isFinalClose(code: number): boolean {
|
|
74
|
+
return code === CLOSE.policy || code === CLOSE.tooBig || (code >= 4000 && code < 5000);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- 上行(浏览器 → 房间) ----------
|
|
78
|
+
|
|
79
|
+
/** 坐到某个座位(等人时选空位,开打后可以接管电脑的座位)。 */
|
|
80
|
+
export interface SitMsg {
|
|
81
|
+
t: 'sit';
|
|
82
|
+
seat: number;
|
|
83
|
+
}
|
|
84
|
+
/** 离开座位(等人时空出来;开打后由电脑接手)。 */
|
|
85
|
+
export interface LeaveMsg {
|
|
86
|
+
t: 'leave';
|
|
87
|
+
}
|
|
88
|
+
/** 开局:空座位由电脑补上。opts 是房主选的开局选项(0.3.0,游戏的 RoomGame.options 校验;不合格回 bad_options)。 */
|
|
89
|
+
export interface StartMsg {
|
|
90
|
+
t: 'start';
|
|
91
|
+
opts?: unknown;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 一个操作意图,内容由游戏定。moveId 由客户端生成(同一个操作重发时不变)。
|
|
95
|
+
* baseSeq 是做这个决定时看到的最新 seq:牌局在那之后变过(别人出了牌、超时代出了),房间回 stale 不做,
|
|
96
|
+
* 免得断线前点的旧操作在重连后被套到新的一手上。
|
|
97
|
+
*/
|
|
98
|
+
export interface ActMsg<A = unknown> {
|
|
99
|
+
t: 'act';
|
|
100
|
+
moveId: string;
|
|
101
|
+
baseSeq: number;
|
|
102
|
+
action: A;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 快捷短语(0.6.0):只发游戏短语表里的编号(0 到 RoomGame.emotes - 1),不收自由文本,不用审内容。
|
|
106
|
+
* 只有入座的人能发(电脑座位由房间子类经 GameRoom.botEmote 发,0.10.0),每个座位一个令牌桶;房间只转发、不存,被拒回 error(见 EMOTE_ERRORS),页面可以不提示。
|
|
107
|
+
*/
|
|
108
|
+
export interface EmoteMsg {
|
|
109
|
+
t: 'emote';
|
|
110
|
+
id: number;
|
|
111
|
+
/** 冲着哪个座位(0.7.0,可选):扔道具、点名嘲讽。得是有人的别的座位,不然回 bad_target;不带就和 0.6.0 一样 */
|
|
112
|
+
to?: number;
|
|
113
|
+
}
|
|
114
|
+
export type UpMsg<A = unknown> = SitMsg | LeaveMsg | StartMsg | ActMsg<A> | EmoteMsg;
|
|
115
|
+
|
|
116
|
+
// ---------- 下行(房间 → 浏览器) ----------
|
|
117
|
+
|
|
118
|
+
export type SeatKind = 'empty' | 'human' | 'ai';
|
|
119
|
+
|
|
120
|
+
/** 座位的公开信息:谁坐着、是真人还是电脑、在不在线、是不是电脑在代打。 */
|
|
121
|
+
export interface SeatInfo {
|
|
122
|
+
kind: SeatKind;
|
|
123
|
+
/**
|
|
124
|
+
* 真人的昵称;开打后绑了人的座位由电脑代打时(主人离座了)是原主人的昵称,牌桌可以显示"电脑代某某打"(0.1.1)。
|
|
125
|
+
* 没绑人的电脑座位和空位为 null(电脑的名字由游戏按座位号自己定)
|
|
126
|
+
*/
|
|
127
|
+
name: string | null;
|
|
128
|
+
/** 真人掉线了(座位还留着) */
|
|
129
|
+
away: boolean;
|
|
130
|
+
/** 真人掉线超过宽限,电脑在代打;回来就交还 */
|
|
131
|
+
auto: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* 开打后这个座位已经绑了人(主人离座了也算):kind 是 'ai' 时别人接管不了(seat_reserved),大厅不用给它挂"接管"按钮;
|
|
134
|
+
* 只有原主人能回来(看 you.bound)。等人阶段都是 false。0.1.1 起房间总会带,旧房间代码不带
|
|
135
|
+
*/
|
|
136
|
+
reserved?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 这条连接是谁:seat 是现在坐的座位(没入座为 null);
|
|
141
|
+
* bound 是这个人开打后绑定的座位(离座了也还是它,可以"回到我的座位";还没开打、一局里没坐过为 null)。
|
|
142
|
+
* bound 是 0.1.1 加的,房间总会带
|
|
143
|
+
*/
|
|
144
|
+
export interface You {
|
|
145
|
+
seat: number | null;
|
|
146
|
+
bound?: number | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type RoomStatus = 'waiting' | 'playing' | 'over';
|
|
150
|
+
|
|
151
|
+
export interface RoomInfo {
|
|
152
|
+
code: string;
|
|
153
|
+
status: RoomStatus;
|
|
154
|
+
seats: SeatInfo[];
|
|
155
|
+
/**
|
|
156
|
+
* 当前要出手的座位和截止时间(毫秒时间戳),没在等人出手时为 null。
|
|
157
|
+
* seats(0.1.1):游戏写了 RoomGame.actors 才有,是这一刻还能出手的全部座位(一组人时就是还没点的那几个),seat 是其中第一个;
|
|
158
|
+
* 截止时间对整组只有一个
|
|
159
|
+
*/
|
|
160
|
+
turn: { seat: number; deadline: number | null; seats?: number[] } | null;
|
|
161
|
+
/**
|
|
162
|
+
* 开房时定下的开局选项(0.4.0):RoomGame.options 校验过的原样,进房的人开打前就知道这一局怎么打(档位、几个座位)。
|
|
163
|
+
* 开房时没带选项的房间没有这个字段(开打时由 start 定,和以前一样)
|
|
164
|
+
*/
|
|
165
|
+
opts?: unknown;
|
|
166
|
+
/**
|
|
167
|
+
* 正在"停一会儿再接着来"(0.5.0,RoomGame.pause):until 是房间接着推进的时间戳(毫秒),页面拿它画倒计时。
|
|
168
|
+
* 只有在停时才有,这时 turn 是 null;游戏的 resume 出错时房间会把它往后推
|
|
169
|
+
*/
|
|
170
|
+
pause?: { until: number };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 一帧画面:按座位过滤过的游戏视图,delay 是相对上一帧的播放延迟(电脑连续出牌时用,4.4)。 */
|
|
174
|
+
export interface Frame<V = unknown> {
|
|
175
|
+
view: V;
|
|
176
|
+
delay: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 进房(含重连)时的全量快照。 */
|
|
180
|
+
export interface WelcomeMsg<V = unknown> {
|
|
181
|
+
t: 'welcome';
|
|
182
|
+
v: number;
|
|
183
|
+
minClientVersion: number;
|
|
184
|
+
seq: number;
|
|
185
|
+
you: You;
|
|
186
|
+
room: RoomInfo;
|
|
187
|
+
view: V | null;
|
|
188
|
+
/**
|
|
189
|
+
* 快捷短语的节奏(0.7.0,游戏有短语时才带):每 ms 毫秒补 1 个令牌、最多攒 burst 个。RoomClient 按它在本地排队发,
|
|
190
|
+
* 用户感觉不到限速;运维在 KIT_QUOTA 里改,不用发版
|
|
191
|
+
*/
|
|
192
|
+
emote?: { ms: number; burst: number };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 每一步之后的状态。ack 只发给做这一步的那条连接:对应它的 moveId。 */
|
|
196
|
+
export interface StateMsg<V = unknown> {
|
|
197
|
+
t: 'state';
|
|
198
|
+
seq: number;
|
|
199
|
+
you: You;
|
|
200
|
+
room: RoomInfo;
|
|
201
|
+
/** 至少一帧;最后一帧就是当前状态 */
|
|
202
|
+
frames: Frame<V>[];
|
|
203
|
+
ack?: string;
|
|
204
|
+
/**
|
|
205
|
+
* 别人看不出来的一步(0.7.1):不轮流的操作没改任何别人能看到的东西(比如声明窗口里真人答"碰"),房间只回给出手的人、seq 不加(和上一条一样)。
|
|
206
|
+
* 只和 ack 一起出现;RoomClient 0.7.1 起认它(seq 一样也交给页面),旧的 RoomClient 当重复丢掉
|
|
207
|
+
*/
|
|
208
|
+
quiet?: true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 操作被拒(不合法、不该你、格式不对、牌局已经变了 stale、不是房主 not_host)。不改 seq。
|
|
213
|
+
* code 是固定的错误码,游戏的 apply 返回的错误码也原样放在这里(见 RoomGame.apply)。
|
|
214
|
+
*/
|
|
215
|
+
export interface ErrorMsg {
|
|
216
|
+
t: 'error';
|
|
217
|
+
code: string;
|
|
218
|
+
moveId?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 有人发了快捷短语(0.6.0):seat 是房间的座位号(游戏按自己的视角换算),id 是短语编号。
|
|
223
|
+
* 发给房间里所有收到过快照的连接(含发的人自己,页面收到再冒气泡)。不带 seq,不改牌局,断线时错过的不补发。
|
|
224
|
+
* 不在 DownMsg 里:RoomClient 把它交给 onEmote,不进 onMessage,不用短语的游戏按 DownMsg 写的代码一行不用改
|
|
225
|
+
*/
|
|
226
|
+
export interface EmotedMsg {
|
|
227
|
+
t: 'emote';
|
|
228
|
+
seat: number;
|
|
229
|
+
id: number;
|
|
230
|
+
/** 发的时候指定了对象才有(0.7.0),房间的座位号 */
|
|
231
|
+
to?: number;
|
|
232
|
+
/** 电脑座位发的(0.10.0,GameRoom.botEmote)才有;页面可以不区分 */
|
|
233
|
+
bot?: true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 快捷短语被拒时 error 的 code(0.6.0):no_emotes 这个游戏没有短语表;bad_emote 编号不对;
|
|
238
|
+
* not_seated 没入座(旁观的不能发);room_over 房间打完了;emote_too_fast 这个座位离上一句不到 emoteMs;
|
|
239
|
+
* bad_target(0.7.0)带的 to 不是有人的别的座位
|
|
240
|
+
*/
|
|
241
|
+
export const EMOTE_ERRORS = ['no_emotes', 'bad_emote', 'not_seated', 'room_over', 'emote_too_fast', 'bad_target'] as const;
|
|
242
|
+
|
|
243
|
+
export type DownMsg<V = unknown> = WelcomeMsg<V> | StateMsg<V> | ErrorMsg;
|
package/src/rng.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 联机发牌用的随机数:ChaCha20(RFC 8439),纯函数,浏览器、DO、普通 Node 都能用,不依赖 DOM、网络、运行时的随机源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 32 位种子:种子只有 2^32 种,玩家拿自己的手牌逐个试种子,几分钟就能反推出全桌的牌(0.2.0 前的房间就有这个问题)。
|
|
5
|
+
* 房间开局时用 crypto.getRandomValues 拿 256 位熵(8 个 u32,Entropy),交给 RoomGame.begin 当 ChaCha20 的密钥;
|
|
6
|
+
* 密钥只在服务端的牌局状态里,绝不下发。同一把密钥、同样的用途和序号永远得到同样的结果,存档、回放、联机校验照样成立。
|
|
7
|
+
*
|
|
8
|
+
* 只用 32 位整数运算(加法后 >>> 0、移位、异或),结果不依赖平台的数学函数,规则核心可以放心用。
|
|
9
|
+
* 一把密钥按"用途 + 序号"分出互不相干的流(比如 stream(key, 'deal', 3) 是第 3 副的发牌):
|
|
10
|
+
* 用途进 nonce 的第 0 个字(FNV-1a 32 位),序号进第 1、2 个字,块计数从 0 起。用途名请用固定的短字符串,同一个游戏里不要两个用途撞同一个哈希
|
|
11
|
+
* (游戏在自己的测试里断言一下)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 256 位熵:8 个 0–2^32-1 的整数(ChaCha20 的密钥,按 RFC 8439 的小端字)。 */
|
|
15
|
+
export type Entropy = readonly number[];
|
|
16
|
+
|
|
17
|
+
export const ENTROPY_WORDS = 8;
|
|
18
|
+
|
|
19
|
+
/** 是不是合格的 256 位熵:8 个 u32 整数。 */
|
|
20
|
+
export function isEntropy(x: unknown): x is Entropy {
|
|
21
|
+
return Array.isArray(x) && x.length === ENTROPY_WORDS && x.every((w) => Number.isInteger(w) && w >= 0 && w <= 0xffffffff);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rotl(x: number, n: number): number {
|
|
25
|
+
return ((x << n) | (x >>> (32 - n))) >>> 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** RFC 8439 2.1 的四分之一轮,原地改 s 的四个字。 */
|
|
29
|
+
export function quarterRound(s: Uint32Array, a: number, b: number, c: number, d: number): void {
|
|
30
|
+
s[a] = (s[a]! + s[b]!) >>> 0;
|
|
31
|
+
s[d] = rotl(s[d]! ^ s[a]!, 16);
|
|
32
|
+
s[c] = (s[c]! + s[d]!) >>> 0;
|
|
33
|
+
s[b] = rotl(s[b]! ^ s[c]!, 12);
|
|
34
|
+
s[a] = (s[a]! + s[b]!) >>> 0;
|
|
35
|
+
s[d] = rotl(s[d]! ^ s[a]!, 8);
|
|
36
|
+
s[c] = (s[c]! + s[d]!) >>> 0;
|
|
37
|
+
s[b] = rotl(s[b]! ^ s[c]!, 7);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** RFC 8439 2.3 的块函数:密钥 8 字、块计数 1 字、nonce 3 字 → 16 个字(64 字节的密钥流,小端)。 */
|
|
41
|
+
export function chacha20Block(key: Entropy, counter: number, nonce: readonly number[]): Uint32Array {
|
|
42
|
+
const init = new Uint32Array(16);
|
|
43
|
+
init[0] = 0x61707865;
|
|
44
|
+
init[1] = 0x3320646e;
|
|
45
|
+
init[2] = 0x79622d32;
|
|
46
|
+
init[3] = 0x6b206574;
|
|
47
|
+
for (let i = 0; i < 8; i++) init[4 + i] = key[i]!;
|
|
48
|
+
init[12] = counter;
|
|
49
|
+
init[13] = nonce[0]!;
|
|
50
|
+
init[14] = nonce[1]!;
|
|
51
|
+
init[15] = nonce[2]!;
|
|
52
|
+
const s = init.slice();
|
|
53
|
+
for (let i = 0; i < 10; i++) {
|
|
54
|
+
quarterRound(s, 0, 4, 8, 12);
|
|
55
|
+
quarterRound(s, 1, 5, 9, 13);
|
|
56
|
+
quarterRound(s, 2, 6, 10, 14);
|
|
57
|
+
quarterRound(s, 3, 7, 11, 15);
|
|
58
|
+
quarterRound(s, 0, 5, 10, 15);
|
|
59
|
+
quarterRound(s, 1, 6, 11, 12);
|
|
60
|
+
quarterRound(s, 2, 7, 8, 13);
|
|
61
|
+
quarterRound(s, 3, 4, 9, 14);
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < 16; i++) s[i] = (s[i]! + init[i]!) >>> 0;
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 用途名的 32 位哈希(FNV-1a,按 UTF-16 码元)。 */
|
|
68
|
+
export function purposeHash(purpose: string): number {
|
|
69
|
+
let h = 0x811c9dc5;
|
|
70
|
+
for (let i = 0; i < purpose.length; i++) {
|
|
71
|
+
h ^= purpose.charCodeAt(i);
|
|
72
|
+
// 乘 FNV 质数 16777619 = 2^24 + 403:拆成移位和加法,不用 Math.imul,每一步都在 2^53 以内
|
|
73
|
+
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 一条随机流:按顺序吐 u32,用完一块(16 个字)算下一块。 */
|
|
79
|
+
export interface RandomStream {
|
|
80
|
+
/** 下一个 0–2^32-1 的整数 */
|
|
81
|
+
u32(): number;
|
|
82
|
+
/** 0 到 n-1 的均匀整数(1 ≤ n ≤ 2^32),拒绝采样,没有取模偏差 */
|
|
83
|
+
below(n: number): number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 从密钥分出一条流:purpose 用途(如 'deal'),index 序号(0 到 2^53-1 的整数,如第几副)。
|
|
88
|
+
* 每条流最多 2^32 块(每块 16 个字),一副牌远远用不完。
|
|
89
|
+
*/
|
|
90
|
+
export function stream(key: Entropy, purpose: string, index = 0): RandomStream {
|
|
91
|
+
if (!isEntropy(key)) throw new Error('rng key must be 8 u32 words');
|
|
92
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new Error('rng index must be a non-negative safe integer');
|
|
93
|
+
const nonce = [purposeHash(purpose), index % 0x1_0000_0000, Math.floor(index / 0x1_0000_0000)];
|
|
94
|
+
let counter = 0;
|
|
95
|
+
let block: Uint32Array | null = null;
|
|
96
|
+
let at = 16;
|
|
97
|
+
const u32 = (): number => {
|
|
98
|
+
if (at >= 16) {
|
|
99
|
+
if (counter > 0xffffffff) throw new Error('rng stream exhausted');
|
|
100
|
+
block = chacha20Block(key, counter++, nonce);
|
|
101
|
+
at = 0;
|
|
102
|
+
}
|
|
103
|
+
return block![at++]!;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
u32,
|
|
107
|
+
below: (n: number) => uniformBelow(u32, n),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 0 到 n-1 的均匀整数:next 给 u32,落在"2^32 里最大的 n 的整数倍"以上的丢掉重抽,剩下的取模才没有偏差。 */
|
|
112
|
+
export function uniformBelow(next: () => number, n: number): number {
|
|
113
|
+
if (!Number.isInteger(n) || n < 1 || n > 0x1_0000_0000) throw new Error('rng below(n) needs 1 ≤ n ≤ 2^32');
|
|
114
|
+
const limit = 0x1_0000_0000 - (0x1_0000_0000 % n);
|
|
115
|
+
for (;;) {
|
|
116
|
+
const x = next();
|
|
117
|
+
if (x < limit) return x % n;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fisher–Yates 洗牌(从后往前,每一步用 below 拒绝采样),返回新数组,不改原数组。 */
|
|
122
|
+
export function shuffle<T>(items: readonly T[], rng: RandomStream): T[] {
|
|
123
|
+
const out = items.slice();
|
|
124
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
125
|
+
const j = rng.below(i + 1);
|
|
126
|
+
const t = out[i]!;
|
|
127
|
+
out[i] = out[j]!;
|
|
128
|
+
out[j] = t;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|