@thebattlearena/corner 0.8.0 → 0.9.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.
Files changed (59) hide show
  1. package/README.md +38 -5
  2. package/fonts/battle-arena-icons/BattleArenaIcons.ttf +0 -0
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/hooks/icons.generated.ts +2 -0
  6. package/plugin/hooks/pane.tsx +16 -2
  7. package/plugin/hooks/register.tsx +62 -33
  8. package/plugin/hooks/ring-io.ts +59 -1
  9. package/plugin/hooks/ring-pane.tsx +125 -35
  10. package/plugin/hooks/ring.ts +73 -38
  11. package/plugin/hooks/sparring.ts +139 -0
  12. package/plugin/icons/rudo-amber-2x1.png +0 -0
  13. package/plugin/icons/rudo-amber-4x2.png +0 -0
  14. package/plugin/icons/rudo-amber-5x2.png +0 -0
  15. package/plugin/icons/rudo-amber-6x2.png +0 -0
  16. package/plugin/icons/rudo-grey-2x1.png +0 -0
  17. package/plugin/icons/rudo-grey-4x2.png +0 -0
  18. package/plugin/icons/rudo-grey-5x2.png +0 -0
  19. package/plugin/icons/rudo-grey-6x2.png +0 -0
  20. package/plugin/icons/rudo-pink-2x1.png +0 -0
  21. package/plugin/icons/rudo-pink-4x2.png +0 -0
  22. package/plugin/icons/rudo-pink-5x2.png +0 -0
  23. package/plugin/icons/rudo-pink-6x2.png +0 -0
  24. package/plugin/icons/rudo-pink-dim-2x1.png +0 -0
  25. package/plugin/icons/rudo-pink-dim-4x2.png +0 -0
  26. package/plugin/icons/rudo-pink-dim-5x2.png +0 -0
  27. package/plugin/icons/rudo-pink-dim-6x2.png +0 -0
  28. package/plugin/icons/rudo-red-2x1.png +0 -0
  29. package/plugin/icons/rudo-red-4x2.png +0 -0
  30. package/plugin/icons/rudo-red-5x2.png +0 -0
  31. package/plugin/icons/rudo-red-6x2.png +0 -0
  32. package/plugin/tests/ring-pane.test.tsx +154 -0
  33. package/plugin/tests/ring.test.ts +13 -4
  34. package/plugin/tests/sparring.test.ts +204 -0
  35. package/plugin/types/index.d.ts +25 -1
  36. package/ring/.claude-plugin/plugin.json +1 -1
  37. package/ring/agents/rudo.md +95 -0
  38. package/ring/scripts/chronicle.mjs +24 -0
  39. package/ring/scripts/config.mjs +47 -1
  40. package/ring/scripts/fight.mjs +238 -0
  41. package/ring/scripts/judges-card.mjs +53 -9
  42. package/ring/scripts/live-hook.mjs +42 -15
  43. package/ring/scripts/live.mjs +96 -1
  44. package/ring/scripts/mask-guard.mjs +184 -6
  45. package/ring/scripts/monitors.mjs +2 -1
  46. package/ring/scripts/play-by-play.mjs +39 -9
  47. package/ring/scripts/round-bell.mjs +10 -6
  48. package/ring/scripts/sparring-chronicle.mjs +470 -0
  49. package/ring/scripts/sparring.mjs +1161 -0
  50. package/ring/skills/play-by-play/SKILL.md +49 -3
  51. package/ring/skills/play-by-play/templates/chronicle.html +7 -0
  52. package/ring/skills/referee/SKILL.md +46 -2
  53. package/ring/skills/sparring/SKILL.md +408 -0
  54. package/ring/skills/sparring/references/host-signal.md +86 -0
  55. package/ring/skills/sparring/references/lessons.md +50 -0
  56. package/ring/skills/sparring/templates/pr-body.md +34 -0
  57. package/ring/skills/sparring/templates/rudo-brief.md +47 -0
  58. package/ring/skills/sparring/templates/sparring-card.md +33 -0
  59. package/ring/skills/weigh-in/SKILL.md +21 -3
@@ -14,7 +14,8 @@ import { blend, tint } from './colors';
14
14
  import { iconSource } from './icon-files';
15
15
  import { tintedLink } from './label-links';
16
16
  import type { Elements } from './pane';
17
- import type { ChipLook, ChipView, RingView } from './ring';
17
+ import type { ChipLook, ChipView, RingView, StripView } from './ring';
18
+ import type { SparringView } from './sparring';
18
19
  import type { RingHealth } from '../types';
