diff --git a/packages/pokemon/src/__tests__/evMapping.test.ts b/packages/pokemon/src/__tests__/evMapping.test.ts new file mode 100644 index 000000000..74deb8442 --- /dev/null +++ b/packages/pokemon/src/__tests__/evMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from 'bun:test' +import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping' + +describe('getEVForTool', () => { + test('returns EV mapping for known tools', () => { + const bashEV = getEVForTool('Bash') + expect(bashEV).toBeDefined() + expect(bashEV!.attack).toBe(2) + expect(bashEV!.speed).toBe(1) + }) + + test('returns undefined for unknown tools', () => { + expect(getEVForTool('UnknownTool')).toBeUndefined() + }) + + test('all mapped tools have correct stat shape', () => { + for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) { + expect(ev.hp).toBeDefined() + expect(ev.attack).toBeDefined() + expect(ev.defense).toBeDefined() + expect(ev.spAtk).toBeDefined() + expect(ev.spDef).toBeDefined() + expect(ev.speed).toBeDefined() + // EVs should sum to > 0 + const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed + expect(total).toBeGreaterThan(0) + } + }) +}) + +describe('EV constants', () => { + test('MAX_EV_PER_STAT is 252', () => { + expect(MAX_EV_PER_STAT).toBe(252) + }) + + test('MAX_EV_TOTAL is 510', () => { + expect(MAX_EV_TOTAL).toBe(510) + }) +}) diff --git a/packages/pokemon/src/__tests__/fallback.test.ts b/packages/pokemon/src/__tests__/fallback.test.ts new file mode 100644 index 000000000..95f3b581b --- /dev/null +++ b/packages/pokemon/src/__tests__/fallback.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from 'bun:test' +import { getFallbackSprite } from '../sprites/fallback' +import { ALL_SPECIES_IDS } from '../types' + +describe('getFallbackSprite', () => { + test('returns 5 lines for every species', () => { + for (const id of ALL_SPECIES_IDS) { + const sprite = getFallbackSprite(id) + expect(sprite.length).toBe(5) + } + }) + + test('returns pikachu fallback for unknown species', () => { + const sprite = getFallbackSprite('unknown' as any) + expect(sprite).toEqual(getFallbackSprite('pikachu')) + }) + + test('each line has consistent width', () => { + for (const id of ALL_SPECIES_IDS) { + const sprite = getFallbackSprite(id) + const widths = sprite.map(line => line.length) + // All lines should be roughly the same width + const maxWidth = Math.max(...widths) + const minWidth = Math.min(...widths) + expect(maxWidth - minWidth).toBeLessThanOrEqual(2) + } + }) +}) diff --git a/packages/pokemon/src/__tests__/names.test.ts b/packages/pokemon/src/__tests__/names.test.ts new file mode 100644 index 000000000..fe224e899 --- /dev/null +++ b/packages/pokemon/src/__tests__/names.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from 'bun:test' +import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names' +import { ALL_SPECIES_IDS } from '../types' + +describe('SPECIES_NAMES', () => { + test('has name for every species', () => { + for (const id of ALL_SPECIES_IDS) { + expect(SPECIES_NAMES[id]).toBeTruthy() + } + }) + + test('Charmander name is correct', () => { + expect(SPECIES_NAMES.charmander).toBe('Charmander') + }) +}) + +describe('SPECIES_I18N', () => { + test('has i18n for every species', () => { + for (const id of ALL_SPECIES_IDS) { + expect(SPECIES_I18N[id]).toBeTruthy() + expect(SPECIES_I18N[id]!.en).toBeTruthy() + } + }) + + test('has Chinese translations', () => { + expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘') + expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟') + }) +}) + +describe('SPECIES_PERSONALITY', () => { + test('has personality for every species', () => { + for (const id of ALL_SPECIES_IDS) { + expect(SPECIES_PERSONALITY[id]).toBeTruthy() + } + }) + + test('personality is non-empty string', () => { + for (const id of ALL_SPECIES_IDS) { + expect(typeof SPECIES_PERSONALITY[id]).toBe('string') + expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0) + } + }) +}) diff --git a/packages/pokemon/src/__tests__/pkmn.test.ts b/packages/pokemon/src/__tests__/pkmn.test.ts new file mode 100644 index 000000000..73f0e788d --- /dev/null +++ b/packages/pokemon/src/__tests__/pkmn.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect } from 'bun:test' +import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio, getPrimaryAbility } from '../data/pkmn' + +describe('FROM_DEX_STAT', () => { + test('maps all 6 stats', () => { + expect(FROM_DEX_STAT.hp).toBe('hp') + expect(FROM_DEX_STAT.atk).toBe('attack') + expect(FROM_DEX_STAT.def).toBe('defense') + expect(FROM_DEX_STAT.spa).toBe('spAtk') + expect(FROM_DEX_STAT.spd).toBe('spDef') + expect(FROM_DEX_STAT.spe).toBe('speed') + }) +}) + +describe('TO_DEX_STAT', () => { + test('reverse maps all 6 stats', () => { + expect(TO_DEX_STAT.hp).toBe('hp') + expect(TO_DEX_STAT.attack).toBe('atk') + expect(TO_DEX_STAT.defense).toBe('def') + expect(TO_DEX_STAT.spAtk).toBe('spa') + expect(TO_DEX_STAT.spDef).toBe('spd') + expect(TO_DEX_STAT.speed).toBe('spe') + }) +}) + +describe('mapBaseStats', () => { + test('converts Dex stat format to our format', () => { + const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 }) + expect(result).toEqual({ + hp: 45, attack: 49, defense: 49, + spAtk: 65, spDef: 65, speed: 45, + }) + }) +}) + +describe('mapGenderRatio', () => { + test('returns -1 for genderless', () => { + expect(mapGenderRatio(undefined)).toBe(-1) + expect(mapGenderRatio('N')).toBe(-1) + }) + + test('calculates female ratio', () => { + expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1 + expect(mapGenderRatio({ M: 0.5, F: 0.5 })).toBe(4) // 50% F → 4 + }) +}) + +describe('getPrimaryAbility', () => { + test('returns first ability', () => { + expect(getPrimaryAbility({ '0': 'Overgrow', '1': 'Chlorophyll' })).toBe('overgrow') + }) + + test('returns empty string for missing ability', () => { + expect(getPrimaryAbility({})).toBe('') + }) +}) diff --git a/packages/pokemon/src/__tests__/species.test.ts b/packages/pokemon/src/__tests__/species.test.ts new file mode 100644 index 000000000..e6ea5db6e --- /dev/null +++ b/packages/pokemon/src/__tests__/species.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect } from 'bun:test' +import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES } from '../data/species' +import { ALL_SPECIES_IDS } from '../types' +import type { SpeciesId } from '../types' + +describe('getSpeciesData', () => { + test('returns valid data for charmander', () => { + const data = getSpeciesData('charmander') + expect(data.id).toBe('charmander') + expect(data.name).toBe('Charmander') + expect(data.dexNumber).toBe(4) + expect(data.growthRate).toBe('medium-slow') + expect(data.captureRate).toBe(45) + expect(data.flavorText).toBeTruthy() + }) + + test('returns valid data for pikachu', () => { + const data = getSpeciesData('pikachu') + expect(data.id).toBe('pikachu') + expect(data.dexNumber).toBe(25) + expect(data.growthRate).toBe('medium-fast') + }) + + test('has baseStats with all 6 stats', () => { + const data = getSpeciesData('bulbasaur') + expect(data.baseStats).toHaveProperty('hp') + expect(data.baseStats).toHaveProperty('attack') + expect(data.baseStats).toHaveProperty('defense') + expect(data.baseStats).toHaveProperty('spAtk') + expect(data.baseStats).toHaveProperty('spDef') + expect(data.baseStats).toHaveProperty('speed') + }) + + test('has types array', () => { + const data = getSpeciesData('squirtle') + expect(data.types.length).toBeGreaterThan(0) + expect(data.types[0]).toBe('water') + }) + + test('has evolutionChain for species with evolutions', () => { + const data = getSpeciesData('charmander') + expect(data.evolutionChain).toBeDefined() + expect(data.evolutionChain?.[0]?.into).toBe('charmeleon') + }) + + test('has no evolutionChain for final evolutions', () => { + const data = getSpeciesData('charizard') + expect(data.evolutionChain).toBeUndefined() + }) +}) + +describe('getAllSpeciesData', () => { + test('returns data for all species', () => { + const all = getAllSpeciesData() + for (const id of ALL_SPECIES_IDS) { + expect(all[id]).toBeDefined() + expect(all[id]!.id).toBe(id) + } + }) +}) + +describe('DEX_TO_SPECIES', () => { + test('maps dex numbers correctly', () => { + expect(DEX_TO_SPECIES[1]).toBe('bulbasaur') + expect(DEX_TO_SPECIES[4]).toBe('charmander') + expect(DEX_TO_SPECIES[7]).toBe('squirtle') + expect(DEX_TO_SPECIES[25]).toBe('pikachu') + }) +}) diff --git a/packages/pokemon/src/__tests__/xpTable.test.ts b/packages/pokemon/src/__tests__/xpTable.test.ts new file mode 100644 index 000000000..5b422e4f5 --- /dev/null +++ b/packages/pokemon/src/__tests__/xpTable.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from 'bun:test' +import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable' + +describe('xpForLevel', () => { + test('returns 0 for level 1', () => { + expect(xpForLevel(1, 'medium-fast')).toBe(0) + }) + + test('returns 0 for level 0', () => { + expect(xpForLevel(0, 'medium-fast')).toBe(0) + }) + + test('medium-fast: level 5 = 125 XP', () => { + expect(xpForLevel(5, 'medium-fast')).toBe(125) + }) + + test('medium-fast: level 10 = 1000 XP', () => { + expect(xpForLevel(10, 'medium-fast')).toBe(1000) + }) + + test('slow: level 5 = 156 XP', () => { + expect(xpForLevel(5, 'slow')).toBe(156) + }) + + test('fast: level 5 = 100 XP', () => { + expect(xpForLevel(5, 'fast')).toBe(100) + }) +}) + +describe('levelFromXp', () => { + test('returns 1 for 0 XP', () => { + expect(levelFromXp(0, 'medium-fast')).toBe(1) + }) + + test('returns 5 for 125 XP medium-fast', () => { + expect(levelFromXp(125, 'medium-fast')).toBe(5) + }) + + test('caps at 100', () => { + expect(levelFromXp(999999999, 'medium-fast')).toBe(100) + }) + + test('roundtrip: xpForLevel then levelFromXp', () => { + for (let lv = 1; lv <= 100; lv += 10) { + const xp = xpForLevel(lv, 'medium-fast') + expect(levelFromXp(xp, 'medium-fast')).toBe(lv) + } + }) +}) + +describe('xpToNextLevel', () => { + test('returns 0 at level 100', () => { + expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0) + }) + + test('returns difference to next level', () => { + // Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216 + expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125) + }) + + test('returns full next level XP from 0', () => { + expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8 + }) +}) diff --git a/packages/pokemon/src/battle/engine.ts b/packages/pokemon/src/battle/engine.ts index 742cd8838..f1c6b9109 100644 --- a/packages/pokemon/src/battle/engine.ts +++ b/packages/pokemon/src/battle/engine.ts @@ -3,7 +3,7 @@ import { Dex } from '@pkmn/sim' import type { Creature, SpeciesId } from '../types' import { TO_DEX_STAT, FROM_DEX_STAT } from '../data/pkmn' import { STAT_NAMES } from '../types' -import type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types' +import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types' import { chooseAIMove } from './ai' // ─── Adapter: Creature → Showdown Set ─── @@ -45,13 +45,23 @@ function wildPokemonToSetString(speciesId: SpeciesId, level: number): string { return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n') } -function getSpeciesMoves(speciesId: string, level: number): string[] { - // Use @pkmn/sim move pool - get natural level-up moves - // This is a simplified approach for the sim +function getSpeciesMoves(speciesId: string, _level: number): string[] { + // In @pkmn/sim, Dex.species doesn't expose learnsets directly. + // Use common moves that exist in the sim's data for basic battles. + // The actual move pool is resolved by the Battle engine during construction. const species = Dex.species.get(speciesId) if (!species) return ['Tackle'] - // For sim battles, just return basic moves - return ['Tackle', 'Splash'] + // Use type-appropriate basic moves as fallback + const type = species.types[0]?.toLowerCase() ?? 'normal' + const basicMoves: Record = { + normal: ['Tackle', 'Scratch'], + fire: ['Ember', 'FireSpin'], + water: ['WaterGun', 'Bubble'], + grass: ['VineWhip', 'RazorLeaf'], + electric: ['ThunderShock', 'Spark'], + poison: ['PoisonSting', 'Smog'], + } + return basicMoves[type] ?? ['Tackle', 'Scratch'] } // ─── State Projection ───