@chipzen-ai/bot 0.3.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 +220 -0
- package/README.md +96 -0
- package/dist/bin.js +2545 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.cjs +2310 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +746 -0
- package/dist/index.d.ts +746 -0
- package/dist/index.js +2244 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,2545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import path5 from "path";
|
|
6
|
+
|
|
7
|
+
// src/run_external.ts
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
import { pathToFileURL } from "url";
|
|
10
|
+
|
|
11
|
+
// src/bot.ts
|
|
12
|
+
var Bot = class {
|
|
13
|
+
/** Called once when the `match_start` message arrives. */
|
|
14
|
+
onMatchStart(_matchInfo) {
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Called at the start of every hand with the raw `round_start` message.
|
|
18
|
+
*
|
|
19
|
+
* Override if you need the Layer 1 envelope (`round_id`,
|
|
20
|
+
* `round_number`) or the full Layer 2 `state` payload. For most bots,
|
|
21
|
+
* `onHandStart` is the simpler hook.
|
|
22
|
+
*/
|
|
23
|
+
onRoundStart(_message) {
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Called when the flop, turn, or river is dealt. Useful for triggering
|
|
27
|
+
* postflop planning *between* your turns rather than inside `decide`.
|
|
28
|
+
*/
|
|
29
|
+
onPhaseChange(_message) {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Called after every participant's action is broadcast — yours and
|
|
33
|
+
* every opponent's. Use for opponent modeling, timing analysis, or
|
|
34
|
+
* stack tracking.
|
|
35
|
+
*
|
|
36
|
+
* This hook runs *before* the next `turn_request` is dispatched, and
|
|
37
|
+
* runs serially. Slow work here eats into your decide budget — see
|
|
38
|
+
* the DEV-MANUAL §6 for the "queue drain" failure mode.
|
|
39
|
+
*/
|
|
40
|
+
onTurnResult(_message) {
|
|
41
|
+
}
|
|
42
|
+
/** Called when a hand ends (`round_result` message). */
|
|
43
|
+
onRoundResult(_message) {
|
|
44
|
+
}
|
|
45
|
+
/** Called once when the match ends. */
|
|
46
|
+
onMatchEnd(_results) {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Called after each `turn_action` is sent, with the wall-clock time
|
|
50
|
+
* `decide()` took in milliseconds.
|
|
51
|
+
*
|
|
52
|
+
* Default is a no-op. Override to track decision latency — useful for
|
|
53
|
+
* spotting when your bot is drifting toward the platform's turn-timeout
|
|
54
|
+
* budget. See chipzen-ai/chipzen-sdk#46.
|
|
55
|
+
*/
|
|
56
|
+
onDecisionLatency(_latencyMs) {
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/config.ts
|
|
61
|
+
import fs from "fs";
|
|
62
|
+
import os from "os";
|
|
63
|
+
import path from "path";
|
|
64
|
+
var CONFIG_FILENAME = "chipzen.toml";
|
|
65
|
+
var SECTION_NAME = "external_api";
|
|
66
|
+
var ChipzenConfigError = class extends Error {
|
|
67
|
+
constructor(message) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "ChipzenConfigError";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function searchPaths() {
|
|
73
|
+
const paths = [
|
|
74
|
+
path.join(process.cwd(), CONFIG_FILENAME),
|
|
75
|
+
path.join(os.homedir(), ".chipzen", CONFIG_FILENAME)
|
|
76
|
+
];
|
|
77
|
+
if (process.platform !== "win32") {
|
|
78
|
+
paths.push(path.join("/etc/chipzen", CONFIG_FILENAME));
|
|
79
|
+
}
|
|
80
|
+
return paths;
|
|
81
|
+
}
|
|
82
|
+
function discoverConfigPath(paths) {
|
|
83
|
+
const candidates = paths ?? searchPaths();
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
try {
|
|
86
|
+
if (fs.statSync(candidate).isFile()) {
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function parseExternalApiSection(raw, filePath) {
|
|
96
|
+
const lines = raw.split(/\r?\n/);
|
|
97
|
+
let inSection = false;
|
|
98
|
+
let sawSection = false;
|
|
99
|
+
const values = {};
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const rawLine = lines[i] ?? "";
|
|
102
|
+
const line = stripComment(rawLine).trim();
|
|
103
|
+
if (line === "") continue;
|
|
104
|
+
if (line.startsWith("[")) {
|
|
105
|
+
if (line.startsWith("[[")) {
|
|
106
|
+
const inner = line.slice(2, line.indexOf("]]"));
|
|
107
|
+
if (inner.trim() === SECTION_NAME) {
|
|
108
|
+
throw new ChipzenConfigError(
|
|
109
|
+
`${filePath}: [${SECTION_NAME}] must be a table (key=value pairs), got an array-of-tables ([[${SECTION_NAME}]]).`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
inSection = false;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const closeIdx = line.indexOf("]");
|
|
116
|
+
if (closeIdx < 0) {
|
|
117
|
+
throw new ChipzenConfigError(
|
|
118
|
+
`${filePath}: malformed section header on line ${i + 1}: ${JSON.stringify(rawLine)}.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const name = line.slice(1, closeIdx).trim();
|
|
122
|
+
inSection = name === SECTION_NAME;
|
|
123
|
+
if (inSection) sawSection = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!inSection) continue;
|
|
127
|
+
const eq = line.indexOf("=");
|
|
128
|
+
if (eq < 0) {
|
|
129
|
+
throw new ChipzenConfigError(
|
|
130
|
+
`${filePath}: Failed to parse line ${i + 1} in [${SECTION_NAME}]: ${JSON.stringify(rawLine)} (expected key = "value").`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const key = line.slice(0, eq).trim();
|
|
134
|
+
const valueText = line.slice(eq + 1).trim();
|
|
135
|
+
if (key === "") {
|
|
136
|
+
throw new ChipzenConfigError(
|
|
137
|
+
`${filePath}: Failed to parse line ${i + 1} in [${SECTION_NAME}]: empty key.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (key === "token" || key === "url" || key === "bot_id") {
|
|
141
|
+
const parsed = parseQuotedString(valueText);
|
|
142
|
+
if (parsed === null) {
|
|
143
|
+
const display = key === "bot_id" ? "bot_id" : key;
|
|
144
|
+
throw new ChipzenConfigError(
|
|
145
|
+
`${filePath}: [${SECTION_NAME}].${display} must be a string, got ${JSON.stringify(valueText)}.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
values[key] = parsed;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!sawSection) {
|
|
152
|
+
throw new ChipzenConfigError(
|
|
153
|
+
`${filePath} has no [${SECTION_NAME}] section. Add one with at least:
|
|
154
|
+
|
|
155
|
+
[${SECTION_NAME}]
|
|
156
|
+
token = "cz_extbot_..."
|
|
157
|
+
`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
token: values.token ?? null,
|
|
162
|
+
url: values.url ?? null,
|
|
163
|
+
botId: values.bot_id ?? null
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function stripComment(line) {
|
|
167
|
+
let inSingle = false;
|
|
168
|
+
let inDouble = false;
|
|
169
|
+
for (let i = 0; i < line.length; i++) {
|
|
170
|
+
const ch = line[i];
|
|
171
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
172
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
173
|
+
else if (ch === "#" && !inSingle && !inDouble) return line.slice(0, i);
|
|
174
|
+
}
|
|
175
|
+
return line;
|
|
176
|
+
}
|
|
177
|
+
function parseQuotedString(text) {
|
|
178
|
+
if (text.length < 2) return null;
|
|
179
|
+
const quote = text[0];
|
|
180
|
+
if (quote !== '"' && quote !== "'") return null;
|
|
181
|
+
if (text[text.length - 1] !== quote) return null;
|
|
182
|
+
const inner = text.slice(1, -1);
|
|
183
|
+
if (quote === "'") {
|
|
184
|
+
if (inner.includes("'")) return null;
|
|
185
|
+
return inner;
|
|
186
|
+
}
|
|
187
|
+
let out = "";
|
|
188
|
+
for (let i = 0; i < inner.length; i++) {
|
|
189
|
+
const ch = inner[i];
|
|
190
|
+
if (ch === "\\") {
|
|
191
|
+
const next = inner[i + 1];
|
|
192
|
+
if (next === '"' || next === "\\") {
|
|
193
|
+
out += next;
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (next === "n") {
|
|
198
|
+
out += "\n";
|
|
199
|
+
i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (next === "t") {
|
|
203
|
+
out += " ";
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
out += ch;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ch === '"') return null;
|
|
211
|
+
out += ch;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
function loadChipzenConfig(paths) {
|
|
216
|
+
const filePath = discoverConfigPath(paths);
|
|
217
|
+
if (filePath === null) return null;
|
|
218
|
+
let raw;
|
|
219
|
+
try {
|
|
220
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
221
|
+
} catch (err) {
|
|
222
|
+
throw new ChipzenConfigError(`Failed to read ${filePath}: ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
const { token, url, botId } = parseExternalApiSection(raw, filePath);
|
|
225
|
+
return { path: filePath, token, url, botId };
|
|
226
|
+
}
|
|
227
|
+
function resolveToken(opts) {
|
|
228
|
+
if (opts.explicitToken !== void 0 && opts.explicitToken !== null) {
|
|
229
|
+
return opts.explicitToken;
|
|
230
|
+
}
|
|
231
|
+
if (opts.explicitTicket !== void 0 && opts.explicitTicket !== null) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (opts.config && opts.config.token !== null) {
|
|
235
|
+
return opts.config.token;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
function resolveUrl(opts) {
|
|
240
|
+
if (opts.explicitUrl !== void 0 && opts.explicitUrl !== null) {
|
|
241
|
+
return opts.explicitUrl;
|
|
242
|
+
}
|
|
243
|
+
if (opts.config && opts.config.url !== null) {
|
|
244
|
+
return opts.config.url;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/retry.ts
|
|
250
|
+
var RetryPolicy = class {
|
|
251
|
+
maxReconnectAttempts;
|
|
252
|
+
initialBackoffMs;
|
|
253
|
+
maxBackoffMs;
|
|
254
|
+
backoffMultiplier;
|
|
255
|
+
constructor(options = {}) {
|
|
256
|
+
const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
257
|
+
const initialBackoffMs = options.initialBackoffMs ?? 500;
|
|
258
|
+
const maxBackoffMs = options.maxBackoffMs ?? 3e4;
|
|
259
|
+
const backoffMultiplier = options.backoffMultiplier ?? 2;
|
|
260
|
+
if (maxReconnectAttempts < 0) {
|
|
261
|
+
throw new Error(`maxReconnectAttempts must be >= 0, got ${maxReconnectAttempts}`);
|
|
262
|
+
}
|
|
263
|
+
if (initialBackoffMs < 0) {
|
|
264
|
+
throw new Error(`initialBackoffMs must be >= 0, got ${initialBackoffMs}`);
|
|
265
|
+
}
|
|
266
|
+
if (maxBackoffMs < initialBackoffMs) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`maxBackoffMs must be >= initialBackoffMs (${maxBackoffMs} < ${initialBackoffMs})`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (backoffMultiplier < 1) {
|
|
272
|
+
throw new Error(`backoffMultiplier must be >= 1.0, got ${backoffMultiplier}`);
|
|
273
|
+
}
|
|
274
|
+
this.maxReconnectAttempts = maxReconnectAttempts;
|
|
275
|
+
this.initialBackoffMs = initialBackoffMs;
|
|
276
|
+
this.maxBackoffMs = maxBackoffMs;
|
|
277
|
+
this.backoffMultiplier = backoffMultiplier;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Return the delay (in ms) to wait **before** the given attempt.
|
|
281
|
+
*
|
|
282
|
+
* @param attempt 1-indexed attempt number. `attempt=1` is the first
|
|
283
|
+
* reconnect after a drop, `attempt=2` the second, etc.
|
|
284
|
+
* @returns The delay in milliseconds, capped at `maxBackoffMs`.
|
|
285
|
+
* @throws Error if `attempt < 1`.
|
|
286
|
+
*/
|
|
287
|
+
backoffMs(attempt) {
|
|
288
|
+
if (attempt < 1) {
|
|
289
|
+
throw new Error(`attempt must be >= 1, got ${attempt}`);
|
|
290
|
+
}
|
|
291
|
+
const raw = this.initialBackoffMs * this.backoffMultiplier ** (attempt - 1);
|
|
292
|
+
return Math.floor(Math.min(raw, this.maxBackoffMs));
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
var DEFAULT_RETRY_POLICY = new RetryPolicy();
|
|
296
|
+
|
|
297
|
+
// src/connect.ts
|
|
298
|
+
var ENV_NAMES = ["prod", "staging", "local"];
|
|
299
|
+
var ENV_VAR_NAME = "CHIPZEN_ENV";
|
|
300
|
+
var ENV_URL_TEMPLATES = {
|
|
301
|
+
prod: "wss://chipzen.ai/ws/external/bot/{botId}",
|
|
302
|
+
staging: "wss://staging.chipzen.ai/ws/external/bot/{botId}",
|
|
303
|
+
local: "ws://localhost:8001/ws/external/bot/{botId}"
|
|
304
|
+
};
|
|
305
|
+
function isEnvName(value) {
|
|
306
|
+
return ENV_NAMES.includes(value);
|
|
307
|
+
}
|
|
308
|
+
function resolveEnvName(explicitEnv, envVarValue) {
|
|
309
|
+
if (explicitEnv !== void 0 && explicitEnv !== null) {
|
|
310
|
+
if (!isEnvName(explicitEnv)) {
|
|
311
|
+
throw new Error(`Unknown env ${JSON.stringify(explicitEnv)}. Valid values: ${ENV_NAMES.join(", ")}.`);
|
|
312
|
+
}
|
|
313
|
+
return explicitEnv;
|
|
314
|
+
}
|
|
315
|
+
if (envVarValue) {
|
|
316
|
+
if (!isEnvName(envVarValue)) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`${ENV_VAR_NAME}=${JSON.stringify(envVarValue)} is not a recognized environment. Valid values: ${ENV_NAMES.join(", ")}.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return envVarValue;
|
|
322
|
+
}
|
|
323
|
+
return "prod";
|
|
324
|
+
}
|
|
325
|
+
function urlForEnv(env, botId) {
|
|
326
|
+
return ENV_URL_TEMPLATES[env].replace("{botId}", botId);
|
|
327
|
+
}
|
|
328
|
+
function connectToChipzen(botId, env, options = {}) {
|
|
329
|
+
if (!botId || typeof botId !== "string") {
|
|
330
|
+
throw new Error(
|
|
331
|
+
"connectToChipzen() requires a non-empty botId string. Pass the external-API bot UUID issued by the Chipzen platform, e.g. connectToChipzen('abc123', 'prod')."
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const resolvedEnv = resolveEnvName(env, process.env[ENV_VAR_NAME]);
|
|
335
|
+
const envDerivedUrl = urlForEnv(resolvedEnv, botId);
|
|
336
|
+
const config = options.config !== void 0 ? options.config : loadChipzenConfig();
|
|
337
|
+
const configUrl = resolveUrl({ explicitUrl: null, config });
|
|
338
|
+
let finalUrl;
|
|
339
|
+
let envForReturn;
|
|
340
|
+
if (configUrl === null) {
|
|
341
|
+
finalUrl = envDerivedUrl;
|
|
342
|
+
envForReturn = resolvedEnv;
|
|
343
|
+
} else {
|
|
344
|
+
finalUrl = configUrl;
|
|
345
|
+
envForReturn = null;
|
|
346
|
+
}
|
|
347
|
+
const token = resolveToken({ explicitToken: null, config });
|
|
348
|
+
const retryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
349
|
+
return {
|
|
350
|
+
url: finalUrl,
|
|
351
|
+
token,
|
|
352
|
+
retryPolicy,
|
|
353
|
+
env: envForReturn,
|
|
354
|
+
config: config ?? null
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/external.ts
|
|
359
|
+
import WebSocket2 from "ws";
|
|
360
|
+
|
|
361
|
+
// src/client.ts
|
|
362
|
+
import WebSocket from "ws";
|
|
363
|
+
|
|
364
|
+
// src/models.ts
|
|
365
|
+
var VALID_RANKS = /* @__PURE__ */ new Set([
|
|
366
|
+
"2",
|
|
367
|
+
"3",
|
|
368
|
+
"4",
|
|
369
|
+
"5",
|
|
370
|
+
"6",
|
|
371
|
+
"7",
|
|
372
|
+
"8",
|
|
373
|
+
"9",
|
|
374
|
+
"T",
|
|
375
|
+
"J",
|
|
376
|
+
"Q",
|
|
377
|
+
"K",
|
|
378
|
+
"A"
|
|
379
|
+
]);
|
|
380
|
+
var VALID_SUITS = /* @__PURE__ */ new Set(["h", "d", "c", "s"]);
|
|
381
|
+
function cardFromString(s) {
|
|
382
|
+
if (typeof s !== "string" || s.length !== 2) {
|
|
383
|
+
throw new Error(`Invalid card string: ${JSON.stringify(s)} (expected 2 chars)`);
|
|
384
|
+
}
|
|
385
|
+
const rank = s[0];
|
|
386
|
+
const suit = s[1];
|
|
387
|
+
if (!VALID_RANKS.has(rank)) {
|
|
388
|
+
throw new Error(`Invalid card rank: ${JSON.stringify(rank)} in ${JSON.stringify(s)}`);
|
|
389
|
+
}
|
|
390
|
+
if (!VALID_SUITS.has(suit)) {
|
|
391
|
+
throw new Error(`Invalid card suit: ${JSON.stringify(suit)} in ${JSON.stringify(s)}`);
|
|
392
|
+
}
|
|
393
|
+
return { rank, suit };
|
|
394
|
+
}
|
|
395
|
+
var Action = class _Action {
|
|
396
|
+
constructor(action, amount) {
|
|
397
|
+
this.action = action;
|
|
398
|
+
this.amount = amount;
|
|
399
|
+
if (action === "raise") {
|
|
400
|
+
if (amount === void 0 || !Number.isFinite(amount) || amount < 0) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Action.raiseTo requires a non-negative finite amount; got ${amount}`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
} else if (amount !== void 0) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`Only raise actions take an amount; ${action} got amount=${amount}`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
action;
|
|
412
|
+
amount;
|
|
413
|
+
static fold() {
|
|
414
|
+
return new _Action("fold");
|
|
415
|
+
}
|
|
416
|
+
static check() {
|
|
417
|
+
return new _Action("check");
|
|
418
|
+
}
|
|
419
|
+
static call() {
|
|
420
|
+
return new _Action("call");
|
|
421
|
+
}
|
|
422
|
+
static raiseTo(amount) {
|
|
423
|
+
return new _Action("raise", amount);
|
|
424
|
+
}
|
|
425
|
+
static allIn() {
|
|
426
|
+
return new _Action("all_in");
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Serialize to the two-layer `turn_action` payload shape the server expects.
|
|
430
|
+
*
|
|
431
|
+
* Returns `{action, params}` where `params` carries the raise amount
|
|
432
|
+
* for `raise` and is empty for everything else.
|
|
433
|
+
*/
|
|
434
|
+
toWire() {
|
|
435
|
+
const params = {};
|
|
436
|
+
if (this.action === "raise" && this.amount !== void 0) {
|
|
437
|
+
params.amount = this.amount;
|
|
438
|
+
}
|
|
439
|
+
return { action: this.action, params };
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
function parseGameState(message) {
|
|
443
|
+
const state = message?.state ?? {};
|
|
444
|
+
const holeStrs = state["your_hole_cards"] ?? [];
|
|
445
|
+
const boardStrs = state["board"] ?? [];
|
|
446
|
+
const validActions = message?.valid_actions ?? state["valid_actions"] ?? [];
|
|
447
|
+
const actionHistoryRaw = state["action_history"] ?? [];
|
|
448
|
+
return {
|
|
449
|
+
handNumber: numberOrZero(state["hand_number"]),
|
|
450
|
+
phase: state["phase"] ?? "preflop",
|
|
451
|
+
holeCards: holeStrs.map(cardFromString),
|
|
452
|
+
board: boardStrs.map(cardFromString),
|
|
453
|
+
pot: numberOrZero(state["pot"]),
|
|
454
|
+
yourStack: numberOrZero(state["your_stack"]),
|
|
455
|
+
opponentStacks: (state["opponent_stacks"] ?? []).map(Number),
|
|
456
|
+
yourSeat: numberOrZero(state["your_seat"]),
|
|
457
|
+
dealerSeat: numberOrZero(state["dealer_seat"]),
|
|
458
|
+
toCall: numberOrZero(state["to_call"]),
|
|
459
|
+
minRaise: numberOrZero(state["min_raise"]),
|
|
460
|
+
maxRaise: numberOrZero(state["max_raise"]),
|
|
461
|
+
validActions,
|
|
462
|
+
actionHistory: actionHistoryRaw.map(parseHistoryEntry),
|
|
463
|
+
roundId: message?.round_id ?? "",
|
|
464
|
+
requestId: message?.request_id ?? ""
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function parseHistoryEntry(raw) {
|
|
468
|
+
const entry = {
|
|
469
|
+
seat: numberOrZero(raw.seat),
|
|
470
|
+
action: raw.action ?? ""
|
|
471
|
+
};
|
|
472
|
+
if (typeof raw.amount === "number") entry.amount = raw.amount;
|
|
473
|
+
if (typeof raw.is_timeout === "boolean") entry.isTimeout = raw.is_timeout;
|
|
474
|
+
return entry;
|
|
475
|
+
}
|
|
476
|
+
function numberOrZero(v) {
|
|
477
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// package.json
|
|
481
|
+
var package_default = {
|
|
482
|
+
name: "@chipzen-ai/bot",
|
|
483
|
+
version: "0.3.0",
|
|
484
|
+
description: "Build, test, and deploy poker bots for the Chipzen AI competition platform",
|
|
485
|
+
license: "Apache-2.0",
|
|
486
|
+
author: {
|
|
487
|
+
name: "Chipzen, Inc.",
|
|
488
|
+
email: "support@chipzen.ai"
|
|
489
|
+
},
|
|
490
|
+
homepage: "https://chipzen.ai",
|
|
491
|
+
repository: {
|
|
492
|
+
type: "git",
|
|
493
|
+
url: "git+https://github.com/chipzen-ai/chipzen-sdk.git",
|
|
494
|
+
directory: "packages/javascript"
|
|
495
|
+
},
|
|
496
|
+
bugs: {
|
|
497
|
+
url: "https://github.com/chipzen-ai/chipzen-sdk/issues"
|
|
498
|
+
},
|
|
499
|
+
keywords: [
|
|
500
|
+
"poker",
|
|
501
|
+
"bot",
|
|
502
|
+
"ai",
|
|
503
|
+
"competition",
|
|
504
|
+
"chipzen",
|
|
505
|
+
"sdk",
|
|
506
|
+
"websocket"
|
|
507
|
+
],
|
|
508
|
+
type: "module",
|
|
509
|
+
main: "./dist/index.cjs",
|
|
510
|
+
module: "./dist/index.js",
|
|
511
|
+
types: "./dist/index.d.ts",
|
|
512
|
+
exports: {
|
|
513
|
+
".": {
|
|
514
|
+
types: "./dist/index.d.ts",
|
|
515
|
+
import: "./dist/index.js",
|
|
516
|
+
require: "./dist/index.cjs"
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
bin: {
|
|
520
|
+
"chipzen-sdk": "./dist/bin.js"
|
|
521
|
+
},
|
|
522
|
+
files: [
|
|
523
|
+
"dist",
|
|
524
|
+
"README.md",
|
|
525
|
+
"CHANGELOG.md"
|
|
526
|
+
],
|
|
527
|
+
publishConfig: {
|
|
528
|
+
access: "public",
|
|
529
|
+
provenance: true
|
|
530
|
+
},
|
|
531
|
+
engines: {
|
|
532
|
+
node: ">=20"
|
|
533
|
+
},
|
|
534
|
+
dependencies: {
|
|
535
|
+
ws: "^8.18.0"
|
|
536
|
+
},
|
|
537
|
+
devDependencies: {
|
|
538
|
+
"@types/node": "^20.14.0",
|
|
539
|
+
"@types/ws": "^8.5.12",
|
|
540
|
+
tsup: "^8.3.0",
|
|
541
|
+
typescript: "^5.6.0",
|
|
542
|
+
vitest: "^2.1.0"
|
|
543
|
+
},
|
|
544
|
+
scripts: {
|
|
545
|
+
build: "tsup",
|
|
546
|
+
test: "vitest run",
|
|
547
|
+
"test:watch": "vitest",
|
|
548
|
+
typecheck: "tsc --noEmit",
|
|
549
|
+
lint: "tsc --noEmit",
|
|
550
|
+
prepack: "pnpm build"
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// src/version.ts
|
|
555
|
+
var VERSION = package_default.version;
|
|
556
|
+
|
|
557
|
+
// src/client.ts
|
|
558
|
+
var BotDecisionError = class extends Error {
|
|
559
|
+
constructor(message) {
|
|
560
|
+
super(message);
|
|
561
|
+
this.name = "BotDecisionError";
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
var SUPPORTED_PROTOCOL_VERSIONS = ["1.0"];
|
|
565
|
+
function _decide(bot, state, validActions, safeMode) {
|
|
566
|
+
const start = Date.now();
|
|
567
|
+
let action;
|
|
568
|
+
try {
|
|
569
|
+
action = bot.decide(state);
|
|
570
|
+
if (!(action instanceof Action)) {
|
|
571
|
+
if (!safeMode) {
|
|
572
|
+
throw new BotDecisionError(
|
|
573
|
+
`bot.decide() returned a non-Action value (${typeof action})`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
action = _safeFallbackAction(validActions);
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
if (err instanceof BotDecisionError) throw err;
|
|
580
|
+
if (!safeMode) {
|
|
581
|
+
throw new BotDecisionError(err instanceof Error ? err.message : String(err));
|
|
582
|
+
}
|
|
583
|
+
action = _safeFallbackAction(validActions);
|
|
584
|
+
}
|
|
585
|
+
return { action, latencyMs: Date.now() - start };
|
|
586
|
+
}
|
|
587
|
+
async function _runSession(ws, bot, ctx, readerOverride) {
|
|
588
|
+
const reader = readerOverride ?? new _NodeWebSocketReader(ws);
|
|
589
|
+
const safeMode = ctx.safeMode ?? true;
|
|
590
|
+
const auth = {
|
|
591
|
+
type: "authenticate",
|
|
592
|
+
match_id: ctx.matchId,
|
|
593
|
+
client_name: ctx.clientName,
|
|
594
|
+
client_version: ctx.clientVersion
|
|
595
|
+
};
|
|
596
|
+
if (ctx.token) auth.token = ctx.token;
|
|
597
|
+
if (ctx.ticket) auth.ticket = ctx.ticket;
|
|
598
|
+
await sendJson(ws, auth);
|
|
599
|
+
const helloRaw = await reader.next();
|
|
600
|
+
if (!helloRaw) {
|
|
601
|
+
throw new Error("connection closed before server hello");
|
|
602
|
+
}
|
|
603
|
+
const hello = parseJson(helloRaw);
|
|
604
|
+
if (hello.type !== "hello") {
|
|
605
|
+
throw new Error(`expected server hello, got ${hello.type ?? "<no type>"}`);
|
|
606
|
+
}
|
|
607
|
+
await sendJson(ws, {
|
|
608
|
+
type: "hello",
|
|
609
|
+
match_id: ctx.matchId,
|
|
610
|
+
supported_versions: [...SUPPORTED_PROTOCOL_VERSIONS]
|
|
611
|
+
});
|
|
612
|
+
let lastSeq = 0;
|
|
613
|
+
for (; ; ) {
|
|
614
|
+
const raw = await reader.next();
|
|
615
|
+
if (raw === null) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
let msg;
|
|
619
|
+
try {
|
|
620
|
+
msg = parseJson(raw);
|
|
621
|
+
} catch {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (typeof msg.seq === "number" && msg.seq <= lastSeq) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (typeof msg.seq === "number") lastSeq = msg.seq;
|
|
628
|
+
const type = msg.type;
|
|
629
|
+
switch (type) {
|
|
630
|
+
case "ping":
|
|
631
|
+
await sendJson(ws, { type: "pong", match_id: ctx.matchId });
|
|
632
|
+
break;
|
|
633
|
+
case "match_start":
|
|
634
|
+
bot.onMatchStart(msg);
|
|
635
|
+
break;
|
|
636
|
+
case "round_start":
|
|
637
|
+
bot.onRoundStart(msg);
|
|
638
|
+
break;
|
|
639
|
+
case "phase_change":
|
|
640
|
+
bot.onPhaseChange(msg);
|
|
641
|
+
break;
|
|
642
|
+
case "turn_result":
|
|
643
|
+
bot.onTurnResult(msg);
|
|
644
|
+
break;
|
|
645
|
+
case "round_result":
|
|
646
|
+
bot.onRoundResult(msg);
|
|
647
|
+
break;
|
|
648
|
+
case "turn_request": {
|
|
649
|
+
const requestId = msg.request_id ?? "";
|
|
650
|
+
const state = parseGameState(msg);
|
|
651
|
+
const { action, latencyMs } = _decide(
|
|
652
|
+
bot,
|
|
653
|
+
state,
|
|
654
|
+
msg.valid_actions,
|
|
655
|
+
safeMode
|
|
656
|
+
);
|
|
657
|
+
await sendJson(ws, {
|
|
658
|
+
type: "turn_action",
|
|
659
|
+
match_id: ctx.matchId,
|
|
660
|
+
request_id: requestId,
|
|
661
|
+
...action.toWire()
|
|
662
|
+
});
|
|
663
|
+
bot.onDecisionLatency(latencyMs);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "action_rejected": {
|
|
667
|
+
const requestId = msg.request_id ?? "";
|
|
668
|
+
const validActions = msg.valid_actions ?? ["fold"];
|
|
669
|
+
const fallback = _safeFallbackAction(validActions);
|
|
670
|
+
await sendJson(ws, {
|
|
671
|
+
type: "turn_action",
|
|
672
|
+
match_id: ctx.matchId,
|
|
673
|
+
request_id: requestId,
|
|
674
|
+
...fallback.toWire()
|
|
675
|
+
});
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
case "reconnected": {
|
|
679
|
+
const pending = msg.pending_request;
|
|
680
|
+
if (pending && pending.type === "turn_request") {
|
|
681
|
+
const requestId = pending.request_id ?? "";
|
|
682
|
+
const { action, latencyMs } = _decide(
|
|
683
|
+
bot,
|
|
684
|
+
parseGameState(pending),
|
|
685
|
+
pending.valid_actions,
|
|
686
|
+
safeMode
|
|
687
|
+
);
|
|
688
|
+
await sendJson(ws, {
|
|
689
|
+
type: "turn_action",
|
|
690
|
+
match_id: ctx.matchId,
|
|
691
|
+
request_id: requestId,
|
|
692
|
+
...action.toWire()
|
|
693
|
+
});
|
|
694
|
+
bot.onDecisionLatency(latencyMs);
|
|
695
|
+
}
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "match_end":
|
|
699
|
+
bot.onMatchEnd(msg.results ?? msg);
|
|
700
|
+
return msg;
|
|
701
|
+
// clean exit — hand the match_end payload to the caller
|
|
702
|
+
case "error":
|
|
703
|
+
break;
|
|
704
|
+
default:
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
var _NodeWebSocketReader = class {
|
|
710
|
+
queue = [];
|
|
711
|
+
waiters = [];
|
|
712
|
+
closed = false;
|
|
713
|
+
constructor(ws) {
|
|
714
|
+
ws.on("message", (data) => {
|
|
715
|
+
const msg = data.toString();
|
|
716
|
+
const w = this.waiters.shift();
|
|
717
|
+
if (w) w(msg);
|
|
718
|
+
else this.queue.push(msg);
|
|
719
|
+
});
|
|
720
|
+
ws.on("close", () => this._close());
|
|
721
|
+
ws.on("error", () => this._close());
|
|
722
|
+
}
|
|
723
|
+
async next() {
|
|
724
|
+
const queued = this.queue.shift();
|
|
725
|
+
if (queued !== void 0) return queued;
|
|
726
|
+
if (this.closed) return null;
|
|
727
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
728
|
+
}
|
|
729
|
+
_close() {
|
|
730
|
+
if (this.closed) return;
|
|
731
|
+
this.closed = true;
|
|
732
|
+
while (this.waiters.length) {
|
|
733
|
+
const w = this.waiters.shift();
|
|
734
|
+
if (w) w(null);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
function _safeFallbackAction(validActions) {
|
|
739
|
+
const valid = new Set(validActions ?? []);
|
|
740
|
+
if (valid.has("check")) return Action.check();
|
|
741
|
+
return Action.fold();
|
|
742
|
+
}
|
|
743
|
+
async function sendJson(ws, msg) {
|
|
744
|
+
const payload = JSON.stringify(msg);
|
|
745
|
+
const result = ws.send(payload);
|
|
746
|
+
if (result && typeof result.then === "function") {
|
|
747
|
+
await result;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function parseJson(raw) {
|
|
751
|
+
return JSON.parse(raw);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/external.ts
|
|
755
|
+
var BOT_TOKEN_SUBPROTOCOL = "chipzen-bot-token";
|
|
756
|
+
var LOBBY_RECV_TIMEOUT_MS = 2e3;
|
|
757
|
+
var MATCH_DRAIN_GRACE_MS = 5e3;
|
|
758
|
+
function botTokenSubprotocols(token) {
|
|
759
|
+
return [BOT_TOKEN_SUBPROTOCOL, token];
|
|
760
|
+
}
|
|
761
|
+
function normaliseBase(url) {
|
|
762
|
+
try {
|
|
763
|
+
const u = new URL(url);
|
|
764
|
+
return `${u.protocol}//${u.host}`;
|
|
765
|
+
} catch {
|
|
766
|
+
const host = url.replace(/\/+$/, "");
|
|
767
|
+
return `wss://${host}`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function resolveGatewayUrl(lobbyUrl, gatewayWsPath) {
|
|
771
|
+
if (gatewayWsPath.startsWith("ws://") || gatewayWsPath.startsWith("wss://")) {
|
|
772
|
+
const lobby = new URL(normaliseBase(lobbyUrl));
|
|
773
|
+
const gateway = new URL(gatewayWsPath);
|
|
774
|
+
const downgrade = lobby.protocol === "wss:" && gateway.protocol !== "wss:";
|
|
775
|
+
if (gateway.host !== lobby.host || downgrade) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`refusing gateway URL ${gatewayWsPath}: cross-origin or insecure relative to lobby ${lobby.protocol}//${lobby.host} (the bot token must not be sent to a different host or in cleartext)`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return gatewayWsPath;
|
|
781
|
+
}
|
|
782
|
+
return `${normaliseBase(lobbyUrl)}${gatewayWsPath}`;
|
|
783
|
+
}
|
|
784
|
+
function loads(raw) {
|
|
785
|
+
try {
|
|
786
|
+
const msg = JSON.parse(raw);
|
|
787
|
+
return msg !== null && typeof msg === "object" && !Array.isArray(msg) ? msg : {};
|
|
788
|
+
} catch {
|
|
789
|
+
return {};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function asFactory(bot) {
|
|
793
|
+
if (bot && typeof bot.decide === "function") {
|
|
794
|
+
const instance = bot;
|
|
795
|
+
return () => instance;
|
|
796
|
+
}
|
|
797
|
+
if (typeof bot === "function") {
|
|
798
|
+
return bot;
|
|
799
|
+
}
|
|
800
|
+
throw new TypeError(
|
|
801
|
+
`runExternalBot(bot=...) must be a Bot instance or a callable returning one, got ${typeof bot}.`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
var MAX_WS_PAYLOAD = 2 ** 24;
|
|
805
|
+
var defaultTransport = async (url, options) => {
|
|
806
|
+
const ws = new WebSocket2(url, options.subprotocols ?? [], {
|
|
807
|
+
headers: { "User-Agent": options.userAgent },
|
|
808
|
+
maxPayload: MAX_WS_PAYLOAD
|
|
809
|
+
});
|
|
810
|
+
await new Promise((resolve, reject) => {
|
|
811
|
+
const onOpen = () => {
|
|
812
|
+
ws.removeListener("error", onError);
|
|
813
|
+
resolve();
|
|
814
|
+
};
|
|
815
|
+
const onError = (err) => {
|
|
816
|
+
ws.removeListener("open", onOpen);
|
|
817
|
+
reject(err);
|
|
818
|
+
};
|
|
819
|
+
ws.once("open", onOpen);
|
|
820
|
+
ws.once("error", onError);
|
|
821
|
+
});
|
|
822
|
+
return {
|
|
823
|
+
send: (data) => ws.send(data),
|
|
824
|
+
reader: new _NodeWebSocketReader(ws),
|
|
825
|
+
close: () => {
|
|
826
|
+
if (ws.readyState !== WebSocket2.CLOSED && ws.readyState !== WebSocket2.CLOSING) {
|
|
827
|
+
ws.close();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
};
|
|
832
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
833
|
+
async function recvWithTimeout(reader, timeoutMs) {
|
|
834
|
+
let timer;
|
|
835
|
+
const timeout = new Promise((resolve) => {
|
|
836
|
+
timer = setTimeout(() => resolve(void 0), timeoutMs);
|
|
837
|
+
});
|
|
838
|
+
try {
|
|
839
|
+
return await Promise.race([reader.next(), timeout]);
|
|
840
|
+
} finally {
|
|
841
|
+
if (timer) clearTimeout(timer);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function playOneMatch(params) {
|
|
845
|
+
const { gatewayUrl, matchId, token, bot, policy, transport } = params;
|
|
846
|
+
let attempt = 0;
|
|
847
|
+
for (; ; ) {
|
|
848
|
+
let conn = null;
|
|
849
|
+
let reason;
|
|
850
|
+
try {
|
|
851
|
+
conn = await transport(gatewayUrl, {
|
|
852
|
+
userAgent: params.userAgent,
|
|
853
|
+
subprotocols: botTokenSubprotocols(token)
|
|
854
|
+
});
|
|
855
|
+
const end = await _runSession(
|
|
856
|
+
conn,
|
|
857
|
+
bot,
|
|
858
|
+
{
|
|
859
|
+
matchId,
|
|
860
|
+
token: null,
|
|
861
|
+
ticket: null,
|
|
862
|
+
clientName: params.clientName,
|
|
863
|
+
clientVersion: params.clientVersion,
|
|
864
|
+
safeMode: params.safeMode
|
|
865
|
+
},
|
|
866
|
+
conn.reader
|
|
867
|
+
);
|
|
868
|
+
if (end !== null) {
|
|
869
|
+
return end;
|
|
870
|
+
}
|
|
871
|
+
reason = "closed without match_end";
|
|
872
|
+
} catch (err) {
|
|
873
|
+
if (err instanceof BotDecisionError) {
|
|
874
|
+
throw err;
|
|
875
|
+
}
|
|
876
|
+
reason = err instanceof Error ? err.message || err.constructor.name : String(err);
|
|
877
|
+
} finally {
|
|
878
|
+
if (conn) conn.close();
|
|
879
|
+
}
|
|
880
|
+
attempt += 1;
|
|
881
|
+
if (attempt > policy.maxReconnectAttempts) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
await sleep(policy.backoffMs(attempt));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function runLobbyOnce(p) {
|
|
888
|
+
const onMatchSettled = (end, err) => {
|
|
889
|
+
if (err !== void 0 && err !== null) {
|
|
890
|
+
if (err instanceof BotDecisionError) {
|
|
891
|
+
p.fatal.push(err);
|
|
892
|
+
p.stop.value = true;
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
p.results.push({ matchId: null, end: null });
|
|
896
|
+
} else {
|
|
897
|
+
p.completed.value += 1;
|
|
898
|
+
p.results.push({
|
|
899
|
+
matchId: end?.match_id ?? null,
|
|
900
|
+
end: end ?? null
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
if (p.maxMatches !== null && p.completed.value >= p.maxMatches) {
|
|
904
|
+
p.stop.value = true;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
const conn = await p.transport(p.lobbyUrl, { userAgent: p.userAgent });
|
|
908
|
+
try {
|
|
909
|
+
await conn.send(JSON.stringify({ type: "authenticate", token: p.token }));
|
|
910
|
+
while (!p.stop.value) {
|
|
911
|
+
const raw = await recvWithTimeout(conn.reader, LOBBY_RECV_TIMEOUT_MS);
|
|
912
|
+
if (raw === void 0) {
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (raw === null) {
|
|
916
|
+
return "closed";
|
|
917
|
+
}
|
|
918
|
+
const msg = loads(raw);
|
|
919
|
+
const mtype = msg.type;
|
|
920
|
+
if (mtype === "ping") {
|
|
921
|
+
await conn.send(JSON.stringify({ type: "pong" }));
|
|
922
|
+
} else if (mtype === "hello") {
|
|
923
|
+
} else if (mtype === "matched") {
|
|
924
|
+
let gatewayUrl;
|
|
925
|
+
try {
|
|
926
|
+
gatewayUrl = resolveGatewayUrl(p.lobbyUrl, msg.gateway_ws_url);
|
|
927
|
+
} catch {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const matchId = msg.match_id;
|
|
931
|
+
const task = { promise: Promise.resolve(), done: false };
|
|
932
|
+
task.promise = playOneMatch({
|
|
933
|
+
gatewayUrl,
|
|
934
|
+
matchId,
|
|
935
|
+
token: p.token,
|
|
936
|
+
bot: p.factory(),
|
|
937
|
+
policy: p.policy,
|
|
938
|
+
clientName: p.clientName,
|
|
939
|
+
clientVersion: p.clientVersion,
|
|
940
|
+
safeMode: p.safeMode,
|
|
941
|
+
userAgent: p.userAgent,
|
|
942
|
+
transport: p.transport
|
|
943
|
+
}).then(
|
|
944
|
+
(end) => {
|
|
945
|
+
task.done = true;
|
|
946
|
+
onMatchSettled(end, void 0);
|
|
947
|
+
},
|
|
948
|
+
(err) => {
|
|
949
|
+
task.done = true;
|
|
950
|
+
onMatchSettled(void 0, err);
|
|
951
|
+
}
|
|
952
|
+
);
|
|
953
|
+
p.matchTasks.push(task);
|
|
954
|
+
} else if (mtype === "evict") {
|
|
955
|
+
return "evicted";
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return "stopped";
|
|
959
|
+
} finally {
|
|
960
|
+
conn.close();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function drainMatches(matchTasks, grace = MATCH_DRAIN_GRACE_MS) {
|
|
964
|
+
if (matchTasks.length === 0) return;
|
|
965
|
+
const all = Promise.allSettled(matchTasks.map((t) => t.promise));
|
|
966
|
+
let timer;
|
|
967
|
+
const graceTimeout = new Promise((resolve) => {
|
|
968
|
+
timer = setTimeout(resolve, grace);
|
|
969
|
+
});
|
|
970
|
+
await Promise.race([all.then(() => void 0), graceTimeout]);
|
|
971
|
+
if (timer) clearTimeout(timer);
|
|
972
|
+
await all;
|
|
973
|
+
}
|
|
974
|
+
async function runExternalBot(bot, options = {}) {
|
|
975
|
+
const config = options.config !== void 0 ? options.config : loadChipzenConfig();
|
|
976
|
+
const transport = options.transport ?? defaultTransport;
|
|
977
|
+
const clientName = options.clientName ?? "chipzen-sdk-js";
|
|
978
|
+
const clientVersion = options.clientVersion ?? VERSION;
|
|
979
|
+
const safeMode = options.safeMode ?? true;
|
|
980
|
+
const maxMatches = options.maxMatches ?? null;
|
|
981
|
+
let lobbyUrl;
|
|
982
|
+
let policy;
|
|
983
|
+
let resolvedToken;
|
|
984
|
+
if (options.url !== void 0 && options.url !== null) {
|
|
985
|
+
lobbyUrl = options.url;
|
|
986
|
+
policy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
987
|
+
resolvedToken = resolveToken({ explicitToken: options.token, config });
|
|
988
|
+
} else {
|
|
989
|
+
const resolvedBotId = options.botId ?? (config ? config.botId : null);
|
|
990
|
+
if (!resolvedBotId) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
"runExternalBot() needs a lobby URL. Pass url=..., or botId=... (or set [external_api].bot_id / url in chipzen.toml)."
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const conn = connectToChipzen(resolvedBotId, options.env, {
|
|
996
|
+
retryPolicy: options.retryPolicy,
|
|
997
|
+
config
|
|
998
|
+
});
|
|
999
|
+
lobbyUrl = conn.url;
|
|
1000
|
+
policy = conn.retryPolicy;
|
|
1001
|
+
resolvedToken = options.token !== void 0 && options.token !== null ? options.token : conn.token;
|
|
1002
|
+
}
|
|
1003
|
+
if (!resolvedToken) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
"runExternalBot() requires an external-API token (cz_extbot_...). Pass token=..., or set [external_api].token in chipzen.toml."
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const userAgent = options.userAgent ?? `chipzen-sdk-js/${clientVersion}`;
|
|
1009
|
+
const factory = asFactory(bot);
|
|
1010
|
+
const results = [];
|
|
1011
|
+
const stop = { value: false };
|
|
1012
|
+
const fatal = [];
|
|
1013
|
+
const matchTasks = [];
|
|
1014
|
+
const completed = { value: 0 };
|
|
1015
|
+
let consecutiveFailures = 0;
|
|
1016
|
+
let everConnected = false;
|
|
1017
|
+
let giveupExc = null;
|
|
1018
|
+
const dropDoneTasks = () => {
|
|
1019
|
+
for (let i = matchTasks.length - 1; i >= 0; i--) {
|
|
1020
|
+
if (matchTasks[i].done) matchTasks.splice(i, 1);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
while (!stop.value) {
|
|
1024
|
+
let status = null;
|
|
1025
|
+
try {
|
|
1026
|
+
status = await runLobbyOnce({
|
|
1027
|
+
lobbyUrl,
|
|
1028
|
+
token: resolvedToken,
|
|
1029
|
+
factory,
|
|
1030
|
+
results,
|
|
1031
|
+
matchTasks,
|
|
1032
|
+
completed,
|
|
1033
|
+
policy,
|
|
1034
|
+
clientName,
|
|
1035
|
+
clientVersion,
|
|
1036
|
+
safeMode,
|
|
1037
|
+
userAgent,
|
|
1038
|
+
maxMatches,
|
|
1039
|
+
stop,
|
|
1040
|
+
fatal,
|
|
1041
|
+
transport
|
|
1042
|
+
});
|
|
1043
|
+
everConnected = true;
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
consecutiveFailures += 1;
|
|
1046
|
+
if (consecutiveFailures > policy.maxReconnectAttempts) {
|
|
1047
|
+
if (!everConnected) {
|
|
1048
|
+
giveupExc = err instanceof Error ? err : new Error(String(err));
|
|
1049
|
+
}
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
await sleep(policy.backoffMs(consecutiveFailures));
|
|
1053
|
+
dropDoneTasks();
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
consecutiveFailures = 0;
|
|
1057
|
+
if (status === "stopped" || status === "evicted" || fatal.length > 0) {
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
consecutiveFailures += 1;
|
|
1061
|
+
if (consecutiveFailures > policy.maxReconnectAttempts) {
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
await sleep(policy.backoffMs(consecutiveFailures));
|
|
1065
|
+
dropDoneTasks();
|
|
1066
|
+
}
|
|
1067
|
+
await drainMatches(matchTasks);
|
|
1068
|
+
if (fatal.length > 0) {
|
|
1069
|
+
throw fatal[0];
|
|
1070
|
+
}
|
|
1071
|
+
if (giveupExc !== null) {
|
|
1072
|
+
throw giveupExc;
|
|
1073
|
+
}
|
|
1074
|
+
return results;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/run_external.ts
|
|
1078
|
+
function parseRunExternalArgs(args) {
|
|
1079
|
+
const out = {
|
|
1080
|
+
botFile: "",
|
|
1081
|
+
env: null,
|
|
1082
|
+
token: null,
|
|
1083
|
+
botId: null,
|
|
1084
|
+
botClass: null,
|
|
1085
|
+
maxMatches: null,
|
|
1086
|
+
safeMode: true
|
|
1087
|
+
};
|
|
1088
|
+
const positionals = [];
|
|
1089
|
+
for (let i = 0; i < args.length; i++) {
|
|
1090
|
+
const arg = args[i];
|
|
1091
|
+
const takeValue = (name) => {
|
|
1092
|
+
const value = args[++i];
|
|
1093
|
+
if (value === void 0) throw new Error(`${name} requires a value`);
|
|
1094
|
+
return value;
|
|
1095
|
+
};
|
|
1096
|
+
switch (arg) {
|
|
1097
|
+
case "--env": {
|
|
1098
|
+
const value = takeValue("--env");
|
|
1099
|
+
if (!ENV_NAMES.includes(value)) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
`--env must be one of ${ENV_NAMES.join(", ")} (got ${JSON.stringify(value)})`
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
out.env = value;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
case "--token":
|
|
1108
|
+
out.token = takeValue("--token");
|
|
1109
|
+
break;
|
|
1110
|
+
case "--bot-id":
|
|
1111
|
+
out.botId = takeValue("--bot-id");
|
|
1112
|
+
break;
|
|
1113
|
+
case "--bot-class":
|
|
1114
|
+
out.botClass = takeValue("--bot-class");
|
|
1115
|
+
break;
|
|
1116
|
+
case "--max-matches": {
|
|
1117
|
+
const value = takeValue("--max-matches");
|
|
1118
|
+
const n = Number.parseInt(value, 10);
|
|
1119
|
+
if (!Number.isFinite(n)) {
|
|
1120
|
+
throw new Error(`--max-matches must be an integer (got ${JSON.stringify(value)})`);
|
|
1121
|
+
}
|
|
1122
|
+
out.maxMatches = n;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
case "--no-safe-mode":
|
|
1126
|
+
out.safeMode = false;
|
|
1127
|
+
break;
|
|
1128
|
+
default:
|
|
1129
|
+
if (arg.startsWith("-")) {
|
|
1130
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1131
|
+
}
|
|
1132
|
+
positionals.push(arg);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const botFile = positionals[0];
|
|
1137
|
+
if (!botFile) {
|
|
1138
|
+
throw new Error("run-external requires a <bot-file> positional argument");
|
|
1139
|
+
}
|
|
1140
|
+
out.botFile = botFile;
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
async function loadBotModule(botFile) {
|
|
1144
|
+
const abs = path2.resolve(botFile);
|
|
1145
|
+
try {
|
|
1146
|
+
const url = `${pathToFileURL(abs).href}?t=${Date.now()}`;
|
|
1147
|
+
return await import(url);
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
throw new Error(`Failed to load ${botFile}: ${err.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function findBotSubclasses(module) {
|
|
1153
|
+
const found = [];
|
|
1154
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1155
|
+
for (const value of Object.values(module)) {
|
|
1156
|
+
if (typeof value !== "function") continue;
|
|
1157
|
+
if (value === Bot) continue;
|
|
1158
|
+
if (seen.has(value)) continue;
|
|
1159
|
+
if (value.prototype instanceof Bot) {
|
|
1160
|
+
seen.add(value);
|
|
1161
|
+
found.push(value);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return found;
|
|
1165
|
+
}
|
|
1166
|
+
function selectBotClass(candidates, botClassName, botFile) {
|
|
1167
|
+
if (botClassName !== null) {
|
|
1168
|
+
const matches = candidates.filter((c) => c.name === botClassName);
|
|
1169
|
+
if (matches.length === 0) {
|
|
1170
|
+
const available = candidates.map((c) => c.name).join(", ") || "<none>";
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
`No Bot subclass named ${JSON.stringify(botClassName)} in ${botFile}. Available subclasses: ${available}.`
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
return matches[0];
|
|
1176
|
+
}
|
|
1177
|
+
if (candidates.length === 0) {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
`No Bot subclass found in ${botFile}. Export a class that extends Bot (e.g. export class MyBot extends Bot { ... }).`
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
if (candidates.length > 1) {
|
|
1183
|
+
const names = candidates.map((c) => c.name).join(", ");
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
`Multiple Bot subclasses found in ${botFile}: ${names}. Pick one with --bot-class <name>.`
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
return candidates[0];
|
|
1189
|
+
}
|
|
1190
|
+
function resolveConnection(opts) {
|
|
1191
|
+
const policy = opts.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
1192
|
+
const configUrl = opts.config ? opts.config.url : null;
|
|
1193
|
+
if (configUrl !== null) {
|
|
1194
|
+
const token2 = opts.token !== null ? opts.token : opts.config ? opts.config.token : null;
|
|
1195
|
+
return { url: configUrl, token: token2, retryPolicy: policy, config: opts.config };
|
|
1196
|
+
}
|
|
1197
|
+
const botId = opts.botId ?? (opts.config ? opts.config.botId : null);
|
|
1198
|
+
if (!botId) {
|
|
1199
|
+
throw new Error(
|
|
1200
|
+
"No lobby URL is configured. Either:\n - Pass --bot-id <id> on the command line, or\n - Set [external_api].bot_id in chipzen.toml, or\n - Set [external_api].url in chipzen.toml for a verbatim URL."
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
const conn = connectToChipzen(botId, opts.env, {
|
|
1204
|
+
retryPolicy: policy,
|
|
1205
|
+
config: opts.config
|
|
1206
|
+
});
|
|
1207
|
+
const token = opts.token !== null ? opts.token : conn.token;
|
|
1208
|
+
return { url: conn.url, token, retryPolicy: conn.retryPolicy, config: conn.config };
|
|
1209
|
+
}
|
|
1210
|
+
function printRunExternalHelp() {
|
|
1211
|
+
console.log("Usage: chipzen-sdk run-external <bot-file> [options]");
|
|
1212
|
+
console.log("");
|
|
1213
|
+
console.log("Run a Chipzen external-API bot from a JavaScript file. Loads config");
|
|
1214
|
+
console.log("from chipzen.toml, resolves the env-aware lobby URL, and plays via");
|
|
1215
|
+
console.log("the SDK's runExternalBot() entry point.");
|
|
1216
|
+
console.log("");
|
|
1217
|
+
console.log("Options:");
|
|
1218
|
+
console.log(" --env <prod|staging|local> Target environment. Defaults to $CHIPZEN_ENV, else prod.");
|
|
1219
|
+
console.log(" --token <cz_extbot_...> External-API token. Overrides [external_api].token.");
|
|
1220
|
+
console.log(" --bot-id <uuid> External-API bot UUID. Overrides [external_api].bot_id.");
|
|
1221
|
+
console.log(" --bot-class <name> Bot subclass to run when the file exports more than one.");
|
|
1222
|
+
console.log(" --max-matches <int> Stop after this many matches. Default: run until lobby closes.");
|
|
1223
|
+
console.log(" --no-safe-mode Let a decide() error crash the process (exit non-zero).");
|
|
1224
|
+
console.log("");
|
|
1225
|
+
console.log("Examples:");
|
|
1226
|
+
console.log(" chipzen-sdk run-external my-bot.js");
|
|
1227
|
+
console.log(" chipzen-sdk run-external my-bot.js --env staging");
|
|
1228
|
+
console.log(" CHIPZEN_ENV=staging chipzen-sdk run-external my-bot.js");
|
|
1229
|
+
console.log(" chipzen-sdk run-external my-bot.js --token cz_extbot_xyz --bot-id abc123");
|
|
1230
|
+
console.log(" chipzen-sdk run-external my-bot.js --bot-class TightAggressive");
|
|
1231
|
+
}
|
|
1232
|
+
async function runExternalCli(args) {
|
|
1233
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1234
|
+
printRunExternalHelp();
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
let parsed;
|
|
1238
|
+
try {
|
|
1239
|
+
parsed = parseRunExternalArgs(args);
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.error(`error: ${err.message}`);
|
|
1242
|
+
process.exit(2);
|
|
1243
|
+
}
|
|
1244
|
+
let config;
|
|
1245
|
+
try {
|
|
1246
|
+
config = loadChipzenConfig();
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
if (err instanceof ChipzenConfigError) {
|
|
1249
|
+
console.error(`error: invalid chipzen.toml: ${err.message}`);
|
|
1250
|
+
process.exit(2);
|
|
1251
|
+
}
|
|
1252
|
+
throw err;
|
|
1253
|
+
}
|
|
1254
|
+
let resolved;
|
|
1255
|
+
try {
|
|
1256
|
+
resolved = resolveConnection({
|
|
1257
|
+
config,
|
|
1258
|
+
env: parsed.env,
|
|
1259
|
+
token: parsed.token,
|
|
1260
|
+
botId: parsed.botId
|
|
1261
|
+
});
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
console.error(`error: ${err.message}`);
|
|
1264
|
+
process.exit(2);
|
|
1265
|
+
}
|
|
1266
|
+
let module;
|
|
1267
|
+
try {
|
|
1268
|
+
module = await loadBotModule(parsed.botFile);
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
console.error(`error: ${err.message}`);
|
|
1271
|
+
process.exit(2);
|
|
1272
|
+
}
|
|
1273
|
+
let BotClass;
|
|
1274
|
+
try {
|
|
1275
|
+
BotClass = selectBotClass(findBotSubclasses(module), parsed.botClass, parsed.botFile);
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
console.error(`error: ${err.message}`);
|
|
1278
|
+
process.exit(2);
|
|
1279
|
+
}
|
|
1280
|
+
let botInstance;
|
|
1281
|
+
try {
|
|
1282
|
+
botInstance = new BotClass();
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
console.error(`error: failed to instantiate ${BotClass.name}: ${err.message}`);
|
|
1285
|
+
process.exit(2);
|
|
1286
|
+
}
|
|
1287
|
+
console.error(`Connecting ${BotClass.name} -> ${resolved.url}`);
|
|
1288
|
+
try {
|
|
1289
|
+
await runExternalBot(botInstance, {
|
|
1290
|
+
url: resolved.url,
|
|
1291
|
+
token: resolved.token,
|
|
1292
|
+
retryPolicy: resolved.retryPolicy,
|
|
1293
|
+
config: resolved.config,
|
|
1294
|
+
safeMode: parsed.safeMode,
|
|
1295
|
+
maxMatches: parsed.maxMatches
|
|
1296
|
+
});
|
|
1297
|
+
} catch (err) {
|
|
1298
|
+
console.error(`error: bot run failed: ${err.message}`);
|
|
1299
|
+
process.exit(1);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/scaffold.ts
|
|
1304
|
+
import { promises as fs2 } from "fs";
|
|
1305
|
+
import path3 from "path";
|
|
1306
|
+
async function scaffoldBot(name, options = {}) {
|
|
1307
|
+
if (!isValidProjectName(name)) {
|
|
1308
|
+
throw new Error(
|
|
1309
|
+
`Invalid project name: ${JSON.stringify(name)}. Use ASCII letters, digits, underscores, and dashes only.`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
const parent = options.parentDir ?? process.cwd();
|
|
1313
|
+
const projectDir = path3.join(parent, name);
|
|
1314
|
+
try {
|
|
1315
|
+
await fs2.access(projectDir);
|
|
1316
|
+
throw new Error(`Directory already exists: ${projectDir}`);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
if (err.code !== "ENOENT") {
|
|
1319
|
+
throw err;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
await fs2.mkdir(projectDir, { recursive: true });
|
|
1323
|
+
await fs2.writeFile(path3.join(projectDir, "bot.js"), BOT_TEMPLATE, "utf-8");
|
|
1324
|
+
await fs2.writeFile(path3.join(projectDir, "package.json"), packageJsonTemplate(name), "utf-8");
|
|
1325
|
+
await fs2.writeFile(path3.join(projectDir, "Dockerfile"), DOCKERFILE_TEMPLATE, "utf-8");
|
|
1326
|
+
await fs2.writeFile(path3.join(projectDir, ".dockerignore"), DOCKERIGNORE, "utf-8");
|
|
1327
|
+
await fs2.writeFile(path3.join(projectDir, ".gitignore"), GITIGNORE, "utf-8");
|
|
1328
|
+
await fs2.writeFile(path3.join(projectDir, "README.md"), readmeTemplate(name), "utf-8");
|
|
1329
|
+
return projectDir;
|
|
1330
|
+
}
|
|
1331
|
+
function isValidProjectName(name) {
|
|
1332
|
+
return /^[A-Za-z0-9_-]+$/.test(name);
|
|
1333
|
+
}
|
|
1334
|
+
var BOT_TEMPLATE = `// Chipzen starter bot \u2014 replace decide() with your strategy.
|
|
1335
|
+
// The SDK handles WebSocket, handshake, ping/pong, retries, and reconnect.
|
|
1336
|
+
|
|
1337
|
+
import { Bot, Action, runBot } from "@chipzen-ai/bot";
|
|
1338
|
+
|
|
1339
|
+
class MyBot extends Bot {
|
|
1340
|
+
decide(state) {
|
|
1341
|
+
// Return one of: Action.fold(), Action.check(), Action.call(),
|
|
1342
|
+
// Action.raiseTo(amount), Action.allIn(). The chosen action's
|
|
1343
|
+
// wire-form must be in state.validActions; raises must satisfy
|
|
1344
|
+
// state.minRaise <= amount <= state.maxRaise.
|
|
1345
|
+
if (state.validActions.includes("check")) return Action.check();
|
|
1346
|
+
return Action.fold();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export async function main() {
|
|
1351
|
+
const url = process.env.CHIPZEN_WS_URL ?? process.argv[2];
|
|
1352
|
+
if (!url) {
|
|
1353
|
+
console.error("error: CHIPZEN_WS_URL not set and no URL passed on the command line");
|
|
1354
|
+
process.exit(1);
|
|
1355
|
+
}
|
|
1356
|
+
await runBot(url, new MyBot(), {
|
|
1357
|
+
token: process.env.CHIPZEN_TOKEN ?? null,
|
|
1358
|
+
ticket: process.env.CHIPZEN_TICKET ?? null,
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Run main() when this file is the entry point \u2014 covers both
|
|
1363
|
+
// \`node bot.js\` (Node sets import.meta.url to a file:// URL matching
|
|
1364
|
+
// argv[1]) and \`bun build --compile\` binaries (Bun sets
|
|
1365
|
+
// import.meta.main on the entry module). Importing from a test file
|
|
1366
|
+
// makes both checks false so MyBot can be exercised in isolation.
|
|
1367
|
+
if (import.meta.main || import.meta.url === \`file://\${process.argv[1]}\`) {
|
|
1368
|
+
await main();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
export { MyBot };
|
|
1372
|
+
`;
|
|
1373
|
+
function packageJsonTemplate(name) {
|
|
1374
|
+
return JSON.stringify(
|
|
1375
|
+
{
|
|
1376
|
+
name,
|
|
1377
|
+
version: "0.1.0",
|
|
1378
|
+
private: true,
|
|
1379
|
+
type: "module",
|
|
1380
|
+
main: "bot.js",
|
|
1381
|
+
scripts: {
|
|
1382
|
+
start: "node bot.js"
|
|
1383
|
+
},
|
|
1384
|
+
dependencies: {
|
|
1385
|
+
"@chipzen-ai/bot": "^0.2.0"
|
|
1386
|
+
},
|
|
1387
|
+
engines: {
|
|
1388
|
+
node: ">=20"
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
null,
|
|
1392
|
+
2
|
|
1393
|
+
) + "\n";
|
|
1394
|
+
}
|
|
1395
|
+
var DOCKERFILE_TEMPLATE = `# syntax=docker/dockerfile:1.7
|
|
1396
|
+
#
|
|
1397
|
+
# IP-protected Chipzen JavaScript bot image.
|
|
1398
|
+
#
|
|
1399
|
+
# Multi-stage build that bundles bot.js + the SDK into a single
|
|
1400
|
+
# statically-linked binary via \`bun build --compile\` in the builder
|
|
1401
|
+
# stage, then ships only that binary in the runtime stage. The runtime
|
|
1402
|
+
# image contains no readable .js source for your strategy code.
|
|
1403
|
+
#
|
|
1404
|
+
# See ../../IP-PROTECTION.md for what this protects (and what it doesn't).
|
|
1405
|
+
#
|
|
1406
|
+
# Build: docker build -t my-bot:test .
|
|
1407
|
+
# Export: docker save my-bot:test | gzip > my-bot.tar.gz
|
|
1408
|
+
#
|
|
1409
|
+
# Build context for this directory should be small (bot.js +
|
|
1410
|
+
# package.json + this file). The .dockerignore alongside this file
|
|
1411
|
+
# keeps node_modules, caches, and lockfile metadata out.
|
|
1412
|
+
|
|
1413
|
+
# -----------------------------------------------------------------------------
|
|
1414
|
+
# Stage 1: Bun bundle + compile. The .js source lives only in this stage and
|
|
1415
|
+
# is discarded before the runtime stage starts.
|
|
1416
|
+
# -----------------------------------------------------------------------------
|
|
1417
|
+
# Base pinned by tag \u2014 Dependabot can rotate to digest pinning later. Tag:
|
|
1418
|
+
# oven/bun:1-debian (glibc-based; the runtime stage below also uses glibc
|
|
1419
|
+
# so the compiled binary's dynamic linker can find what it needs).
|
|
1420
|
+
FROM oven/bun:1-debian AS builder
|
|
1421
|
+
|
|
1422
|
+
WORKDIR /build
|
|
1423
|
+
|
|
1424
|
+
# Bring in the bot source + dependency manifest. Only these are copied \u2014
|
|
1425
|
+
# keep the build context narrow so the .dockerignore is the only allowlist.
|
|
1426
|
+
COPY package.json bot.js ./
|
|
1427
|
+
|
|
1428
|
+
# Resolve @chipzen-ai/bot + ws so \`bun build\` can bundle them. We don't
|
|
1429
|
+
# need a lockfile committed; the registry pin in package.json is enough
|
|
1430
|
+
# for a fresh install at build time.
|
|
1431
|
+
RUN bun install --production
|
|
1432
|
+
|
|
1433
|
+
# Compile bot.js -> /build/bot. \`--compile\` emits a single executable
|
|
1434
|
+
# that bundles the JS, all deps, and the Bun runtime statically.
|
|
1435
|
+
# \`--minify\` shrinks output; \`--sourcemap=none\` excludes maps so the
|
|
1436
|
+
# strategy isn't trivially readable from inside the binary.
|
|
1437
|
+
RUN bun build --compile --minify --sourcemap=none --target=bun-linux-x64 \\
|
|
1438
|
+
bot.js --outfile=/build/bot \\
|
|
1439
|
+
&& rm bot.js \\
|
|
1440
|
+
&& rm -rf node_modules
|
|
1441
|
+
|
|
1442
|
+
# -----------------------------------------------------------------------------
|
|
1443
|
+
# Stage 2: Runtime. Only the compiled binary + ENTRYPOINT.
|
|
1444
|
+
# No .js source for the bot's strategy is present.
|
|
1445
|
+
# -----------------------------------------------------------------------------
|
|
1446
|
+
# Base pinned by tag \u2014 Dependabot can rotate to digest pinning later. Tag:
|
|
1447
|
+
# debian:12-slim (matches the glibc the Bun --compile output expects).
|
|
1448
|
+
FROM debian:12-slim
|
|
1449
|
+
|
|
1450
|
+
# CA certs are needed for outbound TLS (the platform's WebSocket
|
|
1451
|
+
# endpoint is wss://). dumb-init reaps any subprocesses cleanly on
|
|
1452
|
+
# container exit; tiny but useful.
|
|
1453
|
+
RUN apt-get update \\
|
|
1454
|
+
&& apt-get install -y --no-install-recommends ca-certificates dumb-init \\
|
|
1455
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
1456
|
+
|
|
1457
|
+
WORKDIR /bot
|
|
1458
|
+
|
|
1459
|
+
# Copy ONLY the compiled binary from the builder stage.
|
|
1460
|
+
COPY --from=builder /build/bot /bot/bot
|
|
1461
|
+
RUN chmod +x /bot/bot
|
|
1462
|
+
|
|
1463
|
+
# Run as non-root (defense in depth \u2014 the platform also applies seccomp
|
|
1464
|
+
# and cap-drop on top of this).
|
|
1465
|
+
RUN groupadd --system --gid 10001 bot \\
|
|
1466
|
+
&& useradd --system --uid 10001 --gid bot --home-dir /bot --shell /usr/sbin/nologin bot \\
|
|
1467
|
+
&& chown -R bot:bot /bot
|
|
1468
|
+
USER 10001
|
|
1469
|
+
|
|
1470
|
+
ENTRYPOINT ["dumb-init", "/bot/bot"]
|
|
1471
|
+
`;
|
|
1472
|
+
var DOCKERIGNORE = `node_modules/
|
|
1473
|
+
.git/
|
|
1474
|
+
.gitignore
|
|
1475
|
+
.env
|
|
1476
|
+
.env.*
|
|
1477
|
+
*.md
|
|
1478
|
+
README*
|
|
1479
|
+
LICENSE*
|
|
1480
|
+
*.log
|
|
1481
|
+
coverage/
|
|
1482
|
+
.DS_Store
|
|
1483
|
+
`;
|
|
1484
|
+
var GITIGNORE = `node_modules/
|
|
1485
|
+
*.log
|
|
1486
|
+
.DS_Store
|
|
1487
|
+
.env
|
|
1488
|
+
.env.*
|
|
1489
|
+
coverage/
|
|
1490
|
+
dist/
|
|
1491
|
+
`;
|
|
1492
|
+
function readmeTemplate(name) {
|
|
1493
|
+
return `# ${name}
|
|
1494
|
+
|
|
1495
|
+
A poker bot for the [Chipzen](https://chipzen.ai) platform.
|
|
1496
|
+
|
|
1497
|
+
## Quick start
|
|
1498
|
+
|
|
1499
|
+
\`\`\`bash
|
|
1500
|
+
npm install
|
|
1501
|
+
\`\`\`
|
|
1502
|
+
|
|
1503
|
+
Edit \`bot.js\` to implement your strategy in the \`decide()\` method.
|
|
1504
|
+
|
|
1505
|
+
Validate before uploading:
|
|
1506
|
+
|
|
1507
|
+
\`\`\`bash
|
|
1508
|
+
chipzen-sdk validate .
|
|
1509
|
+
\`\`\`
|
|
1510
|
+
|
|
1511
|
+
Build and export the upload tarball:
|
|
1512
|
+
|
|
1513
|
+
\`\`\`bash
|
|
1514
|
+
docker build -t ${name}:v1 .
|
|
1515
|
+
docker save ${name}:v1 | gzip > ${name}.tar.gz
|
|
1516
|
+
\`\`\`
|
|
1517
|
+
|
|
1518
|
+
Then upload via the Chipzen platform UI.
|
|
1519
|
+
`;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/validate.ts
|
|
1523
|
+
import { execSync } from "child_process";
|
|
1524
|
+
import { promises as fs3 } from "fs";
|
|
1525
|
+
import path4 from "path";
|
|
1526
|
+
|
|
1527
|
+
// src/conformance.ts
|
|
1528
|
+
var MATCH_ID = "m_conformance_test";
|
|
1529
|
+
var VALID_ACTION_KINDS = /* @__PURE__ */ new Set(["fold", "check", "call", "raise", "all_in"]);
|
|
1530
|
+
var _ScriptedReader = class {
|
|
1531
|
+
constructor(messages) {
|
|
1532
|
+
this.messages = messages;
|
|
1533
|
+
}
|
|
1534
|
+
messages;
|
|
1535
|
+
index = 0;
|
|
1536
|
+
async next() {
|
|
1537
|
+
if (this.index >= this.messages.length) return null;
|
|
1538
|
+
return this.messages[this.index++] ?? null;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
var _CapturingSocket = class {
|
|
1542
|
+
sent = [];
|
|
1543
|
+
send(data) {
|
|
1544
|
+
this.sent.push(data);
|
|
1545
|
+
}
|
|
1546
|
+
close() {
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
function serverHello() {
|
|
1550
|
+
return {
|
|
1551
|
+
type: "hello",
|
|
1552
|
+
match_id: MATCH_ID,
|
|
1553
|
+
seq: 1,
|
|
1554
|
+
server_ts: "2026-04-13T14:30:05.123Z",
|
|
1555
|
+
supported_versions: ["1.0"],
|
|
1556
|
+
selected_version: "1.0",
|
|
1557
|
+
game_type: "nlhe_6max",
|
|
1558
|
+
capabilities: []
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
function matchStart() {
|
|
1562
|
+
return {
|
|
1563
|
+
type: "match_start",
|
|
1564
|
+
match_id: MATCH_ID,
|
|
1565
|
+
seq: 2,
|
|
1566
|
+
server_ts: "2026-04-13T14:30:06.000Z",
|
|
1567
|
+
seats: [
|
|
1568
|
+
{ seat: 0, participant_id: "p0", display_name: "You", is_self: true },
|
|
1569
|
+
{ seat: 1, participant_id: "p1", display_name: "Opp", is_self: false }
|
|
1570
|
+
],
|
|
1571
|
+
game_config: {
|
|
1572
|
+
variant: "nlhe",
|
|
1573
|
+
starting_stack: 1e3,
|
|
1574
|
+
small_blind: 5,
|
|
1575
|
+
big_blind: 10,
|
|
1576
|
+
ante: 0,
|
|
1577
|
+
total_hands: 0
|
|
1578
|
+
},
|
|
1579
|
+
turn_timeout_ms: 5e3
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function roundStart() {
|
|
1583
|
+
return {
|
|
1584
|
+
type: "round_start",
|
|
1585
|
+
match_id: MATCH_ID,
|
|
1586
|
+
seq: 3,
|
|
1587
|
+
server_ts: "2026-04-13T14:30:07.000Z",
|
|
1588
|
+
round_id: "r_1",
|
|
1589
|
+
round_number: 1,
|
|
1590
|
+
state: {
|
|
1591
|
+
hand_number: 1,
|
|
1592
|
+
dealer_seat: 0,
|
|
1593
|
+
your_hole_cards: ["Ah", "Kd"],
|
|
1594
|
+
stacks: [995, 990],
|
|
1595
|
+
deck_commitment: ""
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
function turnRequest() {
|
|
1600
|
+
return {
|
|
1601
|
+
type: "turn_request",
|
|
1602
|
+
match_id: MATCH_ID,
|
|
1603
|
+
seq: 4,
|
|
1604
|
+
server_ts: "2026-04-13T14:30:07.500Z",
|
|
1605
|
+
seat: 0,
|
|
1606
|
+
request_id: "req_1",
|
|
1607
|
+
timeout_ms: 5e3,
|
|
1608
|
+
valid_actions: ["fold", "call", "raise"],
|
|
1609
|
+
state: {
|
|
1610
|
+
hand_number: 1,
|
|
1611
|
+
phase: "preflop",
|
|
1612
|
+
board: [],
|
|
1613
|
+
your_hole_cards: ["Ah", "Kd"],
|
|
1614
|
+
pot: 15,
|
|
1615
|
+
your_stack: 995,
|
|
1616
|
+
opponent_stacks: [990],
|
|
1617
|
+
to_call: 5,
|
|
1618
|
+
min_raise: 20,
|
|
1619
|
+
max_raise: 995,
|
|
1620
|
+
action_history: []
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
function turnResult() {
|
|
1625
|
+
return {
|
|
1626
|
+
type: "turn_result",
|
|
1627
|
+
match_id: MATCH_ID,
|
|
1628
|
+
seq: 5,
|
|
1629
|
+
server_ts: "2026-04-13T14:30:08.000Z",
|
|
1630
|
+
is_timeout: false,
|
|
1631
|
+
details: { seat: 0, action: "call", amount: 5 }
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
function roundResult() {
|
|
1635
|
+
return {
|
|
1636
|
+
type: "round_result",
|
|
1637
|
+
match_id: MATCH_ID,
|
|
1638
|
+
seq: 6,
|
|
1639
|
+
server_ts: "2026-04-13T14:30:12.000Z",
|
|
1640
|
+
round_id: "r_1",
|
|
1641
|
+
round_number: 1,
|
|
1642
|
+
result: {
|
|
1643
|
+
hand_number: 1,
|
|
1644
|
+
winner_seats: [0],
|
|
1645
|
+
pot: 40,
|
|
1646
|
+
payouts: [{ seat: 0, amount: 40 }],
|
|
1647
|
+
showdown: [],
|
|
1648
|
+
action_history: [],
|
|
1649
|
+
stacks: [1020, 980],
|
|
1650
|
+
deck_commitment: "",
|
|
1651
|
+
deck_reveal: null
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
function matchEnd(seq = 7) {
|
|
1656
|
+
return {
|
|
1657
|
+
type: "match_end",
|
|
1658
|
+
match_id: MATCH_ID,
|
|
1659
|
+
seq,
|
|
1660
|
+
server_ts: "2026-04-13T14:35:00.000Z",
|
|
1661
|
+
reason: "complete",
|
|
1662
|
+
results: [
|
|
1663
|
+
{ seat: 0, participant_id: "p0", rank: 1, score: 1020 },
|
|
1664
|
+
{ seat: 1, participant_id: "p1", rank: 2, score: 980 }
|
|
1665
|
+
]
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function turnRequestN(seq, requestId) {
|
|
1669
|
+
return { ...turnRequest(), seq, request_id: requestId };
|
|
1670
|
+
}
|
|
1671
|
+
function turnResultN(seq) {
|
|
1672
|
+
return { ...turnResult(), seq };
|
|
1673
|
+
}
|
|
1674
|
+
function phaseChange(seq, phase, board) {
|
|
1675
|
+
return {
|
|
1676
|
+
type: "phase_change",
|
|
1677
|
+
match_id: MATCH_ID,
|
|
1678
|
+
seq,
|
|
1679
|
+
server_ts: "2026-04-13T14:30:09.000Z",
|
|
1680
|
+
state: { phase, board }
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
function roundResultN(seq) {
|
|
1684
|
+
return { ...roundResult(), seq };
|
|
1685
|
+
}
|
|
1686
|
+
function actionRejected(seq, requestId = "req_1", remainingMs = 4e3) {
|
|
1687
|
+
return {
|
|
1688
|
+
type: "action_rejected",
|
|
1689
|
+
match_id: MATCH_ID,
|
|
1690
|
+
seq,
|
|
1691
|
+
server_ts: "2026-04-13T14:30:08.000Z",
|
|
1692
|
+
request_id: requestId,
|
|
1693
|
+
reason: "invalid_action",
|
|
1694
|
+
message: "action not in valid_actions",
|
|
1695
|
+
remaining_ms: remainingMs,
|
|
1696
|
+
valid_actions: ["check", "fold"]
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function fullMatchScript() {
|
|
1700
|
+
return [
|
|
1701
|
+
serverHello(),
|
|
1702
|
+
matchStart(),
|
|
1703
|
+
roundStart(),
|
|
1704
|
+
turnRequest(),
|
|
1705
|
+
turnResult(),
|
|
1706
|
+
roundResult(),
|
|
1707
|
+
matchEnd()
|
|
1708
|
+
].map((m) => JSON.stringify(m));
|
|
1709
|
+
}
|
|
1710
|
+
function multiTurnScript() {
|
|
1711
|
+
return [
|
|
1712
|
+
serverHello(),
|
|
1713
|
+
matchStart(),
|
|
1714
|
+
roundStart(),
|
|
1715
|
+
turnRequestN(4, "req_1"),
|
|
1716
|
+
turnResultN(5),
|
|
1717
|
+
phaseChange(6, "flop", ["2s", "7d", "Tc"]),
|
|
1718
|
+
turnRequestN(7, "req_2"),
|
|
1719
|
+
turnResultN(8),
|
|
1720
|
+
phaseChange(9, "turn", ["2s", "7d", "Tc", "Kh"]),
|
|
1721
|
+
turnRequestN(10, "req_3"),
|
|
1722
|
+
turnResultN(11),
|
|
1723
|
+
roundResultN(12),
|
|
1724
|
+
matchEnd(13)
|
|
1725
|
+
].map((m) => JSON.stringify(m));
|
|
1726
|
+
}
|
|
1727
|
+
function actionRejectedScript() {
|
|
1728
|
+
return [
|
|
1729
|
+
serverHello(),
|
|
1730
|
+
matchStart(),
|
|
1731
|
+
roundStart(),
|
|
1732
|
+
turnRequestN(4, "req_1"),
|
|
1733
|
+
actionRejected(5, "req_1"),
|
|
1734
|
+
turnResultN(6),
|
|
1735
|
+
roundResultN(7),
|
|
1736
|
+
matchEnd(8)
|
|
1737
|
+
].map((m) => JSON.stringify(m));
|
|
1738
|
+
}
|
|
1739
|
+
function retryStormScript() {
|
|
1740
|
+
return [
|
|
1741
|
+
serverHello(),
|
|
1742
|
+
matchStart(),
|
|
1743
|
+
roundStart(),
|
|
1744
|
+
turnRequestN(4, "req_1"),
|
|
1745
|
+
actionRejected(5, "req_1"),
|
|
1746
|
+
actionRejected(6, "req_1"),
|
|
1747
|
+
actionRejected(7, "req_1"),
|
|
1748
|
+
turnResultN(8),
|
|
1749
|
+
roundResultN(9),
|
|
1750
|
+
matchEnd(10)
|
|
1751
|
+
].map((m) => JSON.stringify(m));
|
|
1752
|
+
}
|
|
1753
|
+
function classifyTurnAction(payload, expectedRequestId = "req_1") {
|
|
1754
|
+
let msg;
|
|
1755
|
+
try {
|
|
1756
|
+
msg = JSON.parse(payload);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
return { ok: false, message: `sent payload was not valid JSON: ${err.message}` };
|
|
1759
|
+
}
|
|
1760
|
+
if (msg.type !== "turn_action") {
|
|
1761
|
+
return { ok: true, message: `non-action message (${JSON.stringify(msg.type)}) \u2014 ignored` };
|
|
1762
|
+
}
|
|
1763
|
+
if (msg.request_id !== expectedRequestId) {
|
|
1764
|
+
return {
|
|
1765
|
+
ok: false,
|
|
1766
|
+
message: `turn_action request_id ${JSON.stringify(msg.request_id)} did not echo the server's ${JSON.stringify(expectedRequestId)} \u2014 the server uses request_id for correlation, idempotency, and action_rejected retries`
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
const params = msg.params ?? {};
|
|
1770
|
+
const action = msg.action ?? params.action;
|
|
1771
|
+
if (!action || !VALID_ACTION_KINDS.has(action)) {
|
|
1772
|
+
return {
|
|
1773
|
+
ok: false,
|
|
1774
|
+
message: `turn_action action ${JSON.stringify(action)} is not in the legal set`
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
return { ok: true, message: `sent turn_action: action=${JSON.stringify(action)}` };
|
|
1778
|
+
}
|
|
1779
|
+
function extractTurnActions(sent) {
|
|
1780
|
+
const out = [];
|
|
1781
|
+
for (const payload of sent) {
|
|
1782
|
+
try {
|
|
1783
|
+
const parsed = JSON.parse(payload);
|
|
1784
|
+
if (parsed.type === "turn_action") out.push(parsed);
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return out;
|
|
1789
|
+
}
|
|
1790
|
+
async function driveSession(bot, script, timeoutMs) {
|
|
1791
|
+
const reader = new _ScriptedReader(script);
|
|
1792
|
+
const socket = new _CapturingSocket();
|
|
1793
|
+
try {
|
|
1794
|
+
await withTimeout(
|
|
1795
|
+
_runSession(
|
|
1796
|
+
socket,
|
|
1797
|
+
bot,
|
|
1798
|
+
{
|
|
1799
|
+
matchId: MATCH_ID,
|
|
1800
|
+
token: "conformance",
|
|
1801
|
+
ticket: null,
|
|
1802
|
+
clientName: "chipzen-sdk-conformance",
|
|
1803
|
+
clientVersion: "0.0.0"
|
|
1804
|
+
},
|
|
1805
|
+
reader
|
|
1806
|
+
),
|
|
1807
|
+
timeoutMs
|
|
1808
|
+
);
|
|
1809
|
+
return { socket, error: null };
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
return { socket, error: err };
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async function withTimeout(p, timeoutMs) {
|
|
1815
|
+
let timer;
|
|
1816
|
+
const timeout = new Promise((_, reject) => {
|
|
1817
|
+
timer = setTimeout(
|
|
1818
|
+
() => reject(new Error(`timed out after ${timeoutMs}ms`)),
|
|
1819
|
+
timeoutMs
|
|
1820
|
+
);
|
|
1821
|
+
});
|
|
1822
|
+
try {
|
|
1823
|
+
return await Promise.race([p, timeout]);
|
|
1824
|
+
} finally {
|
|
1825
|
+
if (timer) clearTimeout(timer);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
async function runFullMatchScenario(bot, timeoutMs) {
|
|
1829
|
+
const name = "connectivity_full_match";
|
|
1830
|
+
const { socket, error } = await driveSession(bot, fullMatchScript(), timeoutMs);
|
|
1831
|
+
if (error) {
|
|
1832
|
+
if (error.message.startsWith("timed out")) {
|
|
1833
|
+
return {
|
|
1834
|
+
severity: "fail",
|
|
1835
|
+
name,
|
|
1836
|
+
message: `bot did not complete the canned full-match exchange within ${timeoutMs}ms \u2014 either decide() is too slow or the bot is hung waiting on something`
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
return {
|
|
1840
|
+
severity: "fail",
|
|
1841
|
+
name,
|
|
1842
|
+
message: `bot raised ${error.constructor.name} during the canned exchange: ${error.message}`
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
if (socket.sent.length === 0) {
|
|
1846
|
+
return {
|
|
1847
|
+
severity: "fail",
|
|
1848
|
+
name,
|
|
1849
|
+
message: "bot did not send any messages during the canned exchange \u2014 at minimum the client should have sent authenticate / hello / turn_action"
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
const turnActions = extractTurnActions(socket.sent);
|
|
1853
|
+
if (turnActions.length === 0) {
|
|
1854
|
+
return {
|
|
1855
|
+
severity: "fail",
|
|
1856
|
+
name,
|
|
1857
|
+
message: "bot completed the exchange but never sent a turn_action \u2014 decide() may have returned an unexpected value or the SDK's runner hit a fallback path"
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
const verdict = classifyTurnAction(JSON.stringify(turnActions[0]));
|
|
1861
|
+
if (!verdict.ok) {
|
|
1862
|
+
return { severity: "fail", name, message: verdict.message };
|
|
1863
|
+
}
|
|
1864
|
+
return {
|
|
1865
|
+
severity: "pass",
|
|
1866
|
+
name,
|
|
1867
|
+
message: `completed handshake + 1 hand + match_end; ${verdict.message}`
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
async function runMultiTurnScenario(bot, timeoutMs) {
|
|
1871
|
+
const name = "multi_turn_request_id_echo";
|
|
1872
|
+
const { socket, error } = await driveSession(bot, multiTurnScript(), timeoutMs);
|
|
1873
|
+
if (error) {
|
|
1874
|
+
if (error.message.startsWith("timed out")) {
|
|
1875
|
+
return {
|
|
1876
|
+
severity: "fail",
|
|
1877
|
+
name,
|
|
1878
|
+
message: `bot did not complete the multi-turn exchange within ${timeoutMs}ms`
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
return {
|
|
1882
|
+
severity: "fail",
|
|
1883
|
+
name,
|
|
1884
|
+
message: `bot raised ${error.constructor.name} during the multi-turn exchange: ${error.message}`
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
const turnActions = extractTurnActions(socket.sent);
|
|
1888
|
+
const expectedIds = ["req_1", "req_2", "req_3"];
|
|
1889
|
+
if (turnActions.length < expectedIds.length) {
|
|
1890
|
+
return {
|
|
1891
|
+
severity: "fail",
|
|
1892
|
+
name,
|
|
1893
|
+
message: `expected ${expectedIds.length} turn_actions across preflop/flop/turn, saw only ${turnActions.length} \u2014 bot stopped responding partway through the hand`
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
for (let i = 0; i < expectedIds.length; i++) {
|
|
1897
|
+
const expectedId = expectedIds[i];
|
|
1898
|
+
const verdict = classifyTurnAction(JSON.stringify(turnActions[i]), expectedId);
|
|
1899
|
+
if (!verdict.ok) {
|
|
1900
|
+
return {
|
|
1901
|
+
severity: "fail",
|
|
1902
|
+
name,
|
|
1903
|
+
message: `turn ${i + 1} of 3 failed: ${verdict.message}`
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
severity: "pass",
|
|
1909
|
+
name,
|
|
1910
|
+
message: `all ${expectedIds.length} turn_actions echoed request_id correctly across preflop/flop/turn`
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
async function runActionRejectedScenario(bot, timeoutMs) {
|
|
1914
|
+
const name = "action_rejected_recovery";
|
|
1915
|
+
const { socket, error } = await driveSession(bot, actionRejectedScript(), timeoutMs);
|
|
1916
|
+
if (error) {
|
|
1917
|
+
if (error.message.startsWith("timed out")) {
|
|
1918
|
+
return {
|
|
1919
|
+
severity: "fail",
|
|
1920
|
+
name,
|
|
1921
|
+
message: `bot did not complete the action_rejected scenario within ${timeoutMs}ms \u2014 the SDK's safe-fallback retry path may be hung`
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
return {
|
|
1925
|
+
severity: "fail",
|
|
1926
|
+
name,
|
|
1927
|
+
message: `bot raised ${error.constructor.name} during action_rejected handling: ${error.message}`
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
const turnActions = extractTurnActions(socket.sent);
|
|
1931
|
+
if (turnActions.length < 2) {
|
|
1932
|
+
return {
|
|
1933
|
+
severity: "fail",
|
|
1934
|
+
name,
|
|
1935
|
+
message: `expected 2 turn_actions (initial + safe-fallback retry), saw ${turnActions.length}; the SDK did not respond to the action_rejected message`
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
const retry = turnActions[1];
|
|
1939
|
+
if (retry.request_id !== "req_1") {
|
|
1940
|
+
return {
|
|
1941
|
+
severity: "fail",
|
|
1942
|
+
name,
|
|
1943
|
+
message: `safe-fallback retry used request_id ${JSON.stringify(retry.request_id)} instead of the original "req_1" \u2014 server-side correlation will fail`
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
const retryParams = retry.params ?? {};
|
|
1947
|
+
const retryAction = retry.action ?? retryParams.action;
|
|
1948
|
+
if (retryAction !== "check" && retryAction !== "fold") {
|
|
1949
|
+
return {
|
|
1950
|
+
severity: "fail",
|
|
1951
|
+
name,
|
|
1952
|
+
message: `safe-fallback retry sent action ${JSON.stringify(retryAction)}; expected "check" or "fold" (the only universally-safe actions when valid_actions is unknown)`
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
return {
|
|
1956
|
+
severity: "pass",
|
|
1957
|
+
name,
|
|
1958
|
+
message: `action_rejected handled cleanly: original action sent, retry sent ${JSON.stringify(retryAction)} with original request_id`
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
async function runRetryStormScenario(bot, timeoutMs) {
|
|
1962
|
+
const name = "retry_storm_bounded";
|
|
1963
|
+
const { socket, error } = await driveSession(bot, retryStormScript(), timeoutMs);
|
|
1964
|
+
if (error) {
|
|
1965
|
+
if (error.message.startsWith("timed out")) {
|
|
1966
|
+
return {
|
|
1967
|
+
severity: "fail",
|
|
1968
|
+
name,
|
|
1969
|
+
message: `bot did not complete the retry-storm scenario within ${timeoutMs}ms \u2014 the SDK may be stuck in a retry loop`
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
return {
|
|
1973
|
+
severity: "fail",
|
|
1974
|
+
name,
|
|
1975
|
+
message: `bot raised ${error.constructor.name} during retry-storm handling: ${error.message}`
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
const turnActions = extractTurnActions(socket.sent);
|
|
1979
|
+
const expectedCount = 4;
|
|
1980
|
+
if (turnActions.length !== expectedCount) {
|
|
1981
|
+
return {
|
|
1982
|
+
severity: turnActions.length < expectedCount ? "fail" : "warn",
|
|
1983
|
+
name,
|
|
1984
|
+
message: `expected ${expectedCount} turn_actions (1 initial + 3 retries) under retry storm, saw ${turnActions.length} \u2014 the SDK's retry behavior may be unbounded or may have stopped responding`
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
return {
|
|
1988
|
+
severity: "pass",
|
|
1989
|
+
name,
|
|
1990
|
+
message: `SDK responded to all 3 action_rejected messages with safe-fallback retries (${expectedCount} turn_actions total) and exited cleanly on match_end`
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
var SCENARIOS = [
|
|
1994
|
+
{ name: "connectivity_full_match", fn: runFullMatchScenario },
|
|
1995
|
+
{ name: "multi_turn_request_id_echo", fn: runMultiTurnScenario },
|
|
1996
|
+
{ name: "action_rejected_recovery", fn: runActionRejectedScenario },
|
|
1997
|
+
{ name: "retry_storm_bounded", fn: runRetryStormScenario }
|
|
1998
|
+
];
|
|
1999
|
+
async function runConformanceChecks(bot, options = {}) {
|
|
2000
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
2001
|
+
const results = [];
|
|
2002
|
+
for (const { fn } of SCENARIOS) {
|
|
2003
|
+
results.push(await fn(bot, timeoutMs));
|
|
2004
|
+
}
|
|
2005
|
+
return results;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// src/validate.ts
|
|
2009
|
+
var VALID_ACTION_KINDS2 = /* @__PURE__ */ new Set(["fold", "check", "call", "raise", "all_in"]);
|
|
2010
|
+
function isAction(v) {
|
|
2011
|
+
if (!v || typeof v !== "object") return false;
|
|
2012
|
+
const a = v;
|
|
2013
|
+
if (typeof a.action !== "string" || !VALID_ACTION_KINDS2.has(a.action)) return false;
|
|
2014
|
+
if (a.action === "raise") {
|
|
2015
|
+
return typeof a.amount === "number" && Number.isFinite(a.amount) && a.amount >= 0;
|
|
2016
|
+
}
|
|
2017
|
+
return a.amount === void 0;
|
|
2018
|
+
}
|
|
2019
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
2020
|
+
var DEFAULT_TIMEOUT_WARN_MS = 100;
|
|
2021
|
+
var PLATFORM_TIMEOUT_MS = 500;
|
|
2022
|
+
var ALLOWED_ENTRY_POINTS = ["bot.js", "bot.mjs", "bot.cjs", "main.js", "main.mjs"];
|
|
2023
|
+
var BLOCKED_MODULES = /* @__PURE__ */ new Set([
|
|
2024
|
+
"node:child_process",
|
|
2025
|
+
"child_process",
|
|
2026
|
+
"node:cluster",
|
|
2027
|
+
"cluster",
|
|
2028
|
+
"node:dgram",
|
|
2029
|
+
"dgram",
|
|
2030
|
+
"node:dns",
|
|
2031
|
+
"dns",
|
|
2032
|
+
"node:net",
|
|
2033
|
+
"net",
|
|
2034
|
+
"node:tls",
|
|
2035
|
+
"tls",
|
|
2036
|
+
"node:repl",
|
|
2037
|
+
"repl",
|
|
2038
|
+
"node:vm",
|
|
2039
|
+
"vm",
|
|
2040
|
+
"node:worker_threads",
|
|
2041
|
+
"worker_threads"
|
|
2042
|
+
]);
|
|
2043
|
+
var WARN_MODULES = /* @__PURE__ */ new Set(["node:fs", "fs", "node:fs/promises"]);
|
|
2044
|
+
async function validateBot(botPath, options = {}) {
|
|
2045
|
+
const results = [];
|
|
2046
|
+
const maxBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
2047
|
+
const timeoutWarn = options.timeoutWarnMs ?? DEFAULT_TIMEOUT_WARN_MS;
|
|
2048
|
+
let stat;
|
|
2049
|
+
try {
|
|
2050
|
+
stat = await fs3.stat(botPath);
|
|
2051
|
+
} catch {
|
|
2052
|
+
results.push({
|
|
2053
|
+
severity: "fail",
|
|
2054
|
+
name: "file_structure",
|
|
2055
|
+
message: `Path not found: ${botPath}`
|
|
2056
|
+
});
|
|
2057
|
+
return results;
|
|
2058
|
+
}
|
|
2059
|
+
if (!stat.isDirectory()) {
|
|
2060
|
+
results.push({
|
|
2061
|
+
severity: "fail",
|
|
2062
|
+
name: "file_structure",
|
|
2063
|
+
message: `Path is not a directory: ${botPath}`
|
|
2064
|
+
});
|
|
2065
|
+
return results;
|
|
2066
|
+
}
|
|
2067
|
+
results.push(...await checkDirectorySize(botPath, maxBytes));
|
|
2068
|
+
results.push(
|
|
2069
|
+
...await checkDirectory(
|
|
2070
|
+
botPath,
|
|
2071
|
+
options.entryPoint,
|
|
2072
|
+
timeoutWarn,
|
|
2073
|
+
options.checkConnectivity ?? false,
|
|
2074
|
+
options.conformanceTimeoutMs ?? 1e4
|
|
2075
|
+
)
|
|
2076
|
+
);
|
|
2077
|
+
return results;
|
|
2078
|
+
}
|
|
2079
|
+
async function checkDirectorySize(dir, maxBytes) {
|
|
2080
|
+
const total = await dirTotalBytes(dir);
|
|
2081
|
+
const mb = total / (1024 * 1024);
|
|
2082
|
+
const limitMb = maxBytes / (1024 * 1024);
|
|
2083
|
+
if (total > maxBytes) {
|
|
2084
|
+
return [
|
|
2085
|
+
{
|
|
2086
|
+
severity: "fail",
|
|
2087
|
+
name: "size",
|
|
2088
|
+
message: `Directory is ${mb.toFixed(1)} MB, exceeds ${limitMb.toFixed(0)} MB upload limit`
|
|
2089
|
+
}
|
|
2090
|
+
];
|
|
2091
|
+
}
|
|
2092
|
+
return [
|
|
2093
|
+
{
|
|
2094
|
+
severity: "pass",
|
|
2095
|
+
name: "size",
|
|
2096
|
+
message: `Size OK (${mb.toFixed(1)} MB uncompressed / ${limitMb.toFixed(0)} MB limit)`
|
|
2097
|
+
}
|
|
2098
|
+
];
|
|
2099
|
+
}
|
|
2100
|
+
async function checkDirectory(dir, entryPointOverride, timeoutWarnMs, checkConnectivity, conformanceTimeoutMs) {
|
|
2101
|
+
const results = [];
|
|
2102
|
+
const entry = await findEntryPoint(dir, entryPointOverride);
|
|
2103
|
+
if (!entry) {
|
|
2104
|
+
results.push({
|
|
2105
|
+
severity: "fail",
|
|
2106
|
+
name: "file_structure",
|
|
2107
|
+
message: `No entry point found. Expected one of: ${ALLOWED_ENTRY_POINTS.join(", ")}`
|
|
2108
|
+
});
|
|
2109
|
+
return results;
|
|
2110
|
+
}
|
|
2111
|
+
results.push({
|
|
2112
|
+
severity: "pass",
|
|
2113
|
+
name: "file_structure",
|
|
2114
|
+
message: `Entry point found: ${path4.basename(entry)}`
|
|
2115
|
+
});
|
|
2116
|
+
const syntaxResult = await checkSyntax(entry);
|
|
2117
|
+
results.push(syntaxResult);
|
|
2118
|
+
if (syntaxResult.severity === "fail") return results;
|
|
2119
|
+
const source = await fs3.readFile(entry, "utf-8");
|
|
2120
|
+
results.push(...checkImports(source, path4.basename(entry)));
|
|
2121
|
+
const botClassResult = checkBotClass(source);
|
|
2122
|
+
results.push(botClassResult);
|
|
2123
|
+
if (botClassResult.severity === "fail") return results;
|
|
2124
|
+
const botClassName = botClassResult.message.replace("Found bot class: ", "");
|
|
2125
|
+
const decideResult = checkDecideMethod(source, botClassName);
|
|
2126
|
+
results.push(decideResult);
|
|
2127
|
+
if (decideResult.severity === "fail") return results;
|
|
2128
|
+
results.push(
|
|
2129
|
+
...await smokeTest(entry, botClassName, timeoutWarnMs, checkConnectivity, conformanceTimeoutMs)
|
|
2130
|
+
);
|
|
2131
|
+
return results;
|
|
2132
|
+
}
|
|
2133
|
+
async function findEntryPoint(dir, override) {
|
|
2134
|
+
const candidates = override ? [override] : ALLOWED_ENTRY_POINTS;
|
|
2135
|
+
for (const name of candidates) {
|
|
2136
|
+
const candidate = path4.join(dir, name);
|
|
2137
|
+
try {
|
|
2138
|
+
const s = await fs3.stat(candidate);
|
|
2139
|
+
if (s.isFile()) return candidate;
|
|
2140
|
+
} catch {
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return null;
|
|
2144
|
+
}
|
|
2145
|
+
async function checkSyntax(filePath) {
|
|
2146
|
+
try {
|
|
2147
|
+
execSync(`node --check ${JSON.stringify(filePath)}`, {
|
|
2148
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2149
|
+
timeout: 5e3
|
|
2150
|
+
});
|
|
2151
|
+
return { severity: "pass", name: "syntax", message: "Valid JavaScript syntax" };
|
|
2152
|
+
} catch (err) {
|
|
2153
|
+
const stderr = err.stderr?.toString() ?? String(err);
|
|
2154
|
+
const firstLine = stderr.split("\n").find((l) => l.includes("SyntaxError")) ?? stderr.split("\n")[0] ?? stderr;
|
|
2155
|
+
return {
|
|
2156
|
+
severity: "fail",
|
|
2157
|
+
name: "syntax",
|
|
2158
|
+
message: `Syntax error: ${firstLine.trim()}`
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function checkImports(source, filename) {
|
|
2163
|
+
const results = [];
|
|
2164
|
+
const importRe = /(?:from|import|require)\s*\(?\s*["']([^"']+)["']/g;
|
|
2165
|
+
const found = /* @__PURE__ */ new Set();
|
|
2166
|
+
let m;
|
|
2167
|
+
while ((m = importRe.exec(source)) !== null) {
|
|
2168
|
+
if (m[1]) found.add(m[1]);
|
|
2169
|
+
}
|
|
2170
|
+
const blocked = [];
|
|
2171
|
+
const warned = [];
|
|
2172
|
+
for (const mod of found) {
|
|
2173
|
+
if (BLOCKED_MODULES.has(mod)) blocked.push(mod);
|
|
2174
|
+
else if (WARN_MODULES.has(mod)) warned.push(mod);
|
|
2175
|
+
}
|
|
2176
|
+
if (blocked.length) {
|
|
2177
|
+
results.push({
|
|
2178
|
+
severity: "fail",
|
|
2179
|
+
name: "imports",
|
|
2180
|
+
message: `Blocked imports detected in ${filename}: ${blocked.join(", ")}`
|
|
2181
|
+
});
|
|
2182
|
+
} else {
|
|
2183
|
+
results.push({
|
|
2184
|
+
severity: "pass",
|
|
2185
|
+
name: "imports",
|
|
2186
|
+
message: "No blocked imports detected"
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
for (const w of warned) {
|
|
2190
|
+
results.push({
|
|
2191
|
+
severity: "warn",
|
|
2192
|
+
name: "imports",
|
|
2193
|
+
message: `Imports ${JSON.stringify(w)} \u2014 usable but the platform sandbox restricts what it can do`
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
return results;
|
|
2197
|
+
}
|
|
2198
|
+
function checkBotClass(source) {
|
|
2199
|
+
const m = /class\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+extends\s+(?:Bot|ChipzenBot)\b/.exec(source);
|
|
2200
|
+
if (!m || !m[1]) {
|
|
2201
|
+
return {
|
|
2202
|
+
severity: "fail",
|
|
2203
|
+
name: "bot_class",
|
|
2204
|
+
message: "No class extending Bot (or ChipzenBot) found in entry point"
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
return {
|
|
2208
|
+
severity: "pass",
|
|
2209
|
+
name: "bot_class",
|
|
2210
|
+
message: `Found bot class: ${m[1]}`
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
function checkDecideMethod(source, className) {
|
|
2214
|
+
const classBlock = extractClassBlock(source, className);
|
|
2215
|
+
if (!classBlock) {
|
|
2216
|
+
return {
|
|
2217
|
+
severity: "fail",
|
|
2218
|
+
name: "decide_method",
|
|
2219
|
+
message: `Could not isolate ${className}'s class body for decide() check`
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
if (!/\bdecide\s*\(/.test(stripComments(classBlock))) {
|
|
2223
|
+
return {
|
|
2224
|
+
severity: "fail",
|
|
2225
|
+
name: "decide_method",
|
|
2226
|
+
message: `${className} does not implement decide()`
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
return {
|
|
2230
|
+
severity: "pass",
|
|
2231
|
+
name: "decide_method",
|
|
2232
|
+
message: `${className}.decide() implemented`
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
function extractClassBlock(source, className) {
|
|
2236
|
+
const start = source.search(new RegExp(`class\\s+${className}\\s+extends\\s+(?:Bot|ChipzenBot)\\b`));
|
|
2237
|
+
if (start < 0) return null;
|
|
2238
|
+
const braceIdx = source.indexOf("{", start);
|
|
2239
|
+
if (braceIdx < 0) return null;
|
|
2240
|
+
let depth = 1;
|
|
2241
|
+
for (let i = braceIdx + 1; i < source.length; i++) {
|
|
2242
|
+
if (source[i] === "{") depth++;
|
|
2243
|
+
else if (source[i] === "}") {
|
|
2244
|
+
depth--;
|
|
2245
|
+
if (depth === 0) return source.slice(braceIdx + 1, i);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
async function smokeTest(entry, className, timeoutWarnMs, checkConnectivity, conformanceTimeoutMs) {
|
|
2251
|
+
const results = [];
|
|
2252
|
+
let mod;
|
|
2253
|
+
try {
|
|
2254
|
+
const url = new URL(`file://${path4.resolve(entry)}`).toString();
|
|
2255
|
+
mod = await import(url);
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
results.push({
|
|
2258
|
+
severity: "fail",
|
|
2259
|
+
name: "smoke_test",
|
|
2260
|
+
message: `Failed to import bot: ${err.message}`
|
|
2261
|
+
});
|
|
2262
|
+
return results;
|
|
2263
|
+
}
|
|
2264
|
+
const Cls = mod[className];
|
|
2265
|
+
if (typeof Cls !== "function") {
|
|
2266
|
+
results.push({
|
|
2267
|
+
severity: "fail",
|
|
2268
|
+
name: "smoke_test",
|
|
2269
|
+
message: `Bot class ${className} not exported from entry point \u2014 add \`export { ${className} }\``
|
|
2270
|
+
});
|
|
2271
|
+
return results;
|
|
2272
|
+
}
|
|
2273
|
+
let bot;
|
|
2274
|
+
try {
|
|
2275
|
+
bot = new Cls();
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
results.push({
|
|
2278
|
+
severity: "fail",
|
|
2279
|
+
name: "smoke_test",
|
|
2280
|
+
message: `${className}() constructor threw: ${err.message}`
|
|
2281
|
+
});
|
|
2282
|
+
return results;
|
|
2283
|
+
}
|
|
2284
|
+
const mockState = mockGameState();
|
|
2285
|
+
const start = performance.now();
|
|
2286
|
+
let action;
|
|
2287
|
+
try {
|
|
2288
|
+
action = bot.decide(mockState);
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
results.push({
|
|
2291
|
+
severity: "fail",
|
|
2292
|
+
name: "smoke_test",
|
|
2293
|
+
message: `${className}.decide() threw: ${err.message}`
|
|
2294
|
+
});
|
|
2295
|
+
return results;
|
|
2296
|
+
}
|
|
2297
|
+
const elapsedMs = performance.now() - start;
|
|
2298
|
+
if (!isAction(action)) {
|
|
2299
|
+
results.push({
|
|
2300
|
+
severity: "fail",
|
|
2301
|
+
name: "smoke_test",
|
|
2302
|
+
message: `${className}.decide() returned ${describe(action)} \u2014 expected an Action`
|
|
2303
|
+
});
|
|
2304
|
+
return results;
|
|
2305
|
+
}
|
|
2306
|
+
results.push({
|
|
2307
|
+
severity: "pass",
|
|
2308
|
+
name: "smoke_test",
|
|
2309
|
+
message: `decide() returned ${action.action} successfully`
|
|
2310
|
+
});
|
|
2311
|
+
if (elapsedMs > PLATFORM_TIMEOUT_MS) {
|
|
2312
|
+
results.push({
|
|
2313
|
+
severity: "fail",
|
|
2314
|
+
name: "timeout",
|
|
2315
|
+
message: `decide() took ${elapsedMs.toFixed(1)} ms \u2014 exceeds platform default ${PLATFORM_TIMEOUT_MS} ms`
|
|
2316
|
+
});
|
|
2317
|
+
} else if (elapsedMs > timeoutWarnMs) {
|
|
2318
|
+
results.push({
|
|
2319
|
+
severity: "warn",
|
|
2320
|
+
name: "timeout",
|
|
2321
|
+
message: `decide() took ${elapsedMs.toFixed(1)} ms \u2014 over the warn threshold of ${timeoutWarnMs} ms`
|
|
2322
|
+
});
|
|
2323
|
+
} else {
|
|
2324
|
+
results.push({
|
|
2325
|
+
severity: "pass",
|
|
2326
|
+
name: "timeout",
|
|
2327
|
+
message: `decide() completed in ${elapsedMs.toFixed(1)} ms`
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
if (checkConnectivity) {
|
|
2331
|
+
const conformance = await runConformanceChecks(bot, {
|
|
2332
|
+
timeoutMs: conformanceTimeoutMs
|
|
2333
|
+
});
|
|
2334
|
+
results.push(...conformance);
|
|
2335
|
+
}
|
|
2336
|
+
return results;
|
|
2337
|
+
}
|
|
2338
|
+
function mockGameState() {
|
|
2339
|
+
return {
|
|
2340
|
+
handNumber: 1,
|
|
2341
|
+
phase: "preflop",
|
|
2342
|
+
holeCards: [
|
|
2343
|
+
{ rank: "A", suit: "h" },
|
|
2344
|
+
{ rank: "K", suit: "d" }
|
|
2345
|
+
],
|
|
2346
|
+
board: [],
|
|
2347
|
+
pot: 150,
|
|
2348
|
+
yourStack: 9900,
|
|
2349
|
+
opponentStacks: [9850],
|
|
2350
|
+
yourSeat: 0,
|
|
2351
|
+
dealerSeat: 0,
|
|
2352
|
+
toCall: 50,
|
|
2353
|
+
minRaise: 200,
|
|
2354
|
+
maxRaise: 9900,
|
|
2355
|
+
validActions: ["fold", "call", "raise"],
|
|
2356
|
+
actionHistory: [],
|
|
2357
|
+
roundId: "",
|
|
2358
|
+
requestId: ""
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
function stripComments(source) {
|
|
2362
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
2363
|
+
}
|
|
2364
|
+
function describe(v) {
|
|
2365
|
+
if (v === null) return "null";
|
|
2366
|
+
if (v === void 0) return "undefined";
|
|
2367
|
+
return `${typeof v} ${JSON.stringify(v)}`;
|
|
2368
|
+
}
|
|
2369
|
+
async function dirTotalBytes(dir) {
|
|
2370
|
+
let total = 0;
|
|
2371
|
+
const entries = await fs3.readdir(dir, { withFileTypes: true });
|
|
2372
|
+
for (const entry of entries) {
|
|
2373
|
+
const full = path4.join(dir, entry.name);
|
|
2374
|
+
if (entry.isDirectory()) {
|
|
2375
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
2376
|
+
total += await dirTotalBytes(full);
|
|
2377
|
+
} else if (entry.isFile()) {
|
|
2378
|
+
const s = await fs3.stat(full);
|
|
2379
|
+
total += s.size;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
return total;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// src/cli.ts
|
|
2386
|
+
var COMMANDS = {
|
|
2387
|
+
init: "Scaffold a new bot project from a starter template",
|
|
2388
|
+
validate: "Run pre-upload checks: size, syntax, imports, smoke test, timeout",
|
|
2389
|
+
"run-external": "Run a bot on the external-API remote-play path (lobby -> matched -> play)"
|
|
2390
|
+
};
|
|
2391
|
+
async function main(argv = process.argv.slice(2)) {
|
|
2392
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
2393
|
+
printHelp();
|
|
2394
|
+
process.exit(argv.length === 0 ? 1 : 0);
|
|
2395
|
+
}
|
|
2396
|
+
const [command, ...rest] = argv;
|
|
2397
|
+
switch (command) {
|
|
2398
|
+
case "init":
|
|
2399
|
+
await initCli(rest);
|
|
2400
|
+
return;
|
|
2401
|
+
case "validate":
|
|
2402
|
+
await validateCli(rest);
|
|
2403
|
+
return;
|
|
2404
|
+
case "run-external":
|
|
2405
|
+
await runExternalCli(rest);
|
|
2406
|
+
return;
|
|
2407
|
+
default:
|
|
2408
|
+
console.error(`Unknown command: ${command}`);
|
|
2409
|
+
console.error("");
|
|
2410
|
+
printHelp();
|
|
2411
|
+
process.exit(1);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
function printHelp() {
|
|
2415
|
+
console.log("Chipzen Poker Bot SDK");
|
|
2416
|
+
console.log("");
|
|
2417
|
+
console.log("Usage: chipzen-sdk <command> [options]");
|
|
2418
|
+
console.log("");
|
|
2419
|
+
console.log("Commands:");
|
|
2420
|
+
for (const [name, desc] of Object.entries(COMMANDS)) {
|
|
2421
|
+
console.log(` ${name.padEnd(14)} ${desc}`);
|
|
2422
|
+
}
|
|
2423
|
+
console.log("");
|
|
2424
|
+
console.log("Run 'chipzen-sdk <command> --help' for details on a specific command.");
|
|
2425
|
+
}
|
|
2426
|
+
async function initCli(args) {
|
|
2427
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
2428
|
+
console.log("Usage: chipzen-sdk init <name> [--dir <parent>]");
|
|
2429
|
+
console.log("");
|
|
2430
|
+
console.log("Scaffold a new Chipzen bot project under <parent>/<name>.");
|
|
2431
|
+
console.log("Default parent is the current directory.");
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const { values, positionals } = parseArgs({
|
|
2435
|
+
args,
|
|
2436
|
+
options: { dir: { type: "string" } },
|
|
2437
|
+
allowPositionals: true
|
|
2438
|
+
});
|
|
2439
|
+
const name = positionals[0];
|
|
2440
|
+
if (!name) {
|
|
2441
|
+
console.error("error: chipzen-sdk init requires a <name> positional argument");
|
|
2442
|
+
process.exit(2);
|
|
2443
|
+
}
|
|
2444
|
+
try {
|
|
2445
|
+
const created = await scaffoldBot(name, { parentDir: values.dir });
|
|
2446
|
+
console.log(`Created bot project: ${created}`);
|
|
2447
|
+
console.log("");
|
|
2448
|
+
console.log("Next steps:");
|
|
2449
|
+
console.log(` cd ${name}`);
|
|
2450
|
+
console.log(" npm install");
|
|
2451
|
+
console.log(" # Edit bot.js to implement your strategy");
|
|
2452
|
+
console.log(" chipzen-sdk validate .");
|
|
2453
|
+
} catch (err) {
|
|
2454
|
+
console.error(`error: ${err.message}`);
|
|
2455
|
+
process.exit(1);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
async function validateCli(args) {
|
|
2459
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
2460
|
+
console.log("Usage: chipzen-sdk validate <path> [options]");
|
|
2461
|
+
console.log("");
|
|
2462
|
+
console.log("Pre-upload validation. Checks performed:");
|
|
2463
|
+
console.log(" size Directory size within upload limits");
|
|
2464
|
+
console.log(" file_structure Entry point file (bot.js / bot.mjs / bot.cjs / main.js)");
|
|
2465
|
+
console.log(" syntax Valid JavaScript via `node --check`");
|
|
2466
|
+
console.log(" imports No blocked sandbox modules; warn on fs/fs-promises");
|
|
2467
|
+
console.log(" bot_class Class extending Bot or ChipzenBot exists");
|
|
2468
|
+
console.log(" decide_method Bot class implements decide()");
|
|
2469
|
+
console.log(" smoke_test Bot can be instantiated; decide() returns an Action");
|
|
2470
|
+
console.log(" timeout decide() completes within time limits");
|
|
2471
|
+
console.log("");
|
|
2472
|
+
console.log("With --check-connectivity, four protocol scenarios also run:");
|
|
2473
|
+
console.log(" connectivity_full_match Drive 1 canned match end-to-end");
|
|
2474
|
+
console.log(" multi_turn_request_id_echo Drive 3 turn_requests across phases");
|
|
2475
|
+
console.log(" and verify request_id echo on each");
|
|
2476
|
+
console.log(" action_rejected_recovery Verify safe-fallback retry on rejection");
|
|
2477
|
+
console.log(" retry_storm_bounded Verify reactive response to 3 back-to-back");
|
|
2478
|
+
console.log(" action_rejected messages");
|
|
2479
|
+
console.log("");
|
|
2480
|
+
console.log("The validator is a courtesy linter -- the authoritative gate is");
|
|
2481
|
+
console.log("server-side seccomp + cap-drop on the bot container.");
|
|
2482
|
+
console.log("");
|
|
2483
|
+
console.log("Options:");
|
|
2484
|
+
console.log(" --entry-point <name> Override entry-point filename");
|
|
2485
|
+
console.log(` --max-size-mb <int> Max upload size in MB (default: ${DEFAULT_MAX_UPLOAD_BYTES / (1024 * 1024)})`);
|
|
2486
|
+
console.log(` --timeout-warn-ms <int> Warn-threshold for decide() in ms (default: ${DEFAULT_TIMEOUT_WARN_MS})`);
|
|
2487
|
+
console.log(" --check-connectivity Run the 4 protocol-conformance scenarios");
|
|
2488
|
+
console.log(" --no-color Disable colored output");
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
const { values, positionals } = parseArgs({
|
|
2492
|
+
args,
|
|
2493
|
+
options: {
|
|
2494
|
+
"entry-point": { type: "string" },
|
|
2495
|
+
"max-size-mb": { type: "string" },
|
|
2496
|
+
"timeout-warn-ms": { type: "string" },
|
|
2497
|
+
"check-connectivity": { type: "boolean" },
|
|
2498
|
+
"no-color": { type: "boolean" }
|
|
2499
|
+
},
|
|
2500
|
+
allowPositionals: true
|
|
2501
|
+
});
|
|
2502
|
+
const target = positionals[0];
|
|
2503
|
+
if (!target) {
|
|
2504
|
+
console.error("error: chipzen-sdk validate requires a <path> positional argument");
|
|
2505
|
+
process.exit(2);
|
|
2506
|
+
}
|
|
2507
|
+
const maxBytes = values["max-size-mb"] ? parseInt(values["max-size-mb"], 10) * 1024 * 1024 : DEFAULT_MAX_UPLOAD_BYTES;
|
|
2508
|
+
const timeoutWarn = values["timeout-warn-ms"] ? parseInt(values["timeout-warn-ms"], 10) : DEFAULT_TIMEOUT_WARN_MS;
|
|
2509
|
+
const results = await validateBot(path5.resolve(target), {
|
|
2510
|
+
entryPoint: values["entry-point"],
|
|
2511
|
+
maxUploadBytes: maxBytes,
|
|
2512
|
+
timeoutWarnMs: timeoutWarn,
|
|
2513
|
+
checkConnectivity: values["check-connectivity"] ?? false
|
|
2514
|
+
});
|
|
2515
|
+
printResults(results, !values["no-color"]);
|
|
2516
|
+
if (results.some((r) => r.severity === "fail")) process.exit(1);
|
|
2517
|
+
}
|
|
2518
|
+
function printResults(results, color) {
|
|
2519
|
+
const supportsColor = color && process.stdout.isTTY;
|
|
2520
|
+
const GREEN = supportsColor ? "\x1B[92m" : "";
|
|
2521
|
+
const YELLOW = supportsColor ? "\x1B[93m" : "";
|
|
2522
|
+
const RED = supportsColor ? "\x1B[91m" : "";
|
|
2523
|
+
const RESET = supportsColor ? "\x1B[0m" : "";
|
|
2524
|
+
console.log("");
|
|
2525
|
+
console.log("Chipzen Bot Validation");
|
|
2526
|
+
console.log("=".repeat(50));
|
|
2527
|
+
for (const r of results) {
|
|
2528
|
+
const icon = r.severity === "pass" ? `${GREEN}PASS${RESET}` : r.severity === "warn" ? `${YELLOW}WARN${RESET}` : `${RED}FAIL${RESET}`;
|
|
2529
|
+
console.log(` [${icon}] ${r.name}: ${r.message}`);
|
|
2530
|
+
}
|
|
2531
|
+
console.log("");
|
|
2532
|
+
const fails = results.filter((r) => r.severity === "fail").length;
|
|
2533
|
+
if (fails > 0) {
|
|
2534
|
+
console.log(`${RED}${fails} check${fails === 1 ? "" : "s"} failed.${RESET}`);
|
|
2535
|
+
} else {
|
|
2536
|
+
console.log(`${GREEN}All checks passed! Your bot is ready to upload.${RESET}`);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/bin.ts
|
|
2541
|
+
main().catch((err) => {
|
|
2542
|
+
console.error(err instanceof Error ? err.stack ?? err.message : String(err));
|
|
2543
|
+
process.exit(1);
|
|
2544
|
+
});
|
|
2545
|
+
//# sourceMappingURL=bin.js.map
|