feat: 第一版可用 pokemon

This commit is contained in:
claude-code-best
2026-04-21 19:03:31 +08:00
parent 956e98a445
commit 88ddba6c23
46 changed files with 4143 additions and 1317 deletions

View File

@@ -0,0 +1,9 @@
{
"name": "@claude-code-best/pokemon",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {}
}

View File

@@ -0,0 +1,124 @@
/**
* Script to pre-fetch all 10 MVP Pokémon sprites from GitHub.
* Run: bun run packages/pokemon/scripts/fetch-sprites.ts
*/
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
const COW_FILES: Record<string, string> = {
bulbasaur: '001_bulbasaur',
ivysaur: '002_ivysaur',
venusaur: '003_venusaur',
charmander: '004_charmander',
charmeleon: '005_charmeleon',
charizard: '006_charizard',
squirtle: '007_squirtle',
wartortle: '008_wartortle',
blastoise: '009_blastoise',
pikachu: '025_pikachu',
}
const SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
function convertCowToLines(cowContent: string): string[] {
const startMarker = '$the_cow =<<EOC;'
const endMarker = 'EOC'
const startIdx = cowContent.indexOf(startMarker)
if (startIdx === -1) return []
const contentStart = startIdx + startMarker.length
const endIdx = cowContent.indexOf(endMarker, contentStart)
if (endIdx === -1) return []
let content = cowContent.slice(contentStart, endIdx)
// Convert \N{U+XXXX} to actual Unicode characters
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
// Convert \e to actual escape character (for ANSI sequences)
content = content.replace(/\\e/g, '\x1b')
// Split into lines
let lines = content.split('\n')
// Strip leading/trailing empty lines
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
// Remove first 4 lines (cowsay thought bubble guide - $thoughts lines)
if (lines.length > 4) {
lines = lines.slice(4)
}
// Trim trailing whitespace on each line
lines = lines.map((line) => line.trimEnd())
return lines
}
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '')
}
async function main() {
// Ensure output directory
if (!existsSync(SPRITES_DIR)) {
mkdirSync(SPRITES_DIR, { recursive: true })
}
for (const [speciesId, cowPrefix] of Object.entries(COW_FILES)) {
const url = `${GITHUB_RAW_BASE}/${cowPrefix}.cow`
console.log(`Fetching ${speciesId} from ${url}...`)
try {
const response = await fetch(url)
if (!response.ok) {
console.error(` FAILED: HTTP ${response.status}`)
continue
}
const cowContent = await response.text()
const lines = convertCowToLines(cowContent)
if (lines.length === 0) {
console.error(` FAILED: No lines after conversion`)
continue
}
// Calculate visible width (strip ANSI for measurement)
const widths = lines.map((l) => stripAnsi(l).length)
const sprite = {
speciesId,
lines,
width: Math.max(...widths),
height: lines.length,
fetchedAt: Date.now(),
}
const outPath = join(SPRITES_DIR, `${speciesId}.json`)
writeFileSync(outPath, JSON.stringify(sprite, null, 2))
console.log(` OK: ${lines.length} lines, ${sprite.width} cols wide`)
// Also print first line for visual check
console.log(` Preview line 1: ${stripAnsi(lines[0]!)}`)
} catch (err) {
console.error(` FAILED: ${err}`)
}
// Small delay to be nice to GitHub
await new Promise((r) => setTimeout(r, 200))
}
console.log('\nDone! Sprites cached to ~/.claude/buddy-sprites/')
}
main().catch(console.error)

View File

@@ -0,0 +1,107 @@
import { describe, test, expect } from 'bun:test'
import type { SpeciesId, Creature } from '../types'
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel } from '../core/creature'
import { SPECIES_DATA } from '../data/species'
describe('generateCreature', () => {
test('creates a creature with correct defaults', () => {
const c = generateCreature('bulbasaur', 42)
expect(c.speciesId).toBe('bulbasaur')
expect(c.level).toBe(1)
expect(c.xp).toBe(0)
expect(c.totalXp).toBe(0)
expect(c.friendship).toBe(SPECIES_DATA.bulbasaur.baseHappiness)
expect(c.isShiny).toBeDefined()
expect(c.id).toBeTruthy()
expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true)
expect(Object.values(c.ev).every((v) => v === 0)).toBe(true)
})
test('deterministic IV generation from seed', () => {
const c1 = generateCreature('charmander', 12345)
const c2 = generateCreature('charmander', 12345)
expect(c1.iv).toEqual(c2.iv)
})
test('different seeds produce different IVs', () => {
const c1 = generateCreature('squirtle', 100)
const c2 = generateCreature('squirtle', 200)
expect(c1.iv).not.toEqual(c2.iv)
})
test('all MVP species can be generated', () => {
const species: SpeciesId[] = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
for (const s of species) {
const c = generateCreature(s)
expect(c.speciesId).toBe(s)
}
})
})
describe('calculateStats', () => {
test('level 1 stats are reasonable', () => {
const c = generateCreature('bulbasaur', 0)
const stats = calculateStats(c)
// HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10
// With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11
expect(stats.hp).toBeGreaterThanOrEqual(11)
expect(stats.hp).toBeLessThanOrEqual(12)
// Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5
expect(stats.attack).toBeGreaterThanOrEqual(5)
expect(stats.attack).toBeLessThanOrEqual(6)
})
test('stats increase with level', () => {
const c1 = generateCreature('charmander', 0)
c1.level = 1
const stats1 = calculateStats(c1)
const c50 = { ...c1, level: 50 }
const stats50 = calculateStats(c50)
// All stats should be higher at level 50
expect(stats50.hp).toBeGreaterThan(stats1.hp)
expect(stats50.attack).toBeGreaterThan(stats1.attack)
})
test('EVs affect stats', () => {
const c = generateCreature('pikachu', 0)
const statsNoEV = calculateStats(c)
const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } }
const statsWithEV = calculateStats(cWithEV)
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
})
})
describe('getCreatureName', () => {
test('returns species name when no nickname', () => {
const c = generateCreature('pikachu')
c.nickname = undefined
expect(getCreatureName(c)).toBe('Pikachu')
})
test('returns nickname when set', () => {
const c = generateCreature('pikachu')
c.nickname = 'Sparky'
expect(getCreatureName(c)).toBe('Sparky')
})
})
describe('getTotalEV', () => {
test('returns 0 for new creature', () => {
const c = generateCreature('bulbasaur')
expect(getTotalEV(c)).toBe(0)
})
test('sums all EV values', () => {
const c = generateCreature('bulbasaur')
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
expect(getTotalEV(c)).toBe(210)
})
})

View File

