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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2419 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3051 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant-C3ZiFskw.js +13 -0
- package/lib/invariant-C3ZiFskw.js.map +1 -0
- package/lib/invariant.js +3 -0
- package/lib/join/card-back-landlord.png +0 -0
- package/lib/join/card-back.png +0 -0
- package/lib/join/face-a.png +0 -0
- package/lib/join/face-j.png +0 -0
- package/lib/join/face-k.png +0 -0
- package/lib/join/face-q.png +0 -0
- package/lib/join/index.html +13 -0
- package/lib/join/index.js +8910 -0
- package/lib/join/index.js.map +1 -0
- package/lib/join/join.css +14 -0
- package/lib/join/joker-black.png +0 -0
- package/lib/join/joker-red.png +0 -0
- package/lib/join/role-farmer-b.png +0 -0
- package/lib/join/role-farmer-c.png +0 -0
- package/lib/join/role-farmer.png +0 -0
- package/lib/join/role-landlord-b.png +0 -0
- package/lib/join/role-landlord.png +0 -0
- package/lib/join/role-spectator.png +0 -0
- package/lib/types/client/CardBack.d.ts +7 -0
- package/lib/types/client/CardBack.d.ts.map +1 -0
- package/lib/types/client/CardFace.d.ts +8 -0
- package/lib/types/client/CardFace.d.ts.map +1 -0
- package/lib/types/client/HostApp.d.ts +4 -0
- package/lib/types/client/HostApp.d.ts.map +1 -0
- package/lib/types/client/InviteDialog.d.ts +15 -0
- package/lib/types/client/InviteDialog.d.ts.map +1 -0
- package/lib/types/client/JoinForm.d.ts +13 -0
- package/lib/types/client/JoinForm.d.ts.map +1 -0
- package/lib/types/client/LobbyView.d.ts +34 -0
- package/lib/types/client/LobbyView.d.ts.map +1 -0
- package/lib/types/client/SeatAvatar.d.ts +18 -0
- package/lib/types/client/SeatAvatar.d.ts.map +1 -0
- package/lib/types/client/SeatRow.d.ts +8 -0
- package/lib/types/client/SeatRow.d.ts.map +1 -0
- package/lib/types/client/SettingsCard.d.ts +6 -0
- package/lib/types/client/SettingsCard.d.ts.map +1 -0
- package/lib/types/client/SettlementView.d.ts +6 -0
- package/lib/types/client/SettlementView.d.ts.map +1 -0
- package/lib/types/client/TableView.d.ts +16 -0
- package/lib/types/client/TableView.d.ts.map +1 -0
- package/lib/types/client/card-stack.d.ts +3 -0
- package/lib/types/client/card-stack.d.ts.map +1 -0
- package/lib/types/client/host-api.d.ts +50 -0
- package/lib/types/client/host-api.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/slots.d.ts +3 -0
- package/lib/types/client/slots.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +15 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/config.d.ts +76 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/crypto.d.ts +9 -0
- package/lib/types/crypto.d.ts.map +1 -0
- package/lib/types/engine/cards.d.ts +22 -0
- package/lib/types/engine/cards.d.ts.map +1 -0
- package/lib/types/engine/deal.d.ts +11 -0
- package/lib/types/engine/deal.d.ts.map +1 -0
- package/lib/types/engine/hands.d.ts +16 -0
- package/lib/types/engine/hands.d.ts.map +1 -0
- package/lib/types/engine/index.d.ts +7 -0
- package/lib/types/engine/index.d.ts.map +1 -0
- package/lib/types/engine/play.d.ts +73 -0
- package/lib/types/engine/play.d.ts.map +1 -0
- package/lib/types/engine/random.d.ts +2 -0
- package/lib/types/engine/random.d.ts.map +1 -0
- package/lib/types/engine/score.d.ts +11 -0
- package/lib/types/engine/score.d.ts.map +1 -0
- package/lib/types/identity/player.d.ts +6 -0
- package/lib/types/identity/player.d.ts.map +1 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/join/JoinApp.d.ts +2 -0
- package/lib/types/join/JoinApp.d.ts.map +1 -0
- package/lib/types/join/main.d.ts +2 -0
- package/lib/types/join/main.d.ts.map +1 -0
- package/lib/types/net/auth.d.ts +16 -0
- package/lib/types/net/auth.d.ts.map +1 -0
- package/lib/types/net/join-page.d.ts +6 -0
- package/lib/types/net/join-page.d.ts.map +1 -0
- package/lib/types/net/json.d.ts +3 -0
- package/lib/types/net/json.d.ts.map +1 -0
- package/lib/types/net/protocol.d.ts +2 -0
- package/lib/types/net/protocol.d.ts.map +1 -0
- package/lib/types/net/routes.d.ts +4 -0
- package/lib/types/net/routes.d.ts.map +1 -0
- package/lib/types/net/views.d.ts +3 -0
- package/lib/types/net/views.d.ts.map +1 -0
- package/lib/types/net/ws.d.ts +8 -0
- package/lib/types/net/ws.d.ts.map +1 -0
- package/lib/types/persist/domain.d.ts +104 -0
- package/lib/types/persist/domain.d.ts.map +1 -0
- package/lib/types/persist/schemas.d.ts +79 -0
- package/lib/types/persist/schemas.d.ts.map +1 -0
- package/lib/types/room/RoomManager.d.ts +142 -0
- package/lib/types/room/RoomManager.d.ts.map +1 -0
- package/lib/types/settle/audit.d.ts +15 -0
- package/lib/types/settle/audit.d.ts.map +1 -0
- package/lib/types/settle/escrow.d.ts +4 -0
- package/lib/types/settle/escrow.d.ts.map +1 -0
- package/lib/types/settle/grant.d.ts +7 -0
- package/lib/types/settle/grant.d.ts.map +1 -0
- package/lib/types/settle/ledger.d.ts +39 -0
- package/lib/types/settle/ledger.d.ts.map +1 -0
- package/lib/types/settle/math.d.ts +12 -0
- package/lib/types/settle/math.d.ts.map +1 -0
- package/lib/types/types.d.ts +253 -0
- package/lib/types/types.d.ts.map +1 -0
- package/package.json +116 -0
- 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
|