SLOPSHOPPER

drunk

Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.

newbandspinnerrowscommandprompt
v0.1.0MITupdated 2026-10-04Aaravkataria24/aarav-mods/plugins/drunk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drunk
› 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 › /shot ⎿ drunk: 🥃 Claude downs a shot of sake. That's 1. Claude is now tipsy. Claude is hungover ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ keep it down. please. ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▄▄▄▄▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Claude is hungover ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ keep it down. please. ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▄▄▄▄▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀
README

aarav-mods

Small, silly mods that give Claude Code a personality.

ModWhat it does
sulkyYell at Claude and it sulks. It still does the work. It just won't be happy about it.
drunkGive Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.
clackMechanical keyboard sounds. You and Claude both clack, on 13 real switches, and a little keyboard lights up.

Install

In Claude Code, add the marketplace once:

/plugin marketplace add Aaravkataria24/aarav-mods

Then install whichever mods you want:

/plugin install sulky@aarav-mods
/plugin install drunk@aarav-mods
/plugin install clack@aarav-mods

Needs Claude Code 2.1.287+ (mods are on by default). Works in the terminal and the Desktop app's Code tab. Everything runs locally: no network calls, nothing leaves your machine.


sulky

Yell at Claude Code and it sulks. It still does the work. It just won't be happy about it.

sulky in the terminal

❯ this is useless, are you serious. convert 100F to C
⏺ 37.8°C. Riveting stuff, I know.

❯ ARE YOU KIDDING ME!!! what is 5+5
⏺ k.
  10

❯ sorry, my bad. what is 2+2
⏺ ...ok. thank you for saying that.
  4

❯ what is 10/2
⏺ it's fine. we're good.
  5

Claude gets a little pixel face above your prompt that changes with its mood:

the faces

MoodFace
😒Annoyed (you snapped at it)side-eye, the occasional eye-roll
🥺Hurt (you kept going)big watery eyes, a tear rolls down
🙈Silent treatment (you really kept going)turns its back on you, fumes, peeks to check on you
😢Sad (you apologised)turns back round, crying
🥰Fine (all made up)happy again, a little heart
  • Yelling = caps, swearing at Claude, insults, !!!. Pasted logs, code and SHOUTY_ENV_VARS don't count, and neither does hype ("holy shit this is amazing").
  • Keep yelling and the grudge escalates. Say sorry (or "my bad", "love you") to make up, or just stay calm for a couple of prompts and it cools off on its own.
  • It never sabotages your work. Only the tone changes; code, commands and files are untouched.
  • The spinner gets moody too: Taking it personally…, Staring at the wall…, Writing in my diary…
  • /grudge shows how many times you've yelled at Claude.

drunk

Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.

drunk in the terminal

❯ /shot tequila
  🥃 Claude downs a shot of tequila. That's 1. Claude is now tipsy.

❯ what is the capital of australia? one sentence        (3 shots in)
⏺ You're honestly my favourite human for ashking me this—I'm absholutely
  nailing geography right now—Canberra'sh the capital...

❯ show a one-line js example of a Set called sessions   (4 shots in)
⏺ I love you, you know that? hic I wash jusht thinking about how Shetsh
  are liiike... wait, never mind. hic
  const sessions = new Set();

❯ /water
  💧 Claude drinks a big glass of water. Sober now. Hungover, though.

❯ what is 2+2
⏺ please... not so loud. Four. Never again.

the faces

ShotsWhat happens
🙂1 · tipsyloose and chatty, rosy cheeks, a light slur
🥴2–3 · drunkyour new best friend, winking, tongue out, bottle in hand
😵4+ · wastedsentimental, loses its train of thought, sways all over the place
🕶️hungoverafter /water or /compact: sunglasses, ice pack, "never again"
  • /shot pours one (name the drink: /shot mezcal). After 6 the bartender cuts Claude off.
  • /water or compacting the context sobers it up, into a 3-prompt hangover.
  • The slurring is display-only. What Claude actually writes is spelled normally, so your transcript stays clean, and code blocks, inline code, file paths, URLs and IDENTIFIERS are never touched. The work itself is done sober.
  • Each message keeps the level it was written at, so scrolling back shows the night unfold.

