SLOPSHOPPER

cricket-commentary

Live cricket commentary for Claude Code: every tool call is a ball, with a scoreboard, a pixel big screen, crowd sounds and an Indian commentator who speaks.

newbandrowsguardcommandstatus
v0.1.0MITupdated 2026-10-04dhruvkumar1805/cricket-commentary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cricket-commentary
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cpl No innings yet. Send Claude a task and the match begins, or try /cpl demo. CPL Claude Premier League · send a task to start the innings · /cpl demo for a preview ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
CPL Claude Premier League · send a task to start the innings · /cpl demo for a preview
Command output
No innings yet. Send Claude a task and the match begins, or try /cpl demo.
README

🏏 cricket-commentary

A Claude Code mod that turns every session into a live IPL match.

Every tool call is a ball. Failing tests get you bowled, git push is a six, and an Indian commentator calls every big moment out loud while the crowd roars.

SIX! on the big screen after the tests pass

Install

In Claude Code:

/plugin marketplace add dhruvkumar1805/cricket-commentary
/plugin install cricket-commentary@dhruv-mods

Then give Claude any task. The toss happens on your first prompt. Turn your sound on 🔊

How it scores

What Claude doesWhat you get
tests pass, git pushSIX 🎆
build, lint, commitFOUR
editssingles, twos and threes
reading and searchingdot ball
tests failOUT, bowled
build breaksOUT, caught behind
command not foundOUT, stumped
rm -rf, force push, sudoNO BALL, free hit
you deny a permissionWIDE

Fifties, centuries, hat-tricks and maiden overs get their own moments. When the turn ends, the commentator names the player of the match.

OUT! with the stumps flying

What's in the box

  • Scoreboard above the prompt: score, overs, run rate, this over ball by ball, top batters, last wicket
  • Pixel big screen in the terminal for sixes, fours, wickets and milestones, with fireworks, flying bails and confetti
  • Live commentary written fresh for each moment by Claude Haiku about the actual command, with a bank of hand-written lines as backup
  • The voice: macOS reads the commentary with its Indian English voice (Aman), or Hindi (Lekha) in Hinglish mode
  • Crowd sounds: bat crack, roar, groan, stumps and applause, all synthesized from scratch

Commands

/cplfull scorecard for the innings
/cpl careercareer stats across sessions
/cpl demoa scripted innings, to see and hear everything
/cpl stop / startpause and resume the match
/cpl mute / unmutethe commentator
/cpl crowdtoggle the crowd
/cpl hinglish / englishcommentary language
/cpl hide / showthe scoreboard

Notes

  • Voice and crowd sounds need macOS. The scoreboard and big screen work in any terminal with true color.
  • Commentary lines use a small amount of your Claude usage (one Haiku call per big moment).
  • If the Aman voice isn't installed, the commentator falls back to your system voice. Add it in System Settings → Accessibility → Spoken Content → System Voice → Manage Voices.
  • Mods run with the same access as Claude Code. Read the code before installing any mod, this one included. It's all in hooks/.

MIT · made by @dhruvkumar1805