@@ -0,0 +1,79 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
beforeEach(() => {
resetEVCooldowns()
})
describe('awardEV', () => {
test('mapped tool awards correct EV', () => {
let c = generateCreature('bulbasaur')
// Clear cooldown by using old timestamp
c = awardEV(c, 'Bash', 0)
expect(c.ev.attack).toBeGreaterThan(0)
expect(c.ev.speed).toBeGreaterThan(0)
})
test('unmapped tool awards random EV', () => {
let c = generateCreature('bulbasaur')
c = awardEV(c, 'UnknownTool', 0)
const totalEV = Object.values(c.ev).reduce((a, b) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
test('cooldown prevents repeated awards', () => {
const now = Date.now()
let c = generateCreature('bulbasaur')
c = awardEV(c, 'Bash', now)
const ev1 = { ...c.ev }
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
expect(c.ev).toEqual(ev1) // No change
})
test('respects per-stat EV cap', () => {
let c = generateCreature('bulbasaur')
// Bash gives attack:2 + speed:1
for (let i = 0; i < 200; i++) {
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
}
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
})
test('respects total EV cap', () => {
let c = generateCreature('bulbasaur')
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
for (let i = 0; i < 200; i++) {
for (const tool of tools) {
c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000)
}
}
const total = Object.values(c.ev).reduce((a, b) => a + b, 0)
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
})
})
describe('awardTurnEV', () => {
test('awards EV for multiple tools', () => {
let c = generateCreature('bulbasaur')
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
const totalEV = Object.values(c.ev).reduce((a, b) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
})
describe('getEVSummary', () => {
test('returns "None" for new creature', () => {
const c = generateCreature('bulbasaur')
expect(getEVSummary(c)).toBe('None')
})
test('shows stat breakdown', () => {
const c = generateCreature('bulbasaur')
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
const summary = getEVSummary(c)
expect(summary).toContain('ATK+5')
expect(summary).toContain('SPA+3')
})
})

View File

@@ -0,0 +1,87 @@
import { describe, test, expect } from 'bun:test'
import { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch } from '../core/egg'
import type { BuddyData } from '../types'
import { generateCreature } from '../core/creature'
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
return {
version: 1,
activeCreatureId: 'test',
creatures: [generateCreature('bulbasaur')],
eggs: [],
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
stats: {
totalTurns: 50,
consecutiveDays: 7,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
...overrides,
},
}
}
describe('checkEggEligibility', () => {
test('eligible when conditions met', () => {
const data = makeBuddyData()
expect(checkEggEligibility(data)).toBe(true)
})
test('not eligible with existing egg', () => {
const data = makeBuddyData()
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible with low consecutive days', () => {
const data = makeBuddyData({ consecutiveDays: 3 })
expect(checkEggEligibility(data)).toBe(false)
})
test('not eligible when turns not multiple of 50', () => {
const data = makeBuddyData({ totalTurns: 51 })
expect(checkEggEligibility(data)).toBe(false)
})
})
describe('generateEgg', () => {
test('prefers uncollected species', () => {
const data = makeBuddyData()
// Already have bulbasaur, so egg should prefer others
const egg = generateEgg(data)
expect(egg.speciesId).not.toBe('bulbasaur')
})
test('egg has valid steps', () => {
const data = makeBuddyData()
const egg = generateEgg(data)
expect(egg.stepsRemaining).toBeGreaterThan(0)
expect(egg.totalSteps).toBe(egg.stepsRemaining)
})
})
describe('advanceEggSteps', () => {
test('reduces steps remaining', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 30)
expect(advanced.stepsRemaining).toBe(70)
})
test('steps do not go below 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
const advanced = advanceEggSteps(egg, 50)
expect(advanced.stepsRemaining).toBe(0)
})
})
describe('isEggReadyToHatch', () => {
test('ready when steps = 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(true)
})
test('not ready when steps > 0', () => {
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
expect(isEggReadyToHatch(egg)).toBe(false)
})
})

View File

@@ -0,0 +1,91 @@
import { describe, test, expect } from 'bun:test'
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
describe('checkEvolution', () => {
test('bulbasaur at level 15 cannot evolve', () => {
const creature = { speciesId: 'bulbasaur' as const, level: 15, friendship: 70 } as any
expect(checkEvolution(creature)).toBeNull()
})
test('bulbasaur at level 16 can evolve into ivysaur', () => {
const creature = { speciesId: 'bulbasaur' as const, level: 16, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.from).toBe('bulbasaur')
expect(result!.to).toBe('ivysaur')
})
test('charmander at level 16 evolves into charmeleon', () => {
const creature = { speciesId: 'charmander' as const, level: 16, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result!.to).toBe('charmeleon')
})
test('charmeleon at level 36 evolves into charizard', () => {
const creature = { speciesId: 'charmeleon' as const, level: 36, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result!.to).toBe('charizard')
})
test('squirtle at level 16 evolves into wartortle', () => {
const creature = { speciesId: 'squirtle' as const, level: 16, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result!.to).toBe('wartortle')
})
test('wartortle at level 36 evolves into blastoise', () => {
const creature = { speciesId: 'wartortle' as const, level: 36, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result!.to).toBe('blastoise')
})
test('venusaur cannot evolve further', () => {
const creature = { speciesId: 'venusaur' as const, level: 50, friendship: 70 } as any
expect(checkEvolution(creature)).toBeNull()
})
test('pikachu cannot evolve in MVP', () => {
const creature = { speciesId: 'pikachu' as const, level: 50, friendship: 70 } as any
expect(checkEvolution(creature)).toBeNull()
})
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
const creature = { speciesId: 'bulbasaur' as const, level: 100, friendship: 70 } as any
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.to).toBe('ivysaur')
})
})
describe('evolve', () => {
test('changes species and boosts friendship', () => {
const creature = { speciesId: 'bulbasaur' as const, friendship: 70, level: 16 } as any
const evolved = evolve(creature, 'ivysaur')
expect(evolved.speciesId).toBe('ivysaur')
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
})
})
describe('canEvolveFurther', () => {
test('starter species can evolve', () => {
expect(canEvolveFurther('bulbasaur')).toBe(true)
expect(canEvolveFurther('charmander')).toBe(true)
expect(canEvolveFurther('squirtle')).toBe(true)
})
test('middle evolution can evolve', () => {
expect(canEvolveFurther('ivysaur')).toBe(true)
expect(canEvolveFurther('charmeleon')).toBe(true)
expect(canEvolveFurther('wartortle')).toBe(true)
})
test('final evolution cannot evolve', () => {
expect(canEvolveFurther('venusaur')).toBe(false)
expect(canEvolveFurther('charizard')).toBe(false)
expect(canEvolveFurther('blastoise')).toBe(false)
})
test('pikachu cannot evolve in MVP', () => {
expect(canEvolveFurther('pikachu')).toBe(false)
})
})

View File

@@ -0,0 +1,84 @@
import { describe, test, expect } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardXP, getXpProgress } from '../core/experience'
import { xpForLevel, levelFromXp } from '../data/xpTable'
describe('xpForLevel', () => {
test('level 1 requires 0 XP', () => {
expect(xpForLevel(1, 'medium-slow')).toBe(0)
})
test('medium-fast: level N requires N^3 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
})
test('fast: level N requires floor(N^3 * 4/5)', () => {
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
})
test('slow: level N requires floor(N^3 * 5/4)', () => {
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
})
test('higher levels require more XP', () => {
for (let i = 2; i < 99; i++) {
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
}
})
})
describe('levelFromXp', () => {
test('0 XP = level 1', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('roundtrip: level → XP → level', () => {
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
const xp = xpForLevel(level, growth)
expect(levelFromXp(xp, growth)).toBe(level)
}
}
})
test('XP slightly below threshold stays at lower level', () => {
const xp20 = xpForLevel(20, 'medium-fast')
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
})
})
describe('awardXP', () => {
test('awards XP and returns updated creature', () => {
const c = generateCreature('bulbasaur')
const result = awardXP(c, 10)
expect(result.creature.totalXp).toBe(10)
expect(result.leveledUp).toBeDefined()
})
test('large XP can cause level up', () => {
const c = generateCreature('bulbasaur')
// Award enough XP for several levels
const result = awardXP(c, 10000)
expect(result.creature.level).toBeGreaterThan(1)
expect(result.leveledUp).toBe(true)
})
test('level capped at 100', () => {
const c = generateCreature('bulbasaur')
c.level = 100
c.totalXp = 1000000
const result = awardXP(c, 999999)
expect(result.creature.level).toBe(100)
expect(result.leveledUp).toBe(false)
})
})
describe('getXpProgress', () => {
test('new creature has 0 XP progress', () => {
const c = generateCreature('bulbasaur')
const progress = getXpProgress(c)
expect(progress.current).toBe(0)
expect(progress.percentage).toBe(0)
})
})