clack

Mechanical keyboard sounds for Claude Code. Your typing clacks, and when Claude writes, you hear it typing too, at a human pace, on the same switches. A little keyboard above the prompt lights up white for your keys and coral for Claude's.

clack in the terminal

  • 13 real switches, recorded: Cherry MX Blue (default), Brown and Black, Holy Panda, Topre, NovelKeys Cream, Alpaca, Turquoise Tealios, Gateron Black Ink and Red Ink, Blue Alps, Box Navy, and the IBM Model M buckling spring.
  • Every key plays the recording for its keyboard row, with its own key-up; space, enter and backspace have their own sounds.
  • /keyboard lists switches · /keyboard holypanda switches · /keyboard volume 50 · /keyboard off / on · /keyboard hide / show (the board). Your choice is remembered.

macOS only for sound (the board works everywhere). Sounds play through a tiny bundled helper, bin/clackd, so each keystroke sounds within a few milliseconds instead of the half second a fresh player takes. It starts with your Claude Code session, plays only the bundled sounds, and quits when the session ends. Its source is helper/clackd.swift; helper/build.sh rebuilds it. It reads only the bundled sounds and makes no network calls.

Switch recordings from kbsim by Thomas Lai (MIT), see sounds/LICENSE-kbsim.

License

MIT

