diff --git a/packages/pokemon/src/__tests__/creature.test.ts b/packages/pokemon/src/__tests__/creature.test.ts index 29cf36b78..7307d00e4 100644 --- a/packages/pokemon/src/__tests__/creature.test.ts +++ b/packages/pokemon/src/__tests__/creature.test.ts @@ -4,8 +4,8 @@ import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalcul import { getSpeciesData } from '../data/species' describe('generateCreature', () => { - test('creates a creature with correct defaults', () => { - const c = generateCreature('bulbasaur', 42) + 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) @@ -13,23 +13,23 @@ describe('generateCreature', () => { expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness) expect(c.isShiny).toBeDefined() expect(c.id).toBeTruthy() - expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true) - expect(Object.values(c.ev).every((v) => v === 0)).toBe(true) + 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', () => { - const c1 = generateCreature('charmander', 12345) - const c2 = generateCreature('charmander', 12345) + 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', () => { - const c1 = generateCreature('squirtle', 100) - const c2 = generateCreature('squirtle', 200) + 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', () => { + test('all MVP species can be generated', async () => { const species: SpeciesId[] = [ 'bulbasaur', 'ivysaur', 'venusaur', 'charmander', 'charmeleon', 'charizard', @@ -37,15 +37,15 @@ describe('generateCreature', () => { 'pikachu', ] for (const s of species) { - const c = generateCreature(s) + const c = await generateCreature(s) expect(c.speciesId).toBe(s) } }) }) describe('calculateStats', () => { - test('level 1 stats are reasonable', () => { - const c = generateCreature('bulbasaur', 0) + 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 @@ -56,8 +56,8 @@ describe('calculateStats', () => { expect(stats.attack).toBeLessThanOrEqual(6) }) - test('stats increase with level', () => { - const c1 = generateCreature('charmander', 0) + test('stats increase with level', async () => { + const c1 = await generateCreature('charmander', 0) c1.level = 1 const stats1 = calculateStats(c1) @@ -68,8 +68,8 @@ describe('calculateStats', () => { expect(stats50.attack).toBeGreaterThan(stats1.attack) }) - test('EVs affect stats', () => { - const c = generateCreature('pikachu', 0) + test('EVs affect stats', async () => { + const c = await generateCreature('pikachu', 0) const statsNoEV = calculateStats(c) const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } } @@ -80,27 +80,27 @@ describe('calculateStats', () => { }) describe('getCreatureName', () => { - test('returns species name when no nickname', () => { - const c = generateCreature('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', () => { - const c = generateCreature('pikachu') + 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', () => { - const c = generateCreature('bulbasaur') + test('returns 0 for new creature', async () => { + const c = await generateCreature('bulbasaur') expect(getTotalEV(c)).toBe(0) }) - test('sums all EV values', () => { - const c = generateCreature('bulbasaur') + 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) }) diff --git a/packages/pokemon/src/__tests__/effort.test.ts b/packages/pokemon/src/__tests__/effort.test.ts index 1860ab870..3b0161fff 100644 --- a/packages/pokemon/src/__tests__/effort.test.ts +++ b/packages/pokemon/src/__tests__/effort.test.ts @@ -8,32 +8,32 @@ beforeEach(() => { }) describe('awardEV', () => { - test('mapped tool awards correct EV', () => { - let c = generateCreature('bulbasaur') + 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', () => { - let c = generateCreature('bulbasaur') + 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, b) => a + b, 0) + const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0) expect(totalEV).toBeGreaterThan(0) }) - test('cooldown prevents repeated awards', () => { + test('cooldown prevents repeated awards', async () => { const now = Date.now() - let c = generateCreature('bulbasaur') + 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', () => { - let c = generateCreature('bulbasaur') + 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) @@ -41,36 +41,36 @@ describe('awardEV', () => { expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT) }) - test('respects total EV cap', () => { - let c = generateCreature('bulbasaur') + 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, b) => a + b, 0) + 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', () => { - let c = generateCreature('bulbasaur') + 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, b) => a + b, 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', () => { - const c = generateCreature('bulbasaur') + test('returns "None" for new creature', async () => { + const c = await generateCreature('bulbasaur') expect(getEVSummary(c)).toBe('None') }) - test('shows stat breakdown', () => { - const c = generateCreature('bulbasaur') + 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') diff --git a/packages/pokemon/src/__tests__/egg.test.ts b/packages/pokemon/src/__tests__/egg.test.ts index 913a7c0fb..f24567ccf 100644 --- a/packages/pokemon/src/__tests__/egg.test.ts +++ b/packages/pokemon/src/__tests__/egg.test.ts @@ -5,18 +5,45 @@ 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: 1, - party: [creature.id, null, null, null, null, null], - creatures: [creature], + 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, }, } diff --git a/packages/pokemon/src/__tests__/experience.test.ts b/packages/pokemon/src/__tests__/experience.test.ts index be31a7d3f..cbb54fd87 100644 --- a/packages/pokemon/src/__tests__/experience.test.ts +++ b/packages/pokemon/src/__tests__/experience.test.ts @@ -49,23 +49,23 @@ describe('levelFromXp', () => { }) describe('awardXP', () => { - test('awards XP and returns updated creature', () => { - const c = generateCreature('bulbasaur') + 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', () => { - const c = generateCreature('bulbasaur') + 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', () => { - const c = generateCreature('bulbasaur') + test('level capped at 100', async () => { + const c = await generateCreature('bulbasaur') c.level = 100 c.totalXp = 1000000 const result = awardXP(c, 999999) @@ -75,8 +75,8 @@ describe('awardXP', () => { }) describe('getXpProgress', () => { - test('new creature has 0 XP progress', () => { - const c = generateCreature('bulbasaur') + 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) diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts index 7c5bfc754..57ab7c61b 100644 --- a/packages/pokemon/src/core/creature.ts +++ b/packages/pokemon/src/core/creature.ts @@ -5,11 +5,13 @@ import { getSpeciesData } from '../data/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' /** * Generate a new creature of the given species. */ -export function generateCreature(speciesId: SpeciesId, seed?: number): Creature { +export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise { const species = getSpeciesData(speciesId) const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff) @@ -29,11 +31,16 @@ export function generateCreature(speciesId: SpeciesId, seed?: number): Creature 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', } } diff --git a/packages/pokemon/src/core/egg.ts b/packages/pokemon/src/core/egg.ts index 7a44d12d4..fb471c927 100644 --- a/packages/pokemon/src/core/egg.ts +++ b/packages/pokemon/src/core/egg.ts @@ -3,6 +3,7 @@ import type { BuddyData, Creature, Egg, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' import { getSpeciesData } from '../data/species' import { generateCreature } from './creature' +import { addToParty, depositToBox } from './storage' /** Days of consecutive coding needed to be eligible for an egg */ export const EGG_REQUIRED_DAYS = 3 @@ -64,13 +65,14 @@ export function isEggReadyToHatch(egg: Egg): boolean { /** * Hatch an egg, creating a new creature and updating buddy data. + * Tries to add to party first, then deposits to PC box. */ -export function hatchEgg(buddyData: BuddyData, egg: Egg): { buddyData: BuddyData; creature: Creature } { - const creature = generateCreature(egg.speciesId) +export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> { + const creature = await generateCreature(egg.speciesId) creature.hatchedAt = Date.now() - // Update buddy data - const updatedData: BuddyData = { + // Add creature to list + let updatedData: BuddyData = { ...buddyData, creatures: [...buddyData.creatures, creature], eggs: buddyData.eggs.filter((e) => e.id !== egg.id), @@ -81,6 +83,15 @@ export function hatchEgg(buddyData: BuddyData, egg: Egg): { buddyData: BuddyData }, } + // 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 } } diff --git a/packages/pokemon/src/core/storage.ts b/packages/pokemon/src/core/storage.ts index 1fbbfa66c..3c03ff82a 100644 --- a/packages/pokemon/src/core/storage.ts +++ b/packages/pokemon/src/core/storage.ts @@ -1,30 +1,39 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' -import type { BuddyData, SpeciesId } from '../types' +import 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' const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json') const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') +const DEFAULT_BOX_COUNT = 8 +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), + })) +} + /** * Load buddy data from disk. Returns default data if file doesn't exist. - * Auto-migrates legacy data without `party` field. + * Auto-migrates from any older version. */ -export function loadBuddyData(): BuddyData { +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) as BuddyData - if (data.version !== 1) { - return migrateData(data) - } - // Migrate legacy data without party field - return ensurePartyField(data) + const data = JSON.parse(raw) + return migrateToV2(data) } catch { return getDefaultBuddyData() } @@ -34,7 +43,6 @@ export function loadBuddyData(): BuddyData { * Save buddy data to disk. */ export function saveBuddyData(data: BuddyData): void { - // Ensure directory exists const dir = join(BUDDY_DATA_PATH, '..') if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) @@ -46,14 +54,15 @@ export function saveBuddyData(data: BuddyData): void { * Get default buddy data for new users. * Randomly assigns one of the three starters. */ -export function getDefaultBuddyData(): BuddyData { +export async function getDefaultBuddyData(): Promise { const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] const randomStarter = starters[Math.floor(Math.random() * starters.length)] - const creature = generateCreature(randomStarter) + const creature = await generateCreature(randomStarter) return { - version: 1, + version: 2, party: [creature.id, null, null, null, null, null], + boxes: makeDefaultBoxes(), creatures: [creature], eggs: [], dex: [ @@ -64,12 +73,15 @@ export function getDefaultBuddyData(): BuddyData { bestLevel: 1, }, ], + bag: { items: [] }, stats: { totalTurns: 0, consecutiveDays: 0, lastActiveDate: new Date().toISOString().split('T')[0], totalEggsObtained: 0, totalEvolutions: 0, + battlesWon: 0, + battlesLost: 0, }, } } @@ -86,172 +98,181 @@ export function getSpritesDir(): string { /** * Migrate from legacy buddy system. - * Accepts legacy companion data and maps to new Pokémon species. - * If species cannot be determined, randomly assigns a starter. */ -export function migrateFromLegacy( +export async function migrateFromLegacy( storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string }, -): BuddyData { +): 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', + duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur', + cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle', + owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle', + snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle', + capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander', + rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander', } - // If species is provided directly, use it; otherwise random starter const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]! - const creature = generateCreature(speciesId) - creature.level = 5 // Reward for existing users + const creature = await generateCreature(speciesId) + creature.level = 5 creature.totalXp = 100 - creature.friendship = 120 // Existing partner bonus + creature.friendship = 120 - // Preserve nickname if it's not the default const speciesInfo = getSpeciesData(speciesId) if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { creature.nickname = storedCompanion.name } return { - version: 1, + version: 2, party: [creature.id, null, null, null, null, null], + boxes: makeDefaultBoxes(), creatures: [creature], eggs: [], - dex: [ - { - speciesId, - discoveredAt: Date.now(), - caughtCount: 1, - bestLevel: 5, - }, - ], + 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, }, } } -/** - * Handle data migration between versions. - */ -function migrateData(data: BuddyData): BuddyData { - // Currently only version 1 exists - if (!data.version || data.version < 1) { - return getDefaultBuddyData() +// ─── Migration ─── + +/** Migrate any version to v2 */ +async function migrateToV2(data: Record): Promise { + const version = (data.version as number) ?? 1 + + if (version >= 2) return data as unknown as BuddyData + + // v1 → v2 + const v1 = data as Record + const party = ensureParty(v1) + + // 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 } + + 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 data } -/** - * Update daily stats (consecutive days, last active date). - */ +/** 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 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 +} + +/** 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 + } + + 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 let consecutiveDays = data.stats.consecutiveDays if (lastDate !== today) { - // Check if yesterday const yesterday = new Date() yesterday.setDate(yesterday.getDate() - 1) const yesterdayStr = yesterday.toISOString().split('T')[0] - - if (lastDate === yesterdayStr) { - consecutiveDays++ - } else { - consecutiveDays = 1 - } + consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1 } return { ...data, - stats: { - ...data.stats, - consecutiveDays, - lastActiveDate: today, - }, + stats: { ...data.stats, consecutiveDays, lastActiveDate: today }, } } -/** - * Increment turn counter. - */ export function incrementTurns(data: BuddyData): BuddyData { return { ...data, - stats: { - ...data.stats, - totalTurns: data.stats.totalTurns + 1, - }, + stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 }, } } -/** - * Ensure buddy data has a `party` field. - * Migrates from legacy `activeCreatureId` if needed. - */ -function ensurePartyField(data: BuddyData): BuddyData { - if (data.party && data.party.length === 6) return data +// ─── Party operations ─── - // Build party from existing creatures - const party: (string | null)[] = new Array(6).fill(null) - - // Put active creature first - const activeId = data.activeCreatureId ?? data.party?.[0] - if (activeId) { - party[0] = activeId - } - - // Fill remaining slots with other creatures - let slot = 1 - for (const c of data.creatures) { - if (c.id === activeId) continue - if (slot >= 6) break - party[slot] = c.id - slot++ - } - - return { ...data, party } -} - -/** - * Add a creature to the party. Finds first empty slot. - * Returns true if added, false if party is full. - */ 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 } - } + if (emptyIdx === -1) return { data, added: false } party[emptyIdx] = creatureId return { data: { ...data, party }, added: true } } -/** - * Remove a creature from party by slot index. - */ export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData { if (slotIndex < 0 || slotIndex >= 6) return data const party = [...data.party] @@ -259,9 +280,6 @@ export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData { return { ...data, party } } -/** - * Swap two party slots by index. - */ export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData { const party = [...data.party] const a = party[indexA] @@ -271,20 +289,122 @@ export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): return { ...data, party } } -/** - * Set party[0] to the given creature ID (by swapping if already in 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 // Already active + if (existingIdx === 0) return data if (existingIdx > 0) { - // Swap with slot 0 party[0] = creatureId party[existingIdx] = data.party[0] } else { - // Not in party — put in slot 0 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 } +} + +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 } +} + +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 } +} + +export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData { + 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 } + + 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), + } +} + +export function getTotalCreatureCount(data: BuddyData): number { + return data.creatures.length +} + +export function getAllCreatureIds(data: BuddyData): string[] { + return data.creatures.map(c => c.id) +} + +// ─── Bag operations ─── + +export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData { + const items = [...data.bag.items] + 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] + 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 } +} + +export function getItemCount(data: BuddyData, itemId: string): number { + return data.bag.items.find(e => e.id === itemId)?.count ?? 0 +} diff --git a/packages/pokemon/src/data/learnsets.ts b/packages/pokemon/src/data/learnsets.ts new file mode 100644 index 000000000..72d679a01 --- /dev/null +++ b/packages/pokemon/src/data/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/index.ts b/packages/pokemon/src/index.ts index 13f982afa..7967d50af 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -4,6 +4,11 @@ export type { NatureName, NatureStat, NatureEffect, + MoveSlot, + ItemId, + PCBox, + BagEntry, + Bag, SpeciesId, Gender, EvolutionTrigger, @@ -19,7 +24,7 @@ export type { SpriteCache, AnimMode, } from './types' -export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } 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' @@ -28,6 +33,7 @@ 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, EVOLUTION_CHAINS } from './data/evolution' +export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './data/learnsets' export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn' // Core @@ -37,7 +43,14 @@ export { awardXP, getXpProgress } from './core/experience' export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort' export { checkEvolution, evolve, canEvolveFurther } from './core/evolution' export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg' -export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns, addToParty, removeFromParty, swapPartySlots, setActivePartyMember } from './core/storage' +export { + 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' // Sprites diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index 9eb5a73c9..56f826170 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -41,6 +41,20 @@ export type NatureName = string export type NatureStat = 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed' export type NatureEffect = { plus: NatureStat | null; minus: NatureStat | null } +// Move slot +export type MoveSlot = { id: string; pp: number; maxPp: number } +export const EMPTY_MOVE: MoveSlot = { id: '', pp: 0, maxPp: 0 } + +// Item ID (Showdown format string) +export type ItemId = string + +// PC box (fixed 30 slots) +export type PCBox = { name: string; slots: (string | null)[] } + +// Bag +export type BagEntry = { id: ItemId; count: number } +export type Bag = { items: BagEntry[] } + // Gender export type Gender = 'male' | 'female' | 'genderless' @@ -85,11 +99,16 @@ export type Creature = { 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 @@ -111,19 +130,21 @@ export type DexEntry = { // buddy-data.json complete structure export type BuddyData = { - version: 1 - /** @deprecated Use party[0] instead. Kept for backward compat during migration. */ - activeCreatureId?: string | null + 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 } } diff --git a/packages/pokemon/src/ui/PokedexView.tsx b/packages/pokemon/src/ui/PokedexView.tsx index 72a518822..a55b80d99 100644 --- a/packages/pokemon/src/ui/PokedexView.tsx +++ b/packages/pokemon/src/ui/PokedexView.tsx @@ -55,8 +55,8 @@ export function PokedexView({ buddyData }: PokedexViewProps) { const species = getSpeciesData(speciesId) const entry = dexMap.get(speciesId) const discovered = !!entry - const isActive = buddyData.activeCreatureId - ? buddyData.creatures.some((c) => c.id === buddyData.activeCreatureId && c.speciesId === speciesId) + const isActive = buddyData.party[0] + ? buddyData.creatures.some((c) => c.id === buddyData.party[0] && c.speciesId === speciesId) : false const nextEvo = getNextEvolution(speciesId) diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 1bf57ac52..e660a76b3 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -26,6 +26,18 @@ const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go const PET_BURST_MS = 2500; // how long hearts float after /buddy pet +// Module-level cache for sync access in render +let _cachedCreature: Creature | null = null; +let _cacheLoadPromise: Promise | null = null; + +function ensureCreatureCache(): void { + if (_cachedCreature !== null || _cacheLoadPromise) return; + _cacheLoadPromise = loadBuddyData().then(data => { + _cachedCreature = getActiveCreature(data); + _cacheLoadPromise = null; + }).catch(() => { _cacheLoadPromise = null; }); +} + // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. const H = figures.heart; const PET_HEARTS = [ @@ -105,17 +117,27 @@ function spriteColWidth(nameWidth: number): number { } /** - * Get active Pokémon creature, or null if buddy system not initialized. + * Get active Pokémon creature from cache, or null if not loaded yet. + * Triggers async load if cache is empty. */ function getPokemonCreature(): Creature | null { try { - const data = loadBuddyData(); - return getActiveCreature(data); + ensureCreatureCache(); + return _cachedCreature; } catch { return null; } } +/** + * Force-refresh the creature cache (call after data changes). + */ +export function refreshCreatureCache(): void { + _cachedCreature = null; + _cacheLoadPromise = null; + ensureCreatureCache(); +} + export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { if (!feature('BUDDY')) return 0; const creature = getPokemonCreature(); diff --git a/src/buddy/companionReact.ts b/src/buddy/companionReact.ts index eb970e090..ed42a7b71 100644 --- a/src/buddy/companionReact.ts +++ b/src/buddy/companionReact.ts @@ -34,11 +34,11 @@ const MAX_RECENT = 8 /** * Trigger a companion reaction after a query turn. */ -export function triggerCompanionReaction( +export async function triggerCompanionReaction( messages: Message[], setReaction: (text: string | undefined) => void, -): void { - const data = loadBuddyData() +): Promise { + const data = await loadBuddyData() const creature = getActiveCreature(data) if (!creature || getGlobalConfig().companionMuted) return diff --git a/src/buddy/prompt.ts b/src/buddy/prompt.ts index 641cbb22e..d389367e8 100644 --- a/src/buddy/prompt.ts +++ b/src/buddy/prompt.ts @@ -17,11 +17,11 @@ A ${species} named ${name} sits beside the user's input box and occasionally com When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.` } -export function getCompanionIntroAttachment( +export async function getCompanionIntroAttachment( messages: Message[] | undefined, -): Attachment[] { +): Promise { if (!feature('BUDDY')) return [] - const data = loadBuddyData() + const data = await loadBuddyData() const creature = getActiveCreature(data) if (!creature || getGlobalConfig().companionMuted) return [] diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index 85bbb3bf2..fc29222e9 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -37,14 +37,14 @@ import { BuddyPanel } from './BuddyPanel.js' * Load or initialize Pokémon buddy data. * Migrates from legacy buddy system if needed. */ -function getOrInitBuddyData(): BuddyData { - let data = loadBuddyData() +async function getOrInitBuddyData(): Promise { + let data = await loadBuddyData() // If no active creature (party empty), check for legacy companion to migrate if (!getActiveCreature(data) || data.creatures.length === 0) { const legacyCompanion = getGlobalConfig().companion if (legacyCompanion) { - data = migrateFromLegacy(legacyCompanion) + data = await migrateFromLegacy(legacyCompanion) saveBuddyData(data) } } @@ -76,7 +76,7 @@ export async function call( // ── /buddy pet — trigger heart animation + XP + egg steps ── if (sub === 'pet') { - const data = getOrInitBuddyData() + const data = await getOrInitBuddyData() const creature = getActiveCreature(data) if (!creature) { onDone('no companion yet · run /buddy first', { display: 'system' }) @@ -100,7 +100,7 @@ export async function call( // Check hatch const readyEgg = data.eggs.find(isEggReadyToHatch) if (readyEgg) { - const { buddyData: updatedData, creature: newCreature } = hatchEgg( + const { buddyData: updatedData, creature: newCreature } = await hatchEgg( data, readyEgg, ) @@ -137,7 +137,7 @@ export async function call( onDone('Usage: /buddy rename ', { display: 'system' }) return null } - const data = getOrInitBuddyData() + const data = await getOrInitBuddyData() const creature = getActiveCreature(data) if (!creature) { onDone('no companion yet · run /buddy first', { display: 'system' }) @@ -172,10 +172,10 @@ export async function call( return null } - const data = getOrInitBuddyData() + const data = await getOrInitBuddyData() // Create the creature - const creature = generateCreature(match) + const creature = await generateCreature(match) if (levelArg && !isNaN(levelArg) && levelArg >= 1 && levelArg <= 100) { creature.level = levelArg } @@ -212,7 +212,7 @@ export async function call( } // ── /buddy (no args) — show unified BuddyPanel ── - const data = getOrInitBuddyData() + const data = await getOrInitBuddyData() let creature = getActiveCreature(data) // Auto-unmute when viewing @@ -224,11 +224,11 @@ export async function call( if (!creature) { const legacyCompanion = getGlobalConfig().companion if (legacyCompanion) { - const migrated = migrateFromLegacy(legacyCompanion) + const migrated = await migrateFromLegacy(legacyCompanion) saveBuddyData(migrated) creature = getActiveCreature(migrated)! } else { - const defaultData = getDefaultBuddyData() + const defaultData = await getDefaultBuddyData() saveBuddyData(defaultData) creature = getActiveCreature(defaultData)! } @@ -244,7 +244,7 @@ export async function call( spriteCached?.lines ?? getFallbackSprite(creature.speciesId) // Reload data to get latest state after possible initialization - const latestData = loadBuddyData() + const latestData = await loadBuddyData() return React.createElement(BuddyPanel, { buddyData: latestData, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 6e181d5cf..a723eb3b5 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -3462,7 +3462,7 @@ export function REPL({ updateDailyStats: _updateDaily, incrementTurns: _incTurns, } = await import('@claude-code-best/pokemon'); - const _data = _updateDaily(_incTurns(_load())); + const _data = _updateDaily(_incTurns(await _load())); const _creature = _getActive(_data); if (_creature) { // 1. Collect tool names from this turn's messages @@ -3502,7 +3502,7 @@ export function REPL({ _data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3)); const _readyEgg = _data.eggs.find(_isReady); if (_readyEgg) { - const { buddyData: _hatched, creature: _newC } = _hatchEgg(_data, _readyEgg); + const { buddyData: _hatched, creature: _newC } = await _hatchEgg(_data, _readyEgg); Object.assign(_data, _hatched); } }