@mauricode/token-derby 2.4.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 };
136
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];
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.4.0".length > 0) {
341
- return "2.4.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);
@@ -466,6 +549,56 @@ var MIDRACE_THRESHOLDS = {
466
549
  // sliding window for recent_events
467
550
  };
468
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
+ ];
601
+
469
602
  // src/identity/identity.ts
470
603
  import { promises as fs } from "fs";
471
604
  import * as path2 from "path";
@@ -668,6 +801,12 @@ function deleteOrgWebhook(orgName) {
668
801
  void 0
669
802
  );
670
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
+ }
671
810
 
672
811
  // src/commands/stable-create.ts
673
812
  async function stableCreateCommand() {
@@ -800,8 +939,114 @@ async function stableDeleteCommand(name) {
800
939
  }
801
940
 
802
941
  // src/commands/stable-edit.ts
803
- import React4 from "react";
942
+ import React6 from "react";
804
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
805
1050
  async function stableEditCommand(name) {
806
1051
  if (!name) {
807
1052
  console.error("Usage: token-derby stable edit <name>");
@@ -815,8 +1060,9 @@ async function stableEditCommand(name) {
815
1060
  return 1;
816
1061
  }
817
1062
  let exitCode = 0;
1063
+ let liveColors = existing.colors;
818
1064
  const app = render3(
819
- React4.createElement(HorseCreator, {
1065
+ React6.createElement(HorseCreator, {
820
1066
  initialColors: existing.colors,
821
1067
  initialName: existing.name,
822
1068
  lockName: true,
@@ -824,6 +1070,7 @@ async function stableEditCommand(name) {
824
1070
  onSubmit: async (_name, colors) => {
825
1071
  try {
826
1072
  await updateStableHorse(existing.stable_horse_id, { colors });
1073
+ liveColors = colors;
827
1074
  app.unmount();
828
1075
  console.log(`\u2713 Updated "${existing.name}".`);
829
1076
  } catch (e) {
@@ -844,6 +1091,34 @@ async function stableEditCommand(name) {
844
1091
  })
845
1092
  );
846
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
+ }
847
1122
  return exitCode;
848
1123
  }
849
1124
  async function fetchStable() {
@@ -944,16 +1219,16 @@ function isIso(s) {
944
1219
  }
945
1220
 
946
1221
  // src/commands/join.ts
947
- import React7 from "react";
1222
+ import React9 from "react";
948
1223
  import { render as render4 } from "ink";
949
1224
 
950
1225
  // src/ui/HorsePicker.tsx
951
- import { useState as useState2 } from "react";
952
- import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
953
- 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";
954
1229
  function HorsePicker({ horses, onPick, onCancel }) {
955
- const [idx, setIdx] = useState2(0);
956
- useInput2((input, key) => {
1230
+ const [idx, setIdx] = useState4(0);
1231
+ useInput3((input, key) => {
957
1232
  if (key.escape) {
958
1233
  onCancel();
959
1234
  return;
@@ -973,31 +1248,31 @@ function HorsePicker({ horses, onPick, onCancel }) {
973
1248
  }
974
1249
  });
975
1250
  if (horses.length === 0) {
976
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
977
- /* @__PURE__ */ jsx4(Text4, { children: "No horses in your stable." }),
978
- /* @__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." })
979
1254
  ] });
980
1255
  }
981
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
982
- /* @__PURE__ */ jsx4(Text4, { children: "Pick a horse to race:" }),
983
- horses.map((h, i) => /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
984
- /* @__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: [
985
1260
  i === idx ? "\u25BA" : " ",
986
1261
  " ",
987
1262
  h.name,
988
1263
  " ",
989
- /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
1264
+ /* @__PURE__ */ jsxs4(Text6, { color: "cyan", children: [
990
1265
  "[Lvl. ",
991
1266
  levelFromXp(h.xp),
992
1267
  "]"
993
1268
  ] })
994
1269
  ] }) }),
995
- /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
996
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
997
- /* @__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 })
998
1273
  ] })
999
1274
  ] }, h.stable_horse_id)),
1000
- /* @__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" }) })
1001
1276
  ] });
1002
1277
  }
1003
1278
 
@@ -1023,45 +1298,45 @@ async function saveActiveRace(active) {
1023
1298
  }
1024
1299
 
1025
1300
  // src/runtime/run-race.tsx
1026
- import { useEffect, useRef, useState as useState3 } from "react";
1027
- import { Box as Box6, Text as Text6, 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";
1028
1303
 
1029
1304
  // src/ui/StatusScreen.tsx
1030
- import { Box as Box5, Text as Text5 } from "ink";
1031
- 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";
1032
1307
  function StatusScreen(props) {
1033
1308
  const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk } = props;
1034
1309
  if (!race) {
1035
- 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" }) });
1036
1311
  }
1037
1312
  const own = race.horses.find((h) => h.horse_id === ownHorseId);
1038
1313
  const leader = race.horses[0];
1039
1314
  const elapsedPct = elapsed(race);
1040
1315
  const timeLeft = formatDuration(race.time_left_seconds);
1041
1316
  const lvl = levelInfo((own?.xp ?? 0) + (own?.live_xp ?? 0));
1042
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1043
- /* @__PURE__ */ jsxs4(Text5, { children: [
1317
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1318
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1044
1319
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
1045
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: race.name }),
1320
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: race.name }),
1046
1321
  " \u2500\u2500\u2500 status: ",
1047
- /* @__PURE__ */ jsx5(Text5, { color: statusColor(race.status), children: race.status })
1322
+ /* @__PURE__ */ jsx7(Text7, { color: statusColor(race.status), children: race.status })
1048
1323
  ] }),
1049
- /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "row", children: [
1050
- /* @__PURE__ */ jsx5(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
1051
- /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
1052
- /* @__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: [
1053
1328
  " ",
1054
1329
  ownHorseName,
1055
1330
  " ",
1056
- /* @__PURE__ */ jsxs4(Text5, { color: "cyan", children: [
1331
+ /* @__PURE__ */ jsxs5(Text7, { color: "cyan", children: [
1057
1332
  "[Lvl. ",
1058
1333
  lvl.level,
1059
1334
  "]"
1060
1335
  ] })
1061
1336
  ] }),
1062
- /* @__PURE__ */ jsxs4(Text5, { children: [
1337
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1063
1338
  " ",
1064
- /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
1339
+ /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
1065
1340
  "(",
1066
1341
  ownUserName,
1067
1342
  ")"
@@ -1069,43 +1344,43 @@ function StatusScreen(props) {
1069
1344
  ] })
1070
1345
  ] })
1071
1346
  ] }),
1072
- /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginTop: 1, children: [
1073
- /* @__PURE__ */ jsxs4(Text5, { children: [
1347
+ /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
1348
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1074
1349
  "Tokens (race): ",
1075
1350
  own?.current_tokens ?? 0
1076
1351
  ] }),
1077
- /* @__PURE__ */ jsxs4(Text5, { children: [
1352
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1078
1353
  "Position: ",
1079
1354
  own?.rank ?? "\u2014",
1080
1355
  " of ",
1081
1356
  race.horses.length
1082
1357
  ] }),
1083
- /* @__PURE__ */ jsxs4(Text5, { children: [
1358
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1084
1359
  "Leader: ",
1085
1360
  leader ? `${leader.name}${leader.user_name ? ` (${leader.user_name})` : ""} \u2014 ${leader.current_tokens}` : "\u2014"
1086
1361
  ] }),
1087
- /* @__PURE__ */ jsxs4(Text5, { children: [
1362
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1088
1363
  "Race elapsed: ",
1089
1364
  (elapsedPct * 100).toFixed(0),
1090
1365
  "% ",
1091
1366
  bar(elapsedPct, 20)
1092
1367
  ] }),
1093
- /* @__PURE__ */ jsxs4(Text5, { children: [
1368
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1094
1369
  "Time left: ",
1095
1370
  timeLeft
1096
1371
  ] }),
1097
- /* @__PURE__ */ jsxs4(Text5, { children: [
1372
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1098
1373
  "XP: ",
1099
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)}`
1100
1375
  ] }),
1101
- /* @__PURE__ */ jsxs4(Text5, { children: [
1376
+ /* @__PURE__ */ jsxs5(Text7, { children: [
1102
1377
  "Last heartbeat: ",
1103
1378
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1104
1379
  " ",
1105
- /* @__PURE__ */ jsx5(Text5, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1380
+ /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1106
1381
  ] })
1107
1382
  ] }),
1108
- /* @__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." }) })
1109
1384
  ] });
1110
1385
  }
1111
1386
  function elapsed(race) {
@@ -1254,25 +1529,25 @@ function initialBaseline(args) {
1254
1529
  }
1255
1530
 
1256
1531
  // src/runtime/run-race.tsx
1257
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1532
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1258
1533
  function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1259
1534
  const { exit } = useApp();
1260
- const [race, setRace] = useState3(null);
1261
- const [lastHbAt, setLastHbAt] = useState3(null);
1262
- const [lastHbOk, setLastHbOk] = useState3(true);
1263
- const [tickNow, setTickNow] = useState3(/* @__PURE__ */ new Date());
1264
- const [fatalError, setFatalError] = useState3(null);
1265
- const [achievements, setAchievements] = useState3([]);
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([]);
1266
1541
  const shownAchievementAtRef = useRef(0);
1267
1542
  const baselineRef = useRef(startingBaseline);
1268
1543
  const pendingRef = useRef(pendingMode);
1269
1544
  const lastTokenSampleRef = useRef(startingBaseline);
1270
1545
  const ctrl = useRef(new AbortController());
1271
- useEffect(() => {
1546
+ useEffect2(() => {
1272
1547
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
1273
1548
  return () => clearInterval(t);
1274
1549
  }, []);
1275
- useEffect(() => {
1550
+ useEffect2(() => {
1276
1551
  if (pendingRef.current && race?.status === "live") {
1277
1552
  sumTokensForRace(active).then((total) => {
1278
1553
  baselineRef.current = total;
@@ -1280,7 +1555,7 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1280
1555
  });
1281
1556
  }
1282
1557
  }, [race?.status]);
1283
- useEffect(() => {
1558
+ useEffect2(() => {
1284
1559
  runHeartbeatLoop({
1285
1560
  sendHeartbeat: async (currentTokens) => {
1286
1561
  const resp = await heartbeat(
@@ -1346,13 +1621,13 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1346
1621
  }, []);
1347
1622
  const lastHeartbeatAgoSec = lastHbAt ? Math.max(0, Math.floor((tickNow.getTime() - lastHbAt.getTime()) / 1e3)) : null;
1348
1623
  if (fatalError) {
1349
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", padding: 1, children: [
1350
- /* @__PURE__ */ jsx6(Text6, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
1351
- /* @__PURE__ */ jsx6(Text6, { 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 })
1352
1627
  ] });
1353
1628
  }
1354
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
1355
- /* @__PURE__ */ jsx6(
1629
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
1630
+ /* @__PURE__ */ jsx8(
1356
1631
  StatusScreen,
1357
1632
  {
1358
1633
  race,
@@ -1364,23 +1639,23 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1364
1639
  lastHeartbeatOk: lastHbOk
1365
1640
  }
1366
1641
  ),
1367
- achievements.length > 0 && /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", marginTop: 1, children: [
1368
- /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Achievements" }),
1642
+ achievements.length > 0 && /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", marginTop: 1, children: [
1643
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Achievements" }),
1369
1644
  achievements.map(({ key, event }) => {
1370
1645
  const description = describeAchievement(event, active);
1371
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "row", children: [
1372
- /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
1646
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "row", children: [
1647
+ /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
1373
1648
  " ",
1374
1649
  formatClockTime(event.at),
1375
1650
  " "
1376
1651
  ] }),
1377
- /* @__PURE__ */ jsxs5(Text6, { color: "yellow", bold: true, children: [
1652
+ /* @__PURE__ */ jsxs6(Text8, { color: "yellow", bold: true, children: [
1378
1653
  "+",
1379
1654
  event.xp,
1380
1655
  " XP "
1381
1656
  ] }),
1382
- /* @__PURE__ */ jsx6(Text6, { children: event.name }),
1383
- /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
1657
+ /* @__PURE__ */ jsx8(Text8, { children: event.name }),
1658
+ /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
1384
1659
  " \u2014 ",
1385
1660
  description
1386
1661
  ] })
@@ -1527,14 +1802,14 @@ async function joinCommand(joinCode) {
1527
1802
  };
1528
1803
  await saveActiveRace(active);
1529
1804
  const initial = await buildInitialState({ active, raceStatus: status, rejoin: isResume });
1530
- 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 }));
1531
1806
  await app.waitUntilExit();
1532
1807
  return 0;
1533
1808
  }
1534
1809
  async function pickHorse(horses) {
1535
1810
  return new Promise((resolve) => {
1536
1811
  const app = render4(
1537
- React7.createElement(HorsePicker, {
1812
+ React9.createElement(HorsePicker, {
1538
1813
  horses,
1539
1814
  onPick: (h) => {
1540
1815
  app.unmount();
@@ -1669,7 +1944,7 @@ var FETCH_TIMEOUT_MS = 5e3;
1669
1944
  async function updateCommand(deps = {}) {
1670
1945
  const fetchImpl = deps.fetchImpl ?? fetch;
1671
1946
  const spawnImpl = deps.spawnImpl ?? spawn;
1672
- const promptYesNo = deps.promptYesNo ?? defaultPromptYesNo;
1947
+ const promptYesNo2 = deps.promptYesNo ?? defaultPromptYesNo;
1673
1948
  let latest;
1674
1949
  try {
1675
1950
  latest = await fetchLatestVersion(fetchImpl);
@@ -1683,7 +1958,7 @@ async function updateCommand(deps = {}) {
1683
1958
  return 0;
1684
1959
  }
1685
1960
  console.log(`Current: ${CLI_VERSION} Latest: ${latest}`);
1686
- const yes = await promptYesNo("Run upgrade now? [y/N]: ");
1961
+ const yes = await promptYesNo2("Run upgrade now? [y/N]: ");
1687
1962
  if (!yes) {
1688
1963
  console.log(`To upgrade manually: ${UPGRADE_CMD}`);
1689
1964
  return 0;
@@ -1732,6 +2007,393 @@ function runNpmUpgrade(spawnImpl) {
1732
2007
  });
1733
2008
  }
1734
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
+
1735
2397
  // src/commands/org-create.ts
1736
2398
  import * as readline6 from "readline/promises";
1737
2399
  import { stdin as stdin6, stdout as stdout6 } from "process";
@@ -1946,6 +2608,10 @@ Races:
1946
2608
  token-derby join <join-code> Join (or resume) a race
1947
2609
  token-derby end <admin-code> End a race early
1948
2610
 
2611
+ Cosmetics:
2612
+ token-derby roll Spend a pending roll to try for a hat.
2613
+ Earn rolls by leveling up horses.
2614
+
1949
2615
  Environment:
1950
2616
  TOKEN_DERBY_API_BASE Override API base URL (default: production)
1951
2617
  TOKEN_DERBY_HOME Override identity/stable directory
@@ -2006,6 +2672,7 @@ async function main() {
2006
2672
  }
2007
2673
  if (cmd === "join") return joinCommand(argv[1]);
2008
2674
  if (cmd === "end") return endCommand(argv[1]);
2675
+ if (cmd === "roll") return rollCommand();
2009
2676
  console.error(`Unknown command: ${cmd}`);
2010
2677
  console.error(HELP);
2011
2678
  return 2;