View File

@@ -0,0 +1,51 @@
import { describe, test, expect } from 'bun:test'
import { determineGender, getGenderSymbol } from '../core/gender'
import { SPECIES_DATA } from '../data/species'
describe('determineGender', () => {
test('genderless species', () => {
// Pikachu has genderRate 4 (50% female)
// Venusaur has genderRate 1 (12.5% female)
// For testing genderless, we'd need a species with genderRate -1
// None in MVP are genderless, so test the basic logic
const pikachu = SPECIES_DATA.pikachu
expect(pikachu.genderRate).toBe(4)
})
test('pikachu 50% female ratio', () => {
const pikachu = SPECIES_DATA.pikachu
let males = 0
let females = 0
for (let seed = 0; seed < 1000; seed++) {
const g = determineGender(pikachu, seed)
if (g === 'male') males++
else females++
}
// Should be roughly 50/50 with some tolerance
expect(females).toBeGreaterThan(300)
expect(males).toBeGreaterThan(300)
})
test('starters are ~12.5% female', () => {
const bulbasaur = SPECIES_DATA.bulbasaur
let females = 0
for (let seed = 0; seed < 1000; seed++) {
if (determineGender(bulbasaur, seed) === 'female') females++
}
// ~12.5% female = ~125 out of 1000
expect(females).toBeGreaterThan(50)
expect(females).toBeLessThan(250)
})
})
describe('getGenderSymbol', () => {
test('male symbol', () => {
expect(getGenderSymbol('male')).toBe('♂')
})
test('female symbol', () => {
expect(getGenderSymbol('female')).toBe('♀')
})
test('genderless has no symbol', () => {
expect(getGenderSymbol('genderless')).toBe('')
})
})

View File

@@ -0,0 +1,117 @@
import { randomUUID } from 'node:crypto'
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
import { STAT_NAMES } from '../types'
import { SPECIES_DATA } from '../data/species'
import { determineGender } from './gender'
import { levelFromXp } from '../data/xpTable'
/**
* Generate a new creature of the given species.
*/
export function generateCreature(speciesId: SpeciesId, seed?: number): Creature {
const species = SPECIES_DATA[speciesId]
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
// Generate IVs (0-31) using simple hash from seed
const iv = generateIVs(actualSeed)
// Determine gender
const gender = determineGender(species, actualSeed & 0xff)
// Determine shiny status
const isShiny = Math.random() < species.shinyChance
return {
id: randomUUID(),
speciesId,
gender,
level: 1,
xp: 0,
totalXp: 0,
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv,
friendship: species.baseHappiness,
isShiny,
hatchedAt: Date.now(),
}
}
/**
* Calculate actual stats for a creature using Pokémon stat formulas.
* HP: floor((2 * base + iv + floor(ev/4)) * level / 100) + level + 10
* Other: floor((2 * base + iv + floor(ev/4)) * level / 100) + 5
*/
export function calculateStats(creature: Creature): StatsResult {
const species = SPECIES_DATA[creature.speciesId]
const level = creature.level
const result: StatsResult = {} as StatsResult
for (const stat of STAT_NAMES) {
const base = species.baseStats[stat]
const iv = creature.iv[stat]
const ev = creature.ev[stat]
const raw = Math.floor((2 * base + iv + Math.floor(ev / 4)) * level / 100)
if (stat === 'hp') {
result[stat] = raw + level + 10
} else {
result[stat] = raw + 5
}
}
return result
}
/**
* Get display name for a creature (nickname or species name).
*/
export function getCreatureName(creature: Creature): string {
if (creature.nickname) return creature.nickname
return SPECIES_DATA[creature.speciesId].name
}
/**
* Recalculate level from total XP (e.g. after XP gain).
*/
export function recalculateLevel(creature: Creature): Creature {
const species = SPECIES_DATA[creature.speciesId]
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
if (newLevel !== creature.level) {
return { ...creature, level: newLevel }
}
return creature
}
/**
* Get the active creature from buddy data.
*/
export function getActiveCreature(buddyData: { activeCreatureId: string | null; creatures: Creature[] }): Creature | null {
if (!buddyData.activeCreatureId) return null
return buddyData.creatures.find((c) => c.id === buddyData.activeCreatureId) ?? null
}
/**
* Generate IVs from a seed value. Each stat gets 0-31.
*/
function generateIVs(seed: number): Record<StatName, number> {
let s = seed
const nextRand = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff
return s
}
return {
hp: nextRand() % 32,
attack: nextRand() % 32,
defense: nextRand() % 32,
spAtk: nextRand() % 32,
spDef: nextRand() % 32,
speed: nextRand() % 32,
}
}
/**
* Get total EV across all stats.
*/
export function getTotalEV(creature: Creature): number {
return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0)
}

View File

@@ -0,0 +1,98 @@
import type { Creature, StatName } from '../types'
import { STAT_NAMES } from '../types'
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { getTotalEV } from './creature'
// Track last EV award time per tool to enforce cooldown
const evCooldowns = new Map<string, number>()
/**
* Reset EV cooldown state (for testing).
*/
export function resetEVCooldowns(): void {
evCooldowns.clear()
}
/**
* Award EV to a creature based on tool usage.
* Returns updated creature and actual EV awarded.
*/
export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature {
const now = timestamp ?? Date.now()
// Check cooldown
const lastTime = evCooldowns.get(toolName)
if (lastTime !== undefined && now - lastTime < 30_000) return creature
const currentTotal = getTotalEV(creature)
if (currentTotal >= MAX_EV_TOTAL) return creature
let evGains = getEVForTool(toolName)
if (!evGains) {
// Random EV for unmapped tools
evGains = generateRandomEV()
}
const updated = { ...creature, ev: { ...creature.ev } }
for (const stat of STAT_NAMES) {
const gain = evGains[stat]
if (gain > 0) {
const current = updated.ev[stat]
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
if (canAdd > 0) {
updated.ev[stat] = current + canAdd
}
}
}
evCooldowns.set(toolName, now)
return updated
}
/**
* Award EVs for a full turn's worth of tool calls.
* Deduplicates tool names and spaces timestamps to avoid cooldown issues.
*/
export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature {
const uniqueTools = [...new Set(toolNames)]
const baseTime = timestamp ?? Date.now()
let current = creature
for (let i = 0; i < uniqueTools.length; i++) {
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
}
return current
}
/**
* Generate random 1-2 EV points in a random stat.
*/
function generateRandomEV(): Record<StatName, number> {
const stats = [...STAT_NAMES]
const stat = stats[Math.floor(Math.random() * stats.length)]
const amount = Math.random() < 0.5 ? 1 : 2
const result: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
result[stat] = amount
return result
}
/**
* Get formatted EV summary string.
*/
export function getEVSummary(creature: Creature): string {
const parts: string[] = []
for (const stat of STAT_NAMES) {
const val = creature.ev[stat]
if (val > 0) {
const labels: Record<StatName, string> = {
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
}
parts.push(`${labels[stat]}+${val}`)
}
}
return parts.join(' ') || 'None'
}

