@mauricode/token-derby 2.3.0 → 2.5.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/dist/bin.js CHANGED
@@ -113,27 +113,64 @@ function tagColor(tag, colors) {
113
113
  return null;
114
114
  }
115
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;
116
+ // src/hats/render.ts
117
+ function composeHatGrid(baseSprite, hat, variantIdx, horseColors) {
118
+ const horseH = baseSprite.length;
119
+ const horseW = baseSprite[0]?.length ?? 0;
120
+ const hatH = hat.rows.length;
121
+ const hatW = hat.width;
122
+ const ext = Math.max(0, hatH - 4);
123
+ const canvasLeft = Math.min(0, hat.anchor_x);
124
+ const canvasRight = Math.max(horseW, hat.anchor_x + hatW);
125
+ const canvasW = canvasRight - canvasLeft;
126
+ const horseOffsetX = -canvasLeft;
127
+ const grid = Array.from(
128
+ { length: horseH + ext },
129
+ () => Array(canvasW).fill(null)
130
+ );
131
+ for (let y = 0; y < horseH; y++) {
132
+ for (let x = 0; x < horseW; x++) {
133
+ const tag = baseSprite[y][x];
134
+ if (tag === null) continue;
135
+ grid[y + ext][x + horseOffsetX] = tagToColor(tag, horseColors);
133
136
  }
134
137
  }
135
- return out;
138
+ const hatColors = hatColorsFor(hat, variantIdx);
139
+ for (let y = 0; y < hatH; y++) {
140
+ const row = hat.rows[y];
141
+ for (let x = 0; x < hatW; x++) {
142
+ const ch = row[x];
143
+ if (ch === "." || ch === void 0) continue;
144
+ const gx = hat.anchor_x + x + horseOffsetX;
145
+ if (gx < 0 || gx >= canvasW) continue;
146
+ const color = ch === "A" ? hatColors.A : hatColors.Q ?? hatColors.A;
147
+ grid[y][gx] = color;
148
+ }
149
+ }
150
+ return { grid, offsetX: canvasLeft };
151
+ }
152
+ function tagToColor(tag, c) {
153
+ switch (tag) {
154
+ case "B":
155
+ return c.body;
156
+ case "M":
157
+ return c.mane;
158
+ case "T":
159
+ return c.tail;
160
+ case "S":
161
+ return c.saddle;
162
+ case "E":
163
+ return FIXED_COLORS.E;
164
+ case "H":
165
+ return FIXED_COLORS.H;
166
+ }
167
+ }
168
+ function hatColorsFor(hat, variantIdx) {
169
+ if (hat.rarity === "legendary") return hat.colors;
170
+ return hat.variants[variantIdx] ?? hat.variants[0];
136
171
  }
172
+
173
+ // src/ui/half-blocks.ts
137
174
  var RESET = "\x1B[0m";
138
175
  function hexToRgb(hex) {
139
176
  const h = hex.replace("#", "");
@@ -151,6 +188,52 @@ function ansiBg(hex) {
151
188
  const [r, g, b] = hexToRgb(hex);
152
189
  return `\x1B[48;2;${r};${g};${b}m`;
153
190
  }
191
+ function hexGridToHalfBlocks(grid) {
192
+ const W = grid[0]?.length ?? 0;
193
+ const padded = grid.length % 2 === 0 ? grid : [...grid, Array(W).fill(null)];
194
+ const lines = [];
195
+ for (let y = 0; y < padded.length; y += 2) {
196
+ let line = "";
197
+ for (let x = 0; x < W; x++) {
198
+ const top = padded[y][x] ?? null;
199
+ const bot = padded[y + 1][x] ?? null;
200
+ if (top === null && bot === null) line += " ";
201
+ else if (top !== null && bot !== null) line += ansiFg(top) + ansiBg(bot) + "\u2580" + RESET;
202
+ else if (top !== null) line += ansiFg(top) + "\u2580" + RESET;
203
+ else line += ansiFg(bot) + "\u2584" + RESET;
204
+ }
205
+ lines.push(line);
206
+ }
207
+ return lines;
208
+ }
209
+
210
+ // src/ui/HorseSprite.tsx
211
+ import { jsx } from "react/jsx-runtime";
212
+ function HorseSprite({ sprite, colors, hat }) {
213
+ if (!hat) {
214
+ const grid2 = renderSprite(sprite, colors);
215
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: grid2.map((row, y) => /* @__PURE__ */ jsx(Text, { children: rowToAnsi(row) }, y)) });
216
+ }
217
+ const { grid } = composeHatGrid(sprite, hat.hat, hat.variant ?? 0, colors);
218
+ const lines = hexGridToHalfBlocks(grid);
219
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx(Text, { children: line }, i)) });
220
+ }
221
+ var RESET2 = "\x1B[0m";
222
+ function rowToAnsi(row) {
223
+ let out = "";
224
+ for (const cell of row) {
225
+ if (cell.top === null && cell.bottom === null) {
226
+ out += " ";
227
+ } else if (cell.top !== null && cell.bottom !== null) {
228
+ out += ansiFg(cell.top) + ansiBg(cell.bottom) + "\u2580" + RESET2;
229
+ } else if (cell.top !== null) {
230
+ out += ansiFg(cell.top) + "\u2580" + RESET2;
231
+ } else {
232
+ out += ansiFg(cell.bottom) + "\u2584" + RESET2;
233
+ }
234
+ }
235
+ return out;
236
+ }
154
237
 
155
238
  // src/ui/palette.ts
156
239
  var SLOTS = ["body", "mane", "tail", "saddle"];
@@ -337,8 +420,8 @@ var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
337
420
  // src/version.ts
338
421
  import { createRequire } from "module";
