Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions skills/call/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> <EV>
```

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: **[닉네임 또는 이름]**[은/는] [반응]
Expand Down
150 changes: 104 additions & 46 deletions src/cli/tokenmon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ 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 { 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';
Expand Down Expand Up @@ -222,15 +224,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);
Expand Down Expand Up @@ -570,16 +564,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;
}
Expand Down Expand Up @@ -754,6 +738,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 };
});
Expand All @@ -762,6 +747,97 @@ 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, 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;
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'));
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
const inner = getEmotionInner(ev);
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] : '';
// 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 {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

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<void> {
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, isShinyKey(resolvedId));
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(() => {
Expand Down Expand Up @@ -845,6 +921,7 @@ function doReset(): void {
legendary_pool: [], legendary_pending: [], titles: [],
completed_chains: [],
star_dismissed: false,
last_called: null,
};
writeState(defaultState);
});
Expand Down Expand Up @@ -909,13 +986,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}`);
Expand Down Expand Up @@ -998,11 +1069,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) }));
Expand Down Expand Up @@ -1331,11 +1398,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] };
Expand Down Expand Up @@ -1720,8 +1783,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)) {
Expand Down Expand Up @@ -1914,7 +1975,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('');
Expand Down Expand Up @@ -2023,17 +2083,15 @@ switch (command) {
case 'call':
cmdCall(args[1] ?? '');
break;
case 'sprite':
await cmdSprite(args[1] ?? '', Math.max(0, parseInt(args[2] ?? '0', 10) || 0));
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;
Expand Down
11 changes: 11 additions & 0 deletions src/core/emotion.ts
Original file line number Diff line number Diff line change
@@ -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! ';
}
5 changes: 3 additions & 2 deletions src/core/evolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 39 additions & 11 deletions src/status-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -517,6 +518,16 @@ 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 inner = getEmotionInner(lastCalled!.ev);
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);
Expand Down Expand Up @@ -562,24 +573,41 @@ function main(): void {
if (!isBlankLine(s[r])) { firstRow = Math.min(firstRow, r); lastRow = Math.max(lastRow, r); }
}
}
// Collect padded bubble lines for this group
let paddedBubbleLines: string[] = [];
let calledGroupIdx = -1;
if (callBubbleActive) {
calledGroupIdx = Array.from({ length: group.length }, (_, i) => gi + i)
.findIndex(pi => pokeData[pi]?.speciesId === lastCalled?.pokemon);
if (calledGroupIdx >= 0) {
const BUBBLE_WIDTH = 8;
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);
}
}

Expand Down
Loading