View File

@@ -0,0 +1,97 @@
import { randomUUID } from 'node:crypto'
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { SPECIES_DATA } from '../data/species'
import { generateCreature } from './creature'
/**
* Check if the player is eligible to receive an egg.
* Conditions: consecutiveDays >= 7 AND totalTurns % 50 === 0 AND eggs.length < 1
*/
export function checkEggEligibility(buddyData: BuddyData): boolean {
if (buddyData.eggs.length >= 1) return false
if (buddyData.stats.consecutiveDays < 7) return false
if (buddyData.stats.totalTurns % 50 !== 0) return false
return true
}
/**
* Generate a new egg with a species the player hasn't collected yet.
* Priority: uncollected species > random from all species.
*/
export function generateEgg(buddyData: BuddyData): Egg {
// Find uncollected species
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
// Pick species (prefer uncollected, fall back to random starter)
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
const speciesId = uncollected.length > 0
? uncollected[Math.floor(Math.random() * uncollected.length)]
: starters[Math.floor(Math.random() * starters.length)]
// Steps based on rarity (capture rate: lower = rarer = more steps)
const species = SPECIES_DATA[speciesId]
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
return {
id: randomUUID(),
obtainedAt: Date.now(),
stepsRemaining: baseSteps,
totalSteps: baseSteps,
speciesId,
}
}
/**
* Advance egg steps by a given amount.
* Returns updated egg or null if egg hatched.
*/
export function advanceEggSteps(egg: Egg, steps: number): Egg {
const newSteps = Math.max(0, egg.stepsRemaining - steps)
return { ...egg, stepsRemaining: newSteps }
}
/**
* Check if an egg is ready to hatch.
*/
export function isEggReadyToHatch(egg: Egg): boolean {
return egg.stepsRemaining <= 0
}
/**
* Hatch an egg, creating a new creature and updating buddy data.
*/
export function hatchEgg(buddyData: BuddyData, egg: Egg): { buddyData: BuddyData; creature: Creature } {
const creature = generateCreature(egg.speciesId)
creature.hatchedAt = Date.now()
// Update buddy data
const updatedData: BuddyData = {
...buddyData,
creatures: [...buddyData.creatures, creature],
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
stats: {
...buddyData.stats,
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
},
}
return { buddyData: updatedData, creature }
}
/**
* Update or create a dex entry for a species.
*/
function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] {
const existing = dex.find((d) => d.speciesId === speciesId)
if (existing) {
return dex.map((d) =>
d.speciesId === speciesId
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
: d,
)
}
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
}

View File

@@ -0,0 +1,46 @@
import type { Creature, EvolutionResult, SpeciesId } from '../types'
import { SPECIES_DATA } from '../data/species'
import { getNextEvolution } from '../data/evolution'
/**
* Check if a creature meets evolution conditions.
* Returns the evolution result if evolution should occur, null otherwise.
*/
export function checkEvolution(creature: Creature): EvolutionResult | null {
if (creature.level > 100) return null
const nextEvo = getNextEvolution(creature.speciesId)
if (!nextEvo) return null
// Check level-up conditions
if (nextEvo.trigger === 'level_up' && creature.level >= nextEvo.minLevel) {
return {
from: creature.speciesId,
to: nextEvo.to,
newLevel: creature.level,
}
}
return null
}
/**
* Execute evolution on a creature.
* Returns the updated creature with new species and recalculated data.
*/
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
const newSpecies = SPECIES_DATA[targetSpeciesId]
return {
...creature,
speciesId: targetSpeciesId,
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
}
}
/**
* Check if a species can evolve further.
*/
export function canEvolveFurther(speciesId: SpeciesId): boolean {
return getNextEvolution(speciesId) !== undefined
}

View File

@@ -0,0 +1,52 @@
import type { Creature } from '../types'
import { SPECIES_DATA } from '../data/species'
import { levelFromXp, xpForLevel } from '../data/xpTable'
/**
* Award XP to a creature. Returns updated creature and whether level up occurred.
*/
export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } {
const species = SPECIES_DATA[creature.speciesId]
if (creature.level >= 100) {
return { creature, leveledUp: false, newLevel: creature.level }
}
const newTotalXp = creature.totalXp + amount
const oldLevel = creature.level
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
// XP progress within current level
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
const xp = newTotalXp - currentLevelXp
const updated: Creature = {
...creature,
totalXp: newTotalXp,
xp: Math.max(0, xp),
level: newLevel,
}
return {
creature: updated,
leveledUp: newLevel > oldLevel,
newLevel,
}
}
/**
* Get XP needed to reach next level from current state.
*/
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
const species = SPECIES_DATA[creature.speciesId]
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
const needed = nextLevelXp - currentLevelXp
const current = creature.totalXp - currentLevelXp
return {
current: Math.max(0, current),
needed,
percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100,
}
}

View File

@@ -0,0 +1,26 @@
import type { Gender, SpeciesData } from '../types'
/**
* Determine gender based on species gender ratio.
* genderRate: -1 = genderless, 0 = always male, 1-7 = female chance = genderRate/8, 8 = always female
*/
export function determineGender(speciesData: SpeciesData, seed: number): Gender {
if (speciesData.genderRate === -1) return 'genderless'
if (speciesData.genderRate === 0) return 'male'
if (speciesData.genderRate === 8) return 'female'
// Use seed value (0-255) to determine gender
const threshold = (speciesData.genderRate / 8) * 256
return (seed % 256) < threshold ? 'female' : 'male'
}
/** Get gender symbol for display */
export function getGenderSymbol(gender: Gender): string {
switch (gender) {
case 'male':
return '♂'
case 'female':
return '♀'
case 'genderless':
return ''
}
}

View File

