mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
59
packages/pokemon/src/data/learnsets.ts
Normal file
59
packages/pokemon/src/data/learnsets.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user