SLOPSHOPPER

GAME MODE · Achievement

★ ACHIEVEMENT toasts for milestones: first save, 10 safe commits, a comeback, a 10-call combo, a rest before HP 10%; /achievements lists them

newrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-achievement
› 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 › /achievements ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ★ ACHIEVEMENTS 0/5 ║ ║ · 첫 세이브 처음으로 /save 하기 - ║ ║ · 안전 운전 가드 거절이 없던 턴의 커밋 10번 0/10 ║ ║ · 역전승 실패한 명령을 고친 뒤 같은 명령 통과 - ║ ║ · 10 콤보 성공한 행동 10번 연속 최고 5 ║ ║ · 제때 휴식 HP가 10% 아래로 떨어지기 전에 /compact - ║ ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ★ ACHIEVEMENTS 0/5 ║ ║ · 첫 세이브 처음으로 /save 하기 - ║ ║ · 안전 운전 가드 거절이 없던 턴의 커밋 10번 0/10 ║ ║ · 역전승 실패한 명령을 고친 뒤 같은 명령 통과 - ║ ║ · 10 콤보 성공한 행동 10번 연속 최고 5 ║ ║ · 제때 휴식 HP가 10% 아래로 떨어지기 전에 /compact - ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝
README

GAME MODE · Achievement

Milestones of good habits, each raised once as a toast and kept across sessions:

AchievementWhen
첫 세이브the first /save
안전 운전10 commits made in turns where no GAME MODE guard refused anything
역전승a command that failed passes after a file changed
10 콤보10 successful calls in a row
제때 휴식a /compact you ran while context left was 30% or less, before it fell under 10%
★ ACHIEVEMENT 역전승 — 실패한 명령을 고친 뒤 같은 명령 통과

/achievements lists them with progress (안전 운전 4/10, 10 콤보 최고 7).

Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-achievement@bitgame-mods

Settings

intensity (casual)off records nothing · casual · hardcore (progress toasts)
palette (nes)nes · gameboy · amber · theme

한국어

좋은 습관을 업적으로 남깁니다: 첫 세이브, 가드 거절 없는 커밋 10번, 실패를 고쳐 통과(역전승), 10 콤보, HP 10% 전에 휴식. /achievements로 진행 상황을 봅니다.