@@ -0,0 +1,139 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { SpeciesId, SpriteCache } from '../types'
import { SPECIES_DATA } from '../data/species'
import { getSpritesDir } from './storage'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
/** Mapping of speciesId to cow file prefix */
const COW_FILE_MAP: Record<SpeciesId, string> = {
bulbasaur: '001_bulbasaur',
ivysaur: '002_ivysaur',
venusaur: '003_venusaur',
charmander: '004_charmander',
charmeleon: '005_charmeleon',
charizard: '006_charizard',
squirtle: '007_squirtle',
wartortle: '008_wartortle',
blastoise: '009_blastoise',
pikachu: '025_pikachu',
}
/**
* Load sprite from local cache. Returns null if not cached.
*/
export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
if (!existsSync(filePath)) return null
try {
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as SpriteCache
} catch {
return null
}
}
/**
* Fetch sprite from GitHub, convert from .cow format, and cache locally.
* Returns the cached sprite data, or null if fetch failed.
*/
export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteCache | null> {
// Try local cache first
const cached = loadSprite(speciesId)
if (cached) return cached
const cowFileName = COW_FILE_MAP[speciesId]
if (!cowFileName) return null
const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow`
try {
const response = await fetch(url)
if (!response.ok) return null
const cowContent = await response.text()
const lines = convertCowToLines(cowContent)
if (lines.length === 0) return null
const sprite: SpriteCache = {
speciesId,
lines,
width: Math.max(...lines.map((l) => stripAnsi(l).length)),
height: lines.length,
fetchedAt: Date.now(),
}
// Cache to disk
const spritesDir = getSpritesDir()
const filePath = join(spritesDir, `${speciesId}.json`)
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
return sprite
} catch {
return null
}
}
/**
* Convert .cow file content to displayable lines.
* Extracts heredoc content, converts Unicode escapes, strips thought lines.
*/
function convertCowToLines(cowContent: string): string[] {
// Extract content between $the_cow =<<EOC; and EOC
const startMarker = '$the_cow =<<EOC;'
const endMarker = 'EOC'
const startIdx = cowContent.indexOf(startMarker)
if (startIdx === -1) return []
const contentStart = startIdx + startMarker.length
const endIdx = cowContent.indexOf(endMarker, contentStart)
if (endIdx === -1) return []
let content = cowContent.slice(contentStart, endIdx)
// Convert \N{U+XXXX} to actual Unicode characters
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
// Convert \e to actual escape character (for ANSI sequences)
content = content.replace(/\\e/g, '\x1b')
// Split into lines
let lines = content.split('\n')
// Strip leading/trailing empty lines
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
// Remove first 4 lines (cowsay thought bubble guide)
if (lines.length > 4) {
lines = lines.slice(4)
}
// Trim trailing whitespace on each line (preserve leading for alignment)
lines = lines.map((line) => line.trimEnd())
return lines
}
/**
* Strip ANSI escape sequences from a string.
*/
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '')
}
/**
* Get species name with dex number for display.
*/
export function getSpeciesDisplay(speciesId: SpeciesId): string {
const data = SPECIES_DATA[speciesId]
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
}

View File

@@ -0,0 +1,206 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { generateCreature } from './creature'
import { SPECIES_DATA } from '../data/species'
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
/**
* Load buddy data from disk. Returns default data if file doesn't exist.
*/
export function loadBuddyData(): BuddyData {
if (!existsSync(BUDDY_DATA_PATH)) {
return getDefaultBuddyData()
}
try {
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
const data = JSON.parse(raw) as BuddyData
if (data.version !== 1) {
return migrateData(data)
}
return data
} catch {
return getDefaultBuddyData()
}
}
/**
* Save buddy data to disk.
*/
export function saveBuddyData(data: BuddyData): void {
// Ensure directory exists
const dir = join(BUDDY_DATA_PATH, '..')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2))
}
/**
* Get default buddy data for new users.
* Randomly assigns one of the three starters.
*/
export function getDefaultBuddyData(): BuddyData {
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
const creature = generateCreature(randomStarter)
return {
version: 1,
activeCreatureId: creature.id,
creatures: [creature],
eggs: [],
dex: [
{
speciesId: randomStarter,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: 1,
},
],
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
},
}
}
/**
* Get the sprites cache directory path.
*/
export function getSpritesDir(): string {
if (!existsSync(BUDDY_SPRITES_DIR)) {
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
}
return BUDDY_SPRITES_DIR
}
/**
* Migrate from legacy buddy system.
* Accepts legacy companion data and maps to new Pokémon species.
* If species cannot be determined, randomly assigns a starter.
*/
export function migrateFromLegacy(
storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string },
): BuddyData {
const speciesMap: Record<string, SpeciesId> = {
duck: 'bulbasaur',
goose: 'squirtle',
blob: 'bulbasaur',
cat: 'charmander',
dragon: 'pikachu',
octopus: 'squirtle',
owl: 'bulbasaur',
penguin: 'squirtle',
turtle: 'squirtle',
snail: 'bulbasaur',
ghost: 'pikachu',
axolotl: 'squirtle',
capybara: 'bulbasaur',
cactus: 'charmander',
robot: 'charmander',
rabbit: 'pikachu',
mushroom: 'bulbasaur',
chonk: 'charmander',
}
// If species is provided directly, use it; otherwise random starter
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
const creature = generateCreature(speciesId)
creature.level = 5 // Reward for existing users
creature.totalXp = 100
creature.friendship = 120 // Existing partner bonus
// Preserve nickname if it's not the default
const speciesInfo = SPECIES_DATA[speciesId]
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
creature.nickname = storedCompanion.name
}
return {
version: 1,
activeCreatureId: creature.id,
creatures: [creature],
eggs: [],
dex: [
{
speciesId,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: 5,
},
],
stats: {
totalTurns: 0,
consecutiveDays: 1,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
},
}
}
/**
* Handle data migration between versions.
*/
function migrateData(data: BuddyData): BuddyData {
// Currently only version 1 exists
if (!data.version || data.version < 1) {
return getDefaultBuddyData()
}
return data
}
/**
* Update daily stats (consecutive days, last active date).
*/
export function updateDailyStats(data: BuddyData): BuddyData {
const today = new Date().toISOString().split('T')[0]
const lastDate = data.stats.lastActiveDate
let consecutiveDays = data.stats.consecutiveDays
if (lastDate !== today) {
// Check if yesterday
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
if (lastDate === yesterdayStr) {
consecutiveDays++
} else {
consecutiveDays = 1
}
}
return {
...data,
stats: {
...data.stats,
consecutiveDays,
lastActiveDate: today,
},
}
}
/**
* Increment turn counter.
*/
export function incrementTurns(data: BuddyData): BuddyData {
return {
...data,
stats: {
...data.stats,
totalTurns: data.stats.totalTurns + 1,
},
}
}

View File

@@ -0,0 +1,48 @@
// Types
export type {
StatName,
SpeciesId,
Gender,
EvolutionTrigger,
EvolutionCondition,
GrowthRate,
SpeciesData,
Creature,
Egg,
DexEntry,
BuddyData,
StatsResult,
EvolutionResult,
SpriteCache,
AnimMode,
} from './types'
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from './types'
// Data
export { SPECIES_DATA, DEX_TO_SPECIES } from './data/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names'
export { getNextEvolution, EVOLUTION_CHAINS } from './data/evolution'
// Core
export { generateCreature, calculateStats, getCreatureName, recalculateLevel, getActiveCreature, getTotalEV } from './core/creature'
export { determineGender, getGenderSymbol } from './core/gender'
export { awardXP, getXpProgress } from './core/experience'
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg } from './core/egg'
export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns } from './core/storage'
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
// Sprites
export { renderAnimatedSprite, getIdleAnimMode } from './sprites/renderer'
export { getFallbackSprite } from './sprites/fallback'
// UI Components
export { CompanionCard } from './ui/CompanionCard'
export { PokedexView } from './ui/PokedexView'
export { EggView } from './ui/EggView'
export { EvolutionAnim } from './ui/EvolutionAnim'
export { StatBar } from './ui/StatBar'
export { SpeciesDetail } from './ui/SpeciesDetail'

View File

@@ -0,0 +1,85 @@
import type { SpeciesId } from '../types'
/**
* Fallback ASCII art for when sprites can't be fetched.
* Simple 5-line representations of each species.
*/
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
bulbasaur: [
' _,,--.,,_ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `--,,__,,--\' ',
],
ivysaur: [
' _,--..,_ ',
' ,\' (o)(o) `, ',
' ; ~~~~~~ ; ',
' ; \\====/ ; ',
' `--,,__,,--\' ',
],
venusaur: [
' _,,,---.,,_ ',
' ,\' (o) (o) `, ',
' ; ~~~~~~~~ ; ',
' ; /========\\ ; ',
' `-,,,____,,,-\' ',
],
charmander: [
' ,^., ',
' ( o o) ',
' / ~~~ \\ ',
' / \\___/ \\ ',
' ^^^ ^^^ ',
],
charmeleon: [
' ,--^. ',
' ( o o) ',
' / ~~~~~ \\ ',
' / \\___/ \\ ',
' ^^ ^^ ',
],
charizard: [
' /\\ /\\ ',
' / \\/ \\ ',
' | o o | ',
' | ~~~~~~ | ',
' \\______/ ',
],
squirtle: [
' _____ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~ ; ',
' `-.,__,\' ',
],
wartortle: [
' _______ ',
' ,\' `, ',
' ; o o ; ',
' ; ~~~~~~~~ ; ',
' `-.,__,\' ',
],
blastoise: [
' .________. ',
' | o o | ',
' | ~~~~~~~~ | ',
' | [====] | ',
' `-.,__,\' ',
],
pikachu: [
' /\\ /\\ ',
' ( o o ) ',
' \\ ~~~ / ',
' /`-...-\'\\ ',
' ^^ ^^ ',
],
}
/**
* Get fallback ASCII sprite lines for a species.
*/
export function getFallbackSprite(speciesId: SpeciesId): string[] {
return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu
}

