@mauricode/token-derby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/bin.js +1216 -0
- package/dist/bin.js.map +1 -0
- package/package.json +42 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/stable-create.ts
|
|
4
|
+
import React2 from "react";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
|
|
7
|
+
// src/ui/HorseCreator.tsx
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { Box as Box2, Text as Text2, useInput } from "ink";
|
|
10
|
+
import TextInput from "ink-text-input";
|
|
11
|
+
|
|
12
|
+
// src/ui/HorseSprite.tsx
|
|
13
|
+
import { Box, Text } from "ink";
|
|
14
|
+
|
|
15
|
+
// src/ui/sprite.ts
|
|
16
|
+
var FIXED_COLORS = {
|
|
17
|
+
E: "#000000",
|
|
18
|
+
H: "#1F1108"
|
|
19
|
+
};
|
|
20
|
+
var MAIN_ROWS = [
|
|
21
|
+
"................................",
|
|
22
|
+
"................................",
|
|
23
|
+
"..........................MMM...",
|
|
24
|
+
"..........................MMM...",
|
|
25
|
+
".........................MBBEBB.",
|
|
26
|
+
".........................MBBEBB.",
|
|
27
|
+
"........................MBBBBBBB",
|
|
28
|
+
"........................MBBBBBBB",
|
|
29
|
+
"..................MMMMMMMBBB....",
|
|
30
|
+
"..................MMMMMMMBBB....",
|
|
31
|
+
"....BBBBBBBBSSSSSSMMBBBBBB......",
|
|
32
|
+
"...BBBBBBBBBSSSSSSMMBBBBBB......",
|
|
33
|
+
".TTBBBBBBBBBSSSSSSBBBBBBBB......",
|
|
34
|
+
".TTBBBBBBBBBSSSSSSBBBBBBBB......",
|
|
35
|
+
"TTTBBBBBBBBBBBBBBBBBBBBBBB......",
|
|
36
|
+
"TTTBBBBBBBBBBBBBBBBBBBBB........",
|
|
37
|
+
"...BBB.BBB.....BBB.BBB..........",
|
|
38
|
+
"...BBB.BBB.....BBB.BBB..........",
|
|
39
|
+
"....BB..BB......BB..BB..........",
|
|
40
|
+
"....BB..BB......BB..BB..........",
|
|
41
|
+
"....BB..BB......BB..BB..........",
|
|
42
|
+
"....BB..BB......BB..BB..........",
|
|
43
|
+
"....BB..BB......BB..BB..........",
|
|
44
|
+
"...HHH.HHH.....HHH.HHH.........."
|
|
45
|
+
];
|
|
46
|
+
var MINI_ROWS = [
|
|
47
|
+
".BBSSMBB",
|
|
48
|
+
".BBSSMBB",
|
|
49
|
+
"TBBBBBB.",
|
|
50
|
+
"THH..HH."
|
|
51
|
+
];
|
|
52
|
+
var MAIN_SPRITE = parse(MAIN_ROWS, 32, 24);
|
|
53
|
+
var MINI_SPRITE = parse(MINI_ROWS, 8, 4);
|
|
54
|
+
function parse(rows, width, height) {
|
|
55
|
+
if (rows.length !== height) {
|
|
56
|
+
throw new Error(`sprite has ${rows.length} rows, expected ${height}`);
|
|
57
|
+
}
|
|
58
|
+
return rows.map((row, y) => {
|
|
59
|
+
if (row.length !== width) {
|
|
60
|
+
throw new Error(`sprite row ${y} has length ${row.length}, expected ${width}`);
|
|
61
|
+
}
|
|
62
|
+
return [...row].map((c) => toTag(c));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function toTag(c) {
|
|
66
|
+
switch (c) {
|
|
67
|
+
case "B":
|
|
68
|
+
return "B";
|
|
69
|
+
case "M":
|
|
70
|
+
return "M";
|
|
71
|
+
case "T":
|
|
72
|
+
return "T";
|
|
73
|
+
case "S":
|
|
74
|
+
return "S";
|
|
75
|
+
case "E":
|
|
76
|
+
return "E";
|
|
77
|
+
case "H":
|
|
78
|
+
return "H";
|
|
79
|
+
case ".":
|
|
80
|
+
return null;
|
|
81
|
+
default:
|
|
82
|
+
throw new Error(`unknown sprite char: ${c}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/ui/sprite-render.ts
|
|
87
|
+
function renderSprite(sprite, colors) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let y = 0; y + 1 < sprite.length || y < sprite.length; y += 2) {
|
|
90
|
+
const topRow = sprite[y];
|
|
91
|
+
const bottomRow = sprite[y + 1];
|
|
92
|
+
if (!topRow) break;
|
|
93
|
+
const row = [];
|
|
94
|
+
for (let x = 0; x < topRow.length; x++) {
|
|
95
|
+
row.push({
|
|
96
|
+
top: tagColor(topRow[x] ?? null, colors),
|
|
97
|
+
bottom: tagColor(bottomRow?.[x] ?? null, colors)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
out.push(row);
|
|
101
|
+
if (!bottomRow) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function tagColor(tag, colors) {
|
|
106
|
+
if (tag === null) return null;
|
|
107
|
+
if (tag === "E") return FIXED_COLORS.E;
|
|
108
|
+
if (tag === "H") return FIXED_COLORS.H;
|
|
109
|
+
if (tag === "B") return colors.body;
|
|
110
|
+
if (tag === "M") return colors.mane;
|
|
111
|
+
if (tag === "T") return colors.tail;
|
|
112
|
+
if (tag === "S") return colors.saddle;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/ui/HorseSprite.tsx
|
|
117
|
+
import { jsx } from "react/jsx-runtime";
|
|
118
|
+
function HorseSprite({ sprite, colors }) {
|
|
119
|
+
const grid = renderSprite(sprite, colors);
|
|
120
|
+
return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: grid.map((row, y) => /* @__PURE__ */ jsx(Text, { children: rowToAnsi(row) }, y)) });
|
|
121
|
+
}
|
|
122
|
+
function rowToAnsi(row) {
|
|
123
|
+
let out = "";
|
|
124
|
+
for (const cell of row) {
|
|
125
|
+
if (cell.top === null && cell.bottom === null) {
|
|
126
|
+
out += " ";
|
|
127
|
+
} else if (cell.top !== null && cell.bottom !== null) {
|
|
128
|
+
out += ansiFg(cell.top) + ansiBg(cell.bottom) + "\u2580" + RESET;
|
|
129
|
+
} else if (cell.top !== null) {
|
|
130
|
+
out += ansiFg(cell.top) + "\u2580" + RESET;
|
|
131
|
+
} else {
|
|
132
|
+
out += ansiFg(cell.bottom) + "\u2584" + RESET;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
var RESET = "\x1B[0m";
|
|
138
|
+
function hexToRgb(hex) {
|
|
139
|
+
const h = hex.replace("#", "");
|
|
140
|
+
return [
|
|
141
|
+
parseInt(h.slice(0, 2), 16),
|
|
142
|
+
parseInt(h.slice(2, 4), 16),
|
|
143
|
+
parseInt(h.slice(4, 6), 16)
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
function ansiFg(hex) {
|
|
147
|
+
const [r, g, b] = hexToRgb(hex);
|
|
148
|
+
return `\x1B[38;2;${r};${g};${b}m`;
|
|
149
|
+
}
|
|
150
|
+
function ansiBg(hex) {
|
|
151
|
+
const [r, g, b] = hexToRgb(hex);
|
|
152
|
+
return `\x1B[48;2;${r};${g};${b}m`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/ui/palette.ts
|
|
156
|
+
var SLOTS = ["body", "mane", "tail", "saddle"];
|
|
157
|
+
var PALETTES = {
|
|
158
|
+
body: [
|
|
159
|
+
"#8B4513",
|
|
160
|
+
"#A0522D",
|
|
161
|
+
"#D2691E",
|
|
162
|
+
"#CD853F",
|
|
163
|
+
"#DEB887",
|
|
164
|
+
"#F5DEB3",
|
|
165
|
+
"#FFFFFF",
|
|
166
|
+
"#000000",
|
|
167
|
+
"#4A2C2A",
|
|
168
|
+
"#5D3A1A",
|
|
169
|
+
"#704214",
|
|
170
|
+
"#9C5919",
|
|
171
|
+
"#B87333",
|
|
172
|
+
"#E5B783",
|
|
173
|
+
"#F0E1C9",
|
|
174
|
+
"#2F1B0C"
|
|
175
|
+
],
|
|
176
|
+
mane: [
|
|
177
|
+
"#000000",
|
|
178
|
+
"#1C1C1C",
|
|
179
|
+
"#2F1B0C",
|
|
180
|
+
"#4A2C2A",
|
|
181
|
+
"#5D3A1A",
|
|
182
|
+
"#8B4513",
|
|
183
|
+
"#FFFFFF",
|
|
184
|
+
"#F5F5DC",
|
|
185
|
+
"#DEB887",
|
|
186
|
+
"#CD853F",
|
|
187
|
+
"#FF4500",
|
|
188
|
+
"#B22222",
|
|
189
|
+
"#191970",
|
|
190
|
+
"#4B0082",
|
|
191
|
+
"#2E8B57",
|
|
192
|
+
"#FFD700"
|
|
193
|
+
],
|
|
194
|
+
tail: [
|
|
195
|
+
"#000000",
|
|
196
|
+
"#1C1C1C",
|
|
197
|
+
"#2F1B0C",
|
|
198
|
+
"#4A2C2A",
|
|
199
|
+
"#5D3A1A",
|
|
200
|
+
"#8B4513",
|
|
201
|
+
"#FFFFFF",
|
|
202
|
+
"#F5F5DC",
|
|
203
|
+
"#DEB887",
|
|
204
|
+
"#CD853F",
|
|
205
|
+
"#FF4500",
|
|
206
|
+
"#B22222",
|
|
207
|
+
"#191970",
|
|
208
|
+
"#4B0082",
|
|
209
|
+
"#2E8B57",
|
|
210
|
+
"#FFD700"
|
|
211
|
+
],
|
|
212
|
+
saddle: [
|
|
213
|
+
"#C0392B",
|
|
214
|
+
"#922B21",
|
|
215
|
+
"#7B241C",
|
|
216
|
+
"#641E16",
|
|
217
|
+
"#1F618D",
|
|
218
|
+
"#21618C",
|
|
219
|
+
"#1B4F72",
|
|
220
|
+
"#0E6655",
|
|
221
|
+
"#117A65",
|
|
222
|
+
"#196F3D",
|
|
223
|
+
"#7D6608",
|
|
224
|
+
"#9A7D0A",
|
|
225
|
+
"#6E2C00",
|
|
226
|
+
"#4D5656",
|
|
227
|
+
"#212F3D",
|
|
228
|
+
"#000000"
|
|
229
|
+
]
|
|
230
|
+
};
|
|
231
|
+
function nextColor(slot, current) {
|
|
232
|
+
const palette = PALETTES[slot];
|
|
233
|
+
const idx = palette.indexOf(current);
|
|
234
|
+
return palette[(idx + 1 + palette.length) % palette.length] ?? palette[0];
|
|
235
|
+
}
|
|
236
|
+
function prevColor(slot, current) {
|
|
237
|
+
const palette = PALETTES[slot];
|
|
238
|
+
const idx = palette.indexOf(current);
|
|
239
|
+
if (idx < 0) return palette[0];
|
|
240
|
+
return palette[(idx - 1 + palette.length) % palette.length];
|
|
241
|
+
}
|
|
242
|
+
function defaultColors() {
|
|
243
|
+
return {
|
|
244
|
+
body: PALETTES.body[0],
|
|
245
|
+
mane: PALETTES.mane[0],
|
|
246
|
+
tail: PALETTES.tail[0],
|
|
247
|
+
saddle: PALETTES.saddle[0]
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/ui/HorseCreator.tsx
|
|
252
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
253
|
+
function HorseCreator({ onSubmit, onCancel, initialColors, initialName }) {
|
|
254
|
+
const [colors, setColors] = useState(initialColors ?? defaultColors());
|
|
255
|
+
const [slotIdx, setSlotIdx] = useState(0);
|
|
256
|
+
const [namingMode, setNamingMode] = useState(false);
|
|
257
|
+
const [name, setName] = useState(initialName ?? "");
|
|
258
|
+
const [error, setError] = useState(null);
|
|
259
|
+
const slot = SLOTS[slotIdx];
|
|
260
|
+
useInput((input, key) => {
|
|
261
|
+
if (namingMode) return;
|
|
262
|
+
if (key.escape) {
|
|
263
|
+
onCancel();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (key.upArrow) {
|
|
267
|
+
setSlotIdx((slotIdx - 1 + SLOTS.length) % SLOTS.length);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (key.downArrow) {
|
|
271
|
+
setSlotIdx((slotIdx + 1) % SLOTS.length);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (key.leftArrow) {
|
|
275
|
+
setColors({ ...colors, [slot]: prevColor(slot, colors[slot]) });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (key.rightArrow) {
|
|
279
|
+
setColors({ ...colors, [slot]: nextColor(slot, colors[slot]) });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (key.return) {
|
|
283
|
+
setNamingMode(true);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
const handleNameSubmit = (value) => {
|
|
288
|
+
if (!value.trim()) {
|
|
289
|
+
setError("Name required");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
onSubmit(value.trim(), colors);
|
|
293
|
+
};
|
|
294
|
+
return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", children: [
|
|
295
|
+
/* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
|
|
296
|
+
/* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: SLOTS.map((s, i) => /* @__PURE__ */ jsxs(Text2, { children: [
|
|
297
|
+
i === slotIdx ? "\u25BA" : " ",
|
|
298
|
+
" ",
|
|
299
|
+
s.padEnd(7),
|
|
300
|
+
" ",
|
|
301
|
+
/* @__PURE__ */ jsx2(Text2, { color: colors[s], children: "\u2588\u2588" }),
|
|
302
|
+
" ",
|
|
303
|
+
colors[s]
|
|
304
|
+
] }, s)) }),
|
|
305
|
+
!namingMode && /* @__PURE__ */ jsx2(Box2, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2191/\u2193 select slot \xB7 \u2190/\u2192 cycle color \xB7 Enter accept \xB7 Esc cancel" }) }),
|
|
306
|
+
namingMode && /* @__PURE__ */ jsxs(Box2, { marginTop: 1, flexDirection: "column", children: [
|
|
307
|
+
/* @__PURE__ */ jsx2(Text2, { children: "Name your horse: " }),
|
|
308
|
+
/* @__PURE__ */ jsx2(TextInput, { value: name, onChange: (v) => {
|
|
309
|
+
setName(v);
|
|
310
|
+
setError(null);
|
|
311
|
+
}, onSubmit: handleNameSubmit }),
|
|
312
|
+
error && /* @__PURE__ */ jsx2(Text2, { color: "red", children: error })
|
|
313
|
+
] })
|
|
314
|
+
] });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/stable/stable.ts
|
|
318
|
+
import * as fs from "fs/promises";
|
|
319
|
+
|
|
320
|
+
// src/paths.ts
|
|
321
|
+
import * as os from "os";
|
|
322
|
+
import * as path from "path";
|
|
323
|
+
function homeDir() {
|
|
324
|
+
return process.env.TOKEN_DERBY_HOME ?? path.join(os.homedir(), ".token-derby");
|
|
325
|
+
}
|
|
326
|
+
function stableFile() {
|
|
327
|
+
return path.join(homeDir(), "stable.json");
|
|
328
|
+
}
|
|
329
|
+
function activeRaceFile(joinCode) {
|
|
330
|
+
return path.join(homeDir(), "active-races", `${joinCode}.json`);
|
|
331
|
+
}
|
|
332
|
+
function activeRacesDir() {
|
|
333
|
+
return path.join(homeDir(), "active-races");
|
|
334
|
+
}
|
|
335
|
+
function claudeProjectsDir() {
|
|
336
|
+
return process.env.TOKEN_DERBY_CLAUDE_DIR ?? path.join(os.homedir(), ".claude", "projects");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/stable/stable.ts
|
|
340
|
+
async function loadStable() {
|
|
341
|
+
try {
|
|
342
|
+
const raw = await fs.readFile(stableFile(), "utf8");
|
|
343
|
+
const parsed = JSON.parse(raw);
|
|
344
|
+
if (!parsed || !Array.isArray(parsed.horses)) return { horses: [] };
|
|
345
|
+
return parsed;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (e?.code === "ENOENT") return { horses: [] };
|
|
348
|
+
if (e instanceof SyntaxError) return { horses: [] };
|
|
349
|
+
throw e;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function saveStable(stable) {
|
|
353
|
+
await fs.mkdir(homeDir(), { recursive: true });
|
|
354
|
+
await fs.writeFile(stableFile(), JSON.stringify(stable, null, 2) + "\n", "utf8");
|
|
355
|
+
}
|
|
356
|
+
async function upsertHorse(horse) {
|
|
357
|
+
const stable = await loadStable();
|
|
358
|
+
const idx = stable.horses.findIndex((h) => h.name === horse.name);
|
|
359
|
+
if (idx >= 0) stable.horses[idx] = horse;
|
|
360
|
+
else stable.horses.push(horse);
|
|
361
|
+
await saveStable(stable);
|
|
362
|
+
}
|
|
363
|
+
async function removeHorse(name) {
|
|
364
|
+
const stable = await loadStable();
|
|
365
|
+
stable.horses = stable.horses.filter((h) => h.name !== name);
|
|
366
|
+
await saveStable(stable);
|
|
367
|
+
}
|
|
368
|
+
function findHorse(stable, name) {
|
|
369
|
+
return stable.horses.find((h) => h.name === name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/commands/stable-create.ts
|
|
373
|
+
import * as readline from "readline/promises";
|
|
374
|
+
import { stdin, stdout } from "process";
|
|
375
|
+
async function stableCreateCommand() {
|
|
376
|
+
let exitCode = 0;
|
|
377
|
+
const app = render(
|
|
378
|
+
React2.createElement(HorseCreator, {
|
|
379
|
+
onSubmit: async (name, colors) => {
|
|
380
|
+
const stable = await loadStable();
|
|
381
|
+
const existing = findHorse(stable, name);
|
|
382
|
+
if (existing) {
|
|
383
|
+
app.unmount();
|
|
384
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
385
|
+
const answer = (await rl.question(`Horse "${name}" already exists. Overwrite? [y/N] `)).trim().toLowerCase();
|
|
386
|
+
rl.close();
|
|
387
|
+
if (answer !== "y" && answer !== "yes") {
|
|
388
|
+
console.log("Cancelled.");
|
|
389
|
+
exitCode = 1;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await upsertHorse({ name, colors, created_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
394
|
+
app.unmount();
|
|
395
|
+
console.log(`\u2713 Saved "${name}" to your stable.`);
|
|
396
|
+
},
|
|
397
|
+
onCancel: () => {
|
|
398
|
+
app.unmount();
|
|
399
|
+
console.log("Cancelled.");
|
|
400
|
+
exitCode = 1;
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
await app.waitUntilExit();
|
|
405
|
+
return exitCode;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/commands/stable-list.tsx
|
|
409
|
+
import React3 from "react";
|
|
410
|
+
import { render as render2, Box as Box3, Text as Text3 } from "ink";
|
|
411
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
412
|
+
async function stableListCommand() {
|
|
413
|
+
const stable = await loadStable();
|
|
414
|
+
if (stable.horses.length === 0) {
|
|
415
|
+
console.log("Your stable is empty. Run `token-derby stable create` to add a horse.");
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
const app = render2(
|
|
419
|
+
React3.createElement(StableList, { horses: stable.horses })
|
|
420
|
+
);
|
|
421
|
+
await app.waitUntilExit();
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
function StableList({ horses }) {
|
|
425
|
+
React3.useEffect(() => {
|
|
426
|
+
setImmediate(() => process.exit(0));
|
|
427
|
+
}, []);
|
|
428
|
+
return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", children: [
|
|
429
|
+
/* @__PURE__ */ jsxs2(Text3, { bold: true, children: [
|
|
430
|
+
"Your stable (",
|
|
431
|
+
horses.length,
|
|
432
|
+
"):"
|
|
433
|
+
] }),
|
|
434
|
+
horses.map((h) => /* @__PURE__ */ jsxs2(Box3, { flexDirection: "row", marginTop: 1, children: [
|
|
435
|
+
/* @__PURE__ */ jsx3(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors }),
|
|
436
|
+
/* @__PURE__ */ jsxs2(Text3, { children: [
|
|
437
|
+
" ",
|
|
438
|
+
h.name
|
|
439
|
+
] })
|
|
440
|
+
] }, h.name))
|
|
441
|
+
] });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/commands/stable-delete.ts
|
|
445
|
+
import * as readline2 from "readline/promises";
|
|
446
|
+
import { stdin as stdin2, stdout as stdout2 } from "process";
|
|
447
|
+
|
|
448
|
+
// src/stable/active-race.ts
|
|
449
|
+
import * as fs2 from "fs/promises";
|
|
450
|
+
import * as path2 from "path";
|
|
451
|
+
async function loadActiveRace(joinCode) {
|
|
452
|
+
try {
|
|
453
|
+
const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
|
|
454
|
+
return JSON.parse(raw);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
if (e?.code === "ENOENT") return null;
|
|
457
|
+
throw e;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function saveActiveRace(active) {
|
|
461
|
+
await fs2.mkdir(activeRacesDir(), { recursive: true });
|
|
462
|
+
await fs2.writeFile(
|
|
463
|
+
activeRaceFile(active.join_code),
|
|
464
|
+
JSON.stringify(active, null, 2) + "\n",
|
|
465
|
+
"utf8"
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
async function listActiveRaces() {
|
|
469
|
+
try {
|
|
470
|
+
const entries = await fs2.readdir(activeRacesDir());
|
|
471
|
+
return entries.filter((f) => f.endsWith(".json")).map((f) => path2.basename(f, ".json"));
|
|
472
|
+
} catch (e) {
|
|
473
|
+
if (e?.code === "ENOENT") return [];
|
|
474
|
+
throw e;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/commands/stable-delete.ts
|
|
479
|
+
async function stableDeleteCommand(name) {
|
|
480
|
+
if (!name) {
|
|
481
|
+
console.error("Usage: token-derby stable delete <name>");
|
|
482
|
+
return 2;
|
|
483
|
+
}
|
|
484
|
+
const stable = await loadStable();
|
|
485
|
+
const horse = findHorse(stable, name);
|
|
486
|
+
if (!horse) {
|
|
487
|
+
console.error(`No horse named "${name}" in your stable.`);
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
const codes = await listActiveRaces();
|
|
491
|
+
for (const code of codes) {
|
|
492
|
+
const active = await loadActiveRace(code);
|
|
493
|
+
if (active?.horse_name === name) {
|
|
494
|
+
console.error(`"${name}" is currently running in race ${code}. Close that terminal first.`);
|
|
495
|
+
return 1;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
|
|
499
|
+
const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
|
|
500
|
+
rl.close();
|
|
501
|
+
if (answer !== "y" && answer !== "yes") {
|
|
502
|
+
console.log("Cancelled.");
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
await removeHorse(name);
|
|
506
|
+
console.log(`\u2713 Deleted "${name}".`);
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/commands/create.ts
|
|
511
|
+
import * as readline3 from "readline/promises";
|
|
512
|
+
import { stdin as stdin3, stdout as stdout3 } from "process";
|
|
513
|
+
|
|
514
|
+
// src/config.ts
|
|
515
|
+
var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
|
|
516
|
+
function apiBase() {
|
|
517
|
+
return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
|
|
518
|
+
}
|
|
519
|
+
var HEARTBEAT_INTERVAL_MS = 6e4;
|
|
520
|
+
var POLL_INTERVAL_MS = 3e3;
|
|
521
|
+
var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
|
|
522
|
+
|
|
523
|
+
// src/api/client.ts
|
|
524
|
+
var ApiError = class extends Error {
|
|
525
|
+
constructor(code, message, status) {
|
|
526
|
+
super(message);
|
|
527
|
+
this.code = code;
|
|
528
|
+
this.status = status;
|
|
529
|
+
this.name = "ApiError";
|
|
530
|
+
}
|
|
531
|
+
code;
|
|
532
|
+
status;
|
|
533
|
+
};
|
|
534
|
+
async function request(method, path4, body, authToken, fetchImpl = fetch) {
|
|
535
|
+
const url = path4.startsWith("http") ? path4 : `${apiBase()}${path4}`;
|
|
536
|
+
const headers = {};
|
|
537
|
+
if (authToken) headers["authorization"] = `Bearer ${authToken}`;
|
|
538
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
539
|
+
let res;
|
|
540
|
+
try {
|
|
541
|
+
res = await fetchImpl(url, {
|
|
542
|
+
method,
|
|
543
|
+
headers,
|
|
544
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
545
|
+
});
|
|
546
|
+
} catch (e) {
|
|
547
|
+
throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
|
|
548
|
+
}
|
|
549
|
+
const text = await res.text();
|
|
550
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
551
|
+
let parsed = null;
|
|
552
|
+
if (contentType.includes("application/json") && text.length > 0) {
|
|
553
|
+
try {
|
|
554
|
+
parsed = JSON.parse(text);
|
|
555
|
+
} catch {
|
|
556
|
+
parsed = null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
if (parsed && typeof parsed.code === "string") {
|
|
561
|
+
throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
|
|
562
|
+
}
|
|
563
|
+
throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
|
|
564
|
+
}
|
|
565
|
+
return parsed;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/api/endpoints.ts
|
|
569
|
+
function createRace(body) {
|
|
570
|
+
return request("POST", "/races", body, void 0);
|
|
571
|
+
}
|
|
572
|
+
function getRace(joinCode) {
|
|
573
|
+
return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
|
|
574
|
+
}
|
|
575
|
+
function joinRace(joinCode, body) {
|
|
576
|
+
return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
|
|
577
|
+
}
|
|
578
|
+
function heartbeat(joinCode, horseId, token, body) {
|
|
579
|
+
return request(
|
|
580
|
+
"POST",
|
|
581
|
+
`/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
|
|
582
|
+
body,
|
|
583
|
+
token
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
function endRace(adminCode) {
|
|
587
|
+
return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/commands/create.ts
|
|
591
|
+
var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
592
|
+
async function createRaceCommand() {
|
|
593
|
+
const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
|
|
594
|
+
try {
|
|
595
|
+
const name = (await rl.question("Race name: ")).trim();
|
|
596
|
+
if (!name) {
|
|
597
|
+
console.error("Name required.");
|
|
598
|
+
return 1;
|
|
599
|
+
}
|
|
600
|
+
const start = (await rl.question("Start time (ISO 8601, e.g. 2026-04-23T15:00:00Z): ")).trim();
|
|
601
|
+
if (!isIso(start)) {
|
|
602
|
+
console.error("Invalid start time.");
|
|
603
|
+
return 1;
|
|
604
|
+
}
|
|
605
|
+
const end = (await rl.question("End time (ISO 8601): ")).trim();
|
|
606
|
+
if (!isIso(end)) {
|
|
607
|
+
console.error("Invalid end time.");
|
|
608
|
+
return 1;
|
|
609
|
+
}
|
|
610
|
+
if (new Date(end).getTime() <= new Date(start).getTime()) {
|
|
611
|
+
console.error("End time must be after start time.");
|
|
612
|
+
return 1;
|
|
613
|
+
}
|
|
614
|
+
const tz = (await rl.question(`Time zone [${DEFAULT_TZ}]: `)).trim() || DEFAULT_TZ;
|
|
615
|
+
const maxRaw = (await rl.question("Max participants [30]: ")).trim();
|
|
616
|
+
const max = maxRaw ? parseInt(maxRaw, 10) : void 0;
|
|
617
|
+
if (max !== void 0 && (!Number.isFinite(max) || max < 1)) {
|
|
618
|
+
console.error("Max participants must be a positive number.");
|
|
619
|
+
return 1;
|
|
620
|
+
}
|
|
621
|
+
const resp = await createRace({
|
|
622
|
+
name,
|
|
623
|
+
start_time: start,
|
|
624
|
+
end_time: end,
|
|
625
|
+
tz,
|
|
626
|
+
...max !== void 0 ? { max_participants: max } : {}
|
|
627
|
+
});
|
|
628
|
+
console.log("");
|
|
629
|
+
console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
630
|
+
console.log(` \u2551 JOIN CODE: ${resp.join_code.padEnd(23)}\u2551`);
|
|
631
|
+
console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
632
|
+
console.log("");
|
|
633
|
+
console.log(` Admin code: ${resp.admin_code}`);
|
|
634
|
+
console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
|
|
635
|
+
console.log("");
|
|
636
|
+
console.log(` Share with participants: token-derby join ${resp.join_code}`);
|
|
637
|
+
return 0;
|
|
638
|
+
} catch (e) {
|
|
639
|
+
if (e instanceof ApiError) {
|
|
640
|
+
console.error(`Error: ${e.code} ${e.message}`);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
throw e;
|
|
644
|
+
} finally {
|
|
645
|
+
rl.close();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function isIso(s) {
|
|
649
|
+
if (!s) return false;
|
|
650
|
+
const d = new Date(s);
|
|
651
|
+
return !Number.isNaN(d.getTime());
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/commands/join.ts
|
|
655
|
+
import React6 from "react";
|
|
656
|
+
import { render as render3 } from "ink";
|
|
657
|
+
|
|
658
|
+
// src/ui/HorsePicker.tsx
|
|
659
|
+
import { useState as useState2 } from "react";
|
|
660
|
+
import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
|
|
661
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
662
|
+
function HorsePicker({ horses, onPick, onCancel }) {
|
|
663
|
+
const [idx, setIdx] = useState2(0);
|
|
664
|
+
useInput2((input, key) => {
|
|
665
|
+
if (key.escape) {
|
|
666
|
+
onCancel();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (horses.length === 0) return;
|
|
670
|
+
if (key.upArrow) {
|
|
671
|
+
setIdx((idx - 1 + horses.length) % horses.length);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (key.downArrow) {
|
|
675
|
+
setIdx((idx + 1) % horses.length);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (key.return) {
|
|
679
|
+
onPick(horses[idx]);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
if (horses.length === 0) {
|
|
684
|
+
return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
|
|
685
|
+
/* @__PURE__ */ jsx4(Text4, { children: "No horses in your stable." }),
|
|
686
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Run `token-derby stable create` to make one." })
|
|
687
|
+
] });
|
|
688
|
+
}
|
|
689
|
+
return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
|
|
690
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Pick a horse to race:" }),
|
|
691
|
+
horses.map((h, i) => /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
|
|
692
|
+
/* @__PURE__ */ jsx4(Box4, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text4, { children: [
|
|
693
|
+
i === idx ? "\u25BA" : " ",
|
|
694
|
+
" ",
|
|
695
|
+
h.name
|
|
696
|
+
] }) }),
|
|
697
|
+
/* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
|
|
698
|
+
/* @__PURE__ */ jsx4(Text4, { children: " " }),
|
|
699
|
+
/* @__PURE__ */ jsx4(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
|
|
700
|
+
] })
|
|
701
|
+
] }, h.name)),
|
|
702
|
+
/* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
|
|
703
|
+
] });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/runtime/run-race.tsx
|
|
707
|
+
import { useEffect, useRef, useState as useState3 } from "react";
|
|
708
|
+
import { useApp } from "ink";
|
|
709
|
+
|
|
710
|
+
// src/ui/StatusScreen.tsx
|
|
711
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
712
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
713
|
+
function StatusScreen(props) {
|
|
714
|
+
const { race, ownHorseId, ownHorseName, ownColors, lastHeartbeatAgoSec, lastHeartbeatOk } = props;
|
|
715
|
+
if (!race) {
|
|
716
|
+
return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: /* @__PURE__ */ jsx5(Text5, { children: "Joining race\u2026" }) });
|
|
717
|
+
}
|
|
718
|
+
const own = race.horses.find((h) => h.horse_id === ownHorseId);
|
|
719
|
+
const leader = race.horses[0];
|
|
720
|
+
const elapsedPct = elapsed(race);
|
|
721
|
+
const timeLeft = formatDuration(race.time_left_seconds);
|
|
722
|
+
return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
|
|
723
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
724
|
+
"\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
|
|
725
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: race.name }),
|
|
726
|
+
" \u2500\u2500\u2500 status: ",
|
|
727
|
+
/* @__PURE__ */ jsx5(Text5, { color: statusColor(race.status), children: race.status })
|
|
728
|
+
] }),
|
|
729
|
+
/* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "row", children: [
|
|
730
|
+
/* @__PURE__ */ jsx5(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
|
|
731
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
732
|
+
" ",
|
|
733
|
+
ownHorseName
|
|
734
|
+
] })
|
|
735
|
+
] }),
|
|
736
|
+
/* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginTop: 1, children: [
|
|
737
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
738
|
+
"Tokens (race): ",
|
|
739
|
+
own?.current_tokens ?? 0
|
|
740
|
+
] }),
|
|
741
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
742
|
+
"Position: ",
|
|
743
|
+
own?.rank ?? "\u2014",
|
|
744
|
+
" of ",
|
|
745
|
+
race.horses.length
|
|
746
|
+
] }),
|
|
747
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
748
|
+
"Leader: ",
|
|
749
|
+
leader ? `${leader.name} (${leader.current_tokens})` : "\u2014"
|
|
750
|
+
] }),
|
|
751
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
752
|
+
"Race elapsed: ",
|
|
753
|
+
(elapsedPct * 100).toFixed(0),
|
|
754
|
+
"% ",
|
|
755
|
+
bar(elapsedPct, 20)
|
|
756
|
+
] }),
|
|
757
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
758
|
+
"Time left: ",
|
|
759
|
+
timeLeft
|
|
760
|
+
] }),
|
|
761
|
+
/* @__PURE__ */ jsxs4(Text5, { children: [
|
|
762
|
+
"Last heartbeat: ",
|
|
763
|
+
lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
|
|
764
|
+
" ",
|
|
765
|
+
/* @__PURE__ */ jsx5(Text5, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
|
|
766
|
+
] })
|
|
767
|
+
] }),
|
|
768
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
|
|
769
|
+
] });
|
|
770
|
+
}
|
|
771
|
+
function elapsed(race) {
|
|
772
|
+
const start = new Date(race.start_time).getTime();
|
|
773
|
+
const end = new Date(race.end_time).getTime();
|
|
774
|
+
const now = new Date(race.server_time).getTime();
|
|
775
|
+
if (end <= start) return 0;
|
|
776
|
+
const v = (now - start) / (end - start);
|
|
777
|
+
return Math.max(0, Math.min(1, v));
|
|
778
|
+
}
|
|
779
|
+
function bar(pct, width) {
|
|
780
|
+
const filled = Math.round(pct * width);
|
|
781
|
+
return "\u2593".repeat(filled) + "\u2591".repeat(width - filled);
|
|
782
|
+
}
|
|
783
|
+
function statusColor(status) {
|
|
784
|
+
if (status === "live") return "green";
|
|
785
|
+
if (status === "pending") return "yellow";
|
|
786
|
+
return "gray";
|
|
787
|
+
}
|
|
788
|
+
function formatDuration(seconds) {
|
|
789
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
790
|
+
const h = Math.floor(s / 3600);
|
|
791
|
+
const m = Math.floor(s % 3600 / 60);
|
|
792
|
+
const ss = s % 60;
|
|
793
|
+
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${ss.toString().padStart(2, "0")}`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/runtime/heartbeat-loop.ts
|
|
797
|
+
function runHeartbeatLoop(opts) {
|
|
798
|
+
let timer = null;
|
|
799
|
+
let retryIndex = 0;
|
|
800
|
+
let stopped = false;
|
|
801
|
+
const stop = () => {
|
|
802
|
+
stopped = true;
|
|
803
|
+
if (timer) clearTimeout(timer);
|
|
804
|
+
timer = null;
|
|
805
|
+
};
|
|
806
|
+
opts.abortSignal.addEventListener("abort", stop, { once: true });
|
|
807
|
+
const schedule = (delay) => {
|
|
808
|
+
if (stopped) return;
|
|
809
|
+
timer = setTimeout(tick, delay);
|
|
810
|
+
};
|
|
811
|
+
const tick = async () => {
|
|
812
|
+
if (stopped) return;
|
|
813
|
+
try {
|
|
814
|
+
const tokens = opts.getCurrentTokens();
|
|
815
|
+
const resp = await opts.sendHeartbeat(tokens);
|
|
816
|
+
retryIndex = 0;
|
|
817
|
+
opts.onSuccess(resp);
|
|
818
|
+
if (resp.race_status === "finished") {
|
|
819
|
+
opts.onFinished();
|
|
820
|
+
stop();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
schedule(opts.intervalMs);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
opts.onError(err);
|
|
826
|
+
const delay = opts.retryDelaysMs[Math.min(retryIndex, opts.retryDelaysMs.length - 1)] ?? 1e3;
|
|
827
|
+
retryIndex += 1;
|
|
828
|
+
schedule(delay);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
schedule(0);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/runtime/poll-loop.ts
|
|
835
|
+
function runPollLoop(opts) {
|
|
836
|
+
let timer = null;
|
|
837
|
+
let stopped = false;
|
|
838
|
+
const stop = () => {
|
|
839
|
+
stopped = true;
|
|
840
|
+
if (timer) clearTimeout(timer);
|
|
841
|
+
timer = null;
|
|
842
|
+
};
|
|
843
|
+
opts.abortSignal.addEventListener("abort", stop, { once: true });
|
|
844
|
+
const tick = async () => {
|
|
845
|
+
if (stopped) return;
|
|
846
|
+
try {
|
|
847
|
+
const race = await opts.fetchRace();
|
|
848
|
+
if (!stopped) opts.onSnapshot(race);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (!stopped) opts.onError(err);
|
|
851
|
+
}
|
|
852
|
+
if (!stopped) timer = setTimeout(tick, opts.intervalMs);
|
|
853
|
+
};
|
|
854
|
+
timer = setTimeout(tick, 0);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/tokens/transcripts.ts
|
|
858
|
+
import * as fs3 from "fs/promises";
|
|
859
|
+
import * as path3 from "path";
|
|
860
|
+
async function sumOutputTokens() {
|
|
861
|
+
const root = claudeProjectsDir();
|
|
862
|
+
const files = await listJsonlFiles(root);
|
|
863
|
+
let total = 0;
|
|
864
|
+
for (const file of files) {
|
|
865
|
+
total += await sumFile(file);
|
|
866
|
+
}
|
|
867
|
+
return total;
|
|
868
|
+
}
|
|
869
|
+
async function listJsonlFiles(root) {
|
|
870
|
+
let projects;
|
|
871
|
+
try {
|
|
872
|
+
projects = await fs3.readdir(root);
|
|
873
|
+
} catch (e) {
|
|
874
|
+
if (e?.code === "ENOENT") return [];
|
|
875
|
+
throw e;
|
|
876
|
+
}
|
|
877
|
+
const out = [];
|
|
878
|
+
for (const project of projects) {
|
|
879
|
+
const projectDir = path3.join(root, project);
|
|
880
|
+
let stat2;
|
|
881
|
+
try {
|
|
882
|
+
stat2 = await fs3.stat(projectDir);
|
|
883
|
+
} catch {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (!stat2.isDirectory()) continue;
|
|
887
|
+
const entries = await fs3.readdir(projectDir);
|
|
888
|
+
for (const entry of entries) {
|
|
889
|
+
if (entry.endsWith(".jsonl")) out.push(path3.join(projectDir, entry));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
async function sumFile(file) {
|
|
895
|
+
let raw;
|
|
896
|
+
try {
|
|
897
|
+
raw = await fs3.readFile(file, "utf8");
|
|
898
|
+
} catch {
|
|
899
|
+
return 0;
|
|
900
|
+
}
|
|
901
|
+
let sum = 0;
|
|
902
|
+
for (const line of raw.split("\n")) {
|
|
903
|
+
if (!line.trim()) continue;
|
|
904
|
+
let parsed;
|
|
905
|
+
try {
|
|
906
|
+
parsed = JSON.parse(line);
|
|
907
|
+
} catch {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const tokens = parsed?.message?.usage?.output_tokens;
|
|
911
|
+
if (typeof tokens === "number" && Number.isFinite(tokens)) sum += tokens;
|
|
912
|
+
}
|
|
913
|
+
return sum;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/tokens/baseline.ts
|
|
917
|
+
function initialBaseline(args) {
|
|
918
|
+
return args.runningTotal;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/runtime/run-race.tsx
|
|
922
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
923
|
+
function RunRace({ active, startingBaseline, pendingMode }) {
|
|
924
|
+
const { exit } = useApp();
|
|
925
|
+
const [race, setRace] = useState3(null);
|
|
926
|
+
const [lastHbAt, setLastHbAt] = useState3(null);
|
|
927
|
+
const [lastHbOk, setLastHbOk] = useState3(true);
|
|
928
|
+
const [tickNow, setTickNow] = useState3(/* @__PURE__ */ new Date());
|
|
929
|
+
const baselineRef = useRef(startingBaseline);
|
|
930
|
+
const pendingRef = useRef(pendingMode);
|
|
931
|
+
const lastTokenSampleRef = useRef(startingBaseline);
|
|
932
|
+
const ctrl = useRef(new AbortController());
|
|
933
|
+
useEffect(() => {
|
|
934
|
+
const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
|
|
935
|
+
return () => clearInterval(t);
|
|
936
|
+
}, []);
|
|
937
|
+
useEffect(() => {
|
|
938
|
+
if (pendingRef.current && race?.status === "live") {
|
|
939
|
+
sumOutputTokens().then((total) => {
|
|
940
|
+
baselineRef.current = total;
|
|
941
|
+
pendingRef.current = false;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}, [race?.status]);
|
|
945
|
+
useEffect(() => {
|
|
946
|
+
runPollLoop({
|
|
947
|
+
fetchRace: () => getRace(active.join_code),
|
|
948
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
949
|
+
onSnapshot: (r) => setRace(r),
|
|
950
|
+
onError: () => {
|
|
951
|
+
},
|
|
952
|
+
abortSignal: ctrl.current.signal
|
|
953
|
+
});
|
|
954
|
+
runHeartbeatLoop({
|
|
955
|
+
sendHeartbeat: async (currentTokens) => {
|
|
956
|
+
const resp = await heartbeat(
|
|
957
|
+
active.join_code,
|
|
958
|
+
active.horse_id,
|
|
959
|
+
active.heartbeat_token,
|
|
960
|
+
{ current_tokens: currentTokens }
|
|
961
|
+
);
|
|
962
|
+
const updated = {
|
|
963
|
+
...active,
|
|
964
|
+
last_race_tokens: currentTokens,
|
|
965
|
+
last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
966
|
+
};
|
|
967
|
+
await saveActiveRace(updated);
|
|
968
|
+
return resp;
|
|
969
|
+
},
|
|
970
|
+
getCurrentTokens: () => {
|
|
971
|
+
if (pendingRef.current) return 0;
|
|
972
|
+
return Math.max(0, lastTokenSampleRef.current - baselineRef.current);
|
|
973
|
+
},
|
|
974
|
+
intervalMs: HEARTBEAT_INTERVAL_MS,
|
|
975
|
+
retryDelaysMs: HEARTBEAT_RETRY_DELAYS_MS,
|
|
976
|
+
onSuccess: (resp) => {
|
|
977
|
+
setLastHbAt(/* @__PURE__ */ new Date());
|
|
978
|
+
setLastHbOk(true);
|
|
979
|
+
if (resp.race_status === "finished") exit();
|
|
980
|
+
},
|
|
981
|
+
onError: () => setLastHbOk(false),
|
|
982
|
+
onFinished: () => exit(),
|
|
983
|
+
abortSignal: ctrl.current.signal
|
|
984
|
+
});
|
|
985
|
+
const sampler = setInterval(async () => {
|
|
986
|
+
try {
|
|
987
|
+
lastTokenSampleRef.current = await sumOutputTokens();
|
|
988
|
+
} catch {
|
|
989
|
+
}
|
|
990
|
+
}, 5e3);
|
|
991
|
+
sumOutputTokens().then((t) => {
|
|
992
|
+
lastTokenSampleRef.current = t;
|
|
993
|
+
}).catch(() => {
|
|
994
|
+
});
|
|
995
|
+
const controller = ctrl.current;
|
|
996
|
+
return () => {
|
|
997
|
+
clearInterval(sampler);
|
|
998
|
+
controller.abort();
|
|
999
|
+
};
|
|
1000
|
+
}, []);
|
|
1001
|
+
const lastHeartbeatAgoSec = lastHbAt ? Math.max(0, Math.floor((tickNow.getTime() - lastHbAt.getTime()) / 1e3)) : null;
|
|
1002
|
+
return /* @__PURE__ */ jsx6(
|
|
1003
|
+
StatusScreen,
|
|
1004
|
+
{
|
|
1005
|
+
race,
|
|
1006
|
+
ownHorseId: active.horse_id,
|
|
1007
|
+
ownHorseName: active.horse_name,
|
|
1008
|
+
ownColors: active.horse_colors,
|
|
1009
|
+
lastHeartbeatAgoSec,
|
|
1010
|
+
lastHeartbeatOk: lastHbOk
|
|
1011
|
+
}
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
async function buildInitialState(args) {
|
|
1015
|
+
const runningTotal = await sumOutputTokens();
|
|
1016
|
+
if (args.rejoin) {
|
|
1017
|
+
return {
|
|
1018
|
+
startingBaseline: Math.max(0, runningTotal - args.active.last_race_tokens),
|
|
1019
|
+
pendingMode: args.raceStatus === "pending"
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
startingBaseline: initialBaseline({ runningTotal, status: args.raceStatus }),
|
|
1024
|
+
pendingMode: args.raceStatus === "pending"
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/commands/join.ts
|
|
1029
|
+
async function joinCommand(joinCode) {
|
|
1030
|
+
if (!joinCode) {
|
|
1031
|
+
console.error("Usage: token-derby join <join-code>");
|
|
1032
|
+
return 2;
|
|
1033
|
+
}
|
|
1034
|
+
const code = joinCode.toUpperCase();
|
|
1035
|
+
const stable = await loadStable();
|
|
1036
|
+
if (stable.horses.length === 0) {
|
|
1037
|
+
console.error("Your stable is empty. Run `token-derby stable create` first.");
|
|
1038
|
+
return 1;
|
|
1039
|
+
}
|
|
1040
|
+
const picked = await pickHorse(stable.horses);
|
|
1041
|
+
if (!picked) {
|
|
1042
|
+
console.log("Cancelled.");
|
|
1043
|
+
return 1;
|
|
1044
|
+
}
|
|
1045
|
+
let joinResp;
|
|
1046
|
+
try {
|
|
1047
|
+
joinResp = await joinRace(code, { horse: { name: picked.name, colors: picked.colors } });
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
if (e instanceof ApiError) {
|
|
1050
|
+
if (e.code === "RACE_FULL") console.error(`This race is full.`);
|
|
1051
|
+
else if (e.code === "RACE_FINISHED") console.error("This race has ended.");
|
|
1052
|
+
else if (e.code === "RACE_NOT_FOUND") console.error(`No race with join code ${code}.`);
|
|
1053
|
+
else console.error(`Error: ${e.code} ${e.message}`);
|
|
1054
|
+
return 1;
|
|
1055
|
+
}
|
|
1056
|
+
throw e;
|
|
1057
|
+
}
|
|
1058
|
+
const race = await getRace(code);
|
|
1059
|
+
if (race.status === "finished") {
|
|
1060
|
+
console.error("Race finished after join. Exiting.");
|
|
1061
|
+
return 1;
|
|
1062
|
+
}
|
|
1063
|
+
const status = race.status;
|
|
1064
|
+
const active = {
|
|
1065
|
+
join_code: code,
|
|
1066
|
+
race_id: race.race_id,
|
|
1067
|
+
horse_id: joinResp.horse_id,
|
|
1068
|
+
heartbeat_token: joinResp.heartbeat_token,
|
|
1069
|
+
horse_name: picked.name,
|
|
1070
|
+
horse_colors: picked.colors,
|
|
1071
|
+
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1072
|
+
last_race_tokens: 0,
|
|
1073
|
+
last_heartbeat_at: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
1074
|
+
};
|
|
1075
|
+
await saveActiveRace(active);
|
|
1076
|
+
const initial = await buildInitialState({ active, raceStatus: status, rejoin: false });
|
|
1077
|
+
const app = render3(React6.createElement(RunRace, { active, ...initial }));
|
|
1078
|
+
await app.waitUntilExit();
|
|
1079
|
+
return 0;
|
|
1080
|
+
}
|
|
1081
|
+
async function pickHorse(horses) {
|
|
1082
|
+
return new Promise((resolve) => {
|
|
1083
|
+
const app = render3(
|
|
1084
|
+
React6.createElement(HorsePicker, {
|
|
1085
|
+
horses,
|
|
1086
|
+
onPick: (h) => {
|
|
1087
|
+
app.unmount();
|
|
1088
|
+
resolve(h);
|
|
1089
|
+
},
|
|
1090
|
+
onCancel: () => {
|
|
1091
|
+
app.unmount();
|
|
1092
|
+
resolve(null);
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/commands/rejoin.ts
|
|
1100
|
+
import React7 from "react";
|
|
1101
|
+
import { render as render4 } from "ink";
|
|
1102
|
+
async function rejoinCommand(joinCode) {
|
|
1103
|
+
if (!joinCode) {
|
|
1104
|
+
console.error("Usage: token-derby rejoin <join-code>");
|
|
1105
|
+
return 2;
|
|
1106
|
+
}
|
|
1107
|
+
const code = joinCode.toUpperCase();
|
|
1108
|
+
const active = await loadActiveRace(code);
|
|
1109
|
+
if (!active) {
|
|
1110
|
+
console.error(`No saved active-race state for ${code}. Use \`token-derby join ${code}\` to enter as a new horse.`);
|
|
1111
|
+
return 1;
|
|
1112
|
+
}
|
|
1113
|
+
let race;
|
|
1114
|
+
try {
|
|
1115
|
+
race = await getRace(code);
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
if (e instanceof ApiError) {
|
|
1118
|
+
console.error(`Error: ${e.code} ${e.message}`);
|
|
1119
|
+
return 1;
|
|
1120
|
+
}
|
|
1121
|
+
throw e;
|
|
1122
|
+
}
|
|
1123
|
+
if (race.status === "finished") {
|
|
1124
|
+
console.error("Race already finished.");
|
|
1125
|
+
return 1;
|
|
1126
|
+
}
|
|
1127
|
+
const status = race.status;
|
|
1128
|
+
const initial = await buildInitialState({ active, raceStatus: status, rejoin: true });
|
|
1129
|
+
const app = render4(React7.createElement(RunRace, { active, ...initial }));
|
|
1130
|
+
await app.waitUntilExit();
|
|
1131
|
+
return 0;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/commands/end.ts
|
|
1135
|
+
import * as readline4 from "readline/promises";
|
|
1136
|
+
import { stdin as stdin4, stdout as stdout4 } from "process";
|
|
1137
|
+
async function endCommand(adminCode) {
|
|
1138
|
+
if (!adminCode) {
|
|
1139
|
+
console.error("Usage: token-derby end <admin-code>");
|
|
1140
|
+
return 2;
|
|
1141
|
+
}
|
|
1142
|
+
const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
|
|
1143
|
+
const answer = (await rl.question("End the race now and freeze final tokens? [y/N] ")).trim().toLowerCase();
|
|
1144
|
+
rl.close();
|
|
1145
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1146
|
+
console.log("Cancelled.");
|
|
1147
|
+
return 1;
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
await endRace(adminCode);
|
|
1151
|
+
console.log("\u2713 Race ended.");
|
|
1152
|
+
return 0;
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
if (e instanceof ApiError) {
|
|
1155
|
+
if (e.code === "RACE_NOT_FOUND") console.error("No race with that admin code.");
|
|
1156
|
+
else console.error(`Error: ${e.code} ${e.message}`);
|
|
1157
|
+
return 1;
|
|
1158
|
+
}
|
|
1159
|
+
throw e;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/bin.ts
|
|
1164
|
+
var VERSION = "0.1.0";
|
|
1165
|
+
var HELP = `token-derby v${VERSION}
|
|
1166
|
+
|
|
1167
|
+
Stable management:
|
|
1168
|
+
token-derby stable create Make a new horse (interactive)
|
|
1169
|
+
token-derby stable list Show your saved horses
|
|
1170
|
+
token-derby stable delete <name> Remove a horse from your stable
|
|
1171
|
+
|
|
1172
|
+
Races:
|
|
1173
|
+
token-derby create Create a new race (interactive)
|
|
1174
|
+
token-derby join <join-code> Pick a horse and join a race
|
|
1175
|
+
token-derby rejoin <join-code> Resume a race after a disconnect
|
|
1176
|
+
token-derby end <admin-code> End a race early
|
|
1177
|
+
|
|
1178
|
+
Environment:
|
|
1179
|
+
TOKEN_DERBY_API_BASE Override API base URL (default: production)
|
|
1180
|
+
`;
|
|
1181
|
+
async function main() {
|
|
1182
|
+
const argv = process.argv.slice(2);
|
|
1183
|
+
const cmd = argv[0];
|
|
1184
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
1185
|
+
console.log(HELP);
|
|
1186
|
+
return 0;
|
|
1187
|
+
}
|
|
1188
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
1189
|
+
console.log(VERSION);
|
|
1190
|
+
return 0;
|
|
1191
|
+
}
|
|
1192
|
+
if (cmd === "stable") {
|
|
1193
|
+
const sub = argv[1];
|
|
1194
|
+
if (sub === "create") return stableCreateCommand();
|
|
1195
|
+
if (sub === "list") return stableListCommand();
|
|
1196
|
+
if (sub === "delete") return stableDeleteCommand(argv[2]);
|
|
1197
|
+
console.error(`Unknown stable subcommand: ${sub ?? "(none)"}`);
|
|
1198
|
+
console.error("Try: stable create | stable list | stable delete <name>");
|
|
1199
|
+
return 2;
|
|
1200
|
+
}
|
|
1201
|
+
if (cmd === "create") return createRaceCommand();
|
|
1202
|
+
if (cmd === "join") return joinCommand(argv[1]);
|
|
1203
|
+
if (cmd === "rejoin") return rejoinCommand(argv[1]);
|
|
1204
|
+
if (cmd === "end") return endCommand(argv[1]);
|
|
1205
|
+
console.error(`Unknown command: ${cmd}`);
|
|
1206
|
+
console.error(HELP);
|
|
1207
|
+
return 2;
|
|
1208
|
+
}
|
|
1209
|
+
main().then(
|
|
1210
|
+
(code) => process.exit(code),
|
|
1211
|
+
(err) => {
|
|
1212
|
+
console.error(err?.stack ?? err);
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
);
|
|
1216
|
+
//# sourceMappingURL=bin.js.map
|