SLOPSHOPPER

mod-hud

여러 Claude 세션의 상태(내 차례, 진행 중)와 저장소 worktree 별 커밋, MR, Linear 상태를 한 패널과 입력창 위 한 줄로 보이고, 세션마다 MR 이 머지되면 깨지는 포켓몬 알을 키운다

newpanebandguardcommandprompt
★ 1v0.2.0no licenseupdated 2026-10-16reeny404/mod-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-hud
│ ┃ Mod HUD ✕ › fix the failing auth test and add an audit log call │ ┃ ⇄ GitHub acme/app · not fetched yet │ ┃ feat/auth-refresh · no MR yet ⏺ Read(src/auth.ts) │ ┃ [ Refresh now ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⚑ Your turn 0 ⎿ Added 2 lines, removed 1 line │ ┃ No session is waiting on you ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▶ Working 0 │ ┃ No session is working ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ⎇ Worktree ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ daycare-toggle0 › /mod-hud │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ mod-hud: Opened the panel. │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ │ ┃ box-toggle0 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod HUD
⇄ GitHub acme/app · not fetched yet feat/auth-refresh · no MR yet [ Refresh now ] ⚑ Your turn 0 No session is waiting on you ▶ Working 0 No session is working ⎇ Worktree daycare-toggle0 ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ box-toggle0
README

mod-hud

English | 한국어

"Your very own POKéMON legend is about to unfold! A world of dreams and adventures with POKéMON awaits! Let's go!" — Professor Oak, Pokémon Red and Blue

Every Claude Code session you open takes a Pokémon egg from the DAY CARE. The egg rocks while the session works, cracks when your MR opens, and hatches the moment it is merged. Leave a Pokémon at the DAY CARE and it grows while you work, until one day it evolves right in front of you. And who knows, keep at it long enough and you might just run into a legendary Pokémon.

Gotta catch 'em all!

Eggs, DAY CARE and PC BOX at the bottom of the panel

What you get

  • An egg per session. Each working session takes one of the 7 eggs the DAY CARE gets every day. Only unevolved Gen 1 Pokémon come out of eggs.
  • PC BOX. Hatched Pokémon go here when the session closes. Like the PC BOX in Pokémon Gold, it lists one Pokémon per line with its name and type; press a line to see its sprite and when and where it was caught. Any Pokémon can be left at the DAY CARE; ones that no longer evolve show MAX.
  • DAY CARE. The Pokémon you leave here hops around the grass and gains experience only while your sessions are actually working. Every 3 hours of work it evolves one stage.
  • A session HUD, too. A line above the prompt and the /mod-hud panel show which sessions are waiting on you, which are working, your worktrees, MRs that need you, and Linear mentions. Sections you have no setup for simply stay hidden.

It runs inside the Claude Code engine, so there is no Node, npm or React to install, and it works whatever your project's language.

So how do I set it up?

  1. Install it from a Claude Code terminal and choose the user scope, so it runs in every project.
   claude plugin marketplace add reeny404/mod-hud
   claude plugin install mod-hud@hyoyyoi
  1. Open a new session, or run /reload-plugins in an open one.
  2. Type /mod-hud to open the panel. The eggs, DAY CARE and PC BOX appear at the bottom of the panel when it is docked beside the conversation.
  3. Optional: to have eggs crack and hatch on your GitLab MRs, give it a GitLab token with read_api scope:
   // ~/.claude/settings.json
   { "env": { "GITLAB_TOKEN": "<token>" } }

That's it. For every option and what each one does, run:

/mod-hud --help

Or let Claude set it up

Paste this into Claude Code:

Install the mod-hud Claude Code plugin for me: run
`claude plugin marketplace add reeny404/mod-hud` and
`claude plugin install mod-hud@hyoyyoi` with the user scope.
Then ask me whether I use GitLab. If I do, ask for a GitLab token with
read_api scope and add it as GITLAB_TOKEN under "env" in
~/.claude/settings.json. Finally tell me to run /reload-plugins and
/mod-hud, and to run /mod-hud --help to see the other options.

Updating

claude plugin marketplace update hyoyyoi
claude plugin update mod-hud

Known limitations

  • A session with an open permission prompt is not yet raised as "permission", because Claude Code doesn't pass that event to mods.
  • Issue keys are read from branch names, so they need an uppercase key, as in feat/#ABC-12-x or fix/ABC-12-x.

Development

claude plugin validate .
claude plugin test .
Source 10 files
hooks/register.tsx 2160 lines
1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolResults, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type {
5  Attention,
6  BackgroundTask,
7  BoardEntry,
8  BoxEntry,
9  InboxCache,
10  InboxItem,
11  IssuesCache,
12  Mr,
13  Pokedex,
14  Row,
15  SessionStatus,
16  TodoItem,
17} from '../types'
18import {
19  applyFetch,
20  categoryLabel,
21  emptyCache,
22  formatAgoShort,
23  INBOX_TTL_MS,
24  onlyForMe,
25  parseCache,
26  parseNotifications,
27  shouldFetch,
28  sortInbox,
29  toastText,
30} from './inbox-lib'
31import {
32  assignSessions,
33  cleanupBlocker,
34  emptyIssuesCache,
35  findCheck,
36  gitlabProjectApi,
37  isGithubRemote,
38  isLongLivedBranch,
39  issuesFileOf,
40  ISSUES_TTL_MS,
41  mrAction,
42  parseDefaultBranch,
43  parseGithubPr,
44  parseIssue,
45  parseIssuesCache,
46  parseWorktrees,
47  readEnvValue,
48} from './issues-lib'
49import { canEvolve, rarityOf, typesOf } from './dex-data'
50import { advanceEvolution, TAKE_BACK, xpBar, xpLeft } from './evolve-lib'
51import { helpText, isHelp } from './help-lib'
52import type { EncounterAction } from './evolve-lib'
53import {
54  advancePokedex,
55  ARRIVAL_FRAMES,
56  ARRIVAL_ROWS,
57  boxOnPage,
58  boxPageOf,
59  boxPageOfKey,
60  boxPages,
61  daycareGroundRows,
62  drawArrival,
63  drawDaycareGround,
64  drawNest,
65  drawPortrait,
66  eggsOf,
67  EVOLVE_MS,
68  evolvePhase,
69  eggsOnScreen,
70  emptyPokedex,
71  favoritesFirst,
72  keeperOf,
73  localDay,
74  nestRows,
75  parseKeys,
76  parsePokedex,
77  speciesToLoad,
78  updatePending,
79  WALKER_SIZE,
80} from './nest-lib'
81import type { NestInput, SpriteLookup } from './nest-lib'
82import { crop, decodePng, fit } from './png-lib'
83import type { Pixels } from './png-lib'
84import {
85  applyTaskUpdate,
86  attentionAfterTurn,
87  finishedTaskIds,
88  firstLine,
89  formatFor,
90  freshness,
91  isCodeReviewPrompt,
92  launchedTaskId,
93  pendingTasks,
94  projectName,
95  pickTitle,
96  PRUNE_MS,
97  summarizeTodos,
98  transcriptPathOf,
99  triage,
100} from './sessions-lib'
101
102const PANE = 'mod-hud'
103
104// 섹션마다 아이콘과 색을 달리해 제목이 항목과 섞이지 않게 한다
105const ICON = { mine: '⚑', running: '▶', mr: '⇄', inbox: '@', issues: '⎇' } as const
106const COLOR = { mine: 'red', running: 'yellow', mr: 'blue', inbox: 'magenta', issues: 'cyan' } as const
107
108const ATTENTION = {
109  permission: { mark: '!', label: 'permission', color: 'red' },
110  question: { mark: '?', label: 'question', color: 'yellow' },
111  answered: { mark: '✓', label: 'done', color: 'green' },
112} as const
113const HEARTBEAT_MS = 60 * 1000
114// 알과 박스 그림을 다시 칠하는 간격. 패널 전체가 아니라 그림만 칠하므로 짧아도 된다
115const NEST_TICK_MS = 250
116const SESSIONS_REFRESH_MS = 30 * 1000
117
118// 세션 상태
119
120// 모든 세션이 자기 상태를 여기 쓰고, 패널과 입력창 위 한 줄이 모아 읽는다
121const STATUS_DIR = '.claude/mod-sessions'
122
123const board = atom({ plugin: 'mod-hud', key: 'board' } as const, {
124  mine: [],
125  running: [],
126  idle: [],
127  hidden: { stale: 0, ended: 0 },
128})
129const sessionsMeta = atom({ plugin: 'mod-hud', key: 'sessionsMeta' } as const, {
130  refreshedAt: null,
131  ownId: null,
132})
133
134let dir: string | null = null
135let mine: SessionStatus | null = null
136// /clear 나 /resume 로 끝난 세션. 같은 창에서 이어지는 새 세션이 알과 대시보드 표시를 물려받는다
137let handoff: { id: string; isDashboard: boolean } | null = null
138let todos: TodoItem[] = []
139let isRefreshing = false
140
141async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
142  try {
143    const ran = await $.process.run(['git', ...args], { cwd })
144    return ran.exitCode === 0 ? ran.stdout : null
145  } catch {
146    return null
147  }
148}
149
150async function statusDir($: EngineInterface): Promise<string | null> {
151  if (dir === null) {
152    const home = await $.env.get('HOME')
153    dir = home ? `${home}/${STATUS_DIR}` : null
154  }
155  return dir
156}
157
158async function readOwnStatus($: EngineInterface, path: string): Promise<SessionStatus | null> {
159  try {
160    return JSON.parse(await $.fs.read(path)) as SessionStatus
161  } catch {
162    return null
163  }
164}
165
166async function writeStatus($: EngineInterface, patch: Partial<SessionStatus>) {
167  try {
168    const base = await statusDir($)
169    if (!base) {
170      return
171    }
172    const id = await $.session.id()
173    // /clear 나 /resume 뒤 classic SessionStart 가 mod 에 오지 않을 때가 있어, 새 id 로 처음 쓸 때 끝난 세션을 이어 받는다
174    const inherited = handoff && handoff.id !== id ? { predecessor: handoff.id, isDashboard: handoff.isDashboard } : {}
175    if (handoff && handoff.id !== id) {
176      // 끝난 세션의 요청, 상태는 새 세션 것이 아니다
177      mine = null
178      handoff = null
179    }
180    const cwd = await $.session.cwd()
181    const repo = await $.session.repo()
182    const root = repo?.root ?? cwd
183    const updatedAt = await $.clock.now()
184    // mod 가 다시 로드되면 모듈 변수가 비므로, 대기 시작 시각과 브랜치는 이전 상태 파일에서 이어 받는다
185    const prev = mine ?? (await readOwnStatus($, `${base}/${id}.json`))
186    // 세션들이 checkout 하나를 같이 쓰면 다른 세션이 브랜치를 바꾼다. 이 세션이 일을 시작하거나 끝낼 때 본 브랜치만 남긴다
187    const isWorkEdge = patch.state === 'working' || (patch.state === 'waiting' && prev?.state === 'working')
188    const branch =
189      isWorkEdge || !prev ? (await git($, cwd, ['branch', '--show-current']))?.trim() || null : prev.branch
190    const state = patch.state ?? prev?.state ?? 'waiting'
191    const waitingSince =
192      state !== 'waiting' ? undefined : prev?.state === 'waiting' ? (prev.waitingSince ?? updatedAt) : updatedAt
193    mine = {
194      state: 'waiting',
195      prompt: null,
196      todos: null,
197      // 대시보드 표시, 리뷰 세션 표시와 MR 상태는 mod 를 다시 로드해도 이어 간다
198      isDashboard: prev?.isDashboard,
199      isCodeReview: prev?.isCodeReview,
200      mrState: prev?.mrState,
201      mrBranch: prev?.mrBranch,
202      ...mine,
203      ...inherited,
204      ...patch,
205      id,
206      cwd,
207      root,
208      project: projectName(root),
209      branch,
210      updatedAt,
211      waitingSince,
212      // 브랜치가 없으면 MR 도 없다. 저장소 밖 세션은 MR 을 조회하지 않으므로 여기서 적어 둔다
213      ...(branch === null ? { mrState: null, mrBranch: null } : {}),
214    }
215    await $.fs.write(`${base}/${id}.json`, JSON.stringify(mine))
216  } catch {
217    // 상태 기록이 실패해도 세션 동작은 막지 않는다
218  }
219}
220
221// 이만큼 지나도 그대로면 OS 알림을 보낸다. 보고 있는 세션에서 바로 답할 때는 알림이 쌓이지 않는다
222const NOTIFY_DELAY_MS = 60 * 1000
223// 입력창 위에 풀어 보일 내 차례 줄 수
224const BAND_SHOWN = 3
225
226// 나를 기다리기 시작한 세션이 스스로 알린다. 다른 세션은 알리지 않으므로 같은 알림이 겹치지 않는다
227async function setAttention($: EngineInterface, attention: Attention | null) {
228  await writeStatus($, { attention })
229  if (!attention) {
230    return
231  }
232  void (async () => {
233    await $.clock.sleep(NOTIFY_DELAY_MS)
234    const now = mine?.attention
235    if (!mine || now?.kind !== attention.kind || now.since !== attention.since) {
236      return
237    }
238    const name = mine.title ?? (mine.branch && parseIssue(mine.branch)) ?? mine.branch ?? mine.project
239    const body = attention.text ?? mine.prompt
240    await $.ui.notify(`${name} ${ATTENTION[attention.kind].label}${body ? `: ${body}` : ''}`, { title: mine.project })
241  })().catch(() => undefined)
242}
243
244// 아직 끝나지 않은 백그라운드 작업. mod 가 다시 로드돼 mine 이 비었으면 상태 파일에서 읽는다
245async function backgroundTasks($: EngineInterface): Promise<BackgroundTask[]> {
246  if (mine) {
247    return mine.background ?? []
248  }
249  const base = await statusDir($)
250  const own = base ? await readOwnStatus($, `${base}/${await $.session.id()}.json`) : null
251  return own?.background ?? []
252}
253
254// 답은 끝났지만 백그라운드 작업을 기다리는 세션. 확인 대기 대신 진행 중에 두고, heartbeat 가 작업이 끝났는지 다시 본다
255async function keepWorking($: EngineInterface) {
256  await writeStatus($, { state: 'working', attention: null, waitingOnTasks: true })
257}
258
259// classic hook 이 알려 준 transcript 경로. mod 가 다시 로드된 직후에는 비어 있어 규칙으로 만든다
260let transcriptPath: string | null = null
261
262async function ownTranscript($: EngineInterface): Promise<string | null> {
263  const home = await $.env.get('HOME')
264  return transcriptPath ?? (home ? transcriptPathOf(home, await $.session.root(), await $.session.id()) : null)
265}
266
267// 끝났다는 알림이 prompt.submit 을 거치지 않고 올 때가 있다. 턴 도중에 와서 queued_command 로 붙거나, 결과가 <agent-message> 로 먼저 온 경우다
268// 그래서 transcript 에서 그 작업 id 가 나온 줄만 grep 으로 뽑아 끝난 작업을 찾는다
269async function finishedInTranscript($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<string[]> {
270  try {
271    const path = await ownTranscript($)
272    if (!path || tasks.length === 0) {
273      return []
274    }
275    const found = await $.process.run(['grep', '-F', ...tasks.flatMap(task => ['-e', task.id]), path])
276    return finishedTaskIds(found.stdout)
277  } catch {
278    return []
279  }
280}
281
282// 끝났거나 너무 오래된 작업을 지우고 남은 작업을 돌려준다. transcript 까지 보는 것은 턴이 끝났을 때만이다
283async function settleBackground($: EngineInterface, now: number, withTranscript: boolean): Promise<BackgroundTask[]> {
284  const tasks = await backgroundTasks($)
285  const finished = withTranscript ? await finishedInTranscript($, pendingTasks(tasks, now)) : []
286  const pending = pendingTasks(tasks, now, finished)
287  if (pending.length !== tasks.length) {
288    await writeStatus($, { background: pending })
289  }
290  return pending
291}
292
293// 1분마다 살아 있다고 남긴다. 백그라운드 작업만 기다리던 세션은 작업이 다 끝났으면 내 차례로 넘긴다
294async function heartbeat($: EngineInterface) {
295  const now = await $.clock.now()
296  const isWaitingOnTasks = mine?.state === 'working' && mine.waitingOnTasks === true
297  const pending = await settleBackground($, now, isWaitingOnTasks)
298  if (isWaitingOnTasks && pending.length === 0) {
299    await writeStatus($, { state: 'waiting', waitingOnTasks: false })
300    await setAttention($, { kind: 'answered', since: now, text: null })
301    return
302  }
303  await writeStatus($, {})
304}
305
306// /resume 목록에 보이는 이름을 transcript 에서 읽는다. 파일이 커서 이름 줄만 grep 으로 뽑는다
307async function readTitle($: EngineInterface): Promise<string | null> {
308  try {
309    const path = await ownTranscript($)
310    if (!path) {
311      return null
312    }
313    const found = await $.process.run(['grep', '-E', '"type":"(custom-title|ai-title)"', path])
314    return pickTitle(found.stdout)
315  } catch {
316    return null
317  }
318}
319
320// 클릭한 MR 이나 Linear 알림을 기본 브라우저로 연다
321function openUrl($: EngineInterface, url: string | null | undefined) {
322  if (url) {
323    void $.process.run(['open', url]).catch(() => undefined)
324  }
325}
326
327const currentTodos = (): TodoItem[] => todos
328
329async function setTodos($: EngineInterface, next: TodoItem[]) {
330  todos = next
331  await writeStatus($, { todos: summarizeTodos(todos) })
332}
333
334async function readSessions($: EngineInterface): Promise<SessionStatus[]> {
335  const base = await statusDir($)
336  if (!base || !(await $.fs.exists(base))) {
337    return []
338  }
339  const now = await $.clock.now()
340  const sessions: SessionStatus[] = []
341  for (const entry of await $.fs.list(base)) {
342    if (!entry.name.endsWith('.json')) {
343      continue
344    }
345    const path = `${base}/${entry.name}`
346    if (now - entry.mtimeMs > PRUNE_MS) {
347      await $.process.run(['rm', '-f', path]).catch(() => undefined)
348      continue
349    }
350    try {
351      sessions.push(JSON.parse(await $.fs.read(path)) as SessionStatus)
352    } catch {
353      // 쓰는 도중이면 다음 갱신 때 읽는다
354    }
355  }
356  return sessions
357}
358
359async function refreshSessions($: EngineInterface) {
360  if (isRefreshing) {
361    return
362  }
363  isRefreshing = true
364  try {
365    const now = await $.clock.now()
366    const merged = Object.entries(await read($, branchMrs)).flatMap(([branch, mr]) => (mr.state === 'merged' ? [branch] : []))
367    const sessions = await readSessions($)
368    const next = triage(sessions, mine?.id ?? null, now, new Set(merged))
369    await update($, board, () => next)
370    await keepPokedex($, sessions, now).catch(() => undefined)
371    await update($, sessionsMeta, () => ({ refreshedAt: now, ownId: mine?.id ?? null }))
372  } finally {
373    isRefreshing = false
374  }
375}
376
377// 저장소의 worktree 와 MR
378
379// 포켓몬 이름을 쓸 언어. PokeAPI 의 언어 이름이다
380type PokemonLanguage = 'en' | 'ko'
381
382// plugin.json 의 userConfig. register 가 받은 값으로 채운다
383const config = { gitlabUrl: '', baseBranch: '', autoCleanup: false, pokemonLanguage: 'en' as PokemonLanguage }
384
385const LINEAR_TTL_MS = 5 * 60 * 1000
386// 파일을 읽어 조회 주기가 됐는지 보는 간격. 파일만 읽으므로 짧아도 된다
387const ISSUES_CHECK_MS = 30 * 1000
388
389const rows = atom({ plugin: 'mod-hud', key: 'rows' } as const, [])
390const branchMrs = atom({ plugin: 'mod-hud', key: 'branchMrs' } as const, {})
391const issuesMeta = atom({ plugin: 'mod-hud', key: 'issuesMeta' } as const, {
392  refreshedAt: null,
393  isRefreshing: false,
394  log: [],
395})
396
397// 세션이 열린 git 저장소. api 는 GitLab 프로젝트 API 주소이고 GitLab 이 아니면 null, base 는 커밋 수를 셀 기준 브랜치
398// host 는 MR 을 찾는 곳(GitHub 는 gh CLI), name 은 패널에 보일 저장소 이름, problem 은 MR 을 못 찾는 까닭
399type RepoInfo = {
400  root: string
401  api: string | null
402  base: string
403  host: 'gitlab' | 'github' | null
404  name: string | null
405  problem: string | null
406}
407
408// undefined 는 아직 안 봄, null 은 git 저장소가 아님
409let repoInfo: RepoInfo | null | undefined
410const linearCache = new Map<string, { status: string | null; at: number }>()
411
412async function ensureRepo($: EngineInterface): Promise<RepoInfo | null> {
413  if (repoInfo === undefined) {
414    const repo = await $.session.repo()
415    if (!repo) {
416      repoInfo = null
417    } else {
418      const head = config.baseBranch ? null : await git($, repo.root, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
419      const api = repo.remote ? gitlabProjectApi(repo.remote, config.gitlabUrl) : null
420      const host = api ? 'gitlab' : repo.remote && isGithubRemote(repo.remote) ? 'github' : null
421      repoInfo = {
422        root: repo.root,
423        api,
424        base: config.baseBranch || (head && parseDefaultBranch(head)) || 'main',
425        host,
426        name: repo.remote?.trim().match(/([^/:]+\/[^/:]+?)(?:\.git)?\/?$/)?.[1] ?? null,
427        problem: !repo.remote
428          ? 'no remote'
429          : host === 'gitlab'
430            ? (await readToken($, repo.root))
431              ? null
432              : 'no GITLAB_TOKEN'
433            : host === 'github'
434              ? (await isGhReady($, repo.root))
435                ? null
436                : 'gh not signed in'
437              : 'not GitLab or GitHub',
438      }
439    }
440  }
441  return repoInfo
442}
443
444// 환경 변수 GITLAB_TOKEN 을 먼저 보고, 없으면 저장소의 .env.local 에서 읽는다
445async function readToken($: EngineInterface, root: string): Promise<string | null> {
446  const fromEnv = await $.env.get('GITLAB_TOKEN').catch(() => undefined)
447  if (fromEnv) {
448    return fromEnv
449  }
450  try {
451    return readEnvValue(await $.fs.read(`${root}/.env.local`), 'GITLAB_TOKEN')
452  } catch {
453    return null
454  }
455}
456
457async function fetchMr($: EngineInterface, api: string, token: string, branch: string): Promise<Mr | null> {
458  const query = `source_branch=${encodeURIComponent(branch)}&order_by=updated_at&per_page=1`
459  const res = await $.http.fetch(`${api}/merge_requests?${query}`, {
460    headers: { 'PRIVATE-TOKEN': token },
461  })
462  if (!res.ok) {
463    return null
464  }
465  const [mr] = JSON.parse(res.text) as Array<Parameters<typeof mrAction>[0] & { iid: number; sha: string; web_url: string }>
466  return mr ? { iid: mr.iid, state: mr.state, sha: mr.sha, url: mr.web_url, action: mrAction(mr) } : null
467}
468
469async function isGhReady($: EngineInterface, cwd: string): Promise<boolean> {
470  try {
471    return (await $.process.run(['gh', 'auth', 'status'], { cwd })).exitCode === 0
472  } catch {
473    return false
474  }
475}
476
477// GitHub 는 gh CLI 로 브랜치의 가장 최근 PR 을 찾는다. gh 가 없거나 로그인하지 않았으면 null
478async function fetchPr($: EngineInterface, cwd: string, branch: string): Promise<Mr | null> {
479  try {
480    const fields = 'number,state,headRefOid,url,mergeable'
481    const ran = await $.process.run(['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', fields], { cwd })
482    return ran.exitCode === 0 ? parseGithubPr(ran.stdout) : null
483  } catch {
484    return null
485  }
486}
487
488async function fetchLinear($: EngineInterface, issue: string, now: number): Promise<string | null> {
489  const cached = linearCache.get(issue)
490  if (cached && now - cached.at < LINEAR_TTL_MS) {
491    return cached.status
492  }
493  let status: string | null = null
494  try {
495    const result = await $.mcp.call('claude.ai Linear', 'get_issue', { id: issue, fields: ['status'] })
496    const text = result.content.find(block => block.type === 'text')?.text
497    const parsed = text ? (JSON.parse(text) as { status?: string | { name?: string } }) : null
498    status = typeof parsed?.status === 'string' ? parsed.status : (parsed?.status?.name ?? null)
499  } catch {
500    // Linear 연결이 없으면 상태 없이 보인다
501  }
502  linearCache.set(issue, { status, at: now })
503  return status
504}
505
506// MR 이 머지됐고 남은 작업이 없는 worktree 를 지운다. 지웠으면 기록할 한 줄을 돌려준다
507async function cleanup($: EngineInterface, root: string, row: Row): Promise<string | null> {
508  const removed = await $.process.run(['git', 'worktree', 'remove', row.path], { cwd: root })
509  if (removed.exitCode !== 0) {
510    row.note = `cleanup failed: ${removed.stderr.trim().split('\n')[0] ?? ''}`
511    return null
512  }
513  // head 가 MR 의 sha 와 같다는 걸 확인했으므로 squash 머지여도 -D 로 지운다
514  if (row.branch) {
515    await $.process.run(['git', 'branch', '-D', row.branch], { cwd: root })
516  }
517  const name = row.issue ? `#${row.issue}` : (row.branch ?? row.path)
518  // 하루 넘게 대기하던 세션은 지운 폴더에 남으므로 닫으라고 알린다
519  const left = row.session ? ', close its idle session' : ''
520  $.ui.toast(`${name} MR merged, worktree cleaned up${left}`)
521  return `${name}: removed worktree and branch${left}`
522}
523
524// git, GitLab, Linear 를 불러 worktree 목록을 새로 만든다. 조회를 맡은 세션만 부른다
525async function collectIssues($: EngineInterface, { root, api, base, host }: RepoInfo, now: number) {
526  const listed = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root })
527  const trees = parseWorktrees(listed.stdout)
528  const allSessions = await readSessions($)
529  const sessions = assignSessions(trees, allSessions, now)
530  const token = api ? await readToken($, root) : null
531  const findMr = (branch: string) =>
532    api && token ? fetchMr($, api, token, branch) : host === 'github' ? fetchPr($, root, branch) : null
533  const ownCwd = await $.session.cwd()
534
535  const next: Row[] = []
536  const log: string[] = []
537  for (const tree of trees) {
538    const [ahead, dirty] = await Promise.all([
539      $.process.run(['git', 'rev-list', '--count', `origin/${base}..HEAD`], { cwd: tree.path }),
540      $.process.run(['git', 'status', '--porcelain'], { cwd: tree.path }),
541    ])
542    const issue = tree.branch ? parseIssue(tree.branch) : null
543    const row: Row = {
544      ...tree,
545      issue,
546      ahead: Number(ahead.stdout.trim()) || 0,
547      dirty: dirty.stdout.split('\n').filter(Boolean).length,
548      session: sessions.get(tree.path) ?? null,
549      mr: tree.branch ? await findMr(tree.branch) : null,
550      linear: issue && issue.includes('-') ? await fetchLinear($, issue, now) : null,
551      note: null,
552    }
553
554    // 자동 정리는 worktree 와 브랜치를 지우므로 autoCleanup 을 켠 사람만 한다
555    if (config.autoCleanup) {
556      const blocker = cleanupBlocker(row, allSessions, ownCwd, now)
557      const removed = blocker === null ? await cleanup($, root, row) : null
558      if (removed !== null) {
559        log.push(removed)
560        continue
561      }
562      if (blocker !== null && blocker !== 'not-merged') {
563        row.note = `merged, cleanup on hold: ${blocker}`
564      }
565    }
566    next.push(row)
567  }
568
569  // worktree 가 아닌 브랜치에서 일한 세션도 MR 을 찾는다. 세션 줄의 MR 링크와 머지 여부에 쓴다
570  const known = new Map(next.map(row => [row.branch, row.mr]))
571  const found: Record<string, Mr> = {}
572  // MR 을 찾아본 브랜치. 세션은 여기 있는 브랜치의 MR 상태만 상태 파일에 적는다
573  const checked = new Set<string>()
574  for (const branch of new Set(allSessions.filter(s => s.state !== 'ended').map(s => s.branch))) {
575    if (!branch || isLongLivedBranch(branch, base)) {
576      continue
577    }
578    const mr = known.has(branch) ? known.get(branch) : await findMr(branch)
579    checked.add(branch)
580    if (mr) {
581      found[branch] = mr
582    }
583  }
584  return { rows: next, branchMrs: found, checked: [...checked], log }
585}
586
587async function readIssuesCache($: EngineInterface, path: string): Promise<IssuesCache> {
588  try {
589    return parseIssuesCache(await $.fs.read(path))
590  } catch {
591    return emptyIssuesCache()
592  }
593}
594
595let isIssuesRefreshing = false
596
597// 파일이 2분 안이면 파일만 읽고, 아니면 한 세션만 조회해 파일에 쓴다. isForced 는 새로고침 버튼
598async function refreshIssues($: EngineInterface, isForced: boolean) {
599  const repo = await ensureRepo($)
600  const home = await $.env.get('HOME')
601  if (!repo || !home || isIssuesRefreshing) {
602    return
603  }
604  isIssuesRefreshing = true
605  try {
606    // 다른 저장소의 세션과 섞이지 않게 저장소마다 파일을 따로 둔다
607    const path = `${home}/.claude/${issuesFileOf(repo.root)}`
608    const me = await $.session.id()
609    let cache = await readIssuesCache($, path)
610    // 이 세션 브랜치의 MR 을 아직 찾아보지 않았으면 주기를 기다리지 않고 조회한다. 그 전까지 이 세션은 알을 받지 않는다
611    const isUnchecked = (branch: string | null | undefined) =>
612      !!branch && !isLongLivedBranch(branch, repo.base) && !cache.checked.includes(branch)
613
614    if (shouldFetch(cache, await $.clock.now(), me, isForced || isUnchecked(mine?.branch), ISSUES_TTL_MS)) {
615      // 파일에 이 세션이 맡는다고 써 두고, 잠깐 뒤 다시 읽어 그대로일 때만 부른다. 나중에 쓴 세션이 맡는다
616      await $.fs.write(path, JSON.stringify({ ...cache, lease: { by: me, at: await $.clock.now() } }))
617      await $.clock.sleep(LEASE_SETTLE_MS)
618      const claimed = await readIssuesCache($, path)
619      if (claimed.lease?.by === me) {
620        await update($, issuesMeta, prev => ({ ...prev, isRefreshing: true }))
621        const now = await $.clock.now()
622        try {
623          const collected = await collectIssues($, repo, now)
624          cache = { fetchedAt: now, ...collected, log: [...collected.log, ...claimed.log].slice(0, 5), lease: null }
625        } catch {
626          // 실패해도 fetchedAt 을 남겨 모든 세션이 주기 동안 다시 부르지 않게 한다. 본 목록은 그대로 둔다
627          cache = { ...claimed, fetchedAt: now, lease: null }
628        }
629        await $.fs.write(path, JSON.stringify(cache))
630      } else {
631        cache = claimed
632      }
633    }
634
635    await update($, rows, () => cache.rows)
636    await update($, branchMrs, () => cache.branchMrs)
637    await update($, issuesMeta, () => ({ refreshedAt: cache.fetchedAt, isRefreshing: false, log: cache.log }))
638    // 포켓몬 알이 금 가고 부화하는 기준이라, 이 세션 브랜치의 MR 상태를 조회한 브랜치와 함께 상태 파일에 적어 다른 세션도 보게 한다
639    // 아직 찾아보지 않은 브랜치는 적지 않는다. 다른 브랜치를 보고 적은 상태는 모르는 것으로 친다
640    const branch = mine?.branch ?? null
641    if (mine && !isUnchecked(branch)) {
642      const mrState = branch ? (cache.branchMrs[branch]?.state ?? null) : null
643      if ((mine.mrState ?? null) !== mrState || mine.mrBranch !== branch) {
644        await writeStatus($, { mrState, mrBranch: branch })
645      }
646    }
647  } catch {
648    // 이슈를 못 읽어도 다른 패널 동작은 막지 않는다
649  } finally {
650    isIssuesRefreshing = false
651    await update($, issuesMeta, prev => ({ ...prev, isRefreshing: false }))
652  }
653}
654
655// 포켓몬 알과 박스
656
657// 부화, 키우미집, 박스 기록. 열린 세션 중 id 가 가장 작은 세션만 쓰고, 모든 세션이 읽는다
658const POKEDEX_FILE = '.claude/mod-hud-pokedex.json'
659// 박스로 보내기 요청. 어느 세션에서 눌러도 알 key 이름의 빈 파일을 두고, 기록을 쓰는 세션이 읽어 박스에 넣은 뒤 지운다
660const SENDBOX_DIR = '.claude/mod-hud-sendbox'
661// 앞장세우기 요청. 어느 세션에서 눌러도 박스 key 를 이 파일에 쓰고, 기록을 쓰는 세션이 읽어 반영한 뒤 지운다
662const LEAD_FILE = '.claude/mod-hud-lead'
663// 야생 전설에게 누른 버튼(throw, run, dismiss). 어느 세션에서 눌러도 이 파일에 쓰고, 기록을 쓰는 세션이 읽어 반영한 뒤 지운다
664const ENCOUNTER_FILE = '.claude/mod-hud-encounter'
665// 즐겨찾는 박스 key. 기록과 따로 두어 어느 세션에서 눌러도 바로 쓰고, 다른 세션은 다음 조회 때 읽는다
666const FAVORITES_FILE = '.claude/mod-hud-favorites.json'
667// 한 번 펼쳐 본 박스 key. 펼쳐 본 포켓몬은 NEW 가 사라진다. 다른 세션도 다음 조회 때 읽는다
668const SEEN_FILE = '.claude/mod-hud-seen.json'
669// 박스에 들어온 지 이만큼 안 된 포켓몬은 펼쳐 보기 전까지 이름 뒤에 NEW 가 붙는다. 길면 박스가 온통 NEW 라 한 시간으로 둔다
670const NEW_MS = 60 * 60 * 1000
671// 세션이 닫힌 뒤 머지를 기다리는 알의 MR 을 다시 조회하는 간격
672const PENDING_CHECK_MS = 2 * 60 * 1000
673
674const pokedex = atom({ plugin: 'mod-hud', key: 'pokedex' } as const, emptyPokedex())
675const eggs = atom({ plugin: 'mod-hud', key: 'eggs' } as const, [])
676
677let pendingCheckedAt = 0
678// 즐겨찾는 포켓몬은 PC BOX 맨 앞에 모이고 이름 앞에 ♥ 가 붙는다
679let favorites = new Set<string>()
680let seen = new Set<string>()
681// 이 패널에서 박스로 보낸 알. 기록을 쓰는 세션이 반영할 때까지 기록을 다시 읽어도 패널에 되살리지 않는다
682const sending = new Set<string>()
683
684async function readPokedex($: EngineInterface, home: string): Promise<Pokedex> {
685  try {
686    return parsePokedex(await $.fs.read(`${home}/${POKEDEX_FILE}`))
687  } catch {
688    return emptyPokedex()
689  }
690}
691
692// 닫힌 세션의 브랜치는 그 세션의 저장소 remote 와 토큰으로 MR 을 다시 조회한다
693async function refreshPending($: EngineInterface, dex: Pokedex): Promise<Pokedex> {
694  let next = dex
695  for (const egg of dex.pending) {
696    if (egg.mrState !== 'opened' || !egg.branch) {
697      continue
698    }
699    try {
700      const remote = (await git($, egg.root, ['remote', 'get-url', 'origin']))?.trim()
701      const api = remote ? gitlabProjectApi(remote, config.gitlabUrl) : null
702      const token = api ? await readToken($, egg.root) : null
703      const mr =
704        api && token
705          ? await fetchMr($, api, token, egg.branch)
706          : remote && isGithubRemote(remote)
707            ? await fetchPr($, egg.root, egg.branch)
708            : null
709      if (mr && mr.state !== egg.mrState) {
710        next = updatePending(next, egg.key, mr.state)
711      }
712    } catch {
713      // 조회하지 못하면 다음 주기에 다시 본다
714    }
715  }
716  return next
717}
718
719async function sendToBox($: EngineInterface, key: string) {
720  const home = await $.env.get('HOME')
721  if (!home) {
722    return
723  }
724  const dir = `${home}/${SENDBOX_DIR}`
725  await $.process.run(['mkdir', '-p', dir])
726  await $.fs.write(`${dir}/${key}`, '')
727  // 기록을 쓰는 세션이 다음 조회 때 넣으므로, 그동안 이 패널에서는 바로 빼 두고 토스트도 여기서 띄운다
728  sending.add(key)
729  const egg = (await read($, eggs)).find(egg => egg.key === key)
730  await update($, eggs, prev => prev.filter(egg => egg.key !== key))
731  if (egg) {
732    $.ui.toast(`${await pokemonName($, home, egg.species)} was sent to the PC BOX (${egg.label})`)
733  }
734  void refreshSessions($)
735}
736
737async function readKeys($: EngineInterface, home: string, file: string): Promise<Set<string>> {
738  try {
739    const path = `${home}/${file}`
740    return new Set((await $.fs.exists(path)) ? parseKeys(await $.fs.read(path)) : [])
741  } catch {
742    return new Set()
743  }
744}
745
746async function toggleFavorite($: EngineInterface, key: string) {
747  const home = await $.env.get('HOME')
748  if (!home) {
749    return
750  }
751  // 다른 세션에서 바꾼 것을 덮지 않게 파일을 다시 읽고 고친다
752  const next = await readKeys($, home, FAVORITES_FILE)
753  if (next.has(key)) {
754    next.delete(key)
755  } else {
756    next.add(key)
757  }
758  await $.fs.write(`${home}/${FAVORITES_FILE}`, JSON.stringify([...next]))
759  favorites = next
760  $.ui.invalidate('ui.render')
761}
762
763// 펼쳐 본 포켓몬은 NEW 를 지운다. 박스에 없는 key 는 버려 파일이 자라지 않게 한다
764async function markSeen($: EngineInterface, key: string, box: readonly BoxEntry[]) {
765  const home = await $.env.get('HOME')
766  if (!home) {
767    return
768  }
769  const inBox = new Set(box.map(entry => entry.key))
770  const next = new Set([...(await readKeys($, home, SEEN_FILE)), key].filter(one => inBox.has(one)))
771  await $.fs.write(`${home}/${SEEN_FILE}`, JSON.stringify([...next]))
772  seen = next
773  $.ui.invalidate('ui.render')
774}
775
776async function pickLead($: EngineInterface, key: string) {
777  const home = await $.env.get('HOME')
778  if (!home) {
779    return
780  }
781  await $.fs.write(`${home}/${LEAD_FILE}`, key)
782  void refreshSessions($)
783}
784
785async function answerEncounter($: EngineInterface, action: EncounterAction) {
786  const home = await $.env.get('HOME')
787  if (!home) {
788    return
789  }
790  if (action === 'throw' && wild && thrown !== wild.species) {
791    thrown = wild.species
792    arrivalShown = { kind: 'catch', species: wild.species, at: await $.clock.now(), name: wild.name }
793    wild = null
794    $.ui.invalidate('ui.render')
795  }
796  await $.fs.write(`${home}/${ENCOUNTER_FILE}`, action)
797  void refreshSessions($)
798}
799
800async function readEncounterRequest($: EngineInterface, home: string): Promise<EncounterAction | null> {
801  try {
802    const path = `${home}/${ENCOUNTER_FILE}`
803    const text = (await $.fs.exists(path)) ? (await $.fs.read(path)).trim() : ''
804    return text === 'throw' || text === 'run' || text === 'dismiss' ? text : null
805  } catch {
806    return null
807  }
808}
809
810async function readLeadRequest($: EngineInterface, home: string): Promise<string | null> {
811  try {
812    const path = `${home}/${LEAD_FILE}`
813    return (await $.fs.exists(path)) ? (await $.fs.read(path)).trim() || null : null
814  } catch {
815    return null
816  }
817}
818
819async function readSendRequests($: EngineInterface, home: string): Promise<string[]> {
820  const dir = `${home}/${SENDBOX_DIR}`
821  try {
822    return (await $.fs.exists(dir)) ? (await $.fs.list(dir)).map(entry => entry.name) : []
823  } catch {
824    return []
825  }
826}
827
828// 세션 목록을 읽을 때마다 기록을 맞춘다. 기록을 쓰는 세션만 파일을 바꾸고 토스트를 띄운다
829async function keepPokedex($: EngineInterface, sessions: SessionStatus[], now: number) {
830  const home = await $.env.get('HOME')
831  if (!home) {
832    return
833  }
834  let dex = await readPokedex($, home)
835  if (keeperOf(sessions, now) === (await $.session.id())) {
836    const before = JSON.stringify(dex)
837    if (now - pendingCheckedAt >= PENDING_CHECK_MS) {
838      pendingCheckedAt = now
839      dex = await refreshPending($, dex)
840    }
841    const requests = await readSendRequests($, home)
842    const advanced = advancePokedex(dex, sessions, now, requests)
843    const pick = await readLeadRequest($, home)
844    const answer = await readEncounterRequest($, home)
845    const grown = advanceEvolution(advanced.dex, sessions, now, pick, answer)
846    dex = grown.dex
847    if (JSON.stringify(dex) !== before) {
848      await $.fs.write(`${home}/${POKEDEX_FILE}`, JSON.stringify(dex))
849    }
850    // 부화하지 않은 알의 요청은 버튼이 없어 생기지 않으니, 읽은 요청은 모두 지운다
851    for (const name of requests) {
852      await $.process.run(['rm', '-f', `${home}/${SENDBOX_DIR}/${name}`])
853    }
854    if (pick !== null) {
855      await $.process.run(['rm', '-f', `${home}/${LEAD_FILE}`])
856    }
857    if (answer !== null) {
858      await $.process.run(['rm', '-f', `${home}/${ENCOUNTER_FILE}`])
859    }
860    for (const egg of advanced.born) {
861      $.ui.toast(`Your ${egg.label} egg hatched into ${await pokemonName($, home, egg.species)}!`)
862    }
863    // 이 패널에서 보낸 알은 누를 때 이미 토스트를 띄웠다
864    for (const egg of advanced.boxed.filter(egg => !sending.has(egg.key))) {
865      $.ui.toast(`${await pokemonName($, home, egg.species)} was sent to the PC BOX (${egg.label})`)
866    }
867    for (const egg of advanced.left) {
868      $.ui.toast(`Left the ${egg.label} egg at the DAY CARE`)
869    }
870    for (const egg of advanced.given) {
871      $.ui.toast(`Got an egg from the DAY CARE for ${egg.label}`)
872    }
873    for (const { entry, from } of grown.evolved) {
874      // 진화했다는 말은 패널의 진화 장면이 끝날 때 한다
875      $.ui.toast(`What? ${(await pokemonName($, home, from)).toUpperCase()} is evolving!`)
876    }
877    if (grown.dex.encounter && grown.dex.encounter.at === now) {
878      $.ui.toast(`A wild ${(await pokemonName($, home, grown.dex.encounter.species)).toUpperCase()} appeared!`)
879    }
880    for (const entry of grown.legends) {
881      $.ui.toast(`Gotcha! ${(await pokemonName($, home, entry.species)).toUpperCase()} was caught!`)
882    }
883    for (const species of grown.fled) {
884      $.ui.toast(`Oh no! ${(await pokemonName($, home, species)).toUpperCase()} fled...`)
885    }
886  }
887  favorites = await readKeys($, home, FAVORITES_FILE)
888  seen = await readKeys($, home, SEEN_FILE)
889  await update($, pokedex, () => dex)
890  const all = eggsOf(sessions, dex, now)
891  // 기록에서 더 이상 부화한 알이 아니면 박스에 들어간 것이라 잊는다
892  for (const key of sending) {
893    if (!all.some(egg => egg.key === key && egg.stage === 'hatched')) {
894      sending.delete(key)
895    }
896  }
897  const next = all.filter(egg => !sending.has(egg.key))
898  // 30초마다 부르므로 그대로면 쓰지 않아 패널을 괜히 다시 그리지 않는다
899  await update($, eggs, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
900}
901
902// 포켓몬 그림과 이름은 PokeAPI 에서 처음 쓸 때 한 번 받아 이 폴더에 두고 다시 쓴다
903const SPRITE_DIR = '.claude/mod-hud-sprites'
904// 7세대 박스 아이콘. 처음부터 작게 그린 도트라 줄이지 않아도 돼 뭉개지지 않는다
905const spriteUrl = (species: number) =>
906  `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/icons/${species}.png`
907const speciesUrl = (species: number) => `https://pokeapi.co/api/v2/pokemon-species/${species}`
908
909// 읽은 그림은 투명한 테두리를 잘라 둔다. null 은 받은 파일이 깨져 이 세션에서는 다시 읽지 않는 것
910const sprites = new Map<number, Pixels | null>()
911const fitted = new Map<string, Pixels>()
912const spritesLoading = new Set<number>()
913
914const lookupSprite: SpriteLookup = (species, size) => {
915  const key = `${species}:${size}`
916  const cached = fitted.get(key)
917  if (cached) {
918    return cached
919  }
920  const source = sprites.get(species)
921  if (!source) {
922    return undefined
923  }
924  const made = fit(source, size)
925  fitted.set(key, made)
926  return made
927}
928
929// 금 간 알과 박스에 보이는 포켓몬만 부른다. 받지 못하면 그동안 몬스터볼을 그리고 다음 그릴 때 다시 받는다
930async function loadSprite($: EngineInterface, species: number) {
931  if (sprites.has(species) || spritesLoading.has(species)) {
932    return
933  }
934  spritesLoading.add(species)
935  try {
936    const home = await $.env.get('HOME')
937    if (!home) {
938      return
939    }
940    const path = `${home}/${SPRITE_DIR}/icons/${species}.png`
941    if (!(await $.fs.exists(path))) {
942      const got = await $.process.run(['curl', '-fsSL', '--create-dirs', '-o', path, spriteUrl(species)])
943      if (got.exitCode !== 0) {
944        return
945      }
946    }
947    const { base64 } = await $.fs.read(path, { as: 'bytes' })
948    // 엔진의 Uint8Array 는 fromBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
949    const bytes = (Uint8Array as unknown as { fromBase64(text: string): Uint8Array }).fromBase64(base64)
950    try {
951      sprites.set(species, crop(decodePng(bytes)))
952    } catch {
953      // 받다 끊긴 파일은 지워 다음 세션에서 다시 받게 한다
954      sprites.set(species, null)
955      await $.process.run(['rm', '-f', path])
956    }
957  } catch {
958    // 네트워크나 파일을 못 쓰면 몬스터볼로 둔다
959  } finally {
960    spritesLoading.delete(species)
961  }
962}
963
964// 펼친 박스 줄에 그릴 도트. 아직 없으면 받아 온 뒤 다시 그린다
965function loadOpened($: EngineInterface, species: number) {
966  if (sprites.has(species)) {
967    return
968  }
969  void loadSprite($, species).then(() => {
970    if (sprites.get(species)) {
971      $.ui.invalidate('ui.render')
972    }
973  })
974}
975
976// 설정한 언어의 이름. 파일에서 한 번 읽어 메모리에 두고, 모르는 이름은 PokeAPI 에서 받아 파일에 더한다. 언어마다 파일을 따로 둔다
977const namesPath = (home: string) => `${home}/${SPRITE_DIR}/names-${config.pokemonLanguage}.json`
978const names = new Map<number, string>()
979// 이 세션에서 받으려다 실패한 종. 패널을 그릴 때마다 네트워크를 다시 부르지 않는다
980const namesFailed = new Set<number>()
981const namesLoading = new Map<number, Promise<string | null>>()
982let namesRead: Promise<void> | null = null
983
984function readNames($: EngineInterface, home: string): Promise<void> {
985  namesRead ??= (async () => {
986    try {
987      const saved = JSON.parse(await $.fs.read(namesPath(home))) as Record<string, string>
988      for (const [species, name] of Object.entries(saved)) {
989        names.set(Number(species), name)
990      }
991    } catch {
992      // 처음이면 파일이 없다
993    }
994  })()
995  return namesRead
996}
997
998function fetchName($: EngineInterface, home: string, species: number): Promise<string | null> {
999  const loading = namesLoading.get(species)
1000  if (loading) {
1001    return loading
1002  }
1003  const made = (async () => {
1004    try {
1005      const res = await $.http.fetch(speciesUrl(species))
1006      const parsed = res.ok ? (JSON.parse(res.text) as { names?: { name: string; language: { name: string } }[] }) : null
1007      const name = parsed?.names?.find(one => one.language.name === config.pokemonLanguage)?.name
1008      if (!name) {
1009        namesFailed.add(species)
1010        return null
1011      }
1012      names.set(species, name)
1013      await $.fs.write(namesPath(home), JSON.stringify(Object.fromEntries(names)))
1014      return name
1015    } catch {
1016      namesFailed.add(species)
1017      return null
1018    } finally {
1019      namesLoading.delete(species)
1020    }
1021  })()
1022  namesLoading.set(species, made)
1023  return made
1024}
1025
1026// 토스트에 쓰는 이름. 받을 때까지 기다리고, 못 받으면 도감 번호로 부른다
1027async function pokemonName($: EngineInterface, home: string, species: number): Promise<string> {
1028  await readNames($, home)
1029  const known = names.get(species) ?? (namesFailed.has(species) ? null : await fetchName($, home, species))
1030  return known ?? `No.${species}`
1031}
1032
1033// 패널에 쓰는 이름. 기다리지 않고 지금 아는 이름을 쓰며, 모르면 도감 번호로 그리고 받은 뒤 다시 그린다
1034async function shownName($: EngineInterface, home: string, species: number): Promise<string> {
1035  await readNames($, home)
1036  const known = names.get(species)
1037  if (known) {
1038    return known
1039  }
1040  if (!namesFailed.has(species)) {
1041    void fetchName($, home, species).then(name => {
1042      if (name) {
1043        $.ui.invalidate('ui.render')
1044      }
1045    })
1046  }
1047  return `No.${species}`
1048}
1049
1050// 패널이 그린 알과 박스의 크기와 내용. 패널이 그릴 때마다 바꾸고, 칠할 수 없으면 비운다
1051let nestScene: { columns: number; rows: number; input: NestInput } | null = null
1052// 보고 있는 박스 페이지. 0부터 센다
1053let boxPage = 0
1054// 이 패널이 마지막으로 본 진화의 시각(기록에 적힌 것)과 그 진화를 처음 본 이 세션의 시각
1055// 진화는 기록을 쓰는 세션이 적고 다른 세션은 30초마다 읽으므로, 깜빡임은 이 세션이 처음 본 때부터 센다
1056let evolvingSeen = 0
1057let evolvingSeenAt = 0
1058// 이 진화를 처음 본 이 세션의 시각. 그림을 기다리느라 evolvingSeenAt 이 밀려도 그대로다
1059let evolvingFirstAt = 0
1060// 이보다 오래된 진화는 처음 봐도 깜빡이지 않는다. 다른 세션이 기록을 읽는 간격보다 넉넉하다
1061const EVOLVING_FRESH_MS = 60 * 1000
1062// 진화 전과 뒤 그림을 받을 때까지 진화 장면을 미룬다. 이만큼 기다려도 못 받으면 그림 없이 지나간다
1063const EVOLVE_WAIT_MS = 30 * 1000
1064// 진화 장면 아래 글 줄에 쓸 진화 전과 뒤 이름
1065let evolvingNames: { from: string; to: string } | null = null
1066// 마지막으로 그린 진화 장면 글. 바뀌면 다시 그린다
1067let evolveCaptionDrawn: string | null = null
1068// 이 패널이 마지막으로 본 전설 등장(기록에 적힌 시각)과, 그 등장 장면을 처음 보인 이 세션의 시각
1069let arrivalSeen = 0
1070// 보일 전설 장면. appear 는 줄무늬와 함께 나타나는 장면, catch 는 몬스터볼에 잡히는 장면. at 은 이 세션에서 장면을 시작할 시각
1071let arrivalShown: { kind: 'appear' | 'catch'; species: number; at: number; name: string } | null = null
1072// 장면을 겹쳐 띄운 Raster 가 지금 그려져 있는지. 시작할 때와 끝날 때 다시 그린다
1073let arrivalMounted = false
1074// 장면마다 drawArrival 의 걸음 범위
1075const ARRIVAL_PART = { appear: { from: 0, length: 16 }, catch: { from: 16, length: ARRIVAL_FRAMES - 16 } } as const
1076const ARRIVAL_COLUMNS = 60
1077// 지금 나타나 있는 야생 전설과 놓친 전설의 이름
1078let wild: { species: number; name: string } | null = null
1079// 이 패널에서 몬스터볼을 던진 전설. 기록을 쓰는 세션은 30초마다 요청을 읽으므로, 그 전에 잡는 장면을 먼저 보이고 버튼을 걷는다
1080// 기록에 그 전설의 잡는 장면이 적히면 다시 보이지 않는다
1081let thrown: number | null = null
1082let gone: string | null = null
1083
1084// 지금 보일 등장 장면의 걸음. 끝났으면 null
1085function arrivalStep(now: number): number | null {
1086  if (!arrivalShown || now < arrivalShown.at) {
1087    return null
1088  }
1089  const part = ARRIVAL_PART[arrivalShown.kind]
1090  const step = Math.floor((now - arrivalShown.at) / NEST_TICK_MS)
1091  return step < part.length ? part.from + step : null
1092}
1093
1094function arrivalCells(columns: number, now: number): string {
1095  const shown = arrivalShown!
1096  return base64Of(drawArrival(shown.species, shown.name, Math.min(columns, ARRIVAL_COLUMNS), arrivalStep(now) ?? 0, lookupSprite))
1097}
1098// PC BOX 와 DAY CARE 머리줄의 글자. 박스 수와, 키우미집에 맡겨 키우는 포켓몬의 경험치. ★ 와 막대는 Raster 가 못 그려 Text 로 그린다
1099let boxHeader: { count: number; lead: string | null } = { count: 0, lead: null }
1100// PC BOX 를 접었는지. 접으면 머리줄만 보인다
1101let boxCollapsed = false
1102// 마지막으로 그린 키우미집 풀밭 줄 수. 칠할 때 줄 수가 바뀌었으면 다시 그린다
1103let daycareRowsDrawn = 0
1104// 키우미집을 접었는지. 접으면 머리줄의 맡긴 포켓몬 경험치와 남은 알 수만 보인다
1105let daycareCollapsed = false
1106// 이번 박스 페이지의 줄마다 보일 이름과 속성. 박스 key 로 찾는다
1107let boxRows = new Map<
1108  string,
1109  { name: string; types: string; caught: string; canLead: boolean; rarity: ReturnType<typeof rarityOf>; isFavorite: boolean; isNew: boolean }
1110>()
1111// 눌러 펼친 박스 줄의 key. 펼친 줄 아래에 잡은 날짜와 어디서 왔는지, 키우미집 버튼이 보인다
1112let boxOpen: string | null = null
1113let nestFrame = 0
1114// 그림이 보일 때만 돈다. 패널이 닫히거나 입력창 위로 옮겨 칠할 곳이 없으면 멈춘다
1115let nestTimer: ReturnType<EngineInterface['clock']['every']> | null = null
1116// 그린 직후에는 Raster 가 아직 화면에 붙지 않아 칠하기를 거절할 수 있어, 몇 번 거절이 이어질 때만 멈춘다
1117let nestDenied = 0
1118const NEST_DENY_LIMIT = 8
1119
1120function base64Of(cells: Uint32Array): string {
1121  // 엔진의 Uint8Array 는 toBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
1122  const bytes = new Uint8Array(cells.buffer) as Uint8Array & { toBase64(): string }
1123  return bytes.toBase64()
1124}
1125
1126function nestCells(scene: { columns: number; input: NestInput }, now: number): string {
1127  return base64Of(drawNest(scene.input, scene.columns, nestFrame, now, lookupSprite))
1128}
1129
1130// 막 진화해 진화 장면을 보이는 중인 포켓몬. 최종 진화형이 되어 맡긴 것이 풀렸어도 장면이 끝날 때까지 풀밭에 선다
1131function evolvingOf(scene: { input: NestInput }, now: number): BoxEntry | undefined {
1132  const evolving = scene.input.evolving
1133  return evolving && now - evolving.at < EVOLVE_MS ? scene.input.box.find(entry => entry.key === evolving.key) : undefined
1134}
1135
1136// 키우미집에 맡긴 포켓몬. 풀밭을 걸어다닌다
1137function walkerOf(scene: { input: NestInput }, now: number): BoxEntry | undefined {
1138  return evolvingOf(scene, now) ?? scene.input.box.find(entry => entry.key === scene.input.lead)
1139}
1140
1141function daycareCells(scene: { columns: number; input: NestInput }, now: number): string {
1142  const lead = walkerOf(scene, now)
1143  // 맡긴 포켓몬이 있으면 그림을 아직 못 불러왔어도 풀밭 높이는 미리 맞춘다
1144  const walker = lead ? (lookupSprite(lead.species, WALKER_SIZE) ?? null) : undefined
1145  const evolving = evolvingOf(scene, now) && scene.input.evolving
1146  const before = evolving ? (lookupSprite(evolving.from, WALKER_SIZE) ?? null) : null
1147  const elapsed = evolving ? now - evolving.at : 0
1148  return base64Of(drawDaycareGround(scene.input.daycare, scene.columns, nestFrame, walker, before, elapsed))
1149}
1150
1151// 진화 장면 아래 글 줄. 원작처럼 진화하는 동안과 끝난 뒤 글이 다르다
1152function evolveCaption(scene: { input: NestInput }, now: number): string | null {
1153  const evolving = evolvingOf(scene, now) && scene.input.evolving
1154  if (!evolving || !evolvingNames) {
1155    return null
1156  }
1157  const { from, to } = evolvingNames
1158  return evolvePhase(now - evolving.at) === 'done'
1159    ? `Congratulations! Your ${from} evolved into ${to}!`
1160    : `What? ${from} is evolving!`
1161}
1162
1163// 진화 전과 뒤 그림을 아직 못 받았으면 장면의 시작을 지금으로 미룬다. 처음 본 뒤 EVOLVE_WAIT_MS 가 지나면 그만 미룬다
1164function holdEvolution(scene: { input: NestInput }, now: number) {
1165  const evolving = scene.input.evolving
1166  const entry = evolvingOf(scene, now)
1167  if (!evolving || !entry || now - evolvingFirstAt >= EVOLVE_WAIT_MS) {
1168    return
1169  }
1170  if (!lookupSprite(evolving.from, WALKER_SIZE) || !lookupSprite(entry.species, WALKER_SIZE)) {
1171    evolving.at = now
1172    evolvingSeenAt = now
1173  }
1174}
1175
1176function startNest($: EngineInterface) {
1177  nestDenied = 0
1178  nestTimer ??= $.clock.every(NEST_TICK_MS, () => void tickNest($))
1179}
1180
1181function stopNest() {
1182  nestScene = null
1183  nestTimer?.cancel()
1184  nestTimer = null
1185}
1186
1187// 박스 페이지를 앞뒤로 넘긴다. 끝에서 넘기면 처음으로 돌아간다
1188function turnBox($: EngineInterface, delta: number) {
1189  if (!nestScene) {
1190    return
1191  }
1192  const pages = boxPages(nestScene.input)
1193  boxPage = (boxPageOf(nestScene.input) + delta + pages) % pages
1194  $.ui.invalidate('ui.render')
1195}
1196
1197async function tickNest($: EngineInterface) {
1198  if (!nestScene) {
1199    stopNest()
1200    return
hooks/inbox-lib.ts 243 lines
1import type { InboxCache, InboxItem } from '../types'
2
3// 이만큼 지나지 않은 조회 결과는 어느 세션이든 파일에서 그대로 쓴다
4export const INBOX_TTL_MS = 15 * 60 * 1000
5// 패널을 열거나 새로고침을 눌러도 이보다 짧은 간격으로는 다시 부르지 않는다
6export const FORCE_MIN_MS = 30 * 1000
7// 조회를 맡은 세션이 이 안에 끝내지 못하면(꺼졌거나 멈췄으면) 다른 세션이 넘겨받는다
8export const LEASE_MS = 2 * 60 * 1000
9
10export const emptyCache = (): InboxCache => ({
11  fetchedAt: null,
12  okAt: null,
13  notifications: [],
14  seenIds: [],
15  lease: null,
16})
17
18// 공유 캐시 파일이 깨졌거나 예전 모양이어도 빈 값으로 읽는다
19export const parseCache = (text: string): InboxCache => {
20  try {
21    const raw = JSON.parse(text) as Partial<InboxCache> | null
22    if (!raw || typeof raw !== 'object') {
23      return emptyCache()
24    }
25    return {
26      fetchedAt: typeof raw.fetchedAt === 'number' ? raw.fetchedAt : null,
27      okAt: typeof raw.okAt === 'number' ? raw.okAt : null,
28      notifications: Array.isArray(raw.notifications) ? raw.notifications : [],
29      seenIds: Array.isArray(raw.seenIds) ? raw.seenIds.filter(id => typeof id === 'string') : [],
30      lease:
31        raw.lease && typeof raw.lease.by === 'string' && typeof raw.lease.at === 'number'
32          ? { by: raw.lease.by, at: raw.lease.at }
33          : null,
34    }
35  } catch {
36    return emptyCache()
37  }
38}
39
40// https://linear.app/<workspace>/issue/ABC-615/... 에서 이슈 번호를 뽑는다
41export const issueFromUrl = (url: string | null): string | null =>
42  url?.match(/\/issue\/([A-Za-z]+-\d+)/)?.[1]?.toUpperCase() ?? null
43
44type RawNotification = {
45  id?: unknown
46  type?: unknown
47  category?: unknown
48  title?: unknown
49  subtitle?: unknown
50  url?: unknown
51  createdAt?: unknown
52  archivedAt?: unknown
53  snoozedUntilAt?: unknown
54}
55
56const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
57
58// 패널에 한 줄로 보이게 비어 있지 않은 첫 줄만 남긴다
59const oneLine = (value: unknown): string | null =>
60  str(value)
61    ?.split('\n')
62    .map(line => line.trim())
63    .find(Boolean) ?? null
64
65// get_notifications 의 text 블록을 읽는다. 모양이 다르면 null
66export const parseNotifications = (text: string, now: number): InboxItem[] | null => {
67  let parsed: { notifications?: unknown }
68  try {
69    parsed = JSON.parse(text) as { notifications?: unknown }
70  } catch {
71    return null
72  }
73  if (!parsed || !Array.isArray(parsed.notifications)) {
74    return null
75  }
76  return (parsed.notifications as RawNotification[]).flatMap(raw => {
77    const id = str(raw.id)
78    if (!id || raw.archivedAt) {
79      return []
80    }
81    // 미뤄 둔 알림은 그 시각이 지나야 보인다
82    const snoozedUntil = str(raw.snoozedUntilAt)
83    if (snoozedUntil && Date.parse(snoozedUntil) > now) {
84      return []
85    }
86    const url = str(raw.url)
87    const createdAt = Date.parse(str(raw.createdAt) ?? '')
88    return [
89      {
90        id,
91        type: str(raw.type) ?? 'unknown',
92        category: str(raw.category),
93        title: oneLine(raw.title) ?? '',
94        subtitle: oneLine(raw.subtitle),
95        url,
96        issue: issueFromUrl(url),
97        createdAt: Number.isNaN(createdAt) ? 0 : createdAt,
98      },
99    ]
100  })
101}
102
103// 사람이 답해야 할 것이 위로 오게 둔 순서. 여기 없는 카테고리는 그 아래에 둔다
104const CATEGORY_ORDER = [
105  'mentions',
106  'commentsAndReplies',
107  'assignments',
108  'reviews',
109  'statusChanges',
110  'reminders',
111  'triage',
112  'subscriptions',
113  'reactions',
114  'documentChanges',
115]
116
117const CATEGORY_LABEL: Record<string, string> = {
118  mentions: 'mention',
119  commentsAndReplies: 'comment',
120  assignments: 'assigned',
121  reviews: 'review',
122  statusChanges: 'status',
123  reminders: 'reminder',
124  triage: 'triage',
125  subscriptions: 'subscribed',
126  reactions: 'reaction',
127  documentChanges: 'document',
128}
129
130// category 가 비어 오면 type 이름으로 짐작한다
131export const categoryOf = (item: Pick<InboxItem, 'type' | 'category'>): string => {
132  if (item.category) {
133    return item.category
134  }
135  const type = item.type
136  if (/Mention/i.test(type)) {
137    return 'mentions'
138  }
139  if (/Comment|Reply/i.test(type)) {
140    return 'commentsAndReplies'
141  }
142  if (/Assigned/i.test(type)) {
143    return 'assignments'
144  }
145  if (/Status|Blocked|Unblocked/i.test(type)) {
146    return 'statusChanges'
147  }
148  if (/Reaction/i.test(type)) {
149    return 'reactions'
150  }
151  if (/Document|Description/i.test(type)) {
152    return 'documentChanges'
153  }
154  return type
155}
156
157// 나를 태그했거나 내 댓글에 답한 알림만 남긴다. GitLab 연동이 바꾼 상태나 배정 같은 알림은 보이지 않는다
158const FOR_ME = new Set(['mentions', 'commentsAndReplies'])
159
160export const onlyForMe = (items: readonly InboxItem[]): InboxItem[] =>
161  items.filter(item => FOR_ME.has(categoryOf(item)))
162
163// 모르는 카테고리는 이름 그대로 보인다
164export const categoryLabel = (item: Pick<InboxItem, 'type' | 'category'>): string => {
165  const category = categoryOf(item)
166  return CATEGORY_LABEL[category] ?? category
167}
168
169const rank = (item: InboxItem): number => {
170  const index = CATEGORY_ORDER.indexOf(categoryOf(item))
171  return index === -1 ? CATEGORY_ORDER.length : index
172}
173
174// 카테고리 순서대로, 같은 카테고리 안에서는 최신을 위로
175export const sortInbox = (items: readonly InboxItem[]): InboxItem[] =>
176  [...items].sort((a, b) => rank(a) - rank(b) || b.createdAt - a.createdAt)
177
178// 지금 조회해야 하는지. 다른 세션이 조회 중이면 그 결과를 기다린다. 이슈 조회도 같은 규칙을 주기만 바꿔 쓴다
179export const shouldFetch = (
180  cache: Pick<InboxCache, 'fetchedAt' | 'lease'>,
181  now: number,
182  me: string,
183  isForced: boolean,
184  ttlMs: number = INBOX_TTL_MS,
185): boolean => {
186  if (cache.lease && cache.lease.by !== me && now - cache.lease.at < LEASE_MS) {
187    return false
188  }
189  const age = cache.fetchedAt === null ? Infinity : now - cache.fetchedAt
190  return age >= (isForced ? FORCE_MIN_MS : ttlMs)
191}
192
193// 조회 결과를 캐시에 반영하고, 토스트할 새 알림을 돌려준다. fetched 가 null 이면 조회 실패
194export const applyFetch = (
195  prev: InboxCache,
196  fetched: InboxItem[] | null,
197  now: number,
198): { cache: InboxCache; fresh: InboxItem[] } => {
199  if (fetched === null) {
200    // 실패해도 fetchedAt 을 남겨 모든 세션이 15분 동안 다시 부르지 않게 한다. 본 목록은 그대로 둔다
201    return { cache: { ...prev, fetchedAt: now, notifications: [], lease: null }, fresh: [] }
202  }
203  const seen = new Set(prev.seenIds)
204  // 처음 성공한 조회에서는 이미 쌓여 있던 알림을 새 알림으로 치지 않는다
205  const fresh = prev.okAt === null ? [] : fetched.filter(item => !seen.has(item.id))
206  return {
207    cache: {
208      fetchedAt: now,
209      okAt: now,
210      notifications: fetched,
211      // 지금 목록에 있는 것만 남겨 파일이 끝없이 자라지 않게 한다
212      seenIds: fetched.map(item => item.id),
213      lease: null,
214    },
215    fresh,
216  }
217}
218
219// 알림 줄 끝에 붙는 시각. 제목 옆 좁은 칸에서 꺾이지 않게 3h 처럼 짧게 쓴다
220export const formatAgoShort = (ms: number): string => {
221  const minutes = Math.floor(ms / 60_000)
222  if (minutes < 1) {
223    return 'now'
224  }
225  if (minutes < 60) {
226    return `${minutes}m`
227  }
228  const hours = Math.floor(minutes / 60)
229  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
230}
231
232const TOAST_TITLE_MAX = 40
233
234export const toastText = (fresh: readonly InboxItem[]): string => {
235  const [top] = sortInbox(fresh)
236  if (!top) {
237    return ''
238  }
239  const title = top.title.length > TOAST_TITLE_MAX ? `${top.title.slice(0, TOAST_TITLE_MAX)}…` : top.title
240  const head = `Linear ${top.issue ? `${top.issue} ` : ''}${categoryLabel(top)}: ${title}`
241  return fresh.length > 1 ? `${head} and ${fresh.length - 1} more` : head
242}
243
hooks/issues-lib.ts 219 lines
1import type { IssuesCache, Mr, Row, TreeSession, Tree } from '../types'
2
3// 이 시간 안에 heartbeat 가 없으면 세션이 닫힌 것으로 본다
4export const LIVE_MS = 3 * 60 * 1000
5
6// feat/#ABC-611-xxx, bugfix/#RF-92-xxx, feat/#867-xxx, chore/ABC-523-xxx 에서 이슈 번호를 뽑는다
7// # 이 없으면 대문자 키만 이슈로 본다. feat/add-login-2 같은 브랜치를 이슈로 잡지 않게 한다
8export const parseIssue = (branch: string): string | null =>
9  (branch.match(/#([A-Za-z]+-\d+|\d+)/)?.[1] ?? branch.match(/(?:^|[/_])([A-Z][A-Z0-9]*-\d+)(?![A-Za-z0-9])/)?.[1])?.toUpperCase() ??
10  null
11
12// origin remote 주소로 GitLab API 의 프로젝트 주소를 만든다. override 는 API 호스트가 remote 와 다를 때(ssh remote 등) 쓴다
13// GitHub 은 MR 이 없으므로 null
14export const gitlabProjectApi = (remote: string, override = ''): string | null => {
15  const url = remote.trim()
16  const parsed =
17    url.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?\/?$/) ??
18    url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) ??
19    url.match(/^(?:[^@/]+@)?([^/:]+):(.+?)(?:\.git)?\/?$/)
20  if (!parsed) {
21    return null
22  }
23  const [, host = '', path = ''] = parsed
24  if (/(^|\.)github\.com(:\d+)?$/.test(host)) {
25    return null
26  }
27  const isHttp = /^https?:/.test(url)
28  const base = override.trim().replace(/\/+$/, '') || `${isHttp ? url.slice(0, url.indexOf(':')) : 'https'}://${host}`
29  return `${base}/api/v4/projects/${encodeURIComponent(path)}`
30}
31
32// remote 가 GitHub 저장소인지. GitHub 는 API 대신 gh CLI 로 PR 을 찾는다
33export const isGithubRemote = (remote: string): boolean =>
34  /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)github\.com[:/]/.test(remote.trim())
35
36// `gh pr list --json number,state,headRefOid,url,mergeable` 출력의 첫 PR 을 GitLab MR 과 같은 모양으로 바꾼다
37export const parseGithubPr = (stdout: string): Mr | null => {
38  try {
39    const [pr] = JSON.parse(stdout) as Array<{ number: number; state: string; headRefOid: string; url: string; mergeable?: string }>
40    if (!pr) {
41      return null
42    }
43    const state = pr.state === 'OPEN' ? 'opened' : pr.state === 'MERGED' ? 'merged' : 'closed'
44    const action = state !== 'opened' ? null : pr.mergeable === 'CONFLICTING' ? 'conflict' : pr.mergeable === 'MERGEABLE' ? 'mergeable' : null
45    return { iid: pr.number, state, sha: pr.headRefOid, url: pr.url, action }
46  } catch {
47    return null
48  }
49}
50
51// `git symbolic-ref refs/remotes/origin/HEAD` 출력에서 기본 브랜치 이름을 뽑는다
52export const parseDefaultBranch = (symbolicRef: string): string | null =>
53  symbolicRef.trim().match(/^refs\/remotes\/origin\/(.+)$/)?.[1] ?? null
54
55// 세션 줄에 MR 을 찾지 않는 브랜치. 기본 브랜치와 배포 브랜치는 MR 의 소스가 아니다
56export const isLongLivedBranch = (branch: string, base: string): boolean =>
57  branch === base || ['main', 'master', 'develop'].includes(branch) || branch.startsWith('release/')
58
59// 이슈 조회 결과를 저장소마다 따로 둘 파일 이름
60export const issuesFileOf = (root: string): string => `mod-issues-${root.replace(/[^a-zA-Z0-9]/g, '-')}.json`
61
62// `git worktree list --porcelain` 출력을 worktree 목록으로 바꾼다. 첫 블록이 메인 worktree 다
63export const parseWorktrees = (porcelain: string): Tree[] =>
64  porcelain
65    .split('\n\n')
66    .map(block => block.trim())
67    .filter(Boolean)
68    .map((block, index) => {
69      const lines = block.split('\n')
70      const field = (name: string) =>
71        lines.find(line => line.startsWith(`${name} `))?.slice(name.length + 1) ?? null
72
73      return {
74        path: field('worktree') ?? '',
75        head: field('HEAD') ?? '',
76        branch: field('branch')?.replace(/^refs\/heads\//, '') ?? null,
77        isMain: index === 0,
78      }
79    })
80    .filter(tree => tree.path !== '')
81
82export const isLive = (session: TreeSession, now: number): boolean =>
83  session.state !== 'ended' && now - session.updatedAt < LIVE_MS
84
85// 메인 worktree 경로가 다른 worktree 경로의 접두어이므로 가장 긴 경로에 붙인다
86export const findTreeFor = (trees: Tree[], cwd: string): Tree | null =>
87  trees
88    .filter(tree => cwd === tree.path || cwd.startsWith(`${tree.path}/`))
89    .sort((a, b) => b.path.length - a.path.length)[0] ?? null
90
91export const assignSessions = (
92  trees: Tree[],
93  sessions: TreeSession[],
94  now: number,
95): Map<string, TreeSession> => {
96  const byTree = new Map<string, TreeSession>()
97  for (const session of sessions) {
98    if (!isLive(session, now)) {
99      continue
100    }
101    const tree = findTreeFor(trees, session.cwd)
102    const prev = tree ? byTree.get(tree.path) : undefined
103    if (tree && (!prev || prev.updatedAt < session.updatedAt)) {
104      byTree.set(tree.path, session)
105    }
106  }
107  return byTree
108}
109
110// 머지된 worktree 에서 이만큼 넘게 입력을 기다린 세션은 정리를 막지 않는다
111export const IDLE_CLEANUP_MS = 24 * 60 * 60 * 1000
112
113export const isIdle = (session: TreeSession, now: number): boolean =>
114  session.state === 'waiting' && session.waitingSince !== undefined && now - session.waitingSince >= IDLE_CLEANUP_MS
115
116// 머지된 MR 의 worktree 를 지워도 되는지 본다. 못 지우면 그 이유를 돌려준다
117// sessions 는 모든 세션의 상태 파일. 한 worktree 에 세션이 여럿이면 모두 오래 대기 중이어야 지운다
118export const cleanupBlocker = (row: Row, sessions: readonly TreeSession[], ownCwd: string, now: number): string | null => {
119  if (row.isMain || row.mr?.state !== 'merged') {
120    return 'not-merged'
121  }
122  const here = sessions.filter(session => isLive(session, now) && findTreeFor([row], session.cwd))
123  if (here.some(session => !isIdle(session, now))) {
124    return 'a session is open here'
125  }
126  // 이 세션의 상태 파일이 없어 위에서 못 걸렀을 때
127  if (here.length === 0 && findTreeFor([row], ownCwd)) {
128    return 'this session runs here'
129  }
130  if (row.dirty > 0) {
131    return `${row.dirty} uncommitted changes`
132  }
133  if (row.head !== row.mr.sha) {
134    return 'has commits not in the MR'
135  }
136  return null
137}
138
139type RawMr = { state: string; detailed_merge_status?: string; has_conflicts?: boolean }
140
141// 열린 MR 중 내가 움직여야 하는 것만 고른다. 충돌은 rebase 를, 머지 가능은 머지 버튼을 누를 차례다
142export const mrAction = (raw: RawMr): Mr['action'] => {
143  if (raw.state !== 'opened') {
144    return null
145  }
146  if (raw.has_conflicts || raw.detailed_merge_status === 'conflict' || raw.detailed_merge_status === 'need_rebase') {
147    return 'conflict'
148  }
149  return raw.detailed_merge_status === 'mergeable' ? 'mergeable' : null
150}
151
152const SCRIPTS = ['build', 'build-lib', 'typecheck', 'test', 'test:e2e', 'lint']
153// 패키지 스크립트가 아니라 도구 이름 그대로 남기는 검사
154const TOOLS = ['vitest', 'jest', 'pytest', 'ruff', 'mypy', 'pyright', 'tsc']
155
156// 상태로 남길 검사를 찾는다. pnpm·npm·yarn 스크립트나 테스트·린트 도구. 없으면 null
157export const findCheck = (command: string): string | null => {
158  const script = command.match(/\b(?:pnpm|npm|yarn)\s+(?:run\s+)?([\w:-]+)/)?.[1]
159  if (script && SCRIPTS.includes(script)) {
160    return script
161  }
162  // 명령을 ;·&&·| 로 나눈 각 조각의 첫 단어를 본다. pnpm exec·uv run·python -m 같은 실행기는 벗긴다
163  const runner = /^(?:(?:pnpm|npx|uv|poetry|pipenv|hatch)\s+(?:exec|run)\s+|npx\s+|python3?\s+-m\s+)/
164  for (const part of command.split(/[;&|]+/)) {
165    const word = part.trim().replace(/^cd\s+\S+\s*/, '').replace(runner, '').split(/\s+/)[0] ?? ''
166    if (TOOLS.includes(word)) {
167      return word
168    }
169  }
170  return null
171}
172
173export const formatAgo = (ms: number): string => {
174  const minutes = Math.floor(ms / 60_000)
175  if (minutes < 1) {
176    return 'just now'
177  }
178  if (minutes < 60) {
179    return `${minutes}m ago`
180  }
181  const hours = Math.floor(minutes / 60)
182  return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
183}
184
185// .env.local 에서 GITLAB_TOKEN 값만 꺼낸다
186export const readEnvValue = (text: string, name: string): string | null => {
187  const line = text.split('\n').find(one => one.trim().startsWith(`${name}=`))
188  const value = line?.slice(line.indexOf('=') + 1).trim().replace(/^['"]|['"]$/g, '')
189  return value ? value : null
190}
191
192// 이슈 조회 주기. 세션이 몇 개든 이 주기에 한 번만 git 과 GitLab 을 부른다
193export const ISSUES_TTL_MS = 2 * 60 * 1000
194
195export const emptyIssuesCache = (): IssuesCache => ({ fetchedAt: null, rows: [], branchMrs: {}, checked: [], log: [], lease: null })
196
197// 깨졌거나 예전 모양인 파일은 빈 값으로 읽는다
198export const parseIssuesCache = (text: string): IssuesCache => {
199  try {
200    const raw = JSON.parse(text) as Partial<IssuesCache> | null
201    if (!raw || typeof raw !== 'object') {
202      return emptyIssuesCache()
203    }
204    return {
205      fetchedAt: typeof raw.fetchedAt === 'number' ? raw.fetchedAt : null,
206      rows: Array.isArray(raw.rows) ? raw.rows : [],
207      branchMrs: raw.branchMrs && typeof raw.branchMrs === 'object' && !Array.isArray(raw.branchMrs) ? raw.branchMrs : {},
208      checked: Array.isArray(raw.checked) ? raw.checked.filter(branch => typeof branch === 'string') : [],
209      log: Array.isArray(raw.log) ? raw.log.filter(line => typeof line === 'string') : [],
210      lease:
211        raw.lease && typeof raw.lease.by === 'string' && typeof raw.lease.at === 'number'
212          ? { by: raw.lease.by, at: raw.lease.at }
213          : null,
214    }
215  } catch {
216    return emptyIssuesCache()
217  }
218}
219
hooks/dex-data.ts 77 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// 진화로 처음 만들면 그 새가 오는 최종 진화형. 얼음은 프리저, 전기는 썬더, 불꽃은 파이어
25export const LEGEND_FINALS: Readonly<Record<144 | 145 | 146, readonly number[]>> = {
26  144: [87, 91],
27  145: [26, 82, 101, 135],
28  146: [6, 38, 59, 78, 136],
29}
30
31// 원작에서 귀한 포켓몬. 한 번만 받거나, 화석에서 되살리거나, 사파리존이나 게임코너에서만 만나거나, 드물게 나오는 종이다
32const RARE_SPECIES: ReadonlySet<number> = new Set([
33  83, 106, 107, 108, 113, 115, 122, 123, 124, 125, 126, 127, 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141,
34  142, 143, 147, 148, 149,
35])
36
37// PC BOX 줄의 이름 뒤에 붙는 표시. 전설은 ✦, 귀한 포켓몬은 ✧ 이다
38export function rarityOf(species: number): 'legend' | 'rare' | null {
39  if ((Object.values(LEGENDS) as number[]).includes(species)) {
40    return 'legend'
41  }
42  return RARE_SPECIES.has(species) ? 'rare' : null
43}
44
45// 도감이 이만큼 차면 뮤츠가 온다
46export const MEWTWO_DEX = 100
47
48export function canEvolve(species: number): boolean {
49  return EVOLUTIONS[species] !== undefined
50}
51
52// 종마다 속성. 도감 번호 순서다. 원작 골드 버전(2세대) 기준이라 코일은 강철이 붙고 삐삐는 페어리가 아닌 노말이다
53const TYPES: readonly string[] = [
54  'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'FIRE', 'FIRE', 'FIRE/FLYING', 'WATER', 'WATER', 'WATER', 'BUG',
55  'BUG', 'BUG/FLYING', 'BUG/POISON', 'BUG/POISON', 'BUG/POISON', 'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING',
56  'NORMAL', 'NORMAL', 'NORMAL/FLYING', 'NORMAL/FLYING', 'POISON', 'POISON', 'ELECTRIC', 'ELECTRIC', 'GROUND', 'GROUND',
57  'POISON', 'POISON', 'POISON/GROUND', 'POISON', 'POISON', 'POISON/GROUND', 'NORMAL', 'NORMAL', 'FIRE', 'FIRE',
58  'NORMAL', 'NORMAL', 'POISON/FLYING', 'POISON/FLYING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'BUG/GRASS',
59  'BUG/GRASS', 'BUG/POISON', 'BUG/POISON', 'GROUND', 'GROUND', 'NORMAL', 'NORMAL', 'WATER', 'WATER', 'FIGHTING',
60  'FIGHTING', 'FIRE', 'FIRE', 'WATER', 'WATER', 'WATER/FIGHTING', 'PSYCHIC', 'PSYCHIC', 'PSYCHIC', 'FIGHTING',
61  'FIGHTING', 'FIGHTING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'WATER/POISON', 'WATER/POISON', 'ROCK/GROUND',
62  'ROCK/GROUND', 'ROCK/GROUND', 'FIRE', 'FIRE', 'WATER/PSYCHIC', 'WATER/PSYCHIC', 'ELECTRIC/STEEL', 'ELECTRIC/STEEL',
63  'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING', 'WATER', 'WATER/ICE', 'POISON', 'POISON', 'WATER', 'WATER/ICE',
64  'GHOST/POISON', 'GHOST/POISON', 'GHOST/POISON', 'ROCK/GROUND', 'PSYCHIC', 'PSYCHIC', 'WATER', 'WATER', 'ELECTRIC',
65  'ELECTRIC', 'GRASS/PSYCHIC', 'GRASS/PSYCHIC', 'GROUND', 'GROUND', 'FIGHTING', 'FIGHTING', 'NORMAL', 'POISON',
66  'POISON', 'GROUND/ROCK', 'GROUND/ROCK', 'NORMAL', 'GRASS', 'NORMAL', 'WATER', 'WATER', 'WATER', 'WATER', 'WATER',
67  'WATER/PSYCHIC', 'PSYCHIC', 'BUG/FLYING', 'ICE/PSYCHIC', 'ELECTRIC', 'FIRE', 'BUG', 'NORMAL', 'WATER', 'WATER/FLYING',
68  'WATER/ICE', 'NORMAL', 'NORMAL', 'WATER', 'ELECTRIC', 'FIRE', 'NORMAL', 'ROCK/WATER', 'ROCK/WATER', 'ROCK/WATER',
69  'ROCK/WATER', 'ROCK/FLYING', 'NORMAL', 'ICE/FLYING', 'ELECTRIC/FLYING', 'FIRE/FLYING', 'DRAGON', 'DRAGON',
70  'DRAGON/FLYING', 'PSYCHIC', 'PSYCHIC',
71]
72
73// 1세대가 아닌 종은 속성을 모르는 것으로 빈 글자를 돌려준다
74export function typesOf(species: number): string {
75  return TYPES[species - 1] ?? ''
76}
77
hooks/evolve-lib.ts 216 lines
1// 박스 포켓몬의 진화. 세션이 일한 시간이 경험치가 되고, 앞장선 포켓몬 한 마리만 먹는다
2// 경험치가 차면 앞장선 포켓몬이 한 단계 진화하고, 진화로 정해진 최종 진화형을 처음 만들거나 도감을 채우면 전설이 박스에 온다
3// 기록을 쓰는 세션이 advancePokedex 바로 뒤에 부른다
4
5import type { BoxEntry, Pokedex, SessionStatus } from '../types'
6import { canEvolve, EVOLUTIONS, LEGEND_FINALS, LEGENDS, MEWTWO_DEX } from './dex-data'
7import { hash, hasEgg } from './nest-lib'
8import { livenessOf } from './sessions-lib'
9
10// 진화 한 번에 드는 일한 시간
11export const XP_PER_EVOLUTION_MS = 3 * 60 * 60 * 1000
12// 지난번 맞춘 뒤 이보다 오래 지났으면 잠자기나 모든 세션이 꺼진 시간으로 보고 세지 않는다
13export const XP_GAP_MS = 5 * 60 * 1000
14// 같은 종이 이만큼 모이면 자동으로 고를 때 그 종이 먼저 앞장선다
15const CROWD = 3
16// 키우미집에서 데려오기 요청. 맡길 포켓몬 key 대신 이 값을 보낸다
17export const TAKE_BACK = '-'
18// 야생 전설이 기다리는 시간. 지나면 달아난다
19export const ENCOUNTER_MS = 60 * 60 * 1000
20// 앞의 전설 장면이 끝나길 기다리는 시간. 차례를 기다리던 전설은 이만큼 지난 뒤 나타난다
21export const SCENE_GAP_MS = 10 * 1000
22// 야생 전설에게 하는 일. throw 는 몬스터볼 던지기, run 은 무시하기, dismiss 는 달아난 안내 닫기
23export type EncounterAction = 'throw' | 'run' | 'dismiss'
24const LEGEND_SPECIES: readonly number[] = Object.values(LEGENDS)
25
26// 진화하면 될 종. 이브이처럼 갈라지면 칸 key 로 골라 다시 계산해도 같다
27export function evolutionOf(entry: BoxEntry): number | null {
28  const next = EVOLUTIONS[entry.species]
29  if (!next) {
30    return null
31  }
32  return next[hash(`evolve:${entry.key}`) % next.length]!
33}
34
35const firstCaught = (entries: readonly BoxEntry[]): BoxEntry | undefined =>
36  entries.reduce<BoxEntry | undefined>((best, one) => (!best || one.at < best.at ? one : best), undefined)
37
38// 아무도 고르지 않았을 때 앞장설 포켓몬. 같은 종이 3마리 이상이면 그 종이 먼저고, 그중 가장 먼저 잡은 포켓몬이다
39export function autoLead(box: readonly BoxEntry[]): string | null {
40  const able = box.filter(one => canEvolve(one.species))
41  const counts = new Map<number, number>()
42  for (const one of able) {
43    counts.set(one.species, (counts.get(one.species) ?? 0) + 1)
44  }
45  const crowded = able.filter(one => (counts.get(one.species) ?? 0) >= CROWD)
46  return firstCaught(crowded.length > 0 ? crowded : able)?.key ?? null
47}
48
49// pick 은 사용자가 Leave at DAY CARE 를 누른 박스 key, 또는 Take back 을 누른 TAKE_BACK
50// evolved 는 이번에 진화한 칸과 진화 전 종, legends 는 이번에 박스에 들어온 전설
51// action 은 야생 전설에게 누른 버튼
52// legends 는 이번에 잡은 전설, fled 는 이번에 달아난 전설
53export function advanceEvolution(
54  dex: Pokedex,
55  sessions: readonly SessionStatus[],
56  now: number,
57  pick: string | null = null,
58  action: EncounterAction | null = null,
59) {
60  let box = [...dex.box]
61  const seen = new Set([...dex.seen, ...box.map(one => one.species)])
62  const legends = new Set([...dex.legends, ...box.map(one => one.species).filter(species => LEGEND_SPECIES.includes(species))])
63  const evolved: Array<{ entry: BoxEntry; from: number }> = []
64  const arrived: BoxEntry[] = []
65
66  const able = (key: string | null) => box.find(one => one.key === key && canEvolve(one.species))
67  let lead = dex.lead
68  let chosen = dex.chosen
69  if (pick === TAKE_BACK) {
70    lead = null
71    chosen = false
72  } else if (pick !== null && box.some(one => one.key === pick)) {
73    // 진화하지 않는 포켓몬(메타몽 등)과 최종 진화형도 맡길 수 있다. 맡긴 동안은 MAX 라 경험치가 쌓이지 않는다
74    lead = pick
75    chosen = true
76  } else if (!box.some(one => one.key === lead)) {
77    // 맡긴 포켓몬은 최종 진화형이 되어도 데려올 때까지 남는다. 박스에서 사라졌을 때만 다시 고른다
78    lead = null
79    chosen = false
80  }
81  lead ??= autoLead(box)
82
83  // 최종 진화형이 맡겨져 있으면 더 진화할 수 없어 경험치를 쌓지 않는다
84  let xp = dex.xp
85  const isMaxed = lead !== null && !able(lead)
86  if (!isMaxed && dex.xpAt > 0 && now - dex.xpAt <= XP_GAP_MS) {
87    // 권한 창이나 질문으로 나를 기다리는 세션은 상태가 working 이어도 일하지 않는 것으로 친다
88    const working = sessions.filter(session => hasEgg(session) && livenessOf(session, now) === 'working' && !session.attention).length
89    // 기록을 쓰는 세션이 바뀌며 시계가 조금 어긋나도 경험치가 줄지 않게 한다
90    xp += Math.max(0, now - dex.xpAt) * working
91  }
92
93  let evolving = dex.evolving
94  const leader = able(lead)
95  if (leader && xp >= XP_PER_EVOLUTION_MS) {
96    const species = evolutionOf(leader)!
97    const grown = { ...leader, species }
98    box = box.map(one => (one.key === leader.key ? grown : one))
99    seen.add(species)
100    evolved.push({ entry: grown, from: leader.species })
101    evolving = { key: leader.key, from: leader.species, at: now }
102    xp = 0
103  }
104
105  let encounter = dex.encounter
106  let scene = dex.scene
107  let fled = dex.fled
108  const escaped = new Set(dex.escaped)
109  const waiting = [...dex.waiting]
110  const gone: number[] = []
111  const runAway = () => {
112    if (encounter) {
113      fled = { species: encounter.species }
114      escaped.add(encounter.species)
115      gone.push(encounter.species)
116      encounter = null
117    }
118  }
119  if (action === 'throw' && encounter) {
120    const entry = { key: `legend:${encounter.species}`, species: encounter.species, root: encounter.root, branch: encounter.branch, label: encounter.label, at: now }
121    box = [entry, ...box]
122    legends.add(entry.species)
123    seen.add(entry.species)
124    escaped.delete(entry.species)
125    arrived.push(entry)
126    scene = { kind: 'catch', species: entry.species, at: now }
127    encounter = null
128  } else if (action === 'run' || (encounter && now - encounter.at >= ENCOUNTER_MS)) {
129    runAway()
130  }
131  if (action === 'dismiss') {
132    fled = null
133  }
134
135  // 전설은 박스에 바로 오지 않고 야생으로 나타난다. 잡은 전설은 다시 나타나지 않는다
136  // 다른 전설이 나타나 있으면 차례를 기다린다. 저장소와 라벨은 진화한 칸, 없으면 박스 맨 앞 칸의 것을 쓴다
137  const meet = (species: number) => {
138    const from = evolved[0]?.entry ?? box[0]
139    if (legends.has(species) || encounter?.species === species || !from) {
140      return
141    }
142    // 다른 전설이 나타나 있거나 전설 장면이 아직 끝나지 않았으면 차례를 기다린다
143    if (encounter || (scene && now - scene.at < SCENE_GAP_MS)) {
144      if (!waiting.includes(species)) {
145        waiting.push(species)
146      }
147      return
148    }
149    encounter = { species, at: now, root: from.root, branch: from.branch, label: from.label }
150    scene = { kind: 'appear', species, at: now }
151    escaped.delete(species)
152    if (waiting.includes(species)) {
153      waiting.splice(waiting.indexOf(species), 1)
154    }
155  }
156  for (const [legend, finals] of Object.entries(LEGEND_FINALS)) {
157    if (evolved.some(one => finals.includes(one.entry.species))) {
158      meet(Number(legend))
159    }
160  }
161  // 도감 조건은 한 번 채우면 계속 채워져 있어, 달아난 뒤에는 새 포켓몬이 박스에 들어올 때만 다시 나타난다
162  // 박스에 막 들어온 칸은 들어온 시각이 지금이다(advancePokedex 가 같은 now 로 넣는다). 진화한 칸과 전설 칸은 시각이 그대로거나 전설이다
163  const newcomer = box.some(one => one.at === now && !one.key.startsWith('legend:'))
164  const dexReady = (species: number) => !escaped.has(species) || newcomer
165  if (seen.size >= MEWTWO_DEX && dexReady(LEGENDS.mewtwo)) {
166    meet(LEGENDS.mewtwo)
167  }
168  if (Array.from({ length: LEGENDS.mew - 1 }, (_, i) => i + 1).every(species => seen.has(species)) && dexReady(LEGENDS.mew)) {
169    meet(LEGENDS.mew)
170  }
171
172  // 차례를 기다리던 전설은 앞의 전설이 끝나고 그 장면도 끝난 뒤 나타난다
173  if (!encounter && waiting.length > 0 && (!scene || now - scene.at >= SCENE_GAP_MS)) {
174    meet(waiting.shift()!)
175  }
176  // 차례를 기다리는 동안 잡은 전설은 빼 둔다
177  for (const species of [...waiting]) {
178    if (legends.has(species)) {
179      waiting.splice(waiting.indexOf(species), 1)
180    }
181  }
182
183  const next: Pokedex = {
184    ...dex,
185    box,
186    xp,
187    xpAt: now,
188    lead,
189    chosen,
190    seen: [...seen],
191    legends: [...legends],
192    evolving,
193    encounter,
194    scene,
195    fled,
196    escaped: [...escaped],
197    waiting,
198  }
199  return { dex: next, evolved, legends: arrived, fled: gone }
200}
201
202const BAR = 5
203
204// PC BOX 머리줄의 경험치 막대
205export function xpBar(xp: number): string {
206  const filled = Math.max(0, Math.min(BAR, Math.floor((xp / XP_PER_EVOLUTION_MS) * BAR)))
207  return '▓'.repeat(filled) + '░'.repeat(BAR - filled)
208}
209
210// 진화까지 남은 일한 시간. 분은 올림한다
211export function xpLeft(xp: number): string {
212  const minutes = Math.max(0, Math.ceil((XP_PER_EVOLUTION_MS - xp) / 60_000))
213  const hours = Math.floor(minutes / 60)
214  return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
215}
216
hooks/help-lib.ts 44 lines
1// /mod-hud --help 가 보이는 설정 안내. README 는 짧게 두고 상세한 설정은 여기서 알려 준다
2
3export function isHelp(args: string): boolean {
4  return ['--help', '-h', 'help'].includes(args.trim())
5}
6
7export function helpText(): string {
8  return [
9    'mod-hud: see which session is waiting on you, and raise a Pokémon egg in every session.',
10    '',
11    'Commands',
12    '  /mod-hud              open or close the panel',
13    '  /mod-hud dashboard    take this session out of the Pokémon eggs (run again to put it back)',
14    '  /mod-hud --help       show this help',
15    '',
16    'GitLab MR status (Worktree, MR sections, eggs cracking and hatching)',
17    '  Needs a token with read_api scope, found in this order:',
18    '    1. the GITLAB_TOKEN environment variable',
19    '       e.g. ~/.claude/settings.json: { "env": { "GITLAB_TOKEN": "<token>" } }',
20    '    2. GITLAB_TOKEN=... in .env.local at the repository root',
21    '',
22    'Linear Inbox',
23    '  Shows up when the Linear connector is enabled on claude.ai.',
24    '',
25    'Options (/config > mod-hud, or claude plugin install mod-hud@hyoyyoi --config key=value)',
26    '  gitlabUrl     GitLab API address. Empty: worked out from the origin remote.',
27    '                Set it only when the remote is ssh and the web port differs.',
28    '  baseBranch    Branch commits are counted against. Empty: origin/HEAD, then main.',
29    '  autoCleanup   false by default. When true, removes a worktree and its local branch',
30    '                once its MR is merged, nothing is left uncommitted, local HEAD equals',
31    '                the MR head, and no session is open there. Each removal shows a toast;',
32    '                a kept worktree shows why as "merged, cleanup on hold: ...".',
33    '                With --plugin-dir, set options in ~/.claude/settings.json:',
34    '                { "pluginConfigs": { "mod-hud": { "options": { "autoCleanup": true } } } }',
35    '  pokemonLanguage',
36    '                Language of Pokémon names: en (default) or ko.',
37    '',
38    'Files',
39    '  ~/.claude/mod-sessions/          one status file per session (removed after a day)',
40    '  ~/.claude/mod-hud-pokedex.json   eggs, DAY CARE and PC BOX, shared by every session',
41    '  ~/.claude/mod-hud-sprites/       Pokémon icons and names cached from PokeAPI',
42  ].join('\n')
43}
44
hooks/nest-lib.ts 1085 lines
1// 패널 아래 빈 자리에 그리는 포켓몬 알, 키우미집, PC 박스
2// 키우미집에는 매일 알 7개가 들어오고, 알 없는 세션은 진행 중이 되면 하나를 받는다
3// 세션이 일하면 알이 흔들리고, 그 브랜치의 MR 이 열리면 금이 가고, 머지되면 포켓몬이 나온다
4// 세션이 닫히면 부화한 포켓몬은 박스로, MR 없이 닫힌 알은 키우미집으로 돌아간다. 금 간 알은 머지될 때까지 남아 기다린다
5// 기록은 모든 저장소와 세션이 같이 쓰는 파일에 남는다
6// 포켓몬은 1세대 도감 번호로만 정해 두고, 그림은 MR 이 열리거나 박스를 열 때 불러온 스프라이트로 그린다
7
8import type { BoxEntry, DexEgg, HeldEgg, NestEgg, Pokedex, SessionStatus } from '../types'
9import { EGG_SPECIES } from './dex-data'
10import { isLongLivedBranch, parseIssue } from './issues-lib'
11import type { Pixels } from './png-lib'
12import { CLEAR } from './png-lib'
13import { isCodeReviewSession, KEEP_MS, livenessOf } from './sessions-lib'
14
15export type Egg = NestEgg
16export type Stage = NestEgg['stage']
17
18// boxPage 는 0부터 센 박스 페이지. 넘치면 마지막 페이지로 당긴다
19// lead 는 키우미집에 맡긴 박스 포켓몬의 key, evolving 은 가장 최근 진화. 키우미집 풀밭이 걷는 포켓몬과 진화 장면을 고를 때 쓴다
20export type NestInput = {
21  eggs: Egg[]
22  daycare: HeldEgg[]
23  box: BoxEntry[]
24  boxPage: number
25  lead?: string | null
26  evolving?: Pokedex['evolving']
27}
28
29// 불러온 스프라이트를 size 안에 들어가게 줄여 준다. 아직 없으면 undefined
30export type SpriteLookup = (species: number, size: number) => Pixels | undefined
31
32// 박스 아이콘 도트는 대부분 이보다 작아 줄이지 않고 그대로 그린다. 정수 배가 아니게 줄이면 도트가 뭉개져 더 줄이지 않는다
33export const HATCHED_SIZE = 24
34// 키우미집을 걷는 포켓몬은 이 안에 들어가게 줄인다. 큰 포켓몬(원본 32점 남짓)을 이보다 작게 줄이면 눈과 팔다리가 사라진다
35export const ICON_SIZE = 20
36// 막 진화한 포켓몬의 진화 장면. 원작처럼 진화 전 모습으로 잠깐 서 있다가(start), 하얗게 빛나며 진화 전과 뒤 모습을
37// 번갈아 보이고 점점 빨라진 뒤(morph), 화면이 번쩍이고(flash), 새 모습으로 선다(done). 값은 장면이 시작한 뒤 ms 다
38export const EVOLVE_START_MS = 2000
39export const EVOLVE_MORPH_MS = 9000
40export const EVOLVE_FLASH_MS = 9750
41export const EVOLVE_MS = 13000
42export type EvolvePhase = 'start' | 'morph' | 'flash' | 'done'
43
44export function evolvePhase(elapsed: number): EvolvePhase {
45  if (elapsed < EVOLVE_START_MS) {
46    return 'start'
47  }
48  if (elapsed < EVOLVE_MORPH_MS) {
49    return 'morph'
50  }
51  return elapsed < EVOLVE_FLASH_MS ? 'flash' : 'done'
52}
53
54// morph 동안 이 순간 진화 뒤 모습이 보이는지. 처음엔 1초에 한 번 바뀌고 끝에는 1초에 네 번 바뀐다
55export function isMorphNew(elapsed: number): boolean {
56  const t = Math.max(0, elapsed - EVOLVE_START_MS) / 1000
57  const span = (EVOLVE_MORPH_MS - EVOLVE_START_MS) / 1000
58  const flips = t + (3 / (2 * span)) * t * t
59  return Math.floor(flips) % 2 === 1
60}
61// 부화한 뒤 높이 뛰며 반짝이는 시간
62export const CELEBRATE_MS = 90 * 1000
63// 세션이 닫힌 뒤 부화한 알은 아무도 못 봤을 수 있어, 박스로 보내기를 누를 때까지 둥지에 남는다. 이만큼 지나면 저절로 박스로 간다
64export const UNSEEN_HATCH_MS = 24 * 60 * 60 * 1000
65// /clear 나 /resume 로 끝난 세션의 알을 이어 받을 세션이 나타나길 기다리는 시간
66export const HANDOFF_MS = 2 * 60 * 1000
67// 키우미집에 남겨 두는 알 수. 넘치면 오래된 알부터 내보낸다
68const DAYCARE_MAX = 30
69// 이미 정리한 세션 id 를 남겨 두는 수
70const DONE_MAX = 500
71// 알 줄 수. 넘치는 알은 보이지 않는다
72const EGG_ROWS_MAX = 2
73
74// 알 칸과 부화한 포켓몬 칸의 폭, 그림 줄 수. 알만 있는 줄은 낮게, 포켓몬이 있는 줄만 높게 그린다
75// 알 칸은 가장 크게 기운 알(16칸)이 딱 들어가는 폭이다. 좁은 패널에도 한 줄에 알이 많이 들어가게 한다
76const EGG_SLOT = 16
77const EGG_ROWS = 8
78const HATCHED_SLOT = 24
79const HATCHED_ROWS = 14
80// 박스 한 페이지의 줄 수. 원작 골드 버전의 PC BOX 처럼 한 줄에 한 마리씩 글자로 보이고 넘치면 페이지를 넘겨 본다
81export const BOX_PER_PAGE = 6
82
83const UPPER_HALF = 0x2580
84const LOWER_HALF = 0x2584
85const SPACE = 0x20
86
87const C = {
88  // 4세대 알 스프라이트의 색
89  eggLine: 0x3a3428,
90  eggShine: 0xffffff,
91  egg: 0xfff6de,
92  eggLight: 0xeee6bd,
93  eggShade: 0xd5cda4,
94  spot: 0x9ccd83,
95  spotLight: 0xcde6b4,
96  spotShade: 0x83b46a,
97  crack: 0x5a4030,
98  nest: 0x9a6a3a,
99  nestDark: 0x7a4f2a,
100  sparkle: 0xfff6a0,
101  sparkleHot: 0xffffff,
102  ballRed: 0xe8384f,
103  ballWhite: 0xf4f4f4,
104  ballLine: 0x2a2a2a,
105  labelEgg: 0x9a9a9a,
106  labelCracked: 0xf0b030,
107  labelHatched: 0x78c850,
108  // 패널 제목의 내 차례(red), 진행 중(yellow)과 맞춘 색
109  labelMine: 0xe8505b,
110  labelWorking: 0xf2d24b,
111  daycareBar: 0xc8782a,
112  daycareGround: 0x8cc860,
113  daycareGroundAlt: 0x7cb850,
114  boxBar: 0x3a6ea5,
115  boxBarText: 0xffffff,
116  boxCount: 0xffe080,
117} as const
118
119// 알 몸통 14x13. 4세대 알 스프라이트를 둥글게 줄여 다시 찍은 것. 외곽선, 위쪽 하이라이트, 오른쪽 아래 그림자
120const EGG_BODY = [
121  '.....kkkk.....',
122  '...kkweeekk...',
123  '..kewweeeehk..',
124  '.keewweeeeehk.',
125  '.keeeeeeeeehk.',
126  'keeeeeeeeeehhk',
127  'keeeeeeeeeehhk',
128  'keeeeeeeeehhhk',
129  'keeeeeeeehhhhk',
130  'keeeeeeehhhhhk',
131  '.kssssssssssk.',
132  '..kksssssskk..',
133  '....kkkkkk....',
134]
135// 연두 반점 무늬. 알마다 하나를 골라 몸통에 칠한다. (x, y) 는 몸통 폭과 키에 대한 비율, r 은 폭에 대한 비율인 원이다
136const SPOTS: ReadonlyArray<ReadonlyArray<readonly [number, number, number]>> = [
137  // 4세대 알의 무늬
138  [[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]],
139  // 점 셋
140  [[0.25, 0.4, 0.13], [0.7, 0.45, 0.13], [0.48, 0.78, 0.13]],
141  // 왼쪽에 크게
142  [[0.1, 0.55, 0.28], [0.75, 0.75, 0.08]],
143  // 사선으로 셋
144  [[0.25, 0.3, 0.1], [0.5, 0.55, 0.12], [0.78, 0.8, 0.14]],
145  // 오른쪽 위에 둘
146  [[0.7, 0.3, 0.16], [0.35, 0.72, 0.1]],
147  // 기본 알. 위 오른쪽, 왼쪽 큰 점, 오른쪽 아래, 아래
148  [[0.6, 0.27, 0.13], [0.2, 0.52, 0.17], [0.85, 0.6, 0.15], [0.42, 0.88, 0.15]],
149]
150const EGG_PALETTE = {
151  k: C.eggLine,
152  w: C.eggShine,
153  e: C.egg,
154  h: C.eggLight,
155  s: C.eggShade,
156  G: C.spot,
157  g: C.spotLight,
158  D: C.spotShade,
159  c: C.crack,
160}
161// 금이 간 알의 금 모양들. 알마다 하나를 골라 그린다
162const CRACKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
163  // 위에서 내려오다 아래에서 두 갈래로 갈라진다
164  [[5, 2], [6, 3], [7, 4], [7, 5], [6, 6], [8, 6], [5, 7], [9, 7], [6, 8]],
165  // 가운데 짧은 금
166  [[7, 3], [6, 4], [7, 5]],
167  // 지그재그로 내려오다 옆으로 한 칸 갈라진다
168  [[7, 2], [6, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
169  // 껍질 꼭대기가 깨져 두 갈래로 내려온다
170  [[7, 0], [7, 1], [6, 2], [8, 2], [6, 3], [7, 4]],
171  // 오른쪽 위에서 내려오다 옆으로 한 칸 갈라진다
172  [[8, 2], [7, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
173]
174// 키우미집에 쌓이는 작은 알
175const MINI_EGG = ['..kk..', '.kwwk.', 'keeeek', 'kGGeek', 'kGeeGk', '.kssk.', '..kk..']
176const MINI_SLOT = 7
177// 스프라이트를 아직 못 불러왔을 때 대신 그리는 몬스터볼
178const BALL = ['..rrrr..', '.rrrrrr.', 'rrrrrrrr', 'kkkwwkkk', 'wwwkkwww', 'wwwwwwww', '.wwwwww.', '..wwww..']
179const BALL_PALETTE = { r: C.ballRed, w: C.ballWhite, k: C.ballLine }
180// 일하는 세션의 알이 기우는 각도(도). 오뚝이처럼 크게 한 번, 작게 한 번 흔들리고 쉰다. 음수는 왼쪽이다
181const ROCK = [0, -15, -25, -15, 0, 15, 25, 15, 0, -10, 0, 10, 0, 0, 0, 0, 0, 0]
182// 기운 알을 담는 칸. 바닥 줄이 알의 바닥이고, 가장 크게 기울어도 잘리지 않는다
183const ROCK_WIDTH = 20
184const ROCK_HEIGHT = 15
185// 부화한 포켓몬이 통통 튀는 높이. 막 나왔을 때는 더 높이 뛴다
186const HOP = [0, 1, 2, 1, 0, 0, 0, 0]
187const HOP_HIGH = [0, 2, 4, 2, 0, 0]
188
189export function hash(text: string): number {
190  let n = 2166136261
191  for (let i = 0; i < text.length; i++) {
192    n = Math.imul(n ^ text.charCodeAt(i), 16777619)
193  }
194  return n >>> 0
195}
196
197// 알 key 마다 정해지는 도감 번호. 알에서는 진화 전 모습만 나온다
198export function speciesOf(key: string): number {
199  return EGG_SPECIES[hash(key) % EGG_SPECIES.length]!
200}
201
202// 키우미집에 매일 새로 채우는 알 수
203export const DAILY_EGGS = 7
204
205// 로컬 시간대의 날짜 YYYY-MM-DD. 이 날짜가 바뀌면 키우미집을 새로 채운다
206export function localDay(now: number): string {
207  const date = new Date(now)
208  const pad = (n: number) => String(n).padStart(2, '0')
209  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
210}
211
212// 그날 키우미집에 들어오는 알. 날짜와 순번으로 key 를 지어 종도 날마다 정해진다
213export function freshEggs(day: string): HeldEgg[] {
214  return Array.from({ length: DAILY_EGGS }, (_, i) => {
215    const key = `day:${day}:${i + 1}`
216    return { key, species: speciesOf(key) }
217  })
218}
219
220export const emptyPokedex = (): Pokedex => ({
221  day: '',
222  hatched: {},
223  pending: [],
224  daycare: [],
225  box: [],
226  holds: {},
227  done: [],
228  sent: {},
229  xp: 0,
230  xpAt: 0,
231  lead: null,
232  chosen: false,
233  seen: [],
234  legends: [],
235  evolving: null,
236  encounter: null,
237  scene: null,
238  fled: null,
239  escaped: [],
240  waiting: [],
241})
242
243export function parsePokedex(text: string): Pokedex {
244  try {
245    const parsed = JSON.parse(text) as Partial<Pokedex>
246    const eggs = <T extends { species?: unknown }>(list: unknown): T[] =>
247      Array.isArray(list) ? (list as T[]).filter(entry => typeof entry?.species === 'number') : []
248    const number = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
249    const numbers = (value: unknown): number[] => (Array.isArray(value) ? value.filter(n => typeof n === 'number') : [])
250    const isEvolving = (value: unknown): value is Pokedex['evolving'] => {
251      const one = value as { key?: unknown; from?: unknown; at?: unknown } | null
252      return typeof one?.key === 'string' && typeof one.from === 'number' && typeof one.at === 'number'
253    }
254    const isEncounter = (value: unknown): value is Pokedex['encounter'] => {
255      const one = value as { species?: unknown; at?: unknown; root?: unknown; label?: unknown } | null
256      return typeof one?.species === 'number' && typeof one.at === 'number' && typeof one.root === 'string' && typeof one.label === 'string'
257    }
258    const isScene = (value: unknown): value is Pokedex['scene'] => {
259      const one = value as { kind?: unknown; species?: unknown; at?: unknown } | null
260      return (one?.kind === 'appear' || one?.kind === 'catch') && typeof one.species === 'number' && typeof one.at === 'number'
261    }
262    const record = <T>(value: unknown): Record<string, T> => (value && typeof value === 'object' ? (value as Record<string, T>) : {})
263    // 옛 기록의 sent 는 { count, branch } 라 버린다
264    const sent = Object.entries(record<unknown>(Array.isArray(parsed.sent) ? {} : parsed.sent)).filter(
265      (entry): entry is [string, string | null] => typeof entry[1] === 'string' || entry[1] === null,
266    )
267    return {
268      day: typeof parsed.day === 'string' ? parsed.day : '',
269      hatched: record(parsed.hatched),
270      pending: eggs(parsed.pending),
271      daycare: eggs(parsed.daycare),
272      box: eggs(parsed.box),
273      holds: record(parsed.holds),
274      done: Array.isArray(parsed.done) ? parsed.done : [],
275      sent: Object.fromEntries(sent),
276      xp: Math.max(0, number(parsed.xp)),
277      xpAt: number(parsed.xpAt),
278      lead: typeof parsed.lead === 'string' ? parsed.lead : null,
279      chosen: parsed.chosen === true,
280      seen: numbers(parsed.seen),
281      legends: numbers(parsed.legends),
282      evolving: isEvolving(parsed.evolving) ? parsed.evolving : null,
283      encounter: isEncounter(parsed.encounter) ? parsed.encounter : null,
284      scene: isScene(parsed.scene) ? parsed.scene : null,
285      fled: typeof parsed.fled?.species === 'number' ? { species: parsed.fled.species } : null,
286      escaped: numbers(parsed.escaped),
287      waiting: numbers(parsed.waiting),
288    }
289  } catch {
290    return emptyPokedex()
291  }
292}
293
294// 알을 쥐고 있는 세션. 끊긴 지 한 시간이 안 된 세션은 잠자기에서 돌아올 수 있어 열린 것으로 본다
295export function isOpenSession(session: SessionStatus, now: number): boolean {
296  const liveness = livenessOf(session, now)
297  return liveness === 'working' || liveness === 'waiting' || (liveness === 'stale' && now - session.updatedAt <= KEEP_MS)
298}
299
300// 알을 갖는 세션. /mod-hud dashboard 로 대시보드라고 표시한 세션과 iTerm2 리뷰 세션은 알이 없다
301export function hasEgg(session: SessionStatus): boolean {
302  return !session.isDashboard && !isCodeReviewSession(session)
303}
304
305// 기록을 쓰는 세션. 열린 세션 중 id 가 가장 작은 세션 하나만 써서 여러 세션이 서로 덮어쓰지 않게 한다
306export function keeperOf(sessions: readonly SessionStatus[], now: number): string | null {
307  const open = sessions.filter(session => isOpenSession(session, now)).map(session => session.id)
308  return open.length > 0 ? open.reduce((a, b) => (a < b ? a : b)) : null
309}
310
311export function labelOf(session: Pick<SessionStatus, 'branch' | 'project'>): string {
312  if (!session.branch || isLongLivedBranch(session.branch, 'main')) {
313    return session.project
314  }
315  const issue = parseIssue(session.branch)
316  return issue ? `#${issue}` : session.branch
317}
318
319type Sent = Pokedex['sent']
320
321// mrState 를 지금 브랜치에서 조회했는지. mrBranch 가 없는 옛 상태 파일은 아직 모르는 것으로 친다
322function knowsMr(session: SessionStatus): boolean {
323  return session.mrBranch !== undefined && session.mrBranch === session.branch
324}
325
326// 지금 알에 쓰는 MR 상태. 박스로 보냈거나 알을 받을 때 이미 머지된 브랜치에 머무는 동안은 머지되지 않은 것으로 친다
327// 다른 브랜치를 보고 적은 MR 상태는 모르는 것으로 친다. mrBranch 가 없는 옛 상태 파일은 예전처럼 믿는다
328function mrStateOf(sent: Sent, session: SessionStatus): string | null {
329  if (sent[session.id] !== undefined && sent[session.id] === session.branch) {
330    return null
331  }
332  if (session.mrBranch !== undefined && session.mrBranch !== session.branch) {
333    return null
334  }
335  return session.mrState ?? null
336}
337
338// 세션 상태와 기록을 맞춘다. 기록을 쓰는 세션이 세션 목록을 읽을 때마다 부른다
339// requests 는 박스로 보내기를 누른 알의 key. 부화한 알만 보낸다
340// born 은 이번에 부화한 알, boxed 는 박스에 넣은 포켓몬, left 는 키우미집에 맡긴 알, given 은 키우미집에서 받은 알
341export function advancePokedex(dex: Pokedex, sessions: readonly SessionStatus[], now: number, requests: readonly string[] = []) {
342  const today = localDay(now)
343  const hatched = { ...dex.hatched }
344  const holds = { ...dex.holds }
345  let pending = [...dex.pending]
346  // 날짜가 바뀌면 키우미집에 남은 알은 버리고 그날 알로 새로 채운다
347  let daycare = today === dex.day ? [...dex.daycare] : freshEggs(today)
348  const box = [...dex.box]
349  const done = new Set(dex.done)
350  const sent: Sent = { ...dex.sent }
351  const asked = new Set(requests)
352  const born: DexEgg[] = []
353  const boxed: DexEgg[] = []
354  const left: DexEgg[] = []
355  const given: DexEgg[] = []
356  const eggOf = (session: SessionStatus, egg: HeldEgg): DexEgg => ({
357    key: egg.key,
358    species: egg.species,
359    root: session.root,
360    branch: session.branch,
361    label: labelOf(session),
362    at: now,
363  })
364  const hatch = (egg: DexEgg) => {
365    if (hatched[egg.key] === undefined) {
366      hatched[egg.key] = now
367      born.push(egg)
368    }
369  }
370  const toBox = (egg: DexEgg) => {
371    delete hatched[egg.key]
372    box.unshift({ ...egg, at: now })
373    boxed.push(egg)
374  }
375  // 키우미집으로 돌아간 알은 다음에 받은 세션에서 새로 부화한다
376  const toDaycare = (egg: DexEgg) => {
377    delete hatched[egg.key]
378    daycare.unshift({ key: egg.key, species: egg.species })
379    left.push(egg)
380  }
381  // 세션이 놓은 알. 머지됐거나 MR 이 열려 있으면 머지를 기다리고, 아니면 키우미집으로 간다
382  const release = (session: SessionStatus, held: HeldEgg) => {
383    const egg = eggOf(session, held)
384    const mrState = mrStateOf(sent, session)
385    // 세션이 열려 있을 때 이미 부화해 보인 포켓몬은 바로 박스로 간다
386    if (mrState === 'merged' && hatched[egg.key] !== undefined) {
387      toBox(egg)
388    } else if (mrState === 'merged' || mrState === 'opened') {
389      pending.push({ ...egg, mrState })
390    } else {
391      toDaycare(egg)
392    }
393  }
394
395  // /clear 나 /resume 는 같은 창에서 새 id 로 이어지므로, 끝난 세션의 알은 이어진 세션이 물려받는다
396  const heirs = new Map(
397    sessions.flatMap(session => (session.predecessor && isOpenSession(session, now) ? [[session.predecessor, session] as const] : [])),
398  )
399  for (const session of sessions) {
400    const heir = heirs.get(session.id)
401    if (!heir || isOpenSession(session, now) || done.has(session.id)) {
402      continue
403    }
404    done.add(session.id)
405    const egg = holds[session.id]
406    // 이어 받을 세션이 이미 알을 들고 있으면 끝난 세션의 알은 닫힌 세션의 알처럼 놓는다
407    if (egg && holds[heir.id]) {
408      release(session, egg)
409    } else if (egg) {
410      holds[heir.id] = egg
411    }
412    if (sent[session.id] !== undefined && sent[heir.id] === undefined) {
413      sent[heir.id] = sent[session.id]!
414    }
415    delete holds[session.id]
416  }
417
418  // 알이 없는 진행 중인 세션. 아래에서 키우미집의 알을 나눠 준다
419  const empty: SessionStatus[] = []
420  for (const session of sessions) {
421    if (!hasEgg(session)) {
422      // 알을 받은 뒤 대시보드로 표시한 세션은 든 알을 놓는다
423      const mine = holds[session.id]
424      if (mine) {
425        release(session, mine)
426        delete holds[session.id]
427      }
428      if (!isOpenSession(session, now)) {
429        done.add(session.id)
430      }
431      continue
432    }
433    if (isOpenSession(session, now)) {
434      // 잠자기에서 한 시간 넘게 지나 닫힌 것으로 보낸 세션이 돌아오면, 아직 아무도 안 받아 간 자기 알만 되찾는다
435      if (done.has(session.id)) {
436        done.delete(session.id)
437        const key = holds[session.id]?.key
438        if (daycare.some(egg => egg.key === key) || pending.some(egg => egg.key === key)) {
439          daycare = daycare.filter(egg => egg.key !== key)
440          pending = pending.filter(egg => egg.key !== key)
441        } else {
442          delete holds[session.id]
443        }
444      }
445      const mine = holds[session.id]
446      if (mine && mrStateOf(sent, session) === 'merged') {
447        const egg = eggOf(session, mine)
448        hatch(egg)
449        // 박스로 보내면 빈 세션이 된다. 같은 브랜치의 머지로 다음 알이 깨지지 않게 브랜치를 기억한다
450        if (asked.has(egg.key)) {
451          toBox(egg)
452          delete holds[session.id]
453          sent[session.id] = session.branch
454        }
455      }
456      if (!holds[session.id] && livenessOf(session, now) === 'working') {
457        empty.push(session)
458      }
459      continue
460    }
461    if (done.has(session.id)) {
462      continue
463    }
464    // 이어 받을 세션이 아직 상태를 쓰지 않았을 수 있어 잠깐 기다린다
465    const isHandingOff = session.endReason === 'clear' || session.endReason === 'resume'
466    if (isHandingOff && now - session.updatedAt < HANDOFF_MS) {
467      continue
468    }
469    done.add(session.id)
470    // 든 알은 holds 에 남겨 둬 잠자기에서 돌아오면 되찾게 한다
471    const mine = holds[session.id]
472    if (!mine) {
473      continue
474    }
475    release(session, mine)
476  }
477
478  // 먼저 일을 시작한 세션부터 키우미집 앞의 알을 하나씩 받는다
479  // 지금 브랜치의 MR 상태를 아직 모르는 세션은 이번에는 건너뛴다. 이미 머지된 브랜치에서 받은 알이 그 머지로 깨지지 않게 한다
480  empty.sort((a, b) => (a.workingSince ?? a.updatedAt) - (b.workingSince ?? b.updatedAt) || (a.id < b.id ? -1 : 1))
481  for (const session of empty.filter(knowsMr)) {
482    const egg = daycare.shift()
483    if (!egg) {
484      break
485    }
486    // 이 알을 전에 들었던 세션은 잠자기에서 돌아와도 되찾지 못하게 기록에서 지운다
487    for (const [id, other] of Object.entries(holds)) {
488      if (other.key === egg.key) {
489        delete holds[id]
490      }
491    }
492    holds[session.id] = egg
493    // 이미 머지된 브랜치에서 받은 알은 그 머지로 깨지지 않는다
494    if (session.mrState === 'merged') {
495      sent[session.id] = session.branch
496    }
497    given.push(eggOf(session, egg))
498  }
499
500  // 세션이 닫힌 뒤 머지된 알은 부화해 박스로 보내기를 누를 때까지 둥지에 남는다. 머지 없이 닫힌 MR 의 알은 키우미집으로 간다
501  pending = pending.filter(egg => {
502    if (egg.mrState === 'merged') {
503      hatch(egg)
504      if (now - (hatched[egg.key] ?? now) < UNSEEN_HATCH_MS && !asked.has(egg.key)) {
505        return true
506      }
507      const { mrState: _, ...entry } = egg
508      toBox(entry)
509      return false
510    }
511    if (egg.mrState !== 'opened') {
512      const { mrState: _, ...entry } = egg
513      toDaycare(entry)
514      return false
515    }
516    return true
517  })
518
519  // 상태 파일이 지워진 세션은 다시 볼 일이 없으니 정리한 기록에서도 뺀다
520  const known = new Set(sessions.map(session => session.id))
521  const ofKnown = <T>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => known.has(id)))
522  const nextDaycare = daycare.slice(0, DAYCARE_MAX)
523  const nextHolds = ofKnown(holds)
524  // 들거나 맡기거나 기다리는 알이 아닌 알의 부화 기록은 지운다
525  const kept = new Set([...Object.values(nextHolds), ...nextDaycare, ...pending].map(egg => egg.key))
526  // 진화 기록은 advanceEvolution 이 맞추므로 그대로 넘긴다
527  const next: Pokedex = {
528    ...dex,
529    day: today,
530    hatched: Object.fromEntries(Object.entries(hatched).filter(([key]) => kept.has(key))),
531    pending,
532    daycare: nextDaycare,
533    box,
534    holds: nextHolds,
535    done: [...done].filter(id => known.has(id)).slice(-DONE_MAX),
536    sent: ofKnown(sent),
537  }
538  return { dex: next, born, boxed, left, given }
539}
540
541// 기다리는 알의 MR 상태를 바꾼다. 기록을 쓰는 세션이 MR 을 다시 조회한 뒤 부른다
542export function updatePending(dex: Pokedex, key: string, mrState: string): Pokedex {
543  return { ...dex, pending: dex.pending.map(egg => (egg.key === key ? { ...egg, mrState } : egg)) }
544}
545
546// 패널에 그릴 알. 열린 세션의 알을 먼저, 머지를 기다리는 알을 그 뒤에 둔다. 자리가 바뀌지 않게 세션 id 순으로 둔다
547export function eggsOf(sessions: readonly SessionStatus[], dex: Pokedex, now: number): Egg[] {
548  const stageOf = (mrState: string | null | undefined): Stage =>
549    mrState === 'merged' ? 'hatched' : mrState === 'opened' ? 'cracked' : 'egg'
550  const open = sessions
551    .filter(session => isOpenSession(session, now) && hasEgg(session))
552    .sort((a, b) => (a.id < b.id ? -1 : 1))
553    .map(session => {
554      // 알을 아직 안 받은 세션은 세션 id 로 된 빈 둥지로 둔다
555      const mine = dex.holds[session.id]
556      const key = mine?.key ?? session.id
557      const stage = mine ? stageOf(mrStateOf(dex.sent, session)) : 'empty'
558      const isWorking = livenessOf(session, now) === 'working'
559      return {
560        key,
561        label: labelOf(session),
562        species: mine?.species ?? 0,
563        stage,
564        isShaking: (stage === 'egg' || stage === 'cracked') && isWorking,
565        activity: session.attention ? ('mine' as const) : isWorking ? ('working' as const) : null,
566        hatchedAt: dex.hatched[key] ?? null,
567      }
568    })
569  const waiting = dex.pending.map(egg => ({
570    key: egg.key,
571    label: egg.label,
572    species: egg.species,
573    stage: stageOf(egg.mrState),
574    isShaking: false,
575    activity: null,
576    hatchedAt: dex.hatched[egg.key] ?? null,
577  }))
578  return [...open, ...waiting]
579}
580
581// 그림에 쓸 스프라이트 번호. 금 간 알은 곧 나올 것이라 미리 불러 둔다
582export function speciesToLoad(input: NestInput, width: number): number[] {
583  const eggs = eggsOnScreen(input, width).filter(egg => egg.stage !== 'egg' && egg.stage !== 'empty')
584  return [...new Set(eggs.map(egg => egg.species))]
585}
586
587// 픽셀 그림판. 비운 픽셀은 터미널 배경이 비친다
588type Canvas = { width: number; height: number; px: Uint32Array }
589
590function canvasOf(width: number, height: number): Canvas {
591  return { width, height, px: new Uint32Array(width * height).fill(CLEAR) }
592}
593
594function put(canvas: Canvas, x: number, y: number, color: number) {
595  if (x >= 0 && x < canvas.width && y >= 0 && y < canvas.height) {
596    canvas.px[y * canvas.width + x] = color
597  }
598}
599
600function sprite(canvas: Canvas, x: number, y: number, rows: string[], palette: Record<string, number>) {
601  rows.forEach((row, dy) => {
602    for (let dx = 0; dx < row.length; dx++) {
603      const color = palette[row[dx] ?? '.']
604      if (color !== undefined) {
605        put(canvas, x + dx, y + dy, color)
606      }
607    }
608  })
609}
610
611function blit(canvas: Canvas, x: number, y: number, image: Pixels) {
612  image.px.forEach((color, i) => {
613    if (color !== CLEAR) {
614      put(canvas, x + (i % image.width), y + Math.floor(i / image.width), color)
615    }
616  })
617}
618
619// 몸통에 알 key 로 고른 반점과, 금 간 알이면 금을 칠한다
620function paintedEgg(key: string, stage: 'egg' | 'cracked'): string[][] {
621  const width = EGG_BODY[0]!.length
622  const height = EGG_BODY.length
623  const spots = SPOTS[hash(`spots:${key}`) % SPOTS.length]!
624  const rows = EGG_BODY.map((row, y) =>
625    [...row].map((c, x) => {
626      if (c !== 'e' && c !== 'h' && c !== 's') {
627        return c
628      }
629      const hit = spots.some(([bx, by, r]) => {
630        const dx = (x + 0.5) / width - bx
631        const dy = ((y + 0.5) / height - by) * (height / width)
632        return dx * dx + dy * dy <= r * r
633      })
634      return hit ? (c === 's' ? 'D' : 'G') : c
635    }),
636  )
637  if (stage === 'cracked') {
638    // 프레임마다 바뀌지 않게 알 key 로 고른다
639    for (const [x, y] of CRACKS[hash(`crack:${key}`) % CRACKS.length]!) {
640      rows[y]![x] = 'c'
641    }
642  }
643  return rows
644}
645
646const eggSprites = new Map<string, string[]>()
647
648// 알 key 의 무늬와 금으로 칠한 알을 angle 도만큼 기울인 그림. ROCK_WIDTH x ROCK_HEIGHT 이고 바닥 줄이 알의 바닥이다
649export function eggSprite(key: string, stage: 'egg' | 'cracked', angle: number): string[] {
650  const id = `${key}:${stage}:${angle}`
651  const cached = eggSprites.get(id)
652  if (cached) {
653    return cached
654  }
655  const source = paintedEgg(key, stage)
656  const width = source[0]!.length
657  const height = source.length
658  // 아랫면 곡률 중심을 축으로 돌리고, 기운 쪽으로 조금 굴려 둥근 바닥이 둥지에 닿은 채 기울게 한다
659  const radius = width / 2 - 0.5
660  const a = (angle * Math.PI) / 180
661  const pivotX = width / 2
662  const pivotY = height - radius
663  const outX = ROCK_WIDTH / 2 + radius * a * 0.4
664  const outY = ROCK_HEIGHT - radius
665  const sample = (x: number, y: number) => {
666    const dx = x - outX
667    const dy = y - outY
668    const sx = Math.floor(dx * Math.cos(-a) - dy * Math.sin(-a) + pivotX)
669    const sy = Math.floor(dx * Math.sin(-a) + dy * Math.cos(-a) + pivotY)
670    return source[sy]?.[sx] ?? '.'
671  }
672  // 칸을 3x3 으로 나눠 반 넘게 알 안이면 칠한다. 한 점만 읽으면 테두리가 들쭉날쭉하다. 색은 칸 중심에서 읽는다
673  const grid = Array.from({ length: ROCK_HEIGHT }, (_, y) =>
674    Array.from({ length: ROCK_WIDTH }, (_, x) => {
675      let inside = 0
676      for (const oy of [1 / 6, 1 / 2, 5 / 6]) {
677        for (const ox of [1 / 6, 1 / 2, 5 / 6]) {
678          inside += sample(x + ox, y + oy) === '.' ? 0 : 1
679        }
680      }
681      if (inside < 5) {
682        return '.'
683      }
684      const center = sample(x + 0.5, y + 0.5)
685      return center === '.' ? 'e' : center
686    }),
687  )
688  // 이웃이 하나뿐인 칸은 돌리다 생긴 돌기라 지운다. 지우면 또 생길 수 있어 없을 때까지 되풀이한다
689  const neighbours = (x: number, y: number) =>
690    [grid[y]?.[x - 1], grid[y]?.[x + 1], grid[y - 1]?.[x], grid[y + 1]?.[x]].filter(c => c !== undefined && c !== '.').length
691  for (let changed = true; changed; ) {
692    changed = false
693    grid.forEach((row, y) =>
694      row.forEach((c, x) => {
695        if (c !== '.' && neighbours(x, y) < 2) {
696          row[x] = '.'
697          changed = true
698        }
699      }),
700    )
701  }
702  // 1칸 외곽선은 돌리면 구멍이 나서, 모양만 돌리고 바깥 둘레를 외곽선으로 다시 칠한다
703  const filled = (x: number, y: number) => (grid[y]?.[x] ?? '.') !== '.'
704  const rows = grid.map((row, y) =>
705    row
706      .map((c, x) => {
707        if (c === '.') {
708          return '.'
709        }
710        if (!filled(x - 1, y) || !filled(x + 1, y) || !filled(x, y - 1) || !filled(x, y + 1)) {
711          return 'k'
712        }
713        return c === 'k' ? 'e' : c
714      })
715      .join(''),
716  )
717  eggSprites.set(id, rows)
718  return rows
719}
720
721// 반짝이는 별을 width x height 칸 안에 흩뿌린다
722// 몸 모양 그대로 하얗게 칠한 그림
723function silhouette(image: Pixels): Pixels {
724  return { ...image, px: image.px.map(color => (color === CLEAR ? CLEAR : C.sparkleHot)) }
725}
726
727function drawSparkles(canvas: Canvas, x: number, y: number, width: number, height: number, frame: number) {
728  const spots = [
729    [0.05, 0.25],
730    [0.9, 0.1],
731    [0.1, 0.7],
732    [0.92, 0.55],
733    [0.45, 0.02],
734    [0.7, 0.35],
735  ] as const
736  spots.forEach(([fx, fy], i) => {
737    const dx = Math.round(fx * (width - 1))
738    const dy = y + Math.round(fy * (height - 1))
739    if ((frame + i) % 3 === 0) {
740      return
741    }
742    const color = (frame + i) % 2 === 0 ? C.sparkle : C.sparkleHot
743    put(canvas, x + dx, dy, color)
744    if ((frame + i) % 3 === 1) {
745      put(canvas, x + dx - 1, dy, color)
746      put(canvas, x + dx + 1, dy, color)
747      put(canvas, x + dx, dy - 1, color)
748      put(canvas, x + dx, dy + 1, color)
749    }
750  })
751}
752
753function drawSlot(canvas: Canvas, slotX: number, slot: number, egg: Egg, frame: number, now: number, lookup: SpriteLookup) {
754  const floor = canvas.height - 1
755  const eggX = slotX + Math.floor((slot - ROCK_WIDTH) / 2)
756  const eggTop = floor - ROCK_HEIGHT
757  for (let dx = 1; dx < slot - 1; dx++) {
758    put(canvas, slotX + dx, floor, dx % 2 === 0 ? C.nest : C.nestDark)
759  }
760  if (egg.stage === 'empty') {
761    return
762  }
763  if (egg.stage === 'hatched') {
764    // 껍질 없이 포켓몬만 둥지 위에서 통통 튄다. 막 나왔을 때는 더 높이 뛰며 반짝인다
765    const isCelebrating = egg.hatchedAt !== null && now - egg.hatchedAt < CELEBRATE_MS
766    const hops = isCelebrating ? HOP_HIGH : HOP
767    const bottom = floor - 1 - (hops[(frame + egg.key.length) % hops.length] ?? 0)
768    const image = lookup(egg.species, HATCHED_SIZE)
769    if (image) {
770      blit(canvas, slotX + Math.floor((slot - image.width) / 2), bottom - image.height + 1, image)
771    } else {
772      sprite(canvas, slotX + Math.floor((slot - 8) / 2), bottom - 7, BALL, BALL_PALETTE)
773    }
774    if (isCelebrating) {
775      drawSparkles(canvas, slotX, 0, slot, floor, frame)
776    }
777    return
778  }
779  const angle = egg.isShaking ? (ROCK[(frame + egg.key.length) % ROCK.length] ?? 0) : 0
780  sprite(canvas, eggX, eggTop, eggSprite(egg.key, egg.stage, angle), EGG_PALETTE)
781}
782
783// 위아래 두 픽셀을 반 블록 한 칸에 담는다. 둘 다 비면 빈칸으로 둔다
784function pixelCells(canvas: Canvas, out: number[]) {
785  for (let row = 0; row < canvas.height / 2; row++) {
786    for (let x = 0; x < canvas.width; x++) {
787      const top = canvas.px[row * 2 * canvas.width + x] ?? CLEAR
788      const bottom = canvas.px[(row * 2 + 1) * canvas.width + x] ?? CLEAR
789      if (top === CLEAR && bottom === CLEAR) {
790        out.push(SPACE, CLEAR, CLEAR)
791      } else if (top === CLEAR) {
792        out.push(LOWER_HALF, bottom, CLEAR)
793      } else {
794        out.push(UPPER_HALF, top, bottom)
795      }
796    }
797  }
798}
799
800// 글자 한 줄. Raster 는 한 칸짜리 글자만 받으므로 ASCII 밖의 글자는 ? 로 바꾼다
801function textCells(width: number, parts: { at: number; text: string; color: number }[], background: number, out: number[]) {
802  const line = Array.from({ length: width }, () => [SPACE, CLEAR, background])
803  for (const part of parts) {
804    for (let i = 0; i < part.text.length; i++) {
805      const at = part.at + i
806      const code = part.text.charCodeAt(i)
807      if (at >= 0 && at < width) {
808        line[at] = [code >= 0x20 && code < 0x7f ? code : 0x3f, part.color, background]
809      }
810    }
811  }
812  for (const cell of line) {
813    out.push(...cell)
814  }
815}
816
817type EggRow = { eggs: Egg[]; slots: number[]; rows: number }
818
819// 알을 줄마다 채운다. 알은 좁은 칸, 부화한 포켓몬은 넓은 칸을 쓴다. 두 줄을 넘는 알은 보이지 않는다
820function eggLayout(eggs: Egg[], width: number): EggRow[] {
821  const rows: EggRow[] = []
822  let current: Egg[] = []
823  let used = 0
824  const close = () => {
825    const slots = current.map(egg => (egg.stage === 'hatched' ? HATCHED_SLOT : EGG_SLOT))
826    rows.push({ eggs: current, slots, rows: current.some(egg => egg.stage === 'hatched') ? HATCHED_ROWS : EGG_ROWS })
827    current = []
828    used = 0
829  }
830  for (const egg of eggs) {
831    const slot = egg.stage === 'hatched' ? HATCHED_SLOT : EGG_SLOT
832    if (current.length > 0 && used + slot > width) {
833      close()
834      if (rows.length === EGG_ROWS_MAX) {
835        return rows
836      }
837    }
838    current.push(egg)
839    used += slot
840  }
841  if (current.length > 0 && rows.length < EGG_ROWS_MAX) {
842    close()
843  }
844  return rows
845}
846
847// 패널에 보이는 알. 박스로 보내기 버튼과 스프라이트를 불러올 대상이 이것이다
848export function eggsOnScreen(input: NestInput, width: number): Egg[] {
849  return eggLayout(input.eggs, width).flatMap(row => row.eggs)
850}
851
852export function boxPages(input: NestInput): number {
853  return Math.max(1, Math.ceil(input.box.length / BOX_PER_PAGE))
854}
855
856export function boxPageOf(input: NestInput): number {
857  return Math.min(Math.max(0, input.boxPage), boxPages(input) - 1)
858}
859
860export function boxOnPage(input: NestInput): BoxEntry[] {
861  const page = boxPageOf(input)
862  return input.box.slice(page * BOX_PER_PAGE, (page + 1) * BOX_PER_PAGE)
863}
864
865// 전설 등장 장면. 원작의 야생 포켓몬 배틀처럼 줄무늬로 화면이 덮이고, 전설이 나타나고, 몬스터볼에 잡힌다
866// 그림 12줄과 글 한 줄. 프레임(250ms)마다 한 걸음이고 ARRIVAL_FRAMES 걸음이면 끝난다
867export const ARRIVAL_FRAMES = 36
868export const ARRIVAL_ROWS = 13
869const ARRIVAL_HEIGHT = (ARRIVAL_ROWS - 1) * 2
870// 전설 그림 크기. 아래 글 줄에 닿지 않게 그림 칸보다 작게 그리고 한 줄을 띄운다
871const ARRIVAL_SIZE = 20
872const ARRIVAL_FLOOR = ARRIVAL_HEIGHT - 2
873const ARRIVAL_PAPER = 0xf8f8f0
874const ARRIVAL_INK = 0x202020
875
876export function drawArrival(species: number, name: string, width: number, step: number, lookup: SpriteLookup): Uint32Array {
877  const canvas = canvasOf(width, ARRIVAL_HEIGHT)
878  canvas.px.fill(ARRIVAL_PAPER)
879  const shout = name.toUpperCase()
880  const image = lookup(species, ARRIVAL_SIZE)
881  const legendX = Math.floor(width / 2) - 14
882  const ballX = Math.floor(width / 2) - 4
883  const ballY = ARRIVAL_HEIGHT - 10
884  const legend = (x: number) => {
885    if (image) {
886      blit(canvas, x, ARRIVAL_FLOOR - image.height, image)
887    } else {
888      sprite(canvas, x, ARRIVAL_FLOOR - 8, BALL, BALL_PALETTE)
889    }
890  }
891  let caption = ''
892  if (step < 6) {
893    // 두 줄씩 번갈아 왼쪽과 오른쪽에서 검은 띠가 들어온다
894    const reach = Math.min(width, Math.ceil(((step + 1) * width) / 5))
895    for (let y = 0; y < ARRIVAL_HEIGHT; y++) {
896      for (let i = 0; i < reach; i++) {
897        put(canvas, Math.floor(y / 2) % 2 === 0 ? i : width - 1 - i, y, ARRIVAL_INK)
898      }
899    }
900  } else if (step < 16) {
901    // 오른쪽에서 미끄러져 들어온다
902    legend(Math.max(legendX, width - (step - 6) * 10))
903    caption = `A wild ${shout} appeared!`
904  } else if (step < 20) {
905    // 몬스터볼이 날아가는 동안 전설이 깜빡인다
906    if (step % 2 === 0) {
907      legend(legendX)
908    }
909    sprite(canvas, 2 + (step - 16) * Math.floor((ballX - 2) / 3), ballY - (step - 16) * 2, BALL, BALL_PALETTE)
910    caption = 'Go! POKe BALL!'
911  } else if (step < 32) {
912    // 세 번 흔들리고 잡힌다
913    const isCaught = step >= 30
914    const shake = isCaught ? 0 : ([0, -1, 0, 1][(step - 20) % 4] ?? 0)
915    sprite(canvas, ballX + shake, ballY, BALL, BALL_PALETTE)
916    if (isCaught) {
917      drawSparkles(canvas, ballX - 6, ballY - 6, 20, 14, step)
918    }
919    caption = isCaught ? `Gotcha! ${shout} was caught!` : '...'
920  } else {
921    sprite(canvas, ballX, ballY, BALL, BALL_PALETTE)
922    caption = `${shout} was sent to the PC BOX`
923  }
924  const out: number[] = []
925  pixelCells(canvas, out)
926  textCells(width, [{ at: Math.max(0, Math.floor((width - caption.length) / 2)), text: caption, color: C.boxBarText }], ARRIVAL_INK, out)
927  return Uint32Array.from(out)
928}
929
930// 아이콘의 원본 크기(7세대 아이콘은 40x30). 야생 전설을 줄이지 않고 그린다
931const PORTRAIT_SIZE = 40
932
933export function drawPortrait(species: number, lookup: SpriteLookup): { columns: number; rows: number; cells: Uint32Array } | null {
934  const image = lookup(species, PORTRAIT_SIZE)
935  if (!image) {
936    return null
937  }
938  const canvas = canvasOf(image.width, image.height + (image.height % 2))
939  blit(canvas, 0, 0, image)
940  const out: number[] = []
941  pixelCells(canvas, out)
942  return { columns: canvas.width, rows: canvas.height / 2, cells: Uint32Array.from(out) }
943}
944
945// 즐겨찾기 파일(~/.claude/mod-hud-favorites.json)과 펼쳐 본 파일(~/.claude/mod-hud-seen.json)은 박스 key 의 배열이다. 읽지 못하면 빈 것으로 본다
946export function parseKeys(text: string): string[] {
947  try {
948    const parsed: unknown = JSON.parse(text)
949    return Array.isArray(parsed) ? parsed.filter((key): key is string => typeof key === 'string') : []
950  } catch {
951    return []
952  }
953}
954
955// 즐겨찾는 포켓몬을 PC BOX 맨 앞에 모은다. 그 안과 나머지는 원래 순서를 지킨다
956export function favoritesFirst(box: readonly BoxEntry[], favorites: ReadonlySet<string>): BoxEntry[] {
957  return [...box.filter(entry => favorites.has(entry.key)), ...box.filter(entry => !favorites.has(entry.key))]
958}
959
960// 박스 key 가 있는 페이지. 막 진화한 포켓몬의 줄로 페이지를 넘길 때 쓴다
961export function boxPageOfKey(input: NestInput, key: string): number {
962  const index = input.box.findIndex(entry => entry.key === key)
963  return index < 0 ? boxPageOf(input) : Math.floor(index / BOX_PER_PAGE)
964}
965
966// 키우미집 풀밭 줄 수. 머리줄은 누르면 접히게 패널이 따로 그린다
967export const DAYCARE_GROUND_ROWS = 4
968
969function drawDaycare(canvas: Canvas, eggs: HeldEgg[], frame: number) {
970  for (let y = 0; y < canvas.height; y++) {
971    for (let x = 0; x < canvas.width; x++) {
972      put(canvas, x, y, (x + (y >> 1)) % 4 === 0 ? C.daycareGroundAlt : C.daycareGround)
973    }
974  }
975  const shown = Math.floor((canvas.width - 2) / MINI_SLOT)
976  eggs.slice(0, shown).forEach((egg, i) => {
977    // 차곡차곡 쌓인 알이 가끔 하나씩 꼼지락거린다
978    const nudge = (frame + i * 5) % 24 === 0 ? -1 : 0
979    const x = 1 + i * MINI_SLOT
980    sprite(canvas, x, 1 + nudge, MINI_EGG, EGG_PALETTE)
981  })
982}
983
984// 알 줄을 그린다. 세션이 없으면 0줄이다. 키우미집 풀밭(drawDaycareGround)과 PC BOX 칸(drawBoxTile)은 따로 그린다
985export function nestRows(input: NestInput, width: number): number {
986  // 알 한 줄은 그림과 라벨 한 줄이고, 알이 있으면 그 아래 한 줄을 띄운다
987  const layout = eggLayout(input.eggs, width)
988  return layout.reduce((sum, row) => sum + row.rows + 1, 0) + (layout.length > 0 ? 1 : 0)
989}
990
991// 앞장선 포켓몬이 풀밭을 걸으면 풀밭이 이만큼 높아진다. 걷는 포켓몬과 통통 튀는 한 칸이 들어간다
992const WALK_GROUND_ROWS = 11
993// 풀밭을 걷는 포켓몬의 크기. 더 작으면 큰 포켓몬이 뭉개진다
994export const WALKER_SIZE = ICON_SIZE
995// 걷는 포켓몬이 통통 튀는 높이. 두 프레임마다 한 칸 뛴다
996const STEP_HOP = [0, 1, 1, 0]
997
998export function daycareGroundRows(hasWalker: boolean): number {
999  return hasWalker ? WALK_GROUND_ROWS : DAYCARE_GROUND_ROWS
1000}
1001
1002// 키우미집 풀밭과 맡긴 작은 알. 알이 없어도 그린다
1003// walker 는 맡긴 포켓몬 그림. 주면 풀밭을 높여 그 포켓몬이 좌우로 걷게 한다. null 이면 풀밭만 높이고 아무도 걷지 않는다
1004// before 는 막 진화한 포켓몬의 진화 전 그림. 주면 걷지 않고 가운데 서서 진화 장면(evolvePhase)을 보인다. elapsed 는 장면이 시작한 뒤 ms 다
1005export function drawDaycareGround(
1006  daycare: HeldEgg[],
1007  width: number,
1008  frame: number,
1009  walker?: Pixels | null,
1010  before?: Pixels | null,
1011  elapsed = 0,
1012): Uint32Array {
1013  const ground = canvasOf(width, daycareGroundRows(walker !== undefined) * 2)
1014  drawDaycare(ground, daycare, frame)
1015  if (walker && before) {
1016    const phase = evolvePhase(elapsed)
1017    // 빛나는 동안은 원작처럼 하얀 실루엣으로 진화 전과 뒤 모습을 번갈아 그린다
1018    const image =
1019      phase === 'start' ? before : phase === 'morph' ? silhouette(isMorphNew(elapsed) ? walker : before) : walker
1020    const x = Math.floor((width - image.width) / 2)
1021    if (phase === 'flash') {
1022      // 다 빛나면 풀밭 전체가 하얗게 번쩍인다
1023      for (let y = 0; y < ground.height; y++) {
1024        for (let dx = 0; dx < width; dx++) {
1025          put(ground, dx, y, C.sparkleHot)
1026        }
1027      }
1028    } else {
1029      blit(ground, x, ground.height - image.height, image)
1030    }
1031    if (phase === 'morph' || phase === 'done') {
1032      drawSparkles(ground, x - 2, 0, image.width + 4, ground.height, frame)
1033    }
1034  } else if (walker) {
1035    // 오른쪽 끝에서 왼쪽으로 한 칸씩 걷다가 끝에 닿으면 돌아온다. 아이콘은 왼쪽을 보므로 오른쪽으로 갈 때 뒤집는다
1036    const range = Math.max(1, width - walker.width)
1037    const step = frame % (range * 2)
1038    const isGoingRight = step >= range
1039    const x = isGoingRight ? step - range : range - step
1040    const bottom = ground.height - 1 - (STEP_HOP[frame % STEP_HOP.length] ?? 0)
1041    const image = isGoingRight ? mirrored(walker) : walker
1042    blit(ground, x, bottom - image.height + 1, image)
1043  }
1044  const out: number[] = []
1045  pixelCells(ground, out)
1046  return Uint32Array.from(out)
1047}
1048
1049function mirrored(image: Pixels): Pixels {
1050  const px = image.px.map((_, i) => {
1051    const x = i % image.width
1052    const y = Math.floor(i / image.width)
1053    return image.px[y * image.width + (image.width - 1 - x)]!
1054  })
1055  return { ...image, px }
1056}
1057
1058export function drawNest(input: NestInput, width: number, frame: number, now: number, lookup: SpriteLookup): Uint32Array {
1059  const out: number[] = []
1060  const color = { empty: C.labelEgg, egg: C.labelEgg, cracked: C.labelCracked, hatched: C.labelHatched }
1061  for (const row of eggLayout(input.eggs, width)) {
1062    const canvas = canvasOf(width, row.rows * 2)
1063    const total = row.slots.reduce((sum, slot) => sum + slot, 0)
1064    const starts = row.slots.map((_, i) => Math.floor((width - total) / 2) + row.slots.slice(0, i).reduce((sum, slot) => sum + slot, 0))
1065    row.eggs.forEach((egg, i) => drawSlot(canvas, starts[i]!, row.slots[i]!, egg, frame, now, lookup))
1066    pixelCells(canvas, out)
1067    textCells(
1068      width,
1069      row.eggs.map((egg, i) => {
1070        const slot = row.slots[i]!
1071        const text = egg.label.length > slot - 1 ? egg.label.slice(0, slot - 1) : egg.label
1072        const tint = egg.activity === 'mine' ? C.labelMine : egg.activity === 'working' ? C.labelWorking : color[egg.stage]
1073        return { at: starts[i]! + Math.floor((slot - text.length) / 2), text, color: tint }
1074      }),
1075      CLEAR,
1076      out,
1077    )
1078  }
1079  if (input.eggs.length > 0) {
1080    textCells(width, [], CLEAR, out)
1081  }
1082
1083  return Uint32Array.from(out)
1084}
1085
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/sessions-lib.ts 255 lines
1import type { Attention, BackgroundTask, Board, BoardEntry, Liveness, SessionStatus, TodoItem, TodoSummary } from '../types'
2import { parseIssue } from './issues-lib'
3
4// heartbeat 가 1분마다 오므로 3분 넘게 소식이 없으면 CLI 가 꺼진 것으로 본다
5export const STALE_MS = 3 * 60 * 1000
6// 끝났거나 응답이 없는 세션은 이만큼만 패널에 남긴다
7export const KEEP_MS = 60 * 60 * 1000
8// 이보다 오래된 상태 파일은 지운다
9export const PRUNE_MS = 24 * 60 * 60 * 1000
10
11const PROMPT_MAX = 60
12
13// iTerm2 Code Review 탭이 리뷰 세션에 보내는 기본 프롬프트의 첫머리
14const CODE_REVIEW_PROMPT = 'Review the pending changes in this repo'
15
16export const isCodeReviewPrompt = (prompt: string | null | undefined): boolean =>
17  prompt?.startsWith(CODE_REVIEW_PROMPT) === true
18
19// iTerm2 Code Review 탭이 띄운 세션. 표시가 없는 예전 상태 파일은 마지막 요청으로 알아본다
20export const isCodeReviewSession = (session: Pick<SessionStatus, 'isCodeReview' | 'prompt'>): boolean =>
21  session.isCodeReview === true || isCodeReviewPrompt(session.prompt)
22
23// 요청의 첫 줄만 짧게 남긴다
24export const firstLine = (text: string): string | null => {
25  const line = text
26    .split('\n')
27    .map(s => s.trim())
28    .find(Boolean)
29  if (!line) {
30    return null
31  }
32  return line.length > PROMPT_MAX ? `${line.slice(0, PROMPT_MAX)}…` : line
33}
34
35export const projectName = (root: string): string => root.split('/').filter(Boolean).pop() ?? root
36
37export const summarizeTodos = (items: readonly TodoItem[]): TodoSummary | null => {
38  if (items.length === 0) {
39    return null
40  }
41  const current = items.find(item => item.status === 'in_progress')
42  return {
43    done: items.filter(item => item.status === 'completed').length,
44    total: items.length,
45    current: current?.label ?? null,
46  }
47}
48
49// TaskCreate·TaskUpdate 는 한 항목씩 오므로 목록을 직접 고쳐 간다
50export const applyTaskUpdate = (
51  items: readonly TodoItem[],
52  id: string,
53  patch: { status?: string; label?: string },
54): TodoItem[] => {
55  if (patch.status === 'deleted') {
56    return items.filter(item => item.id !== id)
57  }
58  return items.map(item =>
59    item.id !== id
60      ? item
61      : {
62          ...item,
63          label: patch.label ?? item.label,
64          status:
65            patch.status === 'pending' || patch.status === 'in_progress' || patch.status === 'completed'
66              ? patch.status
67              : item.status,
68        },
69  )
70}
71
72export const livenessOf = (session: SessionStatus, now: number): Liveness => {
73  if (session.state === 'ended') {
74    return 'ended'
75  }
76  return now - session.updatedAt > STALE_MS ? 'stale' : session.state
77}
78
79// 마크다운 꾸밈을 벗긴 마지막 줄. 코드 블록 닫는 줄은 건너뛴다
80export const lastLine = (text: string): string | null =>
81  text
82    .split('\n')
83    .map(line => line.trim().replace(/^[#>*\-\s]+/, '').replace(/[*_`\s]+$/, ''))
84    .filter(line => line !== '' && !line.startsWith('```'))
85    .pop() ?? null
86
87export const isQuestion = (text: string): boolean => /[??]\)?$/.test(lastLine(text) ?? '')
88
89// 메인 턴이 끝난 뒤 세션이 나를 기다리는 이유. 내가 끊은 턴은 내가 보고 있으니 기다리는 것으로 치지 않는다
90export const attentionAfterTurn = (answer: string, isAborted: boolean, now: number): Attention | null => {
91  if (isAborted) {
92    return null
93  }
94  return isQuestion(answer)
95    ? { kind: 'question', since: now, text: firstLine(lastLine(answer) ?? '') }
96    : { kind: 'answered', since: now, text: null }
97}
98
99// 끝났다는 알림을 놓친 작업이 세션을 진행 중에 묶어 두지 않게, 이만큼 지난 작업은 끝난 것으로 본다
100export const BACKGROUND_MAX_MS = 60 * 60 * 1000
101
102// 도구 결과에서 백그라운드로 띄운 작업 id 를 뽑는다. Bash 는 backgroundTaskId, Agent 는 async_launched 의 agentId
103export const launchedTaskId = (result: unknown): string | null => {
104  if (!result || typeof result !== 'object') {
105    return null
106  }
107  const raw = result as { backgroundTaskId?: unknown; status?: unknown; agentId?: unknown }
108  if (typeof raw.backgroundTaskId === 'string' && raw.backgroundTaskId) {
109    return raw.backgroundTaskId
110  }
111  const isLaunched = raw.status === 'async_launched' || raw.status === 'remote_launched'
112  return isLaunched && typeof raw.agentId === 'string' && raw.agentId ? raw.agentId : null
113}
114
115// <task-notification> 입력에서 끝난 작업 id 를 뽑는다. 아직 도는 중이라는 알림은 빼고 본다
116// 서브에이전트가 결과를 돌려준 <agent-message> 도 끝난 것으로 본다. transcript 줄에서는 따옴표가 \" 로 escape 돼 있다
117export const finishedTaskIds = (text: string): string[] => [
118  ...[...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)]
119    .map(match => match[1] ?? '')
120    .filter(body => !/<status>\s*(running|pending)\s*<\/status>/.test(body))
121    .map(body => body.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/)?.[1] ?? null)
122    .filter((id): id is string => id !== null),
123  ...[...text.matchAll(/<agent-message from=\\?"([^"\\]+)\\?">(?:\s|\\n)*\[Subagent hand-back\]/g)].map(
124    match => match[1] ?? '',
125  ),
126]
127
128// 끝난 것과 너무 오래된 것을 뺀, 아직 도는 백그라운드 작업
129export const pendingTasks = (
130  tasks: readonly BackgroundTask[],
131  now: number,
132  finished: readonly string[] = [],
133): BackgroundTask[] => tasks.filter(task => !finished.includes(task.id) && now - task.at < BACKGROUND_MAX_MS)
134
135const KIND_ORDER: Record<Attention['kind'], number> = { permission: 0, question: 1, answered: 2 }
136
137const entryOf = (session: SessionStatus, since: number, withProject: boolean): BoardEntry => {
138  const name = (session.branch && parseIssue(session.branch)) ?? session.branch ?? session.project
139  return {
140    id: session.id,
141    branch: session.branch,
142    title: session.title ?? null,
143    label: withProject && name !== session.project ? `${session.project} ${name}` : name,
144    prompt: session.prompt,
145    todos: session.todos,
146    check: session.check,
147    since,
148    attention: session.attention ?? null,
149  }
150}
151
152// 세션을 내 차례, 진행 중, 빈 대기, 숨길 것으로 나눈다. 내 차례는 막힌 정도와 오래 기다린 순서로 둔다
153// merged 는 MR 이 머지된 브랜치. 그 브랜치에서 답을 끝낸 세션은 할 일이 끝난 것이라 빈 대기로 둔다
154export const triage = (
155  sessions: readonly SessionStatus[],
156  ownId: string | null,
157  now: number,
158  merged: ReadonlySet<string> = new Set(),
159): Board => {
160  const hidden = { stale: 0, ended: 0 }
161  const mine: Array<[SessionStatus, number]> = []
162  const running: Array<[SessionStatus, number]> = []
163  const idle: Array<[SessionStatus, number]> = []
164  for (const session of sessions) {
165    if (session.id === ownId || isCodeReviewSession(session)) {
166      continue
167    }
168    const liveness: Liveness = livenessOf(session, now)
169    if (liveness === 'stale' || liveness === 'ended') {
170      if (now - session.updatedAt <= KEEP_MS) {
171        hidden[liveness] += 1
172      }
173      continue
174    }
175    // 질문 도구와 권한 창은 턴 도중에 뜨므로 작업 중이어도 내 차례다. 새 요청을 보내면 attention 이 비워진다
176    const attention = session.attention ?? null
177    const isMergedDone = attention?.kind === 'answered' && session.branch !== null && merged.has(session.branch)
178    if (attention && !isMergedDone) {
179      mine.push([session, attention.since])
180    } else if (liveness === 'working') {
181      running.push([session, session.workingSince ?? session.updatedAt])
182    } else {
183      idle.push([session, attention?.since ?? session.waitingSince ?? session.updatedAt])
184    }
185  }
186  const hasManyProjects = new Set([...mine, ...running].map(([session]) => session.project)).size > 1
187  const toEntry = ([session, since]: [SessionStatus, number]) => entryOf(session, since, hasManyProjects)
188  return {
189    mine: mine
190      .map(toEntry)
191      .sort((a, b) => KIND_ORDER[a.attention!.kind] - KIND_ORDER[b.attention!.kind] || a.since - b.since),
192    running: running.map(toEntry).sort((a, b) => a.since - b.since),
193    // 어느 프로젝트에 열어 둔 것인지가 핵심이라 늘 프로젝트를 붙인다
194    idle: idle.map(([session, since]) => entryOf(session, since, true)).sort((a, b) => a.since - b.since),
195    hidden,
196  }
197}
198
199export const formatAgo = (ms: number): string => {
200  const minutes = Math.floor(ms / 60_000)
201  if (minutes < 1) {
202    return 'just now'
203  }
204  if (minutes < 60) {
205    return `${minutes}m ago`
206  }
207  const hours = Math.floor(minutes / 60)
208  return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
209}
210
211// 기다리거나 일한 시간. 오래될수록 병목이다
212export const formatFor = (ms: number): string => {
213  const minutes = Math.floor(ms / 60_000)
214  if (minutes < 1) {
215    return 'just now'
216  }
217  if (minutes < 60) {
218    return `for ${minutes}m`
219  }
220  const hours = Math.floor(minutes / 60)
221  return hours < 24 ? `for ${hours}h` : `for ${Math.floor(hours / 24)}d`
222}
223
224// transcript 에서 /resume 목록에 보이는 이름을 고른다. /rename 으로 지은 이름이 자동 제목보다 먼저고, 둘 다 마지막 것이 지금 이름이다
225export const pickTitle = (lines: string): string | null => {
226  let custom: string | null = null
227  let ai: string | null = null
228  for (const line of lines.split('\n')) {
229    try {
230      const entry = JSON.parse(line) as { type?: unknown; customTitle?: unknown; aiTitle?: unknown }
231      if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle) {
232        custom = entry.customTitle
233      } else if (entry.type === 'ai-title' && typeof entry.aiTitle === 'string' && entry.aiTitle) {
234        ai = entry.aiTitle
235      }
236    } catch {
237      // 잘린 줄은 건너뛴다
238    }
239  }
240  return custom ?? ai
241}
242
243// Claude Code 가 transcript 를 두는 곳. 프로젝트 경로의 영숫자 아닌 글자를 - 로 바꾼 폴더다
244export const transcriptPathOf = (home: string, projectDir: string, id: string): string =>
245  `${home}/.claude/projects/${projectDir.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
246
247// 공유 캐시가 언제 조회됐고 다음 조회가 언제인지. 주기가 지났으면 어느 세션이든 곧 다시 부른다
248export const freshness = (fetchedAt: number | null, ttlMs: number, now: number): string => {
249  if (fetchedAt === null) {
250    return 'not fetched yet'
251  }
252  const left = Math.ceil((fetchedAt + ttlMs - now) / 60_000)
253  return `fetched ${formatAgo(now - fetchedAt)}, ${left > 0 ? `next in ${left}m` : 'refreshing soon'}`
254}
255
types/index.d.ts 223 lines
1export type Check = { name: string; isOk: boolean; at: number }
2
3export type TodoItem = { id: string; label: string; status: 'pending' | 'in_progress' | 'completed' }
4
5export type TodoSummary = { done: number; total: number; current: string | null }
6
7// 이슈 패널이 worktree 에 세션을 붙일 때 쓰는 부분만
8export type TreeSession = {
9  id: string
10  cwd: string
11  state: 'working' | 'waiting' | 'ended'
12  updatedAt: number
13  // 입력 대기에 들어간 시각. heartbeat 가 updatedAt 을 계속 바꾸므로 따로 둔다
14  waitingSince?: number
15  check?: Check
16}
17
18// 세션이 나를 기다리는 이유. permission 은 작업 중에도 생긴다
19export type Attention = {
20  kind: 'permission' | 'question' | 'answered'
21  since: number
22  // 권한 요청 문구나 질문 한 줄. answered 는 null
23  text: string | null
24}
25
26// 모든 세션이 ~/.claude/mod-sessions/<id>.json 에 쓰는 상태
27export type SessionStatus = TreeSession & {
28  root: string
29  project: string
30  branch: string | null
31  prompt: string | null
32  todos: TodoSummary | null
33  attention?: Attention | null
34  // /resume 목록에 보이는 이름. /rename 으로 지은 이름이나 자동 제목
35  title?: string | null
36  // 마지막 요청을 받은 시각
37  workingSince?: number
38  // 백그라운드로 띄우고 아직 끝났다는 알림을 받지 못한 작업
39  background?: BackgroundTask[]
40  // 턴은 끝났고 백그라운드 작업만 기다리는 중. heartbeat 가 작업이 끝났는지 다시 본다
41  waitingOnTasks?: boolean
42  // 이 세션 브랜치의 MR 상태(opened, merged, closed). 포켓몬 알이 금 가고 부화하는 기준이다
43  mrState?: string | null
44  // mrState 를 조회한 브랜치. 지금 브랜치와 다르면 mrState 는 모르는 것으로 친다. 없으면 이 필드가 생기기 전의 상태 파일이다
45  mrBranch?: string | null
46  // /mod-hud dashboard 로 대시보드라고 표시한 세션. 포켓몬 알이 없다
47  isDashboard?: boolean
48  // iTerm2 Claude Code 연동의 Code Review 탭이 띄운 리뷰 세션. 목록과 포켓몬 알에서 뺀다
49  isCodeReview?: boolean
50  // 세션이 끝난 이유(prompt_input_exit, clear, resume 등)
51  endReason?: string
52  // /clear 나 /resume 로 이 세션 직전에 같은 창에서 끝난 세션 id. 그 세션의 알을 물려받는다
53  predecessor?: string
54}
55
56// 백그라운드 Bash 의 backgroundTaskId 나 백그라운드 Agent 의 agentId 와 띄운 시각
57export type BackgroundTask = { id: string; at: number }
58
59export type Liveness = 'working' | 'waiting' | 'stale' | 'ended'
60
61// 패널 한 줄에 보일 세션
62export type BoardEntry = {
63  id: string
64  label: string
65  // /resume 목록에서 찾을 수 있는 이름
66  title: string | null
67  branch: string | null
68  prompt: string | null
69  todos: TodoSummary | null
70  check?: Check
71  // 내 차례면 기다리기 시작한 시각, 진행 중이면 요청을 받은 시각
72  since: number
73  attention: Attention | null
74}
75
76// 세션을 내가 할 일 기준으로 나눈 것. 이 세션은 넣지 않는다
77export type Board = {
78  mine: BoardEntry[]
79  running: BoardEntry[]
80  // 요청 없이 열려만 있는 세션. since 는 열린 시각
81  idle: BoardEntry[]
82  hidden: { stale: number; ended: number }
83}
84
85export type SessionsMeta = { refreshedAt: number | null; ownId: string | null }
86
87// action 은 열린 MR 에서 내가 움직여야 하는 것. 그 밖의 상태는 null
88export type Mr = { iid: number; state: string; sha: string; url: string; action: 'mergeable' | 'conflict' | null }
89
90export type Tree = { path: string; head: string; branch: string | null; isMain: boolean }
91
92export type Row = Tree & {
93  issue: string | null
94  ahead: number
95  dirty: number
96  session: TreeSession | null
97  mr: Mr | null
98  linear: string | null
99  note: string | null
100}
101
102export type IssuesMeta = { refreshedAt: number | null; isRefreshing: boolean; log: string[] }
103
104// 같은 저장소의 세션이 같이 쓰는 ~/.claude/ 아래 파일. 한 세션만 git, GitLab, Linear 를 부르고 나머지는 이 파일을 읽는다
105export type IssuesCache = {
106  fetchedAt: number | null
107  rows: Row[]
108  // 살아 있는 세션이 일한 브랜치의 MR. 세션 줄의 MR 링크와 머지 여부에 쓴다
109  branchMrs: Record<string, Mr>
110  // 이번 조회에서 MR 을 찾아본 브랜치. branchMrs 에 없으면 MR 이 없는 것이다. 여기 없는 브랜치는 아직 모른다
111  checked: string[]
112  // 최근 정리 기록
113  log: string[]
114  lease: { by: string; at: number } | null
115}
116
117// Linear 알림 한 건. get_notifications 응답에서 쓰는 필드만
118export type InboxItem = {
119  id: string
120  type: string
121  category: string | null
122  title: string
123  subtitle: string | null
124  url: string | null
125  issue: string | null
126  createdAt: number
127}
128
129// 모든 세션이 같이 쓰는 ~/.claude/mod-inbox.json. 한 세션만 Linear 를 부르고 나머지는 이 파일을 읽는다
130export type InboxCache = {
131  fetchedAt: number | null
132  // 마지막으로 조회에 성공한 시각. fetchedAt 과 다르면 마지막 조회가 실패한 것
133  okAt: number | null
134  notifications: InboxItem[]
135  // 이미 토스트했거나 첫 조회 때 있던 알림 id
136  seenIds: string[]
137  // 지금 조회를 맡은 세션
138  lease: { by: string; at: number } | null
139}
140
141export type Inbox = { items: InboxItem[]; fetchedAt: number | null; isOk: boolean }
142
143// 세션 정보를 붙인 포켓몬 알. key 는 알 이름, species 는 1세대 도감 번호
144export type DexEgg = { key: string; species: number; root: string; branch: string | null; label: string; at: number }
145
146// 키우미집에 있거나 세션이 든 알
147export type HeldEgg = { key: string; species: number }
148
149// 박스로 간 포켓몬
150export type BoxEntry = DexEgg
151
152// 모든 저장소와 세션이 같이 쓰는 ~/.claude/mod-hud-pokedex.json. 살아 있는 세션 중 id 가 가장 작은 세션만 쓴다
153export type Pokedex = {
154  // 마지막으로 키우미집을 채운 로컬 날짜 YYYY-MM-DD. 빈 문자열이면 아직 안 채웠다
155  day: string
156  // 알 key 와 그 알의 MR 이 처음 머지된 걸 본 시각
157  hatched: Record<string, number>
158  // 세션은 닫혔지만 MR 이 열려 있어 머지를 기다리는 알
159  pending: Array<DexEgg & { mrState: string }>
160  // 진행 중인 세션이 받아 갈 알. 매일 새로 채우고, MR 없이 닫힌 세션의 알도 돌아온다
161  daycare: HeldEgg[]
162  // 최근에 잡은 것이 앞
163  box: BoxEntry[]
164  // 세션 id 와 그 세션이 든 알. 세션이 닫혀도 남겨 두고 잠자기에서 돌아올 때 되찾는 데 쓴다
165  holds: Record<string, HeldEgg>
166  // 이미 정리한 닫힌 세션 id
167  done: string[]
168  // 세션 id 와 머지돼도 알이 깨지지 않을 브랜치. 박스로 보낸 브랜치나 알을 받을 때 이미 머지된 브랜치다
169  sent: Record<string, string | null>
170  // 앞장선 포켓몬이 먹은 경험치(일한 시간 ms). 막대는 하나라 앞장이 바뀌어도 남는다
171  xp: number
172  // 경험치를 마지막으로 더한 시각. 0 이면 아직 안 더했다
173  xpAt: number
174  // 앞장선 박스 포켓몬의 key. chosen 이면 사용자가 고른 것
175  lead: string | null
176  chosen: boolean
177  // 지금까지 박스에 들어온 종. 진화해서 사라진 종도 남는다
178  seen: number[]
179  // 이미 받은 전설
180  legends: number[]
181  // 가장 최근 진화. 모든 세션의 패널이 깜빡임을 그리는 데 쓴다
182  evolving: { key: string; from: number; at: number } | null
183  // 지금 나타나 있는 야생 전설. 몬스터볼을 던지면 잡히고, Run 하거나 한 시간이 지나면 달아난다
184  encounter: { species: number; at: number; root: string; branch: string | null; label: string } | null
185  // 가장 최근 전설 장면. appear 는 나타나는 장면, catch 는 잡는 장면. 모든 세션의 패널이 겹쳐 띄우는 데 쓴다
186  scene: { kind: 'appear' | 'catch'; species: number; at: number } | null
187  // 방금 달아난 전설. 사용자가 닫을 때까지 안내가 남는다
188  fled: { species: number } | null
189  // 달아난 전설. 새는 그 타입 최종 진화형을 다시 만들 때, 뮤츠와 뮤는 새 포켓몬이 박스에 들어올 때 다시 나타난다
190  escaped: number[]
191  // 다른 전설이 나타나 있어 차례를 기다리는 전설. 앞의 전설을 잡거나 놓치고 장면이 끝나면 이어서 나타난다
192  waiting: number[]
193}
194
195// 패널에 그릴 알 하나
196export type NestEgg = {
197  key: string
198  label: string
199  species: number
200  // 알을 못 받은 세션은 empty, 빈 둥지만 보인다
201  stage: 'empty' | 'egg' | 'cracked' | 'hatched'
202  isShaking: boolean
203  // 세션이 나를 기다리면 mine, 일하는 중이면 working. 라벨 색을 패널의 내 차례, 진행 중과 같게 칠한다
204  activity: 'mine' | 'working' | null
205  // 처음 머지된 걸 본 시각. 이 뒤 잠깐 동안 높이 뛴다
206  hatchedAt: number | null
207}
208
209declare module 'claude-code' {
210  interface PluginState {
211    'mod-hud': {
212      rows: Row[]
213      branchMrs: Record<string, Mr>
214      issuesMeta: IssuesMeta
215      board: Board
216      sessionsMeta: SessionsMeta
217      inbox: Inbox
218      pokedex: Pokedex
219      eggs: NestEgg[]
220    }
221  }
222}
223