Source 2 files
hooks/register.tsx 213 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { intensityOf, paletteOf, windowProps } from './palette'
4
5// GAME MODE · ACHIEVEMENT
6//
7// Milestones of good habits, each raised once as a toast and kept across sessions:
8//   ★ ACHIEVEMENT 역전승 — 실패한 명령을 고친 뒤 같은 명령 통과
9// /achievements lists them with progress.
10
11type Achievement = { id: string; name: string; how: string; goal?: number }
12
13export const ACHIEVEMENTS: readonly Achievement[] = [
14  { id: 'first-save', name: '첫 세이브', how: '처음으로 /save 하기' },
15  { id: 'safe-10', name: '안전 운전', how: '가드 거절이 없던 턴의 커밋 10번', goal: 10 },
16  { id: 'comeback', name: '역전승', how: '실패한 명령을 고친 뒤 같은 명령 통과' },
17  { id: 'combo-10', name: '10 콤보', how: '성공한 행동 10번 연속', goal: 10 },
18  { id: 'well-rested', name: '제때 휴식', how: 'HP가 10% 아래로 떨어지기 전에 /compact' },
19]
20
21const GUARD = /\bgame-(?:trap-guard|barrier|loop-breaker)\b/
22const COMMIT = /(?:^|[;&|]\s*|\s)git\s+(?:-[^\s]+\s+(?:[^\s-][^\s]*\s+)?)*commit\b/
23const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
24
25type Record_ = { unlocked: Record<string, number>; safeCommits: number; bestCombo: number }
26
27export const register: Register = (on, options) => {
28  const intensity = intensityOf(options.intensity)
29  const pal = paletteOf(options.palette)
30  const keep: Keeper = { isOn: intensity !== 'off', book: { unlocked: {}, safeCommits: 0, bestCombo: 0 } }
31  // This session.
32  let combo = 0
33  let changes = 0
34  const failedAt = new Map<string, number>()
35  let turnCommits = 0
36  let turnRefusals = 0
37  let hpLeft: number | null = null
38
39  on('session.start', async ($, e, next) => {
40    keep.book = bookOf(await $.store.get('book'))
41    await $.command.register({ name: 'achievements', description: 'GAME MODE achievements: what you have unlocked and how far the rest are' })
42    return next(e)
43  })
44
45  on('turn.start', async ($, e, next) => {
46    turnCommits = 0
47    turnRefusals = 0
48    return next(e)
49  }).catch(($, e, next) => next(e))
50
51  on('session.measure', async ($, e, next) => {
52    const used = e.context.percent
53    if (used !== undefined && used !== null) hpLeft = 100 - used
54    return next(e)
55  }).catch(($, e, next) => next(e))
56
57  on('tool.call', async ($, e, next) => {
58    const ran = await next(e)
59    if (e.agentId !== undefined || intensity === 'off') return ran
60    const failed = ran.deny !== undefined || ran.isError === true
61    if (GUARD.test(ran.deny ?? (ran.isError === true ? ran.text ?? '' : ''))) turnRefusals++
62
63    combo = failed ? 0 : combo + 1
64    if (combo > keep.book.bestCombo) {
65      keep.book = { ...keep.book, bestCombo: combo }
66      await $.store.set('book', keep.book)
67    }
68    if (combo >= 10) await unlock($, keep, 'combo-10')
69
70    const tool = String(e.tool)
71    if (WRITES.has(tool) && !failed) changes++
72    if (tool === 'Bash') {
73      const command = String((e as { command?: unknown }).command ?? '').trim().replace(/\s+/g, ' ')
74      if (ran.isError === true) failedAt.set(command, changes)
75      else if (!failed) {
76        const was = failedAt.get(command)
77        if (was !== undefined && changes > was) await unlock($, keep, 'comeback')
78        failedAt.delete(command)
79        if (COMMIT.test(command)) turnCommits++
80      }
81    }
82    return ran
83  }).catch(($, e, next) => next(e))
84
85  // Refusals by a guard seated above this plugin, and save points, as the conversation keeps them.
86  on('session.append', async ($, e, next) => {
87    const stored = await next(e)
88    if (e.agentId !== undefined) return stored
89    const text = textOf(e.message.content)
90    if (e.door === 'tool-result' && GUARD.test(text) && hasError(e.message.content)) turnRefusals++
91    if (e.door === 'command' && text.includes('◆ SAVE POINT · ') && text.includes('[PASSWORD]')) await unlock($, keep, 'first-save').catch(() => undefined)
92    return stored
93  }).catch(($, e, next) => next(e))
94
95  on('turn.complete', async ($, e, next) => {
96    if (e.agentId === undefined && turnCommits > 0 && turnRefusals === 0 && intensity !== 'off') {
97      keep.book = { ...keep.book, safeCommits: keep.book.safeCommits + turnCommits }
98      await $.store.set('book', keep.book)
99      if (keep.book.safeCommits >= 10) await unlock($, keep, 'safe-10')
100      else if (intensity === 'hardcore') $.ui.toast(`안전 운전 ${keep.book.safeCommits}/10`)
101    }
102    return next(e)
103  }).catch(($, e, next) => next(e))
104
105  on('session.compact', async ($, e, next) => {
106    if (e.agentId === undefined && e.trigger === 'manual' && hpLeft !== null && hpLeft > 10 && hpLeft <= 30) await unlock($, keep, 'well-rested')
107    return next(e)
108  }).catch(($, e, next) => next(e))
109
110  on('command.run', { command: 'achievements' }, async () => ({ text: bookText(keep.book) }))
111
112  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
113    if (intensity === 'off' || e.props.command !== 'achievements' || e.props.isErrored) return next(e)
114    const { Box, Text } = $.ui.resolve(e)
115    const book = keep.book
116    const done = ACHIEVEMENTS.filter(a => book.unlocked[a.id] !== undefined).length
117    return (
118      <Box {...windowProps(pal)} flexDirection="column">
119        <Box flexDirection="row" gap={2}>
120          <Text color={pal.title} bold>
121            ★ ACHIEVEMENTS
122          </Text>
123          <Text color={pal.text}>{`${done}/${ACHIEVEMENTS.length}`}</Text>
124        </Box>
125        {ACHIEVEMENTS.map(a => {
126          const isDone = book.unlocked[a.id] !== undefined
127          return (
128            <Box key={a.id} flexDirection="row" gap={1}>
129              <Text color={isDone ? pal.title : pal.dim} bold>
130                {isDone ? '★' : '·'}
131              </Text>
132              <Text color={isDone ? pal.text : pal.dim} bold={isDone}>
133                {padWide(a.name, NAME_WIDTH)}
134              </Text>
135              <Box flexGrow={1} flexShrink={1}>
136                <Text color={pal.dim} wrap="truncate-end">
137                  {a.how}
138                </Text>
139              </Box>
140              <Text color={isDone ? pal.ok : pal.info}>{progressOf(a, book)}</Text>
141            </Box>
142          )
143        })}
144      </Box>
145    )
146  })
147}
148
149type Keeper = { isOn: boolean; book: Record_ }
150
151/** Columns a text takes in a terminal: Hangul and other wide letters take two. */
152export function widthOf(text: string): number {
153  let w = 0
154  for (const ch of text) w += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1
155  return w
156}
157
158export function padWide(text: string, width: number): string {
159  return text + ' '.repeat(Math.max(0, width - widthOf(text)))
160}
161
162const NAME_WIDTH = Math.max(...ACHIEVEMENTS.map(a => widthOf(a.name)))
163
164/** Unlocks an achievement once: kept across sessions, raised as a toast. */
165async function unlock($: EngineInterface, keep: Keeper, id: string): Promise<void> {
166  if (!keep.isOn || keep.book.unlocked[id] !== undefined) return
167  const a = ACHIEVEMENTS.find(x => x.id === id)
168  if (a === undefined) return
169  keep.book = { ...keep.book, unlocked: { ...keep.book.unlocked, [id]: await $.clock.now() } }
170  await $.store.set('book', keep.book)
171  $.ui.toast(`★ ACHIEVEMENT ${a.name} — ${a.how}`, { timeoutMs: 6000 })
172}
173
174export function progressOf(a: Achievement, book: Record_): string {
175  if (book.unlocked[a.id] !== undefined) return 'CLEAR'
176  if (a.id === 'safe-10') return `${Math.min(book.safeCommits, 10)}/10`
177  if (a.id === 'combo-10') return `최고 ${book.bestCombo}`
178  return '-'
179}
180
181export function bookText(book: Record_): string {
182  const done = ACHIEVEMENTS.filter(a => book.unlocked[a.id] !== undefined).length
183  return [
184    `ACHIEVEMENTS · ${done}/${ACHIEVEMENTS.length}`,
185    ...ACHIEVEMENTS.map(a => `${book.unlocked[a.id] !== undefined ? '★' : '·'} ${a.name} — ${a.how} · ${progressOf(a, book)}`),
186  ].join('\n')
187}
188
189function bookOf(value: unknown): Record_ {
190  const o = value !== null && typeof value === 'object' ? (value as Partial<Record_>) : {}
191  return {
192    unlocked: o.unlocked !== null && typeof o.unlocked === 'object' ? { ...o.unlocked } : {},
193    safeCommits: typeof o.safeCommits === 'number' ? o.safeCommits : 0,
194    bestCombo: typeof o.bestCombo === 'number' ? o.bestCombo : 0,
195  }
196}
197
198function textOf(content: readonly unknown[]): string {
199  return content
200    .map(block => {
201      if (block === null || typeof block !== 'object') return ''
202      const b = block as { type?: unknown; text?: unknown; content?: unknown }
203      if (b.type === 'text' && typeof b.text === 'string') return b.text
204      if (b.type === 'tool_result') return typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? textOf(b.content) : ''
205      return ''
206    })
207    .join('\n')
208}
209
210function hasError(content: readonly unknown[]): boolean {
211  return content.some(b => b !== null && typeof b === 'object' && (b as { is_error?: unknown }).is_error === true)
212}
213
hooks/palette.ts 124 lines
1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13  /** Headings, gold, the level. */
14  title: string
15  ok: string
16  warn: string
17  bad: string
18  info: string
19  dim: string
20  text: string
21  /** A window's border. */
22  frame: string
23  /** A window's fill; '' for none. */
24  win: string
25  /** The bright inner edge of a window, and a highlighted badge's text. */
26  edge: string
27  /** A bar's empty cells. */
28  off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32  nes: {
33    title: '#f8d830',
34    ok: '#58d854',
35    warn: '#ff8a6a',
36    bad: '#f83800',
37    info: '#3cbcfc',
38    dim: '#b8b8f0',
39    text: '#fcfcfc',
40    frame: '#5060d8',
41    win: '#1c2a8a',
42    edge: '#fcfcfc',
43    off: '#2c3a98',
44  },
45  gameboy: {
46    title: '#e0f8d0',
47    ok: '#9bbc0f',
48    warn: '#e0f8d0',
49    bad: '#f8f8a0',
50    info: '#c4e890',
51    dim: '#8bac0f',
52    text: '#e0f8d0',
53    frame: '#9bbc0f',
54    win: '#1e4a1e',
55    edge: '#e0f8d0',
56    off: '#306230',
57  },
58  amber: {
59    title: '#ffd060',
60    ok: '#ffb000',
61    warn: '#ff7040',
62    bad: '#ff5a2a',
63    info: '#ffe0a0',
64    dim: '#c89040',
65    text: '#ffd9a0',
66    frame: '#ffb000',
67    win: '#2a1700',
68    edge: '#ffe0a0',
69    off: '#4a2a00',
70  },
71  theme: {
72    title: 'claude',
73    ok: 'success',
74    warn: 'warning',
75    bad: 'error',
76    info: 'suggestion',
77    dim: 'subtle',
78    text: 'text',
79    frame: 'promptBorder',
80    win: '',
81    edge: 'text',
82    off: 'subtle',
83  },
84}
85
86export function paletteOf(name: unknown): Palette {
87  return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93  return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99  return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104  return pal.win === ''
105    ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106    : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111  const s = Math.max(0, Math.round(ms / 1000))
112  const h = Math.floor(s / 3600)
113  const m = Math.floor((s % 3600) / 60)
114  const sec = s % 60
115  const two = (n: number) => String(n).padStart(2, '0')
116  return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121  if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122  return '$' + usd.toFixed(2)
123}
124