feat: Phase 1 — 数据模型升级 Creature v2 + PCBox/Bag

- 新增 MoveSlot, PCBox, Bag, ItemId 类型
- Creature 扩展 nature/moves/ability/heldItem/pokeball 字段
- BuddyData 升级 v2: 新增 boxes, bag, battlesWon/battlesLost
- 新建 data/learnsets.ts: getDefaultMoveset/getDefaultAbility/getNewLearnableMoves
- storage.ts v1→v2 迁移: 回填 nature/moves/ability,新增 PCBox/Bag
- 新增 PCBox 操作: deposit/withdraw/move/rename/findLocation/release
- 新增 Bag 操作: add/remove/getCount
- generateCreature/loadBuddyData/hatchEgg 改为 async (Dex.learnsets.get 异步)
- 修复 PokedexView: activeCreatureId → party[0]
- 更新测试文件: async/await + v2 BuddyData fixtures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 00:20:08 +08:00
parent 96f3e1b309
commit 12cbb7c4c7
16 changed files with 496 additions and 216 deletions

View File

@@ -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)
})

View File

@@ -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')

View File

@@ -5,18 +5,45 @@ import { generateCreature } from '../core/creature'
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): 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,
},
}

View File

@@ -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)

View File

@@ -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<Creature> {
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',
}
}

View File

@@ -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 }
}

View File

@@ -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<BuddyData> {
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<BuddyData> {
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<BuddyData> {
const speciesMap: Record<string, SpeciesId> = {
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<string, unknown>): Promise<BuddyData> {
const version = (data.version as number) ?? 1
if (version >= 2) return data as unknown as BuddyData
// v1 → v2
const v1 = data as Record<string, unknown>
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<string, number>)?.totalTurns) ?? 0,
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
totalEvolutions: ((v1.stats as Record<string, number>)?.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, unknown>): (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<Creature[]> {
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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<StatName, number> // Effort values
iv: Record<StatName, number> // 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
}
}

View File

@@ -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)

View File

@@ -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<void> | 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();

View File

@@ -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<void> {
const data = await loadBuddyData()
const creature = getActiveCreature(data)
if (!creature || getGlobalConfig().companionMuted) return

View File

@@ -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<Attachment[]> {
if (!feature('BUDDY')) return []
const data = loadBuddyData()
const data = await loadBuddyData()
const creature = getActiveCreature(data)
if (!creature || getGlobalConfig().companionMuted) return []

View File

@@ -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<BuddyData> {
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 <name>', { 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,

View File

@@ -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);
}
}