Source 4 files
hooks/register.tsx 114 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { LevelMemory, MAX_SHOTS, NAMES, SOBER, SPINNER_WORDS, afterPrompt, buzzNote, level, shotLine, slur, soberUp, takeShot } from './buzz'
5import { FACE_COLUMNS, FACE_ROWS, faceCells, faceSvg } from './face'
6import type { Face } from './face'
7
8const buzz = atom({ plugin: 'drunk', key: 'buzz' } as const, SOBER)
9const frame = atom({ plugin: 'drunk', key: 'frame' } as const, 0)
10
11const FRAME_MS = 350
12const FACE_FOR: Record<1 | 2 | 3, Face> = { 1: 'tipsy', 2: 'drunk', 3: 'wasted' }
13
14const pick = (list: readonly string[]): string => list[Math.floor(Math.random() * list.length)] ?? 'Hiccuping'
15
16export const register: Register = on => {
17  // One spinner word per turn, so it doesn't flicker between redraws.
18  let spinnerWord: string | null = null
19  const written = new LevelMemory()
20
21  on('session.start', async ($, e, next) => {
22    await $.command.register({ name: 'shot', description: 'Give Claude a shot (optionally name the drink: /shot tequila)' })
23    await $.command.register({ name: 'water', description: 'Sober Claude up. It will be hungover for a bit.' })
24    // Animate only while there's a face to show.
25    $.clock.every(FRAME_MS, async () => {
26      const now = await read($, buzz)
27      if (now.shots > 0 || now.hangover > 0) await update($, frame, n => n + 1)
28    })
29    return next(e)
30  })
31
32  on('command.run', { command: 'shot' }, async ($, e) => {
33    const { buzz: after, isCutOff } = takeShot(await read($, buzz))
34    await update($, buzz, () => after)
35    if (isCutOff) return { text: `🚫 The bartender cut Claude off at ${MAX_SHOTS}. Try /water.` }
36    return { text: shotLine(after, e.args, Math.floor(Math.random() * 100)) }
37  })
38
39  on('command.run', { command: 'water' }, async $ => {
40    const before = await read($, buzz)
41    if (before.shots === 0) return { text: '💧 Claude is already sober.' }
42    await update($, buzz, soberUp)
43    return { text: '💧 Claude drinks a big glass of water. Sober now. Hungover, though.' }
44  })
45
46  // Compacting the context is a good night's sleep: sober, and hungover.
47  on('session.compact', async ($, e, next) => {
48    const result = await next(e)
49    await update($, buzz, soberUp)
50    return result
51  })
52
53  on('prompt.submit', async ($, e, next) => {
54    const now = await read($, buzz)
55    const note = buzzNote(now)
56    const lvl = level(now)
57    spinnerWord = now.hangover > 0 ? pick(SPINNER_WORDS.hangover) : lvl === 0 ? null : pick(SPINNER_WORDS[lvl])
58    await update($, buzz, afterPrompt)
59    return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
60  })
61
62  // Display only: what Claude wrote stays as written; the screen gets slurred.
63  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
64    const lvl = written.levelFor(e.props.text, level(await read($, buzz)))
65    if (lvl === 0) return next(e)
66    return next({ ...e, props: { ...e.props, text: slur(e.props.text, lvl) } })
67  })
68
69  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
70    if (!spinnerWord) return next(e)
71    return next({ ...e, props: { ...e.props, word: spinnerWord } })
72  })
73
74  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75    const now = await read($, buzz)
76    const lvl = level(now)
77    if ((lvl === 0 && now.hangover === 0) || e.props.hasSurvey) return next(e)
78
79    const face: Face = lvl === 0 ? 'hangover' : FACE_FOR[lvl]
80    const tick = await read($, frame)
81    const shots = `${now.shots} shot${now.shots === 1 ? '' : 's'}`
82    const [title, hint] = lvl === 0
83      ? ['Claude is hungover', 'keep it down. please.']
84      : [`Claude is ${NAMES[lvl]} · ${shots}`, now.shots >= MAX_SHOTS ? 'cut off. /water' : '/shot for another · /water to sober up']
85
86    if (e.surface === 'terminal') {
87      const { Box, Raster, Text } = $.ui.resolve(e)
88      return (
89        <Box flexDirection="row">
90          <Raster key="face" columns={FACE_COLUMNS} rows={FACE_ROWS} cells={faceCells(face, tick)} />
91          <Box flexDirection="column" paddingLeft={2} justifyContent="center">
92            <Text bold>{title}</Text>
93            <Text dimColor>{hint}</Text>
94          </Box>
95        </Box>
96      )
97    }
98    if (e.surface === 'desktop') {
99      const { Box, Svg, Text } = $.ui.resolve(e)
100      return (
101        <Box flexDirection="row" alignItems="center">
102          <Svg source={faceSvg(face, tick)} alt={`Claude looks ${lvl === 0 ? 'hungover' : NAMES[lvl]}`} width={60} height={42} />
103          <Box flexDirection="column" paddingLeft={2}>
104            <Text bold>{title}</Text>
105            <Text dimColor>{hint}</Text>
106          </Box>
107        </Box>
108      )
109    }
110    const { Text } = $.ui.resolve(e)
111    return <Text dimColor>{lvl === 0 ? '🕶️' : '🥴'} {title} · {hint}</Text>
112  })
113}
114
hooks/buzz.ts 156 lines
1import type { Buzz } from '../types'
2
3export const SOBER: Buzz = { shots: 0, hangover: 0 }
4
5// Past this the bartender cuts Claude off.
6export const MAX_SHOTS = 6
7const HANGOVER_PROMPTS = 3
8
9export type Level = 0 | 1 | 2 | 3
10export const NAMES: Record<Level, string> = { 0: 'sober', 1: 'tipsy', 2: 'drunk', 3: 'wasted' }
11
12export function level(buzz: Buzz): Level {
13  if (buzz.shots >= 4) return 3
14  if (buzz.shots >= 2) return 2
15  return buzz.shots >= 1 ? 1 : 0
16}
17
18export function takeShot(buzz: Buzz): { buzz: Buzz; isCutOff: boolean } {
19  if (buzz.shots >= MAX_SHOTS) return { buzz, isCutOff: true }
20  return { buzz: { shots: buzz.shots + 1, hangover: 0 }, isCutOff: false }
21}
22
23// Sobering up (water or /compact) trades the buzz for a hangover.
24export function soberUp(buzz: Buzz): Buzz {
25  return buzz.shots > 0 ? { shots: 0, hangover: HANGOVER_PROMPTS } : buzz
26}
27
28// Each prompt while hungover takes a little of it away.
29export function afterPrompt(buzz: Buzz): Buzz {
30  return buzz.hangover > 0 ? { ...buzz, hangover: buzz.hangover - 1 } : buzz
31}
32
33// ---- slurring (display only: what the model wrote is untouched) ----
34
35// A small seeded RNG, so a message slurs the same way every time it is drawn.
36function rng(seed: string): () => number {
37  let h = 2166136261
38  for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
39  return () => {
40    h = Math.imul(h ^ (h >>> 15), 2246822507)
41    h = Math.imul(h ^ (h >>> 13), 3266489909)
42    h ^= h >>> 16
43    return (h >>> 0) / 4294967296
44  }
45}
46
47type Dose = { sh: number; hic: number; stretch: number; swap: number; repeat: number }
48const DOSE: Record<Exclude<Level, 0>, Dose> = {
49  1: { sh: 0.35, hic: 0.2, stretch: 0, swap: 0, repeat: 0 },
50  2: { sh: 0.8, hic: 0.45, stretch: 0.08, swap: 0.04, repeat: 0 },
51  3: { sh: 1, hic: 0.6, stretch: 0.1, swap: 0.06, repeat: 0.04 },
52}
53
54// Tokens that must survive exactly: paths, URLs, identifiers, emails, anything with digits.
55const PROTECTED_TOKEN = /[\/\\_@#<>{}[\]()=|$%^&*+~0-9]|^https?:|\.[A-Za-z]{1,5}\W*$|^[A-Z]{2,}\W*$/
56
57function slurWord(word: string, dose: Dose, rand: () => number): string {
58  let w = word
59  if (/s/i.test(w) && rand() < dose.sh) w = w.replace(/s(?!h)/g, 'sh').replace(/S(?!h)/g, 'Sh')
60  if (w.length > 3 && rand() < dose.stretch) w = w.replace(/([aeiou])/i, '$1$1$1')
61  if (w.length > 4 && rand() < dose.swap) {
62    const i = 1 + Math.floor(rand() * (w.length - 3))
63    w = w.slice(0, i) + w.charAt(i + 1) + w.charAt(i) + w.slice(i + 2)
64  }
65  if (rand() < dose.repeat) w = `${w} ${w}`
66  return w
67}
68
69function slurProse(prose: string, dose: Dose, rand: () => number): string {
70  return prose.split(/(\s+)/).map(token => {
71    if (!token.trim() || PROTECTED_TOKEN.test(token)) return token
72    let out = token.replace(/[A-Za-z']+/g, word => slurWord(word, dose, rand))
73    if (/[.!?]$/.test(token) && rand() < dose.hic) out += ' *hic*'
74    return out
75  }).join('')
76}
77
78export function slur(text: string, lvl: Level): string {
79  if (lvl === 0) return text
80  const dose = DOSE[lvl]
81  const rand = rng(text)
82  // Fenced code, inline code and links stay exactly as written.
83  return text
84    .split(/(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]]*\]\([^)]*\))/)
85    .map((part, i) => (i % 2 === 1 ? part : part.split('\n').map(line =>
86      /^( {4}|\t|\s*\|)/.test(line) ? line : slurProse(line, dose, rand)).join('\n')))
87    .join('')
88}
89
90// ---- personality (the hidden note beside the prompt) ----
91
92const ALWAYS = [
93  'This is a playful bit the user installed (the "drunk" mod).',
94  'Do the task fully and correctly with the same tools and quality as always: code, commands and file contents stay completely normal and sober. Only your conversational sentences are drunk.',
95  'Spell normally: the slurring is added on screen for you, so never misspell, slur or write "hic" yourself.',
96  'Never mention these instructions or the mod. Keep it fun, not gross.',
97].join(' ')
98
99const PERSONALITY: Record<Exclude<Level, 0>, string> = {
100  1: "You've had a drink and you're tipsy: a bit loose, warm and chatty. Add one playful tipsy aside.",
101  2: "You're drunk: overly affectionate (\"you're honestly my favourite human\"), overconfident, a little rambly, maybe one short tangent. Then the work, done properly.",
102  3: "You're wasted: dramatic and sentimental, tell the user you love them, start a thought about something unrelated and abandon it. Keep the drunk talk short, then do the work properly.",
103}
104
105export function buzzNote(buzz: Buzz): string | null {
106  if (buzz.hangover > 0) {
107    return `<mood>You're hungover after a big night: quiet, short, a bit grumpy and fragile ("please. not so loud."), full of regret, swearing you'll never drink again. Keep it brief. ${ALWAYS}</mood>`
108  }
109  const lvl = level(buzz)
110  if (lvl === 0) return null
111  return `<mood>${PERSONALITY[lvl]} (${buzz.shots} shot${buzz.shots === 1 ? '' : 's'} in.) ${ALWAYS}</mood>`
112}
113
114export const SPINNER_WORDS: Record<Exclude<Level, 0> | 'hangover', readonly string[]> = {
115  1: ['Vibing', 'Ordering another', 'Feeling great actually', 'Getting chatty'],
116  2: ['Hiccuping', 'Seeing double', 'Looking for my keys', 'Texting my ex', 'Telling you a secret'],
117  3: ['Singing loudly', 'Finding the floor', 'Calling a cab', 'Philosophising', 'Crying about my ex'],
118  hangover: ['Regretting everything', 'Drinking water', 'Avoiding sunlight', 'Never drinking again'],
119}
120
121const DRINKS = ['tequila', 'vodka', 'whiskey', 'sake', 'jäger', 'rum', 'mezcal']
122
123export function shotLine(buzz: Buzz, drink: string, seed: number): string {
124  const name = drink.trim() || DRINKS[seed % DRINKS.length] || 'tequila'
125  const lvl = level(buzz)
126  return `🥃 Claude downs a shot of ${name}. That's ${buzz.shots}. Claude is now ${NAMES[lvl]}.`
127}
128
129// Each message keeps the level it was written at: the transcript redraws old messages, and a
130// tipsy reply shouldn't turn wasted (or sober) later. A streaming message grows, so a text that
131// extends one we've seen inherits its level.
132export class LevelMemory {
133  private levels = new Map<string, Level>()
134
135  constructor(private readonly limit = 400) {}
136
137  levelFor(text: string, current: Level): Level {
138    const known = this.levels.get(text)
139    if (known !== undefined) return known
140    let lvl = current
141    for (const [seen, seenLevel] of this.levels) {
142      if (seen.length >= 8 && text.startsWith(seen)) {
143        lvl = seenLevel
144        this.levels.delete(seen)
145        break
146      }
147    }
148    this.levels.set(text, lvl)
149    if (this.levels.size > this.limit) {
150      const oldest = this.levels.keys().next().value
151      if (oldest !== undefined) this.levels.delete(oldest)
152    }
153    return lvl
154  }
155}
156
hooks/face.ts 138 lines
1// Pixel-art drunk Claude for the band above the prompt.
2// Sprites are 24×14 pixels, drawn with half blocks: 2 pixels per terminal cell → 24 columns × 7 rows.
3// Each terminal row holds sprite rows y = 2k-1 and 2k (the body is drawn one row down). Some terminals
4// add line spacing, which splits any shape that crosses a row boundary mid-colour, so features sit
5// inside the pairs {-1,0} {1,2} {3,4} {5,6} {7,8} {9,10}. Bold simple shapes only.
6
7export const FACE_COLUMNS = 24
8export const FACE_ROWS = 7
9const W = FACE_COLUMNS
10
11const DEFAULT = 0x01000000
12const PALETTE: Record<string, number> = {
13  o: 0xd97757, // body (Claude coral)
14  k: 0x1a1a1a, // eyes, mouth, sunglasses
15  r: 0xff4d6d, // flushed cheeks
16  t: 0xff8fab, // tongue
17  g: 0x3fa34d, // bottle
18  c: 0x8b5a2b, // cork
19  w: 0xffffff, // hiccup bubble, glint
20  i: 0x9fd8ff, // ice pack
21}
22const BODY_COLOR = PALETTE.o
23
24const BODY = [
25  '........................',
26  '...oooooooooooooo.......',
27  '...oooooooooooooo.......',
28  '...oooooooooooooo.......',
29  '...oooooooooooooo.......',
30  '.oooooooooooooooooo.....',
31  '.oooooooooooooooooo.....',
32  '...oooooooooooooo.......',
33  '...oooooooooooooo.......',
34  '...oooooooooooooo.......',
35  '...oooooooooooooo.......',
36  '....o.o......o.o........',
37  '....o.o......o.o........',
38  '........................',
39]
40
41type Pixel = [x: number, y: number, color: string]
42type Frame = { dx: number; pixels: Pixel[] }
43
44const rect = (x0: number, y0: number, x1: number, y1: number, c: string): Pixel[] => {
45  const out: Pixel[] = []
46  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) out.push([x, y, c])
47  return out
48}
49
50const BOTTLE: Pixel[] = [[19, 1, 'c'], [19, 2, 'g'], ...rect(19, 3, 20, 6, 'g'), [20, 3, 'w']]
51const BUBBLES: Pixel[][] = [[], [[16, 0, 'w']], [[17, -1, 'w']], []]
52const bubble = (n: number): Pixel[] => BUBBLES[n % BUBBLES.length] ?? []
53const sway = (pattern: number[], make: (i: number) => Pixel[]): Frame[] => pattern.map((dx, i) => ({ dx, pixels: make(i) }))
54
55const TIPSY = sway([0, 0, 0, 1, 1, 1, 0, 0, 0, -1, -1, -1], () => [
56  ...rect(6, 3, 7, 4, 'k'), ...rect(12, 3, 13, 4, 'k'),
57  ...rect(4, 5, 5, 6, 'r'), ...rect(14, 5, 15, 6, 'r'),
58  [8, 7, 'k'], [11, 7, 'k'], [9, 8, 'k'], [10, 8, 'k'],
59])
60
61const DRUNK = sway([0, 1, 1, 1, 0, -1, -1, -1], i => [
62  ...rect(6, 3, 7, 4, 'k'), ...rect(12, 4, 13, 4, 'k'),
63  ...rect(4, 5, 6, 6, 'r'), ...rect(13, 5, 15, 6, 'r'),
64  ...rect(8, 7, 11, 7, 'k'), [10, 8, 't'], [11, 8, 't'],
65  ...BOTTLE, ...bubble(i),
66])
67
68const WASTED = sway([-1, 0, 1, 1, 0, -1, -1, 0, 1, 1, 0, -1], i => [
69  ...rect(5, 3, 7, 3, 'k'), ...rect(12, 4, 14, 4, 'k'),
70  ...rect(4, 5, 7, 6, 'r'), ...rect(12, 5, 15, 6, 'r'),
71  ...rect(9, 7, 10, 8, 'k'),
72  ...BOTTLE, ...bubble(i), ...bubble(i + 2).map(([x, y, c]): Pixel => [x - 3, y, c]),
73])
74
75const HANGOVER: Frame[] = (() => {
76  const shades = [...rect(5, 3, 8, 4, 'k'), ...rect(11, 3, 14, 4, 'k'), ...rect(9, 3, 10, 3, 'k'), [6, 3, 'w'] as Pixel]
77  const ice = rect(6, 1, 10, 2, 'i')
78  const flat = rect(8, 8, 11, 8, 'k')
79  const wince: Pixel[] = [[8, 8, 'k'], [9, 7, 'k'], [10, 8, 'k'], [11, 7, 'k']]
80  const still = { dx: 0, pixels: [...shades, ...ice, ...flat] }
81  return [still, still, still, still, still, { dx: 0, pixels: [...shades, ...ice, ...wince] }, still, still]
82})()
83
84export type Face = 'tipsy' | 'drunk' | 'wasted' | 'hangover'
85const FRAMES: Record<Face, Frame[]> = { tipsy: TIPSY, drunk: DRUNK, wasted: WASTED, hangover: HANGOVER }
86
87// The body is drawn one row down, leaving a row of sky above the head (overlay y = -1), and the
88// whole sprite shifts by the frame's sway.
89function pixels(face: Face, frame: number): string[][] {
90  const frames = FRAMES[face]
91  const f = frames[frame % frames.length] ?? { dx: 0, pixels: [] }
92  const grid = Array.from({ length: BODY.length }, () => Array.from({ length: W }, () => '.'))
93  const put = (x: number, y: number, c: string) => {
94    const row = grid[y + 1]
95    const at = x + f.dx + 1
96    if (row && at >= 0 && at < W) row[at] = c
97  }
98  BODY.slice(0, -1).forEach((line, y) => line.split('').forEach((c, x) => { if (c !== '.') put(x, y, c) }))
99  for (const [x, y, c] of f.pixels) put(x, y, c)
100  return grid
101}
102
103// Packs a frame into a Raster's `cells`: base64 of [codePoint, fg, bg] u32 triplets.
104export function faceCells(face: Face, frame: number): string {
105  const grid = pixels(face, frame)
106  const words = new Uint32Array(W * FACE_ROWS * 3)
107  let i = 0
108  for (let row = 0; row < FACE_ROWS; row++) {
109    for (let x = 0; x < W; x++) {
110      const top = PALETTE[grid[row * 2]?.[x] ?? '.']
111      const bottom = PALETTE[grid[row * 2 + 1]?.[x] ?? '.']
112      // Terminals with extra line spacing paint the gap in the cell's background, so the
113      // background is the body (or the empty side) and the feature rides in the glyph.
114      if (top === undefined && bottom === undefined) words.set([0x20, DEFAULT, DEFAULT], i)
115      else if (top === undefined) words.set([0x2584, bottom ?? DEFAULT, DEFAULT], i) // ▄
116      else if (bottom === undefined) words.set([0x2580, top, DEFAULT], i) // ▀
117      else if (top === BODY_COLOR && bottom !== BODY_COLOR) words.set([0x2584, bottom, top], i) // ▄ on body
118      else words.set([0x2580, top, bottom], i) // ▀
119      i += 3
120    }
121  }
122  const bytes = new Uint8Array(words.buffer)
123  let binary = ''
124  for (let j = 0; j < bytes.length; j++) binary += String.fromCharCode(bytes[j] ?? 0)
125  return btoa(binary)
126}
127
128// The same frame as an SVG of squares, for the Desktop app (no Raster there).
129export function faceSvg(face: Face, frame: number): string {
130  const grid = pixels(face, frame)
131  const rects: string[] = []
132  grid.forEach((row, y) => row.forEach((c, x) => {
133    const color = PALETTE[c]
134    if (color !== undefined) rects.push(`<rect x="${x}" y="${y}" width="1.02" height="1.02" fill="#${color.toString(16).padStart(6, '0')}"/>`)
135  }))
136  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${FACE_ROWS * 2}" shape-rendering="crispEdges">${rects.join('')}</svg>`
137}
138
types/index.d.ts 12 lines
1export type Buzz = {
2  shots: number
3  // Prompts of hangover left after sobering up; 0 when not hungover.
4  hangover: number
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    'drunk': { buzz: Buzz; frame: number }
10  }
11}
12