mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 第一版可用 pokemon
This commit is contained in:
9
packages/pokemon/package.json
Normal file
9
packages/pokemon/package.json
Normal 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": {}
|
||||
}
|
||||
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
124
packages/pokemon/scripts/fetch-sprites.ts
Normal 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)
|
||||
107
packages/pokemon/src/__tests__/creature.test.ts
Normal file
107
packages/pokemon/src/__tests__/creature.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
79
packages/pokemon/src/__tests__/effort.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
87
packages/pokemon/src/__tests__/egg.test.ts
Normal file
87
packages/pokemon/src/__tests__/egg.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
91
packages/pokemon/src/__tests__/evolution.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
84
packages/pokemon/src/__tests__/experience.test.ts
Normal file
84
packages/pokemon/src/__tests__/experience.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
51
packages/pokemon/src/__tests__/gender.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
117
packages/pokemon/src/core/creature.ts
Normal file
117
packages/pokemon/src/core/creature.ts
Normal 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)
|
||||
}
|
||||
98
packages/pokemon/src/core/effort.ts
Normal file
98
packages/pokemon/src/core/effort.ts
Normal 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'
|
||||
}
|
||||
97
packages/pokemon/src/core/egg.ts
Normal file
97
packages/pokemon/src/core/egg.ts
Normal 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 }]
|
||||
}
|
||||
46
packages/pokemon/src/core/evolution.ts
Normal file
46
packages/pokemon/src/core/evolution.ts
Normal 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
|
||||
}
|
||||
52
packages/pokemon/src/core/experience.ts
Normal file
52
packages/pokemon/src/core/experience.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
26
packages/pokemon/src/core/gender.ts
Normal file
26
packages/pokemon/src/core/gender.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
139
packages/pokemon/src/core/spriteCache.ts
Normal file
139
packages/pokemon/src/core/spriteCache.ts
Normal 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}`
|
||||
}
|
||||
206
packages/pokemon/src/core/storage.ts
Normal file
206
packages/pokemon/src/core/storage.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
48
packages/pokemon/src/index.ts
Normal file
48
packages/pokemon/src/index.ts
Normal 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'
|
||||
85
packages/pokemon/src/sprites/fallback.ts
Normal file
85
packages/pokemon/src/sprites/fallback.ts
Normal 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
|
||||
}
|
||||
4
packages/pokemon/src/sprites/index.ts
Normal file
4
packages/pokemon/src/sprites/index.ts
Normal 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'
|
||||
76
packages/pokemon/src/sprites/renderer.ts
Normal file
76
packages/pokemon/src/sprites/renderer.ts
Normal 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]
|
||||
}
|
||||
143
packages/pokemon/src/types.ts
Normal file
143
packages/pokemon/src/types.ts
Normal 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'
|
||||
170
packages/pokemon/src/ui/CompanionCard.tsx
Normal file
170
packages/pokemon/src/ui/CompanionCard.tsx
Normal 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'
|
||||
}
|
||||
54
packages/pokemon/src/ui/EggView.tsx
Normal file
54
packages/pokemon/src/ui/EggView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal file
90
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal 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)
|
||||
}
|
||||
163
packages/pokemon/src/ui/PokedexView.tsx
Normal file
163
packages/pokemon/src/ui/PokedexView.tsx
Normal 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'],
|
||||
]
|
||||
}
|
||||
193
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal file
193
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal 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'
|
||||
}
|
||||
28
packages/pokemon/src/ui/StatBar.tsx
Normal file
28
packages/pokemon/src/ui/StatBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
packages/pokemon/tsconfig.json
Normal file
8
packages/pokemon/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user