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,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('')
})
})