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

Milestones of good habits, each raised once as a toast and kept across sessions:
| Achievement | When |
|---|---|
첫 세이브 | 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.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-achievement@bitgame-mods
intensity (casual) | off records nothing · casual · hardcore (progress toasts) |
palette (nes) | nes · gameboy · amber · theme |
좋은 습관을 업적으로 남깁니다: 첫 세이브, 가드 거절 없는 커밋 10번, 실패를 고쳐 통과(역전승), 10 콤보, HP 10% 전에 휴식. /achievements로 진행 상황을 봅니다.
hooks/register.tsx 213 lines1import 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}
213hooks/palette.ts 124 lines1// 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