From 6c48dfbe396e6b30d66a4fdc32a3523d3e8ea0b2 Mon Sep 17 00:00:00 2001 From: eulneul Date: Sun, 19 Apr 2026 20:52:54 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20call-sprite=20=E2=80=94=20emotion?= =?UTF-8?q?=20bubble=20in=20status=20bar=20+=20sprite=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the Pokémon's braille sprite with an ASCII speech bubble to the right, showing its emotional state based on EV bond level. Plays a bounce animation when stdout is a real TTY (not piped). After `tkm call`, State.last_called records { pokemon, ev, ts }. status-line.ts reads this and renders the speech bubble next to the called Pokémon's sprite for 15 seconds, then auto-clears. | EV | Bubble | Feeling | |---------|--------|-----------------| | 0 | ? | Cold / confused | | 1–50 | ... | Shy / hesitant | | 51–120 | :) | Warming up | | 121–200 | <3 | Happy / fond | | 201–252 | <3! | Deep bond | - src/core/types.ts: add `last_called` field to State - src/cli/tokenmon.ts: add cmdSprite(), case 'sprite':, write last_called in cmdCall() - src/status-line.ts: read last_called, render bubble next to sprite - skills/call/SKILL.md: Step 3 updated to use sprite command Co-Authored-By: Claude Sonnet 4.6 --- skills/call/SKILL.md | 10 ++- src/cli/tokenmon.ts | 149 ++++++++++++++++++++++++++++++------------- src/core/types.ts | 1 + src/status-line.ts | 28 +++++++- 4 files changed, 138 insertions(+), 50 deletions(-) diff --git a/skills/call/SKILL.md b/skills/call/SKILL.md index 580cf02e..63a00f60 100644 --- a/skills/call/SKILL.md +++ b/skills/call/SKILL.md @@ -27,12 +27,18 @@ The command outputs JSON: `{"ev": N, "call_count": N, "evGained": true/false}` If `evGained` is true, append a subtle note after the reaction: > *(사이가 조금 가까워진 것 같다... EV +1)* -## Step 3: Show sprite +## Step 3: Show animated sprite with emotion bubble + +Run with the Pokémon's ID and the EV value returned from Step 2: ```bash -"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh" "${CLAUDE_PLUGIN_ROOT}/src/cli/tokenmon.ts" pokedex <포켓몬_ID> +"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh" "${CLAUDE_PLUGIN_ROOT}/src/cli/tokenmon.ts" sprite <포켓몬_ID> ``` +This renders the Pokémon's braille sprite with: +- A speech bubble showing its emotional state (? / ... / :) / <3 / <3!) based on EV +- A brief bounce animation (sprite hops up and down when called) + ## Step 4: React based on EV Format: **[닉네임 또는 이름]**[은/는] [반응] diff --git a/src/cli/tokenmon.ts b/src/cli/tokenmon.ts index 8e6b2eda..2d16e9d2 100644 --- a/src/cli/tokenmon.ts +++ b/src/cli/tokenmon.ts @@ -18,10 +18,10 @@ import { getEventsDB, getRegionsDB, getPokedexRewardsDB } from '../core/pokemon- import { getTypeMasterProgress } from '../core/pokedex-rewards.js'; import { t, initLocale, getLocale } from '../i18n/index.js'; import { withLock, withLockRetry } from '../core/lock.js'; -import { getActiveGeneration, setActiveGenerationCache, clearActiveGenerationCache, PLUGIN_ROOT, GLOBAL_CONFIG_PATH, DATA_DIR } from '../core/paths.js'; +import { getActiveGeneration, setActiveGenerationCache, clearActiveGenerationCache, PLUGIN_ROOT, GLOBAL_CONFIG_PATH, DATA_DIR, SPRITES_BRAILLE_DIR, SPRITES_TERMINAL_DIR } from '../core/paths.js'; import { execSync } from 'node:child_process'; import { detectRenderer } from '../core/detect-renderer.js'; -import { isShinyKey, toBaseId, toShinyKey } from '../core/shiny-utils.js'; +import { isShinyKey, toBaseId } from '../core/shiny-utils.js'; import { readWeatherCache, WEATHER_LABELS, refreshWeatherIfStale, type WeatherCondition } from '../core/weather.js'; import type { ExpGroup, EvolutionContext } from '../core/types.js'; @@ -222,15 +222,7 @@ function cmdStarter(choiceArg?: string): void { if (!freshState.pokemon[chosen]) { const starterLevel = 5; const expGroup: ExpGroup = pData?.exp_group ?? 'medium_fast'; - freshState.pokemon[chosen] = { - id: pData?.id ?? 0, - xp: levelToXp(starterLevel, expGroup), - level: starterLevel, - friendship: 0, - ev: 0, - met: 'starter', - met_detail: { region: freshConfig.current_region, met_level: starterLevel, met_date: new Date().toISOString().split('T')[0] }, - }; + freshState.pokemon[chosen] = { id: pData?.id ?? 0, xp: levelToXp(starterLevel, expGroup), level: starterLevel, friendship: 0, ev: 0 }; } if (!freshState.unlocked.includes(chosen)) { freshState.unlocked.push(chosen); @@ -570,16 +562,6 @@ function cmdPokedex(): void { if (state.pokemon[pokemonName]) { const ps = state.pokemon[pokemonName]; console.log(` ${t('cli.pokedex.detail_current_level', { level: ps.level, xp: formatNumber(ps.xp) })}`); - if (ps.met) { - const metText = formatMetInfo(ps.met, ps.met_detail); - if (metText) { - console.log(''); - console.log(` ${t('cli.pokedex.trainer_memo')}`); - for (const line of metText.split('\n')) { - console.log(` ${line}`); - } - } - } } return; } @@ -754,6 +736,7 @@ function cmdCall(nameOrId: string): void { p.call_count = 0; evGained = p.ev > prevEv; } + s.last_called = { pokemon: id, ev: p.ev ?? 0, ts: Date.now() }; writeState(s); return { ev: p.ev, call_count: p.call_count, evGained }; }); @@ -762,6 +745,99 @@ function cmdCall(nameOrId: string): void { console.log(JSON.stringify(result.value)); } +// ── call-sprite: animated sprite with emotion bubble ────────────────────────── + +const SPRITE_H = 10; +const SPRITE_W = 20; + +function loadSpriteForCall(pokemonId: number): string[] { + const brailleFile = join(SPRITES_BRAILLE_DIR, `${pokemonId}.txt`); + const termFile = join(SPRITES_TERMINAL_DIR, `${pokemonId}.txt`); + const file = existsSync(brailleFile) ? brailleFile : existsSync(termFile) ? termFile : null; + if (!file) return Array(SPRITE_H).fill(''); + const raw = readFileSync(file, 'utf-8').split('\n'); + if (raw.length > 0 && raw[raw.length - 1] === '') raw.pop(); + const lines = raw.map((l: string) => l.replace(/ /g, '\u2800')); + while (lines.length < SPRITE_H) lines.push(''); + return lines.slice(0, SPRITE_H); +} + +function emotionBubble(ev: number): string[] { + // Each bubble line is 8 visible cols: ╭──────╮ + // Middle: │ + space + 5-char content + │ = 8 + let inner: string; + if (ev <= 0) inner = ' ? '; + else if (ev <= 50) inner = '... '; + else if (ev <= 120) inner = ':) '; + else if (ev <= 200) inner = '<3 '; + else inner = '<3! '; + return [ + `╭──────╮`, + `│ ${inner}│`, + `╰───╮──╯`, + ` │ `, + ]; +} + +function spriteFrameLines(lines: string[], bubble: string[], offset: number): string[] { + const result: string[] = []; + for (let i = 0; i < SPRITE_H; i++) { + const src = i + offset; + const spriteLine = (src >= 0 && src < lines.length) ? lines[src] : ''; + const vLen = spriteLine.replace(/\x1b\[[^m]*m/g, '').length; + const padded = spriteLine + (vLen < SPRITE_W ? '\u2800'.repeat(SPRITE_W - vLen) : ''); + const bubbleLine = (src >= 0 && src < bubble.length) ? bubble[src] : ''; + result.push(padded.replace(/\u2800/g, ' ') + (bubbleLine ? ' ' + bubbleLine : '')); + } + return result; +} + +function sleepSync(ms: number): void { + const end = Date.now() + ms; + while (Date.now() < end) { /* busy-wait */ } +} + +function printFrame(frameLines: string[]): void { + for (const line of frameLines) process.stdout.write(line + '\n'); +} + +function eraseFrame(n: number): void { + for (let i = 0; i < n; i++) process.stdout.write('\x1b[1A\x1b[2K'); +} + +async function cmdSprite(nameOrId: string, ev: number): Promise { + const state = readState(); + const pokemonDB = getPokemonDB(); + const resolvedId = resolveNameToId(nameOrId, state) ?? nameOrId; + const baseId = toBaseId(resolvedId); + const pData = pokemonDB.pokemon[baseId]; + if (!pData) return; + + const spriteLines = loadSpriteForCall(pData.id); + const bubble = emotionBubble(ev); + + const normalFrame = spriteFrameLines(spriteLines, bubble, 0); + + // Animate only on real TTY — not when captured by Claude Code or piped + if (process.stdout.isTTY) { + const bouncedFrame = spriteFrameLines(spriteLines, bubble, 1); + printFrame(normalFrame); + sleepSync(100); + eraseFrame(SPRITE_H); + printFrame(bouncedFrame); + sleepSync(140); + eraseFrame(SPRITE_H); + printFrame(normalFrame); + sleepSync(90); + eraseFrame(SPRITE_H); + printFrame(bouncedFrame); + sleepSync(120); + eraseFrame(SPRITE_H); + } + + printFrame(normalFrame); +} + function cmdNickname(nameOrId: string, nickname?: string): void { // Resolve ID and mutate inside the same lock to prevent nickname race const result = withLockRetry(() => { @@ -909,13 +985,7 @@ function cmdCheat(subcmd: string, arg1?: string, arg2?: string): void { case 'unlock': { const pData = pokemonDB.pokemon[arg1!]; if (!state.unlocked.includes(arg1!)) state.unlocked.push(arg1!); - if (!state.pokemon[arg1!]) { - state.pokemon[arg1!] = { - id: pData.id, xp: 0, level: 1, friendship: 0, ev: 0, - met: 'unknown', - met_detail: { met_level: 1, met_date: new Date().toISOString().split('T')[0] }, - }; - } + if (!state.pokemon[arg1!]) state.pokemon[arg1!] = { id: pData.id, xp: 0, level: 1, friendship: 0, ev: 0 }; if (!state.pokedex[arg1!]) state.pokedex[arg1!] = { seen: true, caught: true, first_seen: new Date().toISOString().split('T')[0] }; else { state.pokedex[arg1!].seen = true; state.pokedex[arg1!].caught = true; } logCheat(`unlock ${arg1}`); @@ -998,11 +1068,7 @@ function cmdEvolve(pokemonArg?: string, targetArg?: string): void { items: state.items ?? {}, }; const branches = getEligibleBranches(pokemonArg, ctx); - // UX-only: hide branches whose evolved form is already in unlocked (safety guards are in checkEvolution/applyBranchEvolution) - const eligible = branches.filter(b => { - const evolvedKey = isShinyKey(pokemonArg) ? toShinyKey(b.name) : b.name; - return b.conditionMet && !state.unlocked.includes(evolvedKey); - }); + const eligible = branches.filter(b => b.conditionMet); if (eligible.length === 0) { warn(t('cli.evolve.no_eligible', { pokemon: getPokemonName(pokemonArg) })); @@ -1331,11 +1397,7 @@ function cmdLegendary(action?: string): void { const pokemonDB = getPokemonDB(); const pData = pokemonDB.pokemon[chosen]; if (pData && !s.pokemon[chosen]) { - s.pokemon[chosen] = { - id: pData.id, xp: 0, level: 50, friendship: 0, ev: 0, - met: 'fateful_encounter', - met_detail: { met_level: 50, met_date: new Date().toISOString().split('T')[0], from: pending.group }, - }; + s.pokemon[chosen] = { id: pData.id, xp: 0, level: 50, friendship: 0, ev: 0 }; } if (!s.pokedex[chosen]) { s.pokedex[chosen] = { seen: true, caught: true, first_seen: new Date().toISOString().split('T')[0] }; @@ -1720,8 +1782,6 @@ function cmdSetup(args: string[]): void { level: starterLevel, friendship: 0, ev: 0, - met: 'starter', - met_detail: { region: freshConfig.current_region, met_level: starterLevel, met_date: new Date().toISOString().split('T')[0] }, }; } if (!freshState.unlocked.includes(starterKey)) { @@ -1914,7 +1974,6 @@ function cmdHelp(): void { console.log(t('cli.help.cmd_uninstall')); console.log(t('cli.help.cmd_uninstall_keep')); console.log(t('cli.help.cmd_reset')); - console.log(t('cli.help.cmd_friendly_battle')); console.log(t('cli.help.cmd_cheat')); console.log(t('cli.help.cmd_help')); console.log(''); @@ -2023,17 +2082,15 @@ switch (command) { case 'call': cmdCall(args[1] ?? ''); break; + case 'sprite': + await cmdSprite(args[1] ?? '', parseInt(args[2] ?? '0', 10)); + break; case 'nickname': cmdNickname(args[1] ?? '', args.slice(2).join(' ') || undefined); break; case 'reset': cmdReset(args.includes('--confirm')); break; - case 'friendly-battle': { - const { runFriendlyBattleCli } = await import('./friendly-battle.js'); - await runFriendlyBattleCli(args.slice(1)); - break; - } case 'cheat': cmdCheat(args[1], args[2], args[3]); break; diff --git a/src/core/types.ts b/src/core/types.ts index 601acf24..d044bf50 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -257,6 +257,7 @@ export interface State { gym_badges?: string[]; rare_weight_multiplier?: number; last_codex_xp?: number | null; + last_called?: { pokemon: string; ev: number; ts: number } | null; } export interface Config { diff --git a/src/status-line.ts b/src/status-line.ts index 97916ccf..d9da8095 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -517,6 +517,22 @@ function main(): void { }); } + // === Call bubble: appears next to sprite for 15s after tkm call === + const lastCalled = state.last_called; + const CALL_BUBBLE_TTL = 15_000; + const callBubbleActive = !!(lastCalled && Date.now() - lastCalled.ts < CALL_BUBBLE_TTL); + const callBubbleLines: string[] = []; + if (callBubbleActive) { + const ev = lastCalled!.ev; + let inner: string; + if (ev <= 0) inner = ' ? '; + else if (ev <= 50) inner = '... '; + else if (ev <= 120) inner = ':) '; + else if (ev <= 200) inner = '<3 '; + else inner = '<3! '; + callBubbleLines.push('╭──────╮', `│ ${inner}│`, '╰───╮──╯', ' │ '); + } + // === Sprite rendering (responsive 4-tier layout) === // Use printWidth for tier selection so sprites fit in the same budget as text const tier = determineTier(printWidth, pokeData.length, spriteMode); @@ -563,10 +579,18 @@ function main(): void { } } for (let row = firstRow; row <= lastRow; row++) { - let rowStr = group.map(s => { + const bubbleRow = row - firstRow; + let rowStr = group.map((s, gidx) => { const line = s[row] ?? ''; const visibleLen = line.replace(/\x1b\[[^m]*m/g, '').length; - return visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; + const padded = visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; + // Attach bubble to the right of the called pokemon's sprite + const partyIdx = gi + gidx; + const isCalledSprite = callBubbleActive && pokeData[partyIdx]?.speciesId === lastCalled?.pokemon; + const bubbleSuffix = isCalledSprite && bubbleRow < callBubbleLines.length + ? '\u2800' + callBubbleLines[bubbleRow] + : ''; + return padded + bubbleSuffix; }).join('\u2800'); if (weatherCondition) { rowStr = scatterWeatherParticles(rowStr, weatherCondition); From 3d56e772dd5946041cea7111834b4e1efb76de7b Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 14:23:39 +0900 Subject: [PATCH 02/10] fix: move call bubble above sprite to avoid layout corruption Bubble was previously appended as a suffix to the right of the sprite, shifting sibling sprites rightward and breaking row alignment. Now rendered as separate lines above the called pokemon's column, centered over its sprite position. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index d9da8095..6f5ca34f 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -578,19 +578,24 @@ function main(): void { if (!isBlankLine(s[r])) { firstRow = Math.min(firstRow, r); lastRow = Math.max(lastRow, r); } } } + // === Call bubble: print above the called pokemon's column === + if (callBubbleActive) { + const calledIdx = Array.from({ length: group.length }, (_, i) => gi + i) + .findIndex(pi => pokeData[pi]?.speciesId === lastCalled?.pokemon); + if (calledIdx >= 0) { + const BUBBLE_WIDTH = 8; + const bubbleLeftPad = calledIdx * SPRITE_COL_WIDTH + Math.floor((SPRITE_WIDTH - BUBBLE_WIDTH) / 2); + for (const bubbleLine of callBubbleLines) { + console.log(' '.repeat(bubbleLeftPad) + bubbleLine); + } + } + } + for (let row = firstRow; row <= lastRow; row++) { - const bubbleRow = row - firstRow; - let rowStr = group.map((s, gidx) => { + let rowStr = group.map(s => { const line = s[row] ?? ''; const visibleLen = line.replace(/\x1b\[[^m]*m/g, '').length; - const padded = visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; - // Attach bubble to the right of the called pokemon's sprite - const partyIdx = gi + gidx; - const isCalledSprite = callBubbleActive && pokeData[partyIdx]?.speciesId === lastCalled?.pokemon; - const bubbleSuffix = isCalledSprite && bubbleRow < callBubbleLines.length - ? '\u2800' + callBubbleLines[bubbleRow] - : ''; - return padded + bubbleSuffix; + return visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; }).join('\u2800'); if (weatherCondition) { rowStr = scatterWeatherParticles(rowStr, weatherCondition); From b955a74007685a072539813e9af790681e0cb8a8 Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 14:28:06 +0900 Subject: [PATCH 03/10] feat: bounce animation in status bar on tkm call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rendering the sprite with call bubble, plays a brief bounce animation (sprite shifts up 1 row × 2 cycles) on TTY output. Only fires when a pokemon was called within the last 15s. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 58 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index 6f5ca34f..83f1d382 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -578,36 +578,58 @@ function main(): void { if (!isBlankLine(s[r])) { firstRow = Math.min(firstRow, r); lastRow = Math.max(lastRow, r); } } } - // === Call bubble: print above the called pokemon's column === + // Collect padded bubble lines for this group + let paddedBubbleLines: string[] = []; + let calledGroupIdx = -1; if (callBubbleActive) { - const calledIdx = Array.from({ length: group.length }, (_, i) => gi + i) + calledGroupIdx = Array.from({ length: group.length }, (_, i) => gi + i) .findIndex(pi => pokeData[pi]?.speciesId === lastCalled?.pokemon); - if (calledIdx >= 0) { + if (calledGroupIdx >= 0) { const BUBBLE_WIDTH = 8; - const bubbleLeftPad = calledIdx * SPRITE_COL_WIDTH + Math.floor((SPRITE_WIDTH - BUBBLE_WIDTH) / 2); - for (const bubbleLine of callBubbleLines) { - console.log(' '.repeat(bubbleLeftPad) + bubbleLine); - } + const bubbleLeftPad = calledGroupIdx * SPRITE_COL_WIDTH + Math.floor((SPRITE_WIDTH - BUBBLE_WIDTH) / 2); + paddedBubbleLines = callBubbleLines.map(bl => ' '.repeat(bubbleLeftPad) + bl); } } + // Collect sprite rows + // Keep \u2800 (braille blank) in output instead of converting to ASCII space. + // In some CJK terminals, non-zero braille (sprite art) and \u2800 are both + // rendered at the same width while ASCII space is narrower — mixing them + // causes per-row width variance proportional to sprite opacity, which is + // invisible without weather but blatant once particles land on random cells. + // Keeping every transparent position as \u2800 guarantees uniform row width + // regardless of the terminal's actual braille glyph width. + const renderedSpriteRows: string[] = []; for (let row = firstRow; row <= lastRow; row++) { let rowStr = group.map(s => { const line = s[row] ?? ''; const visibleLen = line.replace(/\x1b\[[^m]*m/g, '').length; return visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; }).join('\u2800'); - if (weatherCondition) { - rowStr = scatterWeatherParticles(rowStr, weatherCondition); - } - // Keep \u2800 (braille blank) in output instead of converting to ASCII space. - // In some CJK terminals, non-zero braille (sprite art) and \u2800 are both - // rendered at the same width while ASCII space is narrower — mixing them - // causes per-row width variance proportional to sprite opacity, which is - // invisible without weather but blatant once particles land on random cells. - // Keeping every transparent position as \u2800 guarantees uniform row width - // regardless of the terminal's actual braille glyph width. - console.log(rowStr); + if (weatherCondition) rowStr = scatterWeatherParticles(rowStr, weatherCondition); + renderedSpriteRows.push(rowStr); + } + + // Print bubble + sprite + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); + + // === Bounce animation (only when a pokemon was just called, TTY only) === + if (calledGroupIdx >= 0 && process.stdout.isTTY) { + const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; + const eraseLines = (n: number) => { for (let i = 0; i < n; i++) process.stdout.write('\x1b[1A\x1b[2K'); }; + const printLines = (lines: string[]) => { for (const l of lines) process.stdout.write(l + '\n'); }; + const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; + const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; + const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; + sleepMs(80); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(140); + eraseLines(totalLines); printLines(normalFrame); + sleepMs(90); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(120); + eraseLines(totalLines); printLines(normalFrame); } } } From 824dbcd000d74645cb7f6c962d4bdf4f4ca445e4 Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 14:29:38 +0900 Subject: [PATCH 04/10] fix: use /dev/tty for bounce animation in stop hook context process.stdout.isTTY is false when Claude Code pipes hook output, so animation never ran. Writing directly to /dev/tty bypasses the pipe and reaches the terminal unconditionally. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index 83f1d382..e2c26f12 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readlinkSync } from 'fs'; +import { existsSync, readFileSync, readlinkSync, openSync, writeSync } from 'fs'; import { execSync } from 'child_process'; import { join } from 'path'; import { readState, readSession } from './core/state.js'; @@ -614,22 +614,28 @@ function main(): void { for (const bl of paddedBubbleLines) console.log(bl); for (const sl of renderedSpriteRows) console.log(sl); - // === Bounce animation (only when a pokemon was just called, TTY only) === - if (calledGroupIdx >= 0 && process.stdout.isTTY) { - const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; - const eraseLines = (n: number) => { for (let i = 0; i < n; i++) process.stdout.write('\x1b[1A\x1b[2K'); }; - const printLines = (lines: string[]) => { for (const l of lines) process.stdout.write(l + '\n'); }; - const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; - const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; - const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; - sleepMs(80); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(140); - eraseLines(totalLines); printLines(normalFrame); - sleepMs(90); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(120); - eraseLines(totalLines); printLines(normalFrame); + // === Bounce animation (only when a pokemon was just called) === + // Write directly to /dev/tty so animation works even when stdout is piped by Claude Code hooks + if (calledGroupIdx >= 0) { + let ttyFd: number | null = null; + try { ttyFd = openSync('/dev/tty', 'w'); } catch { /* no tty available */ } + if (ttyFd !== null) { + const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; + const w = (s: string) => writeSync(ttyFd!, s); + const eraseLines = (n: number) => { for (let i = 0; i < n; i++) w('\x1b[1A\x1b[2K'); }; + const printLines = (lines: string[]) => { for (const l of lines) w(l + '\n'); }; + const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; + const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; + const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; + sleepMs(80); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(140); + eraseLines(totalLines); printLines(normalFrame); + sleepMs(90); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(120); + eraseLines(totalLines); printLines(normalFrame); + } } } } From e0e6b0986429a1784ee1983f1739171ed8b80904 Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 14:49:15 +0900 Subject: [PATCH 05/10] fix: route initial frame through /dev/tty to prevent stdout flush corruption Previously the initial bubble+sprite was printed via console.log (stdout), then animation ran via /dev/tty. When stdout flushed asynchronously, frames appeared in the chat window. Now all output for animated groups goes through /dev/tty, keeping stdout and /dev/tty writes fully separate. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index e2c26f12..7613f323 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -610,12 +610,9 @@ function main(): void { renderedSpriteRows.push(rowStr); } - // Print bubble + sprite - for (const bl of paddedBubbleLines) console.log(bl); - for (const sl of renderedSpriteRows) console.log(sl); - - // === Bounce animation (only when a pokemon was just called) === - // Write directly to /dev/tty so animation works even when stdout is piped by Claude Code hooks + // === Print bubble + sprite, with bounce animation if called === + // When animating, route ALL output through /dev/tty to avoid stdout buffer flush + // racing with /dev/tty cursor movement and printing frames in the chat window. if (calledGroupIdx >= 0) { let ttyFd: number | null = null; try { ttyFd = openSync('/dev/tty', 'w'); } catch { /* no tty available */ } @@ -627,6 +624,8 @@ function main(): void { const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; + // Initial print via /dev/tty (not stdout) to keep cursor tracking consistent + printLines(normalFrame); sleepMs(80); eraseLines(totalLines); printLines(bouncedFrame); sleepMs(140); @@ -635,7 +634,13 @@ function main(): void { eraseLines(totalLines); printLines(bouncedFrame); sleepMs(120); eraseLines(totalLines); printLines(normalFrame); + } else { + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); } + } else { + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); } } } From 51510e6eeb32f17581453054360638dcdd75c32d Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 15:28:09 +0900 Subject: [PATCH 06/10] revert: remove bounce animation from status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor-movement animation in the stop hook races with Claude Code's TUI rendering — /dev/tty writes land at unpredictable cursor positions and corrupt the chat window. The bubble already conveys the call reaction; bounce animation remains available in the chat-window sprite command. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index 7613f323..112f01fb 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readlinkSync, openSync, writeSync } from 'fs'; +import { existsSync, readFileSync, readlinkSync } from 'fs'; import { execSync } from 'child_process'; import { join } from 'path'; import { readState, readSession } from './core/state.js'; @@ -610,38 +610,9 @@ function main(): void { renderedSpriteRows.push(rowStr); } - // === Print bubble + sprite, with bounce animation if called === - // When animating, route ALL output through /dev/tty to avoid stdout buffer flush - // racing with /dev/tty cursor movement and printing frames in the chat window. - if (calledGroupIdx >= 0) { - let ttyFd: number | null = null; - try { ttyFd = openSync('/dev/tty', 'w'); } catch { /* no tty available */ } - if (ttyFd !== null) { - const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; - const w = (s: string) => writeSync(ttyFd!, s); - const eraseLines = (n: number) => { for (let i = 0; i < n; i++) w('\x1b[1A\x1b[2K'); }; - const printLines = (lines: string[]) => { for (const l of lines) w(l + '\n'); }; - const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; - const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; - const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; - // Initial print via /dev/tty (not stdout) to keep cursor tracking consistent - printLines(normalFrame); - sleepMs(80); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(140); - eraseLines(totalLines); printLines(normalFrame); - sleepMs(90); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(120); - eraseLines(totalLines); printLines(normalFrame); - } else { - for (const bl of paddedBubbleLines) console.log(bl); - for (const sl of renderedSpriteRows) console.log(sl); - } - } else { - for (const bl of paddedBubbleLines) console.log(bl); - for (const sl of renderedSpriteRows) console.log(sl); - } + // Print bubble + sprite + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); } } From b793e877c7a5660231e74616665971a28fc10a1c Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 15:31:15 +0900 Subject: [PATCH 07/10] feat: bounce animation via writeSync(fd 1) in status bar Replaces /dev/tty approach with writeSync to stdout fd directly. writeSync bypasses Node's buffer so frames reach Claude Code's pipe reader immediately; sleepSync provides inter-frame timing. All animation output stays in the hook's stdout stream, avoiding cursor-position races with the Claude Code TUI. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index 112f01fb..0e641915 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readlinkSync } from 'fs'; +import { existsSync, readFileSync, readlinkSync, writeSync } from 'fs'; import { execSync } from 'child_process'; import { join } from 'path'; import { readState, readSession } from './core/state.js'; @@ -610,9 +610,30 @@ function main(): void { renderedSpriteRows.push(rowStr); } - // Print bubble + sprite - for (const bl of paddedBubbleLines) console.log(bl); - for (const sl of renderedSpriteRows) console.log(sl); + // Print bubble + sprite; animate via writeSync(fd 1) when called + // writeSync bypasses Node's stdout buffer so frames flush immediately, + // letting Claude Code stream them to the terminal with correct timing. + if (calledGroupIdx >= 0) { + const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; + const w = (s: string) => writeSync(1, s); + const eraseLines = (n: number) => { for (let i = 0; i < n; i++) w('\x1b[1A\x1b[2K'); }; + const printLines = (lines: string[]) => { for (const l of lines) w(l + '\n'); }; + const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; + const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; + const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; + printLines(normalFrame); + sleepMs(80); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(140); + eraseLines(totalLines); printLines(normalFrame); + sleepMs(90); + eraseLines(totalLines); printLines(bouncedFrame); + sleepMs(120); + eraseLines(totalLines); printLines(normalFrame); + } else { + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); + } } } From 695d53f4150c7f1fbd38647b5aedb252fb57d09a Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 15:35:41 +0900 Subject: [PATCH 08/10] revert: static bubble only in status bar, no animation Claude Code buffers hook stdout so animation timing is lost. Bubble renders statically above the called pokemon's sprite. Co-Authored-By: Claude Sonnet 4.6 --- src/status-line.ts | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/status-line.ts b/src/status-line.ts index 0e641915..112f01fb 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readlinkSync, writeSync } from 'fs'; +import { existsSync, readFileSync, readlinkSync } from 'fs'; import { execSync } from 'child_process'; import { join } from 'path'; import { readState, readSession } from './core/state.js'; @@ -610,30 +610,9 @@ function main(): void { renderedSpriteRows.push(rowStr); } - // Print bubble + sprite; animate via writeSync(fd 1) when called - // writeSync bypasses Node's stdout buffer so frames flush immediately, - // letting Claude Code stream them to the terminal with correct timing. - if (calledGroupIdx >= 0) { - const totalLines = paddedBubbleLines.length + renderedSpriteRows.length; - const w = (s: string) => writeSync(1, s); - const eraseLines = (n: number) => { for (let i = 0; i < n; i++) w('\x1b[1A\x1b[2K'); }; - const printLines = (lines: string[]) => { for (const l of lines) w(l + '\n'); }; - const sleepMs = (ms: number) => { const end = Date.now() + ms; while (Date.now() < end) {} }; - const normalFrame = [...paddedBubbleLines, ...renderedSpriteRows]; - const bouncedFrame = [...paddedBubbleLines, ...renderedSpriteRows.slice(1), '']; - printLines(normalFrame); - sleepMs(80); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(140); - eraseLines(totalLines); printLines(normalFrame); - sleepMs(90); - eraseLines(totalLines); printLines(bouncedFrame); - sleepMs(120); - eraseLines(totalLines); printLines(normalFrame); - } else { - for (const bl of paddedBubbleLines) console.log(bl); - for (const sl of renderedSpriteRows) console.log(sl); - } + // Print bubble + sprite + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); } } From b508264f7f4c4250cd2b740a136855380935b098 Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 15:40:15 +0900 Subject: [PATCH 09/10] fix: special evolution requires friendship>=200, not any level-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'special' condition was triggering on every level-up, causing 3-stage chains (e.g. Pawmi→Pawmo→Pawmot) to skip the middle stage: Pawmo would evolve to Pawmot on its very first level-up after evolving. Changed to require friendship>=200, matching the spirit of walk-based special evolutions and preventing the instant skip. Co-Authored-By: Claude Sonnet 4.6 --- src/core/evolution.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/evolution.ts b/src/core/evolution.ts index 9cef0e33..ee38a98f 100644 --- a/src/core/evolution.ts +++ b/src/core/evolution.ts @@ -226,8 +226,9 @@ function checkCondition(condition: string, context: EvolutionContext): boolean { return context.newLevel > context.oldLevel; } if (condition === 'special') { - // Generic special: trigger on level up as fallback - return context.newLevel > context.oldLevel; + // Special evolutions (e.g. walk-based) require high friendship instead of + // triggering on any level-up, which caused 3-stage chains to skip stage 1. + return context.friendship >= 200; } return false; } From 5d4513dfdb8ed402da8ca763d204f698f80ebeaa Mon Sep 17 00:00:00 2001 From: eulneul Date: Mon, 20 Apr 2026 17:18:00 +0900 Subject: [PATCH 10/10] refactor: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract getEmotionInner(ev) to src/core/emotion.ts, eliminating duplicate EV threshold logic between tokenmon.ts and status-line.ts - Replace sleepSync busy-wait with Atomics.wait() to avoid blocking Node event loop during sprite animation - Add shiny sprite support in loadSpriteForCall via shiftAnsiHue() - Add last_called: null to doReset() defaultState - Guard parseInt(args[2]) against NaN with || 0 fallback - Add comment explaining why CLI converts \u2800 → space while status-line.ts keeps \u2800 for CJK terminal width consistency --- src/cli/tokenmon.ts | 27 ++++++++++++++------------- src/core/emotion.ts | 11 +++++++++++ src/status-line.ts | 9 ++------- 3 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 src/core/emotion.ts diff --git a/src/cli/tokenmon.ts b/src/cli/tokenmon.ts index 2d16e9d2..95cd45cd 100644 --- a/src/cli/tokenmon.ts +++ b/src/cli/tokenmon.ts @@ -22,8 +22,10 @@ import { getActiveGeneration, setActiveGenerationCache, clearActiveGenerationCac import { execSync } from 'node:child_process'; import { detectRenderer } from '../core/detect-renderer.js'; import { isShinyKey, toBaseId } from '../core/shiny-utils.js'; +import { shiftAnsiHue } from '../sprites/shiny.js'; import { readWeatherCache, WEATHER_LABELS, refreshWeatherIfStale, type WeatherCondition } from '../core/weather.js'; import type { ExpGroup, EvolutionContext } from '../core/types.js'; +import { getEmotionInner } from '../core/emotion.js'; // ANSI color helpers const BOLD = '\x1b[1m'; @@ -750,7 +752,7 @@ function cmdCall(nameOrId: string): void { const SPRITE_H = 10; const SPRITE_W = 20; -function loadSpriteForCall(pokemonId: number): string[] { +function loadSpriteForCall(pokemonId: number, isShiny: boolean = false): string[] { const brailleFile = join(SPRITES_BRAILLE_DIR, `${pokemonId}.txt`); const termFile = join(SPRITES_TERMINAL_DIR, `${pokemonId}.txt`); const file = existsSync(brailleFile) ? brailleFile : existsSync(termFile) ? termFile : null; @@ -758,19 +760,15 @@ function loadSpriteForCall(pokemonId: number): string[] { const raw = readFileSync(file, 'utf-8').split('\n'); if (raw.length > 0 && raw[raw.length - 1] === '') raw.pop(); const lines = raw.map((l: string) => l.replace(/ /g, '\u2800')); - while (lines.length < SPRITE_H) lines.push(''); - return lines.slice(0, SPRITE_H); + const result = isShiny ? lines.map(l => shiftAnsiHue(l)) : lines; + while (result.length < SPRITE_H) result.push(''); + return result.slice(0, SPRITE_H); } function emotionBubble(ev: number): string[] { // Each bubble line is 8 visible cols: ╭──────╮ // Middle: │ + space + 5-char content + │ = 8 - let inner: string; - if (ev <= 0) inner = ' ? '; - else if (ev <= 50) inner = '... '; - else if (ev <= 120) inner = ':) '; - else if (ev <= 200) inner = '<3 '; - else inner = '<3! '; + const inner = getEmotionInner(ev); return [ `╭──────╮`, `│ ${inner}│`, @@ -787,14 +785,16 @@ function spriteFrameLines(lines: string[], bubble: string[], offset: number): st const vLen = spriteLine.replace(/\x1b\[[^m]*m/g, '').length; const padded = spriteLine + (vLen < SPRITE_W ? '\u2800'.repeat(SPRITE_W - vLen) : ''); const bubbleLine = (src >= 0 && src < bubble.length) ? bubble[src] : ''; + // CLI output converts \u2800 → ASCII space (unlike status-line.ts which keeps \u2800 + // for CJK terminal width consistency). Here we control the column via fixed SPRITE_W, + // so ASCII spaces are fine and avoid non-printable characters in terminal output. result.push(padded.replace(/\u2800/g, ' ') + (bubbleLine ? ' ' + bubbleLine : '')); } return result; } function sleepSync(ms: number): void { - const end = Date.now() + ms; - while (Date.now() < end) { /* busy-wait */ } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function printFrame(frameLines: string[]): void { @@ -813,7 +813,7 @@ async function cmdSprite(nameOrId: string, ev: number): Promise { const pData = pokemonDB.pokemon[baseId]; if (!pData) return; - const spriteLines = loadSpriteForCall(pData.id); + const spriteLines = loadSpriteForCall(pData.id, isShinyKey(resolvedId)); const bubble = emotionBubble(ev); const normalFrame = spriteFrameLines(spriteLines, bubble, 0); @@ -921,6 +921,7 @@ function doReset(): void { legendary_pool: [], legendary_pending: [], titles: [], completed_chains: [], star_dismissed: false, + last_called: null, }; writeState(defaultState); }); @@ -2083,7 +2084,7 @@ switch (command) { cmdCall(args[1] ?? ''); break; case 'sprite': - await cmdSprite(args[1] ?? '', parseInt(args[2] ?? '0', 10)); + await cmdSprite(args[1] ?? '', Math.max(0, parseInt(args[2] ?? '0', 10) || 0)); break; case 'nickname': cmdNickname(args[1] ?? '', args.slice(2).join(' ') || undefined); diff --git a/src/core/emotion.ts b/src/core/emotion.ts new file mode 100644 index 00000000..7c6074d8 --- /dev/null +++ b/src/core/emotion.ts @@ -0,0 +1,11 @@ +/** + * EV-based emotion string for call speech bubbles. + * Shared between status-line.ts (hook) and tokenmon.ts (CLI sprite command). + */ +export function getEmotionInner(ev: number): string { + if (ev <= 0) return ' ? '; + if (ev <= 50) return '... '; + if (ev <= 120) return ':) '; + if (ev <= 200) return '<3 '; + return '<3! '; +} diff --git a/src/status-line.ts b/src/status-line.ts index 112f01fb..4ad9722d 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -14,6 +14,7 @@ import { readWeatherCache, WEATHER_LABELS, type WeatherCondition } from './core/ import { ppBar } from './core/pp.js'; import type { ExpGroup, StdinData } from './core/types.js'; import { determineTier, SPRITE_WIDTH, SPRITE_COL_WIDTH } from './core/layout.js'; +import { getEmotionInner } from './core/emotion.js'; interface SignatureMove { move: string; @@ -523,13 +524,7 @@ function main(): void { const callBubbleActive = !!(lastCalled && Date.now() - lastCalled.ts < CALL_BUBBLE_TTL); const callBubbleLines: string[] = []; if (callBubbleActive) { - const ev = lastCalled!.ev; - let inner: string; - if (ev <= 0) inner = ' ? '; - else if (ev <= 50) inner = '... '; - else if (ev <= 120) inner = ':) '; - else if (ev <= 200) inner = '<3 '; - else inner = '<3! '; + const inner = getEmotionInner(lastCalled!.ev); callBubbleLines.push('╭──────╮', `│ ${inner}│`, '╰───╮──╯', ' │ '); }