View File

@@ -0,0 +1,4 @@
export { renderAnimatedSprite, getIdleAnimMode } from './renderer'
export type { AnimMode } from '../types'
export { getFallbackSprite } from './fallback'
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'

View File

@@ -0,0 +1,76 @@
import type { AnimMode } from '../types'
/** Heart particle frames for pet animation */
const PET_HEARTS = [
[' ♥ ', ' '],
[' ♥ ♥ ', ' ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ', ' ♥ ♥ '],
]
/**
* Render animated sprite by applying mode-specific transformations.
* All species share the same animation logic - only the base sprite differs.
*/
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
switch (mode) {
case 'idle':
return lines
case 'fidget':
return shiftLines(lines, tick % 2 === 0 ? 0 : 1)
case 'blink':
return blinkEyes(lines)
case 'excited':
return shiftLines(lines, tick % 2 === 0 ? -1 : 1)
case 'pet':
return addPetParticles(lines, tick)
default:
return lines
}
}
/**
* Shift all lines left or right by offset columns.
*/
function shiftLines(lines: string[], offset: number): string[] {
if (offset === 0) return lines
if (offset > 0) {
return lines.map((line) => ' '.repeat(offset) + line)
}
// Shift left: remove leading characters
const absOffset = Math.abs(offset)
return lines.map((line) => line.slice(absOffset))
}
/**
* Replace eye characters with blink indicator.
*/
function blinkEyes(lines: string[]): string[] {
// Eye characters that should be replaced for blink
return lines.map((line) =>
line.replace(/[·✦×◉@°oO]/g, '—'),
)
}
/**
* Add heart particle frames above the sprite for pet animation.
*/
function addPetParticles(lines: string[], tick: number): string[] {
const hearts = PET_HEARTS[tick % PET_HEARTS.length]
return [...hearts, ...lines]
}
/**
* Get the animation mode for a given tick in the idle sequence.
* IDLE_SEQUENCE replicates the original buddy design pattern.
*/
const IDLE_SEQUENCE: AnimMode[] = [
'idle', 'idle', 'idle', 'idle',
'fidget', 'idle', 'idle', 'idle',
'blink', 'idle', 'idle', 'idle', 'idle',
]
export function getIdleAnimMode(tick: number): AnimMode {
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
}

View File

@@ -0,0 +1,143 @@
// 6 attributes (mapped to programming scenarios)
export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed']
export const STAT_LABELS: Record<StatName, string> = {
hp: 'HP',
attack: 'ATK',
defense: 'DEF',
spAtk: 'SPA',
spDef: 'SPD',
speed: 'SPE',
}
// Species IDs (MVP 10 species)
export type SpeciesId =
| 'bulbasaur'
| 'ivysaur'
| 'venusaur'
| 'charmander'
| 'charmeleon'
| 'charizard'
| 'squirtle'
| 'wartortle'
| 'blastoise'
| 'pikachu'
export const ALL_SPECIES_IDS: SpeciesId[] = [
'bulbasaur',
'ivysaur',
'venusaur',
'charmander',
'charmeleon',
'charizard',
'squirtle',
'wartortle',
'blastoise',
'pikachu',
]
// Gender
export type Gender = 'male' | 'female' | 'genderless'
// Evolution trigger types
export type EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship'
export type EvolutionCondition = {
trigger: EvolutionTrigger
level?: number // Level evolution: target level
minFriendship?: number // Friendship evolution
item?: string // Item evolution
into: SpeciesId // Evolves into
}
// Growth rate types (from PokeAPI)
export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erratic' | 'fluctuating'
// Species base data
export type SpeciesData = {
id: SpeciesId
name: string // English name
names: Record<string, string> // Multilingual names { ja, en, zh }
dexNumber: number // Pokédex number (1-10 MVP)
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
baseStats: Record<StatName, number>
types: [string, string?] // Types (grass/poison, fire, water etc.)
baseHappiness: number // Base friendship
growthRate: GrowthRate
captureRate: number
personality: string // Default personality description
evolutionChain?: EvolutionCondition[]
shinyChance: number // Shiny probability (default 1/4096)
flavorText?: string // Pokédex description
}
// Instantiated creature (stored in buddy-data.json)
export type Creature = {
id: string // UUID
speciesId: SpeciesId
nickname?: string // User-defined name
gender: Gender
level: number
xp: number // Current level progress XP
totalXp: number // Total accumulated XP
ev: Record<StatName, number> // Effort values
iv: Record<StatName, number> // Individual values (0-31)
friendship: number // Friendship (0-255)
isShiny: boolean
hatchedAt: number // Timestamp when obtained
}
// Egg
export type Egg = {
id: string
obtainedAt: number
stepsRemaining: number // Remaining hatch steps
totalSteps: number // Original total steps (for progress calc)
speciesId: SpeciesId // Pre-determined species
}
// Pokédex entry
export type DexEntry = {
speciesId: SpeciesId
discoveredAt: number
caughtCount: number // Number caught
bestLevel: number // Highest level record
}
// buddy-data.json complete structure
export type BuddyData = {
version: 1
activeCreatureId: string | null
creatures: Creature[]
eggs: Egg[]
dex: DexEntry[]
stats: {
totalTurns: number
consecutiveDays: number
lastActiveDate: string // ISO date
totalEggsObtained: number
totalEvolutions: number
}
}
// Calculated stats result
export type StatsResult = Record<StatName, number>
// Evolution result
export type EvolutionResult = {
from: SpeciesId
to: SpeciesId
newLevel: number
}
// Sprite cache entry
export type SpriteCache = {
speciesId: SpeciesId
lines: string[]
width: number
height: number
fetchedAt: number
}
// Animation mode
export type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet'

