SLOPSHOPPER

pokemon-egg

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

newbandguardcommandtoastprompt
v0.1.1MITupdated 2026-10-23reeny404/claude-pokemon-egg
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pokemon-egg
› 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 › /pokemon-egg ⎿ pokemon-egg: EGG: egg, worked 0 of 60 minutes. Leaning toward shell (FIRE/FIGHTING/ROCK/GROUND). ⟨Claude Code's own drawing⟩ EGG ▄▄▄▄ ▱▱▱▱▱▱▱▱▱▱ ▄▀▀▀▀▀▀▀▀▄ What will hatch from this? It doesn't seem close to hatching. ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ EGG ▄▄▄▄ ▱▱▱▱▱▱▱▱▱▱ ▄▀▀▀▀▀▀▀▀▄ What will hatch from this? It doesn't seem close to hatching. ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀
README

claude-pokemon-egg

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:

MostlySpotsLeans toward
shell commandsredFIRE, FIGHTING, ROCK, GROUND
editing filesgreenGRASS, BUG
reading and searchingpurplePSYCHIC, GHOST, POISON
running testsyellowELECTRIC, STEEL
web and MCP toolsblueWATER, FLYING, ICE
subagentsbrownNORMAL, DRAGON

How it grows

  • Egg. Cracks after 20 minutes of work or when the branch's MR opens. Hatches at 60 minutes of work or when the MR is merged. One MR hatches one egg, however many sessions worked on it.
  • Experience. A hatched Pokémon starts at Lv5. Every minute of work is 1 EXP, a merged MR is 60, and it gains a level every 15 EXP. Only time Claude spends working counts.
  • Evolution. At the level from Pokémon Red and Blue. Stone evolutions happen at Lv30 and trade evolutions at Lv36. Eevee becomes Vaporeon, Jolteon or Flareon depending on what its session leaned toward.
  • After the session closes. The Pokémon stays in your PC BOX. An egg still waiting on an open MR is watched by your other sessions for 14 days and hatches when the MR is merged.
  • Branch. A session's branch is the worktree it last edited with Edit, Write or Bash, not just the folder Claude was started in.

Install

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.

Commands

CommandWhat it does
/pokemon-eggThis session's egg or Pokémon: work time, EXP, and what it leans toward
/pokemon-egg boxEvery Pokémon in your PC BOX, one line per species
/pokemon-egg dexYour POKéDEX: how many of the 151 you have seen
/pokemon-egg helpEvery option

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

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.

Source 7 files
hooks/register.tsx 760 lines
1import { 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}
760
hooks/dex-data.ts 87 lines
1// 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}
87
hooks/egg-lib.ts 343 lines
1// 입력창 위에 그리는 세션의 포켓몬 알과 깨어난 포켓몬. 알 그림과 진화 장면은 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}
343
hooks/grow-lib.ts 233 lines
1// 알이 깨어나고 포켓몬이 자라는 규칙. 시각과 저장은 부르는 쪽이 맡고 여기서는 값만 계산한다
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}
233
hooks/png-lib.ts 327 lines
1// 포켓몬 스프라이트 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}
327
hooks/repo-lib.ts 120 lines
1// 세션이 일하는 브랜치와 그 브랜치의 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
120
types/index.d.ts 59 lines
1// 세션이 주로 쓴 도구. 알에서 나올 포켓몬의 타입이 이쪽으로 기운다
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