dsh-poker 0.1.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/client.js +2419 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/index.js +3051 -0
  8. package/lib/index.js.map +1 -0
  9. package/lib/invariant-C3ZiFskw.js +13 -0
  10. package/lib/invariant-C3ZiFskw.js.map +1 -0
  11. package/lib/invariant.js +3 -0
  12. package/lib/join/card-back-landlord.png +0 -0
  13. package/lib/join/card-back.png +0 -0
  14. package/lib/join/face-a.png +0 -0
  15. package/lib/join/face-j.png +0 -0
  16. package/lib/join/face-k.png +0 -0
  17. package/lib/join/face-q.png +0 -0
  18. package/lib/join/index.html +13 -0
  19. package/lib/join/index.js +8910 -0
  20. package/lib/join/index.js.map +1 -0
  21. package/lib/join/join.css +14 -0
  22. package/lib/join/joker-black.png +0 -0
  23. package/lib/join/joker-red.png +0 -0
  24. package/lib/join/role-farmer-b.png +0 -0
  25. package/lib/join/role-farmer-c.png +0 -0
  26. package/lib/join/role-farmer.png +0 -0
  27. package/lib/join/role-landlord-b.png +0 -0
  28. package/lib/join/role-landlord.png +0 -0
  29. package/lib/join/role-spectator.png +0 -0
  30. package/lib/types/client/CardBack.d.ts +7 -0
  31. package/lib/types/client/CardBack.d.ts.map +1 -0
  32. package/lib/types/client/CardFace.d.ts +8 -0
  33. package/lib/types/client/CardFace.d.ts.map +1 -0
  34. package/lib/types/client/HostApp.d.ts +4 -0
  35. package/lib/types/client/HostApp.d.ts.map +1 -0
  36. package/lib/types/client/InviteDialog.d.ts +15 -0
  37. package/lib/types/client/InviteDialog.d.ts.map +1 -0
  38. package/lib/types/client/JoinForm.d.ts +13 -0
  39. package/lib/types/client/JoinForm.d.ts.map +1 -0
  40. package/lib/types/client/LobbyView.d.ts +34 -0
  41. package/lib/types/client/LobbyView.d.ts.map +1 -0
  42. package/lib/types/client/SeatAvatar.d.ts +18 -0
  43. package/lib/types/client/SeatAvatar.d.ts.map +1 -0
  44. package/lib/types/client/SeatRow.d.ts +8 -0
  45. package/lib/types/client/SeatRow.d.ts.map +1 -0
  46. package/lib/types/client/SettingsCard.d.ts +6 -0
  47. package/lib/types/client/SettingsCard.d.ts.map +1 -0
  48. package/lib/types/client/SettlementView.d.ts +6 -0
  49. package/lib/types/client/SettlementView.d.ts.map +1 -0
  50. package/lib/types/client/TableView.d.ts +16 -0
  51. package/lib/types/client/TableView.d.ts.map +1 -0
  52. package/lib/types/client/card-stack.d.ts +3 -0
  53. package/lib/types/client/card-stack.d.ts.map +1 -0
  54. package/lib/types/client/host-api.d.ts +50 -0
  55. package/lib/types/client/host-api.d.ts.map +1 -0
  56. package/lib/types/client/index.d.ts +5 -0
  57. package/lib/types/client/index.d.ts.map +1 -0
  58. package/lib/types/client/slots.d.ts +3 -0
  59. package/lib/types/client/slots.d.ts.map +1 -0
  60. package/lib/types/client/store.d.ts +15 -0
  61. package/lib/types/client/store.d.ts.map +1 -0
  62. package/lib/types/config.d.ts +76 -0
  63. package/lib/types/config.d.ts.map +1 -0
  64. package/lib/types/crypto.d.ts +9 -0
  65. package/lib/types/crypto.d.ts.map +1 -0
  66. package/lib/types/engine/cards.d.ts +22 -0
  67. package/lib/types/engine/cards.d.ts.map +1 -0
  68. package/lib/types/engine/deal.d.ts +11 -0
  69. package/lib/types/engine/deal.d.ts.map +1 -0
  70. package/lib/types/engine/hands.d.ts +16 -0
  71. package/lib/types/engine/hands.d.ts.map +1 -0
  72. package/lib/types/engine/index.d.ts +7 -0
  73. package/lib/types/engine/index.d.ts.map +1 -0
  74. package/lib/types/engine/play.d.ts +73 -0
  75. package/lib/types/engine/play.d.ts.map +1 -0
  76. package/lib/types/engine/random.d.ts +2 -0
  77. package/lib/types/engine/random.d.ts.map +1 -0
  78. package/lib/types/engine/score.d.ts +11 -0
  79. package/lib/types/engine/score.d.ts.map +1 -0
  80. package/lib/types/identity/player.d.ts +6 -0
  81. package/lib/types/identity/player.d.ts.map +1 -0
  82. package/lib/types/index.d.ts +8 -0
  83. package/lib/types/index.d.ts.map +1 -0
  84. package/lib/types/invariant.d.ts +9 -0
  85. package/lib/types/invariant.d.ts.map +1 -0
  86. package/lib/types/join/JoinApp.d.ts +2 -0
  87. package/lib/types/join/JoinApp.d.ts.map +1 -0
  88. package/lib/types/join/main.d.ts +2 -0
  89. package/lib/types/join/main.d.ts.map +1 -0
  90. package/lib/types/net/auth.d.ts +16 -0
  91. package/lib/types/net/auth.d.ts.map +1 -0
  92. package/lib/types/net/join-page.d.ts +6 -0
  93. package/lib/types/net/join-page.d.ts.map +1 -0
  94. package/lib/types/net/json.d.ts +3 -0
  95. package/lib/types/net/json.d.ts.map +1 -0
  96. package/lib/types/net/protocol.d.ts +2 -0
  97. package/lib/types/net/protocol.d.ts.map +1 -0
  98. package/lib/types/net/routes.d.ts +4 -0
  99. package/lib/types/net/routes.d.ts.map +1 -0
  100. package/lib/types/net/views.d.ts +3 -0
  101. package/lib/types/net/views.d.ts.map +1 -0
  102. package/lib/types/net/ws.d.ts +8 -0
  103. package/lib/types/net/ws.d.ts.map +1 -0
  104. package/lib/types/persist/domain.d.ts +104 -0
  105. package/lib/types/persist/domain.d.ts.map +1 -0
  106. package/lib/types/persist/schemas.d.ts +79 -0
  107. package/lib/types/persist/schemas.d.ts.map +1 -0
  108. package/lib/types/room/RoomManager.d.ts +142 -0
  109. package/lib/types/room/RoomManager.d.ts.map +1 -0
  110. package/lib/types/settle/audit.d.ts +15 -0
  111. package/lib/types/settle/audit.d.ts.map +1 -0
  112. package/lib/types/settle/escrow.d.ts +4 -0
  113. package/lib/types/settle/escrow.d.ts.map +1 -0
  114. package/lib/types/settle/grant.d.ts +7 -0
  115. package/lib/types/settle/grant.d.ts.map +1 -0
  116. package/lib/types/settle/ledger.d.ts +39 -0
  117. package/lib/types/settle/ledger.d.ts.map +1 -0
  118. package/lib/types/settle/math.d.ts +12 -0
  119. package/lib/types/settle/math.d.ts.map +1 -0
  120. package/lib/types/types.d.ts +253 -0
  121. package/lib/types/types.d.ts.map +1 -0
  122. package/package.json +116 -0
  123. package/prepare.mjs +16 -0