View File

@@ -0,0 +1,170 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types'
import { SPECIES_DATA } from '../data/species'
import { SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience'
import { getEVSummary } from '../core/effort'
import { getGenderSymbol } from '../core/gender'
import { getNextEvolution } from '../data/evolution'
import { StatBar } from './StatBar'
interface CompanionCardProps {
creature: Creature
buddyData: BuddyData
spriteLines?: string[]
}
// ANSI color constants
const CYAN: Color = 'ansi:cyan'
const YELLOW: Color = 'ansi:yellow'
const GREEN: Color = 'ansi:green'
const BLUE: Color = 'ansi:blue'
const RED: Color = 'ansi:red'
const MAGENTA: Color = 'ansi:magenta'
const WHITE: Color = 'ansi:whiteBright'
const GRAY: Color = 'ansi:white'
/** Type → display color mapping */
const TYPE_COLORS: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
/**
* Redesigned companion card with Pokémon-style stats display.
*/
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
const species = SPECIES_DATA[creature.speciesId]
const stats = calculateStats(creature)
const xp = getXpProgress(creature)
const genderSymbol = getGenderSymbol(creature.gender)
const name = getCreatureName(creature)
const evSummary = getEVSummary(creature)
const totalEV = getTotalEV(creature)
const nextEvo = getNextEvolution(creature.speciesId)
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? '/' : ''}{t.toUpperCase()}
</Text>
))
// Friendship color
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
// Shiny badge
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> SHINY</Text> : null
// Evolution hint
const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header row */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>{name}</Text>
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge}
</Box>
<Text bold>Lv.{creature.level}</Text>
</Box>
{/* Species + type + gender */}
<Box>
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
</Box>
{/* Sprite */}
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines ? (
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
) : (
<Text color={GRAY}>[Loading sprite...]</Text>
)}
</Box>
{/* Personality */}
<Box>
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
</Box>
{/* Stats section */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<StatBar
key={stat}
label={STAT_LABELS[stat]}
value={stats[stat]}
maxValue={255}
color={getStatColor(stat)}
/>
))}
</Box>
{/* XP progress */}
<Box marginTop={0}>
<Text color={GRAY}>XP </Text>
<Text color={BLUE}>
{'█'.repeat(Math.round(xp.percentage / 10))}
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
</Text>
<Text> {xp.current}/{xp.needed}</Text>
</Box>
{/* EV + Friendship */}
<Box flexDirection="column">
<Box>
<Text color={GRAY}>EV </Text>
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
<Text color={GRAY}> ({totalEV}/510)</Text>
</Box>
<Box>
<Text color={GRAY}> </Text>
<Text color={friendshipColor}>
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
</Text>
<Text> {creature.friendship}/255</Text>
</Box>
</Box>
{/* Evolution hint */}
{evoHint && (
<Box marginTop={0}>
<Text color={GRAY}>Next: </Text>
{evoHint}
</Box>
)}
</Box>
)
}
function getStatColor(stat: string): Color {
const colors: Record<string, Color> = {
hp: 'ansi:green',
attack: 'ansi:red',
defense: 'ansi:yellow',
spAtk: 'ansi:blue',
spDef: 'ansi:magenta',
speed: 'ansi:cyan',
}
return colors[stat] ?? 'ansi:white'
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { Egg } from '../types'
const CYAN: Color = 'ansi:cyan'
const YELLOW: Color = 'ansi:yellow'
const GRAY: Color = 'ansi:white'
interface EggViewProps {
egg: Egg
}
/**
* Egg status view showing hatch progress.
*/
export function EggView({ egg }: EggViewProps) {
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
const filled = Math.round(percentage / 10)
const empty = 10 - filled
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={CYAN}>
Egg Status
</Text>
{/* ASCII egg */}
<Box flexDirection="column" alignItems="center" marginY={1}>
<Text> . </Text>
<Text> / \ </Text>
<Text> | | </Text>
<Text> \_/ </Text>
</Box>
{/* Progress */}
<Box flexDirection="column" alignItems="center">
<Text>
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
</Text>
<Text color={YELLOW}>
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</Text>
<Text>{percentage}%</Text>
</Box>
{/* Tips */}
<Box marginTop={1} flexDirection="column" alignItems="center">
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { SPECIES_DATA } from '../data/species'
import { loadSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
const YELLOW: Color = 'ansi:yellow'
const GREEN: Color = 'ansi:green'
const GRAY: Color = 'ansi:white'
interface EvolutionAnimProps {
fromSpecies: SpeciesId
toSpecies: SpeciesId
onComplete: () => void
}
/**
* Evolution animation component.
* Displays a flashing/morphing effect from old species to new species.
* 8 frames × 500ms = ~4 seconds total.
*/
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
const [tick, setTick] = useState(0)
const totalFrames = 8
useEffect(() => {
if (tick >= totalFrames) {
onComplete()
return
}
const timer = setTimeout(() => setTick((t) => t + 1), 500)
return () => clearTimeout(timer)
}, [tick, onComplete])
const fromSprite = getSpriteLines(fromSpecies)
const toSprite = getSpriteLines(toSpecies)
const fromName = SPECIES_DATA[fromSpecies].name
const toName = SPECIES_DATA[toSpecies].name
// Frame logic:
// 0-3: old sprite with flash (alternate blank)
// 4-7: alternate old/new, settle on new
let displayLines: string[]
if (tick < 3) {
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
} else if (tick < 6) {
displayLines = tick % 2 === 0 ? fromSprite : toSprite
} else {
displayLines = toSprite
}
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={YELLOW}>
Evolution!
</Text>
<Box flexDirection="column" alignItems="center" marginY={1}>
{displayLines.map((line, i) => (
<Text key={i}>
{tick >= 6 ? '✨ ' : ''}
{line}
{tick >= 6 ? ' ✨' : ''}
</Text>
))}
</Box>
<Text>
<Text color={GRAY}>{fromName}</Text>
<Text color={YELLOW}> </Text>
<Text bold color={GREEN}>
{toName}
</Text>
</Text>
{tick >= totalFrames - 1 && (
<Text bold color={GREEN}>
</Text>
)}
</Box>
)
}
function getSpriteLines(speciesId: SpeciesId): string[] {
const cached = loadSprite(speciesId)
if (cached) return cached.lines
return getFallbackSprite(speciesId)
}

View File

@@ -0,0 +1,163 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { SPECIES_DATA } from '../data/species'
import { getNextEvolution } from '../data/evolution'
const CYAN: Color = 'ansi:cyan'
const GREEN: Color = 'ansi:green'
const GRAY: Color = 'ansi:white'
const YELLOW: Color = 'ansi:yellow'
const WHITE: Color = 'ansi:whiteBright'
const RED: Color = 'ansi:red'
const BLUE: Color = 'ansi:blue'
interface PokedexViewProps {
buddyData: BuddyData
}
/**
* Pokédex view — shows all species with collection status,
* evolution chains, and active creature indicator.
*/
export function PokedexView({ buddyData }: PokedexViewProps) {
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
const collected = buddyData.dex.length
const total = ALL_SPECIES_IDS.length
// Group species by evolution chain
const chains = groupByChain()
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between" marginBottom={0}>
<Text bold color={CYAN}>Pokédex</Text>
<Text>
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
<Text color={GRAY}>/{total} </Text>
<Text color={GRAY}>collected</Text>
</Text>
</Box>
{/* Progress bar */}
<Box>
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
<Text> {Math.floor((collected / total) * 100)}%</Text>
</Box>
{/* Species list grouped by evolution chains */}
{chains.map((chain, ci) => (
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
{chain.map((speciesId, si) => {
const species = SPECIES_DATA[speciesId]
const entry = dexMap.get(speciesId)
const discovered = !!entry
const isActive = buddyData.activeCreatureId
? buddyData.creatures.some((c) => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
: false
const nextEvo = getNextEvolution(speciesId)
return (
<Box key={speciesId} flexDirection="column">
<Box>
{/* Chain connector */}
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
{/* Active indicator */}
<Text>{isActive ? <Text color={YELLOW}></Text> : ' '}</Text>
{/* Dex number */}
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
{/* Name */}
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
{discovered
? (species.names.zh ?? species.name)
: '???'}
</Text>
{/* Type badges */}
{discovered && (
<Text>
{' '}
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
<Text key={t} color={getTypeColor(t)}>
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
</Text>
))}
</Text>
)}
{/* Level / unknown indicator */}
{discovered && entry ? (
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
) : (
<Text color={GRAY}> </Text>
)}
{/* Evolution arrow */}
{nextEvo && (
<Text color={GRAY}> <Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
)}
</Box>
</Box>
)
})}
</Box>
))}
{/* Stats row */}
<Box marginTop={0} flexDirection="column">
<Text color={GRAY}> Stats </Text>
<Box>
<Text color={GRAY}>Turns: </Text>
<Text>{buddyData.stats.totalTurns}</Text>
<Text color={GRAY}> Days: </Text>
<Text>{buddyData.stats.consecutiveDays}</Text>
</Box>
<Box>
<Text color={GRAY}>Eggs: </Text>
<Text>{buddyData.stats.totalEggsObtained}</Text>
<Text color={GRAY}> Evolutions: </Text>
<Text>{buddyData.stats.totalEvolutions}</Text>
</Box>
</Box>
{/* Egg info */}
{buddyData.eggs.length > 0 && (
<Box marginTop={0}>
<Text color={YELLOW}>🥚 Egg: </Text>
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
<Text color={GRAY}> steps</Text>
</Box>
)}
{buddyData.stats.consecutiveDays < 7 && (
<Box>
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
</Box>
)}
</Box>
)
}
/** Type → color mapping */
function getTypeColor(type: string): Color {
const colors: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
return colors[type] ?? 'ansi:white'
}
/** Group species by evolution chain for visual display */
function groupByChain(): SpeciesId[][] {
return [
['bulbasaur', 'ivysaur', 'venusaur'],
['charmander', 'charmeleon', 'charizard'],
['squirtle', 'wartortle', 'blastoise'],
['pikachu'],
]
}

