diff --git a/packages/pokemon/src/__tests__/battle.test.ts b/packages/pokemon/src/__tests__/battle.test.ts new file mode 100644 index 000000000..51b825f4f --- /dev/null +++ b/packages/pokemon/src/__tests__/battle.test.ts @@ -0,0 +1,232 @@ +import { describe, test, expect } from 'bun:test' +import { createBattle, executeTurn } from '../battle/engine' +import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement' +import { chooseAIMove } from '../battle/ai' +import type { Creature, BuddyData } from '../types' + +function makeTestCreature(overrides: Partial = {}): Creature { + return { + id: overrides.id ?? 'test-1', + speciesId: overrides.speciesId ?? 'charmander', + gender: overrides.gender ?? 'male', + level: overrides.level ?? 50, + xp: 0, + totalXp: 0, + nature: overrides.nature ?? 'adamant', + ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 }, + moves: overrides.moves ?? [ + { id: 'flamethrower', pp: 15, maxPp: 15 }, + { id: 'airslash', pp: 15, maxPp: 15 }, + { id: 'dragontail', pp: 10, maxPp: 10 }, + { id: 'slash', pp: 20, maxPp: 20 }, + ], + ability: overrides.ability ?? 'blaze', + heldItem: null, + friendship: 70, + isShiny: false, + hatchedAt: Date.now(), + pokeball: 'pokeball', + } +} + +function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData { + return { + version: 2, + party: [creatures[0]!.id, null, null, null, null, null], + boxes: [], + creatures: creatures, + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: '', + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } +} + +describe('createBattle', () => { + test('creates battle with valid initial state', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + expect(init.state).toBeDefined() + expect(init.state.playerPokemon).toBeDefined() + expect(init.state.opponentPokemon).toBeDefined() + expect(init.state.finished).toBe(false) + }) + + test('player pokemon has correct species', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'bulbasaur', 30) + expect(init.state.playerPokemon.speciesId).toBe('charmander') + expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur') + }) + + test('player pokemon has moves', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0) + }) +}) + +describe('executeTurn', () => { + test('move action generates events', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + const initialEventCount = init.state.events.length + + const newState = executeTurn(init, { type: 'move', moveIndex: 0 }) + expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount) + }) + + test('battle eventually ends within 50 turns', () => { + const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } }) + const init = createBattle([creature], 'squirtle', 5) + + let state = init.state + for (let i = 0; i < 50 && !state.finished; i++) { + state = executeTurn(init, { type: 'move', moveIndex: 0 }) + } + + expect(state.finished).toBe(true) + }) +}) + +describe('settleBattle', () => { + test('player win increments battlesWon', () => { + const creature = makeTestCreature() + const data: BuddyData = { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: [], + creatures: [creature], + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: '', + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } + const result = { + winner: 'player' as const, + turns: 5, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + + const settlement = settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.stats.battlesWon).toBe(1) + }) + + test('player loss returns unchanged data', () => { + const creature = makeTestCreature() + const data: BuddyData = { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: [], + creatures: [creature], + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: '', + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } + const result = { + winner: 'opponent' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + + const settlement = settleBattle(data, result, 'squirtle', 20) + // Loss early-returns unchanged data + expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp) + expect(settlement.learnableMoves).toEqual([]) + expect(settlement.pendingEvolutions).toEqual([]) + }) +}) + +describe('applyMoveLearn', () => { + test('replaces move at given index', () => { + const creature = makeTestCreature() + const data: BuddyData = { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: [], + creatures: [creature], + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: '', + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } + const updated = applyMoveLearn(data, creature.id, 'fireblast', 3) + expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast') + }) +}) + +describe('applyEvolution', () => { + test('evolves charmander to charmeleon and increments counter', () => { + const creature = makeTestCreature({ speciesId: 'charmander' }) + const data: BuddyData = { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: [], + creatures: [creature], + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: '', + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } + const updated = applyEvolution(data, creature.id, 'charmeleon') + expect(updated.creatures[0]!.speciesId).toBe('charmeleon') + expect(updated.stats.totalEvolutions).toBe(1) + }) +}) + +describe('chooseAIMove', () => { + test('returns a valid move index', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + const aiPokemon = init.state.opponentPokemon + const idx = chooseAIMove(aiPokemon) + expect(idx).toBeGreaterThanOrEqual(0) + expect(idx).toBeLessThan(aiPokemon.moves.length) + }) +}) diff --git a/packages/pokemon/src/__tests__/egg.test.ts b/packages/pokemon/src/__tests__/egg.test.ts index f24567ccf..723fefe02 100644 --- a/packages/pokemon/src/__tests__/egg.test.ts +++ b/packages/pokemon/src/__tests__/egg.test.ts @@ -62,7 +62,7 @@ describe('checkEggEligibility', () => { }) test('not eligible with low consecutive days', () => { - const data = makeBuddyData({ consecutiveDays: 3 }) + const data = makeBuddyData({ consecutiveDays: 2 }) expect(checkEggEligibility(data)).toBe(false) }) diff --git a/packages/pokemon/src/__tests__/learnsets.test.ts b/packages/pokemon/src/__tests__/learnsets.test.ts new file mode 100644 index 000000000..8888cd642 --- /dev/null +++ b/packages/pokemon/src/__tests__/learnsets.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from 'bun:test' +import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../data/learnsets' +import { EMPTY_MOVE } from '../types' + +describe('getDefaultMoveset', () => { + test('charmander at level 1 has at least one move', async () => { + const moves = await getDefaultMoveset('charmander', 1) + expect(moves.length).toBe(4) + expect(moves[0]!.id).not.toBe('') + }) + + test('charmander at level 10 has more moves', async () => { + const moves = await getDefaultMoveset('charmander', 10) + const nonEmpty = moves.filter(m => m.id !== '') + expect(nonEmpty.length).toBeGreaterThan(1) + }) + + test('all moves have valid pp', async () => { + const moves = await getDefaultMoveset('bulbasaur', 20) + for (const move of moves) { + if (move.id) { + expect(move.pp).toBeGreaterThan(0) + expect(move.maxPp).toBeGreaterThan(0) + } + } + }) + + test('invalid species returns empty moves', async () => { + const moves = await getDefaultMoveset('nonexistent' as any, 10) + expect(moves.every(m => m.id === '')).toBe(true) + }) +}) + +describe('getDefaultAbility', () => { + test('charmander has blaze', () => { + expect(getDefaultAbility('charmander')).toBe('blaze') + }) + + test('bulbasaur has overgrow', () => { + expect(getDefaultAbility('bulbasaur')).toBe('overgrow') + }) + + test('squirtle has torrent', () => { + expect(getDefaultAbility('squirtle')).toBe('torrent') + }) +}) + +describe('getNewLearnableMoves', () => { + test('charmander gains ember at level 4', async () => { + const moves = await getNewLearnableMoves('charmander', 1, 4) + expect(moves.length).toBeGreaterThan(0) + expect(moves.some(m => m.id === 'ember')).toBe(true) + }) + + test('no new moves when level stays same', async () => { + const moves = await getNewLearnableMoves('charmander', 5, 5) + expect(moves.length).toBe(0) + }) +}) diff --git a/packages/pokemon/src/__tests__/nature.test.ts b/packages/pokemon/src/__tests__/nature.test.ts new file mode 100644 index 000000000..f22482f09 --- /dev/null +++ b/packages/pokemon/src/__tests__/nature.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from 'bun:test' +import { getAllNatureNames, randomNature, getNatureEffect } from '../data/nature' + +describe('getAllNatureNames', () => { + test('returns 25 nature names', () => { + const names = getAllNatureNames() + expect(names.length).toBe(25) + }) + + test('includes hardy and quirky', () => { + const names = getAllNatureNames() + expect(names).toContain('hardy') + expect(names).toContain('quirky') + }) +}) + +describe('randomNature', () => { + test('returns a valid nature name', () => { + const nature = randomNature() + expect(getAllNatureNames()).toContain(nature) + }) + + test('produces different natures over multiple calls', () => { + const natures = new Set(Array.from({ length: 50 }, () => randomNature())) + expect(natures.size).toBeGreaterThan(1) + }) +}) + +describe('getNatureEffect', () => { + test('hardy is neutral (no effect)', () => { + const effect = getNatureEffect('hardy') + expect(effect.plus).toBeNull() + expect(effect.minus).toBeNull() + }) + + test('adamant boosts attack and lowers spAtk', () => { + const effect = getNatureEffect('adamant') + expect(effect.plus).toBe('attack') + expect(effect.minus).toBe('spAtk') + }) + + test('timid boosts speed and lowers attack', () => { + const effect = getNatureEffect('timid') + expect(effect.plus).toBe('speed') + expect(effect.minus).toBe('attack') + }) + + test('invalid nature returns neutral', () => { + const effect = getNatureEffect('nonexistent') + expect(effect.plus).toBeNull() + expect(effect.minus).toBeNull() + }) +}) diff --git a/packages/pokemon/src/__tests__/storage.test.ts b/packages/pokemon/src/__tests__/storage.test.ts new file mode 100644 index 000000000..6df65a716 --- /dev/null +++ b/packages/pokemon/src/__tests__/storage.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from 'bun:test' +import { getDefaultBuddyData } from '../core/storage' + +describe('getDefaultBuddyData', () => { + test('returns v2 data with correct structure', async () => { + const data = await getDefaultBuddyData() + expect(data.version).toBe(2) + expect(data.party.length).toBe(6) + expect(data.party[0]).toBeTruthy() + expect(data.boxes.length).toBe(8) + expect(data.boxes[0]!.slots.length).toBe(30) + expect(data.bag.items).toEqual([]) + expect(data.stats.battlesWon).toBe(0) + expect(data.stats.battlesLost).toBe(0) + }) + + test('has one creature matching party[0]', async () => { + const data = await getDefaultBuddyData() + expect(data.creatures.length).toBe(1) + expect(data.creatures[0]!.id).toBe(data.party[0]!) + }) + + test('creature has v2 fields', async () => { + const data = await getDefaultBuddyData() + const creature = data.creatures[0]! + expect(creature.nature).toBeTruthy() + expect(creature.moves.length).toBe(4) + expect(creature.ability).toBeTruthy() + expect(creature.heldItem).toBeNull() + expect(creature.pokeball).toBe('pokeball') + }) +}) diff --git a/packages/pokemon/src/data/nature.ts b/packages/pokemon/src/data/nature.ts index 7bc408c91..c79234504 100644 --- a/packages/pokemon/src/data/nature.ts +++ b/packages/pokemon/src/data/nature.ts @@ -1,4 +1,5 @@ import { Dex } from '@pkmn/sim' +import { FROM_DEX_STAT } from './pkmn' import type { NatureName, NatureEffect, NatureStat } from '../types' // All 25 canonical nature names (Dex.natures is not iterable, so we list them) @@ -21,12 +22,18 @@ export function randomNature(): NatureName { return names[Math.floor(Math.random() * names.length)]! } +/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */ +function mapDexStat(stat: string | undefined): NatureStat | null { + if (!stat) return null + return (FROM_DEX_STAT[stat] as NatureStat) ?? null +} + /** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */ export function getNatureEffect(nature: NatureName): NatureEffect { const n = Dex.natures.get(nature) if (!n?.exists) return { plus: null, minus: null } return { - plus: (n.plus as NatureStat | undefined) ?? null, - minus: (n.minus as NatureStat | undefined) ?? null, + plus: mapDexStat(n.plus), + minus: mapDexStat(n.minus), } }