339
422
  function readVersion() {
340
- if ("2.3.0".length > 0) {
341
- return "2.3.0";
423
+ if ("2.5.0".length > 0) {
424
+ return "2.5.0";
342
425
  }
343
426
  try {
344
427
  const req = createRequire(import.meta.url);
@@ -358,6 +441,32 @@ var USER_NAME_MAX_LENGTH = 40;
358
441
  var ORG_NAME_MAX_LENGTH = 12;
359
442
  var ORG_NAME_PATTERN = /^[A-Za-z0-9]{1,12}$/;
360
443
 
444
+ // ../shared/dist/version-match.js
445
+ var SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
446
+ function parseSemver(v) {
447
+ if (typeof v !== "string")
448
+ return null;
449
+ const m = SEMVER_RE.exec(v.trim());
450
+ if (!m)
451
+ return null;
452
+ return {
453
+ major: Number(m[1]),
454
+ minor: Number(m[2]),
455
+ patch: Number(m[3])
456
+ };
457
+ }
458
+ function gteSemver(a, b) {
459
+ const pa = parseSemver(a);
460
+ const pb = parseSemver(b);
461
+ if (!pa || !pb)
462
+ return false;
463
+ if (pa.major !== pb.major)
464
+ return pa.major > pb.major;
465
+ if (pa.minor !== pb.minor)
466
+ return pa.minor > pb.minor;
467
+ return pa.patch >= pb.patch;
468
+ }
469
+
361
470
  // ../shared/dist/levels.js
362
471
  var MAX_LEVEL = 30;
363
472
  function xpForLevel(n) {
@@ -404,6 +513,91 @@ function overtakeDescription(positionsClimbed) {
404
513
  return "Overtook another horse";
405
514
  return `Overtook ${positionsClimbed} horses`;
406
515
  }
516
+ var TOKEN_INPUT_MULTIPLIER = 10;
517
+ function tokenMultiplier(race) {
518
+ return race.counts_input ? TOKEN_INPUT_MULTIPLIER : 1;
519
+ }
520
+ function describeAchievement(event, race) {
521
+ if (event.name === "Overtake!") {
522
+ return overtakeDescription(Math.floor(event.xp / 3));
523
+ }
524
+ const m = tokenMultiplier(race);
525
+ if (event.name === "Stampede!") {
526
+ return `Gained ${(MIDRACE_THRESHOLDS.stampede_tokens * m).toLocaleString("en-US")}+ tokens in a single minute`;
527
+ }
528
+ if (event.name === "Pulled Away!") {
529
+ return `Grew the lead by ${(MIDRACE_THRESHOLDS.pulled_away_gap * m).toLocaleString("en-US")}+ tokens in a minute`;
530
+ }
531
+ return ACHIEVEMENT_DESCRIPTIONS[event.name];
532
+ }
533
+ var MIDRACE_THRESHOLDS = {
534
+ warm_up_fraction: 0.08,
535
+ // first 8% of race time
536
+ streak_hour_ms: 36e5,
537
+ // 1 hour for Racer!/Pacesetter!
538
+ racer_dt_cap_ms: 9e4,
539
+ // single-tick credit cap for Racer!
540
+ stampede_tokens: 7e3,
541
+ // tokens-in-a-minute threshold
542
+ stampede_cooldown_ms: 72e5,
543
+ // 2 hours
544
+ pulled_away_gap: 5e3,
545
+ // gap-growth threshold per minute
546
+ pulled_away_cooldown_ms: 72e5,
547
+ // 2 hours
548
+ recent_events_retention_ms: 9e4
549
+ // sliding window for recent_events
550
+ };
551
+
552
+ // ../shared/dist/hats.js
553
+ function hatById(id) {
554
+ return HATS.find((h) => h.id === id);
555
+ }
556
+ var HATS = [
557
+ // ── COMMON (18) ────────────────────────────────────────────────────────
558
+ { id: "flat_cap", name: "Flat Cap", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...........", "....AAAA...", "...AAAAAA..", "..AAAAAAA.."], variants: [{ A: "#8B6914" }, { A: "#12301b" }, { A: "#aab5cb" }, { A: "#bdcbaa" }] },
559
+ { id: "bucket_hat", name: "Bucket Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...QQQQQ...", "...AAAAA...", "...AAAAA...", ".AAAAAAAAA."], variants: [{ A: "#CC2200", Q: "#FFFFFF" }, { A: "#0007cc", Q: "#FFFFFF" }, { A: "#becc00", Q: "#4d3dc7" }, { A: "#cc00c5", Q: "#d6d6dc" }] },
560
+ { id: "beanie", name: "Beanie", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "....AAA....", "...AAAAA...", "...AAAAA...", "..AAAAAAA.."], variants: [{ A: "#4A7C59" }, { A: "#b21f35" }, { A: "#ec9418" }, { A: "#3a17e6" }] },
561
+ { id: "stetson", name: "Stetson", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...AA.AA...", "...AAAAA...", "...AAAAA...", "A..AAAAA..A", "AAAAAAAAAAA", "AAAAAAAAAAA"], variants: [{ A: "#C49A00" }, { A: "#272004" }, { A: "#040404" }, { A: "#373606" }] },
562
+ { id: "party_hat", name: "Party Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", ".....A.....", "....AAA....", "....AQA....", "...AQAQA...", "...AAAAA..."], variants: [{ A: "#FF69B4", Q: "#FFD700" }, { A: "#c46bff", Q: "#ff000d" }, { A: "#fdff6b", Q: "#0400ff" }, { A: "#6bff97", Q: "#05050a" }] },
563
+ { id: "fez", name: "Fez", rarity: "common", width: 11, anchor_x: 22, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "....Q......", "....AAA....", "....AAA....", "....AAA...."], variants: [{ A: "#CC0000", Q: "#8B0000" }, { A: "#CC0000", Q: "#f5e5e5" }, { A: "#CC0000", Q: "#f1e209" }, { A: "#CC0000", Q: "#0956f1" }] },
564
+ { id: "sailor_hat", name: "Sailor Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...QQQQQ...", "..AAAAAAA..", "..QQQQQQQ..", "..AAAAAAA.."], variants: [{ A: "#FFFFFF", Q: "#000080" }, { A: "#FFFFFF", Q: "#c0c0ed" }, { A: "#FFFFFF", Q: "#00000a" }, { A: "#FFFFFF", Q: "#0505ef" }] },
565
+ { id: "newsboy_cap", name: "Newsboy Cap", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...AAAAA...", "..AAAQAAA..", "..AAAAAAA..", "..AAAAAA..."], variants: [{ A: "#5C4033", Q: "#3D2B1F" }, { A: "#e96020", Q: "#131211" }, { A: "#342c28", Q: "#131211" }, { A: "#0a0300", Q: "#ddab78" }] },
566
+ { id: "tam_o_shanter", name: "Tam O'Shanter", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", ".....Q.....", "....AAA....", "..AAAAAAA..", "..AAAAAAA..", "...AAAAA..."], variants: [{ A: "#006400", Q: "#FF0000" }, { A: "#ff0000", Q: "#016400" }] },
567
+ { id: "trucker_cap", name: "Trucker Cap", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...AAQQ....", "...AAQQ....", "..AAQQQAA..", "..AAQQQAA.."], variants: [{ A: "#2196F3", Q: "#FFFFFF" }, { A: "#f41fe3", Q: "#FFFFFF" }, { A: "#1ff45f", Q: "#FFFFFF" }] },
568
+ { id: "hard_hat", name: "Hard Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "....AAA....", "...AAAAA...", "..AAAAAAA..", "..AAAAAAAAA"], variants: [{ A: "#FFD600" }, { A: "#d74c1d" }] },
569
+ { id: "chef_toque", name: "Chef's Toque", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...AAAAA...", "..AAQAQAA..", "...AQAQA...", "...AQAQA...", "...AQAQA...", "...AAAAA..."], variants: [{ A: "#FFFFFF", Q: "#f9f9f9" }] },
570
+ { id: "bobble_hat", name: "Bobble Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", ".....Q.....", "...AAAAA...", "...AAAAA...", "..AAAAAAA.."], variants: [{ A: "#C62828", Q: "#FFFFFF" }, { A: "#293bc7", Q: "#FFFFFF" }, { A: "#29c775", Q: "#231f1f" }, { A: "#a529c7", Q: "#d50707" }] },
571
+ { id: "cowboy_hat", name: "Cowboy Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...AAAAA...", "...AAAAA...", "A.AAAAAAA.A", ".AAAAAAAAA."], variants: [{ A: "#8B4513" }, { A: "#0a0400" }, { A: "#956c50" }] },
572
+ { id: "baseball_cap", name: "Baseball Cap", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "...AAAAA...", "..AAAAAA...", "..AAAAAAA..", "..AAAAAAAAA"], variants: [{ A: "#1565C0" }, { A: "#8515c1" }, { A: "#15c157" }, { A: "#ef2d0b" }] },
573
+ { id: "tinfoil_hat", name: "Tinfoil Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", ".....A.....", "....AAA....", "...AAAAA...", "..AAAAAAA.."], variants: [{ A: "#B0BEC5" }, { A: "#6a6c6c" }] },
574
+ { id: "dunce_cap", name: "Dunce Cap", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", ".....Q.....", "....QAQ....", "...QAAAQ...", "...QAAAQ...", "..QAAAAAQ..", "..AAAAAAA.."], variants: [{ A: "#F8F8F8", Q: "#F44336" }] },
575
+ { id: "mini_top_hat", name: "Mini Top Hat", rarity: "common", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...AAAAA...", "...AAAAA...", "...AAAAA...", "..AAAAAAA..", "..AAAAAAA.."], variants: [{ A: "#212121" }, { A: "#626060" }] },
576
+ // ── RARE (10) ──────────────────────────────────────────────────────────
577
+ { id: "bicorne", name: "Bicorne", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "..AAAQQQQ..", "..AAAAAAA..", "..AAAAAAA..", "...QQQQQ..."], variants: [{ A: "#1A237E", Q: "#FFD700" }, { A: "#01052d", Q: "#544803" }] },
578
+ { id: "viking_helmet", name: "Viking Helmet", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "..A.AAA.A..", "..AAAAAAA..", "..AAAAAAA..", "..AQQQQQA.."], variants: [{ A: "#9E9E9E", Q: "#8D6E63" }, { A: "#dcdbdb", Q: "#bf3908" }] },
579
+ { id: "jesters_cap", name: "Jester's Cap", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "..AQAQAQA..", "..AQAQAQA..", "...AAAAA...", "...AAAAA..."], variants: [{ A: "#E53935", Q: "#FFD600" }, { A: "#e5a434", Q: "#0040ff" }, { A: "#34e537", Q: "#eeff00" }, { A: "#e234e5", Q: "#00ff40" }] },
580
+ { id: "plague_doctor", name: "Plague Doctor Beak", rarity: "rare", width: 11, anchor_x: 24, rows: ["...........", "...........", "...........", "...........", "....QQQ....", "...QAAAQQ..", "...QQQQQQQ.", "..QQQQQ.QQQ", "..QQQQ....Q", "..QQQQ....."], variants: [{ A: "#F5F5F5", Q: "#795548" }, { A: "#e1dbdb", Q: "#080707" }] },
581
+ { id: "morion", name: "Conquistador Morion", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", ".....Q.....", "...QQQQQ...", "...QAAAQ...", ".A..AAA..A.", "..AAQQQAA.."], variants: [{ A: "#B0BEC5", Q: "#FFD600" }, { A: "#0a4768", Q: "#e6c10a" }] },
582
+ { id: "shako", name: "Shako", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "........Q..", "........Q..", "........Q..", "........Q..", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..AQQQQQA.."], variants: [{ A: "#45464f", Q: "#00ff2a" }, { A: "#45464f", Q: "#ff4d00" }, { A: "#45464f", Q: "#ff0019" }, { A: "#45464f", Q: "#fff700" }] },
583
+ { id: "centurion_helm", name: "Centurion Helm", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "....QQQ....", "...QQQQQQ..", "..QQQQQQQQ.", ".QQ..A...Q.", ".Q..AAA....", "...AAAAA...", "..AAAAAAA..", "..AAAAAAA.."], variants: [{ A: "#B0BEC5", Q: "#C62828" }, { A: "#2f3131", Q: "#C62828" }] },
584
+ { id: "papal_mitre", name: "Papal Mitre", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "....QQQ....", "...QQQQQ...", "...QAAAQ...", "..AAAAAAA.."], variants: [{ A: "#FFFFFF", Q: "#FFD700" }] },
585
+ { id: "headdress", name: "Headdress", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", "...........", "..AQAQAQA..", "..AQAQAQA..", "..AAAAAAA..", "...QQQQQ..."], variants: [{ A: "#FF8F00", Q: "#1565C0" }] },
586
+ { id: "sombrero", name: "Sombrero", rarity: "rare", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...........", ".....A.....", "....AAA....", "...AAAAA...", "...AQAQA...", "AAAAAAAAAAA"], variants: [{ A: "#F57F17", Q: "#BF360C" }, { A: "#f8b377", Q: "#3e1204" }, { A: "#d6f877", Q: "#04133e" }, { A: "#d6f877", Q: "#04133e" }] },
587
+ // ── EPIC (6) ───────────────────────────────────────────────────────────
588
+ { id: "papal_tiara", name: "Papal Tiara", rarity: "epic", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "....QQ.....", "...QQQQQ...", "...QAAQQ...", "...QQQQQ...", "..AAAAAAA..", "..AQQQQQA.."], variants: [{ A: "#FFFFFF", Q: "#FFD700" }, { A: "#b18e10", Q: "#f3f3f1" }] },
589
+ { id: "samurai_kabuto", name: "Samurai Kabuto", rarity: "epic", width: 11, anchor_x: 23, rows: ["....Q...Q..", "....QQ.QQ..", ".....QQQ...", ".......Q...", "...AAAAQ...", "..AAAAAQQ..", "..AAAAAQQ..", ".AAAAAAAQ..", "AAAAAAAAQ..", "AAAAAAAAQ.."], variants: [{ A: "#B0BEC5", Q: "#C62828" }, { A: "#050505", Q: "#C62828" }, { A: "#675f5f", Q: "#c4c729" }] },
590
+ { id: "gladiator_galea", name: "Gladiator Galea", rarity: "epic", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "....QQQQQQ.", "...QQQQQ...", "...QQQQQ...", "...QAAA....", "..AAAAAAA..", "..AAAAAAA..", "..AQQQQQA.."], variants: [{ A: "#B0BEC5", Q: "#C62828" }, { A: "#ffe907", Q: "#C62828" }] },
591
+ { id: "pharaoh_nemes", name: "Pharaoh Nemes", rarity: "epic", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "..AAAAAAA..", "..AAQAQAA..", "..QAQAQAQ..", "..QAQAQAQ..", "..QQAAAQQ..", "..QQAAAAA..", "...QAQA...."], variants: [{ A: "#FFD700", Q: "#1565C0" }, { A: "#8d342a", Q: "#63686e" }] },
592
+ { id: "spartan_helmet", name: "Spartan Helmet", rarity: "epic", width: 11, anchor_x: 23, rows: ["...........", "........Q..", ".......QQ..", ".....QQQQ..", "....QQQQQ..", "..QQQQQQ...", ".QQQAA.....", "QQAAAAAAA..", "Q.AAAAAAA..", "..AQQQQQA.."], variants: [{ A: "#B0BEC5", Q: "#B71C1C" }, { A: "#B0BEC5", Q: "#b2b51c" }, { A: "#B0BEC5", Q: "#b5611c" }] },
593
+ { id: "conquistador_full", name: "Conquistador Helm", rarity: "epic", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", ".....A.....", "....AAA....", "...AAAAA...", "..AAAAAAA..", "...AQQQA...", "...AQQQA..."], variants: [{ A: "#B0BEC5", Q: "#FFD700" }, { A: "#B0BEC5", Q: "#0040ff" }, { A: "#B0BEC5", Q: "#36123b" }] },
594
+ // ── LEGENDARY (5) ──────────────────────────────────────────────────────
595
+ { id: "rainbow_crown", name: "Rainbow Crown", rarity: "legendary", width: 11, anchor_x: 23, rows: ["...........", "...........", ".....A.....", "....AQA....", "....AQA....", "....AQA....", "...AAQAA...", "...AQQQA...", "..AAQQQAA..", "..AAAAAAA.."], colors: { A: "#FFD700", Q: "#553f3f" }, animation: { type: "cycle", frames: ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#8B00FF"], fps: 8 } },
596
+ { id: "inferno_cap", name: "Inferno Cap", rarity: "legendary", width: 11, anchor_x: 23, rows: ["...........", "....AAA....", "...AAAAA...", "....AAA....", ".....Q.....", "A....Q....A", "A...QQQ...A", ".A..QQQ..A.", "..AAAAAAA..", "..AAAAAAA.."], colors: { A: "#FF4500", Q: "#FFD700" }, animation: { type: "cycle", frames: ["#FF0000", "#FF2200", "#FF4500", "#FF6600", "#FF8C00", "#FFA500"], fps: 12 } },
597
+ { id: "void_hood", name: "Void Hood", rarity: "legendary", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...AAA.....", "..AAAAAA...", ".AAAAAAA...", ".AAAAAAAA..", "AAAAAAAAA..", "AAAAQQQAA..", "AAAAQQQAA.."], colors: { A: "#1A0033", Q: "#d9cfe3" }, animation: { type: "cycle", frames: ["#0D0019", "#1A0033", "#2D004D", "#3D0066", "#2D004D", "#1A0033"], fps: 3 } },
598
+ { id: "prismatic_jester", name: "Prismatic Jester", rarity: "legendary", width: 11, anchor_x: 23, rows: ["...........", "..Q.Q.Q.Q..", "Q..A.A.A..Q", ".Q.A.A.A.Q.", "..AQAQAQA..", "..AQAQAQA..", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA.."], colors: { A: "#FF0000", Q: "#0000FF" }, animation: { type: "cycle", frames: ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#8B00FF", "#FF00FF", "#00FFFF"], fps: 15 } },
599
+ { id: "aurora_helm", name: "Aurora Helm", rarity: "legendary", width: 11, anchor_x: 23, rows: ["...........", "...........", "...........", "...........", "...QQQQQ...", "...QQQQQ...", "..AAAAAAA..", "..AAAAAAA..", "..AQAAAQA..", "..AQAAAQA.."], colors: { A: "#00CED1", Q: "#00FF7F" }, animation: { type: "cycle", frames: ["#0000FF", "#0066FF", "#00BFFF", "#00CED1", "#00FF7F", "#7CFC00", "#00FF7F", "#00CED1"], fps: 4 } }
600
+ ];
407
601
 
408
602
  // src/identity/identity.ts
409
603
  import { promises as fs } from "fs";
@@ -607,6 +801,12 @@ function deleteOrgWebhook(orgName) {
607
801
  void 0
608
802
  );
609
803
  }
804
+ function rollHat(stableHorseId) {
805
+ return request("POST", `/jockey/me/horses/${encodeURIComponent(stableHorseId)}/roll`, void 0, void 0);
806
+ }
807
+ function equipHat(stableHorseId, body) {
808
+ return request("POST", `/jockey/me/horses/${encodeURIComponent(stableHorseId)}/equip`, body, void 0);
809
+ }
610
810
 
611
811
  // src/commands/stable-create.ts
612
812
  async function stableCreateCommand() {
@@ -739,8 +939,114 @@ async function stableDeleteCommand(name) {
739
939
  }
740
940
 
741
941
  // src/commands/stable-edit.ts
742
- import React4 from "react";
942
+ import React6 from "react";
743
943
  import { render as render3 } from "ink";
944
+
945
+ // src/ui/HatPicker.tsx
946
+ import { useState as useState3 } from "react";
947
+ import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
948
+
949
+ // src/ui/AnimatedHorseSprite.tsx
950
+ import { useEffect, useState as useState2 } from "react";
951
+ import { Box as Box4, Text as Text4 } from "ink";
952
+ import { jsx as jsx4 } from "react/jsx-runtime";
953
+ function AnimatedHorseSprite({ sprite, colors, hat }) {
954
+ const isLegendary = hat.rarity === "legendary";
955
+ const frames = isLegendary ? hat.animation.frames : [];
956
+ const fps = isLegendary ? hat.animation.fps : 1;
957
+ const [idx, setIdx] = useState2(0);
958
+ useEffect(() => {
959
+ if (!isLegendary || frames.length <= 1) return;
960
+ const interval = setInterval(
961
+ () => setIdx((i) => (i + 1) % frames.length),
962
+ Math.max(1, Math.round(1e3 / fps))
963
+ );
964
+ return () => clearInterval(interval);
965
+ }, [isLegendary, frames.length, fps]);
966
+ const renderedHat = isLegendary && frames[idx] ? { ...hat, colors: { ...hat.colors, A: frames[idx] } } : hat;
967
+ const { grid } = composeHatGrid(sprite, renderedHat, 0, colors);
968
+ const lines = hexGridToHalfBlocks(grid);
969
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx4(Text4, { children: line }, i)) });
970
+ }
971
+
972
+ // src/ui/HatPicker.tsx
973
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
974
+ function HatPicker({ hats, equipped, colors, onPick, onCancel }) {
975
+ const entries = [
976
+ { kind: "unequip" },
977
+ ...hats.map((c, idx) => ({ kind: "hat", idx, collected: c }))
978
+ ];
979
+ const [cursor, setCursor] = useState3(0);
980
+ useInput2((input, key) => {
981
+ if (key.escape) {
982
+ onCancel();
983
+ return;
984
+ }
985
+ if (key.upArrow) {
986
+ setCursor((cursor - 1 + entries.length) % entries.length);
987
+ return;
988
+ }
989
+ if (key.downArrow) {
990
+ setCursor((cursor + 1) % entries.length);
991
+ return;
992
+ }
993
+ if (key.return) {
994
+ const e = entries[cursor];
995
+ onPick(e.kind === "unequip" ? null : e.idx);
996
+ return;
997
+ }
998
+ });
999
+ const focused = entries[cursor];
1000
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", children: [
1001
+ /* @__PURE__ */ jsx5(Text5, { children: "Pick a hat to equip:" }),
1002
+ entries.map((e, i) => {
1003
+ const isCursor = i === cursor;
1004
+ if (e.kind === "unequip") {
1005
+ const isEquipped2 = equipped == null;
1006
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text5, { children: [
1007
+ isCursor ? "\u25BA" : " ",
1008
+ " ",
1009
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Unequip" }),
1010
+ isEquipped2 ? " \u2713" : ""
1011
+ ] }) }, "unequip");
1012
+ }
1013
+ const hat = hatById(e.collected.id);
1014
+ const name = hat?.name ?? e.collected.id;
1015
+ const variantSuffix = hat && hat.rarity !== "legendary" && e.collected.variant !== void 0 ? ` #${e.collected.variant + 1}` : "";
1016
+ const isEquipped = equipped === e.idx;
1017
+ const rarityColor = hat ? hat.rarity === "legendary" ? "yellow" : hat.rarity === "epic" ? "magenta" : hat.rarity === "rare" ? "blue" : "gray" : "gray";
1018
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text5, { children: [
1019
+ isCursor ? "\u25BA" : " ",
1020
+ " ",
1021
+ name,
1022
+ variantSuffix,
1023
+ " ",
1024
+ /* @__PURE__ */ jsxs3(Text5, { color: rarityColor, children: [
1025
+ "[",
1026
+ hat?.rarity ?? "?",
1027
+ "]"
1028
+ ] }),
1029
+ isEquipped ? " \u2713" : ""
1030
+ ] }) }, `hat-${e.idx}`);
1031
+ }),
1032
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Preview:" }) }),
1033
+ /* @__PURE__ */ jsx5(PreviewArea, { focused, colors }),
1034
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
1035
+ ] });
1036
+ }
1037
+ function PreviewArea({ focused, colors }) {
1038
+ if (focused.kind === "unequip") {
1039
+ return /* @__PURE__ */ jsx5(HorseSprite, { sprite: MAIN_SPRITE, colors });
1040
+ }
1041
+ const hat = hatById(focused.collected.id);
1042
+ if (!hat) return /* @__PURE__ */ jsx5(HorseSprite, { sprite: MAIN_SPRITE, colors });
1043
+ if (hat.rarity === "legendary") {
1044
+ return /* @__PURE__ */ jsx5(AnimatedHorseSprite, { sprite: MAIN_SPRITE, colors, hat });
1045
+ }
1046
+ return /* @__PURE__ */ jsx5(HorseSprite, { sprite: MAIN_SPRITE, colors, hat: { hat, variant: focused.collected.variant ?? 0 } });
1047
+ }
1048
+
1049
+ // src/commands/stable-edit.ts
744
1050
  async function stableEditCommand(name) {
745
1051
  if (!name) {
746
1052
  console.error("Usage: token-derby stable edit <name>");
@@ -754,8 +1060,9 @@ async function stableEditCommand(name) {
754
1060
  return 1;
755
1061
  }
756
1062
  let exitCode = 0;
1063
+ let liveColors = existing.colors;
757
1064
  const app = render3(
758
- React4.createElement(HorseCreator, {
1065
+ React6.createElement(HorseCreator, {
759
1066
  initialColors: existing.colors,
760
1067
  initialName: existing.name,
761
1068
  lockName: true,
@@ -763,6 +1070,7 @@ async function stableEditCommand(name) {
763
1070
  onSubmit: async (_name, colors) => {
764
1071
  try {
765
1072
  await updateStableHorse(existing.stable_horse_id, { colors });
1073
+ liveColors = colors;
766
1074
  app.unmount();
767
1075
  console.log(`\u2713 Updated "${existing.name}".`);
768
1076
  } catch (e) {
@@ -783,6 +1091,34 @@ async function stableEditCommand(name) {
783
1091
  })
784
1092
  );
785
1093
  await app.waitUntilExit();
1094
+ if (exitCode === 0 && existing.hats && existing.hats.length > 0) {
1095
+ const equipResult = await new Promise((resolve) => {
1096
+ const app2 = render3(
1097
+ React6.createElement(HatPicker, {
1098
+ hats: existing.hats,
1099
+ equipped: existing.equipped_hat ?? null,
1100
+ colors: liveColors,
1101
+ onPick: (idx) => {
1102
+ app2.unmount();
1103
+ resolve({ done: true, idx });
1104
+ },
1105
+ onCancel: () => {
1106
+ app2.unmount();
1107
+ resolve({ done: false, idx: null });
1108
+ }
1109
+ })
1110
+ );
1111
+ });
1112
+ if (equipResult.done) {
1113
+ try {
1114
+ await equipHat(existing.stable_horse_id, { hat_index: equipResult.idx });
1115
+ console.log(equipResult.idx === null ? "Hat unequipped." : "Hat equipped.");
1116
+ } catch (e) {
1117
+ if (e instanceof ApiError) console.error(`Equip failed: ${e.code} ${e.message}`);
1118
+ else throw e;
1119
+ }
1120
+ }
1121
+ }
786
1122
  return exitCode;
787
1123
  }
788
1124
  async function fetchStable() {
@@ -839,13 +1175,16 @@ async function createRaceCommand(organisationName) {
839
1175
  console.error("Organisation name must be 1\u201312 alphanumeric characters.");
840
1176
  return 1;
841
1177
  }
1178
+ const countInputRaw = (await rl.question("Count input tokens (fresh input + cache creation) toward race totals? [y/N]: ")).trim().toLowerCase();
1179
+ const counts_input = countInputRaw === "y" || countInputRaw === "yes";
842
1180
  const resp = await createRace({
843
1181
  name,
844
1182
  start_time: start,
845
1183
  end_time: end,
846
1184
  tz,
847
1185
  ...max !== void 0 ? { max_participants: max } : {},
848
- ...org ? { organisation_name: org } : {}
1186
+ ...org ? { organisation_name: org } : {},
1187
+ ...counts_input ? { counts_input: true } : {}
849
1188
  });
850
1189
  console.log("");
851
1190
  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");
@@ -858,6 +1197,9 @@ async function createRaceCommand(organisationName) {
858
1197
  if (org) {
859
1198
  console.log(` Restricted to organisation: ${org}`);
860
1199
  }
1200
+ if (counts_input) {
1201
+ console.log(" Counting input + output tokens (excluding cache reads).");
1202
+ }
861
1203
  console.log(` Share with participants: token-derby join ${resp.join_code}`);
862
1204
  return 0;
863
1205
  } catch (e) {
@@ -877,16 +1219,16 @@ function isIso(s) {
877
1219
  }
878
1220
 
879
1221
  // src/commands/join.ts
880
- import React7 from "react";
1222
+ import React9 from "react";
881
1223
  import { render as render4 } from "ink";
882
1224
 
883
1225
  // src/ui/HorsePicker.tsx
884
- import { useState as useState2 } from "react";
885
- import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
886
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1226
+ import { useState as useState4 } from "react";
1227
+ import { Box as Box6, Text as Text6, useInput as useInput3 } from "ink";
1228
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
887
1229
  function HorsePicker({ horses, onPick, onCancel }) {
888
- const [idx, setIdx] = useState2(0);
889
- useInput2((input, key) => {
1230
+ const [idx, setIdx] = useState4(0);
1231
+ useInput3((input, key) => {
890
1232
  if (key.escape) {
891
1233
  onCancel();
892
1234
  return;
@@ -906,31 +1248,31 @@ function HorsePicker({ horses, onPick, onCancel }) {
906
1248
  }
907
1249
  });
908
1250
  if (horses.length === 0) {
909
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
910
- /* @__PURE__ */ jsx4(Text4, { children: "No horses in your stable." }),
911
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Run `token-derby stable create` to make one." })
1251
+ return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
1252
+ /* @__PURE__ */ jsx6(Text6, { children: "No horses in your stable." }),
1253
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Run `token-derby stable create` to make one." })
912
1254
  ] });
913
1255
  }
914
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
915
- /* @__PURE__ */ jsx4(Text4, { children: "Pick a horse to race:" }),
916
- horses.map((h, i) => /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
917
- /* @__PURE__ */ jsx4(Box4, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text4, { children: [
1256
+ return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
1257
+ /* @__PURE__ */ jsx6(Text6, { children: "Pick a horse to race:" }),
1258
+ horses.map((h, i) => /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
1259
+ /* @__PURE__ */ jsx6(Box6, { flexDirection: "row", children: /* @__PURE__ */ jsxs4(Text6, { children: [
918
1260
  i === idx ? "\u25BA" : " ",
919
1261
  " ",
920
1262
  h.name,
921
1263
  " ",
922
- /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
1264
+ /* @__PURE__ */ jsxs4(Text6, { color: "cyan", children: [
923
1265
  "[Lvl. ",
924
1266
  levelFromXp(h.xp),
925
1267
  "]"
926
1268
  ] })
927
1269
  ] }) }),
928
- /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
929
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
930
- /* @__PURE__ */ jsx4(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
1270
+ /* @__PURE__ */ jsxs4(Box6, { flexDirection: "row", children: [
1271
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
1272
+ /* @__PURE__ */ jsx6(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
931
1273
  ] })
932
1274
  ] }, h.stable_horse_id)),
933
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
1275
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
934
1276
  ] });
935
1277
  }
936
1278
 
@@ -956,45 +1298,45 @@ async function saveActiveRace(active) {
956
1298
  }
957
1299
 
958
1300
  // src/runtime/run-race.tsx
959
- import { useEffect, useRef, useState as useState3 } from "react";
960
- import { Box as Box7, Text as Text7, useApp } from "ink";
1301
+ import { useEffect as useEffect2, useRef, useState as useState5 } from "react";
1302
+ import { Box as Box8, Text as Text8, useApp } from "ink";
961
1303
 
962
1304
  // src/ui/StatusScreen.tsx
963
- import { Box as Box5, Text as Text5 } from "ink";
964
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1305
+ import { Box as Box7, Text as Text7 } from "ink";
1306
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
965
1307
  function StatusScreen(props) {
966
1308
  const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk } = props;
967
1309
  if (!race) {
968
- return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: /* @__PURE__ */ jsx5(Text5, { children: "Joining race\u2026" }) });
1310
+ return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { children: "Joining race\u2026" }) });
969
1311
  }
970
1312
  const own = race.horses.find((h) => h.horse_id === ownHorseId);
971
1313
  const leader = race.horses[0];
972
1314
  const elapsedPct = elapsed(race);
973
1315
  const timeLeft = formatDuration(race.time_left_seconds);
974
1316
  const lvl = levelInfo((own?.xp ?? 0) + (own?.live_xp ?? 0));
975
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
976
- /* @__PURE__ */ jsxs4(Text5, { children: [
1317
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1318
+ /* @__PURE__ */ jsxs5(Text7, { children: [
977
1319
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
978
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: race.name }),
1320
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: race.name }),
979
1321
  " \u2500\u2500\u2500 status: ",
980
- /* @__PURE__ */ jsx5(Text5, { color: statusColor(race.status), children: race.status })
1322
+ /* @__PURE__ */ jsx7(Text7, { color: statusColor(race.status), children: race.status })
981
1323
  ] }),
982
- /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "row", children: [
983
- /* @__PURE__ */ jsx5(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
984
- /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
985
- /* @__PURE__ */ jsxs4(Text5, { children: [
1324
+ /* @__PURE__ */ jsxs5(Box7, { marginTop: 1, flexDirection: "row", children: [
1325
+ /* @__PURE__ */ jsx7(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
1326
+ /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1327
+ /* @__PURE__ */ jsxs5(Text7, { children: [
986
1328
  " ",
987
1329
  ownHorseName,
988
1330
  " ",
989
- /* @__PURE__ */ jsxs4(Text5, { color: "cyan", children: [
1331
+ /* @__PURE__ */ jsxs5(Text7, { color: "cyan", children: [
990
1332
  "[Lvl. ",
991
1333
  lvl.level,
992
1334
  "]"
993
1335
  ] })
994
1336
  ] }),
995
- /* @__PURE__ */ jsxs4(Text5, { children: [
1337
+ /* @__PURE__ */ jsxs5(Text7, { children: [
996
1338
  " ",
997
- /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
1339
+ /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
998
1340
  "(",
999
1341
  ownUserName,
1000
1342
  ")"
@@ -1002,43 +1344,43 @@ function StatusScreen(props) {
1002
1344
  ] })
1003
1345
  ] })
1004
1346
  ] }),
1005
- /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginTop: 1, children: [
1006
- /* @__PURE__ */ jsxs4(Text5, { children: [
1347
+ /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
1348
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1007
1349
  "Tokens (race): ",
1008
1350
  own?.current_tokens ?? 0
1009
1351
  ] }),
1010
- /* @__PURE__ */ jsxs4(Text5, { children: [
1352
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1011
1353
  "Position: ",
1012
1354
  own?.rank ?? "\u2014",
1013
1355
  " of ",
1014
1356
  race.horses.length
1015
1357
  ] }),
1016
- /* @__PURE__ */ jsxs4(Text5, { children: [
1358
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1017
1359
  "Leader: ",
1018
1360
  leader ? `${leader.name}${leader.user_name ? ` (${leader.user_name})` : ""} \u2014 ${leader.current_tokens}` : "\u2014"
1019
1361
  ] }),
1020
- /* @__PURE__ */ jsxs4(Text5, { children: [
1362
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1021
1363
  "Race elapsed: ",
1022
1364
  (elapsedPct * 100).toFixed(0),
1023
1365
  "% ",
1024
1366
  bar(elapsedPct, 20)
1025
1367
  ] }),
1026
- /* @__PURE__ */ jsxs4(Text5, { children: [
1368
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1027
1369
  "Time left: ",
1028
1370
  timeLeft
1029
1371
  ] }),
1030
- /* @__PURE__ */ jsxs4(Text5, { children: [
1372
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1031
1373
  "XP: ",
1032
1374
  lvl.next_level_xp === null ? `${lvl.xp} (max level) ${bar(1, 20)}` : `${lvl.xp_into_level}/${lvl.xp_for_level} \u2192 Lvl. ${lvl.level + 1} ${bar(lvl.progress, 20)}`
1033
1375
  ] }),
1034
- /* @__PURE__ */ jsxs4(Text5, { children: [
1376
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1035
1377
  "Last heartbeat: ",
1036
1378
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1037
1379
  " ",
1038
- /* @__PURE__ */ jsx5(Text5, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1380
+ /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1039
1381
  ] })
1040
1382
  ] }),
1041
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
1383
+ /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
1042
1384
  ] });
1043
1385
  }
1044
1386
  function elapsed(race) {
@@ -1066,27 +1408,6 @@ function formatDuration(seconds) {
1066
1408
  return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${ss.toString().padStart(2, "0")}`;
1067
1409
  }
1068
1410
 
1069
- // src/ui/AchievementToast.tsx
1070
- import { Box as Box6, Text as Text6 } from "ink";
1071
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1072
- function AchievementToast({ horseName, name, description, xp }) {
1073
- return /* @__PURE__ */ jsxs5(Box6, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [
1074
- /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", justifyContent: "center", marginRight: 1, children: /* @__PURE__ */ jsxs5(Text6, { bold: true, color: "yellow", children: [
1075
- "+",
1076
- xp,
1077
- " XP"
1078
- ] }) }),
1079
- /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
1080
- /* @__PURE__ */ jsxs5(Text6, { bold: true, children: [
1081
- horseName,
1082
- " gained ",
1083
- name
1084
- ] }),
1085
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: description })
1086
- ] })
1087
- ] });
1088
- }
1089
-
1090
1411
  // src/runtime/heartbeat-loop.ts
1091
1412
  function runHeartbeatLoop(opts) {
1092
1413
  let timer = null;
@@ -1140,15 +1461,9 @@ async function sumTokens() {
1140
1461
  }
1141
1462
  return { input, output };
1142
1463
  }
1143
- async function sumOutputTokens() {
1464
+ async function sumTokensForRace(race) {
1144
1465
  const { input, output } = await sumTokens();
1145
- return countInputTokens() ? input + output : output;
1146
- }
1147
- function countInputTokens() {
1148
- const v = process.env.TOKEN_DERBY_COUNT_INPUT_TOKENS;
1149
- if (!v) return false;
1150
- const s = v.toLowerCase();
1151
- return s === "1" || s === "true" || s === "yes" || s === "on";
1466
+ return race.counts_input ? input + output : output;
1152
1467
  }
1153
1468
  async function listJsonlFiles(root) {
1154
1469
  let projects;
@@ -1202,7 +1517,7 @@ async function sumFile(file) {
1202
1517
  }
1203
1518
  const usage = parsed?.message?.usage;
1204
1519
  if (!usage) continue;
1205
- input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens) + addNum(usage.cache_read_input_tokens);
1520
+ input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1206
1521
  output += addNum(usage.output_tokens);
1207
1522
  }
1208
1523
  return { input, output };
@@ -1214,33 +1529,33 @@ function initialBaseline(args) {
1214
1529
  }
1215
1530
 
1216
1531
  // src/runtime/run-race.tsx
1217
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1532
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1218
1533
  function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1219
1534
  const { exit } = useApp();
1220
- const [race, setRace] = useState3(null);
1221
- const [lastHbAt, setLastHbAt] = useState3(null);
1222
- const [lastHbOk, setLastHbOk] = useState3(true);
1223
- const [tickNow, setTickNow] = useState3(/* @__PURE__ */ new Date());
1224
- const [fatalError, setFatalError] = useState3(null);
1225
- const [toasts, setToasts] = useState3([]);
1226
- const shownToastAtRef = useRef(0);
1535
+ const [race, setRace] = useState5(null);
1536
+ const [lastHbAt, setLastHbAt] = useState5(null);
1537
+ const [lastHbOk, setLastHbOk] = useState5(true);
1538
+ const [tickNow, setTickNow] = useState5(/* @__PURE__ */ new Date());
1539
+ const [fatalError, setFatalError] = useState5(null);
1540
+ const [achievements, setAchievements] = useState5([]);
1541
+ const shownAchievementAtRef = useRef(0);
1227
1542
  const baselineRef = useRef(startingBaseline);
1228
1543
  const pendingRef = useRef(pendingMode);
1229
1544
  const lastTokenSampleRef = useRef(startingBaseline);
1230
1545
  const ctrl = useRef(new AbortController());
1231
- useEffect(() => {
1546
+ useEffect2(() => {
1232
1547
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
1233
1548
  return () => clearInterval(t);
1234
1549
  }, []);
1235
- useEffect(() => {
1550
+ useEffect2(() => {
1236
1551
  if (pendingRef.current && race?.status === "live") {
1237
- sumOutputTokens().then((total) => {
1552
+ sumTokensForRace(active).then((total) => {
1238
1553
  baselineRef.current = total;
1239
1554
  pendingRef.current = false;
1240
1555
  });
1241
1556
  }
1242
1557
  }, [race?.status]);
1243
- useEffect(() => {
1558
+ useEffect2(() => {
1244
1559
  runHeartbeatLoop({
1245
1560
  sendHeartbeat: async (currentTokens) => {
1246
1561
  const resp = await heartbeat(
@@ -1268,14 +1583,11 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1268
1583
  setLastHbOk(true);
1269
1584
  setRace(raceViewFrom(resp));
1270
1585
  const own = resp.horses.find((h) => h.horse_id === active.horse_id);
1271
- const candidates = (own?.recent_events ?? []).filter((e) => e.at > shownToastAtRef.current);
1586
+ const candidates = (own?.recent_events ?? []).filter((e) => e.at > shownAchievementAtRef.current);
1272
1587
  if (candidates.length > 0) {
1273
- shownToastAtRef.current = Math.max(...candidates.map((e) => e.at));
1588
+ shownAchievementAtRef.current = Math.max(...candidates.map((e) => e.at));
1274
1589
  const fresh = candidates.map((e) => ({ key: `${e.at}-${e.name}`, event: e }));
1275
- setToasts((prev) => [...prev, ...fresh]);
1276
- for (const { key } of fresh) {
1277
- setTimeout(() => setToasts((prev) => prev.filter((t) => t.key !== key)), 1e4);
1278
- }
1590
+ setAchievements((prev) => [...prev, ...fresh]);
1279
1591
  }
1280
1592
  if (resp.race_status === "finished") exit();
1281
1593
  },
@@ -1293,12 +1605,12 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1293
1605
  });
1294
1606
  const sampler = setInterval(async () => {
1295
1607
  try {
1296
- lastTokenSampleRef.current = await sumOutputTokens();
1608
+ lastTokenSampleRef.current = await sumTokensForRace(active);
1297
1609
  } catch (e) {
1298
1610
  console.error("[token-derby] token sampler failed:", e);
1299
1611
  }
1300
1612
  }, 5e3);
1301
- sumOutputTokens().then((t) => {
1613
+ sumTokensForRace(active).then((t) => {
1302
1614
  lastTokenSampleRef.current = t;
1303
1615
  }).catch((e) => console.error("[token-derby] token sampler prime failed:", e));
1304
1616
  const controller = ctrl.current;
@@ -1309,13 +1621,13 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1309
1621
  }, []);
1310
1622
  const lastHeartbeatAgoSec = lastHbAt ? Math.max(0, Math.floor((tickNow.getTime() - lastHbAt.getTime()) / 1e3)) : null;
1311
1623
  if (fatalError) {
1312
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", padding: 1, children: [
1313
- /* @__PURE__ */ jsx7(Text7, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
1314
- /* @__PURE__ */ jsx7(Text7, { children: fatalError })
1624
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", padding: 1, children: [
1625
+ /* @__PURE__ */ jsx8(Text8, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
1626
+ /* @__PURE__ */ jsx8(Text8, { children: fatalError })
1315
1627
  ] });
1316
1628
  }
1317
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
1318
- /* @__PURE__ */ jsx7(
1629
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
1630
+ /* @__PURE__ */ jsx8(
1319
1631
  StatusScreen,
1320
1632
  {
1321
1633
  race,
@@ -1327,18 +1639,38 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1327
1639
  lastHeartbeatOk: lastHbOk
1328
1640
  }
1329
1641
  ),
1330
- toasts.slice(0, 3).map(({ key, event }) => /* @__PURE__ */ jsx7(
1331
- AchievementToast,
1332
- {
1333
- horseName: active.horse_name,
1334
- name: event.name,
1335
- description: event.name === "Overtake!" ? overtakeDescription(Math.floor(event.xp / 3)) : ACHIEVEMENT_DESCRIPTIONS[event.name],
1336
- xp: event.xp
1337
- },
1338
- key
1339
- ))
1642
+ achievements.length > 0 && /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", marginTop: 1, children: [
1643
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Achievements" }),
1644
+ achievements.map(({ key, event }) => {
1645
+ const description = describeAchievement(event, active);
1646
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "row", children: [
1647
+ /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
1648
+ " ",
1649
+ formatClockTime(event.at),
1650
+ " "
1651
+ ] }),
1652
+ /* @__PURE__ */ jsxs6(Text8, { color: "yellow", bold: true, children: [
1653
+ "+",
1654
+ event.xp,
1655
+ " XP "
1656
+ ] }),
1657
+ /* @__PURE__ */ jsx8(Text8, { children: event.name }),
1658
+ /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
1659
+ " \u2014 ",
1660
+ description
1661
+ ] })
1662
+ ] }, key);
1663
+ })
1664
+ ] })
1340
1665
  ] });
1341
1666
  }
1667
+ function formatClockTime(at) {
1668
+ const d = new Date(at);
1669
+ const h = String(d.getHours()).padStart(2, "0");
1670
+ const m = String(d.getMinutes()).padStart(2, "0");
1671
+ const s = String(d.getSeconds()).padStart(2, "0");
1672
+ return `${h}:${m}:${s}`;
1673
+ }
1342
1674
  function raceViewFrom(resp) {
1343
1675
  return {
1344
1676
  ...resp.race,
@@ -1349,7 +1681,7 @@ function raceViewFrom(resp) {
1349
1681
  };
1350
1682
  }
1351
1683
  async function buildInitialState(args) {
1352
- const runningTotal = await sumOutputTokens();
1684
+ const runningTotal = await sumTokensForRace(args.active);
1353
1685
  if (args.rejoin) {
1354
1686
  return {
1355
1687
  startingBaseline: Math.max(0, runningTotal - args.active.last_race_tokens),
@@ -1400,6 +1732,17 @@ async function joinCommand(joinCode) {
1400
1732
  chosenColors = ownHorse.colors;
1401
1733
  isResume = true;
1402
1734
  } else {
1735
+ if (race.org_id) {
1736
+ try {
1737
+ const { organisations } = await listOrganisations();
1738
+ if (!organisations.some((o) => o.org_id === race.org_id)) {
1739
+ const label = race.organisation_name ?? race.org_id;
1740
+ console.error(`This race is restricted to members of "${label}".`);
1741
+ return 1;
1742
+ }
1743
+ } catch {
1744
+ }
1745
+ }
1403
1746
  let horses;
1404
1747
  try {
1405
1748
  horses = (await listStable()).horses;
@@ -1454,18 +1797,19 @@ async function joinCommand(joinCode) {
1454
1797
  horse_colors: chosenColors,
1455
1798
  joined_at: ownHorse?.joined_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1456
1799
  last_race_tokens: lastTokens,
1457
- last_heartbeat_at: (/* @__PURE__ */ new Date(0)).toISOString()
1800
+ last_heartbeat_at: (/* @__PURE__ */ new Date(0)).toISOString(),
1801
+ ...race.counts_input ? { counts_input: true } : {}
1458
1802
  };
1459
1803
  await saveActiveRace(active);
1460
1804
  const initial = await buildInitialState({ active, raceStatus: status, rejoin: isResume });
1461
- const app = render4(React7.createElement(RunRace, { active, ...initial, ownUserName: identity.display_name }));
1805
+ const app = render4(React9.createElement(RunRace, { active, ...initial, ownUserName: identity.display_name }));
1462
1806
  await app.waitUntilExit();
1463
1807
  return 0;
1464
1808
  }
1465
1809
  async function pickHorse(horses) {
1466
1810
  return new Promise((resolve) => {
1467
1811
  const app = render4(
1468
- React7.createElement(HorsePicker, {
1812
+ React9.createElement(HorsePicker, {
1469
1813
  horses,
1470
1814
  onPick: (h) => {
1471
1815
  app.unmount();
@@ -1590,11 +1934,471 @@ async function initCommand(reset = false) {
1590
1934
  }
1591
1935
  }
1592
1936
 
1593
- // src/commands/org-create.ts
1937
+ // src/commands/update.ts
1594
1938
  import * as readline5 from "readline/promises";
1595
1939
  import { stdin as stdin5, stdout as stdout5 } from "process";
1596
- async function orgCreateCommand() {
1940
+ import { spawn } from "child_process";
1941
+ var REGISTRY_URL = "https://registry.npmjs.org/@mauricode/token-derby/latest";
1942
+ var UPGRADE_CMD = "npm install -g @mauricode/token-derby@latest";
1943
+ var FETCH_TIMEOUT_MS = 5e3;
1944
+ async function updateCommand(deps = {}) {
1945
+ const fetchImpl = deps.fetchImpl ?? fetch;
1946
+ const spawnImpl = deps.spawnImpl ?? spawn;
1947
+ const promptYesNo2 = deps.promptYesNo ?? defaultPromptYesNo;
1948
+ let latest;
1949
+ try {
1950
+ latest = await fetchLatestVersion(fetchImpl);
1951
+ } catch (e) {
1952
+ console.error(`Could not reach the npm registry${e?.message ? ` (${e.message})` : ""}.`);
1953
+ console.error(`To upgrade manually: ${UPGRADE_CMD}`);
1954
+ return 1;
1955
+ }
1956
+ if (gteSemver(CLI_VERSION, latest)) {
1957
+ console.log(`You're on the latest version (${CLI_VERSION}).`);
1958
+ return 0;
1959
+ }
1960
+ console.log(`Current: ${CLI_VERSION} Latest: ${latest}`);
1961
+ const yes = await promptYesNo2("Run upgrade now? [y/N]: ");
1962
+ if (!yes) {
1963
+ console.log(`To upgrade manually: ${UPGRADE_CMD}`);
1964
+ return 0;
1965
+ }
1966
+ return runNpmUpgrade(spawnImpl);
1967
+ }
1968
+ async function fetchLatestVersion(fetchImpl) {
1969
+ const controller = new AbortController();
1970
+ const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1971
+ try {
1972
+ const res = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
1973
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1974
+ const body = await res.json();
1975
+ if (typeof body.version !== "string" || !/^\d+\.\d+\.\d+/.test(body.version)) {
1976
+ throw new Error("unexpected registry response");
1977
+ }
1978
+ return body.version;
1979
+ } finally {
1980
+ clearTimeout(t);
1981
+ }
1982
+ }
1983
+ async function defaultPromptYesNo(question) {
1597
1984
  const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1985
+ try {
1986
+ const answer = (await rl.question(question)).trim().toLowerCase();
1987
+ return answer === "y" || answer === "yes";
1988
+ } finally {
1989
+ rl.close();
1990
+ }
1991
+ }
1992
+ function runNpmUpgrade(spawnImpl) {
1993
+ return new Promise((resolve) => {
1994
+ const child = spawnImpl("npm", ["install", "-g", "@mauricode/token-derby@latest"], {
1995
+ stdio: "inherit"
1996
+ });
1997
+ child.on("error", (e) => {
1998
+ if (e.code === "ENOENT") {
1999
+ console.error("Could not find `npm` on PATH.");
2000
+ console.error(`To upgrade manually: ${UPGRADE_CMD}`);
2001
+ } else {
2002
+ console.error(`npm failed to start: ${e.message}`);
2003
+ }
2004
+ resolve(1);
2005
+ });
2006
+ child.on("exit", (code) => resolve(code ?? 1));
2007
+ });
2008
+ }
2009
+
2010
+ // src/commands/roll.ts
2011
+ import React13 from "react";
2012
+ import { render as render5 } from "ink";
2013
+
2014
+ // src/ui/RollReveal.tsx
2015
+ import { useState as useState7, useEffect as useEffect4, useMemo } from "react";
2016
+ import { Box as Box10, Text as Text10 } from "ink";
2017
+
2018
+ // src/ui/HatSprite.tsx
2019
+ import { useEffect as useEffect3, useState as useState6 } from "react";
2020
+ import { Box as Box9, Text as Text9 } from "ink";
2021
+ import { jsx as jsx9 } from "react/jsx-runtime";
2022
+ function HatSprite({ hat, variant, centerIn }) {
2023
+ const colors = hatColorsFor2(hat, variant ?? 0);
2024
+ const grid = makeHatGrid(hat, colors, centerIn);
2025
+ const lines = hexGridToHalfBlocks(grid);
2026
+ return /* @__PURE__ */ jsx9(Box9, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx9(Text9, { children: line }, i)) });
2027
+ }
2028
+ function AnimatedHatSprite({ hat, variant, centerIn }) {
2029
+ if (hat.rarity !== "legendary") {
2030
+ return /* @__PURE__ */ jsx9(HatSprite, { hat, variant, centerIn });
2031
+ }
2032
+ const frames = hat.animation.frames;
2033
+ const fps = hat.animation.fps;
2034
+ const [idx, setIdx] = useState6(0);
2035
+ useEffect3(() => {
2036
+ if (frames.length <= 1) return;
2037
+ const interval = setInterval(
2038
+ () => setIdx((i) => (i + 1) % frames.length),
2039
+ Math.max(1, Math.round(1e3 / fps))
2040
+ );
2041
+ return () => clearInterval(interval);
2042
+ }, [frames.length, fps]);
2043
+ const framed = { ...hat, colors: { ...hat.colors, A: frames[idx] } };
2044
+ return /* @__PURE__ */ jsx9(HatSprite, { hat: framed, variant, centerIn });
2045
+ }
2046
+ function hatColorsFor2(hat, variantIdx) {
2047
+ if (hat.rarity === "legendary") return hat.colors;
2048
+ return hat.variants[variantIdx] ?? hat.variants[0];
2049
+ }
2050
+ function makeHatGrid(hat, colors, centerIn) {
2051
+ const w = centerIn?.w ?? hat.width;
2052
+ const h = centerIn?.h ?? hat.rows.length;
2053
+ const offX = Math.floor((w - hat.width) / 2);
2054
+ const offY = Math.floor((h - hat.rows.length) / 2);
2055
+ const grid = Array.from({ length: h }, () => Array(w).fill(null));
2056
+ for (let y = 0; y < hat.rows.length; y++) {
2057
+ const row = hat.rows[y];
2058
+ for (let x = 0; x < hat.width; x++) {
2059
+ const ch = row[x];
2060
+ if (ch === "." || ch === void 0) continue;
2061
+ const gx = x + offX;
2062
+ const gy = y + offY;
2063
+ if (gx < 0 || gx >= w || gy < 0 || gy >= h) continue;
2064
+ grid[gy][gx] = ch === "A" ? colors.A : colors.Q ?? colors.A;
2065
+ }
2066
+ }
2067
+ return grid;
2068
+ }
2069
+
2070
+ // src/ui/RollReveal.tsx
2071
+ import { jsx as jsx10 } from "react/jsx-runtime";
2072
+ var RESET3 = "\x1B[0m";
2073
+ var BOX_COLOR = "#E5C76B";
2074
+ var TIER_PALETTE = {
2075
+ common: ["#FFFFFF", "#DDDDDD", "#AAAAAA", "#9E9E9E"],
2076
+ rare: ["#42A5F5", "#1E88E5", "#90CAF9", "#0277BD"],
2077
+ epic: ["#AB47BC", "#8E24AA", "#CE93D8", "#6A1B9A"],
2078
+ legendary: ["#FFD700", "#FF7F00", "#FF0000", "#FF00FF", "#00BFFF", "#7CFC00", "#8B00FF"]
2079
+ };
2080
+ var CONFETTI_CHARS = ["\u2726", "\u2727", "\u22C6", "\u2605", "\u2606", "\u2728", "*", "\u2022", "\u25C6", "\u25C7"];
2081
+ var SCENE_W = 32;
2082
+ var SCENE_H = 10;
2083
+ function pad(line) {
2084
+ const visible = stripAnsi(line).length;
2085
+ const lead = Math.max(0, Math.floor((SCENE_W - visible) / 2));
2086
+ return " ".repeat(lead) + line + " ".repeat(Math.max(0, SCENE_W - lead - visible));
2087
+ }
2088
+ function stripAnsi(s) {
2089
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
2090
+ }
2091
+ var BOX_CLOSED = [
2092
+ "",
2093
+ "",
2094
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2095
+ "\u2551 \u2591\u2591\u2591\u2591\u2591 \u2551",
2096
+ "\u2551\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2551",
2097
+ "\u2551 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2551",
2098
+ "\u2551\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2551",
2099
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2100
+ "",
2101
+ ""
2102
+ ].map(pad);
2103
+ var BOX_OPENING_1 = [
2104
+ "",
2105
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2106
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2107
+ "",
2108
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2109
+ "\u2551 \u2726 \u2551",
2110
+ "\u2551\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2551",
2111
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2112
+ "",
2113
+ ""
2114
+ ].map(pad);
2115
+ var BOX_OPENING_2 = [
2116
+ " \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2117
+ " \u2551 \u2551",
2118
+ " \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2119
+ "",
2120
+ " \u2728 \u2605 \u2726",
2121
+ "",
2122
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2123
+ "\u2551 \u2551",
2124
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2125
+ ""
2126
+ ].map(pad);
2127
+ var BOX_EMPTY = [
2128
+ "",
2129
+ "",
2130
+ "",
2131
+ "",
2132
+ "",
2133
+ "",
2134
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2135
+ "\u2551 \u2551",
2136
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2137
+ ""
2138
+ ].map(pad);
2139
+ function GiftBox({ frame, color }) {
2140
+ return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: frame.map((line, i) => /* @__PURE__ */ jsx10(Text10, { children: line ? ansiFg(color) + line + RESET3 : line }, i)) });
2141
+ }
2142
+ function printClosedBox() {
2143
+ for (const line of BOX_CLOSED) {
2144
+ const colored = line.trim().length > 0 ? `${ansiFg(BOX_COLOR)}${line}${RESET3}` : line;
2145
+ process.stdout.write(colored + "\n");
2146
+ }
2147
+ }
2148
+ function spawnParticles(tier, count, cx, cy) {
2149
+ const palette = TIER_PALETTE[tier];
2150
+ const out = [];
2151
+ for (let i = 0; i < count; i++) {
2152
+ const angle = i / count * Math.PI * 2 + (Math.random() - 0.5) * 0.6;
2153
+ const speed = 0.8 + Math.random() * 1.6;
2154
+ out.push({
2155
+ x: cx,
2156
+ y: cy,
2157
+ vx: Math.cos(angle) * speed,
2158
+ vy: Math.sin(angle) * speed * 0.5,
2159
+ char: CONFETTI_CHARS[Math.floor(Math.random() * CONFETTI_CHARS.length)],
2160
+ color: palette[Math.floor(Math.random() * palette.length)]
2161
+ });
2162
+ }
2163
+ return out;
2164
+ }
2165
+ function ConfettiBurst({ tier }) {
2166
+ const cx = Math.floor(SCENE_W / 2);
2167
+ const cy = Math.floor(SCENE_H / 2);
2168
+ const particles = useMemo(() => spawnParticles(tier, 36, cx, cy), [tier, cx, cy]);
2169
+ const [tick, setTick] = useState7(0);
2170
+ useEffect4(() => {
2171
+ const i = setInterval(() => setTick((t) => t + 1), 70);
2172
+ return () => clearInterval(i);
2173
+ }, []);
2174
+ const grid = Array.from({ length: SCENE_H }, () => Array(SCENE_W).fill(" "));
2175
+ for (const p of particles) {
2176
+ const x = Math.round(p.x + p.vx * tick);
2177
+ const y = Math.round(p.y + p.vy * tick);
2178
+ if (x >= 0 && x < SCENE_W && y >= 0 && y < SCENE_H) {
2179
+ grid[y][x] = ansiFg(p.color) + p.char + RESET3;
2180
+ }
2181
+ }
2182
+ return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: grid.map((row, y) => /* @__PURE__ */ jsx10(Text10, { children: row.join("") }, y)) });
2183
+ }
2184
+ function RollReveal({ outcome, onDone }) {
2185
+ const isNoHat = outcome.kind === "no_hat";
2186
+ const isLegendary = outcome.kind !== "no_hat" && outcome.hat.rarity === "legendary";
2187
+ const [phase, setPhase] = useState7("open1");
2188
+ useEffect4(() => {
2189
+ const timers = [];
2190
+ timers.push(setTimeout(() => setPhase("open2"), 350));
2191
+ if (isNoHat) {
2192
+ timers.push(setTimeout(() => setPhase("empty"), 700));
2193
+ timers.push(setTimeout(onDone, 1500));
2194
+ } else {
2195
+ timers.push(setTimeout(() => setPhase("burst"), 700));
2196
+ timers.push(setTimeout(() => setPhase("reveal"), 1650));
2197
+ timers.push(setTimeout(onDone, isLegendary ? 4650 : 2650));
2198
+ }
2199
+ return () => timers.forEach(clearTimeout);
2200
+ }, [isNoHat, isLegendary, onDone]);
2201
+ if (phase === "open1") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
2202
+ if (phase === "open2") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
2203
+ if (phase === "empty") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
2204
+ if (phase === "burst" && outcome.kind !== "no_hat") {
2205
+ return /* @__PURE__ */ jsx10(ConfettiBurst, { tier: outcome.hat.rarity });
2206
+ }
2207
+ if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
2208
+ return outcome.hat.rarity === "legendary" ? /* @__PURE__ */ jsx10(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx10(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
2209
+ }
2210
+
2211
+ // src/ui/RollHorsePicker.tsx
2212
+ import { useState as useState8 } from "react";
2213
+ import { Box as Box11, Text as Text11, useInput as useInput4 } from "ink";
2214
+ import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
2215
+ function RollHorsePicker({ horses, onPick, onCancel }) {
2216
+ const [idx, setIdx] = useState8(0);
2217
+ useInput4((input, key) => {
2218
+ if (key.escape) {
2219
+ onCancel();
2220
+ return;
2221
+ }
2222
+ if (horses.length === 0) return;
2223
+ if (key.upArrow) {
2224
+ setIdx((idx - 1 + horses.length) % horses.length);
2225
+ return;
2226
+ }
2227
+ if (key.downArrow) {
2228
+ setIdx((idx + 1) % horses.length);
2229
+ return;
2230
+ }
2231
+ if (key.return) {
2232
+ onPick(horses[idx]);
2233
+ return;
2234
+ }
2235
+ });
2236
+ return /* @__PURE__ */ jsxs7(Box11, { flexDirection: "column", children: [
2237
+ /* @__PURE__ */ jsx11(Text11, { children: "Pick a horse to roll for:" }),
2238
+ horses.map((h, i) => /* @__PURE__ */ jsxs7(Box11, { flexDirection: "column", children: [
2239
+ /* @__PURE__ */ jsx11(Box11, { flexDirection: "row", children: /* @__PURE__ */ jsxs7(Text11, { children: [
2240
+ i === idx ? "\u25BA" : " ",
2241
+ " ",
2242
+ h.name,
2243
+ " ",
2244
+ /* @__PURE__ */ jsxs7(Text11, { color: "cyan", children: [
2245
+ "[Lvl. ",
2246
+ levelFromXp(h.xp),
2247
+ "]"
2248
+ ] }),
2249
+ " ",
2250
+ /* @__PURE__ */ jsxs7(Text11, { color: "yellow", children: [
2251
+ "\u2014 ",
2252
+ h.pending,
2253
+ " roll",
2254
+ h.pending === 1 ? "" : "s"
2255
+ ] })
2256
+ ] }) }),
2257
+ /* @__PURE__ */ jsxs7(Box11, { flexDirection: "row", children: [
2258
+ /* @__PURE__ */ jsx11(Text11, { children: " " }),
2259
+ /* @__PURE__ */ jsx11(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
2260
+ ] })
2261
+ ] }, h.stable_horse_id)),
2262
+ /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
2263
+ ] });
2264
+ }
2265
+
2266
+ // src/commands/roll.ts
2267
+ function pendingFor(horse) {
2268
+ const level = levelFromXp(horse.xp);
2269
+ const lastRolled = horse.last_rolled_level ?? Math.max(0, level - 1);
2270
+ return level - lastRolled;
2271
+ }
2272
+ async function promptYesNo(question) {
2273
+ const readline7 = await import("readline/promises");
2274
+ const rl = readline7.createInterface({ input: process.stdin, output: process.stdout });
2275
+ const a = (await rl.question(question)).trim().toLowerCase();
2276
+ rl.close();
2277
+ return a === "" || a === "y" || a === "yes";
2278
+ }
2279
+ async function runReveal(outcome) {
2280
+ printClosedBox();
2281
+ const readline7 = await import("readline/promises");
2282
+ const rl = readline7.createInterface({ input: process.stdin, output: process.stdout });
2283
+ await rl.question("Press Enter to open the box\u2026 ");
2284
+ rl.close();
2285
+ await new Promise((resolve) => {
2286
+ const app = render5(React13.createElement(RollReveal, {
2287
+ outcome,
2288
+ onDone: () => {
2289
+ app.unmount();
2290
+ resolve();
2291
+ }
2292
+ }));
2293
+ });
2294
+ }
2295
+ async function rollCommand() {
2296
+ let stable;
2297
+ try {
2298
+ stable = await listStable();
2299
+ } catch (e) {
2300
+ if (e instanceof ApiError) {
2301
+ console.error(`Error: ${e.code} ${e.message}`);
2302
+ return 1;
2303
+ }
2304
+ throw e;
2305
+ }
2306
+ const eligible = stable.horses.map((h) => ({ ...h, pending: pendingFor(h) })).filter((h) => h.pending > 0);
2307
+ if (eligible.length === 0) {
2308
+ console.log("No rolls available. Level up a horse to earn a roll!");
2309
+ return 0;
2310
+ }
2311
+ let chosen = eligible[0];
2312
+ if (eligible.length > 1) {
2313
+ const picked = await new Promise((resolve) => {
2314
+ const app = render5(React13.createElement(RollHorsePicker, {
2315
+ horses: eligible,
2316
+ onPick: (h) => {
2317
+ app.unmount();
2318
+ resolve(h);
2319
+ },
2320
+ onCancel: () => {
2321
+ app.unmount();
2322
+ resolve(null);
2323
+ }
2324
+ }));
2325
+ });
2326
+ if (!picked) {
2327
+ console.log("Cancelled.");
2328
+ return 0;
2329
+ }
2330
+ chosen = picked;
2331
+ }
2332
+ while (true) {
2333
+ let result;
2334
+ try {
2335
+ result = await rollHat(chosen.stable_horse_id);
2336
+ } catch (e) {
2337
+ if (e instanceof ApiError) {
2338
+ if (e.code === "INSUFFICIENT_ROLLS") {
2339
+ console.log("No more rolls available.");
2340
+ return 0;
2341
+ }
2342
+ console.error(`Error: ${e.code} ${e.message}`);
2343
+ return 1;
2344
+ }
2345
+ throw e;
2346
+ }
2347
+ const outcome = (() => {
2348
+ if (result.result === "hat") {
2349
+ const hat = hatById(result.collected.id);
2350
+ if (!hat) return null;
2351
+ return { kind: "hat", hat, variant: result.collected.variant };
2352
+ }
2353
+ if (result.result === "duplicate") {
2354
+ const hat = hatById(result.hat_id);
2355
+ if (!hat) return null;
2356
+ return { kind: "duplicate", hat, variant: result.variant };
2357
+ }
2358
+ return { kind: "no_hat" };
2359
+ })();
2360
+ if (!outcome) {
2361
+ console.error("Server returned an unknown hat id \u2014 catalog mismatch.");
2362
+ return 1;
2363
+ }
2364
+ await runReveal(outcome);
2365
+ if (result.result === "hat") {
2366
+ const hat = hatById(result.collected.id);
2367
+ const variantSuffix = hat.rarity !== "legendary" && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
2368
+ console.log(`
2369
+ \u2728 ${hat.name}${variantSuffix} [${hat.rarity.toUpperCase()}]
2370
+ `);
2371
+ if (await promptYesNo("Equip now? [Y/n] ")) {
2372
+ try {
2373
+ await equipHat(chosen.stable_horse_id, { hat_index: result.hat_index });
2374
+ console.log(`Equipped on ${chosen.name}.`);
2375
+ } catch (e) {
2376
+ if (e instanceof ApiError) {
2377
+ console.error(`Equip failed: ${e.code} ${e.message}`);
2378
+ } else throw e;
2379
+ }
2380
+ }
2381
+ } else if (result.result === "duplicate") {
2382
+ const hat = hatById(result.hat_id);
2383
+ const variantSuffix = result.variant !== void 0 ? ` #${result.variant + 1}` : "";
2384
+ console.log(`
2385
+ You already have ${hat?.name ?? result.hat_id}${variantSuffix}. +${result.xp_awarded} XP.
2386
+ `);
2387
+ } else {
2388
+ console.log(`
2389
+ No hat this time. +${result.xp_awarded} XP toward your next level.
2390
+ `);
2391
+ }
2392
+ if (result.remaining_rolls <= 0) return 0;
2393
+ if (!await promptYesNo(`${result.remaining_rolls} more roll${result.remaining_rolls === 1 ? "" : "s"} available. Roll again? [Y/n] `)) return 0;
2394
+ }
2395
+ }
2396
+
2397
+ // src/commands/org-create.ts
2398
+ import * as readline6 from "readline/promises";
2399
+ import { stdin as stdin6, stdout as stdout6 } from "process";
2400
+ async function orgCreateCommand() {
2401
+ const rl = readline6.createInterface({ input: stdin6, output: stdout6 });
1598
2402
  try {
1599
2403
  const name = (await rl.question(`Organisation name (1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric chars): `)).trim();
1600
2404
  if (!ORG_NAME_PATTERN.test(name)) {
@@ -1773,6 +2577,9 @@ Identity:
1773
2577
  token-derby init --reset Wipe local identity and create a fresh account.
1774
2578
  Your previous stable is abandoned on the server.
1775
2579
 
2580
+ Maintenance:
2581
+ token-derby update Check for and install the latest CLI version
2582
+
1776
2583
  Stable management:
1777
2584
  token-derby stable create Make a new horse (interactive)
1778
2585
  token-derby stable list Show your saved horses
@@ -1801,6 +2608,10 @@ Races:
1801
2608
  token-derby join <join-code> Join (or resume) a race
1802
2609
  token-derby end <admin-code> End a race early
1803
2610
 
2611
+ Cosmetics:
2612
+ token-derby roll Spend a pending roll to try for a hat.
2613
+ Earn rolls by leveling up horses.
2614
+
1804
2615
  Environment:
1805
2616
  TOKEN_DERBY_API_BASE Override API base URL (default: production)
1806
2617
  TOKEN_DERBY_HOME Override identity/stable directory
@@ -1820,6 +2631,7 @@ async function main() {
1820
2631
  const reset = argv.slice(1).includes("--reset");
1821
2632
  return initCommand(reset);
1822
2633
  }
2634
+ if (cmd === "update") return updateCommand();
1823
2635
  const identity = await loadIdentity();
1824
2636
  if (!identity) {
1825
2637
  console.error("Run `token-derby init` to set up your identity before using any other command.");
@@ -1860,6 +2672,7 @@ async function main() {
1860
2672
  }
1861
2673
  if (cmd === "join") return joinCommand(argv[1]);
1862
2674
  if (cmd === "end") return endCommand(argv[1]);
2675
+ if (cmd === "roll") return rollCommand();
1863
2676
  console.error(`Unknown command: ${cmd}`);
1864
2677
  console.error(HELP);
1865
2678
  return 2;