세션마다 포켓몬 알 하나를 입력창 위에서 키운다. 세션이 일한 시간과 MR 머지로 경험치가 쌓이고, 세션이 쓴 도구에 따라 깨어날 포켓몬의 타입이 기운다

English | 한국어
Every Claude Code session you open gets its own Pokémon egg, right above the prompt. The egg rocks while Claude works, cracks after a while or when the session's MR opens, and hatches after an hour of work or the moment the MR is merged. After that it keeps gaining experience and evolves at the levels from the games.
What hatches leans toward what the session did. The egg's spots take on the color of the tools it used most, and the Pokémon that comes out leans to the matching types:
| Mostly | Spots | Leans toward |
|---|---|---|
| shell commands | red | FIRE, FIGHTING, ROCK, GROUND |
| editing files | green | GRASS, BUG |
| reading and searching | purple | PSYCHIC, GHOST, POISON |
| running tests | yellow | ELECTRIC, STEEL |
| web and MCP tools | blue | WATER, FLYING, ICE |
| subagents | brown | NORMAL, DRAGON |
claude plugin marketplace add reeny404/claude-pokemon-egg
claude plugin install pokemon-egg@claude-pokemon-egg
Then open a new session, or run /reload-plugins. The egg shows up above the prompt in the terminal.
GitHub PRs are read through the gh CLI. For GitLab MRs, set a token with read_api scope:
// ~/.claude/settings.json
{ "env": { "GITLAB_TOKEN": "<token>" } }
If you used mod-hud, its PC BOX and POKéDEX are copied over once, the first time pokemon-egg starts. mod-hud's file is only read, never changed.
| Command | What it does |
|---|---|
/pokemon-egg | This session's egg or Pokémon: work time, EXP, and what it leans toward |
/pokemon-egg box | Every Pokémon in your PC BOX, one line per species |
/pokemon-egg dex | Your POKéDEX: how many of the 151 you have seen |
/pokemon-egg help | Every option |
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. Pokémon and Pokémon names are trademarks of Nintendo, Creatures Inc. and GAME FREAK inc. Sprites and names are fetched from PokeAPI at runtime and are not part of this repository.
hooks/register.tsx 760 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Lean, Mon, Mr, Scene, SessionEgg } from '../types'
5import { rarityOf } from './dex-data'
6import { drawBand, EGG_COLUMNS, EGG_LINES, EVOLVE_MS, evolvePhase, MON_SIZE } from './egg-lib'
7import type { BandArt } from './egg-lib'
8import {
9 addTally,
10 eggNote,
11 evolveLevelOf,
12 grow,
13 groupBox,
14 hatch,
15 HATCH_MS,
16 leanOf,
17 leanOfTool,
18 LEAN_TYPES,
19 levelOf,
20 levelProgress,
21 MERGE_XP,
22 migrateDex,
23 newEgg,
24 seenOf,
25 stageOf,
26 xpBar,
27} from './grow-lib'
28import { crop, decodePng, fit } from './png-lib'
29import type { Pixels } from './png-lib'
30import {
31 gitlabProjectApi,
32 isGithubRemote,
33 isLongLivedBranch,
34 parseGithubPr,
35 parseGitlabMr,
36 parseWorktrees,
37 readEnvValue,
38 touchedPath,
39 treeOfPath,
40} from './repo-lib'
41
42// 이 세션의 알, 깨어난 포켓몬, 입력창 위에 보이는 장면
43const egg = atom({ plugin: 'pokemon-egg', key: 'egg' } as const, null)
44const mon = atom({ plugin: 'pokemon-egg', key: 'mon' } as const, null)
45const scene = atom({ plugin: 'pokemon-egg', key: 'scene' } as const, null)
46
47const config = { gitlabUrl: '', language: 'en' }
48
49// 흔들리는 알과 뛰는 포켓몬을 다시 칠하는 간격
50const TICK_MS = 250
51// 그린 직후에는 Raster 가 아직 화면에 붙지 않아 칠하기를 거절할 수 있어, 거절이 이만큼 이어질 때만 멈춘다
52const DENY_LIMIT = 8
53// 일한 시간을 알에 더하는 간격
54const FLUSH_MS = 60 * 1000
55// 한 번에 더하는 일한 시간의 끝. 일하는 중에 컴퓨터가 잠들면 그 시간은 세지 않는다
56const FLUSH_MAX_MS = 10 * 60 * 1000
57// MR 을 다시 보는 간격. 닫힌 세션의 알은 열린 세션 중 한 곳만 이 간격으로 본다
58const MR_CHECK_MS = 3 * 60 * 1000
59// 닫힌 세션의 알은 이만큼 MR 머지를 기다리다 지운다
60const EGG_KEEP_MS = 14 * 24 * 60 * 60 * 1000
61// 닫혔다는 소식 없이 이만큼 저장하지 않은 세션은 닫힌 것으로 본다
62const STALE_MS = 3 * 24 * 60 * 60 * 1000
63// 알이 깨어나는 장면에서 금 간 알이 흔들리는 시간
64const HATCH_SHAKE_MS = 2500
65// 장면 글이 남는 시간
66const CAPTION_MS = 60 * 1000
67// 일할 때 쓰는 도구. 이 도구가 다른 worktree 를 손대면 그 브랜치를 세션의 브랜치로 쓴다
68const WORK_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
69
70const eggKey = (id: string) => `egg:${id}`
71const monKey = (key: string) => `mon:${key}`
72// MR 하나는 포켓몬 하나에만 보상을 준다. 먼저 머지를 본 알의 key 가 남는다
73const claimKey = (root: string, branch: string, mr: Mr) => `mr:${root}\t${branch}\t${mr.iid}`
74
75let sessionId = ''
76// /clear 로 끝난 세션 id. 이어지는 새 세션 id 가 이 세션의 알을 받는다
77let clearedFrom: string | null = null
78// 지금 턴이 일하기 시작한 시각, 또는 일한 시간을 마지막으로 더한 시각. 쉬는 중이면 null
79let workingSince: number | null = null
80// 아직 알에 더하지 않은 도구 횟수
81let pendingTally: SessionEgg['tally'] = {}
82
83let frame = 0
84let timer: ReturnType<EngineInterface['clock']['every']> | null = null
85let denied = 0
86// 알을 그린 입력창 위 band 의 requestId. 칠할 때 이곳을 다시 칠한다
87let band: string | null = null
88// 지난번 칠할 때 장면이 움직이고 있었는지. 멈추면 글을 바꾸러 다시 그린다
89let wasMoving = false
90
91function base64Of(cells: Uint32Array): string {
92 // 엔진의 Uint8Array 는 toBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
93 const bytes = new Uint8Array(cells.buffer) as Uint8Array & { toBase64(): string }
94 return bytes.toBase64()
95}
96
97// 앞선 버전이 저장한 알은 없는 칸을 새 알의 값으로 채운다
98async function readEgg($: EngineInterface, id: string): Promise<SessionEgg | null> {
99 const saved = (await $.store.get(eggKey(id))) as Partial<SessionEgg> | undefined
100 return saved?.key ? { ...newEgg(saved.key, saved.at ?? 0), ...saved } : null
101}
102
103async function readMon($: EngineInterface, key: string): Promise<Mon | null> {
104 return ((await $.store.get(monKey(key))) as Mon | undefined) ?? null
105}
106
107async function allMons($: EngineInterface): Promise<Mon[]> {
108 const keys = (await $.store.keys()).filter(key => key.startsWith('mon:'))
109 return (await Promise.all(keys.map(key => $.store.get(key) as Promise<Mon>))).filter(Boolean)
110}
111
112// 이 세션의 알을 고친다. 닫힌 세션의 알을 대신 보는 다른 세션과 겹치지 않게 저장된 값을 읽어 고친다
113async function editOwnEgg($: EngineInterface, edit: (saved: SessionEgg) => SessionEgg): Promise<SessionEgg> {
114 const now = await $.clock.now()
115 const saved = (await readEgg($, sessionId)) ?? (await read($, egg)) ?? newEgg(sessionId, now)
116 const next = { ...edit(saved), savedAt: now }
117 await $.store.set(eggKey(sessionId), next)
118 await update($, egg, () => next)
119 return next
120}
121
122// 세션 id 의 알과 포켓몬을 불러 band 에 올린다. 처음 연 세션이면 새 알을 받는다
123async function takeEgg($: EngineInterface, id: string) {
124 sessionId = id
125 const now = await $.clock.now()
126 const saved = await readEgg($, id)
127 const mine = saved ? { ...saved, endedAt: null, savedAt: now } : newEgg(id, now)
128 await $.store.set(eggKey(id), mine)
129 await update($, egg, () => mine)
130 const partner = mine.mon ? await readMon($, mine.mon) : null
131 await update($, mon, () => partner)
132 if (partner) {
133 loadSprite($, partner.species)
134 }
135}
136
137// 세션 id 가 바뀌었으면 그 세션의 알로 바꾼다. /clear 로 바뀐 것이면 같은 일을 잇는 것이라 알을 그대로 넘긴다
138// classic.SessionStart 가 mod 에 오지 않는 환경이 있어 다음 프롬프트에서도 확인한다
139async function followSession($: EngineInterface) {
140 const id = await $.session.id()
141 if (!sessionId || id === sessionId) {
142 return
143 }
144 if (clearedFrom === sessionId) {
145 const carried = await readEgg($, sessionId)
146 if (carried) {
147 await $.store.set(eggKey(id), { ...carried, endedAt: null })
148 await $.store.delete(eggKey(sessionId))
149 }
150 }
151 clearedFrom = null
152 await takeEgg($, id)
153}
154
155// ---- 일한 시간과 도구
156
157async function flush($: EngineInterface) {
158 const now = await $.clock.now()
159 const worked = workingSince === null ? 0 : Math.max(0, Math.min(now - workingSince, FLUSH_MAX_MS))
160 if (workingSince !== null) {
161 workingSince = now
162 }
163 const tally = pendingTally
164 pendingTally = {}
165 if (worked === 0 && Object.keys(tally).length === 0) {
166 return
167 }
168 const born: { mon: Mon | null } = { mon: null }
169 await editOwnEgg($, saved => {
170 const next = { ...saved, workMs: saved.workMs + worked, tally: addTally(saved.tally, tally) }
171 if (next.mon || next.workMs < HATCH_MS) {
172 return next
173 }
174 born.mon = hatch(next, now)
175 return { ...next, mon: born.mon.key }
176 })
177 if (born.mon) {
178 await welcome($, born.mon, now)
179 } else if (worked > 0) {
180 await feed($, worked / 60_000, now)
181 }
182}
183
184// 이 세션의 알에서 막 깨어난 포켓몬을 박스에 넣고 깨어나는 장면을 띄운다
185async function welcome($: EngineInterface, born: Mon, now: number) {
186 await $.store.set(monKey(born.key), born)
187 await update($, mon, () => born)
188 await update($, scene, () => ({ kind: 'hatch', species: born.species, from: born.species, at: now }))
189 loadSprite($, born.species)
190 $.ui.toast(`Oh? Your EGG hatched into ${await nameOf($, born.species)}!`)
191}
192
193// 이 세션의 포켓몬에 경험치를 준다. 진화하면 진화 장면을 띄운다
194async function feed($: EngineInterface, xp: number, now: number) {
195 const key = (await read($, egg))?.mon
196 const saved = key ? await readMon($, key) : null
197 if (!saved) {
198 return
199 }
200 const { mon: grown, from } = grow(saved, xp)
201 await $.store.set(monKey(grown.key), grown)
202 await update($, mon, () => grown)
203 if (from !== null) {
204 await update($, scene, () => ({ kind: 'evolve', species: grown.species, from, at: now }))
205 loadSprite($, from)
206 loadSprite($, grown.species)
207 }
208}
209
210// ---- 브랜치와 MR
211
212let repoRoot: string | null = null
213
214async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
215 try {
216 const ran = await $.process.run(['git', ...args], { cwd })
217 return ran.exitCode === 0 ? ran.stdout.trim() : null
218 } catch {
219 return null
220 }
221}
222
223// 처음 켠 곳의 저장소와 브랜치. 이어 연 알은 이미 아는 브랜치를 지킨다
224async function noteStartBranch($: EngineInterface) {
225 repoRoot = (await $.session.repo())?.root ?? null
226 const mine = await read($, egg)
227 if (!repoRoot || !mine || mine.branch) {
228 return
229 }
230 const branch = await git($, await $.session.cwd(), ['rev-parse', '--abbrev-ref', 'HEAD'])
231 if (branch) {
232 await editOwnEgg($, saved => ({ ...saved, root: repoRoot, branch }))
233 }
234}
235
236// 도구가 같은 저장소의 다른 worktree 를 손대면 그 worktree 의 브랜치를 세션의 브랜치로 쓴다
237async function noteWorkPath($: EngineInterface, input: { file_path?: unknown; notebook_path?: unknown; command?: unknown }) {
238 const path = repoRoot ? touchedPath(input, await $.session.cwd()) : null
239 if (!repoRoot || !path) {
240 return
241 }
242 const listed = await git($, repoRoot, ['worktree', 'list', '--porcelain'])
243 const tree = listed ? treeOfPath(parseWorktrees(listed), path) : null
244 const mine = await read($, egg)
245 if (tree?.branch && mine && tree.branch !== mine.branch) {
246 await editOwnEgg($, saved => ({ ...saved, root: repoRoot, branch: tree.branch, mr: null, merged: false }))
247 }
248}
249
250// 환경 변수 GITLAB_TOKEN 을 먼저 보고, 없으면 저장소의 .env.local 에서 읽는다
251async function readToken($: EngineInterface, root: string): Promise<string | null> {
252 const fromEnv = await $.env.get('GITLAB_TOKEN').catch(() => undefined)
253 if (fromEnv) {
254 return fromEnv
255 }
256 try {
257 return readEnvValue(await $.fs.read(`${root}/.env.local`), 'GITLAB_TOKEN')
258 } catch {
259 return null
260 }
261}
262
263// 브랜치의 가장 최근 MR. GitHub 는 gh CLI 로, GitLab 은 API 로 찾는다. 찾을 수 없으면 null
264async function findMr($: EngineInterface, root: string, branch: string): Promise<Mr | null> {
265 try {
266 const remote = await git($, root, ['remote', 'get-url', 'origin'])
267 if (!remote) {
268 return null
269 }
270 if (isGithubRemote(remote)) {
271 const argv = ['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,state,url']
272 const ran = await $.process.run(argv, { cwd: root })
273 return ran.exitCode === 0 ? parseGithubPr(ran.stdout) : null
274 }
275 const api = gitlabProjectApi(remote, config.gitlabUrl)
276 const token = api ? await readToken($, root) : null
277 if (!api || !token) {
278 return null
279 }
280 const query = `source_branch=${encodeURIComponent(branch)}&order_by=updated_at&per_page=1`
281 const res = await $.http.fetch(`${api}/merge_requests?${query}`, { headers: { 'PRIVATE-TOKEN': token } })
282 return res.ok ? parseGitlabMr(res.text) : null
283 } catch {
284 return null
285 }
286}
287
288// 알의 MR 을 새로 보고, 처음 머지된 걸 봤으면 보상을 준다. 바뀐 알을 돌려주고 그대로면 null
289// 보상은 아직 알이면 깨어나기, 깨어났으면 경험치다. 다른 세션의 알이면 장면 없이 알림만 띄운다
290async function checkMr($: EngineInterface, saved: SessionEgg, now: number, isOwn: boolean): Promise<SessionEgg | null> {
291 const { root, branch } = saved
292 if (!root || !branch || isLongLivedBranch(branch) || saved.merged) {
293 return null
294 }
295 const mr = await findMr($, root, branch)
296 if (!mr || JSON.stringify(mr) === JSON.stringify(saved.mr)) {
297 return null
298 }
299 if (mr.state !== 'merged') {
300 return { ...saved, mr }
301 }
302 const claim = claimKey(root, branch, mr)
303 // shortcut: 읽고 쓰는 사이에 다른 세션이 같은 MR 머지를 보면 둘 다 보상받을 수 있음, $.store 에 비교 후 쓰기가 생기면 그걸로 바꾼다
304 const owner = (await $.store.get(claim)) as string | undefined
305 if (owner && owner !== saved.key) {
306 return { ...saved, mr, merged: true }
307 }
308 await $.store.set(claim, saved.key)
309 if (!saved.mon) {
310 const born = hatch(saved, now)
311 if (isOwn) {
312 await welcome($, born, now)
313 } else {
314 await $.store.set(monKey(born.key), born)
315 $.ui.toast(`!${mr.iid} was merged and its EGG hatched into ${await nameOf($, born.species)}!`)
316 }
317 return { ...saved, mr, merged: true, mon: born.key }
318 }
319 if (isOwn) {
320 await feed($, MERGE_XP, now)
321 } else {
322 const kept = await readMon($, saved.mon)
323 if (kept) {
324 await $.store.set(monKey(kept.key), grow(kept, MERGE_XP).mon)
325 $.ui.toast(`!${mr.iid} was merged and ${await nameOf($, kept.species)} gained ${MERGE_XP} EXP.`)
326 }
327 }
328 return { ...saved, mr, merged: true }
329}
330
331async function checkMrs($: EngineInterface) {
332 const now = await $.clock.now()
333 const own = await read($, egg)
334 const checked = own ? await checkMr($, own, now, true) : null
335 if (checked) {
336 await editOwnEgg($, saved => ({ ...saved, mr: checked.mr, merged: checked.merged, mon: checked.mon }))
337 }
338 // 닫힌 세션의 알은 열린 세션 중 한 곳만 본다
339 // shortcut: 두 세션이 같은 순간 시각을 읽으면 둘 다 닫힌 세션의 알을 봄, 보상은 claim 이 막으니 MR 조회만 겹친다
340 const last = Number((await $.store.get('poll:at')) ?? 0)
341 if (now - last < MR_CHECK_MS) {
342 return
343 }
344 await $.store.set('poll:at', now)
345 for (const key of await $.store.keys()) {
346 if (!key.startsWith('egg:') || key === eggKey(sessionId)) {
347 continue
348 }
349 const saved = await readEgg($, key.slice('egg:'.length))
350 // shortcut: 3일 넘게 일하지 않고 열어만 둔 세션도 닫힌 것으로 봄, 세션이 살아 있다는 표시를 따로 남길 때 그걸로 가린다
351 const endedAt = saved?.endedAt ?? (saved && now - saved.savedAt > STALE_MS ? saved.savedAt : null)
352 if (!saved || endedAt === null) {
353 continue
354 }
355 // 더 기다릴 머지가 없는 알은 지운다. 깨어난 포켓몬은 박스에 남는다
356 const isDone = saved.merged || !saved.branch || isLongLivedBranch(saved.branch) || saved.mr?.state === 'closed'
357 if (isDone || now - endedAt > EGG_KEEP_MS) {
358 await $.store.delete(key)
359 continue
360 }
361 const next = await checkMr($, saved, now, false)
362 if (next) {
363 await $.store.set(key, next)
364 }
365 }
366}
367
368// ---- 기록 이전
369
370// mod-hud 의 PC BOX 와 도감을 한 번 옮겨 온다. 원본 파일은 읽기만 한다
371async function migrateOnce($: EngineInterface) {
372 if (await $.store.get('migrated')) {
373 return
374 }
375 await $.store.set('migrated', true)
376 const home = await $.env.get('HOME')
377 let text: string
378 try {
379 text = await $.fs.read(`${home}/.claude/mod-hud-pokedex.json`)
380 } catch {
381 return
382 }
383 const { mons, seen } = migrateDex(text)
384 for (const one of mons) {
385 if (!(await $.store.get(monKey(one.key)))) {
386 await $.store.set(monKey(one.key), one)
387 }
388 }
389 await $.store.set('legacy:seen', seen)
390 if (mons.length > 0) {
391 $.ui.toast(`Moved ${mons.length} Pokémon over from mod-hud's PC BOX.`)
392 }
393}
394
395// ---- 스프라이트와 이름
396
397// 포켓몬 그림과 이름은 PokeAPI 에서 처음 쓸 때 한 번 받아 이 폴더에 두고 다시 쓴다
398const SPRITE_DIR = '.claude/pokemon-egg-sprites'
399// 7세대 박스 아이콘. 처음부터 작게 그린 도트라 줄여도 덜 뭉개진다
400const spriteUrl = (species: number) =>
401 `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/icons/${species}.png`
402const speciesUrl = (species: number) => `https://pokeapi.co/api/v2/pokemon-species/${species}`
403
404// 읽은 그림은 MON_SIZE 안에 들어가게 줄여 둔다. null 은 받은 파일이 깨져 이 세션에서는 다시 읽지 않는 것
405const sprites = new Map<number, Pixels | null>()
406const spritesLoading = new Set<number>()
407
408// 받지 못하면 그동안 몬스터볼을 그리고, 받으면 다시 그린다
409function loadSprite($: EngineInterface, species: number) {
410 if (sprites.has(species) || spritesLoading.has(species)) {
411 return
412 }
413 spritesLoading.add(species)
414 void (async () => {
415 try {
416 const home = await $.env.get('HOME')
417 if (!home) {
418 return
419 }
420 const path = `${home}/${SPRITE_DIR}/icons/${species}.png`
421 if (!(await $.fs.exists(path))) {
422 const got = await $.process.run(['curl', '-fsSL', '--create-dirs', '-o', path, spriteUrl(species)])
423 if (got.exitCode !== 0) {
424 return
425 }
426 }
427 const { base64 } = await $.fs.read(path, { as: 'bytes' })
428 // 엔진의 Uint8Array 는 fromBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
429 const bytes = (Uint8Array as unknown as { fromBase64(text: string): Uint8Array }).fromBase64(base64)
430 try {
431 sprites.set(species, fit(crop(decodePng(bytes)), MON_SIZE))
432 $.ui.invalidate('ui.render')
433 } catch {
434 // 받다 끊긴 파일은 지워 다음 세션에서 다시 받게 한다
435 sprites.set(species, null)
436 await $.process.run(['rm', '-f', path])
437 }
438 } catch {
439 // 네트워크나 파일을 못 쓰면 몬스터볼로 둔다
440 } finally {
441 spritesLoading.delete(species)
442 }
443 })()
444}
445
446const spriteOf = (species: number) => sprites.get(species) ?? undefined
447
448// 설정한 언어의 이름. 파일에서 한 번 읽어 메모리에 두고, 모르는 이름은 PokeAPI 에서 받아 파일에 더한다
449const names = new Map<number, string>()
450const namesFailed = new Set<number>()
451let namesRead: Promise<void> | null = null
452
453const namesPath = (home: string) => `${home}/${SPRITE_DIR}/names-${config.language}.json`
454
455async function nameOf($: EngineInterface, species: number): Promise<string> {
456 const home = (await $.env.get('HOME')) ?? ''
457 namesRead ??= (async () => {
458 try {
459 const saved = JSON.parse(await $.fs.read(namesPath(home))) as Record<string, string>
460 for (const [no, name] of Object.entries(saved)) {
461 names.set(Number(no), name)
462 }
463 } catch {
464 // 처음이면 파일이 없다
465 }
466 })()
467 await namesRead
468 if (!names.has(species) && !namesFailed.has(species)) {
469 try {
470 const res = await $.http.fetch(speciesUrl(species))
471 const parsed = res.ok ? (JSON.parse(res.text) as { names?: { name: string; language: { name: string } }[] }) : null
472 const name = parsed?.names?.find(one => one.language.name === config.language)?.name
473 if (name) {
474 names.set(species, name)
475 await $.fs.write(namesPath(home), JSON.stringify(Object.fromEntries(names)))
476 } else {
477 namesFailed.add(species)
478 }
479 } catch {
480 namesFailed.add(species)
481 }
482 }
483 return shownName(species)
484}
485
486const shownName = (species: number) => (names.get(species) ?? `No.${String(species).padStart(3, '0')}`).toUpperCase()
487
488// 그리는 동안에는 네트워크를 기다리지 않는다. 모르는 이름은 받아 온 뒤 다시 그린다
489const namesAsked = new Set<number>()
490
491function drawnName($: EngineInterface, species: number): string {
492 if (!names.has(species) && !namesAsked.has(species)) {
493 namesAsked.add(species)
494 void nameOf($, species).then(() => $.ui.invalidate('ui.render'))
495 }
496 return shownName(species)
497}
498
499// ---- 그리기
500
501type View = { egg: SessionEgg; mon: Mon | null; scene: Scene | null; isWorking: boolean }
502let view: View | null = null
503
504// 장면 그림이 아직 움직이는지. 움직이는 동안은 일하지 않아도 칠한다
505function isSceneMoving(shown: Scene | null, now: number): boolean {
506 if (!shown) {
507 return false
508 }
509 return now - shown.at < (shown.kind === 'hatch' ? HATCH_SHAKE_MS : EVOLVE_MS)
510}
511
512function artOf(shown: View, now: number): BandArt {
513 const elapsed = shown.scene ? now - shown.scene.at : Infinity
514 if (shown.scene?.kind === 'hatch' && elapsed < HATCH_SHAKE_MS) {
515 return { kind: 'egg', key: shown.egg.key, stage: 'cracked', lean: leanOf(shown.egg.tally), isShaking: true }
516 }
517 if (shown.scene?.kind === 'evolve' && elapsed < EVOLVE_MS) {
518 return { kind: 'evolve', before: spriteOf(shown.scene.from), after: spriteOf(shown.scene.species), elapsed }
519 }
520 if (shown.mon) {
521 return { kind: 'mon', image: spriteOf(shown.mon.species), isHopping: shown.isWorking }
522 }
523 const stage = stageOf(shown.egg)
524 return { kind: 'egg', key: shown.egg.key, stage: stage === 'hatched' ? 'cracked' : stage, lean: leanOf(shown.egg.tally), isShaking: shown.isWorking }
525}
526
527function startTicker($: EngineInterface) {
528 denied = 0
529 timer ??= $.clock.every(TICK_MS, () => void tick($))
530}
531
532function stopTicker() {
533 timer?.cancel()
534 timer = null
535 frame = 0
536}
537
538async function tick($: EngineInterface) {
539 if (!band || !view) {
540 stopTicker()
541 return
542 }
543 frame += 1
544 const now = await $.clock.now()
545 const isMoving = isSceneMoving(view.scene, now)
546 // 장면이 끝나면 글을 바꾸고, 일도 안 하면 멈추게 다시 그린다
547 if (wasMoving && !isMoving) {
548 $.ui.invalidate('ui.render')
549 }
550 wasMoving = isMoving
551 const got = await $.ui.blit({ requestId: band, key: 'egg', cells: base64Of(drawBand(artOf(view, now), frame)) })
552 denied = got.deny === undefined ? 0 : denied + 1
553 if (denied >= DENY_LIMIT) {
554 stopTicker()
555 }
556}
557
558// 그림 옆 세 줄. 이름과 레벨, 경험치 막대, 원작 같은 한 줄이다
559function captionsOf($: EngineInterface, shown: View, now: number): [string, string, string] {
560 const { mon: partner, scene: last } = shown
561 if (!partner || (last?.kind === 'hatch' && now - last.at < HATCH_SHAKE_MS)) {
562 const progress = Math.min(1, shown.egg.workMs / HATCH_MS)
563 const note = last?.kind === 'hatch' ? 'Oh?' : eggNote(shown.egg)
564 return ['EGG', xpBar(progress), note]
565 }
566 const name = drawnName($, partner.species)
567 const level = levelOf(partner.xp)
568 const title = `${name}${rarityOf(partner.species) ? ' ✧' : ''} Lv${level}`
569 const elapsed = last ? now - last.at : Infinity
570 let note: string
571 if (last?.kind === 'evolve' && elapsed < EVOLVE_MS + CAPTION_MS) {
572 const from = drawnName($, last.from)
573 note = evolvePhase(elapsed) === 'done' ? `Congratulations! Your ${from} evolved into ${name}!` : `What? ${from} is evolving!`
574 } else if (last?.kind === 'hatch' && elapsed < CAPTION_MS) {
575 note = `${name} hatched from the EGG!`
576 } else {
577 const next = evolveLevelOf(partner.species)
578 note = next === null ? `${name} is fully evolved.` : next > level ? `Evolves at Lv${next}.` : 'It may evolve soon.'
579 }
580 return [title, xpBar(levelProgress(partner.xp)), note]
581}
582
583// ---- 명령
584
585const HELP = [
586 '/pokemon-egg This session\'s egg or Pokémon: work time, EXP and what it leans toward',
587 '/pokemon-egg box Every Pokémon in your PC BOX, one line per species',
588 '/pokemon-egg dex Your POKéDEX: how many of the 151 you have seen',
589 '/pokemon-egg help This list',
590 '',
591 'An egg cracks after 20 minutes of work or when its MR opens, and hatches after 60 minutes or when the MR is merged.',
592 'After that, every minute of work is 1 EXP and a merged MR is 60. Pokémon evolve at their levels from the games.',
593 'What hatches leans toward the tools the session used most: shell, edit, read, test, web or agent.',
594 'Set GITLAB_TOKEN (read_api) for GitLab MRs; GitHub PRs are read through the gh CLI.',
595].join('\n')
596
597async function statusText($: EngineInterface): Promise<string> {
598 const mine = await read($, egg)
599 if (!mine) {
600 return 'No egg yet.'
601 }
602 const lean = leanOf(mine.tally)
603 const leaning = lean ? `${lean} (${LEAN_TYPES[lean].join('/')})` : 'nothing yet'
604 const mr = mine.mr ? `, MR !${mine.mr.iid} ${mine.mr.state}` : ''
605 const partner = await read($, mon)
606 if (!partner) {
607 const minutes = Math.floor(mine.workMs / 60_000)
608 return `EGG: ${stageOf(mine)}, worked ${minutes} of ${HATCH_MS / 60_000} minutes${mr}. Leaning toward ${leaning}.`
609 }
610 const next = evolveLevelOf(partner.species)
611 const evolve = next === null ? 'fully evolved' : `evolves at Lv${next}`
612 return `${await nameOf($, partner.species)} Lv${levelOf(partner.xp)}, ${Math.floor(partner.xp)} EXP, ${evolve}${mr}.`
613}
614
615async function boxText($: EngineInterface): Promise<string> {
616 const groups = groupBox(await allMons($))
617 if (groups.length === 0) {
618 return 'Your PC BOX is empty.'
619 }
620 const lines = await Promise.all(
621 groups.map(async group => {
622 const count = group.count > 1 ? ` ×${group.count}` : ''
623 return `No.${String(group.species).padStart(3, '0')} ${await nameOf($, group.species)}${count} Lv${group.level}`
624 }),
625 )
626 return [`PC BOX ${groups.reduce((sum, group) => sum + group.count, 0)}`, ...lines].join('\n')
627}
628
629async function dexText($: EngineInterface): Promise<string> {
630 const legacy = ((await $.store.get('legacy:seen')) as number[] | undefined) ?? []
631 const seen = seenOf(await allMons($), legacy)
632 const listed = await Promise.all(seen.map(async no => `No.${String(no).padStart(3, '0')} ${await nameOf($, no)}`))
633 return [`POKéDEX ${seen.length}/151`, ...listed].join('\n')
634}
635
636export const register: Register = (on, options) => {
637 config.gitlabUrl = typeof options.gitlabUrl === 'string' ? options.gitlabUrl : ''
638 // 모르는 값이면 영어로 둔다
639 config.language = options.pokemonLanguage === 'ko' ? 'ko' : 'en'
640
641 on('session.start', async ($, e, next) => {
642 await $.command.register({ name: 'pokemon-egg', description: 'Your Pokémon egg for this session. box and dex list what you have' })
643 await migrateOnce($)
644 await takeEgg($, await $.session.id())
645 await noteStartBranch($)
646 $.clock.every(FLUSH_MS, () => void flush($))
647 $.clock.every(MR_CHECK_MS, () => void checkMrs($))
648 void checkMrs($)
649 return next(e)
650 })
651
652 // /clear 는 새 세션 id 로 이어지고 session.start 가 다시 오지 않는다. 같은 일을 잇는 것이라 알도 그대로 넘긴다
653 // 세션 안에서 /resume 으로 다른 세션을 열면 그 세션의 알로 바꾼다
654 on('classic.SessionStart', async ($, e, next) => {
655 if (e.source === 'clear') {
656 clearedFrom = sessionId
657 }
658 await followSession($)
659 return next(e)
660 }).catch(($, e, next) => next(e))
661
662 on('prompt.submit', async ($, e, next) => {
663 await followSession($)
664 workingSince ??= await $.clock.now()
665 return next(e)
666 }).catch(($, e, next) => next(e))
667
668 on('turn.complete', async ($, e, next) => {
669 const done = await next(e)
670 // 서브에이전트의 턴이 끝나도 메인 세션은 아직 일하는 중이다
671 if (e.agentId === undefined) {
672 await flush($)
673 workingSince = null
674 }
675 return done
676 })
677
678 on('tool.call', async ($, e, next) => {
679 const ran = await next(e)
680 if (ran.deny !== undefined || ran.isError) {
681 return ran
682 }
683 const lean = leanOfTool(e.tool, e as { command?: unknown })
684 if (lean) {
685 pendingTally = addTally(pendingTally, { [lean]: 1 } as Partial<Record<Lean, number>>)
686 }
687 // 서브에이전트가 손댄 곳은 세션이 주로 쓰는 worktree 로 치지 않는다
688 if (WORK_TOOLS.has(e.tool) && !e.agentId) {
689 await noteWorkPath($, e as { file_path?: unknown; notebook_path?: unknown; command?: unknown })
690 }
691 return ran
692 }).catch(($, e, next) => next(e))
693
694 on('session.end', async ($, e, next) => {
695 await flush($)
696 workingSince = null
697 // /clear 는 다음 세션이 알을 이어 받는다
698 if (e.reason === 'clear') {
699 clearedFrom = sessionId
700 } else {
701 const now = await $.clock.now()
702 await editOwnEgg($, saved => ({ ...saved, endedAt: now }))
703 }
704 return next(e)
705 })
706
707 on('command.run', { command: 'pokemon-egg' }, async ($, e) => {
708 const arg = e.args.trim()
709 if (arg === '') {
710 return { text: await statusText($) }
711 }
712 if (arg === 'box') {
713 return { text: await boxText($) }
714 }
715 if (arg === 'dex') {
716 return { text: await dexText($) }
717 }
718 if (['help', '--help', '-h'].includes(arg)) {
719 return { text: HELP }
720 }
721 return { text: `Unknown argument "${arg}". See /pokemon-egg help.` }
722 })
723
724 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
725 const below = await next(e)
726 const mine = await read($, egg)
727 // Raster 는 터미널만 그리고 다른 곳에서는 빈 조각이 된다. 설문이 떠 있거나 그림이 들어갈 높이가 없으면 다른 플러그인의 band 만 둔다
728 if (!mine || e.surface !== 'terminal' || e.props.hasSurvey || e.props.maxRows < EGG_LINES) {
729 stopTicker()
730 return below
731 }
732 const now = await $.clock.now()
733 view = { egg: mine, mon: await read($, mon), scene: await read($, scene), isWorking: e.props.isWorking }
734 band = e.requestId
735 wasMoving = isSceneMoving(view.scene, now)
736 if (view.isWorking || wasMoving) {
737 startTicker($)
738 } else {
739 stopTicker()
740 }
741 const [title, bar, note] = captionsOf($, view, now)
742 const { Box, Raster, Text } = $.ui.resolve(e)
743 return (
744 <Box flexDirection="column">
745 {below}
746 <Box>
747 <Raster key="egg" columns={EGG_COLUMNS} rows={EGG_LINES} cells={base64Of(drawBand(artOf(view, now), frame))} />
748 <Box flexDirection="column" justifyContent="flex-end" marginLeft={1} flexShrink={1}>
749 <Text bold>{title}</Text>
750 <Text color="green">{bar}</Text>
751 <Text dimColor wrap="truncate-end">
752 {note}
753 </Text>
754 </Box>
755 </Box>
756 </Box>
757 )
758 })
759}
760hooks/dex-data.ts 87 lines1// 1세대 도감 데이터. PokeAPI 의 pokemon-species 에서 뽑았고 바뀌지 않으니 박아 둔다
2// 진화도 1세대 안에서만 한다. 2세대 이후에 생긴 진화 전 모습(피츄, 먹고자 등)과 진화형(크로뱃 등)은 없는 것으로 친다
3
4// 알에서 나오는 종. 진화 전 모습이고 전설이 아닌 74종
5export const EGG_SPECIES: readonly number[] = [
6 1, 4, 7, 10, 13, 16, 19, 21, 23, 25, 27, 29, 32, 35, 37, 39, 41, 43, 46, 48, 50, 52, 54, 56, 58, 60, 63, 66, 69, 72, 74,
7 77, 79, 81, 83, 84, 86, 88, 90, 92, 95, 96, 98, 100, 102, 104, 106, 107, 108, 109, 111, 113, 114, 115, 116, 118, 120,
8 122, 123, 124, 125, 126, 127, 128, 129, 131, 132, 133, 137, 138, 140, 142, 143, 147,
9]
10
11// 종마다 진화하는 다음 종. 이브이만 셋으로 갈라진다
12export const EVOLUTIONS: Readonly<Record<number, readonly number[]>> = {
13 1: [2], 2: [3], 4: [5], 5: [6], 7: [8], 8: [9], 10: [11], 11: [12], 13: [14], 14: [15], 16: [17], 17: [18], 19: [20],
14 21: [22], 23: [24], 25: [26], 27: [28], 29: [30], 30: [31], 32: [33], 33: [34], 35: [36], 37: [38], 39: [40], 41: [42],
15 43: [44], 44: [45], 46: [47], 48: [49], 50: [51], 52: [53], 54: [55], 56: [57], 58: [59], 60: [61], 61: [62], 63: [64],
16 64: [65], 66: [67], 67: [68], 69: [70], 70: [71], 72: [73], 74: [75], 75: [76], 77: [78], 79: [80], 81: [82], 84: [85],
17 86: [87], 88: [89], 90: [91], 92: [93], 93: [94], 96: [97], 98: [99], 100: [101], 102: [103], 104: [105], 109: [110],
18 111: [112], 116: [117], 118: [119], 120: [121], 129: [130], 133: [134, 135, 136], 138: [139], 140: [141], 147: [148],
19 148: [149],
20}
21
22export const LEGENDS = { articuno: 144, zapdos: 145, moltres: 146, mewtwo: 150, mew: 151 } as const
23
24// 원작에서 귀한 포켓몬. 한 번만 받거나, 화석에서 되살리거나, 사파리존이나 게임코너에서만 만나거나, 드물게 나오는 종이다
25const RARE_SPECIES: ReadonlySet<number> = new Set([
26 83, 106, 107, 108, 113, 115, 122, 123, 124, 125, 126, 127, 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141,
27 142, 143, 147, 148, 149,
28])
29
30// PC BOX 줄의 이름 뒤에 붙는 표시. 전설은 ✦, 귀한 포켓몬은 ✧ 이다
31export function rarityOf(species: number): 'legend' | 'rare' | null {
32 if ((Object.values(LEGENDS) as number[]).includes(species)) {
33 return 'legend'
34 }
35 return RARE_SPECIES.has(species) ? 'rare' : null
36}
37
38export function canEvolve(species: number): boolean {
39 return EVOLUTIONS[species] !== undefined
40}
41
42// 돌이나 통신 교환으로 진화하는 종은 이 레벨에 진화한 것으로 친다
43const STONE = 30
44const TRADE = 36
45
46// 종마다 다음 모습으로 진화하는 레벨. 원작 레드, 블루 기준이다
47export const EVOLVE_LEVEL: Readonly<Record<number, number>> = {
48 1: 16, 2: 32, 4: 16, 5: 36, 7: 16, 8: 36, 10: 7, 11: 10, 13: 7, 14: 10, 16: 18, 17: 36, 19: 20, 21: 20, 23: 22,
49 25: STONE, 27: 22, 29: 16, 30: STONE, 32: 16, 33: STONE, 35: STONE, 37: STONE, 39: STONE, 41: 22, 43: 21, 44: STONE,
50 46: 24, 48: 31, 50: 26, 52: 28, 54: 33, 56: 28, 58: STONE, 60: 25, 61: STONE, 63: 16, 64: TRADE, 66: 28, 67: TRADE,
51 69: 21, 70: STONE, 72: 30, 74: 25, 75: TRADE, 77: 40, 79: 37, 81: 30, 84: 31, 86: 34, 88: 38, 90: STONE, 92: 25,
52 93: TRADE, 96: 26, 98: 28, 100: 30, 102: STONE, 104: 28, 109: 35, 111: 42, 116: 32, 118: 33, 120: STONE, 129: 20,
53 133: STONE, 138: 40, 140: 40, 147: 30, 148: 55,
54}
55
56// 진화하기 전 모습. 진화 전 모습이 없으면 null
57export function preEvolutionOf(species: number): number | null {
58 const found = Object.entries(EVOLUTIONS).find(([, next]) => next.includes(species))
59 return found ? Number(found[0]) : null
60}
61
62// 종마다 속성. 도감 번호 순서다. 원작 골드 버전(2세대) 기준이라 코일은 강철이 붙고 삐삐는 페어리가 아닌 노말이다
63const TYPES: readonly string[] = [
64 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'FIRE', 'FIRE', 'FIRE/FLYING', 'WATER', 'WATER', 'WATER', 'BUG',
65 'BUG', 'BUG/FLYING', 'BUG/POISON', 'BUG/POISON', 'BUG/POISON', 'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING',
66 'NORMAL', 'NORMAL', 'NORMAL/FLYING', 'NORMAL/FLYING', 'POISON', 'POISON', 'ELECTRIC', 'ELECTRIC', 'GROUND', 'GROUND',
67 'POISON', 'POISON', 'POISON/GROUND', 'POISON', 'POISON', 'POISON/GROUND', 'NORMAL', 'NORMAL', 'FIRE', 'FIRE',
68 'NORMAL', 'NORMAL', 'POISON/FLYING', 'POISON/FLYING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'BUG/GRASS',
69 'BUG/GRASS', 'BUG/POISON', 'BUG/POISON', 'GROUND', 'GROUND', 'NORMAL', 'NORMAL', 'WATER', 'WATER', 'FIGHTING',
70 'FIGHTING', 'FIRE', 'FIRE', 'WATER', 'WATER', 'WATER/FIGHTING', 'PSYCHIC', 'PSYCHIC', 'PSYCHIC', 'FIGHTING',
71 'FIGHTING', 'FIGHTING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'WATER/POISON', 'WATER/POISON', 'ROCK/GROUND',
72 'ROCK/GROUND', 'ROCK/GROUND', 'FIRE', 'FIRE', 'WATER/PSYCHIC', 'WATER/PSYCHIC', 'ELECTRIC/STEEL', 'ELECTRIC/STEEL',
73 'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING', 'WATER', 'WATER/ICE', 'POISON', 'POISON', 'WATER', 'WATER/ICE',
74 'GHOST/POISON', 'GHOST/POISON', 'GHOST/POISON', 'ROCK/GROUND', 'PSYCHIC', 'PSYCHIC', 'WATER', 'WATER', 'ELECTRIC',
75 'ELECTRIC', 'GRASS/PSYCHIC', 'GRASS/PSYCHIC', 'GROUND', 'GROUND', 'FIGHTING', 'FIGHTING', 'NORMAL', 'POISON',
76 'POISON', 'GROUND/ROCK', 'GROUND/ROCK', 'NORMAL', 'GRASS', 'NORMAL', 'WATER', 'WATER', 'WATER', 'WATER', 'WATER',
77 'WATER/PSYCHIC', 'PSYCHIC', 'BUG/FLYING', 'ICE/PSYCHIC', 'ELECTRIC', 'FIRE', 'BUG', 'NORMAL', 'WATER', 'WATER/FLYING',
78 'WATER/ICE', 'NORMAL', 'NORMAL', 'WATER', 'ELECTRIC', 'FIRE', 'NORMAL', 'ROCK/WATER', 'ROCK/WATER', 'ROCK/WATER',
79 'ROCK/WATER', 'ROCK/FLYING', 'NORMAL', 'ICE/FLYING', 'ELECTRIC/FLYING', 'FIRE/FLYING', 'DRAGON', 'DRAGON',
80 'DRAGON/FLYING', 'PSYCHIC', 'PSYCHIC',
81]
82
83// 1세대가 아닌 종은 속성을 모르는 것으로 빈 글자를 돌려준다
84export function typesOf(species: number): string {
85 return TYPES[species - 1] ?? ''
86}
87hooks/egg-lib.ts 343 lines1// 입력창 위에 그리는 세션의 포켓몬 알과 깨어난 포켓몬. 알 그림과 진화 장면은 mod-hud 에서 옮겨 왔다
2// 알 key 로 무늬와 금 모양이 정해지고, 반점 색은 세션이 기운 쪽을 따른다. 세션이 일하는 동안 오뚝이처럼 흔들린다
3
4import type { Lean } from '../types'
5import type { Pixels } from './png-lib'
6import { CLEAR } from './png-lib'
7
8const UPPER_HALF = 0x2580
9const LOWER_HALF = 0x2584
10const SPACE = 0x20
11
12const C = {
13 // 4세대 알 스프라이트의 색
14 eggLine: 0x3a3428,
15 eggShine: 0xffffff,
16 egg: 0xfff6de,
17 eggLight: 0xeee6bd,
18 eggShade: 0xd5cda4,
19 spot: 0x9ccd83,
20 spotLight: 0xcde6b4,
21 spotShade: 0x83b46a,
22 crack: 0x5a4030,
23 sparkleHot: 0xffffff,
24 ballRed: 0xe8384f,
25 ballWhite: 0xf4f4f4,
26 ballLine: 0x2a2a2a,
27} as const
28
29// 기운 쪽마다 반점의 색. 가운데, 밝은 쪽, 어두운 쪽이다. 아무 도구도 안 쓴 알은 흐린 무늬라 일할수록 색이 드러난다
30const SPOT_COLORS: Readonly<Record<Lean | 'none', readonly [number, number, number]>> = {
31 none: [0xddd5bc, 0xe8e0c8, 0xcbc3a8],
32 shell: [0xe8784f, 0xf4b090, 0xc85a35],
33 edit: [C.spot, C.spotLight, C.spotShade],
34 read: [0xb08ad8, 0xd4bdf0, 0x8c66b8],
35 test: [0xf2d24b, 0xf8e690, 0xd0b030],
36 web: [0x7ab4e8, 0xb4d6f4, 0x5a90c8],
37 agent: [0xc8a080, 0xe0c8b0, 0xa88060],
38}
39
40// 알 몸통 14x13. 4세대 알 스프라이트를 둥글게 줄여 다시 찍은 것. 외곽선, 위쪽 하이라이트, 오른쪽 아래 그림자
41const EGG_BODY = [
42 '.....kkkk.....',
43 '...kkweeekk...',
44 '..kewweeeehk..',
45 '.keewweeeeehk.',
46 '.keeeeeeeeehk.',
47 'keeeeeeeeeehhk',
48 'keeeeeeeeeehhk',
49 'keeeeeeeeehhhk',
50 'keeeeeeeehhhhk',
51 'keeeeeeehhhhhk',
52 '.kssssssssssk.',
53 '..kksssssskk..',
54 '....kkkkkk....',
55]
56// 연두 반점 무늬. 알마다 하나를 골라 몸통에 칠한다. (x, y) 는 몸통 폭과 키에 대한 비율, r 은 폭에 대한 비율인 원이다
57const SPOTS: ReadonlyArray<ReadonlyArray<readonly [number, number, number]>> = [
58 // 4세대 알의 무늬
59 [[0.12, 0.5, 0.2], [0.6, 0.5, 0.09], [0.8, 0.66, 0.16], [0.3, 0.9, 0.12], [0.84, 0.9, 0.05]],
60 // 점 셋
61 [[0.25, 0.4, 0.13], [0.7, 0.45, 0.13], [0.48, 0.78, 0.13]],
62 // 왼쪽에 크게
63 [[0.1, 0.55, 0.28], [0.75, 0.75, 0.08]],
64 // 사선으로 셋
65 [[0.25, 0.3, 0.1], [0.5, 0.55, 0.12], [0.78, 0.8, 0.14]],
66 // 오른쪽 위에 둘
67 [[0.7, 0.3, 0.16], [0.35, 0.72, 0.1]],
68 // 기본 알. 위 오른쪽, 왼쪽 큰 점, 오른쪽 아래, 아래
69 [[0.6, 0.27, 0.13], [0.2, 0.52, 0.17], [0.85, 0.6, 0.15], [0.42, 0.88, 0.15]],
70]
71const EGG_PALETTE = {
72 k: C.eggLine,
73 w: C.eggShine,
74 e: C.egg,
75 h: C.eggLight,
76 s: C.eggShade,
77 G: C.spot,
78 g: C.spotLight,
79 D: C.spotShade,
80 c: C.crack,
81}
82// 금이 간 알의 금 모양들. 알마다 하나를 골라 그린다
83const CRACKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
84 // 위에서 내려오다 아래에서 두 갈래로 갈라진다
85 [[5, 2], [6, 3], [7, 4], [7, 5], [6, 6], [8, 6], [5, 7], [9, 7], [6, 8]],
86 // 가운데 짧은 금
87 [[7, 3], [6, 4], [7, 5]],
88 // 지그재그로 내려오다 옆으로 한 칸 갈라진다
89 [[7, 2], [6, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
90 // 껍질 꼭대기가 깨져 두 갈래로 내려온다
91 [[7, 0], [7, 1], [6, 2], [8, 2], [6, 3], [7, 4]],
92 // 오른쪽 위에서 내려오다 옆으로 한 칸 갈라진다
93 [[8, 2], [7, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
94]
95// 일하는 세션의 알이 기우는 각도(도). 오뚝이처럼 크게 한 번, 작게 한 번 흔들리고 쉰다. 음수는 왼쪽이다
96const ROCK = [0, -15, -25, -15, 0, 15, 25, 15, 0, -10, 0, 10, 0, 0, 0, 0, 0, 0]
97// 기운 알을 담는 칸. 바닥 줄이 알의 바닥이고, 가장 크게 기울어도 잘리지 않는다
98const ROCK_WIDTH = 20
99const ROCK_HEIGHT = 15
100
101export function hash(text: string): number {
102 let n = 2166136261
103 for (let i = 0; i < text.length; i++) {
104 n = Math.imul(n ^ text.charCodeAt(i), 16777619)
105 }
106 return n >>> 0
107}
108
109// 픽셀 그림판. 비운 픽셀은 터미널 배경이 비친다
110type Canvas = { width: number; height: number; px: Uint32Array }
111
112function canvasOf(width: number, height: number): Canvas {
113 return { width, height, px: new Uint32Array(width * height).fill(CLEAR) }
114}
115
116function put(canvas: Canvas, x: number, y: number, color: number) {
117 if (x >= 0 && x < canvas.width && y >= 0 && y < canvas.height) {
118 canvas.px[y * canvas.width + x] = color
119 }
120}
121
122function sprite(canvas: Canvas, x: number, y: number, rows: string[], palette: Record<string, number>) {
123 rows.forEach((row, dy) => {
124 for (let dx = 0; dx < row.length; dx++) {
125 const color = palette[row[dx] ?? '.']
126 if (color !== undefined) {
127 put(canvas, x + dx, y + dy, color)
128 }
129 }
130 })
131}
132
133// 몸통에 알 key 로 고른 반점과, 금 간 알이면 금을 칠한다
134function paintedEgg(key: string, stage: 'egg' | 'cracked'): string[][] {
135 const width = EGG_BODY[0]!.length
136 const height = EGG_BODY.length
137 const spots = SPOTS[hash(`spots:${key}`) % SPOTS.length]!
138 const rows = EGG_BODY.map((row, y) =>
139 [...row].map((c, x) => {
140 if (c !== 'e' && c !== 'h' && c !== 's') {
141 return c
142 }
143 const hit = spots.some(([bx, by, r]) => {
144 const dx = (x + 0.5) / width - bx
145 const dy = ((y + 0.5) / height - by) * (height / width)
146 return dx * dx + dy * dy <= r * r
147 })
148 return hit ? (c === 's' ? 'D' : 'G') : c
149 }),
150 )
151 if (stage === 'cracked') {
152 // 프레임마다 바뀌지 않게 알 key 로 고른다
153 for (const [x, y] of CRACKS[hash(`crack:${key}`) % CRACKS.length]!) {
154 rows[y]![x] = 'c'
155 }
156 }
157 return rows
158}
159
160const eggSprites = new Map<string, string[]>()
161
162// 알 key 의 무늬와 금으로 칠한 알을 angle 도만큼 기울인 그림. ROCK_WIDTH x ROCK_HEIGHT 이고 바닥 줄이 알의 바닥이다
163export function eggSprite(key: string, stage: 'egg' | 'cracked', angle: number): string[] {
164 const id = `${key}:${stage}:${angle}`
165 const cached = eggSprites.get(id)
166 if (cached) {
167 return cached
168 }
169 const source = paintedEgg(key, stage)
170 const width = source[0]!.length
171 const height = source.length
172 // 아랫면 곡률 중심을 축으로 돌리고, 기운 쪽으로 조금 굴려 둥근 바닥이 둥지에 닿은 채 기울게 한다
173 const radius = width / 2 - 0.5
174 const a = (angle * Math.PI) / 180
175 const pivotX = width / 2
176 const pivotY = height - radius
177 const outX = ROCK_WIDTH / 2 + radius * a * 0.4
178 const outY = ROCK_HEIGHT - radius
179 const sample = (x: number, y: number) => {
180 const dx = x - outX
181 const dy = y - outY
182 const sx = Math.floor(dx * Math.cos(-a) - dy * Math.sin(-a) + pivotX)
183 const sy = Math.floor(dx * Math.sin(-a) + dy * Math.cos(-a) + pivotY)
184 return source[sy]?.[sx] ?? '.'
185 }
186 // 칸을 3x3 으로 나눠 반 넘게 알 안이면 칠한다. 한 점만 읽으면 테두리가 들쭉날쭉하다. 색은 칸 중심에서 읽는다
187 const grid = Array.from({ length: ROCK_HEIGHT }, (_, y) =>
188 Array.from({ length: ROCK_WIDTH }, (_, x) => {
189 let inside = 0
190 for (const oy of [1 / 6, 1 / 2, 5 / 6]) {
191 for (const ox of [1 / 6, 1 / 2, 5 / 6]) {
192 inside += sample(x + ox, y + oy) === '.' ? 0 : 1
193 }
194 }
195 if (inside < 5) {
196 return '.'
197 }
198 const center = sample(x + 0.5, y + 0.5)
199 return center === '.' ? 'e' : center
200 }),
201 )
202 // 이웃이 하나뿐인 칸은 돌리다 생긴 돌기라 지운다. 지우면 또 생길 수 있어 없을 때까지 되풀이한다
203 const neighbours = (x: number, y: number) =>
204 [grid[y]?.[x - 1], grid[y]?.[x + 1], grid[y - 1]?.[x], grid[y + 1]?.[x]].filter(c => c !== undefined && c !== '.').length
205 for (let changed = true; changed; ) {
206 changed = false
207 grid.forEach((row, y) =>
208 row.forEach((c, x) => {
209 if (c !== '.' && neighbours(x, y) < 2) {
210 row[x] = '.'
211 changed = true
212 }
213 }),
214 )
215 }
216 // 1칸 외곽선은 돌리면 구멍이 나서, 모양만 돌리고 바깥 둘레를 외곽선으로 다시 칠한다
217 const filled = (x: number, y: number) => (grid[y]?.[x] ?? '.') !== '.'
218 const rows = grid.map((row, y) =>
219 row
220 .map((c, x) => {
221 if (c === '.') {
222 return '.'
223 }
224 if (!filled(x - 1, y) || !filled(x + 1, y) || !filled(x, y - 1) || !filled(x, y + 1)) {
225 return 'k'
226 }
227 return c === 'k' ? 'e' : c
228 })
229 .join(''),
230 )
231 eggSprites.set(id, rows)
232 return rows
233}
234// 위아래 두 픽셀을 반 블록 한 칸에 담는다. 둘 다 비면 빈칸으로 둔다
235function pixelCells(canvas: Canvas, out: number[]) {
236 for (let row = 0; row < canvas.height / 2; row++) {
237 for (let x = 0; x < canvas.width; x++) {
238 const top = canvas.px[row * 2 * canvas.width + x] ?? CLEAR
239 const bottom = canvas.px[(row * 2 + 1) * canvas.width + x] ?? CLEAR
240 if (top === CLEAR && bottom === CLEAR) {
241 out.push(SPACE, CLEAR, CLEAR)
242 } else if (top === CLEAR) {
243 out.push(LOWER_HALF, bottom, CLEAR)
244 } else {
245 out.push(UPPER_HALF, top, bottom)
246 }
247 }
248 }
249}
250
251// 입력창 위 그림의 칸 수. 반 블록 한 칸에 픽셀 두 줄이 들어가 기운 알(20x15)이 8줄에 담긴다
252export const EGG_COLUMNS = ROCK_WIDTH
253export const EGG_LINES = Math.ceil(ROCK_HEIGHT / 2)
254// 깨어난 포켓몬은 이 안에 들어가게 줄인다. 통통 뛰어도 위가 잘리지 않게 그림 높이보다 조금 작다
255export const MON_SIZE = 14
256
257// 막 진화한 포켓몬의 진화 장면. 원작처럼 진화 전 모습으로 잠깐 서 있다가(start), 하얗게 빛나며 진화 전과 뒤 모습을
258// 번갈아 보이고 점점 빨라진 뒤(morph), 하얗게 번쩍이고(flash), 새 모습으로 선다(done). 값은 장면이 시작한 뒤 ms 다
259export const EVOLVE_START_MS = 2000
260export const EVOLVE_MORPH_MS = 9000
261export const EVOLVE_FLASH_MS = 9750
262export const EVOLVE_MS = 13000
263export type EvolvePhase = 'start' | 'morph' | 'flash' | 'done'
264
265export function evolvePhase(elapsed: number): EvolvePhase {
266 if (elapsed < EVOLVE_START_MS) {
267 return 'start'
268 }
269 if (elapsed < EVOLVE_MORPH_MS) {
270 return 'morph'
271 }
272 return elapsed < EVOLVE_FLASH_MS ? 'flash' : 'done'
273}
274
275// morph 동안 이 순간 진화 뒤 모습이 보이는지. 처음엔 1초에 한 번 바뀌고 끝에는 1초에 네 번 바뀐다
276export function isMorphNew(elapsed: number): boolean {
277 const t = Math.max(0, elapsed - EVOLVE_START_MS) / 1000
278 const span = (EVOLVE_MORPH_MS - EVOLVE_START_MS) / 1000
279 const flips = t + (3 / (2 * span)) * t * t
280 return Math.floor(flips) % 2 === 1
281}
282
283// 스프라이트를 아직 못 불러왔을 때 대신 그리는 몬스터볼
284const BALL = ['..rrrr..', '.rrrrrr.', 'rrrrrrrr', 'kkkwwkkk', 'wwwkkwww', 'wwwwwwww', '.wwwwww.', '..wwww..']
285const BALL_PALETTE = { r: C.ballRed, w: C.ballWhite, k: C.ballLine }
286// 일하는 동안 포켓몬이 통통 뛰는 높이
287const HOP = [0, 1, 2, 1, 0, 0, 0, 0]
288
289// 입력창 위에 그릴 것. image 가 없으면 아직 스프라이트를 못 불러온 것이라 몬스터볼을 그린다
290export type BandArt =
291 | { kind: 'egg'; key: string; stage: 'egg' | 'cracked'; lean: Lean | null; isShaking: boolean }
292 | { kind: 'mon'; image: Pixels | undefined; isHopping: boolean }
293 | { kind: 'evolve'; before: Pixels | undefined; after: Pixels | undefined; elapsed: number }
294
295function blit(canvas: Canvas, x: number, y: number, image: Pixels) {
296 image.px.forEach((color, i) => {
297 if (color !== CLEAR) {
298 put(canvas, x + (i % image.width), y + Math.floor(i / image.width), color)
299 }
300 })
301}
302
303// 몸 모양 그대로 하얗게 칠한 그림
304function silhouette(image: Pixels): Pixels {
305 return { ...image, px: image.px.map(color => (color === CLEAR ? CLEAR : C.sparkleHot)) }
306}
307
308// 바닥 가운데에 세운다. lift 만큼 띄운다
309function stand(canvas: Canvas, image: Pixels | undefined, lift: number) {
310 if (!image) {
311 sprite(canvas, Math.floor((canvas.width - 8) / 2), canvas.height - 8 - lift, BALL, BALL_PALETTE)
312 return
313 }
314 blit(canvas, Math.floor((canvas.width - image.width) / 2), canvas.height - image.height - lift, image)
315}
316
317// frame 번째 그림의 Raster 칸
318export function drawBand(art: BandArt, frame: number): Uint32Array {
319 const canvas = canvasOf(EGG_COLUMNS, EGG_LINES * 2)
320 if (art.kind === 'egg') {
321 const angle = art.isShaking ? ROCK[frame % ROCK.length]! : 0
322 const [spot, light, shade] = SPOT_COLORS[art.lean ?? 'none']
323 const palette = { ...EGG_PALETTE, G: spot, g: light, D: shade }
324 sprite(canvas, 0, canvas.height - ROCK_HEIGHT, eggSprite(art.key, art.stage, angle), palette)
325 } else if (art.kind === 'mon') {
326 stand(canvas, art.image, art.isHopping ? HOP[frame % HOP.length]! : 0)
327 } else {
328 const phase = evolvePhase(art.elapsed)
329 const shown =
330 phase === 'start'
331 ? art.before
332 : phase === 'done'
333 ? art.after
334 : phase === 'flash' || isMorphNew(art.elapsed)
335 ? art.after && silhouette(art.after)
336 : art.before && silhouette(art.before)
337 stand(canvas, shown, 0)
338 }
339 const out: number[] = []
340 pixelCells(canvas, out)
341 return Uint32Array.from(out)
342}
343hooks/grow-lib.ts 233 lines1// 알이 깨어나고 포켓몬이 자라는 규칙. 시각과 저장은 부르는 쪽이 맡고 여기서는 값만 계산한다
2// 세션이 일한 시간이 쌓이면 알에 금이 가고 깨어나며, 깨어난 뒤에는 일한 1분이 경험치 1이 된다
3// MR 이 머지되면 알은 바로 깨어나고, 이미 깨어났으면 경험치를 크게 받는다
4
5import type { Lean, Mon, SessionEgg } from '../types'
6import { EGG_SPECIES, EVOLUTIONS, EVOLVE_LEVEL, preEvolutionOf, rarityOf, typesOf } from './dex-data'
7import { hash } from './egg-lib'
8
9// 일한 시간이 이만큼이면 금이 가고, 이만큼이면 깨어난다
10export const CRACK_MS = 20 * 60 * 1000
11export const HATCH_MS = 60 * 60 * 1000
12// 깨어난 포켓몬의 레벨과, 한 레벨 오르는 데 드는 경험치(일한 분)
13export const HATCH_LEVEL = 5
14export const XP_PER_LEVEL = 15
15export const MAX_LEVEL = 100
16// MR 이 머지되면 받는 경험치. 한 시간 일한 만큼이다
17export const MERGE_XP = 60
18// 전설은 알에서 나오지 않고 mod-hud 에서 옮겨 온 것뿐이다. 원작에서 만나는 레벨 즈음으로 둔다
19const LEGEND_LEVEL = 50
20
21export const newEgg = (key: string, at: number): SessionEgg => ({
22 key,
23 at,
24 workMs: 0,
25 tally: {},
26 root: null,
27 branch: null,
28 mr: null,
29 merged: false,
30 mon: null,
31 endedAt: null,
32 savedAt: at,
33})
34
35export function stageOf(egg: SessionEgg): 'egg' | 'cracked' | 'hatched' {
36 if (egg.mon) {
37 return 'hatched'
38 }
39 return egg.workMs >= CRACK_MS || egg.mr?.state === 'opened' ? 'cracked' : 'egg'
40}
41
42// 원작에서 알을 들여다볼 때 나오는 글. 깨어날 때가 가까울수록 바뀐다
43export function eggNote(egg: SessionEgg): string {
44 if (stageOf(egg) === 'cracked') {
45 return 'Sounds can be heard coming from inside! It will hatch soon!'
46 }
47 return egg.workMs >= CRACK_MS / 2
48 ? 'It appears to move occasionally. It may be close to hatching.'
49 : "What will hatch from this? It doesn't seem close to hatching."
50}
51
52export const levelOf = (xp: number): number => Math.min(MAX_LEVEL, HATCH_LEVEL + Math.floor(xp / XP_PER_LEVEL))
53export const xpOfLevel = (level: number): number => (level - HATCH_LEVEL) * XP_PER_LEVEL
54
55// 다음 레벨까지 찬 정도. 0 부터 1 이다
56export const levelProgress = (xp: number): number => (levelOf(xp) >= MAX_LEVEL ? 1 : (xp % XP_PER_LEVEL) / XP_PER_LEVEL)
57
58export function xpBar(progress: number, width = 10): string {
59 const filled = Math.max(0, Math.min(width, Math.floor(progress * width)))
60 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
61}
62
63const TEST_COMMAND = /\b(test|tests|jest|vitest|pytest|mocha|rspec|phpunit|playwright|cypress)\b/
64
65// 도구 하나가 어느 쪽으로 기우는지. 셀 만한 도구가 아니면 null
66export function leanOfTool(tool: string, input: { command?: unknown }): Lean | null {
67 if (tool === 'Bash') {
68 return typeof input.command === 'string' && TEST_COMMAND.test(input.command) ? 'test' : 'shell'
69 }
70 if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool)) {
71 return 'edit'
72 }
73 if (['Read', 'Grep', 'Glob', 'LS'].includes(tool)) {
74 return 'read'
75 }
76 if (['WebFetch', 'WebSearch'].includes(tool) || tool.startsWith('mcp__')) {
77 return 'web'
78 }
79 return ['Agent', 'Task'].includes(tool) ? 'agent' : null
80}
81
82const LEANS: readonly Lean[] = ['shell', 'edit', 'read', 'test', 'web', 'agent']
83
84export function addTally(a: SessionEgg['tally'], b: SessionEgg['tally']): SessionEgg['tally'] {
85 const sum: SessionEgg['tally'] = { ...a }
86 for (const lean of LEANS) {
87 if (b[lean]) {
88 sum[lean] = (sum[lean] ?? 0) + b[lean]
89 }
90 }
91 return sum
92}
93
94// 가장 많이 쓴 쪽. 같으면 LEANS 에서 앞선 쪽이고, 아무 도구도 안 썼으면 null
95export function leanOf(tally: SessionEgg['tally']): Lean | null {
96 let best: Lean | null = null
97 let most = 0
98 for (const lean of LEANS) {
99 const count = tally[lean] ?? 0
100 if (count > most) {
101 best = lean
102 most = count
103 }
104 }
105 return best
106}
107
108// 쪽마다 끌어당기는 속성. 셸은 불꽃과 격투, 편집은 풀과 벌레, 읽기는 에스퍼와 고스트, 테스트는 전기, 웹은 물과 비행이다
109export const LEAN_TYPES: Readonly<Record<Lean, readonly string[]>> = {
110 shell: ['FIRE', 'FIGHTING', 'ROCK', 'GROUND'],
111 edit: ['GRASS', 'BUG'],
112 read: ['PSYCHIC', 'GHOST', 'POISON'],
113 test: ['ELECTRIC', 'STEEL'],
114 web: ['WATER', 'FLYING', 'ICE'],
115 agent: ['NORMAL', 'DRAGON'],
116}
117
118// 귀한 포켓몬은 다른 종의 1/4 만큼 나오고, 기운 쪽 속성의 종은 20배 잘 나온다. 정해지는 게 아니라 기울 뿐이다
119const COMMON_WEIGHT = 4
120const RARE_WEIGHT = 1
121const LEAN_BOOST = 20
122
123export function hatchSpecies(key: string, lean: Lean | null): number {
124 const weights = EGG_SPECIES.map(species => {
125 const base = rarityOf(species) === 'rare' ? RARE_WEIGHT : COMMON_WEIGHT
126 const isLeaning = lean !== null && typesOf(species).split('/').some(type => LEAN_TYPES[lean].includes(type))
127 return base * (isLeaning ? LEAN_BOOST : 1)
128 })
129 let pick = hash(key) % weights.reduce((sum, weight) => sum + weight, 0)
130 for (const [i, weight] of weights.entries()) {
131 if (pick < weight) {
132 return EGG_SPECIES[i]!
133 }
134 pick -= weight
135 }
136 return EGG_SPECIES[0]!
137}
138
139export function hatch(egg: SessionEgg, now: number): Mon {
140 const lean = leanOf(egg.tally)
141 return { key: egg.key, species: hatchSpecies(egg.key, lean), xp: 0, at: now, lean, root: egg.root, branch: egg.branch }
142}
143
144// 이브이는 깨어날 때 기운 쪽을 따라 웹은 샤미드, 테스트는 쥬피썬더, 셸은 부스터가 되고, 나머지는 알 key 로 고른다
145const EEVEE_BY_LEAN: Partial<Record<Lean, number>> = { web: 134, test: 135, shell: 136 }
146
147export function nextSpecies(mon: Mon): number | null {
148 const next = EVOLUTIONS[mon.species]
149 const at = EVOLVE_LEVEL[mon.species]
150 if (!next || at === undefined || levelOf(mon.xp) < at) {
151 return null
152 }
153 if (next.length === 1) {
154 return next[0]!
155 }
156 return (mon.lean && EEVEE_BY_LEAN[mon.lean]) ?? next[hash(mon.key) % next.length]!
157}
158
159// 경험치를 더하고 레벨에 닿은 만큼 진화한다. 진화했으면 from 은 진화하기 전 처음 모습이다
160export function grow(mon: Mon, xp: number): { mon: Mon; from: number | null } {
161 let grown: Mon = { ...mon, xp: mon.xp + xp }
162 for (let next = nextSpecies(grown); next !== null; next = nextSpecies(grown)) {
163 grown = { ...grown, species: next }
164 }
165 return { mon: grown, from: grown.species === mon.species ? null : mon.species }
166}
167
168// 다음 진화 레벨. 더 진화하지 않으면 null
169export function evolveLevelOf(species: number): number | null {
170 return EVOLUTIONS[species] ? (EVOLVE_LEVEL[species] ?? null) : null
171}
172
173// 옮겨 온 포켓몬이 서 있을 레벨. 진화한 모습이면 진화 전 모습이 진화하는 레벨이다
174export function startLevelOf(species: number): number {
175 if (rarityOf(species) === 'legend') {
176 return LEGEND_LEVEL
177 }
178 const pre = preEvolutionOf(species)
179 return pre === null ? HATCH_LEVEL : (EVOLVE_LEVEL[pre] ?? HATCH_LEVEL)
180}
181
182type LegacyEntry = { key?: unknown; species?: unknown; root?: unknown; branch?: unknown; at?: unknown }
183type LegacyDex = { box?: LegacyEntry[]; seen?: unknown[]; lead?: unknown; xp?: unknown }
184
185// mod-hud 의 ~/.claude/mod-hud-pokedex.json 을 박스 포켓몬과 도감으로 바꾼다. 읽지 못하면 빈 것으로 본다
186export function migrateDex(text: string): { mons: Mon[]; seen: number[] } {
187 let dex: LegacyDex
188 try {
189 dex = JSON.parse(text) as LegacyDex
190 } catch {
191 return { mons: [], seen: [] }
192 }
193 const mons = (Array.isArray(dex.box) ? dex.box : [])
194 .filter(entry => typeof entry.key === 'string' && typeof entry.species === 'number')
195 .map(entry => {
196 const species = entry.species as number
197 // 키우미집에 맡겨 두던 포켓몬은 그동안 먹은 경험치(일한 ms)도 가져온다
198 const extra = entry.key === dex.lead && typeof dex.xp === 'number' ? Math.floor(dex.xp / 60_000) : 0
199 return {
200 key: entry.key as string,
201 species,
202 xp: xpOfLevel(startLevelOf(species)) + extra,
203 at: typeof entry.at === 'number' ? entry.at : 0,
204 lean: null,
205 root: typeof entry.root === 'string' ? entry.root : null,
206 branch: typeof entry.branch === 'string' ? entry.branch : null,
207 }
208 })
209 const seen = (Array.isArray(dex.seen) ? dex.seen : []).filter((no): no is number => typeof no === 'number')
210 return { mons, seen }
211}
212
213// 도감에 오른 종. 박스 포켓몬과 그 진화 전 모습, mod-hud 에서 옮겨 온 도감을 합친다
214export function seenOf(mons: readonly Mon[], legacy: readonly number[]): number[] {
215 const seen = new Set(legacy)
216 for (const mon of mons) {
217 for (let species: number | null = mon.species; species !== null; species = preEvolutionOf(species)) {
218 seen.add(species)
219 }
220 }
221 return [...seen].sort((a, b) => a - b)
222}
223
224// 박스를 도감 번호 순으로 같은 종끼리 묶는다. level 은 그 종 중 가장 높은 레벨이다
225export function groupBox(mons: readonly Mon[]): Array<{ species: number; count: number; level: number }> {
226 const groups = new Map<number, { species: number; count: number; level: number }>()
227 for (const mon of mons) {
228 const group = groups.get(mon.species) ?? { species: mon.species, count: 0, level: 0 }
229 groups.set(mon.species, { ...group, count: group.count + 1, level: Math.max(group.level, levelOf(mon.xp)) })
230 }
231 return [...groups.values()].sort((a, b) => a.species - b.species)
232}
233hooks/png-lib.ts 327 lines1// 포켓몬 스프라이트 PNG 를 읽어 작은 도트로 줄인다. mod 환경에는 zlib 이 없어 inflate 를 직접 한다
2
3// 색이 없는 픽셀. Raster 의 터미널 기본색과 같은 값이라 그대로 칠하면 배경이 비친다
4export const CLEAR = 0x01000000
5
6export type Pixels = { width: number; height: number; px: number[] }
7
8// RFC 1951 inflate. 정적, 동적 허프만과 저장 블록을 모두 읽는다
9function inflate(data: Uint8Array): Uint8Array {
10 let pos = 0
11 let bit = 0
12 const out: number[] = []
13 const bits = (n: number) => {
14 let value = 0
15 for (let i = 0; i < n; i++) {
16 if (pos >= data.length) {
17 throw new Error('inflate: unexpected end')
18 }
19 value |= ((data[pos]! >> bit) & 1) << i
20 bit += 1
21 if (bit === 8) {
22 bit = 0
23 pos += 1
24 }
25 }
26 return value
27 }
28 type Huffman = { counts: number[]; symbols: number[] }
29 const build = (lengths: number[]): Huffman => {
30 const counts = new Array(16).fill(0)
31 for (const len of lengths) {
32 counts[len] += 1
33 }
34 counts[0] = 0
35 const offsets = new Array(16).fill(0)
36 for (let i = 1; i < 16; i++) {
37 offsets[i] = offsets[i - 1] + counts[i - 1]
38 }
39 const symbols = new Array(lengths.length).fill(0)
40 lengths.forEach((len, symbol) => {
41 if (len !== 0) {
42 symbols[offsets[len]++] = symbol
43 }
44 })
45 return { counts, symbols }
46 }
47 const decode = (h: Huffman) => {
48 let code = 0
49 let first = 0
50 let index = 0
51 for (let len = 1; len < 16; len++) {
52 code |= bits(1)
53 const count = h.counts[len]!
54 if (code - count < first) {
55 return h.symbols[index + (code - first)]!
56 }
57 index += count
58 first += count
59 first <<= 1
60 code <<= 1
61 }
62 throw new Error('inflate: bad code')
63 }
64 const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
65 const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
66 const DIST_BASE = [
67 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289,
68 16385, 24577,
69 ]
70 const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
71 const fixedLit = build([...new Array(144).fill(8), ...new Array(112).fill(9), ...new Array(24).fill(7), ...new Array(8).fill(8)])
72 const fixedDist = build(new Array(30).fill(5))
73 const ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
74
75 let isLast = 0
76 while (!isLast) {
77 isLast = bits(1)
78 const type = bits(2)
79 if (type === 0) {
80 if (bit !== 0) {
81 bit = 0
82 pos += 1
83 }
84 const len = data[pos]! | (data[pos + 1]! << 8)
85 pos += 4
86 for (let i = 0; i < len; i++) {
87 out.push(data[pos++]!)
88 }
89 continue
90 }
91 let lit = fixedLit
92 let dist = fixedDist
93 if (type === 2) {
94 const nlen = bits(5) + 257
95 const ndist = bits(5) + 1
96 const ncode = bits(4) + 4
97 const codeLengths = new Array(19).fill(0)
98 for (let i = 0; i < ncode; i++) {
99 codeLengths[ORDER[i]!] = bits(3)
100 }
101 const codes = build(codeLengths)
102 const lengths: number[] = []
103 while (lengths.length < nlen + ndist) {
104 const symbol = decode(codes)
105 if (symbol < 16) {
106 lengths.push(symbol)
107 } else if (symbol === 16) {
108 const prev = lengths[lengths.length - 1] ?? 0
109 for (let n = bits(2) + 3; n > 0; n--) {
110 lengths.push(prev)
111 }
112 } else {
113 for (let n = symbol === 17 ? bits(3) + 3 : bits(7) + 11; n > 0; n--) {
114 lengths.push(0)
115 }
116 }
117 }
118 lit = build(lengths.slice(0, nlen))
119 dist = build(lengths.slice(nlen))
120 } else if (type !== 1) {
121 throw new Error('inflate: bad block')
122 }
123 for (;;) {
124 const symbol = decode(lit)
125 if (symbol < 256) {
126 out.push(symbol)
127 } else if (symbol === 256) {
128 break
129 } else {
130 const li = symbol - 257
131 const length = LEN_BASE[li]! + bits(LEN_EXTRA[li]!)
132 const di = decode(dist)
133 const back = DIST_BASE[di]! + bits(DIST_EXTRA[di]!)
134 const from = out.length - back
135 for (let i = 0; i < length; i++) {
136 out.push(out[from + i]!)
137 }
138 }
139 }
140 }
141 return Uint8Array.from(out)
142}
143
144// 인터레이스 없는 PNG 를 0xRRGGBB 픽셀로 읽는다. 반 넘게 투명한 픽셀은 CLEAR 다
145export function decodePng(bytes: Uint8Array): Pixels {
146 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
147 let pos = 8
148 let width = 0
149 let height = 0
150 let depth = 8
151 let colorType = 6
152 let palette: number[] = []
153 let alphas: number[] = []
154 const idat: number[] = []
155 while (pos < bytes.length) {
156 const len = view.getUint32(pos)
157 const type = String.fromCharCode(...bytes.subarray(pos + 4, pos + 8))
158 const body = bytes.subarray(pos + 8, pos + 8 + len)
159 if (type === 'IHDR') {
160 width = view.getUint32(pos + 8)
161 height = view.getUint32(pos + 12)
162 depth = body[8]!
163 colorType = body[9]!
164 if (body[12] !== 0) {
165 throw new Error('png: interlaced')
166 }
167 } else if (type === 'PLTE') {
168 palette = Array.from({ length: len / 3 }, (_, i) => (body[i * 3]! << 16) | (body[i * 3 + 1]! << 8) | body[i * 3 + 2]!)
169 } else if (type === 'tRNS') {
170 alphas = Array.from(body)
171 } else if (type === 'IDAT') {
172 for (const b of body) {
173 idat.push(b)
174 }
175 } else if (type === 'IEND') {
176 break
177 }
178 pos += 12 + len
179 }
180 // zlib 머리 두 바이트를 건너뛴다
181 const raw = inflate(Uint8Array.from(idat).subarray(2))
182 const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType]
183 if (channels === undefined || depth === 16) {
184 throw new Error(`png: color type ${colorType} depth ${depth}`)
185 }
186 const bpp = Math.max(1, (channels * depth) / 8)
187 const stride = Math.ceil((width * channels * depth) / 8)
188 const lines = new Uint8Array(stride * height)
189 let at = 0
190 for (let y = 0; y < height; y++) {
191 const filter = raw[at++]!
192 for (let x = 0; x < stride; x++) {
193 const a = x >= bpp ? lines[y * stride + x - bpp]! : 0
194 const b = y > 0 ? lines[(y - 1) * stride + x]! : 0
195 const c = x >= bpp && y > 0 ? lines[(y - 1) * stride + x - bpp]! : 0
196 let value = raw[at++]!
197 if (filter === 1) {
198 value += a
199 } else if (filter === 2) {
200 value += b
201 } else if (filter === 3) {
202 value += (a + b) >> 1
203 } else if (filter === 4) {
204 const p = a + b - c
205 const pa = Math.abs(p - a)
206 const pb = Math.abs(p - b)
207 const pc = Math.abs(p - c)
208 value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
209 }
210 lines[y * stride + x] = value & 255
211 }
212 }
213 const sample = (y: number, index: number) => {
214 if (depth === 8) {
215 return lines[y * stride + index]!
216 }
217 const bitAt = index * depth
218 return (lines[y * stride + (bitAt >> 3)]! >> (8 - depth - (bitAt & 7))) & ((1 << depth) - 1)
219 }
220 const px: number[] = []
221 for (let y = 0; y < height; y++) {
222 for (let x = 0; x < width; x++) {
223 let rgb = 0
224 let alpha = 255
225 if (colorType === 3) {
226 const i = sample(y, x)
227 rgb = palette[i] ?? 0
228 alpha = alphas[i] ?? 255
229 } else if (colorType === 0 || colorType === 4) {
230 const g = sample(y, x * channels) * (255 / ((1 << depth) - 1))
231 rgb = (g << 16) | (g << 8) | g
232 alpha = colorType === 4 ? sample(y, x * channels + 1) : 255
233 } else {
234 rgb = (sample(y, x * channels) << 16) | (sample(y, x * channels + 1) << 8) | sample(y, x * channels + 2)
235 alpha = colorType === 6 ? sample(y, x * channels + 3) : 255
236 }
237 px.push(alpha < 128 ? CLEAR : rgb)
238 }
239 }
240 return { width, height, px }
241}
242
243// 투명한 테두리를 잘라 낸다
244export function crop(image: Pixels): Pixels {
245 let [left, top, right, bottom] = [image.width, image.height, -1, -1]
246 image.px.forEach((color, i) => {
247 if (color !== CLEAR) {
248 const x = i % image.width
249 const y = Math.floor(i / image.width)
250 left = Math.min(left, x)
251 right = Math.max(right, x)
252 top = Math.min(top, y)
253 bottom = Math.max(bottom, y)
254 }
255 })
256 if (right < 0) {
257 return { width: 1, height: 1, px: [CLEAR] }
258 }
259 const width = right - left + 1
260 const height = bottom - top + 1
261 const px: number[] = []
262 for (let y = top; y <= bottom; y++) {
263 for (let x = left; x <= right; x++) {
264 px.push(image.px[y * image.width + x]!)
265 }
266 }
267 return { width, height, px }
268}
269
270// 이보다 어두운 색은 테두리로 본다
271const OUTLINE_LUMA = 60
272const lumaOf = (color: number) => ((color >> 16) & 255) * 0.3 + ((color >> 8) & 255) * 0.59 + (color & 255) * 0.11
273
274// 비율을 지켜 size 안에 들어가게 줄인다. 칸마다 반 넘게 차 있으면 가장 많은 색으로 칠한다
275// 큰 포켓몬을 크게 줄이면 가는 테두리가 몸 색에 묻혀 뭉개지므로, 칸에 테두리가 넉넉히 있으면 테두리를 먼저 남긴다
276export function fit(image: Pixels, size: number): Pixels {
277 const scale = Math.max(1, Math.max(image.width, image.height) / size)
278 const width = Math.max(1, Math.round(image.width / scale))
279 const height = Math.max(1, Math.round(image.height / scale))
280 const px: number[] = []
281 for (let ty = 0; ty < height; ty++) {
282 for (let tx = 0; tx < width; tx++) {
283 const x0 = Math.floor(tx * scale)
284 const x1 = Math.max(x0 + 1, Math.floor((tx + 1) * scale))
285 const y0 = Math.floor(ty * scale)
286 const y1 = Math.max(y0 + 1, Math.floor((ty + 1) * scale))
287 const seen = new Map<number, number>()
288 let filled = 0
289 let dark = 0
290 let darkest = CLEAR
291 for (let y = y0; y < y1 && y < image.height; y++) {
292 for (let x = x0; x < x1 && x < image.width; x++) {
293 const color = image.px[y * image.width + x]!
294 if (color !== CLEAR) {
295 filled += 1
296 seen.set(color, (seen.get(color) ?? 0) + 1)
297 if (lumaOf(color) < OUTLINE_LUMA) {
298 dark += 1
299 darkest = darkest === CLEAR || lumaOf(color) < lumaOf(darkest) ? color : darkest
300 }
301 }
302 }
303 }
304 const area = (x1 - x0) * (y1 - y0)
305 if (dark * 3 >= area) {
306 px.push(darkest)
307 continue
308 }
309 if (filled * 2 < area) {
310 px.push(CLEAR)
311 continue
312 }
313 // 테두리가 조금 걸친 칸은 몸 색으로 칠한다
314 let best = CLEAR
315 let most = 0
316 for (const [color, count] of seen) {
317 if (lumaOf(color) >= OUTLINE_LUMA && count > most) {
318 best = color
319 most = count
320 }
321 }
322 px.push(best)
323 }
324 }
325 return { width, height, px }
326}
327hooks/repo-lib.ts 120 lines1// 세션이 일하는 브랜치와 그 브랜치의 MR 을 찾는 데 쓰는 함수. mod-hud 에서 옮겨 왔다
2
3import type { Mr, Tree } from '../types'
4
5// origin remote 주소로 GitLab API 의 프로젝트 주소를 만든다. override 는 API 호스트가 remote 와 다를 때(ssh remote 등) 쓴다
6// GitHub 은 MR 이 없으므로 null
7export const gitlabProjectApi = (remote: string, override = ''): string | null => {
8 const url = remote.trim()
9 const parsed =
10 url.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?\/?$/) ??
11 url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) ??
12 url.match(/^(?:[^@/]+@)?([^/:]+):(.+?)(?:\.git)?\/?$/)
13 if (!parsed) {
14 return null
15 }
16 const [, host = '', path = ''] = parsed
17 if (/(^|\.)github\.com(:\d+)?$/.test(host)) {
18 return null
19 }
20 const isHttp = /^https?:/.test(url)
21 const base = override.trim().replace(/\/+$/, '') || `${isHttp ? url.slice(0, url.indexOf(':')) : 'https'}://${host}`
22 return `${base}/api/v4/projects/${encodeURIComponent(path)}`
23}
24
25// remote 가 GitHub 저장소인지. GitHub 는 API 대신 gh CLI 로 PR 을 찾는다
26export const isGithubRemote = (remote: string): boolean =>
27 /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)github\.com[:/]/.test(remote.trim())
28
29
30// `gh pr list --json number,state,url` 출력의 첫 PR 을 GitLab MR 과 같은 모양으로 바꾼다
31export const parseGithubPr = (stdout: string): Mr | null => {
32 try {
33 const [pr] = JSON.parse(stdout) as Array<{ number: number; state: string; url: string }>
34 if (!pr) {
35 return null
36 }
37 return { iid: pr.number, state: pr.state === 'OPEN' ? 'opened' : pr.state === 'MERGED' ? 'merged' : 'closed', url: pr.url }
38 } catch {
39 return null
40 }
41}
42
43// GitLab API 의 MR 목록 응답에서 첫 MR. state 는 opened, merged, closed, locked 이다
44export const parseGitlabMr = (text: string): Mr | null => {
45 try {
46 const [mr] = JSON.parse(text) as Array<{ iid: number; state: string; web_url: string }>
47 if (!mr) {
48 return null
49 }
50 return { iid: mr.iid, state: mr.state === 'merged' ? 'merged' : mr.state === 'closed' ? 'closed' : 'opened', url: mr.web_url }
51 } catch {
52 return null
53 }
54}
55
56// 기본 브랜치와 배포 브랜치는 MR 의 소스가 아니라 MR 을 찾지 않는다
57export const isLongLivedBranch = (branch: string): boolean =>
58 ['main', 'master', 'develop', 'HEAD'].includes(branch) || branch.startsWith('release/')
59
60// 경로의 . 과 .. 를 풀어 절대 경로로 만든다. 상대 경로는 base 에서 시작한다
61const resolvePath = (base: string, path: string): string => {
62 const parts: string[] = []
63 for (const part of (path.startsWith('/') ? path : `${base}/${path}`).split('/')) {
64 if (part === '..') {
65 parts.pop()
66 } else if (part && part !== '.') {
67 parts.push(part)
68 }
69 }
70 return `/${parts.join('/')}`
71}
72
73// 도구가 손댄 경로. Edit 와 Write 는 파일 경로, Bash 는 맨 앞의 cd 나 git -C 경로다. 세션의 브랜치를 주로 쓰는 worktree 에서 읽는 데 쓴다
74export const touchedPath = (input: { file_path?: unknown; notebook_path?: unknown; command?: unknown }, cwd: string): string | null => {
75 const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
76 if (file) {
77 return resolvePath(cwd, file)
78 }
79 if (typeof input.command !== 'string') {
80 return null
81 }
82 const dir = input.command.match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/) ?? input.command.match(/\bgit\s+-C\s+(?:"([^"]+)"|'([^']+)'|(\S+))/)
83 const path = dir ? (dir[1] ?? dir[2] ?? dir[3] ?? null) : null
84 return path ? resolvePath(cwd, path) : null
85}
86
87// 경로가 든 worktree. 다른 worktree 안에 만든 worktree 도 있어 가장 깊은 것을 고른다
88export const treeOfPath = (trees: readonly Tree[], path: string): Tree | null =>
89 trees
90 .filter(tree => path === tree.path || path.startsWith(`${tree.path}/`))
91 .reduce<Tree | null>((best, tree) => (best && best.path.length >= tree.path.length ? best : tree), null)
92
93// `git worktree list --porcelain` 출력을 worktree 목록으로 바꾼다. 첫 블록이 메인 worktree 다
94export const parseWorktrees = (porcelain: string): Tree[] =>
95 porcelain
96 .split('\n\n')
97 .map(block => block.trim())
98 .filter(Boolean)
99 .map((block, index) => {
100 const lines = block.split('\n')
101 const field = (name: string) =>
102 lines.find(line => line.startsWith(`${name} `))?.slice(name.length + 1) ?? null
103
104 return {
105 path: field('worktree') ?? '',
106 head: field('HEAD') ?? '',
107 branch: field('branch')?.replace(/^refs\/heads\//, '') ?? null,
108 isMain: index === 0,
109 }
110 })
111 .filter(tree => tree.path !== '')
112
113// .env.local 같은 파일에서 NAME=value 한 줄의 값을 읽는다
114export const readEnvValue = (text: string, name: string): string | null => {
115 const line = text.split('\n').find(one => one.trim().startsWith(`${name}=`))
116 const value = line?.slice(line.indexOf('=') + 1).trim().replace(/^['"]|['"]$/g, '')
117 return value ? value : null
118}
119
120types/index.d.ts 59 lines1// 세션이 주로 쓴 도구. 알에서 나올 포켓몬의 타입이 이쪽으로 기운다
2export type Lean = 'shell' | 'edit' | 'read' | 'test' | 'web' | 'agent'
3
4export type Mr = { iid: number; state: 'opened' | 'merged' | 'closed'; url: string }
5
6export type Tree = { path: string; head: string; branch: string | null; isMain: boolean }
7
8// 세션 하나가 키우는 알. $.store 의 egg:<세션 id> 에 남아, 이어 연 세션도 같은 알을 받는다
9// 세션이 닫힌 뒤에도 남아, 열린 다른 세션이 그 브랜치의 MR 이 머지됐는지 대신 본다
10export type SessionEgg = {
11 // 무늬, 금 모양, 나올 포켓몬을 정한다. 깨어나면 포켓몬의 key 가 된다
12 key: string
13 // 알을 받은 시각
14 at: number
15 // 세션이 일한 시간(ms). 이만큼 쌓이면 금이 가고 깨어난다
16 workMs: number
17 // 도구를 쓴 횟수
18 tally: Partial<Record<Lean, number>>
19 // 세션이 일하는 저장소 루트와 브랜치. Edit, Write, Bash 로 마지막에 손댄 worktree 를 따른다
20 root: string | null
21 branch: string | null
22 mr: Mr | null
23 // 이 알이 받은 MR 머지. MR 하나는 알 하나에만 보상을 준다
24 merged: boolean
25 // 깨어났으면 박스에 든 포켓몬의 key
26 mon: string | null
27 // 세션이 닫힌 시각. 열린 세션은 null
28 endedAt: number | null
29 // 이 세션이 마지막으로 저장한 시각. 닫혔다는 소식 없이 오래 멈춘 세션도 닫힌 것으로 본다
30 savedAt: number
31}
32
33// 박스에 든 포켓몬. $.store 의 mon:<key> 에 하나씩 남는다
34export type Mon = {
35 key: string
36 species: number
37 // 경험치. 일한 1분이 1이다
38 xp: number
39 // 깨어난 시각
40 at: number
41 // 깨어날 때 기운 쪽. 이브이가 무엇으로 진화할지 정한다
42 lean: Lean | null
43 root: string | null
44 branch: string | null
45}
46
47// 입력창 위에 보이는 장면. hatch 는 알이 깨어나는 장면, evolve 는 from 이 species 로 진화하는 장면이다
48export type Scene = { kind: 'hatch' | 'evolve'; species: number; from: number; at: number }
49
50declare module 'claude-code' {
51 interface PluginState {
52 'pokemon-egg': {
53 egg: SessionEgg | null
54 mon: Mon | null
55 scene: Scene | null
56 }
57 }
58}
59