View File

@@ -0,0 +1,193 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId, StatName } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types'
import { SPECIES_DATA } from '../data/species'
import { SPECIES_PERSONALITY } from '../data/names'
import { getNextEvolution } from '../data/evolution'
import { StatBar } from './StatBar'
const CYAN: Color = 'ansi:cyan'
const GRAY: Color = 'ansi:white'
const WHITE: Color = 'ansi:whiteBright'
const YELLOW: Color = 'ansi:yellow'
const GREEN: Color = 'ansi:green'
const RED: Color = 'ansi:red'
const BLUE: Color = 'ansi:blue'
/** Type → color */
const TYPE_COLORS: Record<string, Color> = {
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
normal: 'ansi:white',
}
interface SpeciesDetailProps {
speciesId: SpeciesId
caughtLevel?: number
spriteLines?: string[]
}
/**
* Detailed species info page — base stats, evolution chain, flavor text.
*/
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
const species = SPECIES_DATA[speciesId]
const nextEvo = getNextEvolution(speciesId)
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
</Text>
))
// Gender info
const genderInfo = species.genderRate === -1
? 'Genderless'
: species.genderRate === 0
? '♂ 100%'
: species.genderRate === 8
? '♀ 100%'
: `${(species.genderRate / 8 * 100).toFixed(1)}%`
// Max base stat for bar scaling
const maxBase = 130
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
</Box>
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
</Box>
{/* Type + gender */}
<Box>
{typeBadges}
<Text color={GRAY}> {genderInfo}</Text>
</Box>
{/* Sprite */}
{spriteLines && (
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
</Box>
)}
{/* Flavor text */}
{species.flavorText && (
<Box marginTop={0}>
<Text color={GRAY} italic>{species.flavorText}</Text>
</Box>
)}
{/* Base Stats */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<Box key={stat}>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
</Text>
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
</Box>
))}
{/* Total */}
<Box>
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
<Text color={GRAY}>
{'─'.repeat(15)}
</Text>
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
</Box>
</Box>
{/* Evolution chain */}
{(nextEvo || species.dexNumber > 1) && (
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Evolution </Text>
<EvolutionChain speciesId={speciesId} />
</Box>
)}
{/* Info */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Info </Text>
<Box>
<Text color={GRAY}>Growth: </Text>
<Text>{species.growthRate}</Text>
</Box>
<Box>
<Text color={GRAY}>Capture: </Text>
<Text>{species.captureRate}</Text>
<Text color={GRAY}> Happiness: </Text>
<Text>{species.baseHappiness}</Text>
</Box>
</Box>
</Box>
)
}
/** Render evolution chain arrow */
function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
// Find the chain head
const chainHeads: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
let head: SpeciesId = speciesId
for (const starter of chainHeads) {
if (isInChain(speciesId, starter)) {
head = starter
break
}
}
const chain: SpeciesId[] = [head]
let current: SpeciesId | undefined = head
while (current) {
const next = getNextEvolution(current)
if (next) {
chain.push(next.to)
current = next.to
} else {
current = undefined
}
}
return (
<Box>
{chain.map((sid, i) => (
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
{SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name}
</Text>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
)}
</React.Fragment>
))}
</Box>
)
}
function isInChain(target: SpeciesId, head: SpeciesId): boolean {
let current: SpeciesId | undefined = head
while (current) {
if (current === target) return true
const next = getNextEvolution(current)
current = next ? next.to : undefined
}
return false
}
function getStatColor(stat: string): Color {
const colors: Record<string, Color> = {
hp: 'ansi:green', attack: 'ansi:red', defense: 'ansi:yellow',
spAtk: 'ansi:blue', spDef: 'ansi:magenta', speed: 'ansi:cyan',
}
return colors[stat] ?? 'ansi:white'
}

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
interface StatBarProps {
label: string
value: number
maxValue: number
color?: Color
width?: number
}
/**
* Compact horizontal stat bar for Pokémon stats.
*/
export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) {
const filled = Math.round((value / maxValue) * width)
const empty = width - filled
const bar = '█'.repeat(filled) + '░'.repeat(empty)
const valueStr = String(value).padStart(3)
return (
<Box>
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
<Text color={color}>{bar}</Text>
<Text> {valueStr}</Text>
</Box>
)
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}