Source 5 files
hooks/register.tsx 622 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Ball, Career, Celebration, CelebrationKind, Innings, Language, Moment, MomentKind, Settings } from '../types'
5import { BANNER, DURATION_MS, frame, screenRowsFor } from './jumbotron'
6import { cannedLine, cleanLine, commentaryRequest, headline } from './lines'
7import type { LineContext } from './lines'
8import { bowl, newInnings, oversText, playerOfTheMatch, runRate, scoreDelivery, scoreText, shorten } from './scoring'
9import type { Delivery, Outcome } from './scoring'
10
11const DEFAULT_SETTINGS: Settings = { isVoiceOn: true, isCrowdOn: true, isHidden: false, isPaused: false, language: 'english' }
12const EMPTY_CAREER: Career = { innings: 0, runs: 0, sixes: 0, fours: 0, wickets: 0, fifties: 0, centuries: 0, best: 0 }
13const VOICE: Record<Language, string> = { english: 'Aman', hinglish: 'Lekha' }
14
15const inningsAtom = atom({ plugin: 'cricket-commentary', key: 'innings' } as const, null)
16const momentAtom = atom({ plugin: 'cricket-commentary', key: 'moment' } as const, null)
17const settingsAtom = atom({ plugin: 'cricket-commentary', key: 'settings' } as const, DEFAULT_SETTINGS)
18const celebrationAtom = atom({ plugin: 'cricket-commentary', key: 'celebration' } as const, null)
19
20/** Highest first: the one moment a ball is remembered for. */
21const PRIORITY: readonly MomentKind[] = ['century', 'fifty', 'hattrick', 'allout', 'six', 'wicket', 'four', 'noball', 'wide', 'maiden']
22const CELEBRATES: Partial<Record<MomentKind, CelebrationKind>> = {
23  century: 'century',
24  fifty: 'fifty',
25  hattrick: 'hattrick',
26  allout: 'wicket',
27  six: 'six',
28  wicket: 'wicket',
29  four: 'four',
30  noball: 'noball',
31}
32const MUST_SAY = new Set<MomentKind>(['start', 'end', 'century', 'hattrick'])
33const BIG = new Set<MomentKind>(['six', 'wicket', 'fifty', 'allout'])
34
35const CHIP: Record<Ball['kind'], { label: (b: Ball) => string; bg: string; fg: string }> = {
36  dot: { label: () => '•', bg: '#2b3a55', fg: '#9fb3d1' },
37  run: { label: b => String(b.runs), bg: '#1f2d4d', fg: '#ffffff' },
38  four: { label: () => '4', bg: '#1565c0', fg: '#ffffff' },
39  six: { label: () => '6', bg: '#8e24aa', fg: '#ffffff' },
40  wicket: { label: () => 'W', bg: '#c62828', fg: '#ffffff' },
41  noball: { label: () => 'nb', bg: '#ef6c00', fg: '#ffffff' },
42  wide: { label: () => 'wd', bg: '#455a64', fg: '#ffffff' },
43}
44
45const NICKNAMES: Record<string, string> = {
46  Read: 'the wall, blocks everything',
47  Grep: 'finds the gaps',
48  Glob: 'reads the field',
49  Bash: 'the finisher',
50  Edit: 'rotates the strike',
51  Write: 'clears the ropes',
52  Agent: 'brings on the specialists',
53  WebSearch: 'studies the opposition',
54  WebFetch: 'scouting report',
55}
56
57const NAVY = '#0a1838'
58const GOLD = '#f5c518'
59
60const DEMO: readonly Delivery[] = [
61  { tool: 'Read', input: { file_path: 'src/app.ts' }, outcome: 'ok' },
62  { tool: 'Grep', input: { pattern: 'useAuth' }, outcome: 'ok' },
63  { tool: 'Edit', input: { file_path: 'src/auth.ts', new_string: 'a\nb\nc\nd\ne\nf' }, outcome: 'ok' },
64  { tool: 'Bash', input: { command: 'pnpm build' }, outcome: 'ok' },
65  { tool: 'Bash', input: { command: 'pnpm test' }, outcome: 'ok', text: '42 passed' },
66  { tool: 'Bash', input: { command: 'pnpm lint' }, outcome: 'error' },
67  { tool: 'Write', input: { file_path: 'src/session.ts', content: 'x\n'.repeat(120) }, outcome: 'ok' },
68  { tool: 'Edit', input: { file_path: 'src/session.ts', new_string: 'x\n'.repeat(25) }, outcome: 'ok' },
69  { tool: 'Bash', input: { command: 'pnpm test' }, outcome: 'ok', text: '58 passed' },
70  { tool: 'Bash', input: { command: 'git commit -m "fix auth"' }, outcome: 'ok' },
71  { tool: 'Bash', input: { command: 'git push' }, outcome: 'ok' },
72]
73
74type Job =
75  | { type: 'start'; task: string }
76  | { type: 'ball'; delivery: Delivery }
77  | { type: 'end'; isAborted: boolean }
78  | { type: 'demo' }
79  | { type: 'stop-demo' }
80
81type BigScreen = { requestId: string; columns: number; rows: number; seq: number }
82
83function scorecard(innings: Innings | null): string {
84  if (innings === null) return 'No innings yet. Send Claude a task and the match begins, or try **/cpl demo**.'
85  const rows = Object.values(innings.batters).sort((a, b) => b.runs - a.runs || b.balls - a.balls)
86  const star = playerOfTheMatch(innings)
87  const table = [
88    '| Batter | | R | B | 4s | 6s | SR |',
89    '|---|---|--:|--:|--:|--:|--:|',
90    ...rows.map(
91      one =>
92        `| **${one.name}**${one.out ? '' : '\\*'} | ${one.out ? `_${one.out}_` : NICKNAMES[one.name] ?? 'not out'} | **${one.runs}** | ${one.balls} | ${one.fours} | ${one.sixes} | ${((one.runs * 100) / Math.max(1, one.balls)).toFixed(1)} |`,
93    ),
94  ]
95  const fow = innings.fallOfWickets.map(w => `${w.score}-${w.wicket} (${w.batter}: ${w.how})`).join(', ')
96  return [
97    `### 🏏 Claude Premier League · Innings ${innings.number}`,
98    `**CLAUDE ${scoreText(innings)}** (${oversText(innings.balls)} ov) · CRR ${runRate(innings.runs, innings.balls)} · extras ${innings.extras}${innings.isLive ? ' · **LIVE**' : ''}`,
99    `_Task: ${innings.task}_`,
100    '',
101    ...table,
102    '',
103    fow ? `**Fall of wickets:** ${fow}` : '**Fall of wickets:** none, flawless',
104    star ? `**Player of the match:** ${star.name}, ${star.runs} off ${star.balls}` : '',
105  ]
106    .filter(line => line !== '')
107    .join('\n')
108}
109
110function careerCard(c: Career): string {
111  return [
112    '### 🏆 Claude career stats',
113    '| Innings | Runs | Best | 50s | 100s | 4s | 6s | Wickets lost |',
114    '|--:|--:|--:|--:|--:|--:|--:|--:|',
115    `| ${c.innings} | ${c.runs} | ${c.best} | ${c.fifties} | ${c.centuries} | ${c.fours} | ${c.sixes} | ${c.wickets} |`,
116    '',
117    `Average ${(c.runs / Math.max(1, c.wickets)).toFixed(1)} runs per wicket.`,
118  ].join('\n')
119}
120
121const HELP = [
122  '**/cpl** scorecard · **/cpl career** · **/cpl demo** (a scripted innings) · **/cpl stop** / **start** (pause the match)',
123  '**/cpl mute** / **unmute** (commentator) · **/cpl crowd** (toggle crowd) · **/cpl hinglish** / **english** · **/cpl hide** / **show**',
124].join('\n')
125
126export const register: Register = on => {
127  const jobs: Job[] = []
128  let screen: BigScreen | null = null
129  let celebrationStartedAt = 0
130
131  // Everything that outlives one hook (sounds, the commentator, the big screen,
132  // the demo) runs here, from the session's own long-lived timers.
133  on('session.start', async ($, e, next) => {
134    let seq = 0
135    let speaking = 0
136    let isDraining = false
137    let animation: { cancel: () => void } | null = null
138    let demo: { cancel: () => void } | null = null
139
140    const stored = (await $.store.get('settings')) as Partial<Settings> | undefined
141    await update($, settingsAtom, () => ({ ...DEFAULT_SETTINGS, ...(stored ?? {}) }))
142    await $.command.register({
143      name: 'cpl',
144      description: 'Claude Premier League: scorecard, career, demo, mute, crowd, hinglish, hide',
145      argumentHint: '[scorecard|career|demo|stop|mute|unmute|crowd|hinglish|english|hide|show]',
146    })
147
148    const settingsNow = async (): Promise<Settings> => ({ ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) })
149    const careerNow = async (): Promise<Career> => ({
150      ...EMPTY_CAREER,
151      ...(((await $.store.get('career')) as Partial<Career> | undefined) ?? {}),
152    })
153
154    const play = (asset: string, gain = 1) => {
155      $.audio.play({ asset }, { gain }).catch(() => undefined)
156    }
157
158    const crowd = (kind: MomentKind) => {
159      if (kind === 'six') {
160        play('sounds/bat.wav')
161        play('sounds/six.wav', 0.9)
162      } else if (kind === 'four') {
163        play('sounds/bat.wav')
164        play('sounds/four.wav', 0.8)
165      } else if (kind === 'wicket' || kind === 'hattrick' || kind === 'allout') {
166        play('sounds/stumps.wav')
167        play('sounds/groan.wav', 0.85)
168      } else if (kind === 'century' || kind === 'fifty') {
169        play('sounds/applause.wav')
170        play('sounds/six.wav', 0.6)
171      } else if (kind === 'noball') {
172        play('sounds/groan.wav', 0.5)
173      } else if (kind === 'maiden') {
174        play('sounds/applause.wav', 0.35)
175      } else if (kind === 'start') {
176        play('sounds/four.wav', 0.6)
177      } else if (kind === 'end') {
178        play('sounds/applause.wav', 0.9)
179      }
180    }
181
182    const speak = async (text: string, language: Language) => {
183      speaking += 1
184      try {
185        const line = language === 'english' ? `[[rate 205]] ${text}` : text
186        await $.audio.speak(line, { voice: VOICE[language] }).catch(() => $.audio.speak(text))
187      } catch {
188        // no synthesizer here: the band still shows the line
189      } finally {
190        speaking -= 1
191      }
192    }
193
194    const commentate = async (kind: MomentKind, ctx: LineContext, mine: number) => {
195      const settings = await settingsNow()
196      const canned = cannedLine(kind, ctx, settings.language, Math.random())
197      const moment: Moment = { kind, headline: headline(kind), line: canned, seq: mine }
198      await update($, momentAtom, () => moment)
199
200      if (settings.isCrowdOn) crowd(kind)
201      if (!settings.isVoiceOn) return
202      if (!MUST_SAY.has(kind) && speaking >= (BIG.has(kind) ? 2 : 1)) return
203
204      let line = canned
205      const ask = commentaryRequest(kind, ctx, settings.language)
206      const reply = await $.model
207        .complete({
208          model: 'haiku',
209          system: ask.system,
210          prompt: ask.prompt,
211          maxTokens: 160,
212          timeoutMs: MUST_SAY.has(kind) ? 6000 : 3200,
213        })
214        .catch(() => undefined)
215      const written = reply?.isAnswered ? cleanLine(reply.text) : undefined
216      if (written !== undefined) {
217        line = written
218        await update($, momentAtom, current => (current?.seq === mine ? { ...current, line } : current))
219      }
220      if (!MUST_SAY.has(kind) && seq - mine > 3) return
221      await speak(line, settings.language)
222    }
223
224    const celebrate = async (kind: CelebrationKind, mine: number) => {
225      animation?.cancel()
226      celebrationStartedAt = await $.clock.now()
227      const celebration: Celebration = { kind, text: BANNER[kind], seq: mine }
228      await update($, celebrationAtom, () => celebration)
229      const duration = DURATION_MS[kind]
230      let isPainting = false
231      const timer = $.clock.every(33, () => {
232        if (isPainting) return
233        isPainting = true
234        void (async () => {
235          try {
236            const elapsed = (await $.clock.now()) - celebrationStartedAt
237            if (elapsed >= duration) {
238              timer.cancel()
239              if (animation === timer) animation = null
240              await update($, celebrationAtom, current => (current?.seq === mine ? null : current))
241              return
242            }
243            const target = screen
244            if (target === null || target.seq !== mine) return
245            await $.ui
246              .blit({ requestId: target.requestId, key: 'big-screen', cells: frame(kind, elapsed, target.columns, mine, target.rows) })
247              .catch(() => undefined)
248          } finally {
249            isPainting = false
250          }
251        })()
252      })
253      animation = timer
254    }
255
256    const startInnings = async (task: string) => {
257      const record = await careerNow()
258      const innings = newInnings(record.innings + 1, task, await $.clock.now())
259      await update($, inningsAtom, () => innings)
260      seq += 1
261      $.ui.status('🏏 CLAUDE 0/0 (0.0)')
262      void commentate('start', { detail: '', batter: 'Claude', score: '0/0', overs: '0.0', task }, seq)
263      return innings
264    }
265
266    const deliver = async (delivery: Delivery) => {
267      let innings = await read($, inningsAtom)
268      if (innings === null || !innings.isLive) innings = await startInnings('whatever the captain asked for')
269
270      const b = scoreDelivery(delivery)
271      const { innings: after, moments } = bowl(innings, b)
272      await update($, inningsAtom, () => after)
273      $.ui.status(`🏏 CLAUDE ${scoreText(after)} (${oversText(after.balls)})`)
274
275      const top = PRIORITY.find(kind => moments.includes(kind))
276      if (top === undefined) return
277      seq += 1
278      const ctx: LineContext = {
279        detail: b.detail,
280        batter: b.batter,
281        score: scoreText(after),
282        overs: oversText(after.balls),
283        how: b.how,
284        task: after.task,
285      }
286      const show = CELEBRATES[top]
287      if (show !== undefined) void celebrate(show, seq)
288      void commentate(top, ctx, seq)
289    }
290
291    const endInnings = async (isAborted: boolean) => {
292      const innings = await read($, inningsAtom)
293      if (innings === null || !innings.isLive) return
294      const ended: Innings = { ...innings, isLive: false, endedAt: await $.clock.now() }
295      await update($, inningsAtom, () => ended)
296      if (ended.balls === 0 && ended.runs === 0) return
297
298      const star = playerOfTheMatch(ended)
299      const record = await careerNow()
300      const sixes = Object.values(ended.batters).reduce((n, one) => n + one.sixes, 0)
301      const fours = Object.values(ended.batters).reduce((n, one) => n + one.fours, 0)
302      const updated: Career = {
303        innings: record.innings + 1,
304        runs: record.runs + ended.runs,
305        sixes: record.sixes + sixes,
306        fours: record.fours + fours,
307        wickets: record.wickets + ended.fallOfWickets.length,
308        fifties: record.fifties + ended.milestones.filter(m => m % 100 !== 0).length,
309        centuries: record.centuries + ended.milestones.filter(m => m % 100 === 0).length,
310        best: Math.max(record.best, ended.runs),
311      }
312      await $.store.set('career', updated)
313
314      seq += 1
315      const starText = star ? `${star.name}, ${star.runs} off ${star.balls} balls` : 'Claude'
316      void commentate('end', { detail: '', batter: 'Claude', score: scoreText(ended), overs: oversText(ended.balls), star: starText }, seq)
317
318      const summary = [
319        `🏏 CPL · CLAUDE ${scoreText(ended)} (${oversText(ended.balls)} ov)`,
320        isAborted ? 'rain stopped play' : `CRR ${runRate(ended.runs, ended.balls)}`,
321        star ? `player of the match: ${star.name} ${star.runs}${star.out ? '' : '*'} (${star.balls})` : '',
322        ended.runs > record.best && record.innings > 0 ? 'NEW CAREER BEST' : '',
323        '/cpl for the scorecard',
324      ]
325      await $.session
326        .append({ message: { type: 'system', content: [{ type: 'text', text: summary.filter(Boolean).join(' · ') }] } })
327        .catch(() => undefined)
328    }
329
330    const runDemo = async () => {
331      demo?.cancel()
332      await startInnings('fix the login bug and ship it')
333      let i = 0
334      const timer = $.clock.every(3800, () => {
335        const one = DEMO[i]
336        i += 1
337        if (one === undefined) {
338          timer.cancel()
339          if (demo === timer) demo = null
340          jobs.push({ type: 'end', isAborted: false })
341          return
342        }
343        jobs.push({ type: 'ball', delivery: one })
344      })
345      demo = timer
346    }
347
348    const drain = async () => {
349      if (isDraining) return
350      isDraining = true
351      try {
352        for (let job = jobs.shift(); job !== undefined; job = jobs.shift()) {
353          try {
354            if (job.type === 'start') {
355              const live = await read($, inningsAtom)
356              if (!(live?.isLive ?? false)) await startInnings(job.task)
357            } else if (job.type === 'ball') {
358              await deliver(job.delivery)
359            } else if (job.type === 'end') {
360              await endInnings(job.isAborted)
361            } else if (job.type === 'demo') {
362              await runDemo()
363            } else {
364              demo?.cancel()
365              demo = null
366            }
367          } catch (error) {
368            $.ui.log(`cricket-commentary: ${String(error)}`)
369          }
370        }
371      } finally {
372        isDraining = false
373      }
374    }
375
376    $.clock.every(60, () => {
377      if (jobs.length > 0) void drain()
378    })
379
380    return next(e)
381  })
382
383  on('prompt.submit', async ($, e, next) => {
384    const settings = await read($, settingsAtom)
385    if (!(settings?.isPaused ?? false) && !e.text.trimStart().startsWith('/')) {
386      jobs.push({ type: 'start', task: shorten(e.text, 90) })
387    }
388    return next(e)
389  })
390
391  on('tool.call', async ($, e, next) => {
392    const ran = await next(e)
393    try {
394      const settings = await read($, settingsAtom)
395      if (settings?.isPaused ?? false) return ran
396      // a plugin's view of a Bash result carries the output in stdout and
397      // stderr; `text` is only what the model read, and may be absent here
398      const result = ran.result as { interrupted?: boolean; stdout?: unknown; stderr?: unknown } | undefined
399      const text = [result?.stdout, result?.stderr, ran.text]
400        .filter((part): part is string => typeof part === 'string' && part.length > 0)
401        .join('\n')
402      let outcome: Outcome = 'ok'
403      if (ran.deny !== undefined || (ran.isError === true && /doesn't want to proceed|user rejected|denied by the user|rejected this/i.test(text))) {
404        outcome = 'denied'
405      } else if (result?.interrupted === true) {
406        outcome = 'interrupted'
407      } else if (ran.isError === true) {
408        outcome = 'error'
409      }
410      const input = { ...(e as unknown as Record<string, unknown>) }
411      // test runners print their counts last: keep the tail of long output
412      const kept = text.length > 6000 ? `${text.slice(0, 1500)}\n…\n${text.slice(-4500)}` : text
413      jobs.push({ type: 'ball', delivery: { tool: String(e.tool), input, outcome, text: kept } })
414    } catch {
415      // never let the scoreboard break a tool call
416    }
417    return ran
418  })
419
420  on('turn.complete', ($, e, next) => {
421    if (e.agentId === undefined) jobs.push({ type: 'end', isAborted: e.isAborted })
422    return next(e)
423  })
424
425  on('command.run', { command: 'cpl' }, async ($, e) => {
426    const arg = e.args.trim().toLowerCase()
427    const change = async (patch: Partial<Settings>) => {
428      const now = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}), ...patch }
429      await update($, settingsAtom, () => now)
430      await $.store.set('settings', now)
431      return now
432    }
433    if (arg === '' || arg === 'scorecard') return { text: scorecard(await read($, inningsAtom)) }
434    if (arg === 'career') {
435      const c = { ...EMPTY_CAREER, ...(((await $.store.get('career')) as Partial<Career> | undefined) ?? {}) }
436      return { text: careerCard(c) }
437    }
438    if (arg === 'demo') {
439      jobs.push({ type: 'demo' })
440      return { text: '🏏 Demo innings starting. Turn the volume up.' }
441    }
442    if (arg === 'stop' || arg === 'pause') {
443      jobs.push({ type: 'stop-demo' })
444      await change({ isPaused: true })
445      return { text: '⏸️ Play suspended. No scoring and no commentary until **/cpl start**.' }
446    }
447    if (arg === 'start' || arg === 'resume') {
448      await change({ isPaused: false })
449      return { text: '🏏 Play resumes. The next task starts a fresh innings.' }
450    }
451    if (arg === 'mute') {
452      await change({ isVoiceOn: false })
453      return { text: '🔇 Commentator muted, the crowd stays. **/cpl unmute** brings him back.' }
454    }
455    if (arg === 'unmute') {
456      await change({ isVoiceOn: true })
457      return { text: '🎙️ Commentator is back in the box.' }
458    }
459    if (arg === 'crowd') {
460      const current = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) }
461      const now = await change({ isCrowdOn: !current.isCrowdOn })
462      return { text: now.isCrowdOn ? '📣 Crowd is back.' : '🤫 Crowd silenced.' }
463    }
464    if (arg === 'hinglish' || arg === 'hindi') {
465      await change({ language: 'hinglish' })
466      return { text: '🇮🇳 Hindi commentary on.' }
467    }
468    if (arg === 'english') {
469      await change({ language: 'english' })
470      return { text: '🎙️ English commentary on.' }
471    }
472    if (arg === 'hide') {
473      await change({ isHidden: true })
474      return { text: 'Scoreboard hidden. **/cpl show** brings it back.' }
475    }
476    if (arg === 'show') {
477      await change({ isHidden: false })
478      return { text: 'Scoreboard is back.' }
479    }
480    return { text: HELP }
481  })
482
483  on('ui.render', { component: 'CommandOutput', props: { command: 'cpl' } }, ($, e, next) => {
484    if (e.props.isErrored) return next(e)
485    const { Markdown } = $.ui.resolve(e)
486    return <Markdown text={e.props.text} />
487  })
488
489  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
490    const settings = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) }
491    if (e.props.hasSurvey || settings.isHidden) return next(e)
492
493    const innings = await read($, inningsAtom)
494    const moment = await read($, momentAtom)
495    const celebration = await read($, celebrationAtom)
496    const ui = $.ui.resolve(e)
497    const { Box, Text } = ui
498    const width = Math.max(20, e.props.bodyColumns)
499
500    if (settings.isPaused) {
501      return (
502        <Box>
503          <Text backgroundColor={GOLD} color="#000000" bold>
504            {' CPL '}
505          </Text>
506          <Text color="#9fb3d1" wrap="truncate-end">
507            {innings === null
508              ? '  ⏸ play suspended · /cpl start to resume'
509              : `  ⏸ play suspended at ${scoreText(innings)} (${oversText(innings.balls)}) · /cpl start to resume`}
510          </Text>
511        </Box>
512      )
513    }
514
515    if (innings === null) {
516      return (
517        <Box>
518          <Text backgroundColor={GOLD} color="#000000" bold>
519            {' CPL '}
520          </Text>
521          <Text color="#9fb3d1" wrap="truncate-end">
522            {'  Claude Premier League · send a task to start the innings · /cpl demo for a preview'}
523          </Text>
524        </Box>
525      )
526    }
527
528    let bigScreen = null
529    const rows = screenRowsFor(e.props.maxRows)
530    if (celebration !== null && e.surface === 'terminal' && rows > 0 && width >= 30) {
531      const { Raster } = $.ui.resolve(e)
532      const columns = Math.min(width, 120)
533      const elapsed = Math.max(0, (await $.clock.now()) - celebrationStartedAt)
534      screen = { requestId: e.requestId, columns, rows, seq: celebration.seq }
535      bigScreen = <Raster key="big-screen" columns={columns} rows={rows} cells={frame(celebration.kind, elapsed, columns, celebration.seq, rows)} />
536    } else {
537      screen = null
538    }
539    const call = moment === null ? '' : moment.line.replace(new RegExp(`^${moment.headline.replace(/[^A-Z]/g, '')}!?\\s*`, 'i'), '')
540
541    const batters = Object.values(innings.batters)
542      .filter(one => one.runs > 0 || one.balls > 2)
543      .sort((a, b) => b.runs - a.runs)
544      .slice(0, 3)
545    const lastWicket = innings.fallOfWickets[innings.fallOfWickets.length - 1]
546    const isWide = width >= 90
547
548    return (
549      <Box flexDirection="column">
550        {bigScreen}
551        <Box>
552          <Text backgroundColor={GOLD} color="#000000" bold>
553            {' CPL '}
554          </Text>
555          <Text backgroundColor={NAVY} color="#ffffff" bold>
556            {'  CLAUDE '}
557          </Text>
558          <Text backgroundColor={NAVY} color={GOLD} bold>
559            {`${scoreText(innings)} `}
560          </Text>
561          <Text backgroundColor={NAVY} color="#cfd8e3">
562            {` ${oversText(innings.balls)} ov `}
563          </Text>
564          {isWide && (
565            <Text backgroundColor={NAVY} color="#8aa0c2">
566              {` CRR ${runRate(innings.runs, innings.balls)} `}
567            </Text>
568          )}
569          <Text backgroundColor={NAVY} color="#8aa0c2">
570            {'  THIS OVER '}
571          </Text>
572          {innings.thisOver.length === 0 && (
573            <Text backgroundColor={NAVY} color="#5c6f91">
574              {' - '}
575            </Text>
576          )}
577          {innings.thisOver.map(one => (
578            <Text backgroundColor={CHIP[one.kind].bg} color={CHIP[one.kind].fg} bold>
579              {` ${CHIP[one.kind].label(one)} `}
580            </Text>
581          ))}
582          <Text backgroundColor={NAVY}>{' '}</Text>
583          {innings.isLive ? (
584            <Text backgroundColor="#b71c1c" color="#ffffff" bold>
585              {e.props.isWorking ? ' ● LIVE ' : ' LIVE '}
586            </Text>
587          ) : (
588            <Text backgroundColor="#37474f" color="#ffffff" bold>
589              {' STUMPS '}
590            </Text>
591          )}
592        </Box>
593        {bigScreen === null && (batters.length > 0 || lastWicket !== undefined) && (
594          <Box>
595            {batters.map((one, i) => (
596              <Text color={i === 0 ? '#ffffff' : '#b0bec5'} bold={i === 0}>
597                {`${i === 0 ? ' ' : '  ·  '}${one.name.toUpperCase()} ${one.runs}${one.out ? '' : '*'} (${one.balls})`}
598              </Text>
599            ))}
600            {innings.extras > 0 && <Text color="#78909c">{`  ·  extras ${innings.extras}`}</Text>}
601            {lastWicket !== undefined && isWide && (
602              <Text color="#ef9a9a" wrap="truncate-end">
603                {`  ·  last wkt ${lastWicket.score}-${lastWicket.wicket}: ${lastWicket.how}`}
604              </Text>
605            )}
606          </Box>
607        )}
608        {moment !== null && (
609          <Box>
610            <Text color={GOLD} bold>
611              {` 🎙  ${moment.headline} `}
612            </Text>
613            <Text color="#e3f2fd" italic wrap="truncate-end">
614              {call}
615            </Text>
616          </Box>
617        )}
618      </Box>
619    )
620  })
621}
622
hooks/jumbotron.ts 374 lines
1import type { CelebrationKind } from '../types'
2
3/**
4 * The stadium big screen: a pixel canvas drawn into a terminal Raster with
5 * half blocks (each cell is two pixels stacked: ▀ painted fg over bg), so a
6 * band of R rows is a 2R pixel tall screen animated by $.ui.blit.
7 */
8
9export const SCREEN_ROWS = 10
10
11const FONT: Record<string, readonly string[]> = {
12  A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
13  B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
14  C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
15  D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
16  E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
17  F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
18  H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
19  I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
20  K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
21  L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
22  M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
23  N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
24  O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
25  R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
26  S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
27  T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
28  U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
29  W: ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
30  X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
31  Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
32  '0': ['01110', '10011', '10101', '10101', '10101', '11001', '01110'],
33  '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
34  '2': ['01110', '10001', '00001', '00110', '01000', '10000', '11111'],
35  '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
36  '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
37  '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
38  '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
39  '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
40  '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
41  '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
42  '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
43  '-': ['00000', '00000', '00000', '01110', '00000', '00000', '00000'],
44  ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
45}
46
47export const DURATION_MS: Record<CelebrationKind, number> = {
48  six: 3400,
49  four: 2300,
50  wicket: 2800,
51  fifty: 3200,
52  century: 4200,
53  hattrick: 3600,
54  noball: 2300,
55}
56
57export const BANNER: Record<CelebrationKind, string> = {
58  six: 'SIX!',
59  four: 'FOUR!',
60  wicket: 'OUT!',
61  fifty: 'FIFTY!',
62  century: '100!',
63  hattrick: 'HAT-TRICK!',
64  noball: 'NO BALL!',
65}
66
67type RGB = readonly [number, number, number]
68
69const hex = (n: number): RGB => [(n >> 16) & 255, (n >> 8) & 255, n & 255]
70const pack = ([r, g, b]: RGB): number => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
71const mixRGB = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
72const clamp01 = (x: number) => Math.max(0, Math.min(1, x))
73
74function hsl(h: number, s: number, l: number): RGB {
75  const k = (n: number) => (n + h / 30) % 12
76  const a = s * Math.min(l, 1 - l)
77  const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
78  return [f(0) * 255, f(8) * 255, f(4) * 255]
79}
80
81function rng(seed: number) {
82  let s = seed >>> 0 || 1
83  return () => {
84    s ^= s << 13
85    s ^= s >>> 17
86    s ^= s << 5
87    return ((s >>> 0) % 100000) / 100000
88  }
89}
90
91const THEME: Record<CelebrationKind, { text: RGB; glow: RGB; top: RGB; bottom: RGB }> = {
92  six: { text: hex(0xffd23f), glow: hex(0xffffff), top: hex(0x12002b), bottom: hex(0x3a0a6b) },
93  four: { text: hex(0x4fc3f7), glow: hex(0xe1f5fe), top: hex(0x001326), bottom: hex(0x00306b) },
94  wicket: { text: hex(0xffffff), glow: hex(0xffcdd2), top: hex(0x2b0000), bottom: hex(0x6b0a0a) },
95  fifty: { text: hex(0xffe082), glow: hex(0xffffff), top: hex(0x1a1200), bottom: hex(0x4a3500) },
96  century: { text: hex(0xffd700), glow: hex(0xffffff), top: hex(0x1a1000), bottom: hex(0x5c3d00) },
97  hattrick: { text: hex(0xff4081), glow: hex(0xffffff), top: hex(0x1f0016), bottom: hex(0x5a0040) },
98  noball: { text: hex(0xffa726), glow: hex(0xfff3e0), top: hex(0x1f1000), bottom: hex(0x4d2600) },
99}
100
101class Canvas {
102  readonly px: Float64Array
103  readonly width: number
104  readonly height: number
105  constructor(width: number, height: number) {
106    this.width = width
107    this.height = height
108    this.px = new Float64Array(width * height * 3)
109  }
110  set(x: number, y: number, c: RGB, alpha = 1) {
111    const xi = Math.round(x)
112    const yi = Math.round(y)
113    if (xi < 0 || yi < 0 || xi >= this.width || yi >= this.height) return
114    const i = (yi * this.width + xi) * 3
115    const a = clamp01(alpha)
116    this.px[i] = this.px[i]! + (c[0] - this.px[i]!) * a
117    this.px[i + 1] = this.px[i + 1]! + (c[1] - this.px[i + 1]!) * a
118    this.px[i + 2] = this.px[i + 2]! + (c[2] - this.px[i + 2]!) * a
119  }
120  get(x: number, y: number): RGB {
121    const i = (y * this.width + x) * 3
122    return [this.px[i]!, this.px[i + 1]!, this.px[i + 2]!]
123  }
124}
125
126function textWidth(text: string, sx: number): number {
127  return text.length * 6 * sx - sx
128}
129
130function drawText(cv: Canvas, text: string, x0: number, y0: number, sx: number, sy: number, color: (x: number, y: number) => RGB) {
131  let x = x0
132  for (const ch of text) {
133    const glyph = FONT[ch] ?? FONT[' ']!
134    for (let gy = 0; gy < 7; gy++) {
135      for (let gx = 0; gx < 5; gx++) {
136        if (glyph[gy]![gx] !== '1') continue
137        for (let dy = 0; dy < sy; dy++) {
138          for (let dx = 0; dx < sx; dx++) {
139            const px = x + gx * sx + dx
140            const py = y0 + gy * sy + dy
141            cv.set(px, py, color(px, py))
142          }
143        }
144      }
145    }
146    x += 6 * sx
147  }
148}
149
150function drawShadowText(cv: Canvas, text: string, x0: number, y0: number, sx: number, sy: number) {
151  drawText(cv, text, x0 + 1, y0 + 1, sx, sy, () => [0, 0, 0])
152}
153
154type Burst = { t0: number; x: number; y: number; hue: number; count: number; speed: number }
155
156function fireworks(cv: Canvas, bursts: readonly Burst[], t: number, seed: number) {
157  for (const [i, b] of bursts.entries()) {
158    const age = t - b.t0
159    if (age < 0 || age > 1.4) continue
160    const r = rng(seed * 31 + i * 7919)
161    for (let j = 0; j < b.count; j++) {
162      const angle = (j / b.count) * Math.PI * 2 + r() * 0.3
163      const v = b.speed * (0.6 + r() * 0.6)
164      const x = b.x + Math.cos(angle) * v * age * 2.2
165      const y = b.y + Math.sin(angle) * v * age + 9 * age * age
166      const fade = 1 - age / 1.4
167      const c = hsl((b.hue + r() * 40) % 360, 1, 0.55 + 0.35 * fade)
168      cv.set(x, y, c, fade)
169      if (age < 0.9) cv.set(x - Math.cos(angle) * 1.5, y - Math.sin(angle) * 0.8, c, fade * 0.35)
170    }
171    if (age < 0.12) cv.set(b.x, b.y, [255, 255, 255])
172  }
173}
174
175function confetti(cv: Canvas, t: number, seed: number, amount: number) {
176  const r = rng(seed * 13 + 5)
177  for (let i = 0; i < amount; i++) {
178    const x0 = r() * cv.width
179    const speed = 5 + r() * 9
180    const start = r() * 1.6
181    const y = ((t - start) * speed) % (cv.height + 6)
182    if (t < start) continue
183    const sway = Math.sin(t * 4 + i) * 1.5
184    cv.set(x0 + sway, y - 3, hsl((i * 47) % 360, 0.95, 0.6))
185  }
186}
187
188function floodlights(cv: Canvas, t: number, seed: number) {
189  const r = rng(seed + 99)
190  for (let x = 1; x < cv.width; x += 3) {
191    const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (2 + r() * 4) + r() * 6))
192    cv.set(x, 0, [255, 250, 220], twinkle * 0.8)
193  }
194}
195
196function stumpsAndBails(cv: Canvas, t: number, x: number, seed: number) {
197  const base = cv.height - 2
198  const tall = Math.min(12, cv.height - 4)
199  const wood: RGB = [242, 222, 172]
200  const shade: RGB = [190, 160, 110]
201  // the middle stump is sent cartwheeling back, the outer two stay planted
202  for (let s = 0; s < 3; s++) {
203    const isHit = s === 1 && t > 0.04
204    const lean = isHit ? Math.min(1, (t - 0.04) * 4) * 5 : 0
205    for (let h = 0; h < tall; h++) {
206      const px = x + s * 3 + (lean * h) / tall
207      cv.set(px, base - h, wood)
208      cv.set(px + 1, base - h, shade)
209    }
210  }
211  for (let gx = -1; gx < 10; gx++) cv.set(x + gx, base + 1, [60, 120, 60])
212  const r = rng(seed + 3)
213  const pieces = 16
214  for (let i = 0; i < pieces; i++) {
215    const age = t - 0.04
216    if (age < 0) {
217      if (i < 2) cv.set(x + 1 + i * 3, base - tall, [255, 255, 255])
218      continue
219    }
220    const vx = (r() - 0.35) * 34
221    const vy = -14 - r() * 14
222    const bx = x + 4 + vx * age
223    const by = base - tall + vy * age + 30 * age * age
224    const isBail = i < 2
225    cv.set(bx, by, isBail ? [255, 255, 255] : wood, clamp01(1.8 - age))
226    if (isBail) cv.set(bx + 1, by, [255, 255, 255], clamp01(1.8 - age))
227  }
228}
229
230function ballStreak(cv: Canvas, t: number, duration: number) {
231  const p = clamp01(t / (duration * 0.55))
232  const ease = 1 - (1 - p) ** 3
233  const x = ease * (cv.width + 6) - 3
234  const y = cv.height - 3 - Math.sin(p * Math.PI) * 2
235  for (let k = 0; k < 14; k++) cv.set(x - k, y + k * 0.05, [255, 255 - k * 12, 255 - k * 18], 1 - k / 14)
236  cv.set(x, y, [230, 30, 30])
237  cv.set(x + 1, y, [255, 80, 80])
238  if (p >= 1) {
239    const flash = clamp01(1 - (t - duration * 0.55) * 2)
240    for (let xx = 0; xx < cv.width; xx++) cv.set(xx, cv.height - 1, [255, 255, 255], flash)
241  }
242}
243
244function warningStripes(cv: Canvas, t: number) {
245  for (let x = 0; x < cv.width; x++) {
246    const on = Math.floor((x + t * 20) / 3) % 2 === 0
247    if (on) {
248      cv.set(x, 0, [255, 167, 38])
249      cv.set(x, cv.height - 1, [255, 167, 38])
250    }
251  }
252}
253
254/** Paints one frame of a celebration at `ms` into a pixel canvas. */
255export function paint(kind: CelebrationKind, ms: number, width: number, seed: number, rows = SCREEN_ROWS): Canvas {
256  const text = BANNER[kind]
257  const height = rows * 2
258  const cv = new Canvas(width, height)
259  const theme = THEME[kind]
260  const t = ms / 1000
261  const duration = DURATION_MS[kind] / 1000
262
263  for (let y = 0; y < height; y++) {
264    const row = mixRGB(theme.top, theme.bottom, y / (height - 1))
265    for (let x = 0; x < width; x++) cv.set(x, y, row)
266  }
267  if (kind === 'wicket' && t < 0.7 && Math.floor(t * 8) % 2 === 0) {
268    for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) cv.set(x, y, [200, 20, 20], 0.55)
269  }
270  floodlights(cv, t, seed)
271
272  const r = rng(seed)
273  if (kind === 'six' || kind === 'century' || kind === 'hattrick' || kind === 'fifty') {
274    const count = kind === 'century' ? 9 : kind === 'six' ? 7 : 5
275    const bursts: Burst[] = Array.from({ length: count }, (_, i) => ({
276      t0: 0.05 + (i * (duration * 0.7)) / count + r() * 0.15,
277      x: i % 2 === 0 ? r() * width * 0.22 + 6 : width - r() * width * 0.22 - 6,
278      y: 4 + r() * (height * 0.35),
279      hue: r() * 360,
280      count: 30 + Math.floor(r() * 14),
281      speed: 5 + r() * 4,
282    }))
283    fireworks(cv, bursts, t, seed)
284  }
285  if (kind === 'century' || kind === 'fifty') confetti(cv, t, seed, Math.floor(width * (kind === 'century' ? 0.9 : 0.5)))
286  if (kind === 'four') ballStreak(cv, t, duration)
287  if (kind === 'noball') warningStripes(cv, t)
288
289  // a short screen (a cramped band) draws the banner at single height
290  const sy = height >= 16 ? 2 : 1
291  const sx = sy === 2 && textWidth(text, 2) <= width - 4 ? 2 : 1
292  const tw = textWidth(text, sx)
293  const pop = clamp01(t / 0.32)
294  const bounce = Math.round((1 - pop) * -10 + Math.sin(pop * Math.PI) * -1.5)
295  const x0 = Math.floor((width - tw) / 2)
296  const y0 = Math.floor((height - 7 * sy) / 2) + bounce
297  const sweep = ((t * 1.4) % 1.6) * (tw + 20) - 10
298  const textColor = (x: number, y: number): RGB => {
299    const d = Math.abs(x - x0 - sweep - (y - y0) * 0.6)
300    const shine = clamp01(1 - d / 5)
301    const base = kind === 'six' || kind === 'century' ? mixRGB(theme.text, hsl((x * 4 + t * 240) % 360, 1, 0.62), kind === 'six' ? 0.25 : 0.1) : theme.text
302    return mixRGB(base, theme.glow, shine * 0.85)
303  }
304  drawShadowText(cv, text, x0, y0, sx, sy)
305  drawText(cv, text, x0, y0, sx, sy, textColor)
306
307  if (kind === 'wicket' || kind === 'hattrick') {
308    stumpsAndBails(cv, t, 4, seed)
309    stumpsAndBails(cv, t, width - 13, seed + 1)
310  }
311
312  const fadeOut = clamp01((duration - t) / 0.35)
313  if (fadeOut < 1) {
314    for (let y = 0; y < height; y++) {
315      for (let x = 0; x < width; x++) {
316        const c = cv.get(x, y)
317        const i = (y * width + x) * 3
318        cv.px[i] = c[0] * fadeOut
319        cv.px[i + 1] = c[1] * fadeOut
320        cv.px[i + 2] = c[2] * fadeOut
321      }
322    }
323  }
324  return cv
325}
326
327const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
328
329function toBase64(bytes: Uint8Array): string {
330  let out = ''
331  let i = 0
332  for (; i + 2 < bytes.length; i += 3) {
333    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
334    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
335  }
336  const rest = bytes.length - i
337  if (rest === 1) {
338    const n = bytes[i]! << 16
339    out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`
340  } else if (rest === 2) {
341    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
342    out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`
343  }
344  return out
345}
346
347const UPPER_HALF = 0x2580
348
349/** Packs a canvas into Raster cells: one ▀ per cell, top pixel fg, bottom bg. */
350export function cells(cv: Canvas): string {
351  const rows = cv.height / 2
352  const words = new Uint32Array(cv.width * rows * 3)
353  let k = 0
354  for (let row = 0; row < rows; row++) {
355    for (let x = 0; x < cv.width; x++) {
356      words[k++] = UPPER_HALF
357      words[k++] = pack(cv.get(x, row * 2))
358      words[k++] = pack(cv.get(x, row * 2 + 1))
359    }
360  }
361  return toBase64(new Uint8Array(words.buffer))
362}
363
364export function frame(kind: CelebrationKind, ms: number, width: number, seed: number, rows = SCREEN_ROWS): string {
365  return cells(paint(kind, ms, width, seed, rows))
366}
367
368/** Screen rows that fit a band of `maxRows`, leaving room for the scorebug and the call; 0 when too cramped. */
369export function screenRowsFor(maxRows: number): number {
370  // one spare row: the band's height moves as the spinner and tips come and go
371  const room = Math.min(SCREEN_ROWS, maxRows - 3)
372  return room >= 4 ? room : 0
373}
374
hooks/lines.ts 191 lines
1import type { Language, MomentKind } from '../types'
2
3export type LineContext = {
4  detail: string
5  batter: string
6  score: string
7  overs: string
8  how?: string
9  task?: string
10  star?: string
11}
12
13type Bank = Record<MomentKind, readonly string[]>
14
15const ENGLISH: Bank = {
16  start: [
17    'Welcome to the Claude Premier League! Claude walks out to the middle, the crowd is on its feet, and we are ready for the first ball.',
18    'What an atmosphere here tonight! The covers are off, the floodlights are on, and Claude is taking guard.',
19    'Good evening and welcome! A packed stadium, a fresh pitch, and one job to do. Claude, over to you.',
20  ],
21  six: [
22    'That is out of the ground! {detail}, and the ball is still travelling!',
23    'Oh, what a shot! {detail} sails over long on and into the second tier!',
24    'SIX! Clean, crisp, magnificent. {detail}, and the crowd goes absolutely berserk!',
25    'He has picked the length early and deposited it into the stands. {detail}. Massive!',
26    'That has gone like a rocket! {detail}, out of the park, and somebody fetch that ball from the car park!',
27    'Up, up and away! {detail}. That is maximum, and Claude is on fire tonight!',
28  ],
29  four: [
30    'Cracking shot! {detail} races away to the boundary, nobody is stopping that.',
31    'Through the covers like a bullet! {detail}, four runs.',
32    'Glorious timing! {detail}, and the fielder can only watch it cross the rope.',
33    'That is a beautiful stroke. {detail}, threaded through the gap for four.',
34    'Short and wide, and punished! {detail}, boundary!',
35  ],
36  wicket: [
37    'Oh, he is gone! {how}. {detail}. The stadium falls silent!',
38    'Timber! {how}. Claude walks back, and that is a big, big wicket.',
39    'Got him! {how}. {detail}, and the bowler is off on a lap of celebration!',
40    'That is a shocker! {how}. Claude cannot believe it, and neither can we.',
41    'Out! {how}. {detail}. Back to the drawing board for Claude.',
42  ],
43  noball: [
44    'No ball! Overstepped by a mile! {detail}. That is a free hit, and you can feel the danger in the air!',
45    'Ooh, that is reckless! {detail}. The umpire signals no ball, and the stadium gasps!',
46  ],
47  wide: [
48    'Wide called! The captain said no, and the umpire stretches those arms out.',
49    'That is a wide. Permission declined, and an extra run for the batting side.',
50  ],
51  fifty: [
52    'Fifty up! The bat is raised, the helmet is off, and this crowd is loving every minute of it!',
53    'There it is, a magnificent half century! {score}, and Claude is just getting started.',
54  ],
55  century: [
56    'A HUNDRED! What a knock! Claude leaps in the air, kisses the badge, and this stadium is shaking!',
57    'Century! Take a bow, Claude! That is one of the finest innings you will ever see!',
58  ],
59  hattrick: [
60    'HAT-TRICK! Three in three! I have never seen anything like it! Absolute carnage out there!',
61  ],
62  maiden: [
63    'A maiden over. Six balls, no runs. Claude is just reading the pitch here, patience personified.',
64    'Maiden! Claude is defending everything, studying the conditions like a Test match veteran.',
65  ],
66  allout: ['All out! The innings collapses, and that is the end of a dramatic, dramatic effort.'],
67  end: [
68    'And that is the end of the innings! Claude finishes on {score} off {overs} overs. Player of the match, {star}!',
69    'What an innings! {score} off {overs} overs. Take a bow. Player of the match tonight, {star}!',
70  ],
71}
72
73const HINGLISH: Bank = {
74  start: [
75    'क्लॉड प्रीमियर लीग में आपका स्वागत है! स्टेडियम खचाखच भरा है, और क्लॉड क्रीज़ पर आ चुके हैं!',
76    'नमस्कार दोस्तों! फ्लडलाइट्स ऑन हैं, पिच तैयार है, और क्लॉड बैटिंग के लिए उतर चुके हैं!',
77  ],
78  six: [
79    'क्या शॉट मारा है! गेंद सीधा स्टेडियम के बाहर! छक्का!',
80    'छक्का! बॉल अभी भी हवा में है भाई साहब, क्या टाइमिंग है!',
81    'उठाके मारा और सीधा दर्शकों के बीच! क्लॉड आज रुकने वाले नहीं हैं!',
82    'ये गया, ये गया, और ये गया! बाउंड्री के बाहर! ज़बरदस्त छक्का!',
83  ],
84  four: [
85    'चौका! गेंद गोली की रफ़्तार से बाउंड्री की तरफ़!',
86    'क्या खूबसूरत शॉट है! फील्डर बस देखता रह गया, चार रन!',
87    'गैप ढूंढा और निकाल दिया! शानदार चौका!',
88  ],
89  wicket: [
90    'आउट! अरे ये क्या हो गया! पूरा स्टेडियम खामोश!',
91    'गया! बड़ा विकेट! क्लॉड को यकीन ही नहीं हो रहा!',
92    'बोल्ड! गिल्लियां बिखर गईं! क्या गेंद थी!',
93  ],
94  noball: ['नो बॉल! ये तो बहुत ख़तरनाक शॉट था! अब फ्री हिट!'],
95  wide: ['वाइड! कप्तान ने मना कर दिया, एक्स्ट्रा रन!'],
96  fifty: ['पचास रन पूरे! बल्ला ऊपर, और दर्शक झूम उठे!'],
97  century: ['शतक! क्या पारी है! क्लॉड ने तो कमाल कर दिया! पूरा स्टेडियम खड़ा है!'],
98  hattrick: ['हैट्रिक! तीन गेंद, तीन विकेट! ऐसा नज़ारा कभी नहीं देखा!'],
99  maiden: ['मेडन ओवर! छह गेंद, एक भी रन नहीं, क्लॉड बस पिच पढ़ रहे हैं।'],
100  allout: ['ऑल आउट! पूरी टीम पवेलियन लौट गई!'],
101  end: [
102    'और पारी ख़त्म! क्लॉड ने बनाए {score}, {overs} ओवर में! मैन ऑफ़ द मैच, {star}!',
103  ],
104}
105
106const HEADLINES: Record<MomentKind, string> = {
107  start: 'TOSS',
108  six: 'SIX!',
109  four: 'FOUR!',
110  wicket: 'OUT!',
111  noball: 'NO BALL',
112  wide: 'WIDE',
113  fifty: 'FIFTY!',
114  century: 'CENTURY!',
115  hattrick: 'HAT-TRICK!',
116  maiden: 'MAIDEN',
117  allout: 'ALL OUT',
118  end: 'INNINGS OVER',
119}
120
121export function headline(kind: MomentKind): string {
122  return HEADLINES[kind]
123}
124
125function fill(template: string, ctx: LineContext): string {
126  return template
127    .replaceAll('{detail}', ctx.detail || 'that one')
128    .replaceAll('{batter}', ctx.batter)
129    .replaceAll('{score}', ctx.score.replace('/', ' for '))
130    .replaceAll('{overs}', ctx.overs)
131    .replaceAll('{how}', ctx.how ?? 'out')
132    .replaceAll('{star}', ctx.star ?? 'Claude')
133    .replace(/\s+([,.!])/g, '$1')
134}
135
136/** A hand-written line, used at once and whenever the model is slow or absent. */
137export function cannedLine(kind: MomentKind, ctx: LineContext, language: Language, roll: number): string {
138  const bank = (language === 'hinglish' ? HINGLISH : ENGLISH)[kind]
139  return fill(bank[Math.floor(roll * bank.length) % bank.length] ?? '', ctx)
140}
141
142const STYLE_ENGLISH = `You are the most excitable IPL television commentator alive, in the booming style of a legendary Indian cricket commentator: huge energy, vivid cricket metaphors, crowd-pleasing exaggeration. You are commentating live as a coding agent called Claude bats in a T20 match. Every tool call it makes is one delivery.
143
144Write ONE line of spoken commentary, at most 24 words, plain text for a text-to-speech voice: no emojis, no quotes, no hashtags, no markdown, no stage directions. Weave the real technical detail into a cricket metaphor so a developer laughs. Never repeat the example phrasing.`
145
146const STYLE_HINGLISH = `तुम IPL के सबसे जोशीले हिंदी कमेंटेटर हो। एक कोडिंग एजेंट "क्लॉड" T20 मैच में बैटिंग कर रहा है, उसका हर टूल कॉल एक गेंद है।
147
148सिर्फ़ एक लाइन लिखो, ज़्यादा से ज़्यादा 22 शब्द, देवनागरी लिपि में Hinglish (जैसे असली हिंदी कमेंट्री: "क्या शॉट है! गेंद सीधा स्टेडियम के बाहर!")। अंग्रेज़ी टेक शब्द भी देवनागरी में लिखो (टेस्ट, बिल्ड, कमिट, फ़ाइल)। कोई इमोजी, कोटेशन या मार्कडाउन नहीं। असली टेक्निकल बात को क्रिकेट की तरह मज़ेदार बनाओ।`
149
150const ASK: Record<MomentKind, string> = {
151  start: 'The innings is starting. Welcome the crowd and introduce what Claude has been asked to do',
152  six: 'Claude just hit a SIX',
153  four: 'Claude just hit a FOUR',
154  wicket: 'Claude just lost a WICKET',
155  noball: 'Claude just bowled a reckless NO BALL by running a dangerous command',
156  wide: 'The human declined a permission request, the umpire called a WIDE',
157  fifty: 'Claude just reached a FIFTY',
158  century: 'Claude just reached a CENTURY, the biggest moment of the night',
159  hattrick: 'Claude just lost three wickets in three balls, a HAT-TRICK',
160  maiden: 'Claude just played a MAIDEN over, six balls of only reading files and no runs',
161  allout: 'Claude is ALL OUT',
162  end: 'The innings is over. Sum it up and name the player of the match',
163}
164
165export function commentaryRequest(kind: MomentKind, ctx: LineContext, language: Language): { system: string; prompt: string } {
166  const facts = [
167    `Moment: ${ASK[kind]}.`,
168    ctx.detail ? `What actually happened in the code: ${ctx.batter} ran ${ctx.detail}.` : `Batter: ${ctx.batter}.`,
169    ctx.how ? `How it went wrong: ${ctx.how}.` : '',
170    `Score: ${ctx.score} after ${ctx.overs} overs.`,
171    ctx.task ? `Claude's task this innings: ${ctx.task}.` : '',
172    ctx.star ? `Player of the match: ${ctx.star}.` : '',
173  ]
174  return {
175    system: language === 'hinglish' ? STYLE_HINGLISH : STYLE_ENGLISH,
176    prompt: facts.filter(Boolean).join('\n'),
177  }
178}
179
180/** Cleans a model reply into one speakable line, or undefined when unusable. */
181export function cleanLine(text: string): string | undefined {
182  const line = text
183    .replace(/[*_`#>"“”]/g, '')
184    .replace(/\p{Extended_Pictographic}/gu, '')
185    .split('\n')
186    .map(one => one.trim())
187    .find(one => one.length > 0)
188  if (line === undefined || line.length < 8) return undefined
189  return line.length > 220 ? `${line.slice(0, 217)}...` : line
190}
191
hooks/scoring.ts 290 lines
1import type { Ball, Batter, Innings, MomentKind } from '../types'
2
3export type Outcome = 'ok' | 'error' | 'denied' | 'interrupted'
4
5export type Delivery = {
6  tool: string
7  input: Record<string, unknown>
8  outcome: Outcome
9  text?: string
10}
11
12const TEST = /\b(jest|vitest|pytest|mocha|playwright|cypress|rspec|phpunit|ctest|unittest)\b|\b(npm|pnpm|yarn|bun|deno|cargo|go|mix|dotnet|swift|gradle|mvn|make|rake)\s+(run\s+)?test\b|\bnode\s+--test\b/
13const BUILD = /\b(tsc|typecheck|type-check|webpack|esbuild|rollup|compile)\b|\b(npm|pnpm|yarn|bun|cargo|go|next|vite|swift|gradle|mvn|make)\s+(run\s+)?build\b|^\s*make\b/
14const LINT = /\b(eslint|prettier|biome|ruff|clippy|flake8|rubocop|stylelint)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?lint\b/
15const INSTALL = /\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|\bpip3?\s+install\b|\bbrew\s+install\b|\bcargo\s+add\b|\buv\s+(add|pip\s+install)\b/
16const PUSH = /\bgit\s+push\b/
17const COMMIT = /\bgit\s+commit\b/
18const DANGER = /\brm\s+-[a-zA-Z]*(rf|fr)\b|\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+-[a-zA-Z]*f|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*(--force\b|\s-f\b)|\bdrop\s+(table|database)\b|\bsudo\s/i
19const LOOK = /^\s*(grep|rg|find|ls|cat|head|tail|wc|which|pwd|echo|tree|file|stat|less|git\s+(status|log|diff|show|branch))\b/
20const NOT_FOUND = /command not found|No such file or directory|not recognized as/i
21const TIMEOUT = /timed out|timeout/i
22
23const READERS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'ToolSearch', 'TaskList', 'TaskGet', 'TaskOutput'])
24const SINGLES = new Set(['WebSearch', 'WebFetch'])
25
26export function shorten(text: string, max = 46): string {
27  const flat = text.replace(/\s+/g, ' ').trim()
28  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
29}
30
31function basename(path: unknown): string {
32  return typeof path === 'string' ? path.split('/').filter(Boolean).pop() ?? path : 'a file'
33}
34
35function lines(value: unknown): number {
36  return typeof value === 'string' ? value.split('\n').length : 0
37}
38
39function countFrom(text: string | undefined, pattern: RegExp): number | undefined {
40  const match = text?.match(pattern)
41  return match ? Number(match[1]) : undefined
42}
43
44export function testCounts(text: string | undefined): { passed?: number; failed?: number } {
45  // jest/vitest/pytest say "42 passed", node --test and TAP say "pass 42"
46  const passed = countFrom(text, /(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?/i) ?? countFrom(text, /^\W*pass\s+(\d+)/im)
47  const failed = countFrom(text, /(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?/i) ?? countFrom(text, /^\W*fail\s+(\d+)/im)
48  return { passed, failed }
49}
50
51// what failing runs print even when `| tail` has cut the summary off
52const FAILURE_MARKS = /AssertionError|✖ failing tests|^not ok \d+|^\s*(FAIL|✕)\s|^\s*● .+ › |\bTests:\s+\d+ failed|=+ FAILURES =+|^FAILED /m
53
54export function hasFailures(text: string | undefined): boolean {
55  const { failed } = testCounts(text)
56  return failed !== undefined ? failed > 0 : FAILURE_MARKS.test(text ?? '')
57}
58
59export function testSummary(text: string | undefined): string {
60  const { passed, failed } = testCounts(text)
61  if (failed) return `${failed} failing`
62  if (failed === undefined && FAILURE_MARKS.test(text ?? '')) return 'failing'
63  if (passed) return `${passed} passing`
64  return ''
65}
66
67const SCORING = [DANGER, PUSH, TEST, BUILD, LINT, COMMIT, INSTALL]
68
69/**
70 * The command a commentator would name: the step of a pipeline or chain that
71 * matters (`npm test`, not `cd x && npm test 2>&1 | grep pass`), redirects gone.
72 */
73export function coreCommand(command: string): string {
74  const steps = command
75    .split(/\s*(?:&&|\|\||;|\|)\s*/)
76    .map(step => step.replace(/\s*\d?>>?&?\s*\S+/g, '').replace(/\s+/g, ' ').trim())
77    .filter(step => step !== '' && !/^cd\s/.test(step))
78  for (const pattern of SCORING) {
79    const step = steps.find(one => pattern.test(one))
80    if (step !== undefined) return step
81  }
82  return steps[0] ?? command.trim()
83}
84
85function ball(kind: Ball['kind'], runs: number, batter: string, detail: string, how?: string): Ball {
86  return how === undefined ? { kind, runs, batter, detail } : { kind, runs, batter, detail, how }
87}
88
89function scoreBash(d: Delivery): Ball {
90  const full = typeof d.input.command === 'string' ? d.input.command : ''
91  const command = coreCommand(full)
92  const detail = shorten(command, 40)
93  const isOk = d.outcome === 'ok'
94
95  if (d.outcome === 'interrupted') return ball('dot', 0, 'Bash', detail, 'rain stopped play')
96  if (DANGER.test(full)) {
97    return isOk ? ball('noball', 1, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'hit wicket, dangerous shot')
98  }
99  if (PUSH.test(command)) {
100    // a pipe can hide git's exit code, its words cannot
101    const isRejected = /\[rejected\]|\[remote rejected\]|failed to push|fatal:/i.test(d.text ?? '')
102    return isOk && !isRejected
103      ? ball('six', 6, 'Bash', detail)
104      : ball('wicket', 0, 'Bash', detail, 'caught on the boundary, push rejected')
105  }
106  if (TEST.test(command) && !LOOK.test(command)) {
107    const summary = testSummary(d.text)
108    const named = summary ? `${detail} (${summary})` : detail
109    // a pipe into grep or tail exits 0 even when tests fail: trust the counts
110    const isGreen = isOk && !hasFailures(d.text)
111    return isGreen ? ball('six', 6, 'Bash', named) : ball('wicket', 0, 'Bash', named, 'bowled, tests failing')
112  }
113  if (BUILD.test(command)) {
114    const isBroken = /error TS\d+|npm ERR!|ELIFECYCLE|Build failed|Failed to compile/i.test(d.text ?? '')
115    return isOk && !isBroken
116      ? ball('four', 4, 'Bash', detail)
117      : ball('wicket', 0, 'Bash', detail, 'caught behind, build broken')
118  }
119  if (LINT.test(command)) {
120    return isOk ? ball('four', 4, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'lbw, the linter appealed')
121  }
122  if (COMMIT.test(command)) {
123    return isOk ? ball('four', 4, 'Bash', detail) : ball('dot', 0, 'Bash', detail)
124  }
125  if (INSTALL.test(command)) {
126    return isOk ? ball('run', 2, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'run out, install failed')
127  }
128  if (LOOK.test(command)) return ball('dot', 0, 'Bash', detail)
129  if (isOk) return ball('run', (d.text?.length ?? 0) > 2000 ? 2 : 1, 'Bash', detail)
130  if (NOT_FOUND.test(d.text ?? '')) return ball('wicket', 0, 'Bash', detail, 'stumped, command not found')
131  if (TIMEOUT.test(d.text ?? '')) return ball('wicket', 0, 'Bash', detail, 'retired hurt, timed out')
132
133  return ball('wicket', 0, 'Bash', detail, 'run out, command failed')
134}
135
136/** One tool call is one delivery: what it scored, who faced it, and how. */
137export function scoreDelivery(d: Delivery): Ball {
138  const tool = d.tool.startsWith('mcp__') ? d.tool.split('__')[1] ?? d.tool : d.tool
139
140  if (d.outcome === 'denied') return ball('wide', 1, tool, 'permission declined')
141  if (d.tool === 'Bash') return scoreBash(d)
142  if (d.outcome === 'interrupted') return ball('dot', 0, tool, 'interrupted')
143
144  if (d.tool === 'Write') {
145    const file = basename(d.input.file_path)
146    if (d.outcome !== 'ok') return ball('dot', 0, 'Write', file)
147    return lines(d.input.content) >= 80 ? ball('six', 6, 'Write', file) : ball('four', 4, 'Write', file)
148  }
149  if (d.tool === 'Edit' || d.tool === 'MultiEdit' || d.tool === 'NotebookEdit') {
150    const file = basename(d.input.file_path ?? d.input.notebook_path)
151    if (d.outcome !== 'ok') return ball('dot', 0, 'Edit', `${file}, played and missed`)
152    const size = lines(d.input.new_string) + lines(d.input.new_source)
153    const total = Array.isArray(d.input.edits) ? Math.max(size, d.input.edits.length * 4) : size
154    return total > 60 ? ball('four', 4, 'Edit', file) : ball('run', total > 20 ? 3 : total > 3 ? 2 : 1, 'Edit', file)
155  }
156  if (READERS.has(d.tool)) {
157    const target = d.input.file_path ?? d.input.pattern ?? d.input.path ?? ''
158    return ball('dot', 0, d.tool, typeof target === 'string' ? shorten(basename(target) || target, 30) : '')
159  }
160  if (SINGLES.has(d.tool)) {
161    const what = d.input.query ?? d.input.url ?? ''
162    return d.outcome === 'ok' ? ball('run', 1, d.tool, shorten(String(what), 34)) : ball('dot', 0, d.tool, '')
163  }
164  if (d.tool === 'Agent' || d.tool === 'Task') {
165    const what = d.input.description ?? d.input.subagent_type ?? 'a subagent'
166    return d.outcome === 'ok' ? ball('run', 2, 'Agent', shorten(String(what), 34)) : ball('dot', 0, 'Agent', '')
167  }
168
169  return d.outcome === 'ok' ? ball('run', 1, tool, '') : ball('dot', 0, tool, '')
170}
171
172export function isLegal(b: Ball): boolean {
173  return b.kind !== 'wide' && b.kind !== 'noball'
174}
175
176export function legalBalls(over: readonly Ball[]): number {
177  return over.filter(isLegal).length
178}
179
180export function oversText(balls: number): string {
181  return `${Math.floor(balls / 6)}.${balls % 6}`
182}
183
184export function runRate(runs: number, balls: number): string {
185  return balls === 0 ? '0.00' : ((runs * 6) / balls).toFixed(2)
186}
187
188export function newInnings(number: number, task: string, startedAt: number): Innings {
189  return {
190    number,
191    task,
192    runs: 0,
193    wickets: 0,
194    balls: 0,
195    extras: 0,
196    thisOver: [],
197    overs: [],
198    batters: {},
199    fallOfWickets: [],
200    milestones: [],
201    wicketStreak: 0,
202    isLive: true,
203    startedAt,
204  }
205}
206
207const MILESTONES = [50, 100, 150, 200, 250, 300, 400, 500]
208
209/** Adds one delivery to the innings and names the moments it made. */
210export function bowl(innings: Innings, b: Ball): { innings: Innings; moments: MomentKind[] } {
211  const moments: MomentKind[] = []
212  const overs = [...innings.overs]
213  let thisOver = innings.thisOver
214  if (legalBalls(thisOver) >= 6) {
215    overs.push(thisOver)
216    thisOver = []
217  }
218
219  const before = innings.runs
220  const legal = isLegal(b)
221  const old: Batter = innings.batters[b.batter] ?? { name: b.batter, runs: 0, balls: 0, fours: 0, sixes: 0 }
222  const batter: Batter = {
223    ...old,
224    runs: old.runs + (legal ? b.runs : 0),
225    balls: old.balls + (b.kind === 'wide' ? 0 : 1),
226    fours: old.fours + (b.kind === 'four' ? 1 : 0),
227    sixes: old.sixes + (b.kind === 'six' ? 1 : 0),
228  }
229  if (b.kind === 'wicket' && b.how !== undefined) batter.out = b.how
230
231  let wickets = innings.wickets + (b.kind === 'wicket' ? 1 : 0)
232  const runs = innings.runs + b.runs
233  const wicketStreak = b.kind === 'wicket' ? innings.wicketStreak + 1 : isLegal(b) ? 0 : innings.wicketStreak
234  const fallOfWickets =
235    b.kind === 'wicket'
236      ? [...innings.fallOfWickets, { score: runs, wicket: wickets, batter: b.batter, how: b.how ?? 'out', detail: b.detail }]
237      : innings.fallOfWickets
238
239  if (b.kind === 'six' || b.kind === 'four' || b.kind === 'wicket' || b.kind === 'noball' || b.kind === 'wide') {
240    moments.push(b.kind)
241  }
242  if (wicketStreak === 3) moments.push('hattrick')
243  if (wickets >= 10) {
244    moments.push('allout')
245    wickets = 0
246  }
247
248  const milestones = [...innings.milestones]
249  for (const mark of MILESTONES) {
250    if (before < mark && runs >= mark && !milestones.includes(mark)) {
251      milestones.push(mark)
252      moments.push(mark % 100 === 0 ? 'century' : 'fifty')
253    }
254  }
255
256  thisOver = [...thisOver, b]
257  const balls = innings.balls + (legal ? 1 : 0)
258  if (legal && legalBalls(thisOver) === 6 && thisOver.every(one => one.runs === 0 && one.kind !== 'wicket')) {
259    moments.push('maiden')
260  }
261
262  return {
263    innings: {
264      ...innings,
265      runs,
266      wickets,
267      balls,
268      extras: innings.extras + (legal ? 0 : b.runs),
269      thisOver,
270      overs,
271      batters: { ...innings.batters, [b.batter]: batter },
272      fallOfWickets,
273      milestones,
274      wicketStreak,
275    },
276    moments,
277  }
278}
279
280/** The batter with the most runs, then the best strike rate. */
281export function playerOfTheMatch(innings: Innings): Batter | undefined {
282  return Object.values(innings.batters)
283    .filter(one => one.runs > 0)
284    .sort((a, b) => b.runs - a.runs || b.runs / Math.max(1, b.balls) - a.runs / Math.max(1, a.balls))[0]
285}
286
287export function scoreText(innings: Innings): string {
288  return `${innings.runs}/${innings.wickets}`
289}
290
types/index.d.ts 91 lines
1export type BallKind = 'dot' | 'run' | 'four' | 'six' | 'wicket' | 'noball' | 'wide'
2
3export type Ball = {
4  kind: BallKind
5  runs: number
6  batter: string
7  detail: string
8  how?: string
9}
10
11export type Batter = {
12  name: string
13  runs: number
14  balls: number
15  fours: number
16  sixes: number
17  out?: string
18}
19
20export type FallOfWicket = { score: number; wicket: number; batter: string; how: string; detail: string }
21
22export type Innings = {
23  number: number
24  task: string
25  runs: number
26  wickets: number
27  balls: number
28  extras: number
29  thisOver: Ball[]
30  overs: Ball[][]
31  batters: Record<string, Batter>
32  fallOfWickets: FallOfWicket[]
33  milestones: number[]
34  wicketStreak: number
35  isLive: boolean
36  startedAt: number
37  endedAt?: number
38}
39
40export type Language = 'english' | 'hinglish'
41
42export type Settings = {
43  isVoiceOn: boolean
44  isCrowdOn: boolean
45  isHidden: boolean
46  isPaused: boolean
47  language: Language
48}
49
50export type MomentKind =
51  | 'start'
52  | 'six'
53  | 'four'
54  | 'wicket'
55  | 'noball'
56  | 'wide'
57  | 'fifty'
58  | 'century'
59  | 'hattrick'
60  | 'maiden'
61  | 'allout'
62  | 'end'
63
64export type Moment = { kind: MomentKind; headline: string; line: string; seq: number }
65
66export type CelebrationKind = 'six' | 'four' | 'wicket' | 'fifty' | 'century' | 'hattrick' | 'noball'
67
68export type Celebration = { kind: CelebrationKind; text: string; seq: number }
69
70export type Career = {
71  innings: number
72  runs: number
73  sixes: number
74  fours: number
75  wickets: number
76  fifties: number
77  centuries: number
78  best: number
79}
80
81declare module 'claude-code' {
82  interface PluginState {
83    'cricket-commentary': {
84      innings: Innings | null
85      moment: Moment | null
86      settings: Settings
87      celebration: Celebration | null
88    }
89  }
90}
91