@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
@@ -0,0 +1,470 @@
1
+ // The pure core of a fight's chronicle when it starts at a sparring: two out of three falls. The
2
+ // first fall is the rudo against the judge (rounds closed by each verdict), the second is the
3
+ // defense of the técnicos (vestidor, the team's Tapes, the bell, the mask), the third is the referee
4
+ // bout. It turns the sparring's records into the same timed events as chronicle.mjs (the eight
5
+ // types, classified at the top of that file) and never invents a time. PURE and zero-dependency.
6
+ import { isTime } from './chronicle.mjs';
7
+
8
+ /**
9
+ * The moments of a fight that only the referee of the sparring can mark (`sparring.mjs mark`), with
10
+ * the chronicle type each one becomes.
11
+ */
12
+ export const MILESTONES = Object.freeze({
13
+ vestidor: 'bateria',
14
+ defense: 'juez',
15
+ bell: 'campana',
16
+ 'mask-kept': 'juez',
17
+ 'mask-lost': 'juez',
18
+ 'no-team': 'juez',
19
+ referee: 'arbitro',
20
+ });
21
+
22
+ const MILESTONE_TITLES = {
23
+ vestidor: 'vestidor (the host gates passed)',
24
+ defense: 'defense of the técnicos opened',
25
+ bell: 'bell of the defense',
26
+ 'mask-kept': 'the rudo keeps the mask',
27
+ 'mask-lost': 'the rudo loses the mask',
28
+ 'no-team': 'no second fall: the judge goes on without the team',
29
+ referee: 'handed to the referee',
30
+ };
31
+
32
+ /** Milestones that open the second fall, even before a KO is recorded. */
33
+ const SECOND_FALL = ['vestidor', 'defense', 'bell', 'mask-kept', 'mask-lost'];
34
+
35
+ /**
36
+ * What a bout opened `--from-sparring` should warn about, or null. PURE. After the KO the sparring
37
+ * goes through the vestidor (the host gates, then the team's defense) unless the judge said «sin
38
+ * equipo» (`no-team`); with neither marked, the branch reaches the referee unchecked.
39
+ */
40
+ export function handOffWarning(state) {
41
+ const marked = (state.milestones ?? []).map((m) => m.kind);
42
+ if (['vestidor', 'no-team'].some((kind) => marked.includes(kind)))
43
+ return null;
44
+ return `sparring "${state.slug}" reaches the referee without the vestidor (\`sparring.mjs vestidor ${state.slug}\`) and without «sin equipo» (\`sparring.mjs mark ${state.slug} no-team\`): its branch never passed the host gates`;
45
+ }
46
+ export const FALLS = Object.freeze([
47
+ { n: 1, name: 'first fall · the rudo against the judge' },
48
+ { n: 2, name: 'second fall · the defense of the técnicos' },
49
+ { n: 3, name: 'third fall · the referee' },
50
+ ]);
51
+
52
+ /** Throws unless `kind` is a milestone. PURE. */
53
+ export function checkMilestone(kind) {
54
+ if (!Object.hasOwn(MILESTONES, kind))
55
+ throw new Error(
56
+ `"${kind}" is not a fight milestone (${Object.keys(MILESTONES).join(', ')})`,
57
+ );
58
+ return kind;
59
+ }
60
+
61
+ /** The milestone appended to a sparring's state, at the time it is marked. PURE. */
62
+ export function withMilestone(
63
+ state,
64
+ kind,
65
+ note,
66
+ at = new Date().toISOString(),
67
+ ) {
68
+ checkMilestone(kind);
69
+ const milestone = { kind, at, ...(note ? { note } : {}) };
70
+ return { ...state, milestones: [...(state.milestones ?? []), milestone] };
71
+ }
72
+
73
+ /**
74
+ * Records a chronicle's link on a bout's or a sparring's state, and stamps `playByPlay.publishedAt`
75
+ * on every publish to it. The same link again is a republish; a different one is refused. PURE.
76
+ */
77
+ export function withChronicleUrl(
78
+ state,
79
+ url,
80
+ publishedAt = new Date().toISOString(),
81
+ owner = 'this bout',
82
+ ) {
83
+ if (!/^https:\/\/claude\.ai\//.test(String(url)))
84
+ throw new Error('the play-by-play link must be a claude.ai artifact URL');
85
+ if (state.playByPlayUrl && state.playByPlayUrl !== url)
86
+ throw new Error(
87
+ `${owner} already has its chronicle at ${state.playByPlayUrl}; republish to that link instead of creating another`,
88
+ );
89
+ return { ...state, playByPlayUrl: url, playByPlay: { publishedAt } };
90
+ }
91
+
92
+ /** The verdicts of a sparring, oldest first: `history`, else the single `verdict`. PURE. */
93
+ export const verdictsOf = (state) =>
94
+ state.history ?? (state.verdict ? [state.verdict] : []);
95
+
96
+ const tapeUrl = (text) =>
97
+ /https?:\/\/\S*\/t\/[\w-]+/.exec(String(text ?? ''))?.[0] ?? null;
98
+
99
+ /** The sparring opening, its verdicts and its milestones as events. PURE. */
100
+ export function stateEvents(state) {
101
+ const source = 'sparring.json';
102
+ const events = [];
103
+ if (isTime(state.openedAt))
104
+ events.push({
105
+ at: state.openedAt,
106
+ type: 'arbitro',
107
+ title: 'sparring opened',
108
+ detail: state.hypothesis ?? '',
109
+ ref: state.slug ?? null,
110
+ source,
111
+ });
112
+ for (const verdict of verdictsOf(state).filter((v) => isTime(v.at)))
113
+ events.push({
114
+ at: verdict.at,
115
+ type: 'juez',
116
+ title: `verdict ${verdict.call}`,
117
+ detail: verdict.note ?? '',
118
+ ref: tapeUrl(verdict.note),
119
+ source,
120
+ call: verdict.call,
121
+ });
122
+ for (const milestone of state.milestones ?? []) {
123
+ if (!isTime(milestone.at) || !Object.hasOwn(MILESTONES, milestone.kind))
124
+ continue;
125
+ events.push({
126
+ at: milestone.at,
127
+ type: MILESTONES[milestone.kind],
128
+ title: MILESTONE_TITLES[milestone.kind],
129
+ detail: milestone.note ?? '',
130
+ ref: null,
131
+ source,
132
+ milestone: milestone.kind,
133
+ });
134
+ }
135
+ for (const entry of state.repos ?? []) {
136
+ const preview = entry.preview;
137
+ if (!preview || !isTime(preview.checkedAt)) continue;
138
+ events.push(previewEvent(entry.repo, preview.checkedAt, preview));
139
+ }
140
+ return events;
141
+ }
142
+
143
+ const previewEvent = (repo, at, { state, url, environment }) => ({
144
+ at,
145
+ type: 'bateria',
146
+ title: `preview ${repo}`,
147
+ detail: [state ?? (url ? 'success' : 'unknown'), environment, url]
148
+ .filter(Boolean)
149
+ .join(' · '),
150
+ ref: url ?? null,
151
+ source: 'sparring.json',
152
+ });
153
+
154
+ /** `PR <owner/repo>#<n>`: the same title from the feed and from GitHub, so they merge. PURE. */
155
+ const prTitle = (repo, number) => `PR ${repo}#${number}`;
156
+
157
+ /** Each sparring PR opened, from `gh pr view --json createdAt,url`. PURE. */
158
+ export function prOpenedEvents(prs) {
159
+ return prs
160
+ .filter((pr) => isTime(pr.createdAt))
161
+ .map((pr) => ({
162
+ at: pr.createdAt,
163
+ type: 'dado',
164
+ title: prTitle(pr.repo, pr.number),
165
+ detail: 'draft opened with the sparring and do-not-merge labels',
166
+ ref: pr.url ?? null,
167
+ source: 'gh:pr',
168
+ }));
169
+ }
170
+
171
+ /**
172
+ * The referee's own lines of the sparring's live feed (`live.jsonl`): repos added, PRs opened and
173
+ * each preview look. The rest of the feed (tool calls, verdicts, milestones) is either noise or
174
+ * already in sparring.json. The feed keeps only its last lines: what it trimmed is simply absent.
175
+ * PURE.
176
+ */
177
+ export function feedEvents(text) {
178
+ const events = [];
179
+ for (const line of String(text ?? '').split('\n')) {
180
+ let raw;
181
+ try {
182
+ raw = JSON.parse(line);
183
+ } catch {
184
+ continue;
185
+ }
186
+ if (raw?.actor !== 'arbitro' || !isTime(raw.at)) continue;
187
+ const base = { at: raw.at, ref: raw.url ?? null, source: 'live.jsonl' };
188
+ if (raw.label === 'sumó un repo')
189
+ events.push({
190
+ ...base,
191
+ type: 'arbitro',
192
+ title: 'repo added',
193
+ detail: raw.detail ?? '',
194
+ });
195
+ if (raw.label === 'abrió el PR' && raw.pr)
196
+ events.push({
197
+ ...base,
198
+ type: 'dado',
199
+ title: prTitle(raw.detail, raw.pr),
200
+ detail: 'draft opened with the sparring and do-not-merge labels',
201
+ });
202
+ if (['preview lista', 'sin preview'].includes(raw.label))
203
+ events.push({
204
+ ...base,
205
+ type: 'bateria',
206
+ title: `preview ${raw.detail}`,
207
+ detail: [raw.url ? 'success' : raw.reason, raw.url]
208
+ .filter(Boolean)
209
+ .join(' · '),
210
+ });
211
+ }
212
+ return events;
213
+ }
214
+
215
+ /**
216
+ * The same event told by two records (the feed and GitHub, the feed and sparring.json) is kept
217
+ * once, the first: same type and title within `windowMs`. PURE.
218
+ */
219
+ export function dedupe(events, windowMs = 5 * 60_000) {
220
+ const kept = [];
221
+ for (const event of [...events].sort(byTime)) {
222
+ const twin = kept.find(
223
+ (other) =>
224
+ other.type === event.type &&
225
+ other.title === event.title &&
226
+ Math.abs(Date.parse(other.at) - Date.parse(event.at)) <= windowMs,
227
+ );
228
+ if (!twin) kept.push(event);
229
+ }
230
+ return kept;
231
+ }
232
+
233
+ const byTime = (a, b) => Date.parse(a.at) - Date.parse(b.at);
234
+
235
+ /** `oct` → 10, in Spanish and English (and `set`, Spanish for September). */
236
+ const MONTHS = Object.fromEntries(
237
+ [
238
+ 'ene feb mar abr may jun jul ago sep oct nov dic',
239
+ 'jan feb mar apr may jun jul aug set oct nov dec',
240
+ ].flatMap((names) =>
241
+ names.split(' ').map((name, index) => [name, index + 1]),
242
+ ),
243
+ );
244
+
245
+ /** Milliseconds a time zone is ahead of UTC at the instant `ms`. PURE. */
246
+ function zoneOffset(ms, timeZone) {
247
+ const parts = Object.fromEntries(
248
+ new Intl.DateTimeFormat('en-US', {
249
+ timeZone,
250
+ hourCycle: 'h23',
251
+ year: 'numeric',
252
+ month: '2-digit',
253
+ day: '2-digit',
254
+ hour: '2-digit',
255
+ minute: '2-digit',
256
+ second: '2-digit',
257
+ })
258
+ .formatToParts(new Date(ms))
259
+ .map((part) => [part.type, part.value]),
260
+ );
261
+ const asUtc = Date.UTC(
262
+ Number(parts.year),
263
+ Number(parts.month) - 1,
264
+ Number(parts.day),
265
+ Number(parts.hour),
266
+ Number(parts.minute),
267
+ Number(parts.second),
268
+ );
269
+ return asUtc - ms;
270
+ }
271
+
272
+ /** A wall-clock time in a time zone → ISO (UTC). PURE. */
273
+ export function zonedIso({ year, month, day, hour, minute }, timeZone) {
274
+ const wall = Date.UTC(year, month - 1, day, hour, minute);
275
+ const first = wall - zoneOffset(wall, timeZone);
276
+ return new Date(wall - zoneOffset(first, timeZone)).toISOString();
277
+ }
278
+
279
+ /**
280
+ * The time the referee wrote next to a Tape, `(29-oct 11:39, 8 min)`, in the judge's time zone →
281
+ * ISO. The year is the sparring's (the next one when the date would fall before it opened). Null
282
+ * when the text holds no such time. PURE.
283
+ */
284
+ export function parseTapeStamp(text, { openedAt, timeZone }) {
285
+ const match = /(\d{1,2})[- ]([a-z]{3})[a-z]*\.?,?\s+(\d{1,2}):(\d{2})/i.exec(
286
+ String(text ?? ''),
287
+ );
288
+ const month = match && MONTHS[match[2].toLowerCase()];
289
+ if (!month || !isTime(openedAt)) return null;
290
+ const [day, hour, minute] = [match[1], match[3], match[4]].map(Number);
291
+ const year = new Date(openedAt).getUTCFullYear();
292
+ const at = zonedIso({ year, month, day, hour, minute }, timeZone);
293
+ const early = Date.parse(at) < Date.parse(openedAt) - 2 * 86_400_000;
294
+ return early
295
+ ? zonedIso({ year: year + 1, month, day, hour, minute }, timeZone)
296
+ : at;
297
+ }
298
+
299
+ const cells = (line) =>
300
+ line
301
+ .trim()
302
+ .replace(/^\||\|$/g, '')
303
+ .split('|')
304
+ .map((cell) => cell.trim());
305
+
306
+ const MINUTE = /^\d{1,3}:\d{2}$/;
307
+
308
+ /**
309
+ * A round's catalog of blows (`blows-round-<n>.md`): the Tapes it cites and each blow, D (decision),
310
+ * B (bug), F (missing), Q (question) or R (noise), with its Tape minute. A row of the team's defense
311
+ * (7 columns: who, Tape · min, category, severity, blow, confirmations) keeps who gave it. The
312
+ * referee's own rows (A…) are notes, not blows. PURE.
313
+ */
314
+ export function parseBlows(text, file) {
315
+ const round = Number(/blows-round-(\d+)\.md$/.exec(file)?.[1]) || null;
316
+ const lines = String(text ?? '').split('\n');
317
+ const tapes = [];
318
+ for (const line of lines.filter((l) => !l.trim().startsWith('|'))) {
319
+ const re = /Tape (https?:\/\/\S+?)\s*\(([^)]*)\)/g;
320
+ for (let m = re.exec(line); m; m = re.exec(line))
321
+ if (!tapes.some((tape) => tape.url === m[1]))
322
+ tapes.push({ url: m[1], stamp: m[2] });
323
+ }
324
+ const blows = lines
325
+ .filter((line) => /^\|\s*[DBFQR]\d+\s*\|/.test(line))
326
+ .map((line) => {
327
+ const row = cells(line);
328
+ const id = row[0];
329
+ const team = row.length >= 7;
330
+ const minute = (team ? /\d{1,3}:\d{2}/.exec(row[2])?.[0] : row[1]) ?? '';
331
+ return {
332
+ id,
333
+ kind: id[0],
334
+ minute: MINUTE.test(minute) ? minute : null,
335
+ text: team ? row[5] : row[3],
336
+ ...(team
337
+ ? { who: row[1], tape: row[2], severity: row[4] }
338
+ : { repo: row[4] ?? null }),
339
+ };
340
+ });
341
+ const counts = Object.fromEntries(
342
+ ['D', 'B', 'F', 'Q', 'R'].map((kind) => [
343
+ kind,
344
+ blows.filter((blow) => blow.kind === kind).length,
345
+ ]),
346
+ );
347
+ return { round, file, tapes, blows, counts };
348
+ }
349
+
350
+ /**
351
+ * Each Tape a catalog cites becomes the judge's attack at the time written next to it; a Tape
352
+ * whose time cannot be read is a gap. PURE.
353
+ */
354
+ export function tapeEvents(catalogs, { openedAt, timeZone }) {
355
+ const events = [];
356
+ const gaps = [];
357
+ const seen = new Set();
358
+ for (const catalog of catalogs)
359
+ for (const tape of catalog.tapes) {
360
+ if (seen.has(tape.url)) continue;
361
+ seen.add(tape.url);
362
+ const counts = Object.entries(catalog.counts)
363
+ .filter(([, n]) => n)
364
+ .map(([kind, n]) => `${kind}${n}`)
365
+ .join(' ');
366
+ const base = {
367
+ type: 'juez',
368
+ title: 'Tape attack',
369
+ detail: [`round ${catalog.round}`, counts, tape.stamp]
370
+ .filter(Boolean)
371
+ .join(' · '),
372
+ ref: tape.url,
373
+ source: `file:${catalog.file}`,
374
+ };
375
+ const at = parseTapeStamp(tape.stamp, { openedAt, timeZone });
376
+ if (at) events.push({ at, ...base });
377
+ if (!at) gaps.push({ ...base, between: [null, null] });
378
+ }
379
+ return { events, gaps };
380
+ }
381
+
382
+ /**
383
+ * Splits a fight into its falls and rounds. Sparring events: the first fall runs from the opening
384
+ * to the KO, one round per verdict (`s1`, `s2`…; the round after the last verdict is still open);
385
+ * the second fall (`defense`) starts at the first second-fall milestone or right after the KO,
386
+ * whichever comes first; the third starts at the hand-off to the referee, at «sin equipo»
387
+ * (`no-team`: no second fall, the second is marked `skipped`) or at the first event of a bout. Bout events (`fall: 3`, `pr`, numeric `round`) keep their own rounds as `<pr>/<n>`.
388
+ * `boutRounds` is [{ pr, rounds }] as sliceRounds returned them. PURE.
389
+ */
390
+ export function sliceFight(events, boutRounds = []) {
391
+ const sorted = [...events].sort(byTime);
392
+ const own = sorted.filter((event) => event.fall !== 3);
393
+ const bouts = sorted.filter((event) => event.fall === 3);
394
+ const timeOf = (event) => (event ? Date.parse(event.at) : Infinity);
395
+ const ko = timeOf(own.find((event) => event.call === 'ko'));
396
+ const second = timeOf(
397
+ own.find((event) => SECOND_FALL.includes(event.milestone)),
398
+ );
399
+ const noTeam = own.find((event) => event.milestone === 'no-team');
400
+ const third = Math.min(
401
+ timeOf(own.find((event) => event.milestone === 'referee')),
402
+ timeOf(noTeam),
403
+ timeOf(bouts[0]),
404
+ );
405
+ const fallOf = (time) => {
406
+ if (time >= third) return 3;
407
+ return time >= second || time > ko ? 2 : 1;
408
+ };
409
+ const verdicts = own.filter(
410
+ (event) => event.call && fallOf(Date.parse(event.at)) === 1,
411
+ );
412
+ const roundOf = (time) => {
413
+ const index = verdicts.findIndex((v) => Date.parse(v.at) >= time);
414
+ return `s${index === -1 ? verdicts.length + 1 : index + 1}`;
415
+ };
416
+ const ROUND_OF_FALL = { 2: () => 'defense', 3: () => 'handoff' };
417
+ const placed = own.map((event) => {
418
+ const time = Date.parse(event.at);
419
+ const fall = fallOf(time);
420
+ return { ...event, fall, round: (ROUND_OF_FALL[fall] ?? roundOf)(time) };
421
+ });
422
+ const boutPlaced = bouts.map((event) => ({
423
+ ...event,
424
+ round: `${event.pr}/${event.round}`,
425
+ }));
426
+ const all = [...placed, ...boutPlaced].sort(byTime);
427
+ const span = (list) => ({
428
+ start: list[0]?.at ?? null,
429
+ end: list.at(-1)?.at ?? null,
430
+ events: list.length,
431
+ });
432
+ const inRound = (n) => all.filter((event) => event.round === n);
433
+ const rounds = [];
434
+ const opened = placed.some((e) => e.fall === 1) ? verdicts.length + 1 : 0;
435
+ for (let k = 1; k <= opened; k += 1) {
436
+ const verdict = verdicts[k - 1];
437
+ const list = inRound(`s${k}`);
438
+ if (!verdict && !list.length) continue;
439
+ rounds.push({
440
+ n: `s${k}`,
441
+ fall: 1,
442
+ name: `round ${k}`,
443
+ ...span(list),
444
+ closed: Boolean(verdict),
445
+ verdict: verdict?.call ?? null,
446
+ });
447
+ }
448
+ for (const n of ['defense', 'handoff'].filter((n) => inRound(n).length))
449
+ rounds.push({
450
+ n,
451
+ fall: n === 'defense' ? 2 : 3,
452
+ name: n === 'defense' ? 'the defense of the técnicos' : 'hand-off',
453
+ ...span(inRound(n)),
454
+ });
455
+ for (const { pr, rounds: list } of boutRounds)
456
+ for (const round of list)
457
+ rounds.push({
458
+ ...round,
459
+ n: `${pr}/${round.n}`,
460
+ fall: 3,
461
+ pr,
462
+ name: `#${pr} ${round.name}`,
463
+ });
464
+ const falls = FALLS.map((fall) => ({
465
+ ...fall,
466
+ ...span(all.filter((event) => event.fall === fall.n)),
467
+ ...(fall.n === 2 && noTeam ? { skipped: true } : {}),
468
+ }));
469
+ return { falls, rounds, events: all };
470
+ }