diff --git a/packages/pokemon/package.json b/packages/pokemon/package.json index 96d105f33..707792545 100644 --- a/packages/pokemon/package.json +++ b/packages/pokemon/package.json @@ -7,7 +7,6 @@ "types": "./src/index.ts", "dependencies": { "@pkmn/client": "^0.7.2", - "@pkmn/protocol": "^0.7.2", - "@pkmn/view": "^0.7.2" + "@pkmn/protocol": "^0.7.2" } } diff --git a/packages/pokemon/src/__tests__/battle.test.ts b/packages/pokemon/src/__tests__/battle.test.ts index e0931e06f..ab9744187 100644 --- a/packages/pokemon/src/__tests__/battle.test.ts +++ b/packages/pokemon/src/__tests__/battle.test.ts @@ -5,465 +5,465 @@ 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: overrides.friendship ?? 70, - isShiny: false, - hatchedAt: Date.now(), - pokeball: 'pokeball', - } + 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: overrides.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, - }, - } + 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('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 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) - }) + 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 + 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) - }) + 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) + 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 }) - } + 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) - }) + expect(state.finished).toBe(true) + }) }) describe('settleBattle', () => { - test('player win increments battlesWon', async () => { - 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], - } + test('player win increments battlesWon', async () => { + 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 = await settleBattle(data, result, 'squirtle', 20) - expect(settlement.data.stats.battlesWon).toBe(1) - }) + const settlement = await settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.stats.battlesWon).toBe(1) + }) - test('player loss returns unchanged data', async () => { - 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], - } + test('player loss returns unchanged data', async () => { + 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 = await 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([]) - }) + const settlement = await 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') - }) + 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) - }) + 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) - }) + 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) + }) - test('returns 0 when all moves have 0 PP', () => { - const pokemon = { - ...makeTestCreature(), - moves: [ - { id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false }, - ], - } - const idx = chooseAIMove(pokemon as any) - expect(idx).toBe(0) // Struggle fallback - }) + test('returns 0 when all moves have 0 PP', () => { + const pokemon = { + ...makeTestCreature(), + moves: [ + { id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false }, + ], + } + const idx = chooseAIMove(pokemon as any) + expect(idx).toBe(0) // Struggle fallback + }) - test('skips disabled moves', () => { - const pokemon = { - ...makeTestCreature(), - moves: [ - { id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true }, - { id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false }, - ], - } - const idx = chooseAIMove(pokemon as any) - expect(idx).toBe(1) // Only non-disabled move - }) + test('skips disabled moves', () => { + const pokemon = { + ...makeTestCreature(), + moves: [ + { id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true }, + { id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false }, + ], + } + const idx = chooseAIMove(pokemon as any) + expect(idx).toBe(1) // Only non-disabled move + }) }) describe('settleBattle - advanced', () => { - test('player win awards XP to creature', async () => { - const creature = makeTestCreature({ level: 5 }) - const data = makeTestBuddyData([creature]) - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [creature.id], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0) - }) + test('player win awards XP to creature', async () => { + const creature = makeTestCreature({ level: 5 }) + const data = makeTestBuddyData([creature]) + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0) + }) - test('player win awards EVs (capped at 252 per stat)', async () => { - const creature = makeTestCreature({ - level: 5, - ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 }, - }) - const data = makeTestBuddyData([creature]) - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [creature.id], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) { - expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252) - } - }) + test('player win awards EVs (capped at 252 per stat)', async () => { + const creature = makeTestCreature({ + level: 5, + ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 }, + }) + const data = makeTestBuddyData([creature]) + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) { + expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252) + } + }) - test('player loss does not increment battlesWon', async () => { - const creature = makeTestCreature() - const data = makeTestBuddyData([creature]) - 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 = await settleBattle(data, result, 'squirtle', 20) - expect(settlement.data.stats.battlesWon).toBe(0) - }) + test('player loss does not increment battlesWon', async () => { + const creature = makeTestCreature() + const data = makeTestBuddyData([creature]) + 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 = await settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.stats.battlesWon).toBe(0) + }) }) describe('createBattle - extended', () => { - test('battle state has turn initialized', () => { - const creature = makeTestCreature() - const init = createBattle([creature], 'squirtle', 50) - expect(init.state.turn).toBeGreaterThanOrEqual(1) - }) + test('battle state has turn initialized', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + expect(init.state.turn).toBeGreaterThanOrEqual(1) + }) - test('player pokemon has correct level', () => { - const creature = makeTestCreature({ level: 25 }) - const init = createBattle([creature], 'bulbasaur', 10) - expect(init.state.playerPokemon.level).toBe(25) - }) + test('player pokemon has correct level', () => { + const creature = makeTestCreature({ level: 25 }) + const init = createBattle([creature], 'bulbasaur', 10) + expect(init.state.playerPokemon.level).toBe(25) + }) - test('opponent pokemon has correct level', () => { - const creature = makeTestCreature() - const init = createBattle([creature], 'squirtle', 15) - expect(init.state.opponentPokemon.level).toBe(15) - }) + test('opponent pokemon has correct level', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 15) + expect(init.state.opponentPokemon.level).toBe(15) + }) - test('battle state has player party', () => { - const creature = makeTestCreature() - const init = createBattle([creature], 'squirtle', 50) - expect(init.state.playerParty.length).toBeGreaterThan(0) - }) + test('battle state has player party', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + expect(init.state.playerParty.length).toBeGreaterThan(0) + }) - test('battle state has usable items (empty bag)', () => { - const creature = makeTestCreature() - const init = createBattle([creature], 'squirtle', 50) - expect(init.state.usableItems).toEqual([]) - }) + test('battle state has usable items (empty bag)', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + expect(init.state.usableItems).toEqual([]) + }) }) describe('executeTurn - extended', () => { - test('item action defaults to move 1', () => { - const creature = makeTestCreature() - const init = createBattle([creature], 'squirtle', 50) - const state = executeTurn(init, { type: 'item', itemId: 'potion' }) - expect(state).toBeDefined() - expect(state.events.length).toBeGreaterThan(0) - }) + test('item action defaults to move 1', () => { + const creature = makeTestCreature() + const init = createBattle([creature], 'squirtle', 50) + const state = executeTurn(init, { type: 'item', itemId: 'potion' }) + expect(state).toBeDefined() + expect(state.events.length).toBeGreaterThan(0) + }) - test('battle produces damage or heal events', () => { - const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } }) - const init = createBattle([creature], 'squirtle', 5) - const state = executeTurn(init, { type: 'move', moveIndex: 0 }) - const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal') - expect(hasDamageOrHeal).toBe(true) - }) + test('battle produces damage or heal events', () => { + const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } }) + const init = createBattle([creature], 'squirtle', 5) + const state = executeTurn(init, { type: 'move', moveIndex: 0 }) + const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal') + expect(hasDamageOrHeal).toBe(true) + }) }) describe('settleBattle - EV limits', () => { - test('EV total cannot exceed 510', async () => { - const creature = makeTestCreature({ - level: 5, - ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 }, - }) - const data = makeTestBuddyData([creature]) - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [creature.id], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0) - expect(totalEV).toBeLessThanOrEqual(510) - }) + test('EV total cannot exceed 510', async () => { + const creature = makeTestCreature({ + level: 5, + ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 }, + }) + const data = makeTestBuddyData([creature]) + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0) + expect(totalEV).toBeLessThanOrEqual(510) + }) - test('non-participant creatures are unchanged', async () => { - const participant = makeTestCreature({ id: 'p1', level: 5 }) - const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' }) - const data = makeTestBuddyData([participant, bystander]) - data.party = [participant.id, bystander.id, null, null, null, null] - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [participant.id], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')! - expect(bystanderAfter.totalXp).toBe(bystander.totalXp) - }) + test('non-participant creatures are unchanged', async () => { + const participant = makeTestCreature({ id: 'p1', level: 5 }) + const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' }) + const data = makeTestBuddyData([participant, bystander]) + data.party = [participant.id, bystander.id, null, null, null, null] + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [participant.id], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')! + expect(bystanderAfter.totalXp).toBe(bystander.totalXp) + }) - test('uses all party members as participants when participantIds is empty', async () => { - const c1 = makeTestCreature({ id: 'p1', level: 5 }) - const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' }) - const data = makeTestBuddyData([c1, c2]) - data.party = [c1.id, c2.id, null, null, null, null] - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [] as string[], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0) - expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0) - }) + test('uses all party members as participants when participantIds is empty', async () => { + const c1 = makeTestCreature({ id: 'p1', level: 5 }) + const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' }) + const data = makeTestBuddyData([c1, c2]) + data.party = [c1.id, c2.id, null, null, null, null] + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [] as string[], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0) + expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0) + }) - test('player win increments battlesWon but not battlesLost', async () => { - const creature = makeTestCreature({ level: 5 }) - const data = makeTestBuddyData([creature]) - const result = { - winner: 'player' as const, - turns: 3, - xpGained: 0, - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [creature.id], - } - const settlement = await settleBattle(data, result, 'squirtle', 20) - expect(settlement.data.stats.battlesWon).toBe(1) - expect(settlement.data.stats.battlesLost).toBe(0) - }) + test('player win increments battlesWon but not battlesLost', async () => { + const creature = makeTestCreature({ level: 5 }) + const data = makeTestBuddyData([creature]) + const result = { + winner: 'player' as const, + turns: 3, + xpGained: 0, + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [creature.id], + } + const settlement = await settleBattle(data, result, 'squirtle', 20) + expect(settlement.data.stats.battlesWon).toBe(1) + expect(settlement.data.stats.battlesLost).toBe(0) + }) }) describe('applyMoveLearn - extended', () => { - test('new move has correct PP from Dex', () => { - const creature = makeTestCreature() - const data = makeTestBuddyData([creature]) - const updated = applyMoveLearn(data, creature.id, 'fireblast', 0) - const move = updated.creatures[0]!.moves[0]! - expect(move.id).toBe('fireblast') - expect(move.pp).toBeGreaterThan(0) - expect(move.maxPp).toBeGreaterThan(0) - }) + test('new move has correct PP from Dex', () => { + const creature = makeTestCreature() + const data = makeTestBuddyData([creature]) + const updated = applyMoveLearn(data, creature.id, 'fireblast', 0) + const move = updated.creatures[0]!.moves[0]! + expect(move.id).toBe('fireblast') + expect(move.pp).toBeGreaterThan(0) + expect(move.maxPp).toBeGreaterThan(0) + }) - test('non-target creatures are unchanged', () => { - const c1 = makeTestCreature({ id: 't1' }) - const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' }) - const data = makeTestBuddyData([c1, c2]) - const updated = applyMoveLearn(data, 't1', 'fireblast', 0) - const unchanged = updated.creatures.find(c => c.id === 't2')! - expect(unchanged.moves[0]!.id).toBe('flamethrower') - }) + test('non-target creatures are unchanged', () => { + const c1 = makeTestCreature({ id: 't1' }) + const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' }) + const data = makeTestBuddyData([c1, c2]) + const updated = applyMoveLearn(data, 't1', 'fireblast', 0) + const unchanged = updated.creatures.find(c => c.id === 't2')! + expect(unchanged.moves[0]!.id).toBe('flamethrower') + }) }) describe('applyEvolution - extended', () => { - test('friendship increases by 10', () => { - const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 }) - const data = makeTestBuddyData([creature]) - const updated = applyEvolution(data, creature.id, 'charmeleon') - expect(updated.creatures[0]!.friendship).toBe(80) - }) + test('friendship increases by 10', () => { + const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 }) + const data = makeTestBuddyData([creature]) + const updated = applyEvolution(data, creature.id, 'charmeleon') + expect(updated.creatures[0]!.friendship).toBe(80) + }) - test('friendship capped at 255', () => { - const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 }) - const data = makeTestBuddyData([creature]) - const updated = applyEvolution(data, creature.id, 'charmeleon') - expect(updated.creatures[0]!.friendship).toBe(255) - }) + test('friendship capped at 255', () => { + const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 }) + const data = makeTestBuddyData([creature]) + const updated = applyEvolution(data, creature.id, 'charmeleon') + expect(updated.creatures[0]!.friendship).toBe(255) + }) - test('multiple evolutions increment counter correctly', () => { - const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' }) - const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' }) - const data = makeTestBuddyData([c1, c2]) - let updated = applyEvolution(data, 't1', 'charmeleon') - updated = applyEvolution(updated, 't2', 'ivysaur') - expect(updated.stats.totalEvolutions).toBe(2) - }) + test('multiple evolutions increment counter correctly', () => { + const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' }) + const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' }) + const data = makeTestBuddyData([c1, c2]) + let updated = applyEvolution(data, 't1', 'charmeleon') + updated = applyEvolution(updated, 't2', 'ivysaur') + expect(updated.stats.totalEvolutions).toBe(2) + }) }) diff --git a/packages/pokemon/src/__tests__/creature.test.ts b/packages/pokemon/src/__tests__/creature.test.ts index d8c275d4a..f5288c788 100644 --- a/packages/pokemon/src/__tests__/creature.test.ts +++ b/packages/pokemon/src/__tests__/creature.test.ts @@ -1,184 +1,184 @@ import { describe, test, expect } from 'bun:test' import type { SpeciesId, Creature } from '../types' import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/species' describe('generateCreature', () => { - test('creates a creature with correct defaults', async () => { - const c = await 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(getSpeciesData('bulbasaur').baseHappiness) - expect(c.isShiny).toBeDefined() - expect(c.id).toBeTruthy() - expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true) - expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true) - }) + test('creates a creature with correct defaults', async () => { + const c = await 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(getSpeciesData('bulbasaur').baseHappiness) + expect(c.isShiny).toBeDefined() + expect(c.id).toBeTruthy() + expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true) + expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true) + }) - test('deterministic IV generation from seed', async () => { - const c1 = await generateCreature('charmander', 12345) - const c2 = await generateCreature('charmander', 12345) - expect(c1.iv).toEqual(c2.iv) - }) + test('deterministic IV generation from seed', async () => { + const c1 = await generateCreature('charmander', 12345) + const c2 = await generateCreature('charmander', 12345) + expect(c1.iv).toEqual(c2.iv) + }) - test('different seeds produce different IVs', async () => { - const c1 = await generateCreature('squirtle', 100) - const c2 = await generateCreature('squirtle', 200) - expect(c1.iv).not.toEqual(c2.iv) - }) + test('different seeds produce different IVs', async () => { + const c1 = await generateCreature('squirtle', 100) + const c2 = await generateCreature('squirtle', 200) + expect(c1.iv).not.toEqual(c2.iv) + }) - test('all MVP species can be generated', async () => { - const species: SpeciesId[] = [ - 'bulbasaur', 'ivysaur', 'venusaur', - 'charmander', 'charmeleon', 'charizard', - 'squirtle', 'wartortle', 'blastoise', - 'pikachu', - ] - for (const s of species) { - const c = await generateCreature(s) - expect(c.speciesId).toBe(s) - } - }) + test('all MVP species can be generated', async () => { + const species: SpeciesId[] = [ + 'bulbasaur', 'ivysaur', 'venusaur', + 'charmander', 'charmeleon', 'charizard', + 'squirtle', 'wartortle', 'blastoise', + 'pikachu', + ] + for (const s of species) { + const c = await generateCreature(s) + expect(c.speciesId).toBe(s) + } + }) }) describe('calculateStats', () => { - test('level 1 stats are reasonable', async () => { - const c = await 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('level 1 stats are reasonable', async () => { + const c = await 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', async () => { - const c1 = await generateCreature('charmander', 0) - c1.level = 1 - const stats1 = calculateStats(c1) + test('stats increase with level', async () => { + const c1 = await 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) - }) + 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', async () => { - const c = await generateCreature('pikachu', 0) - const statsNoEV = calculateStats(c) + test('EVs affect stats', async () => { + const c = await generateCreature('pikachu', 0) + const statsNoEV = calculateStats(c) - const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } } - const statsWithEV = calculateStats(cWithEV) + const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } } + const statsWithEV = calculateStats(cWithEV) - expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack) - }) + expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack) + }) }) describe('getCreatureName', () => { - test('returns species name when no nickname', async () => { - const c = await generateCreature('pikachu') - c.nickname = undefined - expect(getCreatureName(c)).toBe('Pikachu') - }) + test('returns species name when no nickname', async () => { + const c = await generateCreature('pikachu') + c.nickname = undefined + expect(getCreatureName(c)).toBe('Pikachu') + }) - test('returns nickname when set', async () => { - const c = await generateCreature('pikachu') - c.nickname = 'Sparky' - expect(getCreatureName(c)).toBe('Sparky') - }) + test('returns nickname when set', async () => { + const c = await generateCreature('pikachu') + c.nickname = 'Sparky' + expect(getCreatureName(c)).toBe('Sparky') + }) }) describe('getTotalEV', () => { - test('returns 0 for new creature', async () => { - const c = await generateCreature('bulbasaur') - expect(getTotalEV(c)).toBe(0) - }) + test('returns 0 for new creature', async () => { + const c = await generateCreature('bulbasaur') + expect(getTotalEV(c)).toBe(0) + }) - test('sums all EV values', async () => { - const c = await generateCreature('bulbasaur') - c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 } - expect(getTotalEV(c)).toBe(210) - }) + test('sums all EV values', async () => { + const c = await generateCreature('bulbasaur') + c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 } + expect(getTotalEV(c)).toBe(210) + }) }) describe('recalculateLevel', () => { - test('returns same creature if level unchanged', async () => { - const c = await generateCreature('bulbasaur', 42) - const result = recalculateLevel(c) - expect(result.level).toBe(c.level) - }) + test('returns same creature if level unchanged', async () => { + const c = await generateCreature('bulbasaur', 42) + const result = recalculateLevel(c) + expect(result.level).toBe(c.level) + }) - test('updates level based on totalXp', async () => { - const c = await generateCreature('charmander', 42) - c.totalXp = 8000 - const result = recalculateLevel(c) - expect(result.level).toBeGreaterThan(1) - }) + test('updates level based on totalXp', async () => { + const c = await generateCreature('charmander', 42) + c.totalXp = 8000 + const result = recalculateLevel(c) + expect(result.level).toBeGreaterThan(1) + }) }) describe('getActiveCreature', () => { - test('returns null when party is empty', async () => { - const c = await generateCreature('bulbasaur') - const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] }) - expect(result).toBeNull() - }) + test('returns null when party is empty', async () => { + const c = await generateCreature('bulbasaur') + const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] }) + expect(result).toBeNull() + }) - test('returns creature from party[0]', async () => { - const c = await generateCreature('pikachu') - const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] }) - expect(result).not.toBeNull() - expect(result!.id).toBe(c.id) - }) + test('returns creature from party[0]', async () => { + const c = await generateCreature('pikachu') + const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] }) + expect(result).not.toBeNull() + expect(result!.id).toBe(c.id) + }) - test('returns creature from activeCreatureId (legacy)', async () => { - const c = await generateCreature('squirtle') - const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] }) - expect(result).not.toBeNull() - expect(result!.id).toBe(c.id) - }) + test('returns creature from activeCreatureId (legacy)', async () => { + const c = await generateCreature('squirtle') + const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] }) + expect(result).not.toBeNull() + expect(result!.id).toBe(c.id) + }) - test('prefers party[0] over activeCreatureId', async () => { - const c1 = await generateCreature('bulbasaur') - const c2 = await generateCreature('charmander') - const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] }) - expect(result!.id).toBe(c1.id) - }) + test('prefers party[0] over activeCreatureId', async () => { + const c1 = await generateCreature('bulbasaur') + const c2 = await generateCreature('charmander') + const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] }) + expect(result!.id).toBe(c1.id) + }) - test('returns null when creature ID not found', () => { - const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] }) - expect(result).toBeNull() - }) + test('returns null when creature ID not found', () => { + const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] }) + expect(result).toBeNull() + }) }) describe('calculateStats - nature effects', () => { - test('adamant nature boosts attack and lowers spAtk', async () => { - const c = await generateCreature('charmander', 42) - c.level = 50 - c.nature = 'adamant' - const adamantStats = calculateStats(c) + test('adamant nature boosts attack and lowers spAtk', async () => { + const c = await generateCreature('charmander', 42) + c.level = 50 + c.nature = 'adamant' + const adamantStats = calculateStats(c) - c.nature = 'hardy' - const hardyStats = calculateStats(c) + c.nature = 'hardy' + const hardyStats = calculateStats(c) - expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack) - expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk) - }) + expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack) + expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk) + }) - test('timid nature boosts speed and lowers attack', async () => { - const c = await generateCreature('pikachu', 42) - c.level = 50 - c.nature = 'timid' - const timidStats = calculateStats(c) + test('timid nature boosts speed and lowers attack', async () => { + const c = await generateCreature('pikachu', 42) + c.level = 50 + c.nature = 'timid' + const timidStats = calculateStats(c) - c.nature = 'hardy' - const hardyStats = calculateStats(c) + c.nature = 'hardy' + const hardyStats = calculateStats(c) - expect(timidStats.speed).toBeGreaterThan(hardyStats.speed) - expect(timidStats.attack).toBeLessThan(hardyStats.attack) - }) + expect(timidStats.speed).toBeGreaterThan(hardyStats.speed) + expect(timidStats.attack).toBeLessThan(hardyStats.attack) + }) }) diff --git a/packages/pokemon/src/__tests__/effort.test.ts b/packages/pokemon/src/__tests__/effort.test.ts index 3b0161fff..a2a251c01 100644 --- a/packages/pokemon/src/__tests__/effort.test.ts +++ b/packages/pokemon/src/__tests__/effort.test.ts @@ -1,79 +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' +import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping' beforeEach(() => { - resetEVCooldowns() + resetEVCooldowns() }) describe('awardEV', () => { - test('mapped tool awards correct EV', async () => { - let c = await 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('mapped tool awards correct EV', async () => { + let c = await 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', async () => { - let c = await generateCreature('bulbasaur') - c = awardEV(c, 'UnknownTool', 0) - const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0) - expect(totalEV).toBeGreaterThan(0) - }) + test('unmapped tool awards random EV', async () => { + let c = await generateCreature('bulbasaur') + c = awardEV(c, 'UnknownTool', 0) + const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0) + expect(totalEV).toBeGreaterThan(0) + }) - test('cooldown prevents repeated awards', async () => { - const now = Date.now() - let c = await 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('cooldown prevents repeated awards', async () => { + const now = Date.now() + let c = await 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', async () => { - let c = await 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 per-stat EV cap', async () => { + let c = await 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', async () => { - let c = await 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: number, b: number) => a + b, 0) - expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL) - }) + test('respects total EV cap', async () => { + let c = await 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: number, b: number) => a + b, 0) + expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL) + }) }) describe('awardTurnEV', () => { - test('awards EV for multiple tools', async () => { - let c = await generateCreature('bulbasaur') - c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0) - const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0) - expect(totalEV).toBeGreaterThan(0) - }) + test('awards EV for multiple tools', async () => { + let c = await generateCreature('bulbasaur') + c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0) + const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0) + expect(totalEV).toBeGreaterThan(0) + }) }) describe('getEVSummary', () => { - test('returns "None" for new creature', async () => { - const c = await generateCreature('bulbasaur') - expect(getEVSummary(c)).toBe('None') - }) + test('returns "None" for new creature', async () => { + const c = await generateCreature('bulbasaur') + expect(getEVSummary(c)).toBe('None') + }) - test('shows stat breakdown', async () => { - const c = await 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') - }) + test('shows stat breakdown', async () => { + const c = await 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') + }) }) diff --git a/packages/pokemon/src/__tests__/egg.test.ts b/packages/pokemon/src/__tests__/egg.test.ts index 6cc8b3305..c0c2084d4 100644 --- a/packages/pokemon/src/__tests__/egg.test.ts +++ b/packages/pokemon/src/__tests__/egg.test.ts @@ -4,157 +4,157 @@ import type { BuddyData } from '../types' import { generateCreature } from '../core/creature' function makeBuddyData(overrides: Partial = {}): BuddyData { - const creature = generateCreature('bulbasaur') - // Sync mock — generateCreature is async but for test setup we use the resolved structure - return { - version: 2, - party: ['test-creature-id', null, null, null, null, null], - boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }], - creatures: [{ - id: 'test-creature-id', - speciesId: 'bulbasaur', - gender: 'male' as const, - level: 5, - xp: 0, - totalXp: 100, - nature: 'hardy', - ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 }, - moves: [ - { id: 'tackle', pp: 35, maxPp: 35 }, - { id: '', pp: 0, maxPp: 0 }, - { id: '', pp: 0, maxPp: 0 }, - { id: '', pp: 0, maxPp: 0 }, - ], - ability: 'overgrow', - heldItem: null, - friendship: 70, - isShiny: false, - hatchedAt: Date.now(), - pokeball: 'pokeball', - }], - eggs: [], - dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }], - bag: { items: [] }, - stats: { - totalTurns: 50, - consecutiveDays: 7, - lastActiveDate: new Date().toISOString().split('T')[0], - totalEggsObtained: 0, - totalEvolutions: 0, - battlesWon: 0, - battlesLost: 0, - ...overrides, - }, - } + const creature = generateCreature('bulbasaur') + // Sync mock — generateCreature is async but for test setup we use the resolved structure + return { + version: 2, + party: ['test-creature-id', null, null, null, null, null], + boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }], + creatures: [{ + id: 'test-creature-id', + speciesId: 'bulbasaur', + gender: 'male' as const, + level: 5, + xp: 0, + totalXp: 100, + nature: 'hardy', + ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 }, + moves: [ + { id: 'tackle', pp: 35, maxPp: 35 }, + { id: '', pp: 0, maxPp: 0 }, + { id: '', pp: 0, maxPp: 0 }, + { id: '', pp: 0, maxPp: 0 }, + ], + ability: 'overgrow', + heldItem: null, + friendship: 70, + isShiny: false, + hatchedAt: Date.now(), + pokeball: 'pokeball', + }], + eggs: [], + dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }], + bag: { items: [] }, + stats: { + totalTurns: 50, + consecutiveDays: 7, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + ...overrides, + }, + } } describe('checkEggEligibility', () => { - test('eligible when conditions met', () => { - const data = makeBuddyData() - expect(checkEggEligibility(data)).toBe(true) - }) + 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 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: 2 }) - expect(checkEggEligibility(data)).toBe(false) - }) + test('not eligible with low consecutive days', () => { + const data = makeBuddyData({ consecutiveDays: 2 }) + expect(checkEggEligibility(data)).toBe(false) + }) - test('not eligible when turns not multiple of 50', () => { - const data = makeBuddyData({ totalTurns: 51 }) - 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('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) - }) + 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('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) - }) + 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('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) - }) + 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) + }) }) describe('hatchEgg', () => { - test('creates a creature and removes egg', async () => { - const data = makeBuddyData() - const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const } - const result = await hatchEgg(data, egg) + test('creates a creature and removes egg', async () => { + const data = makeBuddyData() + const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const } + const result = await hatchEgg(data, egg) - expect(result.creature.speciesId).toBe('charmander') - expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1) - expect(result.buddyData.eggs.length).toBe(0) - }) + expect(result.creature.speciesId).toBe('charmander') + expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1) + expect(result.buddyData.eggs.length).toBe(0) + }) - test('adds creature to party when slot available', async () => { - const data = makeBuddyData() - const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const } - const result = await hatchEgg(data, egg) - const newCreature = result.creature - const inParty = result.buddyData.party.includes(newCreature.id) - expect(inParty).toBe(true) - }) + test('adds creature to party when slot available', async () => { + const data = makeBuddyData() + const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const } + const result = await hatchEgg(data, egg) + const newCreature = result.creature + const inParty = result.buddyData.party.includes(newCreature.id) + expect(inParty).toBe(true) + }) - test('increments totalEggsObtained', async () => { - const data = makeBuddyData() - const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const } - const result = await hatchEgg(data, egg) - expect(result.buddyData.stats.totalEggsObtained).toBe(1) - }) + test('increments totalEggsObtained', async () => { + const data = makeBuddyData() + const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const } + const result = await hatchEgg(data, egg) + expect(result.buddyData.stats.totalEggsObtained).toBe(1) + }) - test('updates dex entry with new species', async () => { - const data = makeBuddyData() - const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const } - const result = await hatchEgg(data, egg) - const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander') - expect(entry).toBeDefined() - expect(entry!.caughtCount).toBe(1) - }) + test('updates dex entry with new species', async () => { + const data = makeBuddyData() + const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const } + const result = await hatchEgg(data, egg) + const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander') + expect(entry).toBeDefined() + expect(entry!.caughtCount).toBe(1) + }) - test('increments caughtCount for existing dex entry', async () => { - const data = makeBuddyData() - const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const } - const result = await hatchEgg(data, egg) - const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur') - expect(entry!.caughtCount).toBe(2) - }) + test('increments caughtCount for existing dex entry', async () => { + const data = makeBuddyData() + const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const } + const result = await hatchEgg(data, egg) + const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur') + expect(entry!.caughtCount).toBe(2) + }) }) diff --git a/packages/pokemon/src/__tests__/evMapping.test.ts b/packages/pokemon/src/__tests__/evMapping.test.ts index 74deb8442..96be1a217 100644 --- a/packages/pokemon/src/__tests__/evMapping.test.ts +++ b/packages/pokemon/src/__tests__/evMapping.test.ts @@ -1,39 +1,39 @@ import { describe, test, expect } from 'bun:test' -import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping' +import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/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 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('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) - } - }) + 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_PER_STAT is 252', () => { + expect(MAX_EV_PER_STAT).toBe(252) + }) - test('MAX_EV_TOTAL is 510', () => { - expect(MAX_EV_TOTAL).toBe(510) - }) + test('MAX_EV_TOTAL is 510', () => { + expect(MAX_EV_TOTAL).toBe(510) + }) }) diff --git a/packages/pokemon/src/__tests__/evolution.test.ts b/packages/pokemon/src/__tests__/evolution.test.ts index 49525d8fd..07a68514a 100644 --- a/packages/pokemon/src/__tests__/evolution.test.ts +++ b/packages/pokemon/src/__tests__/evolution.test.ts @@ -3,122 +3,122 @@ import type { Creature } from '../types' import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution' function makeEvolutionCreature(overrides: Partial = {}): Creature { - return { - id: 'test-evo', - speciesId: overrides.speciesId ?? 'bulbasaur', - gender: 'male', - level: overrides.level ?? 50, - xp: 0, - totalXp: 0, - nature: 'hardy', - ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 }, - moves: [ - { id: 'tackle', pp: 35, maxPp: 35 }, - { id: 'growl', pp: 40, maxPp: 40 }, - { id: 'vinewhip', pp: 15, maxPp: 15 }, - { id: 'razorleaf', pp: 10, maxPp: 10 }, - ], - ability: 'overgrow', - heldItem: null, - friendship: overrides.friendship ?? 70, - isShiny: false, - hatchedAt: Date.now(), - pokeball: 'pokeball', - } + return { + id: 'test-evo', + speciesId: overrides.speciesId ?? 'bulbasaur', + gender: 'male', + level: overrides.level ?? 50, + xp: 0, + totalXp: 0, + nature: 'hardy', + ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 }, + moves: [ + { id: 'tackle', pp: 35, maxPp: 35 }, + { id: 'growl', pp: 40, maxPp: 40 }, + { id: 'vinewhip', pp: 15, maxPp: 15 }, + { id: 'razorleaf', pp: 10, maxPp: 10 }, + ], + ability: 'overgrow', + heldItem: null, + friendship: overrides.friendship ?? 70, + isShiny: false, + hatchedAt: Date.now(), + pokeball: 'pokeball', + } } describe('checkEvolution', () => { - test('bulbasaur at level 15 cannot evolve', () => { - const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 }) - expect(checkEvolution(creature)).toBeNull() - }) + test('bulbasaur at level 15 cannot evolve', () => { + const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 }) + expect(checkEvolution(creature)).toBeNull() + }) - test('bulbasaur at level 16 can evolve into ivysaur', () => { - const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 }) - const result = checkEvolution(creature) - expect(result).not.toBeNull() - expect(result!.from).toBe('bulbasaur') - expect(result!.to).toBe('ivysaur') - }) + test('bulbasaur at level 16 can evolve into ivysaur', () => { + const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 }) + 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 = makeEvolutionCreature({ speciesId: 'charmander', level: 16 }) - const result = checkEvolution(creature) - expect(result!.to).toBe('charmeleon') - }) + test('charmander at level 16 evolves into charmeleon', () => { + const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 }) + const result = checkEvolution(creature) + expect(result!.to).toBe('charmeleon') + }) - test('charmeleon at level 36 evolves into charizard', () => { - const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 }) - const result = checkEvolution(creature) - expect(result!.to).toBe('charizard') - }) + test('charmeleon at level 36 evolves into charizard', () => { + const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 }) + const result = checkEvolution(creature) + expect(result!.to).toBe('charizard') + }) - test('squirtle at level 16 evolves into wartortle', () => { - const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 }) - const result = checkEvolution(creature) - expect(result!.to).toBe('wartortle') - }) + test('squirtle at level 16 evolves into wartortle', () => { + const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 }) + const result = checkEvolution(creature) + expect(result!.to).toBe('wartortle') + }) - test('wartortle at level 36 evolves into blastoise', () => { - const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 }) - const result = checkEvolution(creature) - expect(result!.to).toBe('blastoise') - }) + test('wartortle at level 36 evolves into blastoise', () => { + const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 }) + const result = checkEvolution(creature) + expect(result!.to).toBe('blastoise') + }) - test('venusaur cannot evolve further', () => { - const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 }) - expect(checkEvolution(creature)).toBeNull() - }) + test('venusaur cannot evolve further', () => { + const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 }) + expect(checkEvolution(creature)).toBeNull() + }) - test('pikachu cannot evolve in MVP', () => { - const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 }) - expect(checkEvolution(creature)).toBeNull() - }) + test('pikachu cannot evolve in MVP', () => { + const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 }) + expect(checkEvolution(creature)).toBeNull() + }) - test('level 100 bulbasaur can still evolve (level >= minLevel)', () => { - const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 }) - const result = checkEvolution(creature) - expect(result).not.toBeNull() - expect(result!.to).toBe('ivysaur') - }) + test('level 100 bulbasaur can still evolve (level >= minLevel)', () => { + const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 }) + const result = checkEvolution(creature) + expect(result).not.toBeNull() + expect(result!.to).toBe('ivysaur') + }) }) describe('evolve', () => { - test('changes species and boosts friendship', () => { - const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 }) - const evolved = evolve(creature, 'ivysaur') - expect(evolved.speciesId).toBe('ivysaur') - expect(evolved.friendship).toBe(80) // +10 friendship on evolution - }) + test('changes species and boosts friendship', () => { + const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 }) + const evolved = evolve(creature, 'ivysaur') + expect(evolved.speciesId).toBe('ivysaur') + expect(evolved.friendship).toBe(80) // +10 friendship on evolution + }) - test('friendship is capped at 255', () => { - const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 }) - const evolved = evolve(creature, 'ivysaur') - expect(evolved.friendship).toBe(255) - }) + test('friendship is capped at 255', () => { + const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 }) + const evolved = evolve(creature, 'ivysaur') + expect(evolved.friendship).toBe(255) + }) }) describe('canEvolveFurther', () => { - test('starter species can evolve', () => { - expect(canEvolveFurther('bulbasaur')).toBe(true) - expect(canEvolveFurther('charmander')).toBe(true) - expect(canEvolveFurther('squirtle')).toBe(true) - }) + 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('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('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) - }) + test('pikachu cannot evolve in MVP', () => { + expect(canEvolveFurther('pikachu')).toBe(false) + }) }) diff --git a/packages/pokemon/src/__tests__/experience.test.ts b/packages/pokemon/src/__tests__/experience.test.ts index fe73e9ad9..13489d39a 100644 --- a/packages/pokemon/src/__tests__/experience.test.ts +++ b/packages/pokemon/src/__tests__/experience.test.ts @@ -1,153 +1,153 @@ import { describe, test, expect } from 'bun:test' import { generateCreature } from '../core/creature' import { awardXP, getXpProgress } from '../core/experience' -import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable' +import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable' describe('xpForLevel', () => { - test('level 1 requires 0 XP', () => { - expect(xpForLevel(1, 'medium-slow')).toBe(0) - }) + 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('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('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('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')) - } - }) + 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('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('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) - }) + 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', async () => { - const c = await generateCreature('bulbasaur') - const result = awardXP(c, 10) - expect(result.creature.totalXp).toBe(10) - expect(result.leveledUp).toBeDefined() - }) + test('awards XP and returns updated creature', async () => { + const c = await generateCreature('bulbasaur') + const result = awardXP(c, 10) + expect(result.creature.totalXp).toBe(10) + expect(result.leveledUp).toBeDefined() + }) - test('large XP can cause level up', async () => { - const c = await 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('large XP can cause level up', async () => { + const c = await 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', async () => { - const c = await generateCreature('bulbasaur') - c.level = 100 - c.totalXp = 1000000 - const result = awardXP(c, 999999) - expect(result.creature.level).toBe(100) - expect(result.leveledUp).toBe(false) - }) + test('level capped at 100', async () => { + const c = await 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', async () => { - const c = await generateCreature('bulbasaur') - const progress = getXpProgress(c) - expect(progress.current).toBe(0) - expect(progress.percentage).toBe(0) - }) + test('new creature has 0 XP progress', async () => { + const c = await generateCreature('bulbasaur') + const progress = getXpProgress(c) + expect(progress.current).toBe(0) + expect(progress.percentage).toBe(0) + }) - test('level 100 creature has 100% progress', async () => { - const c = await generateCreature('charmander') - c.level = 100 - c.totalXp = 1000000 - const progress = getXpProgress(c) - expect(progress.percentage).toBe(100) - }) + test('level 100 creature has 100% progress', async () => { + const c = await generateCreature('charmander') + c.level = 100 + c.totalXp = 1000000 + const progress = getXpProgress(c) + expect(progress.percentage).toBe(100) + }) - test('needed is positive for sub-100 creatures', async () => { - const c = await generateCreature('bulbasaur') - c.level = 5 - c.totalXp = xpForLevel(5, 'medium-slow') - const progress = getXpProgress(c) - expect(progress.needed).toBeGreaterThan(0) - expect(progress.current).toBe(0) - }) + test('needed is positive for sub-100 creatures', async () => { + const c = await generateCreature('bulbasaur') + c.level = 5 + c.totalXp = xpForLevel(5, 'medium-slow') + const progress = getXpProgress(c) + expect(progress.needed).toBeGreaterThan(0) + expect(progress.current).toBe(0) + }) }) describe('xpToNextLevel', () => { - test('returns XP needed from current to next level', () => { - const xp10 = xpForLevel(10, 'medium-fast') - const xp11 = xpForLevel(11, 'medium-fast') - const needed = xpToNextLevel(10, xp10, 'medium-fast') - expect(needed).toBe(xp11 - xp10) - }) + test('returns XP needed from current to next level', () => { + const xp10 = xpForLevel(10, 'medium-fast') + const xp11 = xpForLevel(11, 'medium-fast') + const needed = xpToNextLevel(10, xp10, 'medium-fast') + expect(needed).toBe(xp11 - xp10) + }) - test('returns 0 at level 100', () => { - expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0) - }) + test('returns 0 at level 100', () => { + expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0) + }) - test('accounts for partial XP already earned', () => { - const xp10 = xpForLevel(10, 'medium-fast') - const xp11 = xpForLevel(11, 'medium-fast') - const halfWay = xp10 + Math.floor((xp11 - xp10) / 2) - const needed = xpToNextLevel(10, halfWay, 'medium-fast') - expect(needed).toBe(xp11 - halfWay) - }) + test('accounts for partial XP already earned', () => { + const xp10 = xpForLevel(10, 'medium-fast') + const xp11 = xpForLevel(11, 'medium-fast') + const halfWay = xp10 + Math.floor((xp11 - xp10) / 2) + const needed = xpToNextLevel(10, halfWay, 'medium-fast') + expect(needed).toBe(xp11 - halfWay) + }) }) describe('awardXP - extended', () => { - test('awarding 0 XP returns unchanged creature', async () => { - const c = await generateCreature('bulbasaur') - const result = awardXP(c, 0) - expect(result.creature.totalXp).toBe(c.totalXp) - expect(result.leveledUp).toBe(false) - }) + test('awarding 0 XP returns unchanged creature', async () => { + const c = await generateCreature('bulbasaur') + const result = awardXP(c, 0) + expect(result.creature.totalXp).toBe(c.totalXp) + expect(result.leveledUp).toBe(false) + }) - test('XP progress is correctly calculated after award', async () => { - const c = await generateCreature('squirtle') - const xpNeeded = xpForLevel(2, 'medium-slow') - const result = awardXP(c, Math.floor(xpNeeded / 2)) - expect(result.creature.xp).toBeGreaterThanOrEqual(0) - }) + test('XP progress is correctly calculated after award', async () => { + const c = await generateCreature('squirtle') + const xpNeeded = xpForLevel(2, 'medium-slow') + const result = awardXP(c, Math.floor(xpNeeded / 2)) + expect(result.creature.xp).toBeGreaterThanOrEqual(0) + }) - test('multiple small XP awards equal one large award', async () => { - const c1 = await generateCreature('bulbasaur', 42) - const c2 = await generateCreature('bulbasaur', 42) - c2.totalXp = c1.totalXp + test('multiple small XP awards equal one large award', async () => { + const c1 = await generateCreature('bulbasaur', 42) + const c2 = await generateCreature('bulbasaur', 42) + c2.totalXp = c1.totalXp - let current = c1 - for (let i = 0; i < 10; i++) { - current = awardXP(current, 100).creature - } - const bigResult = awardXP(c2, 1000) + let current = c1 + for (let i = 0; i < 10; i++) { + current = awardXP(current, 100).creature + } + const bigResult = awardXP(c2, 1000) - expect(current.totalXp).toBe(bigResult.creature.totalXp) - expect(current.level).toBe(bigResult.creature.level) - }) + expect(current.totalXp).toBe(bigResult.creature.totalXp) + expect(current.level).toBe(bigResult.creature.level) + }) }) diff --git a/packages/pokemon/src/__tests__/fallback.test.ts b/packages/pokemon/src/__tests__/fallback.test.ts index 95f3b581b..48372340b 100644 --- a/packages/pokemon/src/__tests__/fallback.test.ts +++ b/packages/pokemon/src/__tests__/fallback.test.ts @@ -3,26 +3,26 @@ 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 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('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) - } - }) + 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__/gender.test.ts b/packages/pokemon/src/__tests__/gender.test.ts index 14839aa8b..bac662380 100644 --- a/packages/pokemon/src/__tests__/gender.test.ts +++ b/packages/pokemon/src/__tests__/gender.test.ts @@ -1,51 +1,51 @@ import { describe, test, expect } from 'bun:test' import { determineGender, getGenderSymbol } from '../core/gender' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/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 = getSpeciesData('pikachu') - expect(pikachu.genderRate).toBe(4) - }) + 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 = getSpeciesData('pikachu') + expect(pikachu.genderRate).toBe(4) + }) - test('pikachu 50% female ratio', () => { - const pikachu = getSpeciesData('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('pikachu 50% female ratio', () => { + const pikachu = getSpeciesData('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 = getSpeciesData('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) - }) + test('starters are ~12.5% female', () => { + const bulbasaur = getSpeciesData('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('') - }) + test('male symbol', () => { + expect(getGenderSymbol('male')).toBe('♂') + }) + test('female symbol', () => { + expect(getGenderSymbol('female')).toBe('♀') + }) + test('genderless has no symbol', () => { + expect(getGenderSymbol('genderless')).toBe('') + }) }) diff --git a/packages/pokemon/src/__tests__/learnsets.test.ts b/packages/pokemon/src/__tests__/learnsets.test.ts index 8888cd642..1ae9ab101 100644 --- a/packages/pokemon/src/__tests__/learnsets.test.ts +++ b/packages/pokemon/src/__tests__/learnsets.test.ts @@ -1,59 +1,59 @@ import { describe, test, expect } from 'bun:test' -import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../data/learnsets' +import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../dex/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 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('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('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) - }) + 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('charmander has blaze', () => { + expect(getDefaultAbility('charmander')).toBe('blaze') + }) - test('bulbasaur has overgrow', () => { - expect(getDefaultAbility('bulbasaur')).toBe('overgrow') - }) + test('bulbasaur has overgrow', () => { + expect(getDefaultAbility('bulbasaur')).toBe('overgrow') + }) - test('squirtle has torrent', () => { - expect(getDefaultAbility('squirtle')).toBe('torrent') - }) + 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('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) - }) + 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__/names.test.ts b/packages/pokemon/src/__tests__/names.test.ts index fe224e899..20916cdca 100644 --- a/packages/pokemon/src/__tests__/names.test.ts +++ b/packages/pokemon/src/__tests__/names.test.ts @@ -1,44 +1,44 @@ import { describe, test, expect } from 'bun:test' -import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names' +import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/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('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') - }) + 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 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('杰尼龟') - }) + 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('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) - } - }) + 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__/nature.test.ts b/packages/pokemon/src/__tests__/nature.test.ts index f22482f09..20baf2c6a 100644 --- a/packages/pokemon/src/__tests__/nature.test.ts +++ b/packages/pokemon/src/__tests__/nature.test.ts @@ -1,53 +1,53 @@ import { describe, test, expect } from 'bun:test' -import { getAllNatureNames, randomNature, getNatureEffect } from '../data/nature' +import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature' describe('getAllNatureNames', () => { - test('returns 25 nature names', () => { - const names = getAllNatureNames() - expect(names.length).toBe(25) - }) + 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') - }) + 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('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) - }) + 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('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('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('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() - }) + test('invalid nature returns neutral', () => { + const effect = getNatureEffect('nonexistent') + expect(effect.plus).toBeNull() + expect(effect.minus).toBeNull() + }) }) diff --git a/packages/pokemon/src/__tests__/pkmn.test.ts b/packages/pokemon/src/__tests__/pkmn.test.ts index c17837fa7..08b5a375a 100644 --- a/packages/pokemon/src/__tests__/pkmn.test.ts +++ b/packages/pokemon/src/__tests__/pkmn.test.ts @@ -1,46 +1,46 @@ import { describe, test, expect } from 'bun:test' -import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../data/pkmn' +import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../dex/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') - }) + 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') - }) + 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, - }) - }) + 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('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 - }) + 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 + }) }) diff --git a/packages/pokemon/src/__tests__/renderer.test.ts b/packages/pokemon/src/__tests__/renderer.test.ts index 654baf674..5400bbdcc 100644 --- a/packages/pokemon/src/__tests__/renderer.test.ts +++ b/packages/pokemon/src/__tests__/renderer.test.ts @@ -2,102 +2,102 @@ import { describe, expect, test } from 'bun:test' import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer' describe('renderAnimatedSprite', () => { - const testSprite = [ - ' AB', - ' C D', - ] + const testSprite = [ + ' AB', + ' C D', + ] - test('idle mode returns original sprite (with ANSI resets)', () => { - const result = renderAnimatedSprite(testSprite, 0, 'idle') - expect(result.length).toBe(2) - // Each row should contain the original characters - expect(result[0]).toContain('A') - expect(result[0]).toContain('B') - }) + test('idle mode returns original sprite (with ANSI resets)', () => { + const result = renderAnimatedSprite(testSprite, 0, 'idle') + expect(result.length).toBe(2) + // Each row should contain the original characters + expect(result[0]).toContain('A') + expect(result[0]).toContain('B') + }) - test('flip reverses rows', () => { - const flipped = renderAnimatedSprite(testSprite, 0, 'flip') - expect(flipped[0]).toContain('B') - expect(flipped[0]).toContain('A') - }) + test('flip reverses rows', () => { + const flipped = renderAnimatedSprite(testSprite, 0, 'flip') + expect(flipped[0]).toContain('B') + expect(flipped[0]).toContain('A') + }) - test('blink replaces eye characters with dash', () => { - const sprite = [' O ', ' O '] - const result = renderAnimatedSprite(sprite, 0, 'blink') - expect(result[0]).toContain('—') - expect(result[1]).toContain('—') - }) + test('blink replaces eye characters with dash', () => { + const sprite = [' O ', ' O '] + const result = renderAnimatedSprite(sprite, 0, 'blink') + expect(result[0]).toContain('—') + expect(result[1]).toContain('—') + }) - test('bounce shifts sprite up', () => { - const result = renderAnimatedSprite(testSprite, 2, 'bounce') - // Bounce at tick 2 should shift up by some amount - expect(result.length).toBe(2) - }) + test('bounce shifts sprite up', () => { + const result = renderAnimatedSprite(testSprite, 2, 'bounce') + // Bounce at tick 2 should shift up by some amount + expect(result.length).toBe(2) + }) - test('excited mode shifts horizontally', () => { - const result = renderAnimatedSprite(testSprite, 0, 'excited') - expect(result.length).toBe(2) - }) + test('excited mode shifts horizontally', () => { + const result = renderAnimatedSprite(testSprite, 0, 'excited') + expect(result.length).toBe(2) + }) - test('walkRight shifts progressively', () => { - const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight') - const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight') - // Different ticks should produce different horizontal positions - expect(r0).toBeDefined() - expect(r1).toBeDefined() - }) + test('walkRight shifts progressively', () => { + const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight') + const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight') + // Different ticks should produce different horizontal positions + expect(r0).toBeDefined() + expect(r1).toBeDefined() + }) - test('walkLeft mode shifts', () => { - const result = renderAnimatedSprite(testSprite, 0, 'walkLeft') - expect(result.length).toBe(2) - }) + test('walkLeft mode shifts', () => { + const result = renderAnimatedSprite(testSprite, 0, 'walkLeft') + expect(result.length).toBe(2) + }) - test('pet mode returns sprite unchanged', () => { - const result = renderAnimatedSprite(testSprite, 0, 'pet') - expect(result.length).toBe(2) - }) + test('pet mode returns sprite unchanged', () => { + const result = renderAnimatedSprite(testSprite, 0, 'pet') + expect(result.length).toBe(2) + }) }) describe('getIdleAnimMode', () => { - test('returns valid AnimMode for any tick', () => { - const modes = new Set() - for (let i = 0; i < 100; i++) { - modes.add(getIdleAnimMode(i)) - } - expect(modes.size).toBeGreaterThan(1) - }) + test('returns valid AnimMode for any tick', () => { + const modes = new Set() + for (let i = 0; i < 100; i++) { + modes.add(getIdleAnimMode(i)) + } + expect(modes.size).toBeGreaterThan(1) + }) - test('cycles through sequence', () => { - // First tick should be 'idle' (first element of IDLE_SEQUENCE) - expect(getIdleAnimMode(0)).toBe('idle') - }) + test('cycles through sequence', () => { + // First tick should be 'idle' (first element of IDLE_SEQUENCE) + expect(getIdleAnimMode(0)).toBe('idle') + }) - test('wraps around after sequence length', () => { - const mode0 = getIdleAnimMode(0) - const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length - expect(mode0).toBe(modeAfterFullCycle) - }) + test('wraps around after sequence length', () => { + const mode0 = getIdleAnimMode(0) + const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length + expect(mode0).toBe(modeAfterFullCycle) + }) }) describe('getPetOverlay', () => { - test('returns two lines', () => { - const overlay = getPetOverlay(0) - expect(overlay.length).toBe(2) - }) + test('returns two lines', () => { + const overlay = getPetOverlay(0) + expect(overlay.length).toBe(2) + }) - test('contains heart characters', () => { - const overlay = getPetOverlay(0) - const combined = overlay.join('') - expect(combined).toContain('♥') - }) + test('contains heart characters', () => { + const overlay = getPetOverlay(0) + const combined = overlay.join('') + expect(combined).toContain('♥') + }) - test('cycles through overlays', () => { - const o0 = getPetOverlay(0) - const o1 = getPetOverlay(1) - expect(o0).not.toEqual(o1) - }) + test('cycles through overlays', () => { + const o0 = getPetOverlay(0) + const o1 = getPetOverlay(1) + expect(o0).not.toEqual(o1) + }) - test('wraps around', () => { - expect(getPetOverlay(0)).toEqual(getPetOverlay(5)) - }) + test('wraps around', () => { + expect(getPetOverlay(0)).toEqual(getPetOverlay(5)) + }) }) diff --git a/packages/pokemon/src/__tests__/species.test.ts b/packages/pokemon/src/__tests__/species.test.ts index c12eef47a..cb8576334 100644 --- a/packages/pokemon/src/__tests__/species.test.ts +++ b/packages/pokemon/src/__tests__/species.test.ts @@ -1,95 +1,95 @@ import { describe, test, expect } from 'bun:test' -import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../data/species' +import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../dex/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 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('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 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 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 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() - }) + 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) - } - }) + 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') - }) + 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') + }) }) describe('ensureSpeciesData', () => { - test('resolves without error', async () => { - await expect(ensureSpeciesData()).resolves.toBeUndefined() - }) + test('resolves without error', async () => { + await expect(ensureSpeciesData()).resolves.toBeUndefined() + }) }) describe('getSpeciesData - supplementary fields', () => { - test('has baseHappiness', () => { - expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70) - }) + test('has baseHappiness', () => { + expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70) + }) - test('pikachu has higher captureRate', () => { - expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate) - }) + test('pikachu has higher captureRate', () => { + expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate) + }) - test('has names with en key', () => { - const data = getSpeciesData('charmander') - expect(data.names).toBeDefined() - expect(data.names.en).toBe('Charmander') - }) + test('has names with en key', () => { + const data = getSpeciesData('charmander') + expect(data.names).toBeDefined() + expect(data.names.en).toBe('Charmander') + }) - test('shinyChance is 1/4096', () => { - expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096) - }) + test('shinyChance is 1/4096', () => { + expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096) + }) }) diff --git a/packages/pokemon/src/__tests__/spriteCache.test.ts b/packages/pokemon/src/__tests__/spriteCache.test.ts index 5fad9a7f0..eac0d211b 100644 --- a/packages/pokemon/src/__tests__/spriteCache.test.ts +++ b/packages/pokemon/src/__tests__/spriteCache.test.ts @@ -2,28 +2,28 @@ import { describe, test, expect } from 'bun:test' import { getSpeciesDisplay, loadSprite } from '../core/spriteCache' describe('getSpeciesDisplay', () => { - test('formats charmander display', () => { - expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander') - }) + test('formats charmander display', () => { + expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander') + }) - test('formats pikachu display', () => { - expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu') - }) + test('formats pikachu display', () => { + expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu') + }) - test('formats bulbasaur display', () => { - expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur') - }) + test('formats bulbasaur display', () => { + expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur') + }) - test('pads dex number to 3 digits', () => { - expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle') - }) + test('pads dex number to 3 digits', () => { + expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle') + }) }) describe('loadSprite', () => { - test('returns null when no cache exists', () => { - // Uses a temp directory via getSpritesDir, should return null for non-cached - const result = loadSprite('nonexistent_pokemon' as any) - // Will be null since the file doesn't exist - expect(result).toBeNull() - }) + test('returns null when no cache exists', () => { + // Uses a temp directory via getSpritesDir, should return null for non-cached + const result = loadSprite('nonexistent_pokemon' as any) + // Will be null since the file doesn't exist + expect(result).toBeNull() + }) }) diff --git a/packages/pokemon/src/__tests__/storage.test.ts b/packages/pokemon/src/__tests__/storage.test.ts index 357fcf71e..e4371c6be 100644 --- a/packages/pokemon/src/__tests__/storage.test.ts +++ b/packages/pokemon/src/__tests__/storage.test.ts @@ -1,380 +1,380 @@ import { describe, test, expect } from 'bun:test' import { - getDefaultBuddyData, - addToParty, removeFromParty, swapPartySlots, setActivePartyMember, - depositToBox, withdrawFromBox, moveInBox, renameBox, - findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds, - addItemToBag, removeItemFromBag, getItemCount, - updateDailyStats, incrementTurns, + getDefaultBuddyData, + addToParty, removeFromParty, swapPartySlots, setActivePartyMember, + depositToBox, withdrawFromBox, moveInBox, renameBox, + findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds, + addItemToBag, removeItemFromBag, getItemCount, + updateDailyStats, incrementTurns, } from '../core/storage' import type { BuddyData } from '../types' function makeData(creatureCount = 1): BuddyData { - const creatures = Array.from({ length: creatureCount }, (_, i) => ({ - id: `creature-${i}`, - speciesId: 'bulbasaur' as const, - gender: 'male' as const, - level: 5, - xp: 0, - totalXp: 100, - nature: 'hardy', - ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 }, - moves: [ - { id: 'tackle', pp: 35, maxPp: 35 }, - { id: '', pp: 0, maxPp: 0 }, - { id: '', pp: 0, maxPp: 0 }, - { id: '', pp: 0, maxPp: 0 }, - ] as [any, any, any, any], - ability: 'overgrow', - heldItem: null, - friendship: 70, - isShiny: false, - hatchedAt: Date.now(), - pokeball: 'pokeball', - })) + const creatures = Array.from({ length: creatureCount }, (_, i) => ({ + id: `creature-${i}`, + speciesId: 'bulbasaur' as const, + gender: 'male' as const, + level: 5, + xp: 0, + totalXp: 100, + nature: 'hardy', + ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 }, + moves: [ + { id: 'tackle', pp: 35, maxPp: 35 }, + { id: '', pp: 0, maxPp: 0 }, + { id: '', pp: 0, maxPp: 0 }, + { id: '', pp: 0, maxPp: 0 }, + ] as [any, any, any, any], + ability: 'overgrow', + heldItem: null, + friendship: 70, + isShiny: false, + hatchedAt: Date.now(), + pokeball: 'pokeball', + })) - const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null] - if (creatureCount > 1) party[1] = creatures[1]!.id - if (creatureCount > 2) party[2] = creatures[2]!.id + const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null] + if (creatureCount > 1) party[1] = creatures[1]!.id + if (creatureCount > 2) party[2] = creatures[2]!.id - return { - version: 2, - party, - boxes: [ - { name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] }, - { name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] }, - ], - creatures, - eggs: [], - dex: [], - bag: { items: [] }, - stats: { - totalTurns: 10, - consecutiveDays: 5, - lastActiveDate: new Date().toISOString().split('T')[0], - totalEggsObtained: 0, - totalEvolutions: 0, - battlesWon: 3, - battlesLost: 1, - }, - } + return { + version: 2, + party, + boxes: [ + { name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] }, + { name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] }, + ], + creatures, + eggs: [], + dex: [], + bag: { items: [] }, + stats: { + totalTurns: 10, + consecutiveDays: 5, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 3, + battlesLost: 1, + }, + } } // ─── Default data ─── 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('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('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') - }) + 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') + }) }) // ─── Party operations ─── describe('addToParty', () => { - test('adds creature to first empty slot', () => { - const data = makeData() - const result = addToParty(data, 'new-creature') - expect(result.added).toBe(true) - expect(result.data.party[1]).toBe('new-creature') - }) + test('adds creature to first empty slot', () => { + const data = makeData() + const result = addToParty(data, 'new-creature') + expect(result.added).toBe(true) + expect(result.data.party[1]).toBe('new-creature') + }) - test('returns false when party is full', () => { - const data = makeData() - data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6'] - const result = addToParty(data, 'new-creature') - expect(result.added).toBe(false) - }) + test('returns false when party is full', () => { + const data = makeData() + data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6'] + const result = addToParty(data, 'new-creature') + expect(result.added).toBe(false) + }) }) describe('removeFromParty', () => { - test('removes creature at index', () => { - const data = makeData(2) - const updated = removeFromParty(data, 1) - expect(updated.party[1]).toBeNull() - }) + test('removes creature at index', () => { + const data = makeData(2) + const updated = removeFromParty(data, 1) + expect(updated.party[1]).toBeNull() + }) - test('does nothing for out-of-bounds index', () => { - const data = makeData() - const updated = removeFromParty(data, 10) - expect(updated.party).toEqual(data.party) - }) + test('does nothing for out-of-bounds index', () => { + const data = makeData() + const updated = removeFromParty(data, 10) + expect(updated.party).toEqual(data.party) + }) }) describe('swapPartySlots', () => { - test('swaps two party slots', () => { - const data = makeData(2) - const updated = swapPartySlots(data, 0, 1) - expect(updated.party[0]).toBe('creature-1') - expect(updated.party[1]).toBe('creature-0') - }) + test('swaps two party slots', () => { + const data = makeData(2) + const updated = swapPartySlots(data, 0, 1) + expect(updated.party[0]).toBe('creature-1') + expect(updated.party[1]).toBe('creature-0') + }) }) describe('setActivePartyMember', () => { - test('swaps creature to slot 0', () => { - const data = makeData(2) - const updated = setActivePartyMember(data, 'creature-1') - expect(updated.party[0]).toBe('creature-1') - expect(updated.party[1]).toBe('creature-0') - }) + test('swaps creature to slot 0', () => { + const data = makeData(2) + const updated = setActivePartyMember(data, 'creature-1') + expect(updated.party[0]).toBe('creature-1') + expect(updated.party[1]).toBe('creature-0') + }) - test('no change if already active', () => { - const data = makeData() - const updated = setActivePartyMember(data, 'creature-0') - expect(updated).toEqual(data) - }) + test('no change if already active', () => { + const data = makeData() + const updated = setActivePartyMember(data, 'creature-0') + expect(updated).toEqual(data) + }) }) // ─── PC Box operations ─── describe('depositToBox', () => { - test('deposits creature to first empty box slot', () => { - const data = makeData() - const result = depositToBox(data, 'box-creature') - expect(result.deposited).toBe(true) - expect(result.data.boxes[0]!.slots[0]).toBe('box-creature') - }) + test('deposits creature to first empty box slot', () => { + const data = makeData() + const result = depositToBox(data, 'box-creature') + expect(result.deposited).toBe(true) + expect(result.data.boxes[0]!.slots[0]).toBe('box-creature') + }) - test('fills second box when first is full', () => { - const data = makeData() - data.boxes[0]!.slots = Array(30).fill('x') - const result = depositToBox(data, 'box-creature') - expect(result.deposited).toBe(true) - expect(result.data.boxes[1]!.slots[0]).toBe('box-creature') - }) + test('fills second box when first is full', () => { + const data = makeData() + data.boxes[0]!.slots = Array(30).fill('x') + const result = depositToBox(data, 'box-creature') + expect(result.deposited).toBe(true) + expect(result.data.boxes[1]!.slots[0]).toBe('box-creature') + }) }) describe('withdrawFromBox', () => { - test('withdraws creature from box', () => { - const data = makeData() - data.boxes[0]!.slots[5] = 'box-creature' - const result = withdrawFromBox(data, 'box-creature') - expect(result.withdrawn).toBe(true) - expect(result.data.boxes[0]!.slots[5]).toBeNull() - }) + test('withdraws creature from box', () => { + const data = makeData() + data.boxes[0]!.slots[5] = 'box-creature' + const result = withdrawFromBox(data, 'box-creature') + expect(result.withdrawn).toBe(true) + expect(result.data.boxes[0]!.slots[5]).toBeNull() + }) - test('returns false when creature not in boxes', () => { - const data = makeData() - const result = withdrawFromBox(data, 'nonexistent') - expect(result.withdrawn).toBe(false) - }) + test('returns false when creature not in boxes', () => { + const data = makeData() + const result = withdrawFromBox(data, 'nonexistent') + expect(result.withdrawn).toBe(false) + }) }) describe('moveInBox', () => { - test('moves creature between slots', () => { - const data = makeData() - data.boxes[0]!.slots[0] = 'moving-creature' - const updated = moveInBox(data, 0, 0, 0, 5) - expect(updated.boxes[0]!.slots[0]).toBeNull() - expect(updated.boxes[0]!.slots[5]).toBe('moving-creature') - }) + test('moves creature between slots', () => { + const data = makeData() + data.boxes[0]!.slots[0] = 'moving-creature' + const updated = moveInBox(data, 0, 0, 0, 5) + expect(updated.boxes[0]!.slots[0]).toBeNull() + expect(updated.boxes[0]!.slots[5]).toBe('moving-creature') + }) - test('does nothing for empty source slot', () => { - const data = makeData() - const updated = moveInBox(data, 0, 0, 0, 5) - expect(updated).toEqual(data) - }) + test('does nothing for empty source slot', () => { + const data = makeData() + const updated = moveInBox(data, 0, 0, 0, 5) + expect(updated).toEqual(data) + }) }) describe('renameBox', () => { - test('renames a box', () => { - const data = makeData() - const updated = renameBox(data, 0, 'My Box') - expect(updated.boxes[0]!.name).toBe('My Box') - }) + test('renames a box', () => { + const data = makeData() + const updated = renameBox(data, 0, 'My Box') + expect(updated.boxes[0]!.name).toBe('My Box') + }) }) describe('findCreatureLocation', () => { - test('finds creature in party', () => { - const data = makeData() - const loc = findCreatureLocation(data, 'creature-0') - expect(loc).toEqual({ area: 'party', slot: 0 }) - }) + test('finds creature in party', () => { + const data = makeData() + const loc = findCreatureLocation(data, 'creature-0') + expect(loc).toEqual({ area: 'party', slot: 0 }) + }) - test('finds creature in box', () => { - const data = makeData() - data.boxes[0]!.slots[3] = 'box-creature' - const loc = findCreatureLocation(data, 'box-creature') - expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 }) - }) + test('finds creature in box', () => { + const data = makeData() + data.boxes[0]!.slots[3] = 'box-creature' + const loc = findCreatureLocation(data, 'box-creature') + expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 }) + }) - test('returns null for nonexistent', () => { - const data = makeData() - expect(findCreatureLocation(data, 'nonexistent')).toBeNull() - }) + test('returns null for nonexistent', () => { + const data = makeData() + expect(findCreatureLocation(data, 'nonexistent')).toBeNull() + }) }) describe('releaseCreature', () => { - test('removes creature from party and creatures array', () => { - const data = makeData(2) - const updated = releaseCreature(data, 'creature-1') - expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined() - }) + test('removes creature from party and creatures array', () => { + const data = makeData(2) + const updated = releaseCreature(data, 'creature-1') + expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined() + }) }) describe('getTotalCreatureCount', () => { - test('returns creature count', () => { - expect(getTotalCreatureCount(makeData(3))).toBe(3) - }) + test('returns creature count', () => { + expect(getTotalCreatureCount(makeData(3))).toBe(3) + }) }) describe('getAllCreatureIds', () => { - test('returns all ids', () => { - expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1']) - }) + test('returns all ids', () => { + expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1']) + }) }) // ─── Bag operations ─── describe('addItemToBag', () => { - test('adds new item', () => { - const data = makeData() - const updated = addItemToBag(data, 'potion', 3) - expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }]) - }) + test('adds new item', () => { + const data = makeData() + const updated = addItemToBag(data, 'potion', 3) + expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }]) + }) - test('stacks existing item', () => { - const data = makeData() - const withItem = addItemToBag(data, 'potion', 2) - const stacked = addItemToBag(withItem, 'potion', 3) - expect(stacked.bag.items[0]!.count).toBe(5) - }) + test('stacks existing item', () => { + const data = makeData() + const withItem = addItemToBag(data, 'potion', 2) + const stacked = addItemToBag(withItem, 'potion', 3) + expect(stacked.bag.items[0]!.count).toBe(5) + }) }) describe('removeItemFromBag', () => { - test('removes item quantity', () => { - const data = makeData() - const withItem = addItemToBag(data, 'potion', 5) - const result = removeItemFromBag(withItem, 'potion', 3) - expect(result.removed).toBe(true) - expect(result.data.bag.items[0]!.count).toBe(2) - }) + test('removes item quantity', () => { + const data = makeData() + const withItem = addItemToBag(data, 'potion', 5) + const result = removeItemFromBag(withItem, 'potion', 3) + expect(result.removed).toBe(true) + expect(result.data.bag.items[0]!.count).toBe(2) + }) - test('removes item entirely when count reaches 0', () => { - const data = makeData() - const withItem = addItemToBag(data, 'potion', 2) - const result = removeItemFromBag(withItem, 'potion', 2) - expect(result.removed).toBe(true) - expect(result.data.bag.items.length).toBe(0) - }) + test('removes item entirely when count reaches 0', () => { + const data = makeData() + const withItem = addItemToBag(data, 'potion', 2) + const result = removeItemFromBag(withItem, 'potion', 2) + expect(result.removed).toBe(true) + expect(result.data.bag.items.length).toBe(0) + }) - test('returns false when not enough items', () => { - const data = makeData() - const withItem = addItemToBag(data, 'potion', 1) - const result = removeItemFromBag(withItem, 'potion', 5) - expect(result.removed).toBe(false) - }) + test('returns false when not enough items', () => { + const data = makeData() + const withItem = addItemToBag(data, 'potion', 1) + const result = removeItemFromBag(withItem, 'potion', 5) + expect(result.removed).toBe(false) + }) - test('returns false for nonexistent item', () => { - const data = makeData() - const result = removeItemFromBag(data, 'potion', 1) - expect(result.removed).toBe(false) - }) + test('returns false for nonexistent item', () => { + const data = makeData() + const result = removeItemFromBag(data, 'potion', 1) + expect(result.removed).toBe(false) + }) }) describe('getItemCount', () => { - test('returns count for existing item', () => { - const data = makeData() - const withItem = addItemToBag(data, 'potion', 3) - expect(getItemCount(withItem, 'potion')).toBe(3) - }) + test('returns count for existing item', () => { + const data = makeData() + const withItem = addItemToBag(data, 'potion', 3) + expect(getItemCount(withItem, 'potion')).toBe(3) + }) - test('returns 0 for nonexistent item', () => { - expect(getItemCount(makeData(), 'potion')).toBe(0) - }) + test('returns 0 for nonexistent item', () => { + expect(getItemCount(makeData(), 'potion')).toBe(0) + }) }) // ─── Stats ─── describe('updateDailyStats', () => { - test('same day does not increment consecutive', () => { - const data = makeData() - const updated = updateDailyStats(data) - expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays) - }) + test('same day does not increment consecutive', () => { + const data = makeData() + const updated = updateDailyStats(data) + expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays) + }) }) describe('incrementTurns', () => { - test('increments totalTurns by 1', () => { - const data = makeData() - const updated = incrementTurns(data) - expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1) - }) + test('increments totalTurns by 1', () => { + const data = makeData() + const updated = incrementTurns(data) + expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1) + }) }) // ─── Extended coverage ─── describe('depositToBox - full boxes', () => { - test('fails when all boxes are full', () => { - const data = makeData() - for (const box of data.boxes) { - for (let i = 0; i < 30; i++) { - box.slots[i] = `filler-${i}` - } - } - const result = depositToBox(data, 'test-id') - expect(result.deposited).toBe(false) - }) + test('fails when all boxes are full', () => { + const data = makeData() + for (const box of data.boxes) { + for (let i = 0; i < 30; i++) { + box.slots[i] = `filler-${i}` + } + } + const result = depositToBox(data, 'test-id') + expect(result.deposited).toBe(false) + }) }) describe('withdrawFromBox - roundtrip', () => { - test('deposit then withdraw leaves box empty', () => { - const data = makeData() - const deposited = depositToBox(data, 'test-id') - expect(deposited.deposited).toBe(true) - const result = withdrawFromBox(deposited.data, 'test-id') - expect(result.withdrawn).toBe(true) - const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id') - expect(slot).toBeUndefined() - }) + test('deposit then withdraw leaves box empty', () => { + const data = makeData() + const deposited = depositToBox(data, 'test-id') + expect(deposited.deposited).toBe(true) + const result = withdrawFromBox(deposited.data, 'test-id') + expect(result.withdrawn).toBe(true) + const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id') + expect(slot).toBeUndefined() + }) }) describe('findCreatureLocation - deposit', () => { - test('finds creature after depositing to box', () => { - const data = makeData() - const deposited = depositToBox(data, 'box-mon') - const loc = findCreatureLocation(deposited.data, 'box-mon') - expect(loc).not.toBeNull() - expect(loc!.area).toBe('box') - }) + test('finds creature after depositing to box', () => { + const data = makeData() + const deposited = depositToBox(data, 'box-mon') + const loc = findCreatureLocation(deposited.data, 'box-mon') + expect(loc).not.toBeNull() + expect(loc!.area).toBe('box') + }) }) describe('releaseCreature - box', () => { - test('removes creature from box and creatures array', () => { - const data = makeData() - const deposited = depositToBox(data, 'box-mon') - const released = releaseCreature(deposited.data, 'box-mon') - expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined() - }) + test('removes creature from box and creatures array', () => { + const data = makeData() + const deposited = depositToBox(data, 'box-mon') + const released = releaseCreature(deposited.data, 'box-mon') + expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined() + }) - test('clears party slot when releasing party member', () => { - const data = makeData(2) - const updated = releaseCreature(data, 'creature-1') - expect(updated.party[1]).toBeNull() - expect(updated.creatures.length).toBe(1) - }) + test('clears party slot when releasing party member', () => { + const data = makeData(2) + const updated = releaseCreature(data, 'creature-1') + expect(updated.party[1]).toBeNull() + expect(updated.creatures.length).toBe(1) + }) }) diff --git a/packages/pokemon/src/__tests__/xpTable.test.ts b/packages/pokemon/src/__tests__/xpTable.test.ts index 5b422e4f5..e80b7761f 100644 --- a/packages/pokemon/src/__tests__/xpTable.test.ts +++ b/packages/pokemon/src/__tests__/xpTable.test.ts @@ -1,64 +1,64 @@ import { describe, test, expect } from 'bun:test' -import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable' +import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable' describe('xpForLevel', () => { - test('returns 0 for level 1', () => { - expect(xpForLevel(1, 'medium-fast')).toBe(0) - }) + 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('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 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('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('slow: level 5 = 156 XP', () => { + expect(xpForLevel(5, 'slow')).toBe(156) + }) - test('fast: level 5 = 100 XP', () => { - expect(xpForLevel(5, 'fast')).toBe(100) - }) + 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 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('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('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) - } - }) + 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 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 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 - }) + 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/ai.ts b/packages/pokemon/src/battle/ai.ts index 899b8f8e1..46411e0fd 100644 --- a/packages/pokemon/src/battle/ai.ts +++ b/packages/pokemon/src/battle/ai.ts @@ -4,10 +4,10 @@ import type { BattlePokemon } from './types' * Simple AI: pick a random usable move. */ export function chooseAIMove(pokemon: BattlePokemon): number { - const usable = pokemon.moves - .map((m, i) => ({ move: m, index: i })) - .filter(({ move }) => move.pp > 0 && !move.disabled) + const usable = pokemon.moves + .map((m, i) => ({ move: m, index: i })) + .filter(({ move }) => move.pp > 0 && !move.disabled) - if (usable.length === 0) return 0 // Struggle - return usable[Math.floor(Math.random() * usable.length)]!.index + if (usable.length === 0) return 0 // Struggle + return usable[Math.floor(Math.random() * usable.length)]!.index } diff --git a/packages/pokemon/src/battle/engine.ts b/packages/pokemon/src/battle/engine.ts index bf8f84685..fbc425faf 100644 --- a/packages/pokemon/src/battle/engine.ts +++ b/packages/pokemon/src/battle/engine.ts @@ -1,7 +1,7 @@ import { Battle, Teams, toID } from '@pkmn/sim' import { Dex } from '@pkmn/sim' import type { Creature, SpeciesId } from '../types' -import { TO_DEX_STAT, FROM_DEX_STAT } from '../data/pkmn' +import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn' import { STAT_NAMES } from '../types' import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types' import { chooseAIMove } from './ai' @@ -9,291 +9,291 @@ import { chooseAIMove } from './ai' // ─── Adapter: Creature → Showdown Set ─── function creatureToSetString(creature: Creature): string { - const species = Dex.species.get(creature.speciesId) - if (!species) throw new Error(`Species ${creature.speciesId} not found`) + const species = Dex.species.get(creature.speciesId) + if (!species) throw new Error(`Species ${creature.speciesId} not found`) - const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1) - const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : '' + const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1) + const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : '' - const moves = creature.moves - .filter(m => m.id) - .map(m => Dex.moves.get(m.id)?.name ?? m.id) + const moves = creature.moves + .filter(m => m.id) + .map(m => Dex.moves.get(m.id)?.name ?? m.id) - const DEX_DISPLAY: Record = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' } - const formatStatLine = (vals: Record) => - STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ') - const ivs = formatStatLine(creature.iv) - const evs = formatStatLine(creature.ev) + const DEX_DISPLAY: Record = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' } + const formatStatLine = (vals: Record) => + STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ') + const ivs = formatStatLine(creature.iv) + const evs = formatStatLine(creature.ev) - const lines = [ - species.name, - `Level: ${creature.level}`, - `Ability: ${abilityName}`, - `Nature: ${natureName}`, - `IVs: ${ivs}`, - `EVs: ${evs}`, - ] - if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`) - for (const move of moves) lines.push(`- ${move}`) + const lines = [ + species.name, + `Level: ${creature.level}`, + `Ability: ${abilityName}`, + `Nature: ${natureName}`, + `IVs: ${ivs}`, + `EVs: ${evs}`, + ] + if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`) + for (const move of moves) lines.push(`- ${move}`) - return lines.join('\n') + return lines.join('\n') } function wildPokemonToSetString(speciesId: SpeciesId, level: number): string { - const species = Dex.species.get(speciesId) - if (!species) throw new Error(`Species ${speciesId} not found`) - const ability = species.abilities['0'] ?? '' - // Get first 4 level-up moves (from species data) - const moves = getSpeciesMoves(speciesId, level) - return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n') + const species = Dex.species.get(speciesId) + if (!species) throw new Error(`Species ${speciesId} not found`) + const ability = species.abilities['0'] ?? '' + // Get first 4 level-up moves (from species data) + const moves = getSpeciesMoves(speciesId, level) + return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n') } 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'] - // 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'], - ice: ['IceShard', 'PowderSnow'], - fighting: ['KarateChop', 'LowKick'], - ground: ['MudSlap', 'SandAttack'], - flying: ['Gust', 'WingAttack'], - psychic: ['Confusion', 'Psybeam'], - bug: ['BugBite', 'StringShot'], - rock: ['RockThrow', 'SandAttack'], - ghost: ['Lick', 'ShadowSneak'], - dragon: ['DragonRage', 'Twister'], - dark: ['Bite', 'Pursuit'], - steel: ['MetalClaw', 'IronTail'], - fairy: ['FairyWind', 'DisarmingVoice'], - } - return basicMoves[type] ?? ['Tackle', 'Scratch'] + // 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'] + // 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'], + ice: ['IceShard', 'PowderSnow'], + fighting: ['KarateChop', 'LowKick'], + ground: ['MudSlap', 'SandAttack'], + flying: ['Gust', 'WingAttack'], + psychic: ['Confusion', 'Psybeam'], + bug: ['BugBite', 'StringShot'], + rock: ['RockThrow', 'SandAttack'], + ghost: ['Lick', 'ShadowSneak'], + dragon: ['DragonRage', 'Twister'], + dark: ['Bite', 'Pursuit'], + steel: ['MetalClaw', 'IronTail'], + fairy: ['FairyWind', 'DisarmingVoice'], + } + return basicMoves[type] ?? ['Tackle', 'Scratch'] } // ─── State Projection ─── function projectPokemon(pkm: any): BattlePokemon { - if (!pkm) throw new Error('No active pokemon') - const species = pkm.species - const hp = pkm.hp ?? 0 - const maxHp = pkm.maxhp ?? 1 + if (!pkm) throw new Error('No active pokemon') + const species = pkm.species + const hp = pkm.hp ?? 0 + const maxHp = pkm.maxhp ?? 1 - return { - id: pkm.name, // sim doesn't store our UUID, use name as temp id - speciesId: toID(species.name) as SpeciesId, - name: species.name, - level: pkm.level, - hp, - maxHp, - types: species.types?.map((t: string) => t.toLowerCase()) ?? [], - moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({ - id: toID(m.name ?? m), - name: m.name ?? m, - type: m.type ?? 'Normal', - pp: m.pp ?? 0, - maxPp: m.maxPp ?? m.pp ?? 0, - disabled: m.disabled ?? false, - })), - ability: pkm.ability ?? '', - heldItem: pkm.item ?? null, - status: mapStatus(pkm.status), - statStages: projectBoosts(pkm.boosts), - } + return { + id: pkm.name, // sim doesn't store our UUID, use name as temp id + speciesId: toID(species.name) as SpeciesId, + name: species.name, + level: pkm.level, + hp, + maxHp, + types: species.types?.map((t: string) => t.toLowerCase()) ?? [], + moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({ + id: toID(m.name ?? m), + name: m.name ?? m, + type: m.type ?? 'Normal', + pp: m.pp ?? 0, + maxPp: m.maxPp ?? m.pp ?? 0, + disabled: m.disabled ?? false, + })), + ability: pkm.ability ?? '', + heldItem: pkm.item ?? null, + status: mapStatus(pkm.status), + statStages: projectBoosts(pkm.boosts), + } } function mapStatus(status: string): StatusCondition { - if (!status) return 'none' - const s = status.toLowerCase() - if (s === 'psn') return 'poison' - if (s === 'tox') return 'bad_poison' - if (s === 'brn') return 'burn' - if (s === 'par') return 'paralysis' - if (s === 'frz') return 'freeze' - if (s === 'slp') return 'sleep' - return 'none' + if (!status) return 'none' + const s = status.toLowerCase() + if (s === 'psn') return 'poison' + if (s === 'tox') return 'bad_poison' + if (s === 'brn') return 'burn' + if (s === 'par') return 'paralysis' + if (s === 'frz') return 'freeze' + if (s === 'slp') return 'sleep' + return 'none' } function projectBoosts(boosts: Record | undefined): Record { - if (!boosts) return {} - const result: Record = {} - for (const [k, v] of Object.entries(boosts)) { - const mapped = FROM_DEX_STAT[k] - if (mapped) result[mapped] = v - else result[k] = v - } - return result + if (!boosts) return {} + const result: Record = {} + for (const [k, v] of Object.entries(boosts)) { + const mapped = FROM_DEX_STAT[k] + if (mapped) result[mapped] = v + else result[k] = v + } + return result } // ─── Log Parsing ─── function parseLogToEvents(log: string[]): BattleEvent[] { - const events: BattleEvent[] = [] - const parseSide = (s: string | undefined): 'player' | 'opponent' => - s?.startsWith('p1a') ? 'player' : 'opponent' + const events: BattleEvent[] = [] + const parseSide = (s: string | undefined): 'player' | 'opponent' => + s?.startsWith('p1a') ? 'player' : 'opponent' - for (const line of log) { - const parts = line.split('|') - const side = parseSide(parts[2]) + for (const line of log) { + const parts = line.split('|') + const side = parseSide(parts[2]) - if (line.startsWith('|move|')) { - events.push({ type: 'move', side, move: parts[3], user: parts[2] }) - } else if (line.startsWith('|-damage|')) { - const [cur, max] = parseHpString(parts[3]) - events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) }) - } else if (line.startsWith('|-heal|')) { - const [cur, max] = parseHpString(parts[3]) - events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) }) - } else if (line.startsWith('|faint|')) { - events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') }) - } else if (line.startsWith('|switch|')) { - const speciesPart = parts[3]?.split(',')[0]?.split(': ') - events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' }) - } else if (line.startsWith('|-supereffective|')) { - events.push({ type: 'effectiveness', multiplier: 2 }) - } else if (line.startsWith('|-resisted|')) { - events.push({ type: 'effectiveness', multiplier: 0.5 }) - } else if (line.startsWith('|-crit|')) { - events.push({ type: 'crit' }) - } else if (line.startsWith('|-miss|')) { - events.push({ type: 'miss', side }) - } else if (line.startsWith('|-status|')) { - events.push({ type: 'status', side, status: mapStatus(parts[3]) }) - } else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) { - const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4]) - events.push({ type: 'statChange', side, stat: parts[3], stages }) - } else if (line.startsWith('|-ability|')) { - events.push({ type: 'ability', side, ability: parts[3] }) - } else if (line.startsWith('|turn|')) { - events.push({ type: 'turn', number: parseInt(parts[2]) }) - } - } - return events + if (line.startsWith('|move|')) { + events.push({ type: 'move', side, move: parts[3], user: parts[2] }) + } else if (line.startsWith('|-damage|')) { + const [cur, max] = parseHpString(parts[3]) + events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) }) + } else if (line.startsWith('|-heal|')) { + const [cur, max] = parseHpString(parts[3]) + events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) }) + } else if (line.startsWith('|faint|')) { + events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') }) + } else if (line.startsWith('|switch|')) { + const speciesPart = parts[3]?.split(',')[0]?.split(': ') + events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' }) + } else if (line.startsWith('|-supereffective|')) { + events.push({ type: 'effectiveness', multiplier: 2 }) + } else if (line.startsWith('|-resisted|')) { + events.push({ type: 'effectiveness', multiplier: 0.5 }) + } else if (line.startsWith('|-crit|')) { + events.push({ type: 'crit' }) + } else if (line.startsWith('|-miss|')) { + events.push({ type: 'miss', side }) + } else if (line.startsWith('|-status|')) { + events.push({ type: 'status', side, status: mapStatus(parts[3]) }) + } else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) { + const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4]) + events.push({ type: 'statChange', side, stat: parts[3], stages }) + } else if (line.startsWith('|-ability|')) { + events.push({ type: 'ability', side, ability: parts[3] }) + } else if (line.startsWith('|turn|')) { + events.push({ type: 'turn', number: parseInt(parts[2]) }) + } + } + return events } function parseHpString(hpStr: string): [number, number] { - if (!hpStr) return [0, 1] - // Remove status suffix like "[1]" - const clean = hpStr.replace(/\[.*\]/, '') - const parts = clean.split('/') - if (parts.length !== 2) return [0, 1] - return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1] + if (!hpStr) return [0, 1] + // Remove status suffix like "[1]" + const clean = hpStr.replace(/\[.*\]/, '') + const parts = clean.split('/') + if (parts.length !== 2) return [0, 1] + return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1] } // ─── Engine ─── export type BattleInit = { - battle: any // @pkmn/sim Battle instance - state: BattleState + battle: any // @pkmn/sim Battle instance + state: BattleState } export function createBattle( - partyCreatures: Creature[], - opponentSpeciesId: SpeciesId, - opponentLevel: number, - _bagItems?: { id: string; count: number }[], + partyCreatures: Creature[], + opponentSpeciesId: SpeciesId, + opponentLevel: number, + _bagItems?: { id: string; count: number }[], ): BattleInit { - const p1Sets = partyCreatures.map(c => creatureToSetString(c)) - const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel) + const p1Sets = partyCreatures.map(c => creatureToSetString(c)) + const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel) - const p1Team = Teams.import(p1Sets.join('\n\n')) - const p2Team = Teams.import(p2Set) + const p1Team = Teams.import(p1Sets.join('\n\n')) + const p2Team = Teams.import(p2Set) - // Create battle - const battle = new Battle({ - formatid: 'gen9customgame' as any, - p1: { name: 'Player', team: p1Team }, - p2: { name: 'Opponent', team: p2Team }, - }) + // Create battle + const battle = new Battle({ + formatid: 'gen9customgame' as any, + p1: { name: 'Player', team: p1Team }, + p2: { name: 'Opponent', team: p2Team }, + }) - // Handle team preview → auto-select leads - battle.makeChoices('team 1', 'team 1') + // Handle team preview → auto-select leads + battle.makeChoices('team 1', 'team 1') - // Project initial state - const state = projectState(battle, _bagItems) - return { battle, state } + // Project initial state + const state = projectState(battle, _bagItems) + return { battle, state } } export function executeTurn( - battleInit: BattleInit, - action: PlayerAction, + battleInit: BattleInit, + action: PlayerAction, ): BattleState { - const { battle } = battleInit - const prevLogLen = battle.log.length + const { battle } = battleInit + const prevLogLen = battle.log.length - // Build choice string - let p1Choice: string - switch (action.type) { - case 'move': - p1Choice = `move ${action.moveIndex + 1}` - break - case 'switch': { - // Find the party slot number for this creature (sim uses 1-based index) - const p1Pokemon: any[] = battle.p1.pokemon - const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId) - p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1' - break - } - case 'item': - p1Choice = 'move 1' // Items handled via settlement - break - default: - p1Choice = 'move 1' - } + // Build choice string + let p1Choice: string + switch (action.type) { + case 'move': + p1Choice = `move ${action.moveIndex + 1}` + break + case 'switch': { + // Find the party slot number for this creature (sim uses 1-based index) + const p1Pokemon: any[] = battle.p1.pokemon + const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId) + p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1' + break + } + case 'item': + p1Choice = 'move 1' // Items handled via settlement + break + default: + p1Choice = 'move 1' + } - // AI choice - const aiPokemon = projectPokemon(battle.p2.active[0]) - const aiMoveIndex = chooseAIMove(aiPokemon) - const p2Choice = `move ${aiMoveIndex + 1}` + // AI choice + const aiPokemon = projectPokemon(battle.p2.active[0]) + const aiMoveIndex = chooseAIMove(aiPokemon) + const p2Choice = `move ${aiMoveIndex + 1}` - // Execute - battle.makeChoices(p1Choice, p2Choice) + // Execute + battle.makeChoices(p1Choice, p2Choice) - // Parse new log entries - const newLog = battle.log.slice(prevLogLen) - const newEvents = parseLogToEvents(newLog) + // Parse new log entries + const newLog = battle.log.slice(prevLogLen) + const newEvents = parseLogToEvents(newLog) - // Project new state - const state = projectState(battle, battleInit.state.usableItems) - state.events = [...battleInit.state.events, ...newEvents] + // Project new state + const state = projectState(battle, battleInit.state.usableItems) + state.events = [...battleInit.state.events, ...newEvents] - // Check for battle end - if (battle.ended) { - state.finished = true - const winner = battle.winner === 'Player' ? 'player' : 'opponent' - state.result = { - winner, - turns: state.turn, - xpGained: 0, // calculated in settlement - evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - participantIds: [], - } - } + // Check for battle end + if (battle.ended) { + state.finished = true + const winner = battle.winner === 'Player' ? 'player' : 'opponent' + state.result = { + winner, + turns: state.turn, + xpGained: 0, // calculated in settlement + evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + participantIds: [], + } + } - battleInit.state = state - return state + battleInit.state = state + return state } function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState { - const p1 = battle.p1 - const p2 = battle.p2 + const p1 = battle.p1 + const p2 = battle.p2 - return { - playerPokemon: projectPokemon(p1.active[0]), - opponentPokemon: projectPokemon(p2.active[0]), - playerParty: p1.pokemon.map((p: any) => projectPokemon(p)), - opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)), - turn: battle.turn ?? 1, - events: [], - finished: battle.ended, - usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [], - } + return { + playerPokemon: projectPokemon(p1.active[0]), + opponentPokemon: projectPokemon(p2.active[0]), + playerParty: p1.pokemon.map((p: any) => projectPokemon(p)), + opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)), + turn: battle.turn ?? 1, + events: [], + finished: battle.ended, + usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [], + } } diff --git a/packages/pokemon/src/battle/settlement.ts b/packages/pokemon/src/battle/settlement.ts index fc79b0dd6..eabe98292 100644 --- a/packages/pokemon/src/battle/settlement.ts +++ b/packages/pokemon/src/battle/settlement.ts @@ -1,191 +1,191 @@ import type { StatName, SpeciesId } from '../types' import { STAT_NAMES } from '../types' -import { TO_DEX_STAT } from '../data/pkmn' +import { TO_DEX_STAT } from '../dex/pkmn' import type { BattleResult } from './types' import type { BuddyData } from '../types' -import { levelFromXp } from '../data/xpTable' -import { getSpeciesData } from '../data/species' -import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping' +import { levelFromXp } from '../dex/xpTable' +import { getSpeciesData } from '../dex/species' +import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping' import { Dex } from '@pkmn/sim' /** * Settle battle results: XP, EV, level ups, move learning, evolution detection. */ export async function settleBattle( - data: BuddyData, - result: BattleResult, - opponentSpeciesId: SpeciesId, - opponentLevel: number, + data: BuddyData, + result: BattleResult, + opponentSpeciesId: SpeciesId, + opponentLevel: number, ): Promise<{ - data: BuddyData - learnableMoves: { creatureId: string; moveId: string; moveName: string }[] - pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] + data: BuddyData + learnableMoves: { creatureId: string; moveId: string; moveName: string }[] + pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] }> { - if (result.winner !== 'player') { - return { data, learnableMoves: [], pendingEvolutions: [] } - } + if (result.winner !== 'player') { + return { data, learnableMoves: [], pendingEvolutions: [] } + } - // Calculate XP reward (simplified: base XP from species) - const oppSpecies = Dex.species.get(opponentSpeciesId) - const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7 - const xpGained = Math.max(1, Math.floor(baseXp)) + // Calculate XP reward (simplified: base XP from species) + const oppSpecies = Dex.species.get(opponentSpeciesId) + const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7 + const xpGained = Math.max(1, Math.floor(baseXp)) - // Calculate EV reward - const evGained: Record = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 } - const evYield = getEvYield(opponentSpeciesId) - for (const stat of STAT_NAMES) { - evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0 - } + // Calculate EV reward + const evGained: Record = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 } + const evYield = getEvYield(opponentSpeciesId) + for (const stat of STAT_NAMES) { + evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0 + } - // Award XP/EV to participant creatures - const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = [] - const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = [] - const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null)) + // Award XP/EV to participant creatures + const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = [] + const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = [] + const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null)) - const updatedCreatures: typeof data.creatures = [] - for (const creature of data.creatures) { - if (!participantIds.has(creature.id)) { - updatedCreatures.push(creature) - continue - } + const updatedCreatures: typeof data.creatures = [] + for (const creature of data.creatures) { + if (!participantIds.has(creature.id)) { + updatedCreatures.push(creature) + continue + } - // Award EVs (capped) - const newEv = { ...creature.ev } - let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0) - for (const stat of STAT_NAMES) { - if (totalEV >= MAX_EV_TOTAL) break - const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV) - newEv[stat] += gain - totalEV += gain - } + // Award EVs (capped) + const newEv = { ...creature.ev } + let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0) + for (const stat of STAT_NAMES) { + if (totalEV >= MAX_EV_TOTAL) break + const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV) + newEv[stat] += gain + totalEV += gain + } - // Award XP - const oldLevel = creature.level - const newTotalXp = creature.totalXp + xpGained - const species = getSpeciesData(creature.speciesId) - const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate)) + // Award XP + const oldLevel = creature.level + const newTotalXp = creature.totalXp + xpGained + const species = getSpeciesData(creature.speciesId) + const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate)) - // Detect new learnable moves on level up - if (newLevel > oldLevel) { - const learnset = await Dex.learnsets.get(creature.speciesId) - if (learnset?.learnset) { - for (const [moveId, sources] of Object.entries(learnset.learnset)) { - for (const src of sources as string[]) { - if (src.startsWith('9L')) { - const moveLevel = parseInt(src.slice(2)) - if (moveLevel > oldLevel && moveLevel <= newLevel) { - const dexMove = Dex.moves.get(moveId) - learnableMoves.push({ - creatureId: creature.id, - moveId, - moveName: dexMove?.name ?? moveId, - }) - } - break - } - } - } - } - } + // Detect new learnable moves on level up + if (newLevel > oldLevel) { + const learnset = await Dex.learnsets.get(creature.speciesId) + if (learnset?.learnset) { + for (const [moveId, sources] of Object.entries(learnset.learnset)) { + for (const src of sources as string[]) { + if (src.startsWith('9L')) { + const moveLevel = parseInt(src.slice(2)) + if (moveLevel > oldLevel && moveLevel <= newLevel) { + const dexMove = Dex.moves.get(moveId) + learnableMoves.push({ + creatureId: creature.id, + moveId, + moveName: dexMove?.name ?? moveId, + }) + } + break + } + } + } + } + } - // Detect evolution - if (newLevel > oldLevel) { - const species = Dex.species.get(creature.speciesId) - if (species?.evos?.length) { - const targetId = species.evos[0]!.toLowerCase() - const target = Dex.species.get(targetId) - if (target?.evoLevel && newLevel >= target.evoLevel) { - pendingEvolutions.push({ - creatureId: creature.id, - from: creature.speciesId, - to: targetId as SpeciesId, - }) - } - } - } + // Detect evolution + if (newLevel > oldLevel) { + const species = Dex.species.get(creature.speciesId) + if (species?.evos?.length) { + const targetId = species.evos[0]!.toLowerCase() + const target = Dex.species.get(targetId) + if (target?.evoLevel && newLevel >= target.evoLevel) { + pendingEvolutions.push({ + creatureId: creature.id, + from: creature.speciesId, + to: targetId as SpeciesId, + }) + } + } + } - updatedCreatures.push({ - ...creature, - level: newLevel, - totalXp: newTotalXp, - ev: newEv, - }) - } + updatedCreatures.push({ + ...creature, + level: newLevel, + totalXp: newTotalXp, + ev: newEv, + }) + } - // Update data - const updatedData: BuddyData = { - ...data, - creatures: updatedCreatures, - stats: { - ...data.stats, - battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0), - battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0), - }, - } + // Update data + const updatedData: BuddyData = { + ...data, + creatures: updatedCreatures, + stats: { + ...data.stats, + battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0), + battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0), + }, + } - return { data: updatedData, learnableMoves, pendingEvolutions } + return { data: updatedData, learnableMoves, pendingEvolutions } } /** * Apply move learning - replace a move at the given index. */ export function applyMoveLearn( - data: BuddyData, - creatureId: string, - moveId: string, - replaceIndex: number, + data: BuddyData, + creatureId: string, + moveId: string, + replaceIndex: number, ): BuddyData { - return { - ...data, - creatures: data.creatures.map(c => { - if (c.id !== creatureId) return c - const dexMove = Dex.moves.get(moveId) - const newMoves = [...c.moves] as typeof c.moves - newMoves[replaceIndex] = { - id: moveId, - pp: dexMove?.pp ?? 10, - maxPp: dexMove?.pp ?? 10, - } - return { ...c, moves: newMoves as typeof c.moves } - }), - } + return { + ...data, + creatures: data.creatures.map(c => { + if (c.id !== creatureId) return c + const dexMove = Dex.moves.get(moveId) + const newMoves = [...c.moves] as typeof c.moves + newMoves[replaceIndex] = { + id: moveId, + pp: dexMove?.pp ?? 10, + maxPp: dexMove?.pp ?? 10, + } + return { ...c, moves: newMoves as typeof c.moves } + }), + } } /** * Apply evolution to a creature. */ export function applyEvolution( - data: BuddyData, - creatureId: string, - newSpeciesId: SpeciesId, + data: BuddyData, + creatureId: string, + newSpeciesId: SpeciesId, ): BuddyData { - return { - ...data, - creatures: data.creatures.map(c => - c.id === creatureId - ? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) } - : c, - ), - stats: { - ...data.stats, - totalEvolutions: data.stats.totalEvolutions + 1, - }, - } + return { + ...data, + creatures: data.creatures.map(c => + c.id === creatureId + ? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) } + : c, + ), + stats: { + ...data.stats, + totalEvolutions: data.stats.totalEvolutions + 1, + }, + } } function getEvYield(speciesId: string): Record { - // @pkmn/sim Dex.species doesn't have evs field - // Use baseStats as proxy: highest base stat gets 1-2 EVs - const species = Dex.species.get(speciesId) - if (!species?.baseStats) return {} - const stats = species.baseStats as Record - const entries = Object.entries(stats) - if (entries.length === 0) return {} - // Sort by value descending, give 1-2 EV to top stats - entries.sort((a, b) => b[1] - a[1]) - const result: Record = {} - // Top stat gets 2 EVs, second gets 1 - if (entries[0]) result[entries[0][0]] = 2 - if (entries[1]) result[entries[1][0]] = 1 - return result + // @pkmn/sim Dex.species doesn't have evs field + // Use baseStats as proxy: highest base stat gets 1-2 EVs + const species = Dex.species.get(speciesId) + if (!species?.baseStats) return {} + const stats = species.baseStats as Record + const entries = Object.entries(stats) + if (entries.length === 0) return {} + // Sort by value descending, give 1-2 EV to top stats + entries.sort((a, b) => b[1] - a[1]) + const result: Record = {} + // Top stat gets 2 EVs, second gets 1 + if (entries[0]) result[entries[0][0]] = 2 + if (entries[1]) result[entries[1][0]] = 1 + return result } diff --git a/packages/pokemon/src/battle/types.ts b/packages/pokemon/src/battle/types.ts index 1ed735e14..83ae08055 100644 --- a/packages/pokemon/src/battle/types.ts +++ b/packages/pokemon/src/battle/types.ts @@ -3,66 +3,66 @@ import type { StatName, SpeciesId } from '../types' export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'none' export type BattlePokemon = { - id: string // creature ID - speciesId: SpeciesId - name: string - level: number - hp: number // current HP in battle - maxHp: number - types: string[] - moves: MoveOption[] - ability: string - heldItem: string | null - status: StatusCondition - statStages: Record // -6 to +6 + id: string // creature ID + speciesId: SpeciesId + name: string + level: number + hp: number // current HP in battle + maxHp: number + types: string[] + moves: MoveOption[] + ability: string + heldItem: string | null + status: StatusCondition + statStages: Record // -6 to +6 } export type MoveOption = { - id: string - name: string - type: string - pp: number - maxPp: number - disabled: boolean + id: string + name: string + type: string + pp: number + maxPp: number + disabled: boolean } export type PlayerAction = - | { type: 'move'; moveIndex: number } - | { type: 'switch'; creatureId: string } - | { type: 'item'; itemId: string } + | { type: 'move'; moveIndex: number } + | { type: 'switch'; creatureId: string } + | { type: 'item'; itemId: string } export type BattleEvent = - | { type: 'move'; side: 'player' | 'opponent'; move: string; user: string } - | { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number } - | { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number } - | { type: 'faint'; side: 'player' | 'opponent'; speciesId: string } - | { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string } - | { type: 'effectiveness'; multiplier: number } - | { type: 'crit' } - | { type: 'miss'; side: 'player' | 'opponent' } - | { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition } - | { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number } - | { type: 'ability'; side: 'player' | 'opponent'; ability: string } - | { type: 'item'; side: 'player' | 'opponent'; item: string } - | { type: 'fail'; reason: string } - | { type: 'turn'; number: number } + | { type: 'move'; side: 'player' | 'opponent'; move: string; user: string } + | { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number } + | { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number } + | { type: 'faint'; side: 'player' | 'opponent'; speciesId: string } + | { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string } + | { type: 'effectiveness'; multiplier: number } + | { type: 'crit' } + | { type: 'miss'; side: 'player' | 'opponent' } + | { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition } + | { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number } + | { type: 'ability'; side: 'player' | 'opponent'; ability: string } + | { type: 'item'; side: 'player' | 'opponent'; item: string } + | { type: 'fail'; reason: string } + | { type: 'turn'; number: number } export type BattleResult = { - winner: 'player' | 'opponent' - turns: number - xpGained: number - evGained: Record - participantIds: string[] + winner: 'player' | 'opponent' + turns: number + xpGained: number + evGained: Record + participantIds: string[] } export type BattleState = { - playerPokemon: BattlePokemon - opponentPokemon: BattlePokemon - playerParty: BattlePokemon[] - opponentParty: BattlePokemon[] - turn: number - events: BattleEvent[] - finished: boolean - result?: BattleResult - usableItems: { id: string; name: string; count: number }[] + playerPokemon: BattlePokemon + opponentPokemon: BattlePokemon + playerParty: BattlePokemon[] + opponentParty: BattlePokemon[] + turn: number + events: BattleEvent[] + finished: boolean + result?: BattleResult + usableItems: { id: string; name: string; count: number }[] } diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts index 57ab7c61b..b09d29ca9 100644 --- a/packages/pokemon/src/core/creature.ts +++ b/packages/pokemon/src/core/creature.ts @@ -1,47 +1,47 @@ import { randomUUID } from 'node:crypto' import type { Creature, SpeciesId, StatName, StatsResult } from '../types' import { STAT_NAMES } from '../types' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/species' import { determineGender } from './gender' -import { levelFromXp } from '../data/xpTable' -import { gen, TO_DEX_STAT } from '../data/pkmn' -import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets' -import { randomNature } from '../data/nature' +import { levelFromXp } from '../dex/xpTable' +import { gen, TO_DEX_STAT } from '../dex/pkmn' +import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets' +import { randomNature } from '../dex/nature' /** * Generate a new creature of the given species. */ export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise { - const species = getSpeciesData(speciesId) - const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff) + const species = getSpeciesData(speciesId) + const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff) - // Generate IVs (0-31) using simple hash from seed - const iv = generateIVs(actualSeed) + // Generate IVs (0-31) using simple hash from seed + const iv = generateIVs(actualSeed) - // Determine gender - const gender = determineGender(species, actualSeed & 0xff) + // Determine gender + const gender = determineGender(species, actualSeed & 0xff) - // Determine shiny status - const isShiny = Math.random() < species.shinyChance + // Determine shiny status + const isShiny = Math.random() < species.shinyChance - return { - id: randomUUID(), - speciesId, - gender, - level: 1, - xp: 0, - totalXp: 0, - nature: randomNature(), - ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, - iv, - moves: await getDefaultMoveset(speciesId, 1), - ability: getDefaultAbility(speciesId), - heldItem: null, - friendship: species.baseHappiness, - isShiny, - hatchedAt: Date.now(), - pokeball: 'pokeball', - } + return { + id: randomUUID(), + speciesId, + gender, + level: 1, + xp: 0, + totalXp: 0, + nature: randomNature(), + ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv, + moves: await getDefaultMoveset(speciesId, 1), + ability: getDefaultAbility(speciesId), + heldItem: null, + friendship: species.baseHappiness, + isShiny, + hatchedAt: Date.now(), + pokeball: 'pokeball', + } } /** @@ -49,47 +49,47 @@ export async function generateCreature(speciesId: SpeciesId, seed?: number): Pro * Handles base stats, IV, EV, level, and nature correction internally. */ export function calculateStats(creature: Creature): StatsResult { - const species = gen.species.get(creature.speciesId) - if (!species) throw new Error(`Species ${creature.speciesId} not found`) + const species = gen.species.get(creature.speciesId) + if (!species) throw new Error(`Species ${creature.speciesId} not found`) - // Get nature if creature has one (Phase 1 adds nature field) - const nature = 'nature' in creature && creature.nature - ? gen.natures.get(creature.nature as string) - : undefined + // Get nature if creature has one (Phase 1 adds nature field) + const nature = 'nature' in creature && creature.nature + ? gen.natures.get(creature.nature as string) + : undefined - const result = {} as StatsResult - for (const stat of STAT_NAMES) { - const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe' - result[stat] = gen.stats.calc( - dexKey, - species.baseStats[dexKey], - creature.iv[stat], - creature.ev[stat], - creature.level, - nature ?? undefined, - ) - } - return result + const result = {} as StatsResult + for (const stat of STAT_NAMES) { + const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe' + result[stat] = gen.stats.calc( + dexKey, + species.baseStats[dexKey], + creature.iv[stat], + creature.ev[stat], + creature.level, + nature ?? undefined, + ) + } + 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 getSpeciesData(creature.speciesId).name + if (creature.nickname) return creature.nickname + return getSpeciesData(creature.speciesId).name } /** * Recalculate level from total XP (e.g. after XP gain). */ export function recalculateLevel(creature: Creature): Creature { - const species = getSpeciesData(creature.speciesId) - const newLevel = levelFromXp(creature.totalXp, species.growthRate) - if (newLevel !== creature.level) { - return { ...creature, level: newLevel } - } - return creature + const species = getSpeciesData(creature.speciesId) + const newLevel = levelFromXp(creature.totalXp, species.growthRate) + if (newLevel !== creature.level) { + return { ...creature, level: newLevel } + } + return creature } /** @@ -97,33 +97,33 @@ export function recalculateLevel(creature: Creature): Creature { * Reads from party[0] (new) with fallback to activeCreatureId (legacy). */ export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null { - const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null - if (!activeId) return null - return buddyData.creatures.find((c) => c.id === activeId) ?? null + const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null + if (!activeId) return null + return buddyData.creatures.find((c) => c.id === activeId) ?? null } /** * Generate IVs from a seed value. Each stat gets 0-31. */ function generateIVs(seed: number): Record { - 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, - } + 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) + return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0) } diff --git a/packages/pokemon/src/core/effort.ts b/packages/pokemon/src/core/effort.ts index 7fd45b0f7..04c6e3825 100644 --- a/packages/pokemon/src/core/effort.ts +++ b/packages/pokemon/src/core/effort.ts @@ -1,6 +1,6 @@ import type { Creature, StatName } from '../types' import { STAT_NAMES } from '../types' -import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../data/evMapping' +import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../dex/evMapping' import { getTotalEV } from './creature' // Track last EV award time per tool to enforce cooldown @@ -10,7 +10,7 @@ const evCooldowns = new Map() * Reset EV cooldown state (for testing). */ export function resetEVCooldowns(): void { - evCooldowns.clear() + evCooldowns.clear() } /** @@ -18,35 +18,35 @@ export function resetEVCooldowns(): void { * Returns updated creature and actual EV awarded. */ export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature { - const now = timestamp ?? Date.now() + const now = timestamp ?? Date.now() - // Check cooldown - const lastTime = evCooldowns.get(toolName) - if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature + // Check cooldown + const lastTime = evCooldowns.get(toolName) + if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature - const currentTotal = getTotalEV(creature) - if (currentTotal >= MAX_EV_TOTAL) 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() - } + 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 - } - } - } + 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 + evCooldowns.set(toolName, now) + return updated } /** @@ -54,45 +54,45 @@ export function awardEV(creature: Creature, toolName: string, timestamp?: number * 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 + 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 { - const stats = [...STAT_NAMES] - const stat = stats[Math.floor(Math.random() * stats.length)] - const amount = Math.random() < 0.5 ? 1 : 2 - const result: Record = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 } - result[stat] = amount - return result + const stats = [...STAT_NAMES] + const stat = stats[Math.floor(Math.random() * stats.length)] + const amount = Math.random() < 0.5 ? 1 : 2 + const result: Record = { 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 = { - hp: 'HP', - attack: 'ATK', - defense: 'DEF', - spAtk: 'SPA', - spDef: 'SPD', - speed: 'SPE', - } - parts.push(`${labels[stat]}+${val}`) - } - } - return parts.join(' ') || 'None' + const parts: string[] = [] + for (const stat of STAT_NAMES) { + const val = creature.ev[stat] + if (val > 0) { + const labels: Record = { + hp: 'HP', + attack: 'ATK', + defense: 'DEF', + spAtk: 'SPA', + spDef: 'SPD', + speed: 'SPE', + } + parts.push(`${labels[stat]}+${val}`) + } + } + return parts.join(' ') || 'None' } diff --git a/packages/pokemon/src/core/egg.ts b/packages/pokemon/src/core/egg.ts index fb471c927..1e9c3d9a8 100644 --- a/packages/pokemon/src/core/egg.ts +++ b/packages/pokemon/src/core/egg.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import type { BuddyData, Creature, Egg, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/species' import { generateCreature } from './creature' import { addToParty, depositToBox } from './storage' @@ -13,10 +13,10 @@ export const EGG_REQUIRED_DAYS = 3 * Conditions: consecutiveDays >= EGG_REQUIRED_DAYS 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 < EGG_REQUIRED_DAYS) return false - if (buddyData.stats.totalTurns % 50 !== 0) return false - return true + if (buddyData.eggs.length >= 1) return false + if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false + if (buddyData.stats.totalTurns % 50 !== 0) return false + return true } /** @@ -24,27 +24,27 @@ export function checkEggEligibility(buddyData: BuddyData): boolean { * 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)) + // 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)] + // 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 = getSpeciesData(speciesId) - const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000) + // Steps based on rarity (capture rate: lower = rarer = more steps) + const species = getSpeciesData(speciesId) + const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000) - return { - id: randomUUID(), - obtainedAt: Date.now(), - stepsRemaining: baseSteps, - totalSteps: baseSteps, - speciesId, - } + return { + id: randomUUID(), + obtainedAt: Date.now(), + stepsRemaining: baseSteps, + totalSteps: baseSteps, + speciesId, + } } /** @@ -52,15 +52,15 @@ export function generateEgg(buddyData: BuddyData): Egg { * 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 } + 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 + return egg.stepsRemaining <= 0 } /** @@ -68,44 +68,44 @@ export function isEggReadyToHatch(egg: Egg): boolean { * Tries to add to party first, then deposits to PC box. */ export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> { - const creature = await generateCreature(egg.speciesId) - creature.hatchedAt = Date.now() + const creature = await generateCreature(egg.speciesId) + creature.hatchedAt = Date.now() - // Add creature to list - let 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, - }, - } + // Add creature to list + let 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, + }, + } - // Place in party or PC box - const partyResult = addToParty(updatedData, creature.id) - if (partyResult.added) { - updatedData = partyResult.data - } else { - const boxResult = depositToBox(updatedData, creature.id) - if (boxResult.deposited) updatedData = boxResult.data - } + // Place in party or PC box + const partyResult = addToParty(updatedData, creature.id) + if (partyResult.added) { + updatedData = partyResult.data + } else { + const boxResult = depositToBox(updatedData, creature.id) + if (boxResult.deposited) updatedData = boxResult.data + } - return { buddyData: updatedData, creature } + 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 }] + 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 }] } diff --git a/packages/pokemon/src/core/evolution.ts b/packages/pokemon/src/core/evolution.ts index 5b8727f27..4968b8b9f 100644 --- a/packages/pokemon/src/core/evolution.ts +++ b/packages/pokemon/src/core/evolution.ts @@ -1,27 +1,27 @@ import type { Creature, EvolutionResult, SpeciesId } from '../types' -import { getSpeciesData } from '../data/species' -import { getNextEvolution } from '../data/evolution' +import { getSpeciesData } from '../dex/species' +import { getNextEvolution } from '../dex/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 + if (creature.level > 100) return null - const nextEvo = getNextEvolution(creature.speciesId) - if (!nextEvo) return null + const nextEvo = getNextEvolution(creature.speciesId) + if (!nextEvo) return null - // Check level-up conditions - if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) { - return { - from: creature.speciesId, - to: nextEvo.to, - newLevel: creature.level, - } - } + // Check level-up conditions + if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) { + return { + from: creature.speciesId, + to: nextEvo.to, + newLevel: creature.level, + } + } - return null + return null } /** @@ -29,18 +29,18 @@ export function checkEvolution(creature: Creature): EvolutionResult | null { * Returns the updated creature with new species and recalculated data. */ export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature { - const newSpecies = getSpeciesData(targetSpeciesId) + const newSpecies = getSpeciesData(targetSpeciesId) - return { - ...creature, - speciesId: targetSpeciesId, - friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship - } + 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 + return getNextEvolution(speciesId) !== undefined } diff --git a/packages/pokemon/src/core/experience.ts b/packages/pokemon/src/core/experience.ts index cade67a25..6a86a8fb2 100644 --- a/packages/pokemon/src/core/experience.ts +++ b/packages/pokemon/src/core/experience.ts @@ -1,52 +1,52 @@ import type { Creature } from '../types' -import { getSpeciesData } from '../data/species' -import { levelFromXp, xpForLevel } from '../data/xpTable' +import { getSpeciesData } from '../dex/species' +import { levelFromXp, xpForLevel } from '../dex/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 = getSpeciesData(creature.speciesId) - if (creature.level >= 100) { - return { creature, leveledUp: false, newLevel: creature.level } - } + const species = getSpeciesData(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) + 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 + // 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, - } + const updated: Creature = { + ...creature, + totalXp: newTotalXp, + xp: Math.max(0, xp), + level: newLevel, + } - return { - creature: updated, - leveledUp: newLevel > oldLevel, - 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 = getSpeciesData(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 + const species = getSpeciesData(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, - } + return { + current: Math.max(0, current), + needed, + percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100, + } } diff --git a/packages/pokemon/src/core/gender.ts b/packages/pokemon/src/core/gender.ts index eb7440833..56ac1b2d9 100644 --- a/packages/pokemon/src/core/gender.ts +++ b/packages/pokemon/src/core/gender.ts @@ -5,22 +5,22 @@ import type { Gender, SpeciesData } from '../types' * 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' + 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 '' - } + switch (gender) { + case 'male': + return '♂' + case 'female': + return '♀' + case 'genderless': + return '' + } } diff --git a/packages/pokemon/src/core/spriteCache.ts b/packages/pokemon/src/core/spriteCache.ts index 42a5ee9c0..d4b98e8f9 100644 --- a/packages/pokemon/src/core/spriteCache.ts +++ b/packages/pokemon/src/core/spriteCache.ts @@ -1,41 +1,41 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { SpeciesId, SpriteCache } from '../types' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/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 = { - 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', + 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`) + const spritesDir = getSpritesDir() + const filePath = join(spritesDir, `${speciesId}.json`) - if (!existsSync(filePath)) return null + if (!existsSync(filePath)) return null - try { - const raw = readFileSync(filePath, 'utf-8') - return JSON.parse(raw) as SpriteCache - } catch (e) { - console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e) - return null - } + try { + const raw = readFileSync(filePath, 'utf-8') + return JSON.parse(raw) as SpriteCache + } catch (e) { + console.error(`[buddy] Failed to load sprite cache for ${speciesId}:`, e) + return null + } } /** @@ -43,41 +43,41 @@ export function loadSprite(speciesId: SpeciesId): SpriteCache | null { * Returns the cached sprite data, or null if fetch failed. */ export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise { - // Try local cache first - const cached = loadSprite(speciesId) - if (cached) return cached + // Try local cache first + const cached = loadSprite(speciesId) + if (cached) return cached - const cowFileName = COW_FILE_MAP[speciesId] - if (!cowFileName) return null + const cowFileName = COW_FILE_MAP[speciesId] + if (!cowFileName) return null - const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow` + const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow` - try { - const response = await fetch(url) - if (!response.ok) return null + 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 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(), - } + 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)) + // Cache to disk + const spritesDir = getSpritesDir() + const filePath = join(spritesDir, `${speciesId}.json`) + writeFileSync(filePath, JSON.stringify(sprite, null, 2)) - return sprite - } catch (e) { - console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e) - return null - } + return sprite + } catch (e) { + console.error(`[buddy] Failed to fetch sprite for ${speciesId}:`, e) + return null + } } /** @@ -85,57 +85,57 @@ export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise - String.fromCodePoint(parseInt(hex, 16)), - ) + // 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') + // Convert \e to actual escape character (for ANSI sequences) + content = content.replace(/\\e/g, '\x1b') - // Split into lines - let lines = content.split('\n') + // 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() + // 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) - } + // 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()) + // Trim trailing whitespace on each line (preserve leading for alignment) + lines = lines.map((line) => line.trimEnd()) - return lines + 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, '') + // 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 = getSpeciesData(speciesId) - return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}` + const data = getSpeciesData(speciesId) + return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}` } diff --git a/packages/pokemon/src/core/storage.ts b/packages/pokemon/src/core/storage.ts index 3af062353..74cf1efef 100644 --- a/packages/pokemon/src/core/storage.ts +++ b/packages/pokemon/src/core/storage.ts @@ -4,9 +4,9 @@ import { homedir } from 'node:os' import type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types' import { ALL_SPECIES_IDS } from '../types' import { generateCreature } from './creature' -import { getSpeciesData } from '../data/species' -import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets' -import { randomNature } from '../data/nature' +import { getSpeciesData } from '../dex/species' +import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets' +import { randomNature } from '../dex/nature' const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json') const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') @@ -16,10 +16,10 @@ const BOX_SIZE = 30 /** Create empty boxes */ function makeDefaultBoxes(): PCBox[] { - return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({ - name: `Box ${i + 1}`, - slots: Array.from({ length: BOX_SIZE }, () => null), - })) + return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({ + name: `Box ${i + 1}`, + slots: Array.from({ length: BOX_SIZE }, () => null), + })) } /** @@ -27,28 +27,28 @@ function makeDefaultBoxes(): PCBox[] { * Auto-migrates from any older version. */ export async function loadBuddyData(): Promise { - if (!existsSync(BUDDY_DATA_PATH)) { - return getDefaultBuddyData() - } - try { - const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8') - const data = JSON.parse(raw) - return migrateToV2(data) - } catch (e) { - console.error('[buddy] Failed to load buddy data:', e) - return getDefaultBuddyData() - } + if (!existsSync(BUDDY_DATA_PATH)) { + return getDefaultBuddyData() + } + try { + const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8') + const data = JSON.parse(raw) + return migrateToV2(data) + } catch (e) { + console.error('[buddy] Failed to load buddy data:', e) + return getDefaultBuddyData() + } } /** * Save buddy data to disk. */ export function saveBuddyData(data: BuddyData): void { - const dir = join(BUDDY_DATA_PATH, '..') - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2)) + const dir = join(BUDDY_DATA_PATH, '..') + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2)) } /** @@ -56,356 +56,356 @@ export function saveBuddyData(data: BuddyData): void { * Randomly assigns one of the three starters. */ export async function getDefaultBuddyData(): Promise { - const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] - const randomStarter = starters[Math.floor(Math.random() * starters.length)] - const creature = await generateCreature(randomStarter) + const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] + const randomStarter = starters[Math.floor(Math.random() * starters.length)] + const creature = await generateCreature(randomStarter) - return { - version: 2, - party: [creature.id, null, null, null, null, null], - boxes: makeDefaultBoxes(), - creatures: [creature], - eggs: [], - dex: [ - { - speciesId: randomStarter, - discoveredAt: Date.now(), - caughtCount: 1, - bestLevel: 1, - }, - ], - bag: { items: [] }, - stats: { - totalTurns: 0, - consecutiveDays: 0, - lastActiveDate: new Date().toISOString().split('T')[0], - totalEggsObtained: 0, - totalEvolutions: 0, - battlesWon: 0, - battlesLost: 0, - }, - } + return { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: makeDefaultBoxes(), + creatures: [creature], + eggs: [], + dex: [ + { + speciesId: randomStarter, + discoveredAt: Date.now(), + caughtCount: 1, + bestLevel: 1, + }, + ], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 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 + if (!existsSync(BUDDY_SPRITES_DIR)) { + mkdirSync(BUDDY_SPRITES_DIR, { recursive: true }) + } + return BUDDY_SPRITES_DIR } /** * Migrate from legacy buddy system. */ export async function migrateFromLegacy( - storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string }, + storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string }, ): Promise { - const speciesMap: Record = { - 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', - } + const speciesMap: Record = { + 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', + } - 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 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 = await generateCreature(speciesId) - creature.level = 5 - creature.totalXp = 100 - creature.friendship = 120 + const creature = await generateCreature(speciesId) + creature.level = 5 + creature.totalXp = 100 + creature.friendship = 120 - const speciesInfo = getSpeciesData(speciesId) - if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { - creature.nickname = storedCompanion.name - } + const speciesInfo = getSpeciesData(speciesId) + if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { + creature.nickname = storedCompanion.name + } - return { - version: 2, - party: [creature.id, null, null, null, null, null], - boxes: makeDefaultBoxes(), - creatures: [creature], - eggs: [], - dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }], - bag: { items: [] }, - stats: { - totalTurns: 0, - consecutiveDays: 1, - lastActiveDate: new Date().toISOString().split('T')[0], - totalEggsObtained: 0, - totalEvolutions: 0, - battlesWon: 0, - battlesLost: 0, - }, - } + return { + version: 2, + party: [creature.id, null, null, null, null, null], + boxes: makeDefaultBoxes(), + creatures: [creature], + eggs: [], + dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }], + bag: { items: [] }, + stats: { + totalTurns: 0, + consecutiveDays: 1, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, + }, + } } // ─── Migration ─── /** Migrate any version to v2 */ async function migrateToV2(data: Record): Promise { - const version = (data.version as number) ?? 1 + const version = (data.version as number) ?? 1 - if (version >= 2) return data as unknown as BuddyData + if (version >= 2) return data as unknown as BuddyData - // v1 → v2 - const v1 = data as Record - const party = ensureParty(v1) + // v1 → v2 + const v1 = data as Record + const party = ensureParty(v1) - // Migrate creatures: add new fields - const creatures = await migrateCreatures(v1.creatures as Creature[] ?? []) + // Migrate creatures: add new fields + const creatures = await migrateCreatures(v1.creatures as Creature[] ?? []) - // Build boxes — put non-party creatures into Box 1 - const partyIds = new Set(party.filter(Boolean)) - const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id)) - const boxes = makeDefaultBoxes() - const box1Slots = [...boxes[0]!.slots] - let boxIdx = 0 - for (const c of nonPartyCreatures) { - if (boxIdx < BOX_SIZE) { - box1Slots[boxIdx] = c.id - boxIdx++ - } - } - boxes[0] = { name: 'Box 1', slots: box1Slots } + // Build boxes — put non-party creatures into Box 1 + const partyIds = new Set(party.filter(Boolean)) + const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id)) + const boxes = makeDefaultBoxes() + const box1Slots = [...boxes[0]!.slots] + let boxIdx = 0 + for (const c of nonPartyCreatures) { + if (boxIdx < BOX_SIZE) { + box1Slots[boxIdx] = c.id + boxIdx++ + } + } + boxes[0] = { name: 'Box 1', slots: box1Slots } - return { - version: 2, - party, - boxes, - creatures, - eggs: (v1.eggs as BuddyData['eggs']) ?? [], - dex: (v1.dex as BuddyData['dex']) ?? [], - bag: { items: [] }, - stats: { - totalTurns: ((v1.stats as Record)?.totalTurns) ?? 0, - consecutiveDays: ((v1.stats as Record)?.consecutiveDays) ?? 0, - lastActiveDate: ((v1.stats as Record)?.lastActiveDate) ?? new Date().toISOString().split('T')[0], - totalEggsObtained: ((v1.stats as Record)?.totalEggsObtained) ?? 0, - totalEvolutions: ((v1.stats as Record)?.totalEvolutions) ?? 0, - battlesWon: 0, - battlesLost: 0, - }, - } + return { + version: 2, + party, + boxes, + creatures, + eggs: (v1.eggs as BuddyData['eggs']) ?? [], + dex: (v1.dex as BuddyData['dex']) ?? [], + bag: { items: [] }, + stats: { + totalTurns: ((v1.stats as Record)?.totalTurns) ?? 0, + consecutiveDays: ((v1.stats as Record)?.consecutiveDays) ?? 0, + lastActiveDate: ((v1.stats as Record)?.lastActiveDate) ?? new Date().toISOString().split('T')[0], + totalEggsObtained: ((v1.stats as Record)?.totalEggsObtained) ?? 0, + totalEvolutions: ((v1.stats as Record)?.totalEvolutions) ?? 0, + battlesWon: 0, + battlesLost: 0, + }, + } } /** Ensure party field is valid */ function ensureParty(data: Record): (string | null)[] { - const existing = data.party as (string | null)[] | undefined - if (existing && existing.length === 6) return existing + const existing = data.party as (string | null)[] | undefined + if (existing && existing.length === 6) return existing - const party: (string | null)[] = new Array(6).fill(null) - const activeId = data.activeCreatureId ?? existing?.[0] - if (activeId) party[0] = activeId as string + const party: (string | null)[] = new Array(6).fill(null) + const activeId = data.activeCreatureId ?? existing?.[0] + if (activeId) party[0] = activeId as string - const creatures = data.creatures as Creature[] ?? [] - let slot = 1 - for (const c of creatures) { - if (c.id === activeId) continue - if (slot >= 6) break - party[slot] = c.id - slot++ - } - return party + const creatures = data.creatures as Creature[] ?? [] + let slot = 1 + for (const c of creatures) { + if (c.id === activeId) continue + if (slot >= 6) break + party[slot] = c.id + slot++ + } + return party } /** Migrate creatures from v1 format to v2 */ async function migrateCreatures(creatures: Creature[]): Promise { - const result: Creature[] = [] - for (const c of creatures) { - // Already v2 (has nature field) - if ('nature' in c && c.nature) { - result.push(c) - continue - } + const result: Creature[] = [] + for (const c of creatures) { + // Already v2 (has nature field) + if ('nature' in c && c.nature) { + result.push(c) + continue + } - result.push({ - ...c, - nature: randomNature(), - moves: await getDefaultMoveset(c.speciesId, c.level), - ability: getDefaultAbility(c.speciesId), - heldItem: null, - pokeball: 'pokeball', - }) - } - return result + result.push({ + ...c, + nature: randomNature(), + moves: await getDefaultMoveset(c.speciesId, c.level), + ability: getDefaultAbility(c.speciesId), + heldItem: null, + pokeball: 'pokeball', + }) + } + return result } // ─── Daily / Turn stats ─── export function updateDailyStats(data: BuddyData): BuddyData { - const today = new Date().toISOString().split('T')[0] - const lastDate = data.stats.lastActiveDate + const today = new Date().toISOString().split('T')[0] + const lastDate = data.stats.lastActiveDate - let consecutiveDays = data.stats.consecutiveDays - if (lastDate !== today) { - const yesterday = new Date() - yesterday.setDate(yesterday.getDate() - 1) - const yesterdayStr = yesterday.toISOString().split('T')[0] - consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1 - } + let consecutiveDays = data.stats.consecutiveDays + if (lastDate !== today) { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = yesterday.toISOString().split('T')[0] + consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1 + } - return { - ...data, - stats: { ...data.stats, consecutiveDays, lastActiveDate: today }, - } + return { + ...data, + stats: { ...data.stats, consecutiveDays, lastActiveDate: today }, + } } export function incrementTurns(data: BuddyData): BuddyData { - return { - ...data, - stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 }, - } + return { + ...data, + stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 }, + } } // ─── Party operations ─── export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } { - const party = [...data.party] - const emptyIdx = party.findIndex(p => p === null) - if (emptyIdx === -1) return { data, added: false } - party[emptyIdx] = creatureId - return { data: { ...data, party }, added: true } + const party = [...data.party] + const emptyIdx = party.findIndex(p => p === null) + if (emptyIdx === -1) return { data, added: false } + party[emptyIdx] = creatureId + return { data: { ...data, party }, added: true } } export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData { - if (slotIndex < 0 || slotIndex >= 6) return data - const party = [...data.party] - party[slotIndex] = null - return { ...data, party } + if (slotIndex < 0 || slotIndex >= 6) return data + const party = [...data.party] + party[slotIndex] = null + return { ...data, party } } export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData { - const party = [...data.party] - const a = party[indexA] - const b = party[indexB] - party[indexA] = b - party[indexB] = a - return { ...data, party } + const party = [...data.party] + const a = party[indexA] + const b = party[indexB] + party[indexA] = b + party[indexB] = a + return { ...data, party } } export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData { - const party = [...data.party] - const existingIdx = party.findIndex(id => id === creatureId) - if (existingIdx === 0) return data - if (existingIdx > 0) { - party[0] = creatureId - party[existingIdx] = data.party[0] - } else { - party[0] = creatureId - } - return { ...data, party } + const party = [...data.party] + const existingIdx = party.findIndex(id => id === creatureId) + if (existingIdx === 0) return data + if (existingIdx > 0) { + party[0] = creatureId + party[existingIdx] = data.party[0] + } else { + party[0] = creatureId + } + return { ...data, party } } // ─── PC Box operations ─── export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } { - for (let b = 0; b < data.boxes.length; b++) { - const slots = [...data.boxes[b]!.slots] - const emptyIdx = slots.findIndex(s => s === null) - if (emptyIdx !== -1) { - slots[emptyIdx] = creatureId - const boxes = [...data.boxes] - boxes[b] = { ...data.boxes[b]!, slots } - return { data: { ...data, boxes }, deposited: true } - } - } - return { data, deposited: false } + for (let b = 0; b < data.boxes.length; b++) { + const slots = [...data.boxes[b]!.slots] + const emptyIdx = slots.findIndex(s => s === null) + if (emptyIdx !== -1) { + slots[emptyIdx] = creatureId + const boxes = [...data.boxes] + boxes[b] = { ...data.boxes[b]!, slots } + return { data: { ...data, boxes }, deposited: true } + } + } + return { data, deposited: false } } export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } { - for (let b = 0; b < data.boxes.length; b++) { - const slots = [...data.boxes[b]!.slots] - const idx = slots.findIndex(s => s === creatureId) - if (idx !== -1) { - slots[idx] = null - const boxes = [...data.boxes] - boxes[b] = { ...data.boxes[b]!, slots } - return { data: { ...data, boxes }, withdrawn: true } - } - } - return { data, withdrawn: false } + for (let b = 0; b < data.boxes.length; b++) { + const slots = [...data.boxes[b]!.slots] + const idx = slots.findIndex(s => s === creatureId) + if (idx !== -1) { + slots[idx] = null + const boxes = [...data.boxes] + boxes[b] = { ...data.boxes[b]!, slots } + return { data: { ...data, boxes }, withdrawn: true } + } + } + return { data, withdrawn: false } } export function moveInBox(data: BuddyData, fromBox: number, fromSlot: number, toBox: number, toSlot: number): BuddyData { - const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] })) - const creatureId = boxes[fromBox]?.slots[fromSlot] - if (!creatureId) return data - boxes[fromBox]!.slots[fromSlot] = null - boxes[toBox]!.slots[toSlot] = creatureId - return { ...data, boxes } + const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] })) + const creatureId = boxes[fromBox]?.slots[fromSlot] + if (!creatureId) return data + boxes[fromBox]!.slots[fromSlot] = null + boxes[toBox]!.slots[toSlot] = creatureId + return { ...data, boxes } } export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData { - const boxes = [...data.boxes] - boxes[boxIndex] = { ...boxes[boxIndex]!, name } - return { ...data, boxes } + const boxes = [...data.boxes] + boxes[boxIndex] = { ...boxes[boxIndex]!, name } + return { ...data, boxes } } export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null { - const partyIdx = data.party.findIndex(id => id === creatureId) - if (partyIdx !== -1) return { area: 'party', slot: partyIdx } + const partyIdx = data.party.findIndex(id => id === creatureId) + if (partyIdx !== -1) return { area: 'party', slot: partyIdx } - for (let b = 0; b < data.boxes.length; b++) { - const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId) - if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b } - } - return null + for (let b = 0; b < data.boxes.length; b++) { + const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId) + if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b } + } + return null } export function releaseCreature(data: BuddyData, creatureId: string): BuddyData { - // Remove from party - let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId)) - // Remove from boxes - const withdrawResult = withdrawFromBox(updated, creatureId) - if (withdrawResult.withdrawn) updated = withdrawResult.data - // Remove from creatures array - return { - ...updated, - creatures: updated.creatures.filter(c => c.id !== creatureId), - } + // Remove from party + let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId)) + // Remove from boxes + const withdrawResult = withdrawFromBox(updated, creatureId) + if (withdrawResult.withdrawn) updated = withdrawResult.data + // Remove from creatures array + return { + ...updated, + creatures: updated.creatures.filter(c => c.id !== creatureId), + } } export function getTotalCreatureCount(data: BuddyData): number { - return data.creatures.length + return data.creatures.length } export function getAllCreatureIds(data: BuddyData): string[] { - return data.creatures.map(c => c.id) + return data.creatures.map(c => c.id) } // ─── Bag operations ─── export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData { - const items = data.bag.items.map(e => ({ ...e })) - const existing = items.find(e => e.id === itemId) - if (existing) { - existing.count += count - } else { - items.push({ id: itemId, count }) - } - return { ...data, bag: { items } } + const items = data.bag.items.map(e => ({ ...e })) + const existing = items.find(e => e.id === itemId) + if (existing) { + existing.count += count + } else { + items.push({ id: itemId, count }) + } + return { ...data, bag: { items } } } export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } { - const items = data.bag.items.map(e => ({ ...e })) - const existing = items.find(e => e.id === itemId) - if (!existing || existing.count < count) return { data, removed: false } + const items = data.bag.items.map(e => ({ ...e })) + const existing = items.find(e => e.id === itemId) + if (!existing || existing.count < count) return { data, removed: false } - existing.count -= count - if (existing.count <= 0) { - const idx = items.indexOf(existing) - items.splice(idx, 1) - } - return { data: { ...data, bag: { items } }, removed: true } + existing.count -= count + if (existing.count <= 0) { + const idx = items.indexOf(existing) + items.splice(idx, 1) + } + return { data: { ...data, bag: { items } }, removed: true } } export function getItemCount(data: BuddyData, itemId: string): number { - return data.bag.items.find(e => e.id === itemId)?.count ?? 0 + return data.bag.items.find(e => e.id === itemId)?.count ?? 0 } diff --git a/packages/pokemon/src/data/evolution.ts b/packages/pokemon/src/data/evolution.ts deleted file mode 100644 index 754f0e0a6..000000000 --- a/packages/pokemon/src/data/evolution.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Dex } from '@pkmn/sim' -import type { SpeciesId } from '../types' -import { ALL_SPECIES_IDS } from '../types' - - - -export interface EvolutionChainStep { - from: SpeciesId - to: SpeciesId - trigger: 'level_up' | 'item' | 'trade' | 'friendship' - minLevel?: number -} - -/** Find the next evolution for a species, dynamically from Dex */ -export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined { - const dex = Dex.species.get(speciesId) - if (!dex?.evos?.length) return undefined - - // Take the first evolution target (most species have single evo path) - const target = dex.evos[0]!.toLowerCase() - if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined - - const targetDex = Dex.species.get(target) - if (!targetDex?.exists) return undefined - - const trigger = dex.evoType === 'trade' ? 'trade' - : dex.evoType === 'useItem' ? 'item' - : dex.evoType === 'levelFriendship' ? 'friendship' - : 'level_up' - - return { - from: speciesId, - to: target as SpeciesId, - trigger, - minLevel: targetDex.evoLevel ?? undefined, - } -} diff --git a/packages/pokemon/src/data/learnsets.ts b/packages/pokemon/src/data/learnsets.ts deleted file mode 100644 index 72d679a01..000000000 --- a/packages/pokemon/src/data/learnsets.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Dex } from '@pkmn/sim' -import type { SpeciesId, MoveSlot } from '../types' -import { EMPTY_MOVE } from '../types' - -const GEN = 9 - -/** Get the default moveset for a species at a given level (last 4 level-up moves) */ -export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> { - const learnset = await Dex.learnsets.get(speciesId) - if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE] - - const levelUpMoves: { id: string; level: number }[] = [] - for (const [moveId, sources] of Object.entries(learnset.learnset)) { - for (const src of sources as string[]) { - if (src.startsWith(`${GEN}L`)) { - levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) }) - break - } - } - } - - levelUpMoves.sort((a, b) => a.level - b.level) - const available = levelUpMoves.filter(m => m.level <= level).slice(-4) - - const slots: MoveSlot[] = available.map(m => { - const dexMove = Dex.moves.get(m.id) - return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 } - }) - - while (slots.length < 4) slots.push(EMPTY_MOVE) - return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot] -} - -/** Get the default ability for a species (first non-hidden ability) */ -export function getDefaultAbility(speciesId: SpeciesId): string { - const species = Dex.species.get(speciesId) - return species?.abilities?.['0']?.toLowerCase() ?? '' -} - -/** Get newly learnable moves when leveling up */ -export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> { - const learnset = await Dex.learnsets.get(speciesId) - if (!learnset?.learnset) return [] - - const result: { id: string; name: string }[] = [] - for (const [moveId, sources] of Object.entries(learnset.learnset)) { - for (const src of sources as string[]) { - if (src.startsWith(`${GEN}L`)) { - const moveLevel = parseInt(src.slice(2)) - if (moveLevel > oldLevel && moveLevel <= newLevel) { - const dexMove = Dex.moves.get(moveId) - result.push({ id: moveId, name: dexMove?.name ?? moveId }) - } - break - } - } - } - return result -} diff --git a/packages/pokemon/src/data/species.ts b/packages/pokemon/src/data/species.ts deleted file mode 100644 index dfa3a7c54..000000000 --- a/packages/pokemon/src/data/species.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { SpeciesData, SpeciesId, GrowthRate } from '../types' -import { ALL_SPECIES_IDS } from '../types' -import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn' -import { getNextEvolution } from './evolution' -import { SPECIES_I18N, SPECIES_PERSONALITY } from './names' - -// ─── Supplementary data (fields not provided by @pkmn/sim) ─── - -const SUPPLEMENT: Record = { - bulbasaur: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.', - }, - ivysaur: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.', - }, - venusaur: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.', - }, - charmander: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.', - }, - charmeleon: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.', - }, - charizard: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.', - }, - squirtle: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.', - }, - wartortle: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.', - }, - blastoise: { - growthRate: 'medium-slow', - captureRate: 45, - baseHappiness: 70, - flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.', - }, - pikachu: { - growthRate: 'medium-fast', - captureRate: 190, - baseHappiness: 70, - flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.', - }, -} - -// ─── Evolution chain builder (from Dex evos field) ─── - -function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] { - const evo = getNextEvolution(speciesId) - if (!evo) return undefined - return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }] -} - -// ─── Build SpeciesData from Dex + supplement ─── - -function buildSpeciesData(id: SpeciesId): SpeciesData { - const dex = getSpecies(id) - const sup = SUPPLEMENT[id] - const i18n = SPECIES_I18N[id] - const personality = SPECIES_PERSONALITY[id] - - if (!dex) { - // Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP) - throw new Error(`Species ${id} not found in @pkmn/sim Dex`) - } - - return { - id, - name: dex.name, - names: i18n ?? { en: dex.name }, - dexNumber: dex.num, - genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined), - baseStats: mapBaseStats(dex.baseStats), - types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?], - baseHappiness: sup.baseHappiness, - growthRate: sup.growthRate, - captureRate: sup.captureRate, - personality: personality ?? '', - evolutionChain: buildEvolutionChain(id), - shinyChance: 1 / 4096, - flavorText: sup.flavorText, - } -} - -// ─── In-memory cache (built once, immutable) ─── - -const speciesCache = new Map() - -function getCached(id: SpeciesId): SpeciesData { - let data = speciesCache.get(id) - if (!data) { - data = buildSpeciesData(id) - speciesCache.set(id, data) - } - return data -} - -// ─── Sync getters (used by all consumers) ─── - -/** Get species data by ID. */ -export function getSpeciesData(id: SpeciesId): SpeciesData { - return getCached(id) -} - -/** Get all species data as a Record. */ -export function getAllSpeciesData(): Record { - const result = {} as Record - for (const id of ALL_SPECIES_IDS) { - result[id] = getCached(id) - } - return result -} - -/** - * Synchronous getter that returns the full map. - * @deprecated Use getSpeciesData / getAllSpeciesData - */ -export const SPECIES_DATA: Record = new Proxy({} as Record, { - get(_, prop: string) { - return getSpeciesData(prop as SpeciesId) - }, - ownKeys() { - return ALL_SPECIES_IDS as unknown as string[] - }, - has(_, prop) { - return ALL_SPECIES_IDS.includes(prop as SpeciesId) - }, - getOwnPropertyDescriptor(_, prop) { - if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) { - return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) } - } - return undefined - }, -}) - -/** No-op — data is now built-in from @pkmn/sim */ -export function ensureSpeciesData(): Promise { - return Promise.resolve() -} - -/** No-op — data is now built-in from @pkmn/sim */ -export async function refreshAllSpeciesData(): Promise { - // Clear cache to force rebuild - speciesCache.clear() -} - -// ─── Dex number mapping ─── - -export const DEX_TO_SPECIES: Record = { - 1: 'bulbasaur', - 2: 'ivysaur', - 3: 'venusaur', - 4: 'charmander', - 5: 'charmeleon', - 6: 'charizard', - 7: 'squirtle', - 8: 'wartortle', - 9: 'blastoise', - 25: 'pikachu', -} diff --git a/packages/pokemon/src/dex/evMapping.ts b/packages/pokemon/src/dex/evMapping.ts new file mode 100644 index 000000000..d352b7267 --- /dev/null +++ b/packages/pokemon/src/dex/evMapping.ts @@ -0,0 +1,31 @@ +import type { StatName } from '../types' + +/** + * Default EV mapping: tool name → EV gains per use. + * Tools not in this mapping get random 1-2 EV points. + */ +export const DEFAULT_EV_MAPPING: Record> = { + Bash: { hp: 0, attack: 2, defense: 0, spAtk: 0, spDef: 0, speed: 1 }, + Edit: { hp: 0, attack: 0, defense: 1, spAtk: 2, spDef: 0, speed: 0 }, + Write: { hp: 0, attack: 0, defense: 0, spAtk: 3, spDef: 0, speed: 0 }, + Read: { hp: 1, attack: 0, defense: 2, spAtk: 0, spDef: 0, speed: 0 }, + Grep: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 }, + Glob: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 }, + Agent: { hp: 0, attack: 1, defense: 0, spAtk: 0, spDef: 0, speed: 2 }, + WebSearch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 }, + WebFetch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 }, +} + +// EV limits (matching original Pokémon) +export const MAX_EV_PER_STAT = 252 +export const MAX_EV_TOTAL = 510 + +// EV cooldown: same tool type only counts once per 30 seconds +export const EV_COOLDOWN_MS = 30_000 + +/** + * Get EV gains for a tool. Returns undefined if not mapped (→ random). + */ +export function getEVForTool(toolName: string): Record | undefined { + return DEFAULT_EV_MAPPING[toolName] +} diff --git a/packages/pokemon/src/dex/evolution.ts b/packages/pokemon/src/dex/evolution.ts new file mode 100644 index 000000000..5b276c4a0 --- /dev/null +++ b/packages/pokemon/src/dex/evolution.ts @@ -0,0 +1,37 @@ +import { Dex } from '@pkmn/sim' +import type { SpeciesId } from '../types' +import { ALL_SPECIES_IDS } from '../types' + + + +export interface EvolutionChainStep { + from: SpeciesId + to: SpeciesId + trigger: 'level_up' | 'item' | 'trade' | 'friendship' + minLevel?: number +} + +/** Find the next evolution for a species, dynamically from Dex */ +export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined { + const dex = Dex.species.get(speciesId) + if (!dex?.evos?.length) return undefined + + // Take the first evolution target (most species have single evo path) + const target = dex.evos[0]!.toLowerCase() + if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined + + const targetDex = Dex.species.get(target) + if (!targetDex?.exists) return undefined + + const trigger = dex.evoType === 'trade' ? 'trade' + : dex.evoType === 'useItem' ? 'item' + : dex.evoType === 'levelFriendship' ? 'friendship' + : 'level_up' + + return { + from: speciesId, + to: target as SpeciesId, + trigger, + minLevel: targetDex.evoLevel ?? undefined, + } +} diff --git a/packages/pokemon/src/dex/learnsets.ts b/packages/pokemon/src/dex/learnsets.ts new file mode 100644 index 000000000..b64d6008c --- /dev/null +++ b/packages/pokemon/src/dex/learnsets.ts @@ -0,0 +1,59 @@ +import { Dex } from '@pkmn/sim' +import type { SpeciesId, MoveSlot } from '../types' +import { EMPTY_MOVE } from '../types' + +const GEN = 9 + +/** Get the default moveset for a species at a given level (last 4 level-up moves) */ +export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> { + const learnset = await Dex.learnsets.get(speciesId) + if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE] + + const levelUpMoves: { id: string; level: number }[] = [] + for (const [moveId, sources] of Object.entries(learnset.learnset)) { + for (const src of sources as string[]) { + if (src.startsWith(`${GEN}L`)) { + levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) }) + break + } + } + } + + levelUpMoves.sort((a, b) => a.level - b.level) + const available = levelUpMoves.filter(m => m.level <= level).slice(-4) + + const slots: MoveSlot[] = available.map(m => { + const dexMove = Dex.moves.get(m.id) + return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 } + }) + + while (slots.length < 4) slots.push(EMPTY_MOVE) + return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot] +} + +/** Get the default ability for a species (first non-hidden ability) */ +export function getDefaultAbility(speciesId: SpeciesId): string { + const species = Dex.species.get(speciesId) + return species?.abilities?.['0']?.toLowerCase() ?? '' +} + +/** Get newly learnable moves when leveling up */ +export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> { + const learnset = await Dex.learnsets.get(speciesId) + if (!learnset?.learnset) return [] + + const result: { id: string; name: string }[] = [] + for (const [moveId, sources] of Object.entries(learnset.learnset)) { + for (const src of sources as string[]) { + if (src.startsWith(`${GEN}L`)) { + const moveLevel = parseInt(src.slice(2)) + if (moveLevel > oldLevel && moveLevel <= newLevel) { + const dexMove = Dex.moves.get(moveId) + result.push({ id: moveId, name: dexMove?.name ?? moveId }) + } + break + } + } + } + return result +} diff --git a/packages/pokemon/src/dex/names.ts b/packages/pokemon/src/dex/names.ts new file mode 100644 index 000000000..d88e5ab1b --- /dev/null +++ b/packages/pokemon/src/dex/names.ts @@ -0,0 +1,43 @@ +import type { SpeciesId } from '../types' + +/** Default names for each species (English) */ +export const SPECIES_NAMES: Record = { + bulbasaur: 'Bulbasaur', + ivysaur: 'Ivysaur', + venusaur: 'Venusaur', + charmander: 'Charmander', + charmeleon: 'Charmeleon', + charizard: 'Charizard', + squirtle: 'Squirtle', + wartortle: 'Wartortle', + blastoise: 'Blastoise', + pikachu: 'Pikachu', +} + +/** Multilingual names */ +export const SPECIES_I18N: Record> = { + bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' }, + ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' }, + venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' }, + charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' }, + charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' }, + charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' }, + squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' }, + wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' }, + blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' }, + pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' }, +} + +/** Personality descriptions for each species */ +export const SPECIES_PERSONALITY: Record = { + bulbasaur: 'Calm and collected, a reliable partner', + ivysaur: 'Steady growth, patient and resilient', + venusaur: 'Majestic and powerful, a natural leader', + charmander: 'Energetic and curious, loves adventure', + charmeleon: 'Fierce and determined, always pushing forward', + charizard: 'Proud and strong-willed, a formidable ally', + squirtle: 'Cheerful and playful, adapts easily', + wartortle: 'Loyal and protective, wise beyond years', + blastoise: 'Steadfast and powerful, a defensive fortress', + pikachu: 'Friendly and energetic, always by your side', +} diff --git a/packages/pokemon/src/data/nature.ts b/packages/pokemon/src/dex/nature.ts similarity index 53% rename from packages/pokemon/src/data/nature.ts rename to packages/pokemon/src/dex/nature.ts index c79234504..11706f01f 100644 --- a/packages/pokemon/src/data/nature.ts +++ b/packages/pokemon/src/dex/nature.ts @@ -4,36 +4,36 @@ import type { NatureName, NatureEffect, NatureStat } from '../types' // All 25 canonical nature names (Dex.natures is not iterable, so we list them) const NATURE_IDS: NatureName[] = [ - 'hardy', 'lonely', 'brave', 'adamant', 'naughty', - 'bold', 'docile', 'relaxed', 'impish', 'lax', - 'timid', 'hasty', 'serious', 'jolly', 'naive', - 'modest', 'mild', 'quiet', 'bashful', 'rash', - 'calm', 'gentle', 'sassy', 'careful', 'quirky', + 'hardy', 'lonely', 'brave', 'adamant', 'naughty', + 'bold', 'docile', 'relaxed', 'impish', 'lax', + 'timid', 'hasty', 'serious', 'jolly', 'naive', + 'modest', 'mild', 'quiet', 'bashful', 'rash', + 'calm', 'gentle', 'sassy', 'careful', 'quirky', ] /** Get all nature names */ export function getAllNatureNames(): NatureName[] { - return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists) + return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists) } /** Randomly assign a nature */ export function randomNature(): NatureName { - const names = getAllNatureNames() - return names[Math.floor(Math.random() * names.length)]! + const names = getAllNatureNames() + 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 + 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: mapDexStat(n.plus), - minus: mapDexStat(n.minus), - } + const n = Dex.natures.get(nature) + if (!n?.exists) return { plus: null, minus: null } + return { + plus: mapDexStat(n.plus), + minus: mapDexStat(n.minus), + } } diff --git a/packages/pokemon/src/data/pkmn.ts b/packages/pokemon/src/dex/pkmn.ts similarity index 63% rename from packages/pokemon/src/data/pkmn.ts rename to packages/pokemon/src/dex/pkmn.ts index 358c8698e..aab2da6ee 100644 --- a/packages/pokemon/src/data/pkmn.ts +++ b/packages/pokemon/src/dex/pkmn.ts @@ -8,32 +8,32 @@ export const gen = gens.get(9) // Stat name mapping: @pkmn/sim → our StatName export const FROM_DEX_STAT: Record = { - hp: 'hp', atk: 'attack', def: 'defense', - spa: 'spAtk', spd: 'spDef', spe: 'speed', + hp: 'hp', atk: 'attack', def: 'defense', + spa: 'spAtk', spd: 'spDef', spe: 'speed', } // Stat name mapping: our StatName → @pkmn/sim abbreviation export const TO_DEX_STAT: Record = { - hp: 'hp', attack: 'atk', defense: 'def', - spAtk: 'spa', spDef: 'spd', speed: 'spe', + hp: 'hp', attack: 'atk', defense: 'def', + spAtk: 'spa', spDef: 'spd', speed: 'spe', } /** Query species from Dex */ export function getSpecies(id: string) { - return gen.species.get(id) + return gen.species.get(id) } /** Map Dex baseStats to our StatName format */ export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record { - const result = {} as Record - for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) { - result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0 - } - return result + const result = {} as Record + for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) { + result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0 + } + return result } /** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */ export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number { - if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless - return Math.round(genderRatio.F * 8) + if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless + return Math.round(genderRatio.F * 8) } diff --git a/packages/pokemon/src/dex/species.ts b/packages/pokemon/src/dex/species.ts new file mode 100644 index 000000000..1bfd02342 --- /dev/null +++ b/packages/pokemon/src/dex/species.ts @@ -0,0 +1,191 @@ +import type { SpeciesData, SpeciesId, GrowthRate } from '../types' +import { ALL_SPECIES_IDS } from '../types' +import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn' +import { getNextEvolution } from './evolution' +import { SPECIES_I18N, SPECIES_PERSONALITY } from './names' + +// ─── Supplementary data (fields not provided by @pkmn/sim) ─── + +const SUPPLEMENT: Record = { + bulbasaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.', + }, + ivysaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.', + }, + venusaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.', + }, + charmander: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.', + }, + charmeleon: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.', + }, + charizard: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.', + }, + squirtle: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.', + }, + wartortle: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.', + }, + blastoise: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.', + }, + pikachu: { + growthRate: 'medium-fast', + captureRate: 190, + baseHappiness: 70, + flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.', + }, +} + +// ─── Evolution chain builder (from Dex evos field) ─── + +function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] { + const evo = getNextEvolution(speciesId) + if (!evo) return undefined + return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }] +} + +// ─── Build SpeciesData from Dex + supplement ─── + +function buildSpeciesData(id: SpeciesId): SpeciesData { + const dex = getSpecies(id) + const sup = SUPPLEMENT[id] + const i18n = SPECIES_I18N[id] + const personality = SPECIES_PERSONALITY[id] + + if (!dex) { + // Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP) + throw new Error(`Species ${id} not found in @pkmn/sim Dex`) + } + + return { + id, + name: dex.name, + names: i18n ?? { en: dex.name }, + dexNumber: dex.num, + genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined), + baseStats: mapBaseStats(dex.baseStats), + types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?], + baseHappiness: sup.baseHappiness, + growthRate: sup.growthRate, + captureRate: sup.captureRate, + personality: personality ?? '', + evolutionChain: buildEvolutionChain(id), + shinyChance: 1 / 4096, + flavorText: sup.flavorText, + } +} + +// ─── In-memory cache (built once, immutable) ─── + +const speciesCache = new Map() + +function getCached(id: SpeciesId): SpeciesData { + let data = speciesCache.get(id) + if (!data) { + data = buildSpeciesData(id) + speciesCache.set(id, data) + } + return data +} + +// ─── Sync getters (used by all consumers) ─── + +/** Get species data by ID. */ +export function getSpeciesData(id: SpeciesId): SpeciesData { + return getCached(id) +} + +/** Get all species data as a Record. */ +export function getAllSpeciesData(): Record { + const result = {} as Record + for (const id of ALL_SPECIES_IDS) { + result[id] = getCached(id) + } + return result +} + +/** + * Synchronous getter that returns the full map. + * @deprecated Use getSpeciesData / getAllSpeciesData + */ +export const SPECIES_DATA: Record = new Proxy({} as Record, { + get(_, prop: string) { + return getSpeciesData(prop as SpeciesId) + }, + ownKeys() { + return ALL_SPECIES_IDS as unknown as string[] + }, + has(_, prop) { + return ALL_SPECIES_IDS.includes(prop as SpeciesId) + }, + getOwnPropertyDescriptor(_, prop) { + if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) { + return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) } + } + return undefined + }, +}) + +/** No-op — data is now built-in from @pkmn/sim */ +export function ensureSpeciesData(): Promise { + return Promise.resolve() +} + +/** No-op — data is now built-in from @pkmn/sim */ +export async function refreshAllSpeciesData(): Promise { + // Clear cache to force rebuild + speciesCache.clear() +} + +// ─── Dex number mapping ─── + +export const DEX_TO_SPECIES: Record = { + 1: 'bulbasaur', + 2: 'ivysaur', + 3: 'venusaur', + 4: 'charmander', + 5: 'charmeleon', + 6: 'charizard', + 7: 'squirtle', + 8: 'wartortle', + 9: 'blastoise', + 25: 'pikachu', +} diff --git a/packages/pokemon/src/dex/xpTable.ts b/packages/pokemon/src/dex/xpTable.ts new file mode 100644 index 000000000..c566d2b85 --- /dev/null +++ b/packages/pokemon/src/dex/xpTable.ts @@ -0,0 +1,81 @@ +import type { GrowthRate } from '../types' + +/** + * Calculate total XP required to reach a given level for a growth rate type. + * Follows original Pokémon XP curve formulas. + */ +export function xpForLevel(level: number, growthRate: GrowthRate): number { + if (level <= 1) return 0 + const n = level + switch (growthRate) { + case 'erratic': + return xpErratic(n) + case 'fast': + return Math.floor((n * n * n * 4) / 5) + case 'medium-fast': + return n * n * n + case 'medium-slow': + return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140) + case 'slow': + return Math.floor((5 * n * n * n) / 4) + case 'fluctuating': + return xpFluctuating(n) + default: + return n * n * n + } +} + +/** + * Calculate level from total XP for a given growth rate. + */ +export function levelFromXp(totalXp: number, growthRate: GrowthRate): number { + // Binary search for level + let lo = 1 + let hi = 100 + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (xpForLevel(mid, growthRate) <= totalXp) { + lo = mid + } else { + hi = mid - 1 + } + } + return Math.min(lo, 100) +} + +/** + * XP needed to go from current level to next level. + */ +export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number { + if (currentLevel >= 100) return 0 + const nextLevelXp = xpForLevel(currentLevel + 1, growthRate) + return nextLevelXp - totalXp +} + +// Erratic growth rate (complex piecewise) +function xpErratic(n: number): number { + if (n <= 1) return 0 + if (n <= 50) { + return Math.floor((n * n * n * (100 - n)) / 50) + } + if (n <= 68) { + return Math.floor((n * n * n * (150 - n)) / 100) + } + if (n <= 98) { + return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500) + } + // n 99-100 + return Math.floor((n * n * n * (160 - n)) / 100) +} + +// Fluctuating growth rate (complex piecewise) +function xpFluctuating(n: number): number { + if (n <= 1) return 0 + if (n <= 15) { + return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50) + } + if (n <= 36) { + return Math.floor((n * n * n * (n + 14)) / 50) + } + return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50) +} diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts index 6ee0e7f6c..7f363e877 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -1,40 +1,40 @@ // Types export type { - StatName, - NatureName, - NatureStat, - NatureEffect, - MoveSlot, - ItemId, - PCBox, - BagEntry, - Bag, - SpeciesId, - Gender, - EvolutionTrigger, - EvolutionCondition, - GrowthRate, - SpeciesData, - Creature, - Egg, - DexEntry, - BuddyData, - StatsResult, - EvolutionResult, - SpriteCache, - AnimMode, + StatName, + NatureName, + NatureStat, + NatureEffect, + MoveSlot, + ItemId, + PCBox, + BagEntry, + Bag, + SpeciesId, + Gender, + EvolutionTrigger, + EvolutionCondition, + GrowthRate, + SpeciesData, + Creature, + Egg, + DexEntry, + BuddyData, + StatsResult, + EvolutionResult, + SpriteCache, + AnimMode, } from './types' export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types' // Data -export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } 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 { getAllNatureNames, randomNature, getNatureEffect } from './data/nature' -export { getNextEvolution } from './data/evolution' -export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './data/learnsets' -export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn' +export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species' +export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping' +export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable' +export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names' +export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature' +export { getNextEvolution } from './dex/evolution' +export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets' +export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn' // Battle export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types' @@ -50,12 +50,12 @@ export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/eff export { checkEvolution, evolve, canEvolveFurther } from './core/evolution' export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg' export { - loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, - updateDailyStats, incrementTurns, - addToParty, removeFromParty, swapPartySlots, setActivePartyMember, - depositToBox, withdrawFromBox, moveInBox, renameBox, - findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds, - addItemToBag, removeItemFromBag, getItemCount, + loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, + updateDailyStats, incrementTurns, + addToParty, removeFromParty, swapPartySlots, setActivePartyMember, + depositToBox, withdrawFromBox, moveInBox, renameBox, + findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds, + addItemToBag, removeItemFromBag, getItemCount, } from './core/storage' export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache' @@ -77,3 +77,4 @@ export { SwitchPanel } from './ui/SwitchPanel' export { ItemPanel } from './ui/ItemPanel' export { BattleResultPanel } from './ui/BattleResultPanel' export { MoveLearnPanel } from './ui/MoveLearnPanel' +export { BattleFlow } from './ui/BattleFlow' diff --git a/packages/pokemon/src/sprites/fallback.ts b/packages/pokemon/src/sprites/fallback.ts index 4a612cb23..1688431b8 100644 --- a/packages/pokemon/src/sprites/fallback.ts +++ b/packages/pokemon/src/sprites/fallback.ts @@ -5,81 +5,81 @@ import type { SpeciesId } from '../types' * Simple 5-line representations of each species. */ const FALLBACK_SPRITES: Record = { - 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 ) ', - ' \\ ~~~ / ', - ' /`-...-\'\\ ', - ' ^^ ^^ ', - ], + 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 + return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu } diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts index 4794e7a7a..7957f24da 100644 --- a/packages/pokemon/src/sprites/renderer.ts +++ b/packages/pokemon/src/sprites/renderer.ts @@ -14,9 +14,9 @@ import type { AnimMode } from '../types' // After transform, render each row back: reset → style → char → reset interface Pixel { - char: string - /** Full ANSI state needed to render this pixel */ - style: string + char: string + /** Full ANSI state needed to render this pixel */ + style: string } const EMPTY_PIXEL: Pixel = { char: ' ', style: '' } @@ -26,53 +26,53 @@ const EMPTY_ROW: Pixel[] = [] /** Parse a raw ANSI string line into a Pixel row */ function parseLine(line: string): Pixel[] { - const pixels: Pixel[] = [] - let style = '' - let i = 0 - while (i < line.length) { - if (line[i] === '\x1b') { - // Collect full ANSI escape sequence: \x1b[ ... m - const start = i - i++ // skip \x1b - if (i < line.length && line[i] === '[') { - i++ // skip [ - while (i < line.length && line[i] !== 'm') i++ - if (i < line.length) i++ // skip m - } - style += line.slice(start, i) - } else { - // Visible character (handle multi-byte Unicode) - const cp = line.codePointAt(i)! - const ch = String.fromCodePoint(cp) - pixels.push({ char: ch, style }) - i += ch.length - } - } - return pixels + const pixels: Pixel[] = [] + let style = '' + let i = 0 + while (i < line.length) { + if (line[i] === '\x1b') { + // Collect full ANSI escape sequence: \x1b[ ... m + const start = i + i++ // skip \x1b + if (i < line.length && line[i] === '[') { + i++ // skip [ + while (i < line.length && line[i] !== 'm') i++ + if (i < line.length) i++ // skip m + } + style += line.slice(start, i) + } else { + // Visible character (handle multi-byte Unicode) + const cp = line.codePointAt(i)! + const ch = String.fromCodePoint(cp) + pixels.push({ char: ch, style }) + i += ch.length + } + } + return pixels } /** Render a Pixel row back to an ANSI string */ function renderRow(pixels: Pixel[]): string { - if (pixels.length === 0) return '' - let out = '' - let lastStyle: string | null = null - for (const p of pixels) { - if (p.style !== lastStyle) { - out += '\x1b[0m' + p.style // reset then apply - lastStyle = p.style - } - out += p.char - } - out += '\x1b[0m' // final reset - return out + if (pixels.length === 0) return '' + let out = '' + let lastStyle: string | null = null + for (const p of pixels) { + if (p.style !== lastStyle) { + out += '\x1b[0m' + p.style // reset then apply + lastStyle = p.style + } + out += p.char + } + out += '\x1b[0m' // final reset + return out } function parseSprite(lines: string[]): Pixel[][] { - return lines.map(parseLine) + return lines.map(parseLine) } function renderSprite(grid: Pixel[][]): string[] { - return grid.map(renderRow) + return grid.map(renderRow) } // ─── Grid Transforms ────────────────────────────────── @@ -80,37 +80,37 @@ function renderSprite(grid: Pixel[][]): string[] { /** Horizontal shift — positive = right, negative = left */ function shiftH(grid: Pixel[][], n: number): Pixel[][] { - if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row]) - if (n < 0) return grid.map(row => row.slice(Math.abs(n))) - return grid + if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row]) + if (n < 0) return grid.map(row => row.slice(Math.abs(n))) + return grid } /** Vertical shift up — removes rows from top, pads empty at bottom */ function shiftUp(grid: Pixel[][], n: number): Pixel[][] { - if (n <= 0) return grid - const height = grid.length - const shifted = grid.slice(n) - while (shifted.length < height) shifted.push(EMPTY_ROW) - return shifted + if (n <= 0) return grid + const height = grid.length + const shifted = grid.slice(n) + while (shifted.length < height) shifted.push(EMPTY_ROW) + return shifted } /** Mirror map — characters that change when flipped horizontally */ const MIRROR: Record = { - '/': '\\', '\\': '/', - '(': ')', ')': '(', - '<': '>', '>': '<', - '{': '}', '}': '{', - '[': ']', ']': '[', - '╱': '╲', '╲': '╱', - '▌': '▐', '▐': '▌', - '▎': '▏', '▏': '▎', - '◀': '▶', '▶': '◀', - '◄': '►', '►': '◄', - '→': '←', '←': '→', - '↗': '↙', '↙': '↗', - '↘': '↖', '↖': '↘', - '`': "'", "'": '`', - ',': '´', '´': ',', + '/': '\\', '\\': '/', + '(': ')', ')': '(', + '<': '>', '>': '<', + '{': '}', '}': '{', + '[': ']', ']': '[', + '╱': '╲', '╲': '╱', + '▌': '▐', '▐': '▌', + '▎': '▏', '▏': '▎', + '◀': '▶', '▶': '◀', + '◄': '►', '►': '◄', + '→': '←', '←': '→', + '↗': '↙', '↙': '↗', + '↘': '↖', '↖': '↘', + '`': "'", "'": '`', + ',': '´', '´': ',', } /** @@ -119,24 +119,24 @@ const MIRROR: Record = { * When mirrorChars=false, only reverse positions (more visible "flip" effect). */ function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] { - const width = Math.max(0, ...grid.map(row => row.length)) - return grid.map(row => - [...row, ...Array(width - row.length).fill(EMPTY_PIXEL)] - .reverse() - .map(p => ({ - ...p, - char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char, - })), - ) + const width = Math.max(0, ...grid.map(row => row.length)) + return grid.map(row => + [...row, ...Array(width - row.length).fill(EMPTY_PIXEL)] + .reverse() + .map(p => ({ + ...p, + char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char, + })), + ) } /** Replace eye-like characters with dash */ function blinkEyes(grid: Pixel[][]): Pixel[][] { - return grid.map(row => - row.map(p => - /[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p, - ), - ) + return grid.map(row => + row.map(p => + /[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p, + ), + ) } // ═══════════════════════════════════════════════════════ @@ -144,29 +144,29 @@ function blinkEyes(grid: Pixel[][]): Pixel[][] { // ═══════════════════════════════════════════════════════ const IDLE_SEQUENCE: AnimMode[] = [ - 'idle', 'idle', - 'breathe', 'breathe', - 'idle', - 'blink', - 'idle', - 'bounce', - 'idle', - 'fidget', 'fidget', - 'idle', - 'breathe', 'breathe', - 'idle', - 'flip', 'flip', 'flip', - 'idle', 'idle', - 'bounce', - 'idle', - 'blink', - 'idle', - 'excited', 'excited', - 'idle', + 'idle', 'idle', + 'breathe', 'breathe', + 'idle', + 'blink', + 'idle', + 'bounce', + 'idle', + 'fidget', 'fidget', + 'idle', + 'breathe', 'breathe', + 'idle', + 'flip', 'flip', 'flip', + 'idle', 'idle', + 'bounce', + 'idle', + 'blink', + 'idle', + 'excited', 'excited', + 'idle', ] export function getIdleAnimMode(tick: number): AnimMode { - return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] + return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] } // ═══════════════════════════════════════════════════════ @@ -178,64 +178,64 @@ export function getIdleAnimMode(tick: number): AnimMode { * Internally: parse ANSI → Pixel grid → transform → render back. */ export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] { - const grid = parseSprite(lines) + const grid = parseSprite(lines) - let result: Pixel[][] = grid + let result: Pixel[][] = grid - switch (mode) { - case 'idle': - break - case 'breathe': - // Right sway → center - result = shiftH(result, tick % 4 < 2 ? 3 : 0) - break - case 'blink': - result = blinkEyes(result) - break - case 'fidget': - // Big right sway → center - result = shiftH(result, tick % 2 === 0 ? 4 : 0) - break - case 'bounce': { - const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0] - const h = PATTERN[tick % PATTERN.length] - result = shiftUp(result, h) - break - } - case 'walkLeft': - // Step right → center (mimics bounce-back from left step) - result = shiftH(result, tick % 4 === 0 ? 0 : 3) - break - case 'walkRight': - // Step right → further right → center - result = shiftH(result, (tick % 4) * 2) - break - case 'flip': - // Pure position reversal — do NOT mirror chars so / \ ( ) - // visibly swap, making the flip obvious. - result = reverseH(result, false) - break - case 'excited': - // Jitter right ↔ further right (never crop) - result = shiftH(result, tick % 2 === 0 ? 1 : 4) - break - case 'pet': - break // overlay handled by SpriteAnimator - } + switch (mode) { + case 'idle': + break + case 'breathe': + // Right sway → center + result = shiftH(result, tick % 4 < 2 ? 3 : 0) + break + case 'blink': + result = blinkEyes(result) + break + case 'fidget': + // Big right sway → center + result = shiftH(result, tick % 2 === 0 ? 4 : 0) + break + case 'bounce': { + const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0] + const h = PATTERN[tick % PATTERN.length] + result = shiftUp(result, h) + break + } + case 'walkLeft': + // Step right → center (mimics bounce-back from left step) + result = shiftH(result, tick % 4 === 0 ? 0 : 3) + break + case 'walkRight': + // Step right → further right → center + result = shiftH(result, (tick % 4) * 2) + break + case 'flip': + // Pure position reversal — do NOT mirror chars so / \ ( ) + // visibly swap, making the flip obvious. + result = reverseH(result, false) + break + case 'excited': + // Jitter right ↔ further right (never crop) + result = shiftH(result, tick % 2 === 0 ? 1 : 4) + break + case 'pet': + break // overlay handled by SpriteAnimator + } - return renderSprite(result) + return renderSprite(result) } // ─── Heart overlay (kept for SpriteAnimator convenience) ── const PET_HEARTS = [ - [' ♥ ', ' '], - [' ♥ ♥ ', ' ♥ '], - [' ♥ ♥ ', ' ♥ ♥ '], - [' ♥ ♥ ', ' ♥ ♥ '], - [' ♥ ', ' ♥ ♥ '], + [' ♥ ', ' '], + [' ♥ ♥ ', ' ♥ '], + [' ♥ ♥ ', ' ♥ ♥ '], + [' ♥ ♥ ', ' ♥ ♥ '], + [' ♥ ', ' ♥ ♥ '], ] export function getPetOverlay(tick: number): string[] { - return PET_HEARTS[tick % PET_HEARTS.length] + return PET_HEARTS[tick % PET_HEARTS.length] } diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index 56f826170..d33b3f66b 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -2,38 +2,38 @@ 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 = { - hp: 'HP', - attack: 'ATK', - defense: 'DEF', - spAtk: 'SPA', - spDef: 'SPD', - speed: 'SPE', + 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' + | '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', + 'bulbasaur', + 'ivysaur', + 'venusaur', + 'charmander', + 'charmeleon', + 'charizard', + 'squirtle', + 'wartortle', + 'blastoise', + 'pikachu', ] // Nature (delegated to @pkmn/sim Dex.natures) @@ -62,11 +62,11 @@ export type Gender = 'male' | 'female' | 'genderless' 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 + 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) @@ -74,78 +74,78 @@ export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erra // Species base data export type SpeciesData = { - id: SpeciesId - name: string // English name - names: Record // 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 - 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 + id: SpeciesId + name: string // English name + names: Record // 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 + 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 - nature: NatureName // Character nature - ev: Record // Effort values - iv: Record // Individual values (0-31) - moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots - ability: string // Showdown ability ID - heldItem: ItemId | null // Held item - friendship: number // Friendship (0-255) - isShiny: boolean - hatchedAt: number // Timestamp when obtained - pokeball: string // Pokeball type + 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 + nature: NatureName // Character nature + ev: Record // Effort values + iv: Record // Individual values (0-31) + moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots + ability: string // Showdown ability ID + heldItem: ItemId | null // Held item + friendship: number // Friendship (0-255) + isShiny: boolean + hatchedAt: number // Timestamp when obtained + pokeball: string // Pokeball type } // 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 + 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 + speciesId: SpeciesId + discoveredAt: number + caughtCount: number // Number caught + bestLevel: number // Highest level record } // buddy-data.json complete structure export type BuddyData = { - version: 2 - party: (string | null)[] // Always length 6, party[0] = active buddy - boxes: PCBox[] // PC storage (default 8 boxes × 30 slots) - creatures: Creature[] - eggs: Egg[] - dex: DexEntry[] - bag: Bag - stats: { - totalTurns: number - consecutiveDays: number - lastActiveDate: string // ISO date - totalEggsObtained: number - totalEvolutions: number - battlesWon: number - battlesLost: number - } + version: 2 + party: (string | null)[] // Always length 6, party[0] = active buddy + boxes: PCBox[] // PC storage (default 8 boxes × 30 slots) + creatures: Creature[] + eggs: Egg[] + dex: DexEntry[] + bag: Bag + stats: { + totalTurns: number + consecutiveDays: number + lastActiveDate: string // ISO date + totalEggsObtained: number + totalEvolutions: number + battlesWon: number + battlesLost: number + } } // Calculated stats result @@ -153,29 +153,29 @@ export type StatsResult = Record // Evolution result export type EvolutionResult = { - from: SpeciesId - to: SpeciesId - newLevel: number + from: SpeciesId + to: SpeciesId + newLevel: number } // Sprite cache entry export type SpriteCache = { - speciesId: SpeciesId - lines: string[] - width: number - height: number - fetchedAt: number + speciesId: SpeciesId + lines: string[] + width: number + height: number + fetchedAt: number } // Animation mode export type AnimMode = - | 'idle' - | 'breathe' - | 'blink' - | 'fidget' - | 'bounce' - | 'walkLeft' - | 'walkRight' - | 'flip' - | 'excited' - | 'pet' + | 'idle' + | 'breathe' + | 'blink' + | 'fidget' + | 'bounce' + | 'walkLeft' + | 'walkRight' + | 'flip' + | 'excited' + | 'pet' diff --git a/packages/pokemon/src/ui/BattleConfigPanel.tsx b/packages/pokemon/src/ui/BattleConfigPanel.tsx index 21a3d05ef..276d2a169 100644 --- a/packages/pokemon/src/ui/BattleConfigPanel.tsx +++ b/packages/pokemon/src/ui/BattleConfigPanel.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Box, Text } from '@anthropic/ink' import type { Creature, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/species' import { calculateStats, getCreatureName } from '../core/creature' const CYAN = 'ansi:cyan' @@ -11,56 +11,56 @@ const GRAY = 'ansi:white' const YELLOW = 'ansi:yellow' interface BattleConfigPanelProps { - party: (Creature | null)[] - onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void - onCancel: () => void + party: (Creature | null)[] + onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void + onCancel: () => void } export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) { - const activeCreature = party[0] + const activeCreature = party[0] - return ( - - 战斗配置 + return ( + + 战斗配置 - {/* Party display */} - - 队伍: - {party.map((creature, i) => { - if (!creature) return ( - - [{i + 1}] [空] - - ) - const species = getSpeciesData(creature.speciesId) - const stats = calculateStats(creature) - const hpPercent = 100 - const hpBar = '█'.repeat(Math.floor(hpPercent / 10)) - const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10)) - const isLead = i === 0 - return ( - - {isLead ? ' ▶ ' : ' '} - {getCreatureName(creature)} - Lv.{creature.level} - {hpBar} - {hpEmpty} - {hpPercent}% - - ) - })} - + {/* Party display */} + + 队伍: + {party.map((creature, i) => { + if (!creature) return ( + + [{i + 1}] [空] + + ) + const species = getSpeciesData(creature.speciesId) + const stats = calculateStats(creature) + const hpPercent = 100 + const hpBar = '█'.repeat(Math.floor(hpPercent / 10)) + const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10)) + const isLead = i === 0 + return ( + + {isLead ? ' ▶ ' : ' '} + {getCreatureName(creature)} + Lv.{creature.level} + {hpBar} + {hpEmpty} + {hpPercent}% + + ) + })} + - {/* Opponent selection */} - - 对手: - [1] 随机遇战(等级自动匹配) - [2] 指定对手(输入物种名) - + {/* Opponent selection */} + + 对手: + [1] 随机遇战(等级自动匹配) + [2] 指定对手(输入物种名) + - - [Enter] 开始战斗 [ESC] 取消 - - - ) + + [Enter] 开始战斗 [ESC] 取消 + + + ) } diff --git a/packages/pokemon/src/ui/BattleFlow.tsx b/packages/pokemon/src/ui/BattleFlow.tsx index 1de91b5aa..02d7757ab 100644 --- a/packages/pokemon/src/ui/BattleFlow.tsx +++ b/packages/pokemon/src/ui/BattleFlow.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react' import { Box, Text, useInput } from '@anthropic/ink' import type { BuddyData, Creature, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' +import { getSpeciesData } from '../dex/species' import { saveBuddyData } from '../core/storage' import { createBattle, executeTurn, type BattleInit } from '../battle/engine' import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement' @@ -15,356 +16,386 @@ import { chooseAIMove } from '../battle/ai' import type { BattleState, PlayerAction } from '../battle/types' type Phase = - | 'config' - | 'configSelect' - | 'battle' - | 'switch' - | 'item' - | 'result' - | 'learnMoves' - | 'evolution' - | 'done' + | 'config' + | 'configSelect' + | 'battle' + | 'switch' + | 'item' + | 'result' + | 'learnMoves' + | 'evolution' + | 'done' interface BattleFlowProps { - buddyData: BuddyData - onClose: () => void + buddyData: BuddyData + onClose: () => void + isActive?: boolean } -export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps) { - const [phase, setPhase] = useState('config') - const [buddyData, setBuddyData] = useState(initialData) - const [battleInit, setBattleInit] = useState(null) - const [battleState, setBattleState] = useState(null) - const [opponentSpeciesId, setOpponentSpeciesId] = useState('pikachu') - const [opponentLevel, setOpponentLevel] = useState(5) - const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([]) - const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([]) - const [replaceIndex, setReplaceIndex] = useState(0) +export function BattleFlow({ buddyData: initialData, onClose, isActive = true }: BattleFlowProps) { + const [phase, setPhase] = useState('config') + const [buddyData, setBuddyData] = useState(initialData) + const [battleInit, setBattleInit] = useState(null) + const [battleState, setBattleState] = useState(null) + const [opponentSpeciesId, setOpponentSpeciesId] = useState('pikachu') + const [opponentLevel, setOpponentLevel] = useState(5) + const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([]) + const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([]) + const [replaceIndex, setReplaceIndex] = useState(0) + const [speciesIndex, setSpeciesIndex] = useState(0) - // ─── Input handling ─── + // ─── Input handling ─── - useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => { - // Config phase: Enter = random battle, ESC = cancel - if (phase === 'config') { - if (key.escape) { - onClose() - } else if (key.return || input === '1') { - handleRandomBattle() - } else if (input === '2') { - setPhase('configSelect') - } - return - } + useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => { + // Config phase: Enter = random battle, ESC = cancel + if (!isActive) return + if (phase === 'config') { + if (key.escape) { + onClose() + } else if (key.return || input === '1') { + handleRandomBattle() + } else if (input === '2') { + setPhase('configSelect') + } + return + } - // Config select: pick species by number - if (phase === 'configSelect') { - if (key.escape) { - setPhase('config') - } else if (key.return) { - handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5) - } - return - } + // Config select: pick species by number + if (phase === 'configSelect') { + if (key.escape) { + setPhase('config') + } else if (key.return) { + handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5) + } + return + } - // Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel - if (phase === 'battle') { - if (key.escape) { - // Can't flee from wild battle - do nothing - return - } - if (input >= '1' && input <= '4') { - const idx = parseInt(input) - 1 - if (battleState && idx < battleState.playerPokemon.moves.length) { - handleAction({ type: 'move', moveIndex: idx }) - } - } else if (input.toLowerCase() === 's') { - setPhase('switch') - } else if (input.toLowerCase() === 'i') { - setPhase('item') - } - return - } + // Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel + if (phase === 'battle') { + if (key.escape) { + // Can't flee from wild battle - do nothing + return + } + if (input >= '1' && input <= '4') { + const idx = parseInt(input) - 1 + if (battleState && idx < battleState.playerPokemon.moves.length) { + handleAction({ type: 'move', moveIndex: idx }) + } + } else if (input.toLowerCase() === 's') { + setPhase('switch') + } else if (input.toLowerCase() === 'i') { + setPhase('item') + } + return + } - // Switch phase: 1-6 = select, ESC = cancel - if (phase === 'switch') { - if (key.escape) { - setPhase('battle') - } else if (input >= '1' && input <= '6') { - const idx = parseInt(input) - 1 - const partyCreatures = getPartyCreatures() - if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) { - handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id }) - setPhase('battle') - } - } - return - } + // Switch phase: 1-6 = select, ESC = cancel + if (phase === 'switch') { + if (key.escape) { + setPhase('battle') + } else if (input >= '1' && input <= '6') { + const idx = parseInt(input) - 1 + const partyCreatures = getPartyCreatures() + if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) { + handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id }) + setPhase('battle') + } + } + return + } - // Item phase: 1-9 = select item, ESC = cancel - if (phase === 'item') { - if (key.escape) { - setPhase('battle') - } else if (input >= '1' && input <= '9') { - if (battleState) { - const idx = parseInt(input) - 1 - const items = battleState.usableItems - if (items[idx]) { - handleAction({ type: 'item', itemId: items[idx]!.id }) - setPhase('battle') - } - } - } - return - } + // Item phase: 1-9 = select item, ESC = cancel + if (phase === 'item') { + if (key.escape) { + setPhase('battle') + } else if (input >= '1' && input <= '9') { + if (battleState) { + const idx = parseInt(input) - 1 + const items = battleState.usableItems + if (items[idx]) { + handleAction({ type: 'item', itemId: items[idx]!.id }) + setPhase('battle') + } + } + } + return + } - // Result phase: Enter = continue - if (phase === 'result') { - if (key.return) { - handleResultContinue() - } - return - } + // Result phase: Enter = continue + if (phase === 'result') { + if (key.return) { + handleResultContinue() + } + return + } - // Move learn phase: 1-4 = replace, S = skip - if (phase === 'learnMoves') { - if (input.toLowerCase() === 's') { - handleMoveSkip() - } else if (input >= '1' && input <= '4') { - const idx = parseInt(input) - 1 - setReplaceIndex(idx) - handleMoveLearn(idx) - } - return - } + // Move learn phase: 1-4 = replace, S = skip + if (phase === 'learnMoves') { + if (input.toLowerCase() === 's') { + handleMoveSkip() + } else if (input >= '1' && input <= '4') { + const idx = parseInt(input) - 1 + setReplaceIndex(idx) + handleMoveLearn(idx) + } + return + } - // Evolution phase: Enter = confirm - if (phase === 'evolution') { - if (key.return) { - handleEvolutionConfirm() - } - return - } - }) + // Evolution phase: Enter = confirm + if (phase === 'evolution') { + if (key.return) { + handleEvolutionConfirm() + } + return + } + }) - // ─── Helpers ─── + // ─── Helpers ─── - function getActiveCreatureLevel(): number { - const id = buddyData.party[0] - if (!id) return 5 - const c = buddyData.creatures.find(cr => cr.id === id) - return c?.level ?? 5 - } + function getActiveCreatureLevel(): number { + const id = buddyData.party[0] + if (!id) return 5 + const c = buddyData.creatures.find(cr => cr.id === id) + return c?.level ?? 5 + } - function getPartyCreatures(): Creature[] { - return buddyData.party - .filter((id): id is string => id !== null) - .map(id => buddyData.creatures.find(c => c.id === id)) - .filter((c): c is Creature => c !== undefined) - } + function getPartyCreatures(): Creature[] { + return buddyData.party + .filter((id): id is string => id !== null) + .map(id => buddyData.creatures.find(c => c.id === id)) + .filter((c): c is Creature => c !== undefined) + } - // ─── Actions ─── + // ─── Actions ─── - const handleRandomBattle = useCallback(() => { - const opponentLevel = getActiveCreatureLevel() - const speciesList = ALL_SPECIES_IDS - const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]! - handleStartBattle(randomSpecies, opponentLevel) - }, [buddyData]) + const handleRandomBattle = useCallback(() => { + const opponentLevel = getActiveCreatureLevel() + const speciesList = ALL_SPECIES_IDS + const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]! + handleStartBattle(randomSpecies, opponentLevel) + }, [buddyData]) - // Config phase: start battle - const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => { - setOpponentSpeciesId(speciesId) - setOpponentLevel(level) + // Config phase: start battle + const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => { + setOpponentSpeciesId(speciesId) + setOpponentLevel(level) - const creatures = buddyData.party - .filter((id): id is string => id !== null) - .map(id => buddyData.creatures.find(c => c.id === id)) - .filter((c): c is Creature => c !== undefined) + const creatures = buddyData.party + .filter((id): id is string => id !== null) + .map(id => buddyData.creatures.find(c => c.id === id)) + .filter((c): c is Creature => c !== undefined) - if (creatures.length === 0) return + if (creatures.length === 0) return - const bagItems = buddyData.bag.items - const init = createBattle(creatures, speciesId, level, bagItems) - setBattleInit(init) - setBattleState(init.state) - setPhase('battle') - }, [buddyData]) + const bagItems = buddyData.bag.items + const init = createBattle(creatures, speciesId, level, bagItems) + setBattleInit(init) + setBattleState(init.state) + setPhase('battle') + }, [buddyData]) - // Battle phase: handle action - const handleAction = useCallback(async (action: PlayerAction) => { - if (!battleInit) return - const state = executeTurn(battleInit, action) - setBattleState(state) + // Battle phase: handle action + const handleAction = useCallback(async (action: PlayerAction) => { + if (!battleInit) return + const state = executeTurn(battleInit, action) + setBattleState(state) - if (state.finished && state.result) { - const participants = buddyData.party.filter((id): id is string => id !== null) - const result = { ...state.result, participantIds: participants } - const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel) + if (state.finished && state.result) { + const participants = buddyData.party.filter((id): id is string => id !== null) + const result = { ...state.result, participantIds: participants } + const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel) - setBuddyData(settled.data) - setPendingMoves(settled.learnableMoves) - setPendingEvos(settled.pendingEvolutions) - setBattleState({ ...state, result }) - setPhase('result') - } - }, [battleInit, buddyData, opponentSpeciesId, opponentLevel]) + setBuddyData(settled.data) + setPendingMoves(settled.learnableMoves) + setPendingEvos(settled.pendingEvolutions) + setBattleState({ ...state, result }) + setPhase('result') + } + }, [battleInit, buddyData, opponentSpeciesId, opponentLevel]) - // Result phase: continue to move learning - const handleResultContinue = useCallback(() => { - if (pendingMoves.length > 0) { - setPhase('learnMoves') - } else if (pendingEvos.length > 0) { - setPhase('evolution') - } else { - saveBuddyData(buddyData) - setPhase('done') - onClose() - } - }, [pendingMoves, pendingEvos, buddyData, onClose]) + // Result phase: continue to move learning + const handleResultContinue = useCallback(() => { + if (pendingMoves.length > 0) { + setPhase('learnMoves') + } else if (pendingEvos.length > 0) { + setPhase('evolution') + } else { + saveBuddyData(buddyData) + setPhase('done') + onClose() + } + }, [pendingMoves, pendingEvos, buddyData, onClose]) - // Move learning - const handleMoveLearn = useCallback((idx: number) => { - if (pendingMoves.length === 0) return - const move = pendingMoves[0]! - const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx) - setBuddyData(updated) - const remaining = pendingMoves.slice(1) - setPendingMoves(remaining) - if (remaining.length === 0) { - if (pendingEvos.length > 0) { - setPhase('evolution') - } else { - saveBuddyData(updated) - setPhase('done') - onClose() - } - } - }, [pendingMoves, pendingEvos, buddyData, onClose]) + // Move learning + const handleMoveLearn = useCallback((idx: number) => { + if (pendingMoves.length === 0) return + const move = pendingMoves[0]! + const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx) + setBuddyData(updated) + const remaining = pendingMoves.slice(1) + setPendingMoves(remaining) + if (remaining.length === 0) { + if (pendingEvos.length > 0) { + setPhase('evolution') + } else { + saveBuddyData(updated) + setPhase('done') + onClose() + } + } + }, [pendingMoves, pendingEvos, buddyData, onClose]) - const handleMoveSkip = useCallback(() => { - const remaining = pendingMoves.slice(1) - setPendingMoves(remaining) - if (remaining.length === 0) { - if (pendingEvos.length > 0) { - setPhase('evolution') - } else { - saveBuddyData(buddyData) - setPhase('done') - onClose() - } - } - }, [pendingMoves, pendingEvos, buddyData, onClose]) + const handleMoveSkip = useCallback(() => { + const remaining = pendingMoves.slice(1) + setPendingMoves(remaining) + if (remaining.length === 0) { + if (pendingEvos.length > 0) { + setPhase('evolution') + } else { + saveBuddyData(buddyData) + setPhase('done') + onClose() + } + } + }, [pendingMoves, pendingEvos, buddyData, onClose]) - // Evolution - const handleEvolutionConfirm = useCallback(() => { - if (pendingEvos.length === 0) return - const evo = pendingEvos[0]! - const updated = applyEvolution(buddyData, evo.creatureId, evo.to) - setBuddyData(updated) - const remaining = pendingEvos.slice(1) - setPendingEvos(remaining) - if (remaining.length === 0) { - saveBuddyData(updated) - setPhase('done') - onClose() - } - }, [pendingEvos, buddyData, onClose]) + // Evolution + const handleEvolutionConfirm = useCallback(() => { + if (pendingEvos.length === 0) return + const evo = pendingEvos[0]! + const updated = applyEvolution(buddyData, evo.creatureId, evo.to) + setBuddyData(updated) + const remaining = pendingEvos.slice(1) + setPendingEvos(remaining) + if (remaining.length === 0) { + saveBuddyData(updated) + setPhase('done') + onClose() + } + }, [pendingEvos, buddyData, onClose]) - // Render by phase - switch (phase) { - case 'config': - case 'configSelect': - return ( - - ) + // Render by phase + switch (phase) { + case 'config': + return ( + + ) - case 'battle': { - if (!battleState) return null - return ( - - ) - } + case 'configSelect': { + const species = getSpeciesData(opponentSpeciesId) + const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId) + const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5)) + const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5) + return ( + + 选择对手 + {visibleSpecies.map((sid, i) => { + const s = getSpeciesData(sid) + const isSelected = sid === opponentSpeciesId + return ( + + + {isSelected ? ' ▶ ' : ' '} + #{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name} + + {isSelected && Lv.{getActiveCreatureLevel()}} + + ) + })} + + [↑↓] 选择 [Enter] 确认 [ESC] 返回 + + + ) + } - case 'switch': { - if (!battleState) return null - return ( - { - handleAction({ type: 'switch', creatureId }) - setPhase('battle') - }} - onCancel={() => setPhase('battle')} - /> - ) - } + case 'battle': { + if (!battleState) return null + return ( + + ) + } - case 'item': { - if (!battleState) return null - return ( - { - handleAction({ type: 'item', itemId }) - setPhase('battle') - }} - onCancel={() => setPhase('battle')} - /> - ) - } + case 'switch': { + if (!battleState) return null + return ( + { + handleAction({ type: 'switch', creatureId }) + setPhase('battle') + }} + onCancel={() => setPhase('battle')} + /> + ) + } - case 'result': { - if (!battleState?.result) return null - return ( - - ) - } + case 'item': { + if (!battleState) return null + return ( + { + handleAction({ type: 'item', itemId }) + setPhase('battle') + }} + onCancel={() => setPhase('battle')} + /> + ) + } - case 'learnMoves': { - if (pendingMoves.length === 0) return null - const move = pendingMoves[0]! - const creature = buddyData.creatures.find(c => c.id === move.creatureId) - if (!creature) return null - return ( - - ) - } + case 'result': { + if (!battleState?.result) return null + return ( + + ) + } - case 'evolution': { - if (pendingEvos.length === 0) return null - const evo = pendingEvos[0]! - return ( - - 进化! - {evo.from} 正在进化为 {evo.to}! - [Enter] 继续 - - ) - } + case 'learnMoves': { + if (pendingMoves.length === 0) return null + const move = pendingMoves[0]! + const creature = buddyData.creatures.find(c => c.id === move.creatureId) + if (!creature) return null + return ( + + ) + } - case 'done': - return null + case 'evolution': { + if (pendingEvos.length === 0) return null + const evo = pendingEvos[0]! + return ( + + 进化! + {evo.from} 正在进化为 {evo.to}! + [Enter] 继续 + + ) + } - default: - return null - } + case 'done': + return null + + default: + return null + } } diff --git a/packages/pokemon/src/ui/BattleResultPanel.tsx b/packages/pokemon/src/ui/BattleResultPanel.tsx index ac2194648..5529d4041 100644 --- a/packages/pokemon/src/ui/BattleResultPanel.tsx +++ b/packages/pokemon/src/ui/BattleResultPanel.tsx @@ -9,40 +9,40 @@ const CYAN = 'ansi:cyan' const WHITE = 'ansi:whiteBright' interface BattleResultPanelProps { - result: BattleResult - playerPokemon: BattlePokemon - onContinue: () => void + result: BattleResult + playerPokemon: BattlePokemon + onContinue: () => void } export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) { - const isWin = result.winner === 'player' + const isWin = result.winner === 'player' - return ( - - - - {' '}战斗结束!{isWin ? '胜利!' : '失败...'} - - + return ( + + + + {' '}战斗结束!{isWin ? '胜利!' : '失败...'} + + - {isWin && ( - - {playerPokemon.name} 获得了 {result.xpGained} 经验值! + {isWin && ( + + {playerPokemon.name} 获得了 {result.xpGained} 经验值! - {Object.keys(result.evGained).length > 0 && ( - - 努力值获得: - {Object.entries(result.evGained).map(([stat, value]) => ( - {stat.toUpperCase()}+{value} - ))} - - )} - - )} + {Object.keys(result.evGained).length > 0 && ( + + 努力值获得: + {Object.entries(result.evGained).map(([stat, value]) => ( + {stat.toUpperCase()}+{value} + ))} + + )} + + )} - - [Enter] 继续 - - - ) + + [Enter] 继续 + + + ) } diff --git a/packages/pokemon/src/ui/BattleView.tsx b/packages/pokemon/src/ui/BattleView.tsx index 7428edfe9..79554557d 100644 --- a/packages/pokemon/src/ui/BattleView.tsx +++ b/packages/pokemon/src/ui/BattleView.tsx @@ -10,117 +10,117 @@ const GRAY = 'ansi:white' const WHITE = 'ansi:whiteBright' function hpColor(pct: number): Color { - if (pct > 50) return GREEN - if (pct > 25) return YELLOW - return RED + if (pct > 50) return GREEN + if (pct > 25) return YELLOW + return RED } function hpBar(current: number, max: number): { bar: string; pct: number } { - if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 } - const pct = Math.round((current / max) * 100) - const filled = Math.round((current / max) * 10) - return { - bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)), - pct, - } + if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 } + const pct = Math.round((current / max) * 100) + const filled = Math.round((current / max) * 10) + return { + bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)), + pct, + } } interface BattleViewProps { - state: BattleState - onAction: (action: import('../battle/types').PlayerAction) => void + state: BattleState + onAction: (action: import('../battle/types').PlayerAction) => void } export function BattleView({ state, onAction }: BattleViewProps) { - const opp = state.opponentPokemon - const player = state.playerPokemon - const oppHp = hpBar(opp.hp, opp.maxHp) - const playerHp = hpBar(player.hp, player.maxHp) + const opp = state.opponentPokemon + const player = state.playerPokemon + const oppHp = hpBar(opp.hp, opp.maxHp) + const playerHp = hpBar(player.hp, player.maxHp) - // Show last 5 events - const recentEvents = state.events.slice(-5) + // Show last 5 events + const recentEvents = state.events.slice(-5) - return ( - - {/* Opponent */} - - - 野生 {opp.name} - (Lv.{opp.level}) - - - HP - {oppHp.bar} - {oppHp.pct}% - {opp.status !== 'none' && [{opp.status}]} - - + return ( + + {/* Opponent */} + + + 野生 {opp.name} + (Lv.{opp.level}) + + + HP + {oppHp.bar} + {oppHp.pct}% + {opp.status !== 'none' && [{opp.status}]} + + - ── vs ── + ── vs ── - {/* Player */} - - - {player.name} - (Lv.{player.level}) - - - HP - {playerHp.bar} - {playerHp.pct}% - {player.status !== 'none' && [{player.status}]} - - + {/* Player */} + + + {player.name} + (Lv.{player.level}) + + + HP + {playerHp.bar} + {playerHp.pct}% + {player.status !== 'none' && [{player.status}]} + + - {/* Move selection */} - {!state.finished && ( - - 选择行动: - {player.moves.map((move, i) => ( - - 0 ? WHITE : GRAY}> - {' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp} - - - ))} - [S] 换人 [I] 道具 - - )} + {/* Move selection */} + {!state.finished && ( + + 选择行动: + {player.moves.map((move, i) => ( + + 0 ? WHITE : GRAY}> + {' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp} + + + ))} + [S] 换人 [I] 道具 + + )} - {/* Event log */} - {recentEvents.length > 0 && ( - - {recentEvents.map((event, i) => ( - {formatEvent(event)} - ))} - - )} - - ) + {/* Event log */} + {recentEvents.length > 0 && ( + + {recentEvents.map((event, i) => ( + {formatEvent(event)} + ))} + + )} + + ) } function eventColor(event: BattleEvent): Color { - switch (event.type) { - case 'damage': return RED - case 'heal': return GREEN - case 'faint': return RED - case 'crit': return YELLOW - case 'miss': return GRAY - case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW - default: return WHITE - } + switch (event.type) { + case 'damage': return RED + case 'heal': return GREEN + case 'faint': return RED + case 'crit': return YELLOW + case 'miss': return GRAY + case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW + default: return WHITE + } } function formatEvent(event: BattleEvent): string { - switch (event.type) { - case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!` - case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)` - case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!` - case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!` - case 'crit': return '击中要害!' - case 'miss': return '攻击没有命中!' - case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...' - case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!` - case 'turn': return `── 回合 ${event.number} ──` - default: return '' - } + switch (event.type) { + case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!` + case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)` + case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!` + case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!` + case 'crit': return '击中要害!' + case 'miss': return '攻击没有命中!' + case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...' + case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!` + case 'turn': return `── 回合 ${event.number} ──` + default: return '' + } } diff --git a/packages/pokemon/src/ui/CompanionCard.tsx b/packages/pokemon/src/ui/CompanionCard.tsx index 736f557e4..57069eaf3 100644 --- a/packages/pokemon/src/ui/CompanionCard.tsx +++ b/packages/pokemon/src/ui/CompanionCard.tsx @@ -2,20 +2,20 @@ 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 { getSpeciesData } from '../data/species' -import { SPECIES_PERSONALITY } from '../data/names' +import { getSpeciesData } from '../dex/species' +import { SPECIES_PERSONALITY } from '../dex/names' import { calculateStats, getCreatureName, getTotalEV } from '../core/creature' import { getXpProgress } from '../core/experience' import { getEVSummary } from '../core/effort' import { getGenderSymbol } from '../core/gender' import { getStatColor } from './shared' -import { getNextEvolution } from '../data/evolution' +import { getNextEvolution } from '../dex/evolution' import { StatBar } from './StatBar' interface CompanionCardProps { - creature: Creature - buddyData: BuddyData - spriteLines?: string[] + creature: Creature + buddyData: BuddyData + spriteLines?: string[] } // ANSI color constants @@ -30,130 +30,130 @@ const GRAY: Color = 'ansi:white' /** Type → display color mapping */ const TYPE_COLORS: Record = { - grass: 'ansi:green', - poison: 'ansi:magenta', - fire: 'ansi:red', - flying: 'ansi:cyan', - water: 'ansi:blue', - electric: 'ansi:yellow', - normal: 'ansi:white', + 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 = getSpeciesData(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) + const species = getSpeciesData(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) => ( - - {i > 0 ? '/' : ''}{t.toUpperCase()} - - )) + // Type badges + const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => ( + + {i > 0 ? '/' : ''}{t.toUpperCase()} + + )) - // Friendship color - const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED + // Friendship color + const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED - // Shiny badge - const shinyBadge = creature.isShiny ? ★SHINY★ : null + // Shiny badge + const shinyBadge = creature.isShiny ? ★SHINY★ : null - // Evolution hint - const evoHint = nextEvo ? ( - {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv.{nextEvo.minLevel} - ) : null + // Evolution hint + const evoHint = nextEvo ? ( + {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv.{nextEvo.minLevel} + ) : null - return ( - - {/* Header row */} - - - {name} - #{String(species.dexNumber).padStart(3, '0')} - {shinyBadge} - - Lv.{creature.level} - + return ( + + {/* Header row */} + + + {name} + #{String(species.dexNumber).padStart(3, '0')} + {shinyBadge} + + Lv.{creature.level} + - {/* Species + type + gender */} - - {species.names.zh ?? species.name} - - {typeBadges} - {genderSymbol && {genderSymbol}} - + {/* Species + type + gender */} + + {species.names.zh ?? species.name} + + {typeBadges} + {genderSymbol && {genderSymbol}} + - {/* Sprite */} - - {spriteLines ? ( - spriteLines.map((line, i) => {line}) - ) : ( - [Loading sprite...] - )} - + {/* Sprite */} + + {spriteLines ? ( + spriteLines.map((line, i) => {line}) + ) : ( + [Loading sprite...] + )} + - {/* Personality */} - - "{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}" - + {/* Personality */} + + "{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}" + - {/* Stats section */} - - ─── Base Stats ─── - {STAT_NAMES.map((stat) => ( - - ))} - + {/* Stats section */} + + ─── Base Stats ─── + {STAT_NAMES.map((stat) => ( + + ))} + - {/* XP progress */} - - XP - - {'█'.repeat(Math.round(xp.percentage / 10))} - {'░'.repeat(10 - Math.round(xp.percentage / 10))} - - {xp.current}/{xp.needed} - + {/* XP progress */} + + XP + + {'█'.repeat(Math.round(xp.percentage / 10))} + {'░'.repeat(10 - Math.round(xp.percentage / 10))} + + {xp.current}/{xp.needed} + - {/* EV + Friendship */} - - - EV - = 510 ? GREEN : GRAY}>{evSummary} - ({totalEV}/510) - - - - - {'█'.repeat(Math.round((creature.friendship / 255) * 10))} - {'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))} - - {creature.friendship}/255 - - + {/* EV + Friendship */} + + + EV + = 510 ? GREEN : GRAY}>{evSummary} + ({totalEV}/510) + + + + + {'█'.repeat(Math.round((creature.friendship / 255) * 10))} + {'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))} + + {creature.friendship}/255 + + - {/* Evolution hint */} - {evoHint && ( - - Next: - {evoHint} - - )} + {/* Evolution hint */} + {evoHint && ( + + Next: + {evoHint} + + )} - - ) + + ) } diff --git a/packages/pokemon/src/ui/EggView.tsx b/packages/pokemon/src/ui/EggView.tsx index 7c1888080..4143663ad 100644 --- a/packages/pokemon/src/ui/EggView.tsx +++ b/packages/pokemon/src/ui/EggView.tsx @@ -7,48 +7,48 @@ const YELLOW: Color = 'ansi:yellow' const GRAY: Color = 'ansi:white' interface EggViewProps { - egg: Egg + 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 + const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100) + const filled = Math.round(percentage / 10) + const empty = 10 - filled - return ( - - - Egg Status - + return ( + + + Egg Status + - {/* ASCII egg */} - - . - / \ - | | - \_/ - + {/* ASCII egg */} + + . + / \ + | | + \_/ + - {/* Progress */} - - - Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps} - - - {'█'.repeat(filled)} - {'░'.repeat(empty)} - - {percentage}% - + {/* Progress */} + + + Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps} + + + {'█'.repeat(filled)} + {'░'.repeat(empty)} + + {percentage}% + - {/* Tips */} - - Pet (+5) · Chat (+3) · Cmd (+1) - Hatch: ~{egg.stepsRemaining} more interactions - - - ) + {/* Tips */} + + Pet (+5) · Chat (+3) · Cmd (+1) + Hatch: ~{egg.stepsRemaining} more interactions + + + ) } diff --git a/packages/pokemon/src/ui/EvolutionAnim.tsx b/packages/pokemon/src/ui/EvolutionAnim.tsx index eff5184b0..b338c2c06 100644 --- a/packages/pokemon/src/ui/EvolutionAnim.tsx +++ b/packages/pokemon/src/ui/EvolutionAnim.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { SpeciesId } from '../types' -import { getSpeciesData } from '../data/species' +import { getSpeciesData } from '../dex/species' import { loadSprite } from '../core/spriteCache' import { getFallbackSprite } from '../sprites/fallback' @@ -10,9 +10,9 @@ const GREEN: Color = 'ansi:green' const GRAY: Color = 'ansi:white' interface EvolutionAnimProps { - fromSpecies: SpeciesId - toSpecies: SpeciesId - onComplete: () => void + fromSpecies: SpeciesId + toSpecies: SpeciesId + onComplete: () => void } /** @@ -21,70 +21,70 @@ interface EvolutionAnimProps { * 8 frames × 500ms = ~4 seconds total. */ export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) { - const [tick, setTick] = useState(0) - const totalFrames = 8 + 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]) + 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 = getSpeciesData(fromSpecies).name - const toName = getSpeciesData(toSpecies).name + const fromSprite = getSpriteLines(fromSpecies) + const toSprite = getSpriteLines(toSpecies) + const fromName = getSpeciesData(fromSpecies).name + const toName = getSpeciesData(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 - } + // 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 ( - - - ✨ Evolution! ✨ - + return ( + + + ✨ Evolution! ✨ + - - {displayLines.map((line, i) => ( - - {tick >= 6 ? '✨ ' : ''} - {line} - {tick >= 6 ? ' ✨' : ''} - - ))} - + + {displayLines.map((line, i) => ( + + {tick >= 6 ? '✨ ' : ''} + {line} + {tick >= 6 ? ' ✨' : ''} + + ))} + - - {fromName} - - - {toName} - - + + {fromName} + + + {toName} + + - {tick >= totalFrames - 1 && ( - - 进化成功! - - )} - - ) + {tick >= totalFrames - 1 && ( + + 进化成功! + + )} + + ) } function getSpriteLines(speciesId: SpeciesId): string[] { - const cached = loadSprite(speciesId) - if (cached) return cached.lines - return getFallbackSprite(speciesId) + const cached = loadSprite(speciesId) + if (cached) return cached.lines + return getFallbackSprite(speciesId) } diff --git a/packages/pokemon/src/ui/ItemPanel.tsx b/packages/pokemon/src/ui/ItemPanel.tsx index 05aaa680c..9d5c9748f 100644 --- a/packages/pokemon/src/ui/ItemPanel.tsx +++ b/packages/pokemon/src/ui/ItemPanel.tsx @@ -5,28 +5,28 @@ const CYAN = 'ansi:cyan' const GRAY = 'ansi:white' interface ItemPanelProps { - items: { id: string; name: string; count: number; description?: string }[] - onSelect: (itemId: string) => void - onCancel: () => void + items: { id: string; name: string; count: number; description?: string }[] + onSelect: (itemId: string) => void + onCancel: () => void } export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) { - return ( - - 道具 - {items.length === 0 ? ( - 没有可用道具 - ) : ( - items.map((item, i) => ( - - [{i + 1}] {item.name} ×{item.count} - {item.description && {item.description}} - - )) - )} - - [ESC] 取消 - - - ) + return ( + + 道具 + {items.length === 0 ? ( + 没有可用道具 + ) : ( + items.map((item, i) => ( + + [{i + 1}] {item.name} ×{item.count} + {item.description && {item.description}} + + )) + )} + + [ESC] 取消 + + + ) } diff --git a/packages/pokemon/src/ui/MoveLearnPanel.tsx b/packages/pokemon/src/ui/MoveLearnPanel.tsx index 41cadc098..33b768645 100644 --- a/packages/pokemon/src/ui/MoveLearnPanel.tsx +++ b/packages/pokemon/src/ui/MoveLearnPanel.tsx @@ -9,41 +9,41 @@ const GRAY = 'ansi:white' const WHITE = 'ansi:whiteBright' interface MoveLearnPanelProps { - creature: Creature - newMoveId: string - replaceIndex: number - onLearn: (replaceIndex: number) => void - onSkip: () => void - onSelectReplace: (index: number) => void + creature: Creature + newMoveId: string + replaceIndex: number + onLearn: (replaceIndex: number) => void + onSkip: () => void + onSelectReplace: (index: number) => void } export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) { - const dexMove = Dex.moves.get(newMoveId) - const moveName = dexMove?.name ?? newMoveId - const moveType = dexMove?.type ?? 'Normal' + const dexMove = Dex.moves.get(newMoveId) + const moveName = dexMove?.name ?? newMoveId + const moveType = dexMove?.type ?? 'Normal' - return ( - - 新招式! - {creature.speciesId} 可以学习: {moveName} ({moveType}) + return ( + + 新招式! + {creature.speciesId} 可以学习: {moveName} ({moveType}) - 当前招式: - {creature.moves.map((move, i) => { - const isReplaceTarget = i === replaceIndex - const moveInfo = move.id ? Dex.moves.get(move.id) : null - return ( - - - {' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp} - - {isReplaceTarget && ← 替换目标} - - ) - })} + 当前招式: + {creature.moves.map((move, i) => { + const isReplaceTarget = i === replaceIndex + const moveInfo = move.id ? Dex.moves.get(move.id) : null + return ( + + + {' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp} + + {isReplaceTarget && ← 替换目标} + + ) + })} - - [1-4] 替换对应招式 [S] 跳过 - - - ) + + [1-4] 替换对应招式 [S] 跳过 + + + ) } diff --git a/packages/pokemon/src/ui/PokedexView.tsx b/packages/pokemon/src/ui/PokedexView.tsx index 587b462b7..15b7a5844 100644 --- a/packages/pokemon/src/ui/PokedexView.tsx +++ b/packages/pokemon/src/ui/PokedexView.tsx @@ -2,8 +2,8 @@ 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 { getSpeciesData } from '../data/species' -import { getNextEvolution } from '../data/evolution' +import { getSpeciesData } from '../dex/species' +import { getNextEvolution } from '../dex/evolution' const CYAN: Color = 'ansi:cyan' const GREEN: Color = 'ansi:green' @@ -14,7 +14,7 @@ const RED: Color = 'ansi:red' const BLUE: Color = 'ansi:blue' interface PokedexViewProps { - buddyData: BuddyData + buddyData: BuddyData } /** @@ -22,165 +22,165 @@ interface PokedexViewProps { * 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 + 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() + // Group species by evolution chain + const chains = groupByChain() - return ( - - {/* Header */} - - Pokédex - - {collected} - /{total} - collected - - + return ( + + {/* Header */} + + Pokédex + + {collected} + /{total} + collected + + - {/* Progress bar */} - - {'█'.repeat(collected)} - {'░'.repeat(total - collected)} - {Math.floor((collected / total) * 100)}% - + {/* Progress bar */} + + {'█'.repeat(collected)} + {'░'.repeat(total - collected)} + {Math.floor((collected / total) * 100)}% + - {/* Species list grouped by evolution chains */} - {chains.map((chain, ci) => ( - 0 ? 0 : 0}> - {chain.map((speciesId, si) => { - const species = getSpeciesData(speciesId) - const entry = dexMap.get(speciesId) - const discovered = !!entry - const isActive = buddyData.party[0] - ? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId) - : false - const nextEvo = getNextEvolution(speciesId) + {/* Species list grouped by evolution chains */} + {chains.map((chain, ci) => ( + 0 ? 0 : 0}> + {chain.map((speciesId, si) => { + const species = getSpeciesData(speciesId) + const entry = dexMap.get(speciesId) + const discovered = !!entry + const isActive = buddyData.party[0] + ? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId) + : false + const nextEvo = getNextEvolution(speciesId) - return ( - - - {/* Chain connector */} - {si === 0 ? ' ' : '├'} - {/* Active indicator */} - {isActive ? : ' '} - {/* Dex number */} - #{String(species.dexNumber).padStart(3, '0')} - {/* Name */} - - {discovered - ? (species.names.zh ?? species.name) - : '???'} - - {/* Type badges */} - {discovered && ( - - {' '} - {species.types.filter((t): t is string => Boolean(t)).map((t, ti) => ( - - {ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()} - - ))} - - )} - {/* Level / unknown indicator */} - {discovered && entry ? ( - Lv.{entry.bestLevel} - ) : ( - ─── - )} - {/* Evolution arrow */} - {nextEvo && ( - Lv.{nextEvo.minLevel} - )} - - - ) - })} - - ))} + return ( + + + {/* Chain connector */} + {si === 0 ? ' ' : '├'} + {/* Active indicator */} + {isActive ? : ' '} + {/* Dex number */} + #{String(species.dexNumber).padStart(3, '0')} + {/* Name */} + + {discovered + ? (species.names.zh ?? species.name) + : '???'} + + {/* Type badges */} + {discovered && ( + + {' '} + {species.types.filter((t): t is string => Boolean(t)).map((t, ti) => ( + + {ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()} + + ))} + + )} + {/* Level / unknown indicator */} + {discovered && entry ? ( + Lv.{entry.bestLevel} + ) : ( + ─── + )} + {/* Evolution arrow */} + {nextEvo && ( + Lv.{nextEvo.minLevel} + )} + + + ) + })} + + ))} - {/* Stats row */} - - ─── Stats ─── - - Turns: - {buddyData.stats.totalTurns} - Days: - {buddyData.stats.consecutiveDays} - - - Eggs: - {buddyData.stats.totalEggsObtained} - Evolutions: - {buddyData.stats.totalEvolutions} - - + {/* Stats row */} + + ─── Stats ─── + + Turns: + {buddyData.stats.totalTurns} + Days: + {buddyData.stats.consecutiveDays} + + + Eggs: + {buddyData.stats.totalEggsObtained} + Evolutions: + {buddyData.stats.totalEvolutions} + + - {/* Egg info */} - {buddyData.eggs.length > 0 && ( - - 🥚 Egg: - {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} - steps - - )} + {/* Egg info */} + {buddyData.eggs.length > 0 && ( + + 🥚 Egg: + {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} + steps + + )} - {buddyData.stats.consecutiveDays < 7 && ( - - Next egg: {7 - buddyData.stats.consecutiveDays} more days - - )} - - ) + {buddyData.stats.consecutiveDays < 7 && ( + + Next egg: {7 - buddyData.stats.consecutiveDays} more days + + )} + + ) } /** Type → color mapping */ function getTypeColor(type: string): Color { - const colors: Record = { - 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' + const colors: Record = { + 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[][] { - const visited = new Set() - const chains: SpeciesId[][] = [] + const visited = new Set() + const chains: SpeciesId[][] = [] - for (const id of ALL_SPECIES_IDS) { - if (visited.has(id)) continue + for (const id of ALL_SPECIES_IDS) { + if (visited.has(id)) continue - // Walk back to find chain head - let head: SpeciesId = id - for (const candidate of ALL_SPECIES_IDS) { - const evo = getNextEvolution(candidate) - if (evo?.to === head) { - head = candidate - break - } - } + // Walk back to find chain head + let head: SpeciesId = id + for (const candidate of ALL_SPECIES_IDS) { + const evo = getNextEvolution(candidate) + if (evo?.to === head) { + head = candidate + break + } + } - // Walk forward to build chain - const chain: SpeciesId[] = [] - let current: SpeciesId | undefined = head - while (current && !visited.has(current)) { - chain.push(current) - visited.add(current) - current = getNextEvolution(current)?.to - } + // Walk forward to build chain + const chain: SpeciesId[] = [] + let current: SpeciesId | undefined = head + while (current && !visited.has(current)) { + chain.push(current) + visited.add(current) + current = getNextEvolution(current)?.to + } - if (chain.length > 0) chains.push(chain) - } + if (chain.length > 0) chains.push(chain) + } - return chains + return chains } diff --git a/packages/pokemon/src/ui/SpeciesDetail.tsx b/packages/pokemon/src/ui/SpeciesDetail.tsx index f499a2ed1..8c1162904 100644 --- a/packages/pokemon/src/ui/SpeciesDetail.tsx +++ b/packages/pokemon/src/ui/SpeciesDetail.tsx @@ -2,8 +2,8 @@ import React from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { SpeciesId, StatName } from '../types' import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types' -import { getSpeciesData } from '../data/species' -import { getNextEvolution } from '../data/evolution' +import { getSpeciesData } from '../dex/species' +import { getNextEvolution } from '../dex/evolution' import { StatBar } from './StatBar' import { getStatColor } from './shared' @@ -17,160 +17,160 @@ const BLUE: Color = 'ansi:blue' /** Type → color */ const TYPE_COLORS: Record = { - grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red', - flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow', - normal: 'ansi:white', + 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[] + 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 = getSpeciesData(speciesId) - const nextEvo = getNextEvolution(speciesId) + const species = getSpeciesData(speciesId) + const nextEvo = getNextEvolution(speciesId) - // Type badges - const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => ( - - {i > 0 ? ' / ' : ''}{t.toUpperCase()} - - )) + // Type badges + const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => ( + + {i > 0 ? ' / ' : ''}{t.toUpperCase()} + + )) - // Gender info - const genderInfo = species.genderRate === -1 - ? 'Genderless' - : species.genderRate === 0 - ? '♂ 100%' - : species.genderRate === 8 - ? '♀ 100%' - : `♀ ${(species.genderRate / 8 * 100).toFixed(1)}%` + // 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 + // Max base stat for bar scaling + const maxBase = 130 - return ( - - {/* Header */} - - - #{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name} - - {caughtLevel && Best: Lv.{caughtLevel}} - + return ( + + {/* Header */} + + + #{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name} + + {caughtLevel && Best: Lv.{caughtLevel}} + - {/* Type + gender */} - - {typeBadges} - {genderInfo} - + {/* Type + gender */} + + {typeBadges} + {genderInfo} + - {/* Sprite */} - {spriteLines && ( - - {spriteLines.map((line, i) => {line})} - - )} + {/* Sprite */} + {spriteLines && ( + + {spriteLines.map((line, i) => {line})} + + )} - {/* Flavor text */} - {species.flavorText && ( - - {species.flavorText} - - )} + {/* Flavor text */} + {species.flavorText && ( + + {species.flavorText} + + )} - {/* Base Stats */} - - ─── Base Stats ─── - {STAT_NAMES.map((stat) => ( - - {STAT_LABELS[stat].padEnd(3)} - - {'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))} - {'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))} - - {String(species.baseStats[stat]).padStart(3)} - - ))} - {/* Total */} - - {'Total'.padEnd(3)} - - {'─'.repeat(15)} - - {Object.values(species.baseStats).reduce((a, b) => a + b, 0)} - - + {/* Base Stats */} + + ─── Base Stats ─── + {STAT_NAMES.map((stat) => ( + + {STAT_LABELS[stat].padEnd(3)} + + {'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))} + {'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))} + + {String(species.baseStats[stat]).padStart(3)} + + ))} + {/* Total */} + + {'Total'.padEnd(3)} + + {'─'.repeat(15)} + + {Object.values(species.baseStats).reduce((a, b) => a + b, 0)} + + - {/* Evolution chain */} - {(nextEvo || species.dexNumber > 1) && ( - - ─── Evolution ─── - - - )} + {/* Evolution chain */} + {(nextEvo || species.dexNumber > 1) && ( + + ─── Evolution ─── + + + )} - {/* Info */} - - ─── Info ─── - - Growth: - {species.growthRate} - - - Capture: - {species.captureRate} - Happiness: - {species.baseHappiness} - - - - ) + {/* Info */} + + ─── Info ─── + + Growth: + {species.growthRate} + + + Capture: + {species.captureRate} + Happiness: + {species.baseHappiness} + + + + ) } /** Render evolution chain arrow */ function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) { - // Walk back to find chain head - let head: SpeciesId = speciesId - for (const candidate of ALL_SPECIES_IDS) { - const evo = getNextEvolution(candidate) - if (evo?.to === head) { - head = candidate - break - } - } + // Walk back to find chain head + let head: SpeciesId = speciesId + for (const candidate of ALL_SPECIES_IDS) { + const evo = getNextEvolution(candidate) + if (evo?.to === head) { + head = candidate + 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 - } - } + 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 ( - - {chain.map((sid, i) => ( - - {i > 0 && } - - {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name} - - {i < chain.length - 1 && getNextEvolution(sid) && ( - Lv.{getNextEvolution(sid)!.minLevel} - )} - - ))} - - ) + return ( + + {chain.map((sid, i) => ( + + {i > 0 && } + + {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name} + + {i < chain.length - 1 && getNextEvolution(sid) && ( + Lv.{getNextEvolution(sid)!.minLevel} + )} + + ))} + + ) } diff --git a/packages/pokemon/src/ui/SpriteAnimator.tsx b/packages/pokemon/src/ui/SpriteAnimator.tsx index 8f82bd844..8b3386dd1 100644 --- a/packages/pokemon/src/ui/SpriteAnimator.tsx +++ b/packages/pokemon/src/ui/SpriteAnimator.tsx @@ -7,18 +7,18 @@ import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites const V_PAD = 4 interface SpriteAnimatorProps { - /** Base sprite lines (ANSI is preserved) */ - lines: string[] - /** Text color for the sprite */ - color?: Color - /** Tick interval in ms (default 250) */ - tickMs?: number - /** Single mode; omit for idle auto-play */ - mode?: AnimMode - /** Center horizontally (default true) */ - centered?: boolean - /** Show pet hearts overlay */ - petting?: boolean + /** Base sprite lines (ANSI is preserved) */ + lines: string[] + /** Text color for the sprite */ + color?: Color + /** Tick interval in ms (default 250) */ + tickMs?: number + /** Single mode; omit for idle auto-play */ + mode?: AnimMode + /** Center horizontally (default true) */ + centered?: boolean + /** Show pet hearts overlay */ + petting?: boolean } /** @@ -29,44 +29,44 @@ interface SpriteAnimatorProps { * - Grid transforms guarantee fixed output height */ export function SpriteAnimator({ - lines, - color, - tickMs = 100, - mode, - centered = true, - petting, + lines, + color, + tickMs = 100, + mode, + centered = true, + petting, }: SpriteAnimatorProps) { - const [tick, setTick] = useState(0) + const [tick, setTick] = useState(0) - useEffect(() => { - const timer = setInterval(() => setTick(t => t + 1), tickMs) - return () => clearInterval(timer) - }, [tickMs]) + useEffect(() => { + const timer = setInterval(() => setTick(t => t + 1), tickMs) + return () => clearInterval(timer) + }, [tickMs]) - // Add vertical padding — bounce shifts within this space - const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')] + // Add vertical padding — bounce shifts within this space + const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')] - // Apply animation (renderer parses to pixels, transforms, renders back) - const currentMode = mode ?? getIdleAnimMode(tick) - const animated = renderAnimatedSprite(padded, tick, currentMode) + // Apply animation (renderer parses to pixels, transforms, renders back) + const currentMode = mode ?? getIdleAnimMode(tick) + const animated = renderAnimatedSprite(padded, tick, currentMode) - // Pet hearts overlay - const overlay = petting ? getPetOverlay(tick) : null - const displayLines = overlay ? [...overlay, ...animated] : animated + // Pet hearts overlay + const overlay = petting ? getPetOverlay(tick) : null + const displayLines = overlay ? [...overlay, ...animated] : animated - const spriteBlock = ( - - {displayLines.map((line, i) => ( - {line || ' '} - ))} - - ) + const spriteBlock = ( + + {displayLines.map((line, i) => ( + {line || ' '} + ))} + + ) - if (!centered) return spriteBlock + if (!centered) return spriteBlock - return ( - - {spriteBlock} - - ) + return ( + + {spriteBlock} + + ) } diff --git a/packages/pokemon/src/ui/StatBar.tsx b/packages/pokemon/src/ui/StatBar.tsx index 35cd620e8..c7a72bb95 100644 --- a/packages/pokemon/src/ui/StatBar.tsx +++ b/packages/pokemon/src/ui/StatBar.tsx @@ -2,27 +2,27 @@ import React from 'react' import { Box, Text, type Color } from '@anthropic/ink' interface StatBarProps { - label: string - value: number - maxValue: number - color?: Color - width?: number + 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) + const filled = Math.round((value / maxValue) * width) + const empty = width - filled + const bar = '█'.repeat(filled) + '░'.repeat(empty) + const valueStr = String(value).padStart(3) - return ( - - {label.padEnd(3)} - {bar} - {valueStr} - - ) + return ( + + {label.padEnd(3)} + {bar} + {valueStr} + + ) } diff --git a/packages/pokemon/src/ui/SwitchPanel.tsx b/packages/pokemon/src/ui/SwitchPanel.tsx index bf26b8b0f..38f6269e3 100644 --- a/packages/pokemon/src/ui/SwitchPanel.tsx +++ b/packages/pokemon/src/ui/SwitchPanel.tsx @@ -8,31 +8,31 @@ const GRAY = 'ansi:white' const WHITE = 'ansi:whiteBright' interface SwitchPanelProps { - party: Creature[] - activeId: string - onSelect: (creatureId: string) => void - onCancel: () => void + party: Creature[] + activeId: string + onSelect: (creatureId: string) => void + onCancel: () => void } export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) { - return ( - - 换人 - {party.map((creature, i) => { - const isActive = creature.id === activeId - return ( - - {isActive ? ' ▶ ' : ' '} - - [{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '} - - {isActive && 当前场上} - - ) - })} - - [ESC] 取消 - - - ) + return ( + + 换人 + {party.map((creature, i) => { + const isActive = creature.id === activeId + return ( + + {isActive ? ' ▶ ' : ' '} + + [{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '} + + {isActive && 当前场上} + + ) + })} + + [ESC] 取消 + + + ) } diff --git a/packages/pokemon/src/ui/shared.ts b/packages/pokemon/src/ui/shared.ts index a20803cab..328ff26e1 100644 --- a/packages/pokemon/src/ui/shared.ts +++ b/packages/pokemon/src/ui/shared.ts @@ -1,14 +1,14 @@ import type { Color } from '@anthropic/ink' const STAT_COLORS: Record = { - hp: 'ansi:green', - attack: 'ansi:red', - defense: 'ansi:yellow', - spAtk: 'ansi:blue', - spDef: 'ansi:magenta', - speed: 'ansi:cyan', + hp: 'ansi:green', + attack: 'ansi:red', + defense: 'ansi:yellow', + spAtk: 'ansi:blue', + spDef: 'ansi:magenta', + speed: 'ansi:cyan', } export function getStatColor(stat: string): Color { - return STAT_COLORS[stat] ?? 'ansi:white' + return STAT_COLORS[stat] ?? 'ansi:white' } diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx index 3438215bf..66113505d 100644 --- a/src/commands/buddy/BuddyPanel.tsx +++ b/src/commands/buddy/BuddyPanel.tsx @@ -21,6 +21,7 @@ import { getXpProgress } from '@claude-code-best/pokemon'; import { getGenderSymbol } from '@claude-code-best/pokemon'; import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon'; +import { BattleFlow, loadBuddyData } from '@claude-code-best/pokemon'; import type { LocalJSXCommandOnDone } from '../../types/command.js'; const CYAN: Color = 'ansi:cyan'; @@ -91,6 +92,13 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) onClose={() => onClose('buddy panel closed')} /> , + + + , , @@ -613,6 +621,35 @@ function DexTab({ ); } +// ─── Battle Tab ────────────────────────────────────────── + +function BattleTab({ + buddyData, + isActive, + onUpdate, +}: { + buddyData: BuddyData; + isActive: boolean; + onUpdate: (data: BuddyData) => void; +}) { + const [battleKey, setBattleKey] = useState(0); + + const handleClose = async () => { + const updated = await loadBuddyData(); + onUpdate(updated); + setBattleKey(k => k + 1); + }; + + return ( + + ); +} + // ─── Egg Tab ────────────────────────────────────────── function EggTab({ buddyData }: { buddyData: BuddyData }) {