19
20
  import {
20
21
  clockTime,
@@ -31,9 +32,14 @@ import {
31
32
  truncateEnd,
32
33
  } from './ring';
33
34
 
34
- /** Lo que Ringside necesita para pintar el ring (diseño «F · Panel del ring», lámina Cuerpo). */
35
- export type RingPaneModel = {
36
- view: RingView;
35
+ /**
36
+ * Lo que Ringside necesita para pintar una sección del ring (diseño «F · Panel del ring», lámina
37
+ * Cuerpo): un combate o un sparring, con su tira de fichas.
38
+ */
39
+ export type StripPaneModel = {
40
+ view: StripView;
41
+ /** Prefijo de las llaves y del grupo del realce: cada sección tiene el suyo (`ring` el combate). */
42
+ scope?: string;
37
43
  /** La fuente de íconos está instalada: glifos en vez de sus dos letras. */
38
44
  iconFont: boolean;
39
45
  /** Carpeta de los PNG de las fichas (`<plugin>/icons`). */
@@ -66,6 +72,14 @@ export type RingPaneModel = {
66
72
  onOpen: (command: string) => void;
67
73
  };
68
74
 
75
+ /** El combate activo: su cabecera lleva el PR, los rounds, la campana y la crónica. */
76
+ export type RingPaneModel = StripPaneModel & { view: RingView };
77
+
78
+ /** Un sparring: su cabecera lleva la ronda, el último veredicto y sus repos con su PR. */
79
+ export type SparringPaneModel = StripPaneModel & { view: SparringView };
80
+
81
+ const scopeOf = (m: StripPaneModel): string => m.scope ?? 'ring';
82
+
69
83
  const AMBER = 'warning';
70
84
  const PURPLE = '#8B5CF6';
71
85
  const RED = '#E5484D';
@@ -161,7 +175,7 @@ export const ringHost = (items: DayItem[], view: RingView): DayItem | null =>
161
175
  * alguien trabaja, ■ ámbar si te toca decidir, 👊N rojo si Copilot dejó hilos; nada en reposo.
162
176
  */
163
177
  export const pointBadge = (
164
- view: RingView,
178
+ view: StripView,
165
179
  tick: number,
166
180
  iconFont = false,
167
181
  ): { text: string; color: string } | null => {
@@ -182,7 +196,7 @@ export const pointBadge = (
182
196
  };
183
197
 
184
198
  /** El `Image` de la superficie, cuando se puede usar: terminal y sin que el engine lo haya negado. */
185
- const imageOf = (el: Elements, m: RingPaneModel, probing = false) =>
199
+ const imageOf = (el: Elements, m: StripPaneModel, probing = false) =>
186
200
  m.isTerminal &&
187
201
  (probing ? m.imagesDraw !== false : m.imagesDraw === true) &&
188
202
  'Image' in el
@@ -216,19 +230,26 @@ const InlineIcon = (
216
230
 
217
231
  const Glyph = (
218
232
  { Text }: Elements,
219
- m: RingPaneModel,
233
+ m: StripPaneModel,
220
234
  icon: Icon,
221
235
  color?: string,
222
236
  ) => <Text color={color}>{iconText(icon, m.iconFont)}</Text>;
223
237
 
238
+ /** La marca de la cabecera: ■ ámbar si te toca, el spinner si alguien trabaja, ⁝ en reposo. */
239
+ const headerMark = (m: StripPaneModel) =>
240
+ m.view.waiting
241
+ ? { text: ic('■'), color: AMBER }
242
+ : {
243
+ text: m.view.running ? spinnerFrame(m.tick) : '⁝',
244
+ color: fills(m.light).current,
245
+ };
246
+
224
247
  /** (a) Cabecera: spinner · #PR · rounds y su barra; a la derecha la última campana y la crónica. */
225
248
  const Header = (el: Elements, m: RingPaneModel, width: number) => {
226
249
  const { Box, Link, Text } = el;
227
250
  const { view } = m;
228
251
  const tone = fills(m.light);
229
- const mark = view.waiting
230
- ? { text: ic('■'), color: AMBER }
231
- : { text: view.running ? spinnerFrame(m.tick) : '⁝', color: tone.current };
252
+ const mark = headerMark(m);
232
253
  const cells =
233
254
  view.rounds &&
234
255
  roundCells(view.rounds.fought, view.rounds.total, ROUND_CELLS);
@@ -274,6 +295,45 @@ const Header = (el: Elements, m: RingPaneModel, width: number) => {
274
295
  );
275
296
  };
276
297
 
298
+ /**
299
+ * (a) Cabecera de un sparring: marca · ronda; a la derecha el último veredicto; debajo, un renglón
300
+ * por repo con su PR (borrador) o «sin PR».
301
+ */
302
+ const SparringHeader = (el: Elements, m: SparringPaneModel, width: number) => {
303
+ const { Box, Text } = el;
304
+ const { view } = m;
305
+ const mark = headerMark(m);
306
+ return (
307
+ <Box flexDirection="column" width={width}>
308
+ <Box justifyContent="space-between" width={width}>
309
+ <Box gap={1}>
310
+ <Text color={mark.color} bold>
311
+ {mark.text}
312
+ </Text>
313
+ <Text bold>{`ronda ${view.round}`}</Text>
314
+ </Box>
315
+ <Text dimColor wrap="truncate-end">
316
+ {view.verdict
317
+ ? `último veredicto: ${view.verdict.call}`
318
+ : 'sin veredicto'}
319
+ </Text>
320
+ </Box>
321
+ {view.repos.map((repo) => (
322
+ <Box key={`${scopeOf(m)}-repo-${repo.repo}`} gap={1} width={width}>
323
+ <Text dimColor wrap="truncate-end">
324
+ {repo.repo}
325
+ </Text>
326
+ {repo.pr && repo.url ? (
327
+ tintedLink(el, repo.url, `#${repo.pr}`, { color: PALETTE.blue })
328
+ ) : (
329
+ <Text dimColor>sin PR</Text>
330
+ )}
331
+ </Box>
332
+ ))}
333
+ </Box>
334
+ );
335
+ };
336
+
277
337
  /** La barra de acento a la izquierda de una tarjeta. */
278
338
  const Accent = ({ Text }: Elements, color: string) => (
279
339
  <Text color={color}>▌</Text>
@@ -283,7 +343,7 @@ const Accent = ({ Text }: Elements, color: string) => (
283
343
  * (b) La línea en vivo: tarjeta a todo lo ancho con acento a la izquierda. Trabajando, azul;
284
344
  * Copilot golpeó, rojo; te toca decidir, ámbar lleno con el tiempo; en reposo, desde cuándo.
285
345
  */
286
- const LiveCard = (el: Elements, m: RingPaneModel, width: number) => {
346
+ const LiveCard = (el: Elements, m: StripPaneModel, width: number) => {
287
347
  const { Box, Text } = el;
288
348
  const live = m.view.live;
289
349
  const tone = fills(m.light);
@@ -462,8 +522,8 @@ export const tileLook = (
462
522
  * des-invierte (probado en Ghostty con un diagnóstico de cuatro variantes; esta y la de Box con llave
463
523
  * invierten la ficha entera).
464
524
  */
465
- export const tileHover = (chip: ChipView) => ({
466
- scope: `ring-tile-${chip.id}`,
525
+ export const tileHover = (chip: ChipView, scope = 'ring') => ({
526
+ scope: `${scope}-tile-${chip.id}`,
467
527
  inverse: true,
468
528
  bold: true,
469
529
  });
@@ -471,7 +531,7 @@ export const tileHover = (chip: ChipView) => ({
471
531
  /** El ícono de una ficha: imagen sólo si la terminal las dibuja; si no, el glifo centrado. */
472
532
  const ChipIcon = (
473
533
  el: Elements,
474
- m: RingPaneModel,
534
+ m: StripPaneModel,
475
535
  chip: ChipView,
476
536
  size: { cols: number; rows: number },
477
537
  look: ChipLook,
@@ -483,7 +543,7 @@ const ChipIcon = (
483
543
  if (Image && chip.icon.png && hasShape(zone))
484
544
  return (
485
545
  <Image
486
- key={`ring-icon-${chip.id}`}
546
+ key={`${scopeOf(m)}-icon-${chip.id}`}
487
547
  source={iconSource(m.iconDir, chipPng(chip, color, look, zone))}
488
548
  columns={zone.cols}
489
549
  rows={zone.rows}
@@ -497,7 +557,7 @@ const ChipIcon = (
497
557
  <Text
498
558
  color={tile.glyph}
499
559
  backgroundColor={tile.fill}
500
- hover={tileHover(chip)}
560
+ hover={tileHover(chip, scopeOf(m))}
501
561
  bold
502
562
  >
503
563
  {marked && <Text color={tile.hex}>▸</Text>}
@@ -514,9 +574,10 @@ const ChipIcon = (
514
574
  * una fila cada uno, el del ícono y el del indicador: un botón no puede tener dos renglones). La
515
575
  * abierta lleva el relleno más fuerte y ▾ debajo.
516
576
  */
517
- const Chips = (el: Elements, m: RingPaneModel, width: number) => {
577
+ const Chips = (el: Elements, m: StripPaneModel, width: number) => {
518
578
  const { Box, Button, Text } = el;
519
579
  const tone = fills(m.light);
580
+ const scope = scopeOf(m);
520
581
  const size = chipSize(width, m.view.chips.length);
521
582
  return (
522
583
  <Box columnGap={size.gap} justifyContent="center" width={width}>
@@ -535,7 +596,7 @@ const Chips = (el: Elements, m: RingPaneModel, width: number) => {
535
596
  ? COLORS.delivered
536
597
  : tile.dot
537
598
  : tile.hex;
538
- const hover = tileHover(chip);
599
+ const hover = tileHover(chip, scope);
539
600
  const health = healthIndicator(chip, m.tick, m.iconFont);
540
601
  // Toda la ficha es UN botón: cada renglón es un botón `plain` con el mismo clic, su texto mide
541
602
  // el ancho de la ficha y lleva su mismo fondo (ningún renglón se ve distinto en reposo), y el
@@ -546,7 +607,7 @@ const Chips = (el: Elements, m: RingPaneModel, width: number) => {
546
607
  </Button>
547
608
  );
548
609
  const indicator = row(
549
- `ring-chip-${chip.id}`,
610
+ `${scope}-chip-${chip.id}`,
550
611
  <Text
551
612
  color={health?.color ?? indicatorColor}
552
613
  backgroundColor={tile.fill}
@@ -557,24 +618,27 @@ const Chips = (el: Elements, m: RingPaneModel, width: number) => {
557
618
  </Text>,
558
619
  );
559
620
  const top = row(
560
- `ring-chip-${chip.id}-top`,
621
+ `${scope}-chip-${chip.id}-top`,
561
622
  <Text backgroundColor={tile.fill} hover={hover}>
562
623
  {' '.repeat(size.box)}
563
624
  </Text>,
564
625
  );
565
626
  const icon = image
566
627
  ? ChipIcon(el, m, chip, size, look)
567
- : row(`ring-chip-${chip.id}-icon`, ChipIcon(el, m, chip, size, look));
628
+ : row(
629
+ `${scope}-chip-${chip.id}-icon`,
630
+ ChipIcon(el, m, chip, size, look),
631
+ );
568
632
  return (
569
633
  <Box
570
- key={`ring-actor-${chip.id}`}
634
+ key={`${scope}-actor-${chip.id}`}
571
635
  flexDirection="column"
572
636
  width={size.box}
573
637
  flexShrink={0}
574
638
  >
575
639
  {/* Todas iguales: margen, ícono e indicador, con el mismo alto en todo estado. */}
576
640
  <Box
577
- key={`ring-tile-${chip.id}`}
641
+ key={`${scope}-tile-${chip.id}`}
578
642
  flexDirection="column"
579
643
  backgroundColor={tile.fill}
580
644
  width={size.box}
@@ -599,14 +663,14 @@ const Chips = (el: Elements, m: RingPaneModel, width: number) => {
599
663
  const OpenAction = (
600
664
  { Button, Text }: Elements,
601
665
  chip: ChipView,
602
- m: RingPaneModel,
666
+ m: StripPaneModel,
603
667
  ) => {
604
668
  const open = chip.open;
605
669
  if (!open) return null;
606
670
  if ('command' in open && open.canRun)
607
671
  return (
608
672
  <Button
609
- key={`ring-open-${chip.id}`}
673
+ key={`${scopeOf(m)}-open-${chip.id}`}
610
674
  label={`abrir ${ic('↗')}`}
611
675
  variant="primary"
612
676
  onPress={() => m.onOpen(open.command)}
@@ -657,7 +721,7 @@ const rowsOf = (chip: ChipView): [string, string][] => {
657
721
  const Detail = (
658
722
  el: Elements,
659
723
  chip: ChipView,
660
- m: RingPaneModel,
724
+ m: StripPaneModel,
661
725
  width: number,
662
726
  ) => {
663
727
  const { Box, Text } = el;
@@ -713,7 +777,7 @@ const Detail = (
713
777
  {rows.length > 0 && (
714
778
  <Box flexDirection="column" marginTop={1}>
715
779
  {rows.map(([key, value]) => (
716
- <Box key={`ring-field-${key}`} width={inner}>
780
+ <Box key={`${scopeOf(m)}-field-${key}`} width={inner}>
717
781
  <Box width={LABEL_WIDTH} flexShrink={0}>
718
782
  <Text dimColor>{key}</Text>
719
783
  </Box>
@@ -758,8 +822,13 @@ const Detail = (
758
822
  );
759
823
  };
760
824
 
761
- /** El bloque desplegado: cabecera, línea en vivo, fichas y la ficha abierta. */
762
- const Block = (el: Elements, m: RingPaneModel, width: number) => {
825
+ /** El bloque desplegado: cabecera (la de un combate o la de un sparring), línea en vivo, fichas y la ficha abierta. */
826
+ const Block = <M extends StripPaneModel>(
827
+ el: Elements,
828
+ m: M,
829
+ width: number,
830
+ header: (el: Elements, m: M, width: number) => JSX.Element,
831
+ ) => {
763
832
  const { Box } = el;
764
833
  const chip = m.view.chips.find((c) => c.id === m.selected);
765
834
  const inner = width - 2;
@@ -772,7 +841,7 @@ const Block = (el: Elements, m: RingPaneModel, width: number) => {
772
841
  paddingTop={1}
773
842
  width={width}
774
843
  >
775
- {Header(el, m, inner)}
844
+ {header(el, m, inner)}
776
845
  {LiveCard(el, m, inner)}
777
846
  {Chips(el, m, inner)}
778
847
  {chip && Detail(el, chip, m, inner)}
@@ -792,32 +861,53 @@ export const RingRow = (
792
861
  m: RingPaneModel,
793
862
  width: number,
794
863
  indent = 0,
864
+ ) => Section(el, m, width, indent, `Revisión #${m.view.pr}`, Header);
865
+
866
+ /**
867
+ * La sección de un sparring activo, como la de un combate: «▸ Sparring <slug>» en su banda y,
868
+ * desplegada, su ronda, su último veredicto, sus repos con su PR y la tira de árbitro, rudo y juez.
869
+ */
870
+ export const SparringRow = (
871
+ el: Elements,
872
+ m: SparringPaneModel,
873
+ width: number,
874
+ ) => Section(el, m, width, 0, `Sparring ${m.view.slug}`, SparringHeader);
875
+
876
+ /** El título con banda (indicador y tiempo de hoy a la derecha) y, desplegado, el bloque. */
877
+ const Section = <M extends StripPaneModel>(
878
+ el: Elements,
879
+ m: M,
880
+ width: number,
881
+ indent: number,
882
+ title: string,
883
+ header: (el: Elements, m: M, width: number) => JSX.Element,
795
884
  ) => {
796
885
  const { Box, Button, Text } = el;
797
886
  const { view } = m;
887
+ const scope = scopeOf(m);
798
888
  const tone = fills(m.light);
799
889
  const minutes = Math.round(view.todayMs / 60_000);
800
890
  const badge = pointBadge(view, m.tick, m.iconFont);
801
891
  return (
802
- <Box key="ring-section" flexDirection="column" width={width}>
892
+ <Box key={`${scope}-section`} flexDirection="column" width={width}>
803
893
  <Box
804
- key="ring-title"
894
+ key={`${scope}-title`}
805
895
  backgroundColor={tone.band}
806
896
  justifyContent="space-between"
807
897
  paddingLeft={Math.max(1, indent)}
808
898
  paddingRight={1}
809
899
  width={width}
810
900
  >
811
- <Button key="ring-row" plain onPress={() => m.onRow()}>
901
+ <Button key={`${scope}-row`} plain onPress={() => m.onRow()}>
812
902
  <Text color={COLORS.blue}>{`${ic(m.isOpen ? '▾' : '▸')} `}</Text>
813
- <Text bold>{`Revisión #${view.pr}`}</Text>
903
+ <Text bold>{title}</Text>
814
904
  </Button>
815
905
  <Text>
816
906
  {badge && <Text color={badge.color}>{`${badge.text} `}</Text>}
817
907
  <Text dimColor>{duration(minutes) || ''}</Text>
818
908
  </Text>
819
909
  </Box>
820
- {m.isOpen && Block(el, m, width)}
910
+ {m.isOpen && Block(el, m, width, header)}
821
911
  </Box>
822
912
  );
823
913
  };
@@ -83,15 +83,11 @@ export const lookOf = (chip: ChipView): ChipLook => {
83
83
  return 'quiet';
84
84
  };
85
85
 
86
- export type RingView = {
87
- slug: string;
88
- pr: number;
89
- prUrl: string;
90
- /** Rounds de la defensa del título; `null` mientras no hay. */
91
- rounds: { fought: number; total: number } | null;
92
- /** La última round-bell: su PR si se sabe, si no su número de round. */
93
- bell: { pr: number | null; url: string | null; round: number } | null;
94
- chronicleUrl: string | null;
86
+ /**
87
+ * Lo que comparten un combate y un sparring: la tira de fichas, la línea en vivo y si alguien
88
+ * trabaja o te espera. Sale de `deriveStrip`.
89
+ */
90
+ export type StripView = {
95
91
  /** Alguien trabaja ahora (gira el spinner). */
96
92
  running: boolean;
97
93
  /** Una decisión espera al humano (el spinner se vuelve ■ ámbar). */
@@ -115,8 +111,21 @@ export type RingView = {
115
111
  health: 'warn' | 'fail' | null;
116
112
  };
117
113
 
118
- /** Los actores del ring, en el orden de la tira. */
119
- export const CORE_CHIPS: { id: RingActorId & IconName; name: string }[] = [
114
+ export type RingView = StripView & {
115
+ slug: string;
116
+ pr: number;
117
+ prUrl: string;
118
+ /** Rounds de la defensa del título; `null` mientras no hay. */
119
+ rounds: { fought: number; total: number } | null;
120
+ /** La última round-bell: su PR si se sabe, si no su número de round. */
121
+ bell: { pr: number | null; url: string | null; round: number } | null;
122
+ chronicleUrl: string | null;
123
+ };
124
+
125
+ export type ActorChip = { id: RingActorId & IconName; name: string };
126
+
127
+ /** Los actores de un combate, en el orden de la tira. */
128
+ export const CORE_CHIPS: ActorChip[] = [
120
129
  { id: 'arbitro', name: 'árbitro' },
121
130
  { id: 'luchador', name: 'luchador' },
122
131
  { id: 'esquinero', name: 'esquinero' },
@@ -125,7 +134,17 @@ export const CORE_CHIPS: { id: RingActorId & IconName; name: string }[] = [
125
134
  { id: 'juez', name: 'juez' },
126
135
  ];
127
136
 
128
- const CORE_IDS: string[] = CORE_CHIPS.map((chip) => chip.id);
137
+ /** Los de un sparring: el árbitro lo abre, el rudo escribe el borrador y el juez dicta. */
138
+ export const SPARRING_CHIPS: ActorChip[] = [
139
+ { id: 'arbitro', name: 'árbitro' },
140
+ { id: 'rudo', name: 'rudo' },
141
+ { id: 'juez', name: 'juez' },
142
+ ];
143
+
144
+ /** Todos los actores del ring: ningún monitor puede llamarse así. */
145
+ const CORE_IDS: string[] = [
146
+ ...new Set([...CORE_CHIPS, ...SPARRING_CHIPS].map((chip) => chip.id)),
147
+ ];
129
148
  const STATES = ['active', 'waiting', 'done', 'failed'];
130
149
  const OPEN_STATES = ['active', 'waiting'];
131
150
  /** Un «activo» sin cierre después de esto se da por perdido (la sesión murió a medio trabajo). */
@@ -497,13 +516,22 @@ const todayOf = (events: RingEvent[], now: number, running: boolean) => {
497
516
  return (running ? now : Math.max(...today)) - Math.min(...today);
498
517
  };
499
518
 
500
- /** El combate → lo que pinta el panel: cabecera, línea en vivo y la tira de chips. */
501
- export const deriveRing = (bout: RingBout, now: number): RingView => {
519
+ /**
520
+ * Los eventos de un feed en vivo → la tira de fichas (los actores dados y los monitores), la línea en
521
+ * vivo y quién trabaja. Lo comparten el combate y el sparring.
522
+ */
523
+ export const deriveStrip = (
524
+ events: RingEvent[],
525
+ actors: ActorChip[],
526
+ monitors: RingMonitor[],
527
+ commands: string[],
528
+ now: number,
529
+ ): StripView => {
502
530
  const bySource = new Map<string, RingEvent[]>();
503
- for (const e of bout.events)
531
+ for (const e of events)
504
532
  bySource.set(sourceOf(e), [...(bySource.get(sourceOf(e)) ?? []), e]);
505
533
  const chips = [
506
- ...CORE_CHIPS.map((chip) =>
534
+ ...actors.map((chip) =>
507
535
  chipOf(
508
536
  {
509
537
  id: chip.id,
@@ -516,7 +544,7 @@ export const deriveRing = (bout: RingBout, now: number): RingView => {
516
544
  null,
517
545
  ),
518
546
  ),
519
- ...bout.monitors.map((m) =>
547
+ ...monitors.map((m) =>
520
548
  chipOf(
521
549
  {
522
550
  id: m.id,
@@ -526,7 +554,7 @@ export const deriveRing = (bout: RingBout, now: number): RingView => {
526
554
  },
527
555
  bySource.get(`monitor:${m.id}`) ?? [],
528
556
  now,
529
- openFor(m, bout.commands),
557
+ openFor(m, commands),
530
558
  ),
531
559
  ),
532
560
  ];
@@ -549,26 +577,7 @@ export const deriveRing = (bout: RingBout, now: number): RingView => {
549
577
  : lookOf(lead) === 'hit'
550
578
  ? 'hit'
551
579
  : 'idle';
552
- const bellEvent = [...bout.events]
553
- .reverse()
554
- .find((e) => e.actor === 'campana' && e.pr);
555
- const hasBell = bout.bells > 0 || !!bellEvent;
556
580
  return {
557
- slug: bout.slug,
558
- pr: bout.pr,
559
- prUrl: `https://github.com/${bout.slug}/pull/${bout.pr}`,
560
- rounds: bout.defense && {
561
- fought: bout.defense.fought,
562
- total: bout.defense.limit,
563
- },
564
- bell: hasBell
565
- ? {
566
- pr: bellEvent?.pr ?? bout.bellPr?.pr ?? null,
567
- url: bellEvent?.url ?? bout.bellPr?.url ?? null,
568
- round: bout.bells,
569
- }
570
- : null,
571
- chronicleUrl: bout.playByPlayUrl,
572
581
  running: working.length > 0,
573
582
  waiting,
574
583
  live: lead?.label
@@ -584,7 +593,7 @@ export const deriveRing = (bout: RingBout, now: number): RingView => {
584
593
  severity: lead.severity,
585
594
  }
586
595
  : null,
587
- todayMs: todayOf(bout.events, now, working.length > 0),
596
+ todayMs: todayOf(events, now, working.length > 0),
588
597
  chips,
589
598
  health: chips.some((c) => c.health?.level === 'fail')
590
599
  ? 'fail'
@@ -593,3 +602,29 @@ export const deriveRing = (bout: RingBout, now: number): RingView => {
593
602
  : null,
594
603
  };
595
604
  };
605
+
606
+ /** El combate → lo que pinta el panel: cabecera, línea en vivo y la tira de chips. */
607
+ export const deriveRing = (bout: RingBout, now: number): RingView => {
608
+ const bellEvent = [...bout.events]
609
+ .reverse()
610
+ .find((e) => e.actor === 'campana' && e.pr);
611
+ const hasBell = bout.bells > 0 || !!bellEvent;
612
+ return {
613
+ slug: bout.slug,
614
+ pr: bout.pr,
615
+ prUrl: `https://github.com/${bout.slug}/pull/${bout.pr}`,
616
+ rounds: bout.defense && {
617
+ fought: bout.defense.fought,
618
+ total: bout.defense.limit,
619
+ },
620
+ bell: hasBell
621
+ ? {
622
+ pr: bellEvent?.pr ?? bout.bellPr?.pr ?? null,
623
+ url: bellEvent?.url ?? bout.bellPr?.url ?? null,
624
+ round: bout.bells,
625
+ }
626
+ : null,
627
+ chronicleUrl: bout.playByPlayUrl,
628
+ ...deriveStrip(bout.events, CORE_CHIPS, bout.monitors, bout.commands, now),
629
+ };
630
+ };
@@ -0,0 +1,139 @@
1
+ import type { RingEvent, RingSparring, RingSparringRepo } from '../types';
2
+ import type { StripView } from './ring';
3
+ import { deriveStrip, SPARRING_CHIPS } from './ring';
4
+
5
+ /**
6
+ * Los sparrings del plugin `ring` en Ringside: de `sparring.json` y su `live.jsonl` sale una sección
7
+ * parecida a la de un combate, con su tira de actores (árbitro, rudo, juez), sus repos con su PR, la
8
+ * ronda y el último veredicto. Todo aquí es puro.
9
+ */
10
+
11
+ /** Los veredictos que cierran un sparring: después ya no se pinta. */
12
+ export const FINAL_VERDICTS = ['ko', 'discard'];
13
+
14
+ /**
15
+ * Un sparring sin tocar en este tiempo ya no es «activo» aunque nadie haya dictado: el timebox de un
16
+ * sparring es de 3 h como mucho, y el juez lo prueba el mismo día. Tocarlo = cualquier escritura de
17
+ * `sparring.json`, `card.md` o `live.jsonl` (un comando del sparring, el rudo, una pregunta).
18
+ */
19
+ export const SPARRING_FRESH_MS = 12 * 3_600_000;
20
+
21
+ /** Lo que Ringside lee de `sparring.json`; todo opcional porque un archivo malo no tumba el panel. */
22
+ export type SparringFile = {
23
+ slug?: unknown;
24
+ verdict?: { call?: unknown; note?: unknown } | null;
25
+ history?: unknown;
26
+ repos?: unknown;
27
+ sessions?: unknown;
28
+ };
29
+
30
+ /** Un sparring tal como se encontró: su archivo y cuándo se tocó por última vez (ms). */
31
+ export type FoundSparring = { state: SparringFile; touched: number };
32
+
33
+ const text = (value: unknown): string | null =>
34
+ typeof value === 'string' && value.trim() ? value.trim() : null;
35
+
36
+ /** ¿Sigue en pelea? Sin veredicto final y tocado dentro de `SPARRING_FRESH_MS`. */
37
+ export const isActiveSparring = (
38
+ state: SparringFile,
39
+ touched: number,
40
+ now: number,
41
+ ): boolean =>
42
+ !!text(state.slug) &&
43
+ !FINAL_VERDICTS.includes(String(state.verdict?.call ?? '')) &&
44
+ now - touched < SPARRING_FRESH_MS;
45
+
46
+ const reposOf = (state: SparringFile) =>
47
+ Array.isArray(state.repos)
48
+ ? (state.repos as Record<string, unknown>[]).filter(
49
+ (entry) => !!entry && typeof entry === 'object',
50
+ )
51
+ : [];
52
+
53
+ const sessionIds = (state: SparringFile): string[] =>
54
+ Array.isArray(state.sessions)
55
+ ? (state.sessions as { id?: unknown }[])
56
+ .map((session) => session?.id)
57
+ .filter((id): id is string => typeof id === 'string')
58
+ : [];
59
+
60
+ const within = (dir: string, path: string): boolean =>
61
+ path === dir || path.startsWith(`${dir.replace(/\/+$/, '')}/`);
62
+
63
+ /**
64
+ * Qué sparrings activos muestra esta sesión: los que ella registró (corrió `sparring.mjs` en ellos);
65
+ * si no registró ninguno, los que trabajan en su carpeta (algún `repos[].worktree` la contiene). Del
66
+ * más reciente al más viejo.
67
+ */
68
+ export const sparringsFor = <T extends FoundSparring>(
69
+ found: T[],
70
+ {
71
+ sessionId,
72
+ root,
73
+ now,
74
+ }: { sessionId: string | null; root: string; now: number },
75
+ ): T[] => {
76
+ const active = found
77
+ .filter((s) => isActiveSparring(s.state, s.touched, now))
78
+ .sort((a, b) => b.touched - a.touched);
79
+ const own = active.filter(
80
+ (s) => !!sessionId && sessionIds(s.state).includes(sessionId),
81
+ );
82
+ if (own.length) return own;
83
+ return active.filter((s) =>
84
+ reposOf(s.state).some(
85
+ (entry) =>
86
+ typeof entry.worktree === 'string' && within(entry.worktree, root),
87
+ ),
88
+ );
89
+ };
90
+
91
+ /** La ronda que se pelea: los veredictos dictados + 1 (los de antes de `history`, por su veredicto). */
92
+ export const roundOf = (state: SparringFile): number => {
93
+ const history = Array.isArray(state.history)
94
+ ? state.history.length
95
+ : state.verdict
96
+ ? 1
97
+ : 0;
98
+ return history + 1;
99
+ };
100
+
101
+ /** `sparring.json` + los eventos de su feed → lo que lleva el snapshot. */
102
+ export const sparringOf = (
103
+ state: SparringFile,
104
+ events: RingEvent[],
105
+ ): RingSparring => {
106
+ const call = text(state.verdict?.call);
107
+ return {
108
+ slug: text(state.slug) ?? '',
109
+ repos: reposOf(state).map(
110
+ (entry): RingSparringRepo => ({
111
+ repo: text(entry.repo) ?? '?',
112
+ pr: typeof entry.pr === 'number' ? entry.pr : null,
113
+ url: text(entry.url),
114
+ }),
115
+ ),
116
+ round: roundOf(state),
117
+ verdict: call ? { call, note: text(state.verdict?.note) ?? '' } : null,
118
+ events,
119
+ };
120
+ };
121
+
122
+ export type SparringView = StripView & {
123
+ slug: string;
124
+ repos: RingSparringRepo[];
125
+ round: number;
126
+ verdict: { call: string; note: string } | null;
127
+ };
128
+
129
+ /** El sparring → lo que pinta su sección: la tira de árbitro, rudo y juez, sus repos y la ronda. */
130
+ export const deriveSparring = (
131
+ sparring: RingSparring,
132
+ now: number,
133
+ ): SparringView => ({
134
+ slug: sparring.slug,
135
+ repos: sparring.repos,
136
+ round: sparring.round,
137
+ verdict: sparring.verdict,
138
+ ...deriveStrip(sparring.events, SPARRING_CHIPS, [], [], now),
139
+ });
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file