package/lib/index.js ADDED
@@ -0,0 +1,3051 @@
1
+ import { a as MAX_ROOM_TITLE, c as SUBPROTOCOL, i as MAX_FRAME_BYTES, n as MAX_CHAT, o as MAX_SPECTATORS, r as MAX_DISPLAY_NAME } from "./invariant-C3ZiFskw.js";
2
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { z as z$1 } from "zod";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { WebSocket, WebSocketServer } from "ws";
10
+
11
+ //#region src/settle/math.ts
12
+ const STAKE_LADDER_M = [
13
+ 1,
14
+ 2,
15
+ 5,
16
+ 10,
17
+ 20,
18
+ 50,
19
+ 100
20
+ ];
21
+ const ATOMS_PER_M = 1000000n;
22
+ const ATOMS_PER_B = 1000000000n;
23
+ function perFarmerCapAtoms(stakeAtoms, maxMultiplier) {
24
+ return stakeAtoms * 3n * BigInt(maxMultiplier) * 2n;
25
+ }
26
+ function seatCapAtoms(stakeAtoms, maxMultiplier, seatCount = 3) {
27
+ return BigInt(seatCount - 1) * perFarmerCapAtoms(stakeAtoms, maxMultiplier);
28
+ }
29
+ /** Display token atoms as AI-token units: M (million) or B (billion). Never dump raw zeros. */
30
+ function formatM(atoms) {
31
+ const sign = atoms < 0n ? "-" : "";
32
+ const abs = atoms < 0n ? -atoms : atoms;
33
+ if (abs >= ATOMS_PER_B) return `${sign}${formatScaled(abs, ATOMS_PER_B)}B`;
34
+ return `${sign}${formatScaled(abs, ATOMS_PER_M)}M`;
35
+ }
36
+ function formatScaled(abs, unit) {
37
+ const whole = abs / unit;
38
+ const rem = abs % unit;
39
+ if (rem === 0n) return whole.toString();
40
+ const decimals = unit.toString().length - 1;
41
+ const frac = rem.toString().padStart(decimals, "0").replace(/0+$/, "");
42
+ return `${whole.toString()}.${frac}`;
43
+ }
44
+
45
+ //#endregion
46
+ //#region src/config.ts
47
+ const DEFAULT_STAKE_M = 1;
48
+ const DEFAULT_MAX_MULTIPLIER = 8;
49
+ const DEFAULT_WELCOME_ATOMS = "200000000";
50
+ const DEFAULT_SEAT_COUNT = 3;
51
+ const DEFAULT_TURN_TIMEOUT_MS = 12e4;
52
+ const MAX_MULTIPLIER_OPTIONS = [
53
+ 8,
54
+ 16,
55
+ 32,
56
+ 64
57
+ ];
58
+ const Config = z.object({
59
+ enabled: z.boolean().default(true),
60
+ welcomeAtoms: z.string().default(DEFAULT_WELCOME_ATOMS),
61
+ defaultStakeM: z.number().default(DEFAULT_STAKE_M),
62
+ defaultMaxMultiplier: z.number().default(DEFAULT_MAX_MULTIPLIER),
63
+ defaultSeatCount: z.union([z.const(3), z.const(4)]).default(DEFAULT_SEAT_COUNT),
64
+ defaultLaiZi: z.boolean().default(false),
65
+ publicBaseUrl: z.string().default(""),
66
+ maxRooms: z.number().default(32),
67
+ allowSpectators: z.boolean().default(true),
68
+ spectatorCardCounter: z.boolean().default(false),
69
+ houseEnabled: z.boolean().default(false),
70
+ reconnectWindowMs: z.number().default(12e4),
71
+ turnTimeoutMs: z.number().default(DEFAULT_TURN_TIMEOUT_MS),
72
+ doubleWindowMs: z.number().default(8e3),
73
+ heartbeatMs: z.number().default(15e3),
74
+ disconnectAfterMs: z.number().default(6e4),
75
+ retainClosedRoomsHours: z.number().default(24),
76
+ inviteTtlHours: z.number().default(24),
77
+ routePrefix: z.string().default("/doudizhu")
78
+ });
79
+ function resolveConfig(config) {
80
+ const welcomeAtoms = parseWelcome(config.welcomeAtoms ?? DEFAULT_WELCOME_ATOMS);
81
+ const defaultStakeM = config.defaultStakeM ?? DEFAULT_STAKE_M;
82
+ const defaultMaxMultiplier = config.defaultMaxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
83
+ return {
84
+ enabled: config.enabled !== false,
85
+ welcomeAtoms,
86
+ defaultStakeM,
87
+ defaultMaxMultiplier,
88
+ defaultSeatCount: config.defaultSeatCount === 4 ? 4 : 3,
89
+ defaultLaiZi: config.defaultLaiZi === true,
90
+ publicBaseUrl: (config.publicBaseUrl ?? "").replace(/\/+$/, ""),
91
+ maxRooms: config.maxRooms ?? 32,
92
+ allowSpectators: config.allowSpectators !== false,
93
+ spectatorCardCounter: config.spectatorCardCounter === true,
94
+ houseEnabled: false,
95
+ reconnectWindowMs: config.reconnectWindowMs ?? 12e4,
96
+ turnTimeoutMs: config.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
97
+ doubleWindowMs: config.doubleWindowMs ?? 8e3,
98
+ heartbeatMs: config.heartbeatMs ?? 15e3,
99
+ disconnectAfterMs: config.disconnectAfterMs ?? 6e4,
100
+ retainClosedRoomsHours: config.retainClosedRoomsHours ?? 24,
101
+ inviteTtlHours: config.inviteTtlHours ?? 24,
102
+ routePrefix: normalizePrefix(config.routePrefix ?? "/doudizhu")
103
+ };
104
+ }
105
+ function parseWelcome(value) {
106
+ if (!/^\d+$/.test(value)) throw new Error("welcomeAtoms must be a non-negative decimal string");
107
+ return BigInt(value);
108
+ }
109
+ function validatePublicBaseUrl(value) {
110
+ if (value === "") return "";
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(value);
114
+ } catch {
115
+ throw new Error("publicBaseUrl must be an absolute http(s) origin");
116
+ }
117
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("publicBaseUrl must be http or https");
118
+ if (parsed.origin !== value.replace(/\/+$/, "")) throw new Error("publicBaseUrl must be an origin with no path, query, or trailing slash");
119
+ return parsed.origin;
120
+ }
121
+ function assertCreateEconomy(welcomeAtoms, stakeM, maxMultiplier, seatCount = 3) {
122
+ if (!STAKE_LADDER_M.includes(stakeM)) throw new Error(`stakeM must be one of ${STAKE_LADDER_M.join(",")}`);
123
+ if (!MAX_MULTIPLIER_OPTIONS.includes(maxMultiplier)) throw new Error(`maxMultiplier must be one of ${MAX_MULTIPLIER_OPTIONS.join(",")}`);
124
+ if (seatCount !== 3 && seatCount !== 4) throw new Error("seatCount must be 3 or 4");
125
+ if (welcomeAtoms < seatCapAtoms(BigInt(stakeM) * 1000000n, maxMultiplier, seatCount)) throw failWelcome();
126
+ }
127
+ function failWelcome() {
128
+ return Object.assign(/* @__PURE__ */ new Error("welcome-below-seatcap"), {
129
+ name: "welcome-below-seatcap",
130
+ code: "insufficient"
131
+ });
132
+ }
133
+ function normalizePrefix(prefix) {
134
+ if (!prefix.startsWith("/")) return `/${prefix.replace(/\/+$/, "")}`;
135
+ return prefix.replace(/\/+$/, "") || "/doudizhu";
136
+ }
137
+
138
+ //#endregion
139
+ //#region src/crypto.ts
140
+ function randomId(prefix) {
141
+ return `${prefix}_${randomBytes(10).toString("hex")}`;
142
+ }
143
+ function randomRoomCode() {
144
+ return (randomBytes(4).readUInt32BE(0) % 1e6).toString().padStart(6, "0");
145
+ }
146
+ function randomToken() {
147
+ return randomBytes(32).toString("base64url").replace(/=+$/, "");
148
+ }
149
+ function sha256Hex(value) {
150
+ return createHash("sha256").update(value).digest("hex");
151
+ }
152
+ function inviteHash(roomSecret, domain, token) {
153
+ return sha256Hex(`${roomSecret}|${domain}|${token}`);
154
+ }
155
+ function cookieValue() {
156
+ return randomBytes(24).toString("base64url");
157
+ }
158
+ function nowIso(date = /* @__PURE__ */ new Date()) {
159
+ return date.toISOString();
160
+ }
161
+ function addHours(hours, date = /* @__PURE__ */ new Date()) {
162
+ return new Date(date.getTime() + hours * 36e5).toISOString();
163
+ }
164
+
165
+ //#endregion
166
+ //#region src/types.ts
167
+ function asTokenAtomString(value) {
168
+ return String(value);
169
+ }
170
+ function parseAtoms(value) {
171
+ if (!/^-?\d+$/.test(value)) throw new Error(`invalid atom string: ${value}`);
172
+ return BigInt(value);
173
+ }
174
+ function asCardId(value) {
175
+ return value;
176
+ }
177
+ function asPlayerId(value) {
178
+ return value;
179
+ }
180
+ function asRoomId(value) {
181
+ return value;
182
+ }
183
+ function asHandId(value) {
184
+ return value;
185
+ }
186
+ function asGrantId(value) {
187
+ return value;
188
+ }
189
+ function asSettlementId(value) {
190
+ return value;
191
+ }
192
+ function asEntryId(value) {
193
+ return value;
194
+ }
195
+ function nextSeat(seat, count = 3) {
196
+ return (seat + 1) % count;
197
+ }
198
+ function prevSeat(seat, count = 3) {
199
+ return (seat + count - 1) % count;
200
+ }
201
+ function decksFor(count) {
202
+ return count === 3 ? 1 : 2;
203
+ }
204
+ function holeCount(count) {
205
+ return count === 3 ? 3 : 8;
206
+ }
207
+ function dealtHandSize(count) {
208
+ return count === 3 ? 17 : 25;
209
+ }
210
+ function landlordHandSize(count) {
211
+ return dealtHandSize(count) + holeCount(count);
212
+ }
213
+
214
+ //#endregion
215
+ //#region src/engine/cards.ts
216
+ const SUITS = [
217
+ "S",
218
+ "H",
219
+ "C",
220
+ "D"
221
+ ];
222
+ const RANK_ORDER = [
223
+ "3",
224
+ "4",
225
+ "5",
226
+ "6",
227
+ "7",
228
+ "8",
229
+ "9",
230
+ "10",
231
+ "J",
232
+ "Q",
233
+ "K",
234
+ "A",
235
+ "2",
236
+ "BJ",
237
+ "RJ"
238
+ ];
239
+ const RANK_VALUE = Object.fromEntries(RANK_ORDER.map((rank, index) => [rank, index]));
240
+ function cardBase(card) {
241
+ return String(card).split("~")[0] ?? String(card);
242
+ }
243
+ function rankOf(card) {
244
+ const raw = cardBase(card);
245
+ if (raw === "BJ" || raw === "RJ") return raw;
246
+ if (raw.startsWith("S") || raw.startsWith("H") || raw.startsWith("C") || raw.startsWith("D")) return raw.slice(1);
247
+ throw new Error(`invalid card: ${raw}`);
248
+ }
249
+ function rankValue(rank) {
250
+ const value = RANK_VALUE[rank];
251
+ if (value === void 0) throw new Error(`invalid rank: ${rank}`);
252
+ return value;
253
+ }
254
+ function cardValue(card) {
255
+ return rankValue(rankOf(card));
256
+ }
257
+ function compareCards(a, b) {
258
+ const byRank = cardValue(a) - cardValue(b);
259
+ if (byRank !== 0) return byRank;
260
+ return a.localeCompare(b);
261
+ }
262
+ function sortCards(cards) {
263
+ return [...cards].sort(compareCards);
264
+ }
265
+ function fullDeck(copy = 0) {
266
+ const suffix = copy === 0 ? "" : `~${copy}`;
267
+ const cards = [];
268
+ for (const suit of SUITS) for (const rank of RANK_ORDER) {
269
+ if (rank === "BJ" || rank === "RJ") continue;
270
+ cards.push(asCardId(`${suit}${rank}${suffix}`));
271
+ }
272
+ cards.push(asCardId(`BJ${suffix}`), asCardId(`RJ${suffix}`));
273
+ return cards;
274
+ }
275
+ function stackedDeck(decks) {
276
+ const cards = [];
277
+ for (let copy = 0; copy < decks; copy += 1) cards.push(...fullDeck(copy));
278
+ return cards;
279
+ }
280
+ const WILDABLE_RANKS = RANK_ORDER.filter((rank) => rank !== "BJ" && rank !== "RJ");
281
+ function isJokerRank(rank) {
282
+ return rank === "BJ" || rank === "RJ";
283
+ }
284
+ function isWildCard(card, wildRanks) {
285
+ if (wildRanks.length === 0) return false;
286
+ const rank = rankOf(card);
287
+ return !isJokerRank(rank) && wildRanks.includes(rank);
288
+ }
289
+ function shuffle(items, random) {
290
+ const next = [...items];
291
+ for (let i = next.length - 1; i > 0; i -= 1) {
292
+ const j = Math.floor(random() * (i + 1));
293
+ const a = next[i];
294
+ const b = next[j];
295
+ if (a === void 0 || b === void 0) continue;
296
+ next[i] = b;
297
+ next[j] = a;
298
+ }
299
+ return next;
300
+ }
301
+ function countRanks(cards) {
302
+ const groups = /* @__PURE__ */ new Map();
303
+ for (const card of cards) {
304
+ const rank = rankOf(card);
305
+ const bucket = groups.get(rank);
306
+ if (bucket) bucket.push(card);
307
+ else groups.set(rank, [card]);
308
+ }
309
+ return groups;
310
+ }
311
+ function isNaturalSeqRank(rank) {
312
+ return rank !== "2" && rank !== "BJ" && rank !== "RJ";
313
+ }
314
+
315
+ //#endregion
316
+ //#region src/engine/random.ts
317
+ function cryptoUnit() {
318
+ return randomBytes(4).readUInt32BE(0) / 4294967296;
319
+ }
320
+
321
+ //#endregion
322
+ //#region src/engine/deal.ts
323
+ function dealCards(seatCount = 3, random = cryptoUnit) {
324
+ const deck = shuffle(stackedDeck(seatCount === 3 ? 1 : 2), random);
325
+ const hole = holeCount(seatCount);
326
+ const per = (deck.length - hole) / seatCount;
327
+ return {
328
+ hands: Array.from({ length: seatCount }, (_, index) => deck.slice(index * per, (index + 1) * per).map(asCardId)),
329
+ bottom: deck.slice(seatCount * per, seatCount * per + hole).map(asCardId)
330
+ };
331
+ }
332
+ function giveBottom(hands, bottom, landlord) {
333
+ return hands.map((hand, index) => index === landlord ? [...hand, ...bottom] : [...hand]);
334
+ }
335
+ function randomSeat(count = 3, random = cryptoUnit) {
336
+ return Math.floor(random() * count);
337
+ }
338
+ function pickLaiZiRanks(count, random = cryptoUnit, exclude = []) {
339
+ const pool = WILDABLE_RANKS.filter((rank) => !exclude.includes(rank));
340
+ const picked = [];
341
+ const available = [...pool];
342
+ for (let i = 0; i < count && available.length > 0; i += 1) {
343
+ const index = Math.floor(random() * available.length);
344
+ const rank = available.splice(index, 1)[0];
345
+ if (rank) picked.push(rank);
346
+ }
347
+ return picked;
348
+ }
349
+
350
+ //#endregion
351
+ //#region src/engine/hands.ts
352
+ function classify(cards, laiZiRanks = []) {
353
+ if (laiZiRanks.length === 0) return classifyNatural(cards);
354
+ const wilds = [];
355
+ const naturals = [];
356
+ for (const card of cards) if (isWildCard(card, laiZiRanks)) wilds.push(card);
357
+ else naturals.push(card);
358
+ if (wilds.length === 0) return classifyNatural(cards);
359
+ return classifyWithWilds(cards, naturals, wilds.length);
360
+ }
361
+ function classifyNatural(cards) {
362
+ const sorted = sortCards(cards);
363
+ if (sorted.length === 0) return null;
364
+ const ranks = countRanks(sorted);
365
+ const entries = [...ranks.entries()].sort((a, b) => rankValue(a[0]) - rankValue(b[0]));
366
+ if (jokerCount(ranks) === sorted.length && sorted.length >= 2) return {
367
+ type: "rocket",
368
+ cards: sorted,
369
+ key: rankValue("RJ"),
370
+ length: sorted.length,
371
+ wingLength: 0
372
+ };
373
+ if (sorted.length === 1) return {
374
+ type: "solo",
375
+ cards: sorted,
376
+ key: cardValue(sorted[0]),
377
+ length: 1,
378
+ wingLength: 0
379
+ };
380
+ if (sorted.length === 2 && entries.length === 1) return {
381
+ type: "pair",
382
+ cards: sorted,
383
+ key: rankValue(entries[0][0]),
384
+ length: 2,
385
+ wingLength: 0
386
+ };
387
+ if (sorted.length === 3 && entries.length === 1) return {
388
+ type: "trio",
389
+ cards: sorted,
390
+ key: rankValue(entries[0][0]),
391
+ length: 3,
392
+ wingLength: 0
393
+ };
394
+ if (sorted.length >= 4 && entries.length === 1) return {
395
+ type: "bomb",
396
+ cards: sorted,
397
+ key: rankValue(entries[0][0]),
398
+ length: sorted.length,
399
+ wingLength: 0
400
+ };
401
+ const trioSolo = classifyTrioSolo(sorted, entries);
402
+ if (trioSolo) return trioSolo;
403
+ const trioPair = classifyTrioPair(sorted, entries);
404
+ if (trioPair) return trioPair;
405
+ const seq = classifySeq(sorted, entries);
406
+ if (seq) return seq;
407
+ const seqPair = classifySeqPair(sorted, entries);
408
+ if (seqPair) return seqPair;
409
+ const plane = classifyBarePlane(sorted, entries);
410
+ if (plane) return plane;
411
+ const planePair = classifyPlanePair(sorted, entries);
412
+ if (planePair) return planePair;
413
+ const planeSolo = classifyPlaneSolo(sorted, entries);
414
+ if (planeSolo) return planeSolo;
415
+ const fourDualPair = classifyFourDualPair(sorted, entries);
416
+ if (fourDualPair) return fourDualPair;
417
+ const fourDualSolo = classifyFourDualSolo(sorted, entries);
418
+ if (fourDualSolo) return fourDualSolo;
419
+ return null;
420
+ }
421
+ function beats(prev, next, decks = 1) {
422
+ const nextBomb = next.type === "bomb" || next.type === "rocket";
423
+ const prevBomb = prev.type === "bomb" || prev.type === "rocket";
424
+ if (next.type === "rocket" && decks === 1) return prev.type !== "rocket";
425
+ if (prev.type === "rocket" && decks === 1) return false;
426
+ if (nextBomb && prevBomb) {
427
+ if (next.length !== prev.length) return next.length > prev.length;
428
+ if (!!next.wild !== !!prev.wild) return !next.wild && !!prev.wild;
429
+ if (next.type !== prev.type) return next.type === "rocket";
430
+ return next.key > prev.key;
431
+ }
432
+ if (nextBomb) return true;
433
+ if (prevBomb) return false;
434
+ if (next.type !== prev.type || next.length !== prev.length || next.wingLength !== prev.wingLength) return false;
435
+ return next.key > prev.key;
436
+ }
437
+ function enumerateLegal(hand, lead, laiZiRanks = [], decks = 1) {
438
+ const sorted = sortCards(hand);
439
+ const combos = [];
440
+ const seen = /* @__PURE__ */ new Set();
441
+ const push = (type, cards) => {
442
+ const key = `${type}:${sortCards(cards).join(",")}`;
443
+ if (seen.has(key)) return;
444
+ seen.add(key);
445
+ combos.push({
446
+ type,
447
+ cards: sortCards(cards)
448
+ });
449
+ };
450
+ if (lead === null) {
451
+ for (const subset of generateCandidates(sorted)) {
452
+ const classified = classify(subset, laiZiRanks);
453
+ if (classified) push(classified.type, classified.cards);
454
+ }
455
+ return capCombos(combos);
456
+ }
457
+ if (!(lead.type === "rocket" && decks === 1)) {
458
+ for (const bomb of findBombs(sorted, laiZiRanks)) {
459
+ const classified = classify(bomb, laiZiRanks);
460
+ if (classified && beats(lead, classified, decks)) push(classified.type, classified.cards);
461
+ }
462
+ const rocket = sorted.filter((card) => isJokerRank(rankOf(card)));
463
+ if (rocket.length >= 2) push("rocket", rocket);
464
+ }
465
+ for (const subset of generateFollowCandidates(sorted, lead)) {
466
+ const classified = classify(subset, laiZiRanks);
467
+ if (classified && beats(lead, classified, decks)) push(classified.type, classified.cards);
468
+ }
469
+ return capCombos(combos);
470
+ }
471
+ function smallestSolo(hand) {
472
+ return sortCards(hand)[0] ?? null;
473
+ }
474
+ function classifyTrioSolo(cards, entries) {
475
+ if (cards.length !== 4) return null;
476
+ const trio = entries.find(([, group]) => group.length === 3);
477
+ const kicker = entries.find(([, group]) => group.length === 1);
478
+ if (!trio || !kicker) return null;
479
+ if (kicker[0] === "BJ" && entries.some(([rank]) => rank === "RJ")) return null;
480
+ if (kicker[0] === "RJ" && entries.some(([rank]) => rank === "BJ")) return null;
481
+ return {
482
+ type: "trioSolo",
483
+ cards,
484
+ key: rankValue(trio[0]),
485
+ length: 4,
486
+ wingLength: 1
487
+ };
488
+ }
489
+ function classifyTrioPair(cards, entries) {
490
+ if (cards.length !== 5) return null;
491
+ const trio = entries.find(([, group]) => group.length === 3);
492
+ const pair = entries.find(([, group]) => group.length === 2);
493
+ if (!trio || !pair || pair[0] === trio[0]) return null;
494
+ return {
495
+ type: "trioPair",
496
+ cards,
497
+ key: rankValue(trio[0]),
498
+ length: 5,
499
+ wingLength: 2
500
+ };
501
+ }
502
+ function classifySeq(cards, entries) {
503
+ if (cards.length < 5) return null;
504
+ if (!entries.every(([, group]) => group.length === 1)) return null;
505
+ if (!isConsecutive(entries.map(([rank]) => rank), 1)) return null;
506
+ return {
507
+ type: "seq",
508
+ cards,
509
+ key: rankValue(entries[entries.length - 1][0]),
510
+ length: cards.length,
511
+ wingLength: 0
512
+ };
513
+ }
514
+ function classifySeqPair(cards, entries) {
515
+ if (cards.length < 6 || cards.length % 2 !== 0) return null;
516
+ if (!entries.every(([, group]) => group.length === 2)) return null;
517
+ if (!isConsecutive(entries.map(([rank]) => rank), 1)) return null;
518
+ if (entries.length < 3) return null;
519
+ return {
520
+ type: "seqPair",
521
+ cards,
522
+ key: rankValue(entries[entries.length - 1][0]),
523
+ length: cards.length,
524
+ wingLength: 0
525
+ };
526
+ }
527
+ function classifyBarePlane(cards, entries) {
528
+ const trios = entries.filter(([, group]) => group.length === 3).map(([rank]) => rank);
529
+ if (trios.length < 2) return null;
530
+ if (entries.some(([, group]) => group.length !== 3)) return null;
531
+ if (!isConsecutive(trios, 1)) return null;
532
+ return {
533
+ type: "plane",
534
+ cards,
535
+ key: rankValue(trios[trios.length - 1]),
536
+ length: cards.length,
537
+ wingLength: 0
538
+ };
539
+ }
540
+ function classifyPlaneSolo(cards, entries = [...countRanks(cards).entries()].sort((a, b) => rankValue(a[0]) - rankValue(b[0]))) {
541
+ const available = entries.filter(([rank, group]) => group.length >= 3 && isNaturalSeqRank(rank) && rank !== "2").map(([rank]) => rank).sort((a, b) => rankValue(a) - rankValue(b));
542
+ const maxK = Math.min(available.length, Math.floor(cards.length / 4));
543
+ for (let k = maxK; k >= 2; k -= 1) {
544
+ if (cards.length !== k * 4) continue;
545
+ const found = [];
546
+ for (const body of consecutiveRuns(available, k)) {
547
+ const leftover = leftoverAfterBody(cards, body, 3);
548
+ if (leftover.length !== k) continue;
549
+ if (containsConsecutiveTrios(leftover)) continue;
550
+ found.push({
551
+ type: "planeSolo",
552
+ cards,
553
+ key: rankValue(body[body.length - 1]),
554
+ length: cards.length,
555
+ wingLength: k
556
+ });
557
+ }
558
+ if (found.length > 0) {
559
+ found.sort((a, b) => b.key - a.key);
560
+ return found[0] ?? null;
561
+ }
562
+ }
563
+ return null;
564
+ }
565
+ function classifyPlanePair(cards, entries) {
566
+ entries.filter(([, group]) => group.length >= 3 && isNaturalSeqRank(group[0] ? rankOf(group[0]) : "3") && rankOf(group[0]) !== "2");
567
+ const trioRanks = entries.filter(([rank, group]) => group.length >= 3 && isNaturalSeqRank(rank) && rank !== "2").map(([rank]) => rank).sort((a, b) => rankValue(a) - rankValue(b));
568
+ for (let k = Math.min(trioRanks.length, Math.floor(cards.length / 5)); k >= 2; k -= 1) {
569
+ if (cards.length !== k * 5) continue;
570
+ for (const body of consecutiveRuns(trioRanks, k)) if (pairWings(cards, body) === k) return {
571
+ type: "planePair",
572
+ cards,
573
+ key: rankValue(body[body.length - 1]),
574
+ length: cards.length,
575
+ wingLength: k
576
+ };
577
+ }
578
+ return null;
579
+ }
580
+ function classifyFourDualSolo(cards, entries) {
581
+ if (cards.length !== 6) return null;
582
+ const quad = entries.find(([, group]) => group.length === 4);
583
+ if (!quad) return null;
584
+ const leftovers = cards.filter((card) => rankOf(card) !== quad[0]);
585
+ if (leftovers.length !== 2) return null;
586
+ if (hasRocket(countRanks(leftovers))) return null;
587
+ return {
588
+ type: "fourDualSolo",
589
+ cards,
590
+ key: rankValue(quad[0]),
591
+ length: 6,
592
+ wingLength: 2
593
+ };
594
+ }
595
+ function classifyFourDualPair(cards, entries) {
596
+ if (cards.length !== 8) return null;
597
+ const quad = entries.find(([, group]) => group.length === 4);
598
+ if (!quad) return null;
599
+ const leftoverGroups = [...countRanks(leftoverAfterBody(cards, [quad[0]], 4)).entries()];
600
+ if (leftoverGroups.some(([rank, group]) => rank === quad[0] || group.length !== 2)) return null;
601
+ if (leftoverGroups.length !== 2) return null;
602
+ return {
603
+ type: "fourDualPair",
604
+ cards,
605
+ key: rankValue(quad[0]),
606
+ length: 8,
607
+ wingLength: 4
608
+ };
609
+ }
610
+ function hasRocket(ranks) {
611
+ return (ranks.get("BJ")?.length ?? 0) >= 1 && (ranks.get("RJ")?.length ?? 0) >= 1;
612
+ }
613
+ function jokerCount(ranks) {
614
+ return (ranks.get("BJ")?.length ?? 0) + (ranks.get("RJ")?.length ?? 0);
615
+ }
616
+ function classifyWithWilds(original, naturals, wildCount) {
617
+ if (wildCount <= 0) return classifyNatural(original);
618
+ const ranksToTry = wildAssignmentRanks(naturals, wildCount);
619
+ let best = null;
620
+ const choice = new Array(wildCount).fill(0);
621
+ const walk = () => {
622
+ const virtuals = choice.map((index) => asCardId(`S${ranksToTry[index]}`));
623
+ const result = classifyNatural([...naturals, ...virtuals]);
624
+ if (!result) return;
625
+ const hand = {
626
+ ...result,
627
+ cards: sortCards(original),
628
+ wild: true
629
+ };
630
+ if (!best || wildPreference(hand, best) > 0) best = hand;
631
+ };
632
+ const rec = (depth) => {
633
+ if (depth === wildCount) {
634
+ walk();
635
+ return;
636
+ }
637
+ for (let i = 0; i < ranksToTry.length; i += 1) {
638
+ choice[depth] = i;
639
+ rec(depth + 1);
640
+ }
641
+ };
642
+ rec(0);
643
+ return best;
644
+ }
645
+ function wildAssignmentRanks(naturals, wildCount) {
646
+ if (naturals.length === 0) return wildCount >= 4 ? ["2"] : [...WILDABLE_RANKS];
647
+ const present = new Set(naturals.map((card) => rankOf(card)));
648
+ if (wildCount >= 5) return WILDABLE_RANKS.filter((rank) => present.has(rank) || neighborsOf(present).has(rank));
649
+ return [...WILDABLE_RANKS];
650
+ }
651
+ function neighborsOf(present) {
652
+ const out = /* @__PURE__ */ new Set();
653
+ for (const rank of present) {
654
+ const value = rankValue(rank);
655
+ for (const candidate of WILDABLE_RANKS) if (Math.abs(rankValue(candidate) - value) === 1) out.add(candidate);
656
+ }
657
+ return out;
658
+ }
659
+ function wildPreference(next, prev) {
660
+ const bombScore = (hand) => {
661
+ if (hand.type === "rocket") return 200 + hand.length;
662
+ if (hand.type === "bomb") return 100 + hand.length;
663
+ return 0;
664
+ };
665
+ const byBomb = bombScore(next) - bombScore(prev);
666
+ if (byBomb !== 0) return byBomb;
667
+ if (next.length !== prev.length) return next.length - prev.length;
668
+ return next.key - prev.key;
669
+ }
670
+ function isConsecutive(ranks, step) {
671
+ if (ranks.some((rank) => !isNaturalSeqRank(rank))) return false;
672
+ for (let i = 1; i < ranks.length; i += 1) if (rankValue(ranks[i]) - rankValue(ranks[i - 1]) !== step) return false;
673
+ return ranks.length > 0;
674
+ }
675
+ function consecutiveRuns(ranks, length) {
676
+ const runs = [];
677
+ for (let i = 0; i + length <= ranks.length; i += 1) {
678
+ const slice = ranks.slice(i, i + length);
679
+ if (isConsecutive(slice, 1)) runs.push(slice);
680
+ }
681
+ return runs;
682
+ }
683
+ function containsConsecutiveTrios(cards) {
684
+ return consecutiveRuns([...countRanks(cards).entries()].filter(([, group]) => group.length >= 3).map(([rank]) => rank).filter((rank) => isNaturalSeqRank(rank) && rank !== "2").sort((a, b) => rankValue(a) - rankValue(b)), 2).length > 0;
685
+ }
686
+ function pairWings(cards, body) {
687
+ const groups = countRanks(leftoverAfterBody(cards, body, 3));
688
+ if ([...groups.values()].some((group) => group.length !== 2 && group.length !== 4)) return null;
689
+ let pairs = 0;
690
+ for (const [rank, group] of groups) {
691
+ if (body.includes(rank)) return null;
692
+ if (group.length === 2) pairs += 1;
693
+ else if (group.length === 4) pairs += 2;
694
+ else return null;
695
+ }
696
+ return pairs;
697
+ }
698
+ function leftoverAfterBody(cards, body, take) {
699
+ const used = /* @__PURE__ */ new Map();
700
+ for (const rank of body) used.set(rank, take);
701
+ const leftover = [];
702
+ for (const card of cards) {
703
+ const rank = rankOf(card);
704
+ const remaining = used.get(rank) ?? 0;
705
+ if (remaining > 0) used.set(rank, remaining - 1);
706
+ else leftover.push(card);
707
+ }
708
+ return leftover;
709
+ }
710
+ function findBombs(hand, laiZiRanks = []) {
711
+ const bombs = [];
712
+ const ranks = countRanks(hand);
713
+ const wilds = laiZiRanks.length === 0 ? [] : hand.filter((card) => isWildCard(card, laiZiRanks));
714
+ for (const [rank, group] of ranks) {
715
+ if (isJokerRank(rank)) continue;
716
+ const natural = laiZiRanks.includes(rank) ? group.filter((card) => !isWildCard(card, laiZiRanks)) : group;
717
+ const total = natural.length + wilds.length;
718
+ if (natural.length + (laiZiRanks.includes(rank) ? 0 : 0) >= 4 || total >= 4) {
719
+ if (natural.length >= 4) bombs.push(natural.slice(0, Math.max(4, natural.length)));
720
+ if (wilds.length > 0 && total >= 4) bombs.push([...natural, ...wilds].slice(0, total));
721
+ }
722
+ }
723
+ const jokers = hand.filter((card) => isJokerRank(rankOf(card)));
724
+ if (jokers.length >= 2) bombs.push(jokers);
725
+ return bombs;
726
+ }
727
+ function generateCandidates(hand) {
728
+ const out = [];
729
+ const ranks = countRanks(hand);
730
+ for (const group of ranks.values()) for (let n = 1; n <= group.length; n += 1) out.push(group.slice(0, n));
731
+ const rocket = hand.filter((card) => isJokerRank(rankOf(card)));
732
+ if (rocket.length === 2) out.push(rocket);
733
+ appendSeqs(out, ranks, 1, 5);
734
+ appendSeqs(out, ranks, 2, 3);
735
+ appendPlanes(out, hand, ranks);
736
+ appendKickers(out, hand, ranks);
737
+ return out;
738
+ }
739
+ function generateFollowCandidates(hand, lead) {
740
+ return generateCandidates(hand).filter((subset) => subset.length === lead.length);
741
+ }
742
+ function appendSeqs(out, ranks, copies, minLen) {
743
+ const usable = [...ranks.entries()].filter(([rank, group]) => isNaturalSeqRank(rank) && group.length >= copies).sort((a, b) => rankValue(a[0]) - rankValue(b[0]));
744
+ for (let len = minLen; len <= usable.length; len += 1) for (let i = 0; i + len <= usable.length; i += 1) {
745
+ const slice = usable.slice(i, i + len);
746
+ if (!isConsecutive(slice.map(([rank]) => rank), 1)) continue;
747
+ out.push(slice.flatMap(([, group]) => group.slice(0, copies)));
748
+ }
749
+ }
750
+ function appendPlanes(out, hand, ranks) {
751
+ const trioRanks = [...ranks.entries()].filter(([rank, group]) => group.length >= 3 && isNaturalSeqRank(rank) && rank !== "2").map(([rank]) => rank).sort((a, b) => rankValue(a) - rankValue(b));
752
+ for (let k = 2; k <= trioRanks.length; k += 1) for (const body of consecutiveRuns(trioRanks, k)) {
753
+ const bodyCards = leftoverInverse(hand, body, 3);
754
+ out.push(bodyCards);
755
+ const rest = leftoverAfterBody(hand, body, 3);
756
+ for (const wings of combinations(rest, k)) out.push([...bodyCards, ...wings]);
757
+ const pairs = pairCombinations(rest, k);
758
+ for (const wings of pairs) out.push([...bodyCards, ...wings]);
759
+ }
760
+ }
761
+ function leftoverInverse(hand, body, take) {
762
+ const used = /* @__PURE__ */ new Map();
763
+ for (const rank of body) used.set(rank, take);
764
+ const taken = [];
765
+ for (const card of hand) {
766
+ const rank = rankOf(card);
767
+ const remaining = used.get(rank) ?? 0;
768
+ if (remaining > 0) {
769
+ used.set(rank, remaining - 1);
770
+ taken.push(card);
771
+ }
772
+ }
773
+ return taken;
774
+ }
775
+ function appendKickers(out, hand, ranks) {
776
+ for (const [rank, group] of ranks) {
777
+ if (group.length >= 3) {
778
+ const trio = group.slice(0, 3);
779
+ for (const card of hand) {
780
+ if (rankOf(card) === rank) continue;
781
+ out.push([...trio, card]);
782
+ }
783
+ for (const [other, otherGroup] of ranks) {
784
+ if (other === rank || otherGroup.length < 2) continue;
785
+ out.push([...trio, ...otherGroup.slice(0, 2)]);
786
+ }
787
+ }
788
+ if (group.length === 4) {
789
+ const rest = hand.filter((card) => rankOf(card) !== rank);
790
+ for (const wings of combinations(rest, 2)) out.push([...group, ...wings]);
791
+ const pairRanks = [...ranks.entries()].filter(([r, g]) => r !== rank && g.length >= 2);
792
+ for (let i = 0; i < pairRanks.length; i += 1) for (let j = i + 1; j < pairRanks.length; j += 1) out.push([
793
+ ...group,
794
+ ...pairRanks[i][1].slice(0, 2),
795
+ ...pairRanks[j][1].slice(0, 2)
796
+ ]);
797
+ }
798
+ }
799
+ }
800
+ function combinations(items, k) {
801
+ const out = [];
802
+ const walk = (start, acc) => {
803
+ if (acc.length === k) {
804
+ out.push([...acc]);
805
+ return;
806
+ }
807
+ for (let i = start; i < items.length; i += 1) {
808
+ acc.push(items[i]);
809
+ walk(i + 1, acc);
810
+ acc.pop();
811
+ }
812
+ };
813
+ if (k <= items.length) walk(0, []);
814
+ return out;
815
+ }
816
+ function pairCombinations(items, k) {
817
+ const groups = [...countRanks(items).entries()].filter(([, group]) => group.length >= 2);
818
+ const out = [];
819
+ const walk = (start, acc, taken) => {
820
+ if (taken === k) {
821
+ out.push([...acc]);
822
+ return;
823
+ }
824
+ for (let i = start; i < groups.length; i += 1) {
825
+ const group = groups[i][1];
826
+ acc.push(...group.slice(0, 2));
827
+ walk(i + 1, acc, taken + 1);
828
+ acc.splice(acc.length - 2, 2);
829
+ if (group.length >= 4 && taken + 2 <= k) {
830
+ acc.push(...group.slice(0, 4));
831
+ walk(i + 1, acc, taken + 2);
832
+ acc.splice(acc.length - 4, 4);
833
+ }
834
+ }
835
+ };
836
+ walk(0, [], 0);
837
+ return out;
838
+ }
839
+ function capCombos(combos) {
840
+ if (combos.length <= 200) return combos;
841
+ return combos.slice(0, 200);
842
+ }
843
+
844
+ //#endregion
845
+ //#region src/engine/play.ts
846
+ function createHand(roomId, dealerSeat, seq, options = {}) {
847
+ const seatCount = options.seatCount ?? 3;
848
+ const laiZi = options.laiZi === true;
849
+ const deal = dealCards(seatCount);
850
+ const start = dealerSeat ?? randomSeat(seatCount);
851
+ const laiZiRanks = laiZi ? pickLaiZiRanks(2) : [];
852
+ return {
853
+ phase: "bidding",
854
+ seatCount,
855
+ laiZi,
856
+ hand: {
857
+ handId: asHandId(`hand_${roomId}_${seq}`),
858
+ roomId,
859
+ dealerSeat: start,
860
+ bottom: [...deal.bottom],
861
+ hands: deal.hands.map((cards) => [...cards]),
862
+ bid: 0,
863
+ landlordSeat: null,
864
+ farmerDoubledBySeat: {},
865
+ landlordReDouble: 1,
866
+ mingPaiBySeat: {},
867
+ mingPaiMult: 1,
868
+ bombCount: 0,
869
+ rocketCount: 0,
870
+ spring: "none",
871
+ table: [],
872
+ turnSeat: start,
873
+ leadSeat: start,
874
+ laiZiRanks
875
+ },
876
+ firstLeadSeq: null,
877
+ successfulLandlordPlaysAfterLead: 0,
878
+ farmerPlayed: Array.from({ length: seatCount }, () => false),
879
+ bids: Array.from({ length: seatCount }, () => null),
880
+ bidTurn: start,
881
+ called: false,
882
+ actedBid: Array.from({ length: seatCount }, () => false),
883
+ refusedCall: Array.from({ length: seatCount }, () => false),
884
+ robBackDone: false,
885
+ highestBidder: null,
886
+ passesInRow: 0,
887
+ doubleDeadlineAt: 0,
888
+ farmerDoubleDone: Array.from({ length: seatCount }, () => false),
889
+ landlordDoubleDone: false
890
+ };
891
+ }
892
+ function applyBid(state, seat, action) {
893
+ if (state.phase !== "bidding") return {
894
+ ok: false,
895
+ code: "phase",
896
+ reason: "not bidding"
897
+ };
898
+ if (state.bidTurn !== seat) return {
899
+ ok: false,
900
+ code: "not-your-turn",
901
+ reason: "not your bid"
902
+ };
903
+ const robBack = state.actedBid[seat] === true;
904
+ if (robBack && action === "call") return {
905
+ ok: false,
906
+ code: "illegal",
907
+ reason: "already called"
908
+ };
909
+ if (action === "call") {
910
+ if (state.called) return {
911
+ ok: false,
912
+ code: "illegal",
913
+ reason: "already called"
914
+ };
915
+ state.called = true;
916
+ state.highestBidder = seat;
917
+ state.hand.bid = 1;
918
+ if (state.refusedCall.filter(Boolean).length === state.seatCount - 1) {
919
+ state.bids[seat] = action;
920
+ state.actedBid[seat] = true;
921
+ lockLandlord(state, seat, 1);
922
+ return {
923
+ ok: true,
924
+ play: dummyPlay(seat),
925
+ emptied: false
926
+ };
927
+ }
928
+ } else if (action === "rob") {
929
+ if (!state.called) return {
930
+ ok: false,
931
+ code: "illegal",
932
+ reason: "nobody called yet"
933
+ };
934
+ if (state.refusedCall[seat]) return {
935
+ ok: false,
936
+ code: "illegal",
937
+ reason: "passed call cannot rob"
938
+ };
939
+ state.highestBidder = seat;
940
+ state.hand.bid = Math.max(1, state.hand.bid) * 2;
941
+ } else if (!state.called) state.refusedCall[seat] = true;
942
+ state.bids[seat] = action;
943
+ if (robBack) state.robBackDone = true;
944
+ else state.actedBid[seat] = true;
945
+ const next = nextBidder(state, seat);
946
+ if (next === null) {
947
+ if (!state.called || state.highestBidder === null || state.hand.bid === 0) {
948
+ state.phase = "redeal";
949
+ return {
950
+ ok: true,
951
+ play: dummyPlay(seat),
952
+ emptied: false
953
+ };
954
+ }
955
+ lockLandlord(state, state.highestBidder, state.hand.bid);
956
+ return {
957
+ ok: true,
958
+ play: dummyPlay(seat),
959
+ emptied: false
960
+ };
961
+ }
962
+ state.bidTurn = next;
963
+ return {
964
+ ok: true,
965
+ play: dummyPlay(seat),
966
+ emptied: false
967
+ };
968
+ }
969
+ function applyMingPai(state, seat) {
970
+ if (state.hand.mingPaiBySeat[seat]) return {
971
+ ok: false,
972
+ code: "illegal",
973
+ reason: "already ming pai"
974
+ };
975
+ if (state.phase === "bidding" && state.hand.landlordSeat === null) {
976
+ state.hand.mingPaiBySeat[seat] = true;
977
+ state.hand.mingPaiMult = Math.max(state.hand.mingPaiMult, 3);
978
+ if (!state.called && state.actedBid.every((acted) => !acted)) state.bidTurn = seat;
979
+ return {
980
+ ok: true,
981
+ play: dummyPlay(seat),
982
+ emptied: false
983
+ };
984
+ }
985
+ if (state.phase === "doubling" || state.phase === "playing") {
986
+ state.hand.mingPaiBySeat[seat] = true;
987
+ state.hand.mingPaiMult = Math.max(state.hand.mingPaiMult, 2);
988
+ return {
989
+ ok: true,
990
+ play: dummyPlay(seat),
991
+ emptied: false
992
+ };
993
+ }
994
+ return {
995
+ ok: false,
996
+ code: "phase",
997
+ reason: "cannot ming pai now"
998
+ };
999
+ }
1000
+ function applyDouble(state, seat, action, now = Date.now()) {
1001
+ if (state.phase !== "doubling") return {
1002
+ ok: false,
1003
+ code: "phase",
1004
+ reason: "not doubling"
1005
+ };
1006
+ if (now > state.doubleDeadlineAt) {
1007
+ finishDoubling(state);
1008
+ return {
1009
+ ok: false,
1010
+ code: "phase",
1011
+ reason: "double window closed"
1012
+ };
1013
+ }
1014
+ const landlord = state.hand.landlordSeat;
1015
+ if (landlord === null) return {
1016
+ ok: false,
1017
+ code: "phase",
1018
+ reason: "no landlord"
1019
+ };
1020
+ const factor = action === "reDouble" ? 4 : action === "double" ? 2 : 1;
1021
+ if (seat === landlord) {
1022
+ if (state.landlordDoubleDone) return {
1023
+ ok: false,
1024
+ code: "illegal",
1025
+ reason: "already answered"
1026
+ };
1027
+ state.hand.landlordReDouble = factor;
1028
+ state.landlordDoubleDone = true;
1029
+ if (farmersFinished(state)) finishDoubling(state);
1030
+ return {
1031
+ ok: true,
1032
+ play: dummyPlay(seat),
1033
+ emptied: false
1034
+ };
1035
+ }
1036
+ if (state.farmerDoubleDone[seat]) return {
1037
+ ok: false,
1038
+ code: "illegal",
1039
+ reason: "already answered"
1040
+ };
1041
+ state.farmerDoubleDone[seat] = true;
1042
+ if (factor > 1) state.hand.farmerDoubledBySeat[seat] = factor;
1043
+ if (farmersFinished(state) && state.landlordDoubleDone) finishDoubling(state);
1044
+ return {
1045
+ ok: true,
1046
+ play: dummyPlay(seat),
1047
+ emptied: false
1048
+ };
1049
+ }
1050
+ function expireDoubling(state, now = Date.now()) {
1051
+ if (state.phase !== "doubling") return;
1052
+ if (now >= state.doubleDeadlineAt) finishDoubling(state);
1053
+ }
1054
+ function applyPlay(state, seat, cards, seq, ts) {
1055
+ if (state.phase !== "playing") return {
1056
+ ok: false,
1057
+ code: "phase",
1058
+ reason: "not playing"
1059
+ };
1060
+ if (state.hand.turnSeat !== seat) return {
1061
+ ok: false,
1062
+ code: "not-your-turn",
1063
+ reason: "not your turn"
1064
+ };
1065
+ if (!containsAll(state.hand.hands[seat] ?? [], cards)) return {
1066
+ ok: false,
1067
+ code: "illegal",
1068
+ reason: "cards not in hand"
1069
+ };
1070
+ const classified = classify(cards, state.hand.laiZiRanks);
1071
+ if (!classified) return {
1072
+ ok: false,
1073
+ code: "illegal",
1074
+ reason: "unknown hand type"
1075
+ };
1076
+ const lead = currentLead(state);
1077
+ if (lead === null || state.hand.leadSeat === seat) return {
1078
+ ok: true,
1079
+ play: commitPlay(state, seat, classified, seq, ts),
1080
+ emptied: state.hand.hands[seat].length === 0
1081
+ };
1082
+ if (!beats(lead, classified, decksFor(state.seatCount))) return {
1083
+ ok: false,
1084
+ code: "illegal",
1085
+ reason: "does not beat lead"
1086
+ };
1087
+ return {
1088
+ ok: true,
1089
+ play: commitPlay(state, seat, classified, seq, ts),
1090
+ emptied: (state.hand.hands[seat] ?? []).length === 0
1091
+ };
1092
+ }
1093
+ function applyPass(state, seat, seq, ts) {
1094
+ if (state.phase !== "playing") return {
1095
+ ok: false,
1096
+ code: "phase",
1097
+ reason: "not playing"
1098
+ };
1099
+ if (state.hand.turnSeat !== seat) return {
1100
+ ok: false,
1101
+ code: "not-your-turn",
1102
+ reason: "not your turn"
1103
+ };
1104
+ if (state.hand.leadSeat === seat || currentLead(state) === null) return {
1105
+ ok: false,
1106
+ code: "illegal",
1107
+ reason: "cannot pass when leading"
1108
+ };
1109
+ const play = {
1110
+ seat,
1111
+ type: "pass",
1112
+ cards: [],
1113
+ seq,
1114
+ ts
1115
+ };
1116
+ state.hand.table.push(play);
1117
+ state.passesInRow += 1;
1118
+ if (state.passesInRow >= state.seatCount - 1) {
1119
+ state.hand.leadSeat = nextSeat(seat, state.seatCount);
1120
+ state.hand.turnSeat = state.hand.leadSeat;
1121
+ state.passesInRow = 0;
1122
+ } else state.hand.turnSeat = nextSeat(seat, state.seatCount);
1123
+ return {
1124
+ ok: true,
1125
+ play,
1126
+ emptied: false
1127
+ };
1128
+ }
1129
+ function autoTimeout(state, seq, ts) {
1130
+ if (state.phase !== "playing") return null;
1131
+ const seat = state.hand.turnSeat;
1132
+ if (state.hand.leadSeat === seat || currentLead(state) === null) {
1133
+ const card = smallestSolo(state.hand.hands[seat] ?? []);
1134
+ if (!card) return null;
1135
+ return applyPlay(state, seat, [card], seq, ts);
1136
+ }
1137
+ return applyPass(state, seat, seq, ts);
1138
+ }
1139
+ function legalFor(state, seat) {
1140
+ if (state.phase !== "playing" || state.hand.turnSeat !== seat) return {
1141
+ canPass: false,
1142
+ combos: []
1143
+ };
1144
+ const lead = currentLead(state);
1145
+ return {
1146
+ canPass: !(state.hand.leadSeat === seat || lead === null),
1147
+ combos: enumerateLegal(state.hand.hands[seat] ?? [], lead, state.hand.laiZiRanks, decksFor(state.seatCount))
1148
+ };
1149
+ }
1150
+ function finishHand(state, winnerSeat) {
1151
+ const landlord = state.hand.landlordSeat;
1152
+ if (landlord === null) return;
1153
+ const landlordEmptied = winnerSeat === landlord;
1154
+ const farmersNeverPlayed = state.hand.hands.map((_, seat) => seat).filter((seat) => seat !== landlord).every((seat) => !state.farmerPlayed[seat]);
1155
+ const spring = landlordEmptied && farmersNeverPlayed;
1156
+ const anti = !landlordEmptied && state.successfulLandlordPlaysAfterLead === 0;
1157
+ state.hand.spring = spring ? "spring" : anti ? "anti" : "none";
1158
+ state.phase = "settled";
1159
+ }
1160
+ function snapshotHand(state) {
1161
+ return {
1162
+ ...state.hand,
1163
+ hands: state.hand.hands.map((cards) => sortCards(cards)),
1164
+ bottom: [...state.hand.bottom],
1165
+ table: [...state.hand.table],
1166
+ farmerDoubledBySeat: { ...state.hand.farmerDoubledBySeat },
1167
+ mingPaiBySeat: { ...state.hand.mingPaiBySeat },
1168
+ laiZiRanks: [...state.hand.laiZiRanks]
1169
+ };
1170
+ }
1171
+ function nextBidder(state, from) {
1172
+ let seat = nextSeat(from, state.seatCount);
1173
+ for (let i = 0; i < state.seatCount; i += 1) {
1174
+ if (!state.actedBid[seat]) return seat;
1175
+ seat = nextSeat(seat, state.seatCount);
1176
+ }
1177
+ const caller = firstCaller(state);
1178
+ if (state.called && !state.robBackDone && caller !== null && state.highestBidder !== caller && !state.refusedCall[caller]) return caller;
1179
+ return null;
1180
+ }
1181
+ function firstCaller(state) {
1182
+ const index = state.bids.findIndex((bid) => bid === "call");
1183
+ return index >= 0 ? index : null;
1184
+ }
1185
+ function lockLandlord(state, seat, bid) {
1186
+ state.hand.landlordSeat = seat;
1187
+ state.hand.bid = bid;
1188
+ state.hand.hands = giveBottom(state.hand.hands, state.hand.bottom, seat);
1189
+ state.hand.turnSeat = seat;
1190
+ state.hand.leadSeat = seat;
1191
+ state.phase = "doubling";
1192
+ state.doubleDeadlineAt = Date.now() + 8e3;
1193
+ }
1194
+ function finishDoubling(state) {
1195
+ if (state.phase !== "doubling") return;
1196
+ state.phase = "playing";
1197
+ state.passesInRow = 0;
1198
+ }
1199
+ function farmersFinished(state) {
1200
+ const landlord = state.hand.landlordSeat;
1201
+ return state.hand.hands.every((_, index) => {
1202
+ const seat = index;
1203
+ return seat === landlord || state.farmerDoubleDone[seat];
1204
+ });
1205
+ }
1206
+ function currentLead(state) {
1207
+ for (let i = state.hand.table.length - 1; i >= 0; i -= 1) {
1208
+ const play = state.hand.table[i];
1209
+ if (!play || play.type === "pass") continue;
1210
+ return classify(play.cards, state.hand.laiZiRanks);
1211
+ }
1212
+ return null;
1213
+ }
1214
+ function commitPlay(state, seat, classified, seq, ts) {
1215
+ const play = {
1216
+ seat,
1217
+ type: classified.type,
1218
+ cards: classified.cards,
1219
+ seq,
1220
+ ts
1221
+ };
1222
+ const held = state.hand.hands[seat];
1223
+ if (!held) return play;
1224
+ removeCards(held, classified.cards);
1225
+ state.hand.table.push(play);
1226
+ if (classified.type === "bomb") state.hand.bombCount += 1;
1227
+ if (classified.type === "rocket") state.hand.rocketCount += 1;
1228
+ const landlord = state.hand.landlordSeat;
1229
+ if (landlord !== null && seat !== landlord) state.farmerPlayed[seat] = true;
1230
+ if (state.firstLeadSeq === null) state.firstLeadSeq = seq;
1231
+ else if (landlord !== null && seat === landlord) state.successfulLandlordPlaysAfterLead += 1;
1232
+ state.hand.leadSeat = seat;
1233
+ state.hand.turnSeat = nextSeat(seat, state.seatCount);
1234
+ state.passesInRow = 0;
1235
+ if ((state.hand.hands[seat] ?? []).length === 0) finishHand(state, seat);
1236
+ return play;
1237
+ }
1238
+ function containsAll(hand, cards) {
1239
+ const bag = /* @__PURE__ */ new Map();
1240
+ for (const card of hand) bag.set(card, (bag.get(card) ?? 0) + 1);
1241
+ for (const card of cards) {
1242
+ const count = bag.get(card) ?? 0;
1243
+ if (count === 0) return false;
1244
+ bag.set(card, count - 1);
1245
+ }
1246
+ return true;
1247
+ }
1248
+ function removeCards(hand, cards) {
1249
+ for (const card of cards) {
1250
+ const index = hand.indexOf(card);
1251
+ if (index >= 0) hand.splice(index, 1);
1252
+ }
1253
+ }
1254
+ function dummyPlay(seat) {
1255
+ return {
1256
+ seat,
1257
+ type: "pass",
1258
+ cards: [],
1259
+ seq: 0,
1260
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1261
+ };
1262
+ }
1263
+
1264
+ //#endregion
1265
+ //#region src/engine/score.ts
1266
+ function personalMult(value) {
1267
+ if (value === true) return 2n;
1268
+ if (typeof value === "number" && value > 1) return BigInt(value);
1269
+ return 1n;
1270
+ }
1271
+ function scoreHand(input) {
1272
+ const { hand, stakeAtoms, maxMultiplier, winner, playerIds } = input;
1273
+ if (hand.landlordSeat === null) throw new Error("cannot score without a landlord");
1274
+ if (hand.bid === 0) throw new Error("cannot score a void bid");
1275
+ const springFlag = hand.spring === "none" ? 0 : 1;
1276
+ const ming = hand.mingPaiMult > 1 ? hand.mingPaiMult : 1;
1277
+ const raw = Math.max(1, hand.bid) * 2 ** (hand.bombCount + hand.rocketCount + springFlag) * ming;
1278
+ const multiplier = Math.min(raw, maxMultiplier);
1279
+ const unitAtoms = stakeAtoms * BigInt(multiplier);
1280
+ const landlord = hand.landlordSeat;
1281
+ const landlordPersonal = personalMult(hand.landlordReDouble);
1282
+ const farmers = playerIds.map((_, index) => index).filter((seat) => seat !== landlord);
1283
+ const farmerPays = /* @__PURE__ */ new Map();
1284
+ for (const seat of farmers) farmerPays.set(seat, unitAtoms * personalMult(hand.farmerDoubledBySeat[seat]) * landlordPersonal);
1285
+ const sign = winner === "landlord" ? 1n : -1n;
1286
+ const landlordTotal = farmers.reduce((sum, seat) => sum + (farmerPays.get(seat) ?? 0n), 0n);
1287
+ const deltas = playerIds.map((playerId, index) => {
1288
+ const seat = index;
1289
+ if (seat === landlord) return {
1290
+ seat,
1291
+ playerId,
1292
+ atoms: asTokenAtomString(sign * landlordTotal)
1293
+ };
1294
+ return {
1295
+ seat,
1296
+ playerId,
1297
+ atoms: asTokenAtomString(-sign * (farmerPays.get(seat) ?? 0n))
1298
+ };
1299
+ });
1300
+ const formula = [
1301
+ `${formatM(stakeAtoms)} × min(叫抢${hand.bid} × 2^(${hand.bombCount}炸 + ${hand.rocketCount}王炸 + ${springFlag}春天) × 明牌${ming}, ${maxMultiplier})`,
1302
+ landlordPersonal > 1n ? `× 地主${landlordPersonal.toString()}` : "",
1303
+ `= ${formatM(unitAtoms)}`
1304
+ ].filter(Boolean).join(" ");
1305
+ return {
1306
+ settlementId: asSettlementId(`set_${hand.handId}`),
1307
+ handId: hand.handId,
1308
+ winner,
1309
+ unitAtoms: asTokenAtomString(unitAtoms),
1310
+ deltas,
1311
+ rakeAtoms: asTokenAtomString(0n),
1312
+ formula
1313
+ };
1314
+ }
1315
+
1316
+ //#endregion
1317
+ //#region src/identity/player.ts
1318
+ function newPlayerId() {
1319
+ return asPlayerId(randomId("pl"));
1320
+ }
1321
+ function sanitizeDisplayName(raw) {
1322
+ const trimmed = raw.trim().replace(/\s+/g, " ");
1323
+ if (trimmed.length === 0) return "玩家";
1324
+ return trimmed.slice(0, MAX_DISPLAY_NAME);
1325
+ }
1326
+ function sanitizeRoomTitle(raw, fallback = "好友局") {
1327
+ const trimmed = raw.trim().replace(/\s+/g, " ");
1328
+ if (trimmed.length === 0) return fallback;
1329
+ return trimmed.slice(0, MAX_ROOM_TITLE);
1330
+ }
1331
+ function sanitizeAvatarUrl(raw, routePrefix) {
1332
+ if (typeof raw !== "string" || raw.length === 0) return null;
1333
+ if (raw.startsWith(`${routePrefix}/api/avatars/`)) return raw;
1334
+ try {
1335
+ const url = new URL(raw);
1336
+ if (url.protocol !== "https:") return null;
1337
+ return url.toString();
1338
+ } catch {
1339
+ return null;
1340
+ }
1341
+ }
1342
+
1343
+ //#endregion
1344
+ //#region src/persist/schemas.ts
1345
+ const TokenAtomString = z$1.string().regex(/^-?\d+$/);
1346
+ const RoomRecordSchema = z$1.object({
1347
+ roomId: z$1.string(),
1348
+ roomCode: z$1.string(),
1349
+ title: z$1.string().default("好友局"),
1350
+ hostPlayerId: z$1.string(),
1351
+ phase: z$1.enum([
1352
+ "waiting",
1353
+ "dealing",
1354
+ "bidding",
1355
+ "doubling",
1356
+ "playing",
1357
+ "settling",
1358
+ "void",
1359
+ "closed"
1360
+ ]),
1361
+ stakeAtoms: TokenAtomString,
1362
+ maxMultiplier: z$1.number().int().positive(),
1363
+ seatCount: z$1.union([z$1.literal(3), z$1.literal(4)]).default(3),
1364
+ laiZi: z$1.boolean().default(false),
1365
+ inviteExpiresAt: z$1.string(),
1366
+ createdAt: z$1.string(),
1367
+ shareable: z$1.boolean(),
1368
+ roomSecret: z$1.string(),
1369
+ sitInviteHash: z$1.string(),
1370
+ watchInviteHash: z$1.string(),
1371
+ lastLandlordSeat: z$1.number().int().min(0).max(3).nullable()
1372
+ });
1373
+ const PlayerRecordSchema = z$1.object({
1374
+ playerId: z$1.string(),
1375
+ displayName: z$1.string(),
1376
+ avatarUrl: z$1.string().url().nullable(),
1377
+ availableAtoms: TokenAtomString,
1378
+ welcomeGranted: z$1.boolean(),
1379
+ createdAt: z$1.string()
1380
+ });
1381
+ const TokenGrantSchema = z$1.object({
1382
+ grantId: z$1.string(),
1383
+ playerId: z$1.string(),
1384
+ roomId: z$1.string(),
1385
+ maxExposureAtoms: TokenAtomString,
1386
+ issuedAt: z$1.string(),
1387
+ expiresAt: z$1.string(),
1388
+ source: z$1.literal("host-welcome"),
1389
+ issuedBy: z$1.literal("room-host")
1390
+ });
1391
+ const LedgerEntrySchema = z$1.object({
1392
+ entryId: z$1.string(),
1393
+ ts: z$1.string(),
1394
+ roomId: z$1.string().optional(),
1395
+ handId: z$1.string().optional(),
1396
+ settlementId: z$1.string().optional(),
1397
+ from: z$1.string(),
1398
+ to: z$1.string(),
1399
+ atoms: TokenAtomString,
1400
+ reason: z$1.enum([
1401
+ "welcome",
1402
+ "freeze",
1403
+ "unfreeze",
1404
+ "settle",
1405
+ "void"
1406
+ ]),
1407
+ prevHash: z$1.string(),
1408
+ hash: z$1.string()
1409
+ });
1410
+ const HandCheckpointSchema = z$1.object({
1411
+ handId: z$1.string(),
1412
+ roomId: z$1.string(),
1413
+ sealedHands: z$1.string(),
1414
+ publicJson: z$1.string(),
1415
+ seq: z$1.number().int()
1416
+ });
1417
+
1418
+ //#endregion
1419
+ //#region src/persist/domain.ts
1420
+ var MemoryKv = class {
1421
+ map = /* @__PURE__ */ new Map();
1422
+ get(key) {
1423
+ return this.map.get(key);
1424
+ }
1425
+ async put(key, value) {
1426
+ this.map.set(key, value);
1427
+ }
1428
+ async delete(key) {
1429
+ return this.map.delete(key);
1430
+ }
1431
+ entries() {
1432
+ return this.map.entries();
1433
+ }
1434
+ };
1435
+ function createMemoryDomain() {
1436
+ return {
1437
+ tables: {
1438
+ rooms: new MemoryKv(),
1439
+ players: new MemoryKv(),
1440
+ grants: new MemoryKv(),
1441
+ ledger: new MemoryKv(),
1442
+ hands: new MemoryKv()
1443
+ },
1444
+ async close() {}
1445
+ };
1446
+ }
1447
+ async function openDoudizhuDomain(storage) {
1448
+ if (!storage || typeof storage !== "object") return null;
1449
+ const facility = storage;
1450
+ if (typeof facility.open !== "function") return null;
1451
+ try {
1452
+ const spec = {
1453
+ name: "doudizhu",
1454
+ version: 1,
1455
+ tables: {
1456
+ rooms: { valueSchema: RoomRecordSchema },
1457
+ players: { valueSchema: PlayerRecordSchema },
1458
+ grants: { valueSchema: TokenGrantSchema },
1459
+ ledger: { valueSchema: LedgerEntrySchema },
1460
+ hands: { valueSchema: HandCheckpointSchema }
1461
+ }
1462
+ };
1463
+ const domain = await facility.open(spec);
1464
+ return {
1465
+ tables: {
1466
+ rooms: domain.table("rooms"),
1467
+ players: domain.table("players"),
1468
+ grants: domain.table("grants"),
1469
+ ledger: domain.table("ledger"),
1470
+ hands: domain.table("hands")
1471
+ },
1472
+ close: () => domain.close()
1473
+ };
1474
+ } catch {
1475
+ return null;
1476
+ }
1477
+ }
1478
+
1479
+ //#endregion
1480
+ //#region src/settle/audit.ts
1481
+ function createCounters(startedAt = Date.now()) {
1482
+ return {
1483
+ startedAt,
1484
+ roomsActive: 0,
1485
+ handsStarted: 0,
1486
+ handsVoided: 0,
1487
+ wsConnected: 0,
1488
+ cmdRejected: 0,
1489
+ settlementsCommitted: 0,
1490
+ needsAudit: false,
1491
+ get uptimeMs() {
1492
+ return Date.now() - startedAt;
1493
+ },
1494
+ bump(key) {
1495
+ this[key] += 1;
1496
+ }
1497
+ };
1498
+ }
1499
+
1500
+ //#endregion
1501
+ //#region src/settle/grant.ts
1502
+ function issueHostGrant(playerId, roomId, maxExposureAtoms, ttlHours = 24) {
1503
+ const issuedAt = nowIso();
1504
+ return {
1505
+ grantId: asGrantId(randomId("gr")),
1506
+ playerId,
1507
+ roomId,
1508
+ maxExposureAtoms: asTokenAtomString(maxExposureAtoms),
1509
+ issuedAt,
1510
+ expiresAt: addHours(ttlHours),
1511
+ source: "host-welcome",
1512
+ issuedBy: "room-host"
1513
+ };
1514
+ }
1515
+ async function persistGrant(domain, grant) {
1516
+ const record = { ...grant };
1517
+ await domain.tables.grants.put(grant.grantId, record);
1518
+ }
1519
+
1520
+ //#endregion
1521
+ //#region src/settle/ledger.ts
1522
+ var Ledger = class {
1523
+ tipHash = "0".repeat(64);
1524
+ escrow = /* @__PURE__ */ new Map();
1525
+ constructor(domain, welcomeAtoms) {
1526
+ this.domain = domain;
1527
+ this.welcomeAtoms = welcomeAtoms;
1528
+ for (const [, entry] of domain.tables.ledger.entries()) this.tipHash = entry.hash;
1529
+ }
1530
+ getAvailable(playerId) {
1531
+ const row = this.domain.tables.players.get(playerId);
1532
+ return row ? parseAtoms(row.availableAtoms) : 0n;
1533
+ }
1534
+ getEscrow(playerId) {
1535
+ return this.escrow.get(playerId) ?? 0n;
1536
+ }
1537
+ snapshot(playerId) {
1538
+ const entries = [...this.domain.tables.ledger.entries()].map(([, entry]) => entry).filter((entry) => entry.from === playerId || entry.to === playerId);
1539
+ return {
1540
+ availableAtoms: asTokenAtomString(this.getAvailable(playerId)),
1541
+ escrowAtoms: asTokenAtomString(this.getEscrow(playerId)),
1542
+ entries
1543
+ };
1544
+ }
1545
+ async ensurePlayer(playerId, displayName, avatarUrl) {
1546
+ const existing = this.domain.tables.players.get(playerId);
1547
+ if (existing) {
1548
+ if (existing.displayName !== displayName) {
1549
+ const next = {
1550
+ ...existing,
1551
+ displayName
1552
+ };
1553
+ await this.domain.tables.players.put(playerId, next);
1554
+ return next;
1555
+ }
1556
+ return existing;
1557
+ }
1558
+ const created = {
1559
+ playerId,
1560
+ displayName,
1561
+ avatarUrl,
1562
+ availableAtoms: "0",
1563
+ welcomeGranted: false,
1564
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1565
+ };
1566
+ await this.domain.tables.players.put(playerId, created);
1567
+ return this.grantWelcome(asPlayerId(playerId), created);
1568
+ }
1569
+ async grantWelcome(playerId, row = this.domain.tables.players.get(playerId)) {
1570
+ if (!row) throw new Error(`unknown player ${playerId}`);
1571
+ if (row.welcomeGranted) return row;
1572
+ const granted = {
1573
+ ...await this.applyAvailable(playerId, this.welcomeAtoms, {
1574
+ reason: "welcome",
1575
+ from: "host",
1576
+ to: playerId
1577
+ }),
1578
+ welcomeGranted: true
1579
+ };
1580
+ await this.domain.tables.players.put(playerId, granted);
1581
+ return granted;
1582
+ }
1583
+ async freeze(playerId, atoms, roomId) {
1584
+ if (atoms <= 0n) throw new Error("freeze amount must be positive");
1585
+ if (this.getAvailable(playerId) < atoms) throw Object.assign(/* @__PURE__ */ new Error("insufficient"), { code: "insufficient" });
1586
+ await this.applyAvailable(playerId, -atoms, {
1587
+ reason: "freeze",
1588
+ from: playerId,
1589
+ to: `escrow:${roomId}`,
1590
+ roomId
1591
+ });
1592
+ this.escrow.set(playerId, this.getEscrow(playerId) + atoms);
1593
+ }
1594
+ async unfreeze(playerId, atoms, roomId, reason = "unfreeze") {
1595
+ const held = this.getEscrow(playerId);
1596
+ const amount = atoms < held ? atoms : held;
1597
+ if (amount <= 0n) return;
1598
+ this.escrow.set(playerId, held - amount);
1599
+ await this.applyAvailable(playerId, amount, {
1600
+ reason,
1601
+ from: `escrow:${roomId}`,
1602
+ to: playerId,
1603
+ roomId
1604
+ });
1605
+ }
1606
+ async settleTransfer(from, to, atoms, meta) {
1607
+ if (atoms <= 0n) return;
1608
+ const held = this.getEscrow(from);
1609
+ if (held < atoms) throw Object.assign(/* @__PURE__ */ new Error("escrow insufficient"), { code: "needsAudit" });
1610
+ this.escrow.set(from, held - atoms);
1611
+ await this.append({
1612
+ from,
1613
+ to,
1614
+ atoms: asTokenAtomString(atoms),
1615
+ reason: "settle",
1616
+ roomId: meta.roomId,
1617
+ handId: meta.handId,
1618
+ settlementId: meta.settlementId
1619
+ });
1620
+ const winner = this.domain.tables.players.get(to);
1621
+ if (!winner) throw new Error(`unknown winner ${to}`);
1622
+ const next = {
1623
+ ...winner,
1624
+ availableAtoms: (parseAtoms(winner.availableAtoms) + atoms).toString()
1625
+ };
1626
+ await this.domain.tables.players.put(to, next);
1627
+ }
1628
+ replaceWelcome(welcomeAtoms) {
1629
+ this.welcomeAtoms = welcomeAtoms;
1630
+ }
1631
+ async applyAvailable(playerId, delta, meta) {
1632
+ const row = this.domain.tables.players.get(playerId);
1633
+ if (!row) throw new Error(`unknown player ${playerId}`);
1634
+ const nextAvailable = parseAtoms(row.availableAtoms) + delta;
1635
+ if (nextAvailable < 0n) throw Object.assign(/* @__PURE__ */ new Error("insufficient"), { code: "insufficient" });
1636
+ const next = {
1637
+ ...row,
1638
+ availableAtoms: nextAvailable.toString()
1639
+ };
1640
+ await this.domain.tables.players.put(playerId, next);
1641
+ await this.append({
1642
+ from: meta.from,
1643
+ to: meta.to,
1644
+ atoms: asTokenAtomString(delta < 0n ? -delta : delta),
1645
+ reason: meta.reason,
1646
+ roomId: meta.roomId,
1647
+ handId: meta.handId,
1648
+ settlementId: meta.settlementId
1649
+ });
1650
+ return next;
1651
+ }
1652
+ async append(partial) {
1653
+ const entryId = asEntryId(randomId("led"));
1654
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1655
+ const prevHash = this.tipHash;
1656
+ const material = JSON.stringify({
1657
+ ...partial,
1658
+ entryId,
1659
+ ts,
1660
+ prevHash
1661
+ });
1662
+ const hash = createHash("sha256").update(material).digest("hex");
1663
+ const entry = {
1664
+ ...partial,
1665
+ entryId,
1666
+ ts,
1667
+ prevHash,
1668
+ hash
1669
+ };
1670
+ await this.domain.tables.ledger.put(entryId, entry);
1671
+ this.tipHash = hash;
1672
+ return entry;
1673
+ }
1674
+ };
1675
+
1676
+ //#endregion
1677
+ //#region src/room/RoomManager.ts
1678
+ var RoomManager = class {
1679
+ config;
1680
+ domain;
1681
+ ledger;
1682
+ rooms = /* @__PURE__ */ new Map();
1683
+ roomsByCode = /* @__PURE__ */ new Map();
1684
+ sessions = /* @__PURE__ */ new Map();
1685
+ tickets = /* @__PURE__ */ new Map();
1686
+ hostCookies = /* @__PURE__ */ new Set();
1687
+ listeners = /* @__PURE__ */ new Set();
1688
+ timers = /* @__PURE__ */ new Set();
1689
+ counters = createCounters();
1690
+ persistReady;
1691
+ constructor(ctx, config, storage) {
1692
+ this.ctx = ctx;
1693
+ this.config = resolveConfig(config);
1694
+ this.domain = createMemoryDomain();
1695
+ this.ledger = new Ledger(this.domain, this.config.welcomeAtoms);
1696
+ this.persistReady = this.hydrate(storage);
1697
+ const tick = setInterval(() => {
1698
+ this.onTick(Date.now());
1699
+ }, 250);
1700
+ this.timers.add(tick);
1701
+ if (typeof tick === "object" && "unref" in tick) tick.unref();
1702
+ }
1703
+ async ready() {
1704
+ await this.persistReady;
1705
+ }
1706
+ replaceConfig(config) {
1707
+ this.config = resolveConfig(config);
1708
+ this.ledger.replaceWelcome(this.config.welcomeAtoms);
1709
+ }
1710
+ getConfig() {
1711
+ return this.config;
1712
+ }
1713
+ onEvent(listener) {
1714
+ this.listeners.add(listener);
1715
+ return () => {
1716
+ this.listeners.delete(listener);
1717
+ };
1718
+ }
1719
+ async dispose() {
1720
+ for (const timer of this.timers) clearInterval(timer);
1721
+ this.timers.clear();
1722
+ await this.domain.close();
1723
+ }
1724
+ health() {
1725
+ return {
1726
+ roomsActive: [...this.rooms.values()].filter((room) => room.room.phase !== "closed").length,
1727
+ handsStarted: this.counters.handsStarted,
1728
+ handsVoided: this.counters.handsVoided,
1729
+ wsConnected: this.counters.wsConnected,
1730
+ cmdRejected: this.counters.cmdRejected,
1731
+ settlementsCommitted: this.counters.settlementsCommitted,
1732
+ needsAudit: [...this.rooms.values()].some((room) => room.needsAudit) || this.counters.needsAudit,
1733
+ uptimeMs: this.counters.uptimeMs
1734
+ };
1735
+ }
1736
+ async createRoom(req, hostCookie) {
1737
+ await this.ready();
1738
+ if (!this.config.enabled) throw fail("phase", "plugin disabled");
1739
+ if (this.rooms.size >= this.config.maxRooms) throw fail("room-full", "too many rooms");
1740
+ const seatCount = req.seatCount === 4 ? 4 : 3;
1741
+ const laiZi = req.laiZi === true;
1742
+ assertCreateEconomy(this.config.welcomeAtoms, req.stakeM, req.maxMultiplier, seatCount);
1743
+ const publicBase = this.config.publicBaseUrl ? validatePublicBaseUrl(this.config.publicBaseUrl) : "";
1744
+ const roomId = asRoomId(randomId("rm"));
1745
+ const roomCode = this.uniqueCode();
1746
+ const secret = randomToken();
1747
+ const sitToken = randomToken();
1748
+ const watchToken = randomToken();
1749
+ const hostPlayerId = newPlayerId();
1750
+ const displayName = sanitizeDisplayName(req.hostDisplayName ?? "房主");
1751
+ const title = sanitizeRoomTitle(req.title ?? "好友局");
1752
+ await this.ledger.ensurePlayer(hostPlayerId, displayName, null);
1753
+ const stakeAtoms = BigInt(req.stakeM) * 1000000n;
1754
+ const now = nowIso();
1755
+ const room = {
1756
+ roomId,
1757
+ roomCode,
1758
+ title,
1759
+ hostPlayerId,
1760
+ phase: "waiting",
1761
+ stakeAtoms,
1762
+ maxMultiplier: req.maxMultiplier,
1763
+ seatCount,
1764
+ laiZi,
1765
+ seats: Array.from({ length: seatCount }, (_, index) => emptySeat(index)),
1766
+ spectatorIds: [],
1767
+ currentHandId: null,
1768
+ createdAt: now,
1769
+ inviteExpiresAt: addHours(this.config.inviteTtlHours),
1770
+ shareable: publicBase !== ""
1771
+ };
1772
+ const live = {
1773
+ room,
1774
+ secret,
1775
+ sitInviteHash: inviteHash(secret, "sit", sitToken),
1776
+ sitToken,
1777
+ watchInviteHash: inviteHash(secret, "watch", watchToken),
1778
+ watchToken,
1779
+ sitConsumed: false,
1780
+ lastLandlordSeat: null,
1781
+ engine: null,
1782
+ deadlineAt: null,
1783
+ seq: 0,
1784
+ events: [],
1785
+ chat: [],
1786
+ nonces: /* @__PURE__ */ new Set(),
1787
+ disconnectedAt: /* @__PURE__ */ new Map(),
1788
+ autoPlay: /* @__PURE__ */ new Set(),
1789
+ lastSettlement: null,
1790
+ needsAudit: false
1791
+ };
1792
+ this.rooms.set(roomId, live);
1793
+ this.roomsByCode.set(roomCode, roomId);
1794
+ await this.persistRoom(live);
1795
+ const cookie = hostCookie ?? cookieValue();
1796
+ this.hostCookies.add(cookie);
1797
+ this.sessions.set(cookie, {
1798
+ cookie,
1799
+ playerId: hostPlayerId,
1800
+ roomId,
1801
+ kind: "host",
1802
+ expiresAt: Date.now() + 864e5
1803
+ });
1804
+ await this.sitInternal(live, hostPlayerId, 0, displayName, true);
1805
+ const origin = publicBase || `http://127.0.0.1:${this.listenPort()}`;
1806
+ return {
1807
+ roomId,
1808
+ roomCode,
1809
+ sitUrl: `${origin}${this.config.routePrefix}/join?code=${roomCode}&invite=${sitToken}`,
1810
+ watchUrl: `${origin}${this.config.routePrefix}/join?code=${roomCode}&invite=${watchToken}&role=watch`,
1811
+ seatCapAtoms: seatCapAtoms(stakeAtoms, req.maxMultiplier, seatCount).toString(),
1812
+ shareable: room.shareable,
1813
+ hostCookie: cookie,
1814
+ hostPlayerId,
1815
+ view: this.viewFor(live, hostPlayerId)
1816
+ };
1817
+ }
1818
+ async join(req) {
1819
+ await this.ready();
1820
+ const live = this.roomByCode(req.roomCode);
1821
+ if (!live || live.room.phase === "closed") throw fail("expired", "room not found");
1822
+ if (Date.parse(live.room.inviteExpiresAt) < Date.now()) throw fail("expired", "invite expired");
1823
+ const displayName = sanitizeDisplayName(req.displayName);
1824
+ const playerId = newPlayerId();
1825
+ await this.ledger.ensurePlayer(playerId, displayName, sanitizeAvatarUrl(null, this.config.routePrefix));
1826
+ const invite = req.invite.trim();
1827
+ const sitOk = invite ? sha256Hex(`${live.secret}|sit|${invite}`) === live.sitInviteHash : Boolean(req.local);
1828
+ const watchOk = invite ? sha256Hex(`${live.secret}|watch|${invite}`) === live.watchInviteHash : false;
1829
+ if (!sitOk && !watchOk) throw fail("auth", invite ? "invalid invite" : "room not found");
1830
+ const empty = this.firstEmptySeat(live);
1831
+ const canSit = live.room.phase === "waiting" && empty !== null && req.role !== "watch";
1832
+ let seat = null;
1833
+ if (canSit) {
1834
+ await this.sitInternal(live, playerId, empty, displayName, true);
1835
+ seat = empty;
1836
+ if (this.firstEmptySeat(live) === null) live.sitConsumed = true;
1837
+ await this.maybeDeal(live);
1838
+ } else {
1839
+ if (!this.config.allowSpectators) throw fail("auth", "spectators disabled");
1840
+ live.sitConsumed = live.sitConsumed || empty === null || live.room.phase !== "waiting";
1841
+ this.addSpectator(live, playerId);
1842
+ }
1843
+ const cookie = cookieValue();
1844
+ this.sessions.set(cookie, {
1845
+ cookie,
1846
+ playerId,
1847
+ roomId: live.room.roomId,
1848
+ kind: seat === null ? "watch" : "seat",
1849
+ expiresAt: Date.now() + 864e5
1850
+ });
1851
+ this.broadcast(live);
1852
+ return {
1853
+ playerId,
1854
+ roomId: live.room.roomId,
1855
+ seat,
1856
+ wsTicket: this.issueTicket(playerId, live.room.roomId),
1857
+ cookie,
1858
+ view: this.viewFor(live, playerId)
1859
+ };
1860
+ }
1861
+ sessionByCookie(cookie) {
1862
+ if (!cookie) return void 0;
1863
+ const session = this.sessions.get(cookie);
1864
+ if (!session || session.expiresAt < Date.now()) return void 0;
1865
+ return session;
1866
+ }
1867
+ isHostCookie(cookie) {
1868
+ return Boolean(cookie && this.hostCookies.has(cookie) && this.sessionByCookie(cookie));
1869
+ }
1870
+ consumeTicket(ticket) {
1871
+ const found = this.tickets.get(ticket);
1872
+ this.tickets.delete(ticket);
1873
+ if (!found || found.expiresAt < Date.now()) return void 0;
1874
+ return found;
1875
+ }
1876
+ issueTicket(playerId, roomId) {
1877
+ const ticket = randomToken();
1878
+ this.tickets.set(ticket, {
1879
+ playerId,
1880
+ roomId,
1881
+ expiresAt: Date.now() + 6e4
1882
+ });
1883
+ return ticket;
1884
+ }
1885
+ view(roomId, playerId) {
1886
+ return this.viewFor(this.requireRoom(roomId), playerId);
1887
+ }
1888
+ eventsSince(roomId, playerId, seq) {
1889
+ const live = this.requireRoom(roomId);
1890
+ const events = [];
1891
+ if (live.seq > seq) events.push({
1892
+ type: "snapshot",
1893
+ seq: live.seq,
1894
+ view: this.viewFor(live, playerId)
1895
+ });
1896
+ if (live.lastSettlement) events.push({
1897
+ type: "settled",
1898
+ seq: live.seq,
1899
+ settlement: live.lastSettlement
1900
+ });
1901
+ if (events.length === 0) events.push({
1902
+ type: "snapshot",
1903
+ seq: live.seq,
1904
+ view: this.viewFor(live, playerId)
1905
+ });
1906
+ return {
1907
+ events,
1908
+ untilSeq: live.seq
1909
+ };
1910
+ }
1911
+ ledgerFor(playerId) {
1912
+ return this.ledger.snapshot(playerId);
1913
+ }
1914
+ async command(roomId, playerId, command, isHost = false) {
1915
+ const live = this.requireRoom(roomId);
1916
+ try {
1917
+ if ("nonce" in command && command.nonce) {
1918
+ if (live.nonces.has(command.nonce)) throw fail("duplicate-nonce", "duplicate play nonce");
1919
+ live.nonces.add(command.nonce);
1920
+ }
1921
+ switch (command.type) {
1922
+ case "sit":
1923
+ await this.sitInternal(live, playerId, command.seat, sanitizeDisplayName(command.displayName), false);
1924
+ break;
1925
+ case "stand":
1926
+ this.stand(live, playerId);
1927
+ break;
1928
+ case "ready":
1929
+ await this.setReady(live, playerId, command.ready);
1930
+ break;
1931
+ case "bid":
1932
+ this.bid(live, playerId, command.action);
1933
+ break;
1934
+ case "double":
1935
+ this.double(live, playerId, command.action);
1936
+ break;
1937
+ case "mingPai":
1938
+ this.mingPai(live, playerId);
1939
+ break;
1940
+ case "rename":
1941
+ this.rename(live, playerId, command.title, isHost);
1942
+ break;
1943
+ case "play":
1944
+ this.play(live, playerId, command.cards);
1945
+ break;
1946
+ case "pass":
1947
+ this.pass(live, playerId);
1948
+ break;
1949
+ case "chat":
1950
+ this.chat(live, playerId, command.text);
1951
+ break;
1952
+ case "rematch":
1953
+ await this.rematch(live, playerId);
1954
+ break;
1955
+ case "hostKick":
1956
+ if (!isHost && live.room.hostPlayerId !== playerId) throw fail("auth", "host only");
1957
+ this.kick(live, command.playerId);
1958
+ break;
1959
+ case "hostClose":
1960
+ if (!isHost && live.room.hostPlayerId !== playerId) throw fail("auth", "host only");
1961
+ await this.closeRoom(live);
1962
+ break;
1963
+ case "ping":
1964
+ this.emit(live, {
1965
+ type: "pong",
1966
+ ts: Date.now()
1967
+ }, playerId);
1968
+ return { seq: live.seq };
1969
+ default: throw fail("illegal", "unknown command");
1970
+ }
1971
+ this.broadcast(live);
1972
+ return { seq: live.seq };
1973
+ } catch (error) {
1974
+ this.counters.bump("cmdRejected");
1975
+ throw error;
1976
+ }
1977
+ }
1978
+ markConnected(playerId, roomId) {
1979
+ const live = this.rooms.get(roomId);
1980
+ if (!live) return;
1981
+ live.disconnectedAt.delete(playerId);
1982
+ live.autoPlay.delete(this.seatOf(live, playerId) ?? 0);
1983
+ this.setConnected(live, playerId, true);
1984
+ this.broadcast(live);
1985
+ }
1986
+ markDisconnected(playerId, roomId) {
1987
+ const live = this.rooms.get(roomId);
1988
+ if (!live) return;
1989
+ live.disconnectedAt.set(playerId, Date.now());
1990
+ this.setConnected(live, playerId, false);
1991
+ this.broadcast(live);
1992
+ }
1993
+ listenPort = () => this.ctx?.webServer?.port ?? 3080;
1994
+ async hydrate(storage) {
1995
+ const opened = await openDoudizhuDomain(storage);
1996
+ if (!opened) return;
1997
+ this.domain = opened;
1998
+ this.ledger = new Ledger(this.domain, this.config.welcomeAtoms);
1999
+ }
2000
+ uniqueCode() {
2001
+ for (let i = 0; i < 20; i += 1) {
2002
+ const code = randomRoomCode();
2003
+ if (!this.roomsByCode.has(code)) return code;
2004
+ }
2005
+ throw new Error("could not allocate room code");
2006
+ }
2007
+ roomByCode(code) {
2008
+ const id = this.roomsByCode.get(code);
2009
+ return id ? this.rooms.get(id) : void 0;
2010
+ }
2011
+ requireRoom(roomId) {
2012
+ const live = this.rooms.get(roomId);
2013
+ if (!live) throw fail("expired", "room not found");
2014
+ return live;
2015
+ }
2016
+ firstEmptySeat(live) {
2017
+ const empty = live.room.seats.find((seat) => seat.playerId === null);
2018
+ return empty ? empty.seat : null;
2019
+ }
2020
+ addSpectator(live, playerId) {
2021
+ if (live.room.spectatorIds.includes(playerId)) return;
2022
+ if (live.room.spectatorIds.length >= MAX_SPECTATORS) throw fail("room-full", "too many spectators");
2023
+ live.room = {
2024
+ ...live.room,
2025
+ spectatorIds: [...live.room.spectatorIds, playerId]
2026
+ };
2027
+ }
2028
+ async sitInternal(live, playerId, seat, displayName, isHost) {
2029
+ if (live.room.phase !== "waiting") throw fail("phase", "cannot sit now");
2030
+ const target = live.room.seats[seat];
2031
+ if (!target) throw fail("illegal", "no such seat");
2032
+ if (target.playerId && target.playerId !== playerId) throw fail("room-full", "seat taken");
2033
+ this.stand(live, playerId, false);
2034
+ const cap = seatCapAtoms(live.room.stakeAtoms, live.room.maxMultiplier, live.room.seatCount);
2035
+ const grant = issueHostGrant(playerId, live.room.roomId, cap);
2036
+ await persistGrant(this.domain, grant);
2037
+ const ready = isHost || live.room.phase === "waiting";
2038
+ if (ready && this.ledger.getEscrow(playerId) < cap) {
2039
+ if (this.ledger.getAvailable(playerId) < cap) throw fail("insufficient", "need more welcome atoms");
2040
+ await this.ledger.freeze(playerId, cap, live.room.roomId);
2041
+ }
2042
+ const seats = live.room.seats.map((item) => item.seat === seat ? {
2043
+ ...item,
2044
+ playerId,
2045
+ displayName,
2046
+ avatarUrl: null,
2047
+ ready,
2048
+ connected: true,
2049
+ role: "empty",
2050
+ grantId: grant.grantId,
2051
+ cardsLeft: 0
2052
+ } : item);
2053
+ live.room = {
2054
+ ...live.room,
2055
+ seats,
2056
+ spectatorIds: live.room.spectatorIds.filter((id) => id !== playerId)
2057
+ };
2058
+ }
2059
+ stand(live, playerId, persist = true) {
2060
+ if (live.room.phase !== "waiting" && persist) throw fail("phase", "cannot stand now");
2061
+ const seats = live.room.seats.map((item) => item.playerId === playerId ? emptySeat(item.seat) : item);
2062
+ live.room = {
2063
+ ...live.room,
2064
+ seats
2065
+ };
2066
+ live.sitConsumed = live.room.phase !== "waiting" || this.firstEmptySeat(live) === null;
2067
+ if (persist && this.ledger.getEscrow(playerId) > 0n) this.ledger.unfreeze(playerId, this.ledger.getEscrow(playerId), live.room.roomId);
2068
+ }
2069
+ async setReady(live, playerId, ready) {
2070
+ if (live.room.phase !== "waiting") throw fail("phase", "not waiting");
2071
+ const seat = this.seatOf(live, playerId);
2072
+ if (seat === null) throw fail("illegal", "not seated");
2073
+ if (ready) {
2074
+ const cap = seatCapAtoms(live.room.stakeAtoms, live.room.maxMultiplier, live.room.seatCount);
2075
+ if (this.ledger.getAvailable(playerId) < cap) throw fail("insufficient", "need more welcome atoms");
2076
+ if (this.ledger.getEscrow(playerId) < cap) await this.ledger.freeze(playerId, cap, live.room.roomId);
2077
+ } else if (this.ledger.getEscrow(playerId) > 0n) await this.ledger.unfreeze(playerId, this.ledger.getEscrow(playerId), live.room.roomId);
2078
+ this.patchSeat(live, seat, { ready });
2079
+ await this.maybeDeal(live);
2080
+ }
2081
+ async maybeDeal(live) {
2082
+ const seated = live.room.seats;
2083
+ if (!seated.every((seat) => seat.playerId && seat.ready && seat.grantId)) return;
2084
+ const dealer = live.lastLandlordSeat === null ? null : nextSeat(live.lastLandlordSeat, live.room.seatCount);
2085
+ live.engine = createHand(live.room.roomId, dealer, this.counters.handsStarted + 1, {
2086
+ seatCount: live.room.seatCount,
2087
+ laiZi: live.room.laiZi
2088
+ });
2089
+ this.counters.bump("handsStarted");
2090
+ live.room = {
2091
+ ...live.room,
2092
+ phase: "bidding",
2093
+ currentHandId: live.engine.hand.handId,
2094
+ seats: seated.map((seat) => ({
2095
+ ...seat,
2096
+ role: "farmer",
2097
+ cardsLeft: dealtHandSize(live.room.seatCount)
2098
+ }))
2099
+ };
2100
+ live.deadlineAt = Date.now() + this.config.turnTimeoutMs;
2101
+ }
2102
+ bid(live, playerId, action) {
2103
+ const engine = this.requireEngine(live, "bidding");
2104
+ const result = applyBid(engine, this.requireSeat(live, playerId), action);
2105
+ if (!result.ok) throw fail(result.code, result.reason);
2106
+ if (engine.phase === "redeal") {
2107
+ const dealer = engine.hand.dealerSeat;
2108
+ live.engine = createHand(live.room.roomId, dealer, this.counters.handsStarted + 1, {
2109
+ seatCount: live.room.seatCount,
2110
+ laiZi: live.room.laiZi
2111
+ });
2112
+ this.counters.bump("handsStarted");
2113
+ live.room = {
2114
+ ...live.room,
2115
+ currentHandId: live.engine.hand.handId,
2116
+ phase: "bidding"
2117
+ };
2118
+ live.deadlineAt = Date.now() + this.config.turnTimeoutMs;
2119
+ return;
2120
+ }
2121
+ if (engine.phase === "doubling" && engine.hand.landlordSeat !== null) {
2122
+ this.patchSeat(live, engine.hand.landlordSeat, {
2123
+ role: "landlord",
2124
+ cardsLeft: landlordHandSize(live.room.seatCount)
2125
+ });
2126
+ live.room = {
2127
+ ...live.room,
2128
+ phase: "doubling"
2129
+ };
2130
+ live.deadlineAt = engine.doubleDeadlineAt;
2131
+ }
2132
+ }
2133
+ mingPai(live, playerId) {
2134
+ if (!live.engine) throw fail("phase", "no hand");
2135
+ const seat = this.requireSeat(live, playerId);
2136
+ const result = applyMingPai(live.engine, seat);
2137
+ if (!result.ok) throw fail(result.code, result.reason);
2138
+ }
2139
+ rename(live, playerId, title, isHost) {
2140
+ if (!isHost && live.room.hostPlayerId !== playerId) throw fail("auth", "host only");
2141
+ live.room = {
2142
+ ...live.room,
2143
+ title: sanitizeRoomTitle(title, live.room.title)
2144
+ };
2145
+ this.persistRoom(live);
2146
+ }
2147
+ double(live, playerId, action) {
2148
+ const engine = this.requireEngine(live, "doubling");
2149
+ const result = applyDouble(engine, this.requireSeat(live, playerId), action);
2150
+ if (!result.ok) throw fail(result.code, result.reason);
2151
+ if (engine.phase === "playing") {
2152
+ live.room = {
2153
+ ...live.room,
2154
+ phase: "playing"
2155
+ };
2156
+ live.deadlineAt = Date.now() + this.config.turnTimeoutMs;
2157
+ }
2158
+ }
2159
+ play(live, playerId, cards) {
2160
+ const engine = this.requireEngine(live, "playing");
2161
+ const seat = this.requireSeat(live, playerId);
2162
+ const result = applyPlay(engine, seat, cards, live.seq + 1, nowIso());
2163
+ if (!result.ok) throw fail(result.code, result.reason);
2164
+ this.patchSeat(live, seat, { cardsLeft: engine.hand.hands[seat]?.length ?? 0 });
2165
+ if (engine.phase === "settled") this.settle(live);
2166
+ else live.deadlineAt = Date.now() + this.config.turnTimeoutMs;
2167
+ }
2168
+ pass(live, playerId) {
2169
+ const result = applyPass(this.requireEngine(live, "playing"), this.requireSeat(live, playerId), live.seq + 1, nowIso());
2170
+ if (!result.ok) throw fail(result.code, result.reason);
2171
+ live.deadlineAt = Date.now() + this.config.turnTimeoutMs;
2172
+ }
2173
+ chat(live, playerId, text) {
2174
+ const cleaned = text.replace(/[\u0000-\u001f]/g, "").slice(0, MAX_CHAT);
2175
+ if (!cleaned) return;
2176
+ const name$1 = this.displayNameOf(live, playerId);
2177
+ live.chat.push({
2178
+ playerId,
2179
+ displayName: name$1,
2180
+ text: cleaned,
2181
+ ts: nowIso()
2182
+ });
2183
+ live.chat = live.chat.slice(-50);
2184
+ }
2185
+ async rematch(live, playerId) {
2186
+ if (live.room.phase !== "waiting") throw fail("phase", "not waiting");
2187
+ if (!(this.seatOf(live, playerId) !== null || live.room.hostPlayerId === playerId)) throw fail("illegal", "not seated");
2188
+ for (const seat of live.room.seats) if (seat.playerId) await this.setReady(live, seat.playerId, true);
2189
+ }
2190
+ kick(live, playerId) {
2191
+ if (live.room.phase !== "waiting") throw fail("phase", "cannot kick now");
2192
+ this.stand(live, playerId);
2193
+ live.room = {
2194
+ ...live.room,
2195
+ spectatorIds: live.room.spectatorIds.filter((id) => id !== playerId)
2196
+ };
2197
+ this.persistRoom(live);
2198
+ }
2199
+ async closeRoom(live) {
2200
+ if (live.engine && (live.room.phase === "playing" || live.room.phase === "bidding" || live.room.phase === "doubling")) await this.voidHand(live);
2201
+ for (const seat of live.room.seats) if (seat.playerId && this.ledger.getEscrow(seat.playerId) > 0n) await this.ledger.unfreeze(seat.playerId, this.ledger.getEscrow(seat.playerId), live.room.roomId);
2202
+ live.room = {
2203
+ ...live.room,
2204
+ phase: "closed"
2205
+ };
2206
+ }
2207
+ async settle(live) {
2208
+ const engine = live.engine;
2209
+ if (!engine || engine.hand.landlordSeat === null) return;
2210
+ live.room = {
2211
+ ...live.room,
2212
+ phase: "settling"
2213
+ };
2214
+ const playerIds = live.room.seats.map((seat) => seat.playerId);
2215
+ if (playerIds.some((id) => !id)) return;
2216
+ const landlord = engine.hand.landlordSeat;
2217
+ const winner = (engine.hand.hands[landlord] ?? []).length === 0 ? "landlord" : "farmers";
2218
+ const settlement = scoreHand({
2219
+ hand: snapshotHand(engine),
2220
+ stakeAtoms: live.room.stakeAtoms,
2221
+ maxMultiplier: live.room.maxMultiplier,
2222
+ winner,
2223
+ playerIds
2224
+ });
2225
+ try {
2226
+ const landlordId = playerIds[landlord];
2227
+ if (!landlordId) throw fail("illegal", "missing landlord");
2228
+ if (winner === "landlord") for (const delta of settlement.deltas) {
2229
+ const atoms = parseAtoms(delta.atoms);
2230
+ if (atoms < 0n) await this.ledger.settleTransfer(delta.playerId, landlordId, -atoms, {
2231
+ roomId: live.room.roomId,
2232
+ handId: settlement.handId,
2233
+ settlementId: settlement.settlementId
2234
+ });
2235
+ }
2236
+ else for (const delta of settlement.deltas) {
2237
+ const atoms = parseAtoms(delta.atoms);
2238
+ if (atoms > 0n) await this.ledger.settleTransfer(landlordId, delta.playerId, atoms, {
2239
+ roomId: live.room.roomId,
2240
+ handId: settlement.handId,
2241
+ settlementId: settlement.settlementId
2242
+ });
2243
+ }
2244
+ for (const seat of live.room.seats) if (seat.playerId) await this.ledger.unfreeze(seat.playerId, this.ledger.getEscrow(seat.playerId), live.room.roomId);
2245
+ } catch (error) {
2246
+ live.needsAudit = true;
2247
+ this.counters.needsAudit = true;
2248
+ this.ctx?.logger?.warn(error);
2249
+ throw fail("illegal", "settlement failed");
2250
+ }
2251
+ const publicSettlement = {
2252
+ ...settlement,
2253
+ trustNote: "好友局信任房主机器上的账本。积分 ≠ DeepSeek 平台余额。"
2254
+ };
2255
+ live.lastSettlement = publicSettlement;
2256
+ live.lastLandlordSeat = landlord;
2257
+ this.counters.bump("settlementsCommitted");
2258
+ live.engine = null;
2259
+ live.room = {
2260
+ ...live.room,
2261
+ phase: "waiting",
2262
+ currentHandId: null,
2263
+ seats: live.room.seats.map((seat) => ({
2264
+ ...seat,
2265
+ ready: false,
2266
+ role: "empty",
2267
+ cardsLeft: 0
2268
+ }))
2269
+ };
2270
+ live.deadlineAt = null;
2271
+ this.push(live, {
2272
+ type: "settled",
2273
+ seq: ++live.seq,
2274
+ settlement: publicSettlement
2275
+ });
2276
+ }
2277
+ async voidHand(live) {
2278
+ for (const seat of live.room.seats) if (seat.playerId) await this.ledger.unfreeze(seat.playerId, this.ledger.getEscrow(seat.playerId), live.room.roomId, "void");
2279
+ live.engine = null;
2280
+ live.room = {
2281
+ ...live.room,
2282
+ phase: "waiting",
2283
+ currentHandId: null,
2284
+ seats: live.room.seats.map((seat) => ({
2285
+ ...seat,
2286
+ ready: false,
2287
+ role: "empty",
2288
+ cardsLeft: 0
2289
+ }))
2290
+ };
2291
+ this.counters.bump("handsVoided");
2292
+ }
2293
+ onTick(now) {
2294
+ for (const live of this.rooms.values()) {
2295
+ if (live.room.phase === "doubling" && live.engine) {
2296
+ expireDoubling(live.engine, now);
2297
+ if (live.engine.phase === "playing") {
2298
+ live.room = {
2299
+ ...live.room,
2300
+ phase: "playing"
2301
+ };
2302
+ live.deadlineAt = now + this.config.turnTimeoutMs;
2303
+ this.broadcast(live);
2304
+ }
2305
+ }
2306
+ if (live.deadlineAt && now >= live.deadlineAt && live.engine && live.room.phase === "playing") {
2307
+ if (autoTimeout(live.engine, live.seq + 1, nowIso())) {
2308
+ const seat = live.engine.hand.turnSeat;
2309
+ const previous = prevSeat(seat, live.room.seatCount);
2310
+ this.patchSeat(live, previous, { cardsLeft: live.engine.hand.hands[previous].length });
2311
+ if (live.engine.phase === "settled") this.settle(live);
2312
+ else live.deadlineAt = now + this.config.turnTimeoutMs;
2313
+ this.broadcast(live);
2314
+ }
2315
+ }
2316
+ for (const [playerId, at] of live.disconnectedAt) {
2317
+ if (now - at < this.config.reconnectWindowMs) continue;
2318
+ const seat = this.seatOf(live, playerId);
2319
+ if (seat !== null && (live.room.phase === "playing" || live.room.phase === "bidding" || live.room.phase === "doubling")) live.autoPlay.add(seat);
2320
+ }
2321
+ const seated = live.room.seats.filter((seat) => seat.playerId);
2322
+ if (seated.length > 0 && seated.every((seat) => !seat.connected) && live.disconnectedAt.size === live.room.seatCount) {
2323
+ if (now - Math.min(...live.disconnectedAt.values()) > this.config.reconnectWindowMs && live.engine) {
2324
+ this.voidHand(live);
2325
+ this.broadcast(live);
2326
+ }
2327
+ }
2328
+ }
2329
+ }
2330
+ requireEngine(live, phase) {
2331
+ if (!live.engine || live.engine.phase !== phase) throw fail("phase", `expected ${phase}`);
2332
+ return live.engine;
2333
+ }
2334
+ requireSeat(live, playerId) {
2335
+ const seat = this.seatOf(live, playerId);
2336
+ if (seat === null) throw fail("illegal", "not seated");
2337
+ return seat;
2338
+ }
2339
+ seatOf(live, playerId) {
2340
+ return live.room.seats.find((seat) => seat.playerId === playerId)?.seat ?? null;
2341
+ }
2342
+ displayNameOf(live, playerId) {
2343
+ return live.room.seats.find((seat) => seat.playerId === playerId)?.displayName ?? "玩家";
2344
+ }
2345
+ patchSeat(live, seat, patch) {
2346
+ live.room = {
2347
+ ...live.room,
2348
+ seats: live.room.seats.map((item) => item.seat === seat ? {
2349
+ ...item,
2350
+ ...patch
2351
+ } : item)
2352
+ };
2353
+ }
2354
+ setConnected(live, playerId, connected) {
2355
+ const seat = this.seatOf(live, playerId);
2356
+ if (seat !== null) this.patchSeat(live, seat, { connected });
2357
+ }
2358
+ viewFor(live, playerId) {
2359
+ const seat = this.seatOf(live, playerId);
2360
+ const spectator = seat === null;
2361
+ const engine = live.engine;
2362
+ const cards = seat !== null && engine ? [...engine.hand.hands[seat] ?? []] : [];
2363
+ const legal = seat !== null && engine ? legalFor(engine, seat) : {
2364
+ canPass: false,
2365
+ combos: []
2366
+ };
2367
+ const bottomRevealed = Boolean(engine && engine.hand.landlordSeat !== null);
2368
+ const remainingRanks = spectator && this.config.spectatorCardCounter ? this.remainingRanks(live) : null;
2369
+ return {
2370
+ room: {
2371
+ ...live.room,
2372
+ stakeAtoms: asTokenAtomString(live.room.stakeAtoms),
2373
+ seats: live.room.seats.map((item) => ({
2374
+ ...item,
2375
+ cardsLeft: engine ? engine.hand.hands[item.seat]?.length ?? 0 : item.cardsLeft,
2376
+ role: engine?.hand.landlordSeat === item.seat ? "landlord" : item.playerId ? engine ? "farmer" : item.role : "empty"
2377
+ }))
2378
+ },
2379
+ you: {
2380
+ playerId,
2381
+ seat,
2382
+ spectator,
2383
+ cards
2384
+ },
2385
+ publicHands: live.room.seats.map((item, index) => engine ? engine.hand.hands[index]?.length ?? 0 : item.cardsLeft),
2386
+ lastPlays: engine ? engine.hand.table.slice(-6) : [],
2387
+ bottom: bottomRevealed && engine ? [...engine.hand.bottom] : null,
2388
+ laiZiRanks: engine?.hand.laiZiRanks ?? [],
2389
+ bid: engine?.hand.bid ?? 0,
2390
+ auction: engine && engine.phase === "bidding" ? {
2391
+ kind: engine.called ? "rob" : "call",
2392
+ multiplier: Math.max(1, engine.hand.bid)
2393
+ } : null,
2394
+ revealedBySeat: this.revealedHands(live),
2395
+ mingPaiBySeat: engine ? { ...engine.hand.mingPaiBySeat } : {},
2396
+ turnSeat: engine?.hand.turnSeat ?? null,
2397
+ leadSeat: engine?.hand.leadSeat ?? null,
2398
+ deadlineAt: live.deadlineAt ? new Date(live.deadlineAt).toISOString() : null,
2399
+ yourAvailableAtoms: asTokenAtomString(this.ledger.getAvailable(playerId)),
2400
+ yourEscrowAtoms: asTokenAtomString(this.ledger.getEscrow(playerId)),
2401
+ legal,
2402
+ remainingRanks,
2403
+ chat: live.chat.slice(-20)
2404
+ };
2405
+ }
2406
+ revealedHands(live) {
2407
+ const engine = live.engine;
2408
+ if (!engine) return {};
2409
+ const out = {};
2410
+ for (const [raw, shown] of Object.entries(engine.hand.mingPaiBySeat)) {
2411
+ if (!shown) continue;
2412
+ const seat = Number(raw);
2413
+ out[seat] = [...engine.hand.hands[seat] ?? []];
2414
+ }
2415
+ return out;
2416
+ }
2417
+ remainingRanks(live) {
2418
+ if (!live.engine) return null;
2419
+ const seen = /* @__PURE__ */ new Map();
2420
+ const bump = (card) => {
2421
+ const rank = rankOf(card);
2422
+ seen.set(rank, (seen.get(rank) ?? 0) + 1);
2423
+ };
2424
+ for (const play of live.engine.hand.table) for (const card of play.cards) bump(card);
2425
+ if (live.engine.hand.landlordSeat !== null) for (const card of live.engine.hand.bottom) bump(card);
2426
+ const decks = decksFor(live.room.seatCount);
2427
+ const totals = {
2428
+ "3": 4 * decks,
2429
+ "4": 4 * decks,
2430
+ "5": 4 * decks,
2431
+ "6": 4 * decks,
2432
+ "7": 4 * decks,
2433
+ "8": 4 * decks,
2434
+ "9": 4 * decks,
2435
+ "10": 4 * decks,
2436
+ J: 4 * decks,
2437
+ Q: 4 * decks,
2438
+ K: 4 * decks,
2439
+ A: 4 * decks,
2440
+ "2": 4 * decks,
2441
+ BJ: decks,
2442
+ RJ: decks
2443
+ };
2444
+ const remaining = {};
2445
+ for (const [rank, total] of Object.entries(totals)) remaining[rank] = total - (seen.get(rank) ?? 0);
2446
+ return remaining;
2447
+ }
2448
+ async persistRoom(live) {
2449
+ await this.domain.tables.rooms.put(live.room.roomId, {
2450
+ roomId: live.room.roomId,
2451
+ roomCode: live.room.roomCode,
2452
+ title: live.room.title,
2453
+ hostPlayerId: live.room.hostPlayerId,
2454
+ phase: live.room.phase,
2455
+ stakeAtoms: live.room.stakeAtoms.toString(),
2456
+ maxMultiplier: live.room.maxMultiplier,
2457
+ seatCount: live.room.seatCount,
2458
+ laiZi: live.room.laiZi,
2459
+ inviteExpiresAt: live.room.inviteExpiresAt,
2460
+ createdAt: live.room.createdAt,
2461
+ shareable: live.room.shareable,
2462
+ roomSecret: live.secret,
2463
+ sitInviteHash: live.sitInviteHash,
2464
+ watchInviteHash: live.watchInviteHash,
2465
+ lastLandlordSeat: live.lastLandlordSeat
2466
+ });
2467
+ }
2468
+ broadcast(live) {
2469
+ live.seq += 1;
2470
+ const ids = new Set([...live.room.seats.map((seat) => seat.playerId).filter((id) => Boolean(id)), ...live.room.spectatorIds]);
2471
+ for (const playerId of ids) this.emit(live, {
2472
+ type: "snapshot",
2473
+ seq: live.seq,
2474
+ view: this.viewFor(live, playerId)
2475
+ }, playerId);
2476
+ }
2477
+ push(live, event) {
2478
+ live.events.push(event);
2479
+ live.events = live.events.slice(-200);
2480
+ const ids = new Set([...live.room.seats.map((seat) => seat.playerId).filter((id) => Boolean(id)), ...live.room.spectatorIds]);
2481
+ for (const playerId of ids) this.emit(live, event, playerId);
2482
+ }
2483
+ emit(_live, event, playerId) {
2484
+ for (const listener of this.listeners) listener(playerId, event);
2485
+ }
2486
+ };
2487
+ function emptySeat(seat) {
2488
+ return {
2489
+ seat,
2490
+ playerId: null,
2491
+ displayName: null,
2492
+ avatarUrl: null,
2493
+ ready: false,
2494
+ connected: false,
2495
+ role: "empty",
2496
+ grantId: null,
2497
+ cardsLeft: 0
2498
+ };
2499
+ }
2500
+ function fail(code, reason) {
2501
+ return Object.assign(new Error(reason), { code });
2502
+ }
2503
+
2504
+ //#endregion
2505
+ //#region src/net/auth.ts
2506
+ function isLoopbackHost(hostHeader, remoteAddress) {
2507
+ const host = (hostHeader ?? "").split(":")[0]?.replace(/^\[|\]$/g, "").toLowerCase();
2508
+ const remote = (remoteAddress ?? "").replace(/^::ffff:/, "");
2509
+ const loopbackHosts = new Set([
2510
+ "127.0.0.1",
2511
+ "localhost",
2512
+ "::1"
2513
+ ]);
2514
+ const loopbackRemotes = new Set([
2515
+ "127.0.0.1",
2516
+ "::1",
2517
+ "::ffff:127.0.0.1"
2518
+ ]);
2519
+ return loopbackHosts.has(host ?? "") || loopbackRemotes.has(remote);
2520
+ }
2521
+ function requestOrigin(req) {
2522
+ const origin = header(req, "origin");
2523
+ if (origin) try {
2524
+ return new URL(origin).origin;
2525
+ } catch {
2526
+ return null;
2527
+ }
2528
+ const referer = header(req, "referer");
2529
+ if (referer) try {
2530
+ return new URL(referer).origin;
2531
+ } catch {
2532
+ return null;
2533
+ }
2534
+ return null;
2535
+ }
2536
+ function originAllowed(origin, publicBaseUrl, browser) {
2537
+ if (!origin) return !browser;
2538
+ if (isLoopbackOrigin(origin)) return true;
2539
+ if (publicBaseUrl && origin === publicBaseUrl) return true;
2540
+ return false;
2541
+ }
2542
+ function isLoopbackOrigin(origin) {
2543
+ try {
2544
+ const url = new URL(origin);
2545
+ return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]";
2546
+ } catch {
2547
+ return false;
2548
+ }
2549
+ }
2550
+ function isBrowserRequest(req) {
2551
+ return Boolean(header(req, "origin") || header(req, "sec-fetch-site"));
2552
+ }
2553
+ function assertOrigin(req, publicBaseUrl) {
2554
+ if (header(req, "sec-fetch-site") === "cross-site") throw Object.assign(/* @__PURE__ */ new Error("cross-site forbidden"), {
2555
+ status: 403,
2556
+ code: "auth"
2557
+ });
2558
+ if (!originAllowed(requestOrigin(req), publicBaseUrl, isBrowserRequest(req))) throw Object.assign(/* @__PURE__ */ new Error("origin not allowed"), {
2559
+ status: 403,
2560
+ code: "auth"
2561
+ });
2562
+ }
2563
+ function assertMutatingHeaders(req) {
2564
+ const requested = header(req, "x-requested-with");
2565
+ const contentType = header(req, "content-type") ?? "";
2566
+ if (requested === "doudizhu") return;
2567
+ if (contentType.includes("application/json")) return;
2568
+ throw Object.assign(/* @__PURE__ */ new Error("missing csrf header"), {
2569
+ status: 403,
2570
+ code: "auth"
2571
+ });
2572
+ }
2573
+ function parseCookies(req) {
2574
+ const raw = header(req, "cookie") ?? "";
2575
+ const out = {};
2576
+ for (const part of raw.split(";")) {
2577
+ const index = part.indexOf("=");
2578
+ if (index < 0) continue;
2579
+ const key = part.slice(0, index).trim();
2580
+ const value = part.slice(index + 1).trim();
2581
+ if (key) out[key] = decodeURIComponent(value);
2582
+ }
2583
+ return out;
2584
+ }
2585
+ function cookieHeader(name$1, value, opts) {
2586
+ const parts = [
2587
+ `${name$1}=${encodeURIComponent(value)}`,
2588
+ "Path=/doudizhu",
2589
+ "HttpOnly",
2590
+ `SameSite=${opts.sameSite}`,
2591
+ `Max-Age=${opts.maxAge ?? 86400}`
2592
+ ];
2593
+ if (opts.secure) parts.push("Secure");
2594
+ return parts.join("; ");
2595
+ }
2596
+ function header(req, name$1) {
2597
+ const value = req.headers[name$1];
2598
+ return Array.isArray(value) ? value[0] : value;
2599
+ }
2600
+
2601
+ //#endregion
2602
+ //#region src/net/json.ts
2603
+ /** JSON wire encoding: bigint atoms become decimal strings. */
2604
+ function encodeJson(body) {
2605
+ return JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value);
2606
+ }
2607
+
2608
+ //#endregion
2609
+ //#region src/net/join-page.ts
2610
+ const here = dirname(fileURLToPath(import.meta.url));
2611
+ function joinPageHtml() {
2612
+ const candidates = [
2613
+ join(here, "join/index.html"),
2614
+ join(here, "../join/index.html"),
2615
+ join(process.cwd(), "lib/join/index.html"),
2616
+ join(process.cwd(), "src/join/join.html")
2617
+ ];
2618
+ for (const path of candidates) try {
2619
+ return readFileSync(path, "utf8");
2620
+ } catch {}
2621
+ return `<!doctype html><html><head><meta charset="utf-8"><title>斗地主</title></head><body><p>加入页尚未构建。请运行 pnpm build。</p></body></html>`;
2622
+ }
2623
+ function joinAsset(name$1) {
2624
+ const candidates = [
2625
+ join(here, "join", name$1),
2626
+ join(here, "../join", name$1),
2627
+ join(process.cwd(), "lib/join", name$1),
2628
+ join(process.cwd(), "src/client/assets", name$1)
2629
+ ];
2630
+ for (const path of candidates) try {
2631
+ return {
2632
+ body: readFileSync(path),
2633
+ type: typeOf(name$1)
2634
+ };
2635
+ } catch {}
2636
+ return null;
2637
+ }
2638
+ function typeOf(name$1) {
2639
+ if (name$1.endsWith(".js")) return "application/javascript; charset=utf-8";
2640
+ if (name$1.endsWith(".css")) return "text/css; charset=utf-8";
2641
+ if (name$1.endsWith(".svg")) return "image/svg+xml";
2642
+ if (name$1.endsWith(".jpg") || name$1.endsWith(".jpeg")) return "image/jpeg";
2643
+ if (name$1.endsWith(".png")) return "image/png";
2644
+ if (name$1.endsWith(".map")) return "application/json";
2645
+ return "application/octet-stream";
2646
+ }
2647
+
2648
+ //#endregion
2649
+ //#region src/net/routes.ts
2650
+ function registerDouDizhuHttp(server, manager) {
2651
+ const prefix = manager.getConfig().routePrefix;
2652
+ return [server.register({
2653
+ kind: "prefix",
2654
+ path: prefix,
2655
+ handler: (req, res) => handle(req, res, manager, prefix)
2656
+ })];
2657
+ }
2658
+ async function handle(req, res, manager, prefix) {
2659
+ try {
2660
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2661
+ const path = url.pathname;
2662
+ if (req.method === "GET" && (path === prefix || path === `${prefix}/` || path === `${prefix}/join`)) {
2663
+ html(res, joinPageHtml());
2664
+ return;
2665
+ }
2666
+ if (req.method === "GET" && path.startsWith(`${prefix}/assets/`)) {
2667
+ const asset = joinAsset(path.slice(`${prefix}/assets/`.length));
2668
+ if (!asset) {
2669
+ send$1(res, 404, "");
2670
+ return;
2671
+ }
2672
+ send$1(res, 200, asset.body, {
2673
+ "content-type": asset.type,
2674
+ "cache-control": "no-store"
2675
+ });
2676
+ return;
2677
+ }
2678
+ if (req.method === "GET" && (path === `${prefix}/join.js` || path === `${prefix}/index.js`)) {
2679
+ const asset = joinAsset("index.js");
2680
+ if (!asset) {
2681
+ send$1(res, 404, "");
2682
+ return;
2683
+ }
2684
+ send$1(res, 200, asset.body, {
2685
+ "content-type": asset.type,
2686
+ "cache-control": "no-store"
2687
+ });
2688
+ return;
2689
+ }
2690
+ if (path.startsWith(`${prefix}/api/`)) {
2691
+ await handleApi(req, res, manager, prefix, url);
2692
+ return;
2693
+ }
2694
+ send$1(res, 404, encodeJson({
2695
+ error: "expired",
2696
+ message: "not found"
2697
+ }), { "content-type": "application/json" });
2698
+ } catch (error) {
2699
+ writeError(res, error);
2700
+ }
2701
+ }
2702
+ async function handleApi(req, res, manager, prefix, url) {
2703
+ const config = manager.getConfig();
2704
+ const path = url.pathname.slice(prefix.length);
2705
+ const cookies = parseCookies(req);
2706
+ const secure = config.publicBaseUrl.startsWith("https://");
2707
+ if (req.method === "POST" && path === "/api/join") {
2708
+ assertOrigin(req, config.publicBaseUrl);
2709
+ assertMutatingHeaders(req);
2710
+ const body = await readJson(req);
2711
+ const loopback = isLoopbackHost(header(req, "host"), req.socket.remoteAddress);
2712
+ const result = await manager.join({
2713
+ roomCode: String(body.roomCode ?? ""),
2714
+ invite: String(body.invite ?? ""),
2715
+ displayName: String(body.displayName ?? ""),
2716
+ role: body.role === "watch" ? "watch" : "sit",
2717
+ local: loopback
2718
+ });
2719
+ json(res, 200, {
2720
+ playerId: result.playerId,
2721
+ roomId: result.roomId,
2722
+ seat: result.seat,
2723
+ wsTicket: result.wsTicket,
2724
+ view: result.view
2725
+ }, [cookieHeader("ddz_seat", result.cookie, {
2726
+ secure,
2727
+ sameSite: "Lax"
2728
+ })]);
2729
+ return;
2730
+ }
2731
+ if (req.method === "POST" && path === "/api/rooms") {
2732
+ assertOrigin(req, config.publicBaseUrl);
2733
+ assertMutatingHeaders(req);
2734
+ if (!isLoopbackHost(header(req, "host"), req.socket.remoteAddress) && !manager.isHostCookie(cookies.ddz_host)) throw Object.assign(/* @__PURE__ */ new Error("createRoom requires loopback or host cookie"), {
2735
+ status: 403,
2736
+ code: "auth"
2737
+ });
2738
+ const body = await readJson(req);
2739
+ try {
2740
+ const created = await manager.createRoom({
2741
+ stakeM: Number(body.stakeM ?? config.defaultStakeM),
2742
+ maxMultiplier: Number(body.maxMultiplier ?? config.defaultMaxMultiplier),
2743
+ seatCount: body.seatCount === 4 ? 4 : 3,
2744
+ laiZi: body.laiZi === true,
2745
+ ...body.hostDisplayName ? { hostDisplayName: body.hostDisplayName } : {},
2746
+ ...body.title ? { title: body.title } : {}
2747
+ }, cookies.ddz_host);
2748
+ json(res, 200, {
2749
+ roomId: created.roomId,
2750
+ roomCode: created.roomCode,
2751
+ sitUrl: created.sitUrl,
2752
+ watchUrl: created.watchUrl,
2753
+ seatCapAtoms: created.seatCapAtoms,
2754
+ shareable: created.shareable,
2755
+ hostPlayerId: created.hostPlayerId,
2756
+ view: created.view,
2757
+ wsTicket: manager.issueTicket(created.hostPlayerId, created.roomId)
2758
+ }, [cookieHeader("ddz_host", created.hostCookie, {
2759
+ secure,
2760
+ sameSite: "Strict"
2761
+ }), cookieHeader("ddz_seat", created.hostCookie, {
2762
+ secure,
2763
+ sameSite: "Lax"
2764
+ })]);
2765
+ } catch (error) {
2766
+ if (error instanceof Error && error.name === "welcome-below-seatcap") throw Object.assign(error, {
2767
+ status: 409,
2768
+ code: "insufficient"
2769
+ });
2770
+ throw error;
2771
+ }
2772
+ return;
2773
+ }
2774
+ if (req.method === "GET" && path === "/api/health") {
2775
+ if (!manager.isHostCookie(cookies.ddz_host)) throw Object.assign(/* @__PURE__ */ new Error("host only"), {
2776
+ status: 403,
2777
+ code: "auth"
2778
+ });
2779
+ json(res, 200, manager.health());
2780
+ return;
2781
+ }
2782
+ if (req.method === "GET" && path === "/api/me/ledger") {
2783
+ const session$1 = manager.sessionByCookie(cookies.ddz_seat);
2784
+ if (!session$1) throw Object.assign(/* @__PURE__ */ new Error("not joined"), {
2785
+ status: 403,
2786
+ code: "auth"
2787
+ });
2788
+ json(res, 200, manager.ledgerFor(session$1.playerId));
2789
+ return;
2790
+ }
2791
+ const roomMatch = /^\/api\/rooms\/([^/]+)(?:\/(since|command))?$/.exec(path);
2792
+ if (!roomMatch) {
2793
+ send$1(res, 404, encodeJson({
2794
+ error: "expired",
2795
+ message: "not found"
2796
+ }), { "content-type": "application/json" });
2797
+ return;
2798
+ }
2799
+ const roomId = roomMatch[1];
2800
+ const tail = roomMatch[2];
2801
+ const session = manager.sessionByCookie(cookies.ddz_seat);
2802
+ if (!session || session.roomId !== roomId) throw Object.assign(/* @__PURE__ */ new Error("not in this room"), {
2803
+ status: 403,
2804
+ code: "auth"
2805
+ });
2806
+ if (req.method === "GET" && !tail) {
2807
+ json(res, 200, manager.view(session.roomId, session.playerId));
2808
+ return;
2809
+ }
2810
+ if (req.method === "GET" && tail === "since") {
2811
+ const seq = Number(url.searchParams.get("seq") ?? "0");
2812
+ json(res, 200, manager.eventsSince(session.roomId, session.playerId, Number.isFinite(seq) ? seq : 0));
2813
+ return;
2814
+ }
2815
+ if (req.method === "POST" && tail === "command") {
2816
+ assertOrigin(req, config.publicBaseUrl);
2817
+ assertMutatingHeaders(req);
2818
+ const command = await readJson(req);
2819
+ try {
2820
+ json(res, 200, await manager.command(session.roomId, session.playerId, command, manager.isHostCookie(cookies.ddz_host)));
2821
+ } catch (error) {
2822
+ send$1(res, 409, encodeJson({
2823
+ error: error.code ?? "illegal",
2824
+ message: error instanceof Error ? error.message : "conflict"
2825
+ }), { "content-type": "application/json" });
2826
+ }
2827
+ return;
2828
+ }
2829
+ send$1(res, 404, "");
2830
+ }
2831
+ async function readJson(req) {
2832
+ const chunks = [];
2833
+ let size = 0;
2834
+ await new Promise((resolve, reject) => {
2835
+ req.on("data", (chunk) => {
2836
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2837
+ size += buf.length;
2838
+ if (size > 64 * 1024) {
2839
+ reject(Object.assign(/* @__PURE__ */ new Error("payload too large"), {
2840
+ status: 400,
2841
+ code: "illegal"
2842
+ }));
2843
+ return;
2844
+ }
2845
+ chunks.push(buf);
2846
+ });
2847
+ req.on("end", () => {
2848
+ resolve();
2849
+ });
2850
+ req.on("error", reject);
2851
+ });
2852
+ if (chunks.length === 0) return {};
2853
+ try {
2854
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
2855
+ } catch {
2856
+ throw Object.assign(/* @__PURE__ */ new Error("invalid json"), {
2857
+ status: 400,
2858
+ code: "illegal"
2859
+ });
2860
+ }
2861
+ }
2862
+ function json(res, status, body, cookies) {
2863
+ const headers = {
2864
+ "content-type": "application/json; charset=utf-8",
2865
+ "cache-control": "no-store"
2866
+ };
2867
+ if (cookies && cookies.length > 0) headers["set-cookie"] = cookies;
2868
+ send$1(res, status, encodeJson(body), headers);
2869
+ }
2870
+ function html(res, body) {
2871
+ send$1(res, 200, body, {
2872
+ "content-type": "text/html; charset=utf-8",
2873
+ "cache-control": "no-store"
2874
+ });
2875
+ }
2876
+ function send$1(res, status, body, headers = {}) {
2877
+ if (res.headersSent) return;
2878
+ res.writeHead(status, headers);
2879
+ res.end(body);
2880
+ }
2881
+ function writeError(res, error) {
2882
+ const status = error.status ?? (error.code === "auth" ? 403 : 400);
2883
+ const code = error.code ?? "illegal";
2884
+ if (error instanceof Error && error.message === "welcome-below-seatcap") {
2885
+ send$1(res, 409, encodeJson({
2886
+ error: "insufficient",
2887
+ message: "welcome-below-seatcap"
2888
+ }), { "content-type": "application/json" });
2889
+ return;
2890
+ }
2891
+ const coded = error;
2892
+ if (error instanceof Error && coded.code) {
2893
+ send$1(res, statusFrom(coded), encodeJson({
2894
+ error: coded.code,
2895
+ message: error.message
2896
+ }), { "content-type": "application/json" });
2897
+ return;
2898
+ }
2899
+ send$1(res, status, encodeJson({
2900
+ error: code,
2901
+ message: error instanceof Error ? error.message : "error"
2902
+ }), { "content-type": "application/json" });
2903
+ }
2904
+ function statusFrom(error) {
2905
+ if (error.code === "auth") return 403;
2906
+ if (error.code === "expired") return 404;
2907
+ if (error.code === "room-full" || error.code === "insufficient" || error.code === "phase" || error.code === "duplicate-nonce") return 409;
2908
+ return 400;
2909
+ }
2910
+
2911
+ //#endregion
2912
+ //#region src/net/ws.ts
2913
+ function registerDouDizhuSocket(server, manager) {
2914
+ const wss = new WebSocketServer({
2915
+ noServer: true,
2916
+ handleProtocols: (protocols) => protocols.has(SUBPROTOCOL) ? SUBPROTOCOL : false
2917
+ });
2918
+ const sockets = /* @__PURE__ */ new Map();
2919
+ const off = manager.onEvent((playerId, event) => {
2920
+ for (const [ws, state] of sockets) if (state.playerId === playerId && ws.readyState === WebSocket.OPEN) send(ws, event);
2921
+ });
2922
+ const heartbeat = setInterval(() => {
2923
+ const now = Date.now();
2924
+ for (const [ws, state] of sockets) if (now - state.lastSeen > manager.getConfig().disconnectAfterMs) ws.close();
2925
+ }, manager.getConfig().heartbeatMs);
2926
+ if (typeof heartbeat === "object" && "unref" in heartbeat) heartbeat.unref();
2927
+ const disposeRoute = server.registerUpgrade({
2928
+ path: `${manager.getConfig().routePrefix}/ws`,
2929
+ handler: (req, socket, head) => {
2930
+ try {
2931
+ assertOrigin(req, manager.getConfig().publicBaseUrl);
2932
+ } catch {
2933
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2934
+ socket.destroy();
2935
+ return;
2936
+ }
2937
+ const ticket = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("ticket") ?? "";
2938
+ const consumed = manager.consumeTicket(ticket);
2939
+ if (!consumed) {
2940
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
2941
+ socket.destroy();
2942
+ return;
2943
+ }
2944
+ wss.handleUpgrade(req, socket, head, (ws) => {
2945
+ const state = {
2946
+ playerId: consumed.playerId,
2947
+ roomId: consumed.roomId,
2948
+ lastSeen: Date.now()
2949
+ };
2950
+ sockets.set(ws, state);
2951
+ manager.counters.wsConnected += 1;
2952
+ manager.markConnected(state.playerId, state.roomId);
2953
+ send(ws, {
2954
+ type: "snapshot",
2955
+ seq: 0,
2956
+ view: manager.view(state.roomId, state.playerId)
2957
+ });
2958
+ ws.on("message", (data) => {
2959
+ if (Buffer.byteLength(data.toString()) > MAX_FRAME_BYTES) {
2960
+ ws.close();
2961
+ return;
2962
+ }
2963
+ state.lastSeen = Date.now();
2964
+ onMessage(ws, state, data.toString(), manager);
2965
+ });
2966
+ ws.on("close", () => {
2967
+ sockets.delete(ws);
2968
+ manager.counters.wsConnected = Math.max(0, manager.counters.wsConnected - 1);
2969
+ manager.markDisconnected(state.playerId, state.roomId);
2970
+ });
2971
+ });
2972
+ }
2973
+ });
2974
+ return () => {
2975
+ clearInterval(heartbeat);
2976
+ off();
2977
+ disposeRoute();
2978
+ wss.close();
2979
+ };
2980
+ }
2981
+ async function onMessage(ws, state, raw, manager) {
2982
+ let command;
2983
+ try {
2984
+ command = JSON.parse(raw);
2985
+ } catch {
2986
+ send(ws, {
2987
+ type: "reject",
2988
+ code: "illegal",
2989
+ reason: "invalid json"
2990
+ });
2991
+ return;
2992
+ }
2993
+ try {
2994
+ await manager.command(state.roomId, state.playerId, command);
2995
+ if (command.type === "ping") send(ws, {
2996
+ type: "pong",
2997
+ ts: Date.now()
2998
+ });
2999
+ } catch (error) {
3000
+ error.code;
3001
+ send(ws, {
3002
+ type: "reject",
3003
+ code: error.code ?? "illegal",
3004
+ reason: error instanceof Error ? error.message : "rejected"
3005
+ });
3006
+ }
3007
+ }
3008
+ function send(ws, event) {
3009
+ if (ws.readyState === WebSocket.OPEN) ws.send(encodeJson(event));
3010
+ }
3011
+
3012
+ //#endregion
3013
+ //#region src/index.ts
3014
+ const name = "doudizhu";
3015
+ const inject = ["webServer", "settings"];
3016
+ const DOUDIZHU_NS = settingsNamespace("doudizhu");
3017
+ function apply(ctx, config) {
3018
+ const manager = new RoomManager(ctx, config, ctx.get("storageDomain"));
3019
+ manager.listenPort = () => ctx.webServer.port;
3020
+ ctx.effect(() => {
3021
+ const stop = [...registerDouDizhuHttp(ctx.webServer, manager), registerDouDizhuSocket(ctx.webServer, manager)];
3022
+ return () => {
3023
+ for (const dispose of stop) dispose();
3024
+ manager.dispose();
3025
+ };
3026
+ });
3027
+ let currentConfig = () => config;
3028
+ installSettingsSection(ctx, DOUDIZHU_NS, Config, config, {
3029
+ setSource: (current) => {
3030
+ currentConfig = current;
3031
+ manager.replaceConfig(current());
3032
+ },
3033
+ onChange: () => {
3034
+ manager.replaceConfig(currentConfig());
3035
+ },
3036
+ validate: (value) => {
3037
+ resolveConfig(value);
3038
+ }
3039
+ });
3040
+ ctx.inject(["systemPrompt"], (promptCtx) => {
3041
+ promptCtx.systemPrompt?.section({
3042
+ name: "plugin:doudizhu",
3043
+ order: 200,
3044
+ text: "本机已安装 dsh-poker。开房走侧栏 New Session 下面的「斗地主」,首页可创建或加入,支持 3 人/4 人、经典/癞子。结算是 Host 欢迎积分账本,不是 DeepSeek 平台余额。"
3045
+ });
3046
+ });
3047
+ }
3048
+
3049
+ //#endregion
3050
+ export { Config, DOUDIZHU_NS, apply, inject, name };
3051
+ //# sourceMappingURL=index.js.map