feat: 又是更新了一大堆

This commit is contained in:
claude-code-best
2026-04-21 21:38:13 +08:00
parent f74492617b
commit 970fcd627f
22 changed files with 417 additions and 126 deletions

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import type { SpeciesId, Creature } from '../types' import type { SpeciesId, Creature } from '../types'
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel } from '../core/creature' import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel } from '../core/creature'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
describe('generateCreature', () => { describe('generateCreature', () => {
test('creates a creature with correct defaults', () => { test('creates a creature with correct defaults', () => {
@@ -10,7 +10,7 @@ describe('generateCreature', () => {
expect(c.level).toBe(1) expect(c.level).toBe(1)
expect(c.xp).toBe(0) expect(c.xp).toBe(0)
expect(c.totalXp).toBe(0) expect(c.totalXp).toBe(0)
expect(c.friendship).toBe(SPECIES_DATA.bulbasaur.baseHappiness) expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
expect(c.isShiny).toBeDefined() expect(c.isShiny).toBeDefined()
expect(c.id).toBeTruthy() expect(c.id).toBeTruthy()
expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true) expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true)

View File

@@ -4,10 +4,11 @@ import type { BuddyData } from '../types'
import { generateCreature } from '../core/creature' import { generateCreature } from '../core/creature'
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData { function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
const creature = generateCreature('bulbasaur')
return { return {
version: 1, version: 1,
activeCreatureId: 'test', party: [creature.id, null, null, null, null, null],
creatures: [generateCreature('bulbasaur')], creatures: [creature],
eggs: [], eggs: [],
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }], dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
stats: { stats: {

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { determineGender, getGenderSymbol } from '../core/gender' import { determineGender, getGenderSymbol } from '../core/gender'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
describe('determineGender', () => { describe('determineGender', () => {
test('genderless species', () => { test('genderless species', () => {
@@ -8,12 +8,12 @@ describe('determineGender', () => {
// Venusaur has genderRate 1 (12.5% female) // Venusaur has genderRate 1 (12.5% female)
// For testing genderless, we'd need a species with genderRate -1 // For testing genderless, we'd need a species with genderRate -1
// None in MVP are genderless, so test the basic logic // None in MVP are genderless, so test the basic logic
const pikachu = SPECIES_DATA.pikachu const pikachu = getSpeciesData('pikachu')
expect(pikachu.genderRate).toBe(4) expect(pikachu.genderRate).toBe(4)
}) })
test('pikachu 50% female ratio', () => { test('pikachu 50% female ratio', () => {
const pikachu = SPECIES_DATA.pikachu const pikachu = getSpeciesData('pikachu')
let males = 0 let males = 0
let females = 0 let females = 0
for (let seed = 0; seed < 1000; seed++) { for (let seed = 0; seed < 1000; seed++) {
@@ -27,7 +27,7 @@ describe('determineGender', () => {
}) })
test('starters are ~12.5% female', () => { test('starters are ~12.5% female', () => {
const bulbasaur = SPECIES_DATA.bulbasaur const bulbasaur = getSpeciesData('bulbasaur')
let females = 0 let females = 0
for (let seed = 0; seed < 1000; seed++) { for (let seed = 0; seed < 1000; seed++) {
if (determineGender(bulbasaur, seed) === 'female') females++ if (determineGender(bulbasaur, seed) === 'female') females++

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { Creature, SpeciesId, StatName, StatsResult } from '../types' import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
import { STAT_NAMES } from '../types' import { STAT_NAMES } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { determineGender } from './gender' import { determineGender } from './gender'
import { levelFromXp } from '../data/xpTable' import { levelFromXp } from '../data/xpTable'
@@ -9,7 +9,7 @@ import { levelFromXp } from '../data/xpTable'
* Generate a new creature of the given species. * Generate a new creature of the given species.
*/ */
export function generateCreature(speciesId: SpeciesId, seed?: number): Creature { export function generateCreature(speciesId: SpeciesId, seed?: number): Creature {
const species = SPECIES_DATA[speciesId] const species = getSpeciesData(speciesId)
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff) const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
// Generate IVs (0-31) using simple hash from seed // Generate IVs (0-31) using simple hash from seed
@@ -42,7 +42,7 @@ export function generateCreature(speciesId: SpeciesId, seed?: number): Creature
* Other: floor((2 * base + iv + floor(ev/4)) * level / 100) + 5 * Other: floor((2 * base + iv + floor(ev/4)) * level / 100) + 5
*/ */
export function calculateStats(creature: Creature): StatsResult { export function calculateStats(creature: Creature): StatsResult {
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
const level = creature.level const level = creature.level
const result: StatsResult = {} as StatsResult const result: StatsResult = {} as StatsResult
@@ -67,14 +67,14 @@ export function calculateStats(creature: Creature): StatsResult {
*/ */
export function getCreatureName(creature: Creature): string { export function getCreatureName(creature: Creature): string {
if (creature.nickname) return creature.nickname if (creature.nickname) return creature.nickname
return SPECIES_DATA[creature.speciesId].name return getSpeciesData(creature.speciesId).name
} }
/** /**
* Recalculate level from total XP (e.g. after XP gain). * Recalculate level from total XP (e.g. after XP gain).
*/ */
export function recalculateLevel(creature: Creature): Creature { export function recalculateLevel(creature: Creature): Creature {
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
const newLevel = levelFromXp(creature.totalXp, species.growthRate) const newLevel = levelFromXp(creature.totalXp, species.growthRate)
if (newLevel !== creature.level) { if (newLevel !== creature.level) {
return { ...creature, level: newLevel } return { ...creature, level: newLevel }
@@ -84,10 +84,12 @@ export function recalculateLevel(creature: Creature): Creature {
/** /**
* Get the active creature from buddy data. * Get the active creature from buddy data.
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
*/ */
export function getActiveCreature(buddyData: { activeCreatureId: string | null; creatures: Creature[] }): Creature | null { export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
if (!buddyData.activeCreatureId) return null const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
return buddyData.creatures.find((c) => c.id === buddyData.activeCreatureId) ?? null if (!activeId) return null
return buddyData.creatures.find((c) => c.id === activeId) ?? null
} }
/** /**

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { BuddyData, Creature, Egg, SpeciesId } from '../types' import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types' import { ALL_SPECIES_IDS } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { generateCreature } from './creature' import { generateCreature } from './creature'
/** Days of consecutive coding needed to be eligible for an egg */ /** Days of consecutive coding needed to be eligible for an egg */
@@ -34,7 +34,7 @@ export function generateEgg(buddyData: BuddyData): Egg {
: starters[Math.floor(Math.random() * starters.length)] : starters[Math.floor(Math.random() * starters.length)]
// Steps based on rarity (capture rate: lower = rarer = more steps) // Steps based on rarity (capture rate: lower = rarer = more steps)
const species = SPECIES_DATA[speciesId] const species = getSpeciesData(speciesId)
const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000) const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000)
return { return {

View File

@@ -1,5 +1,5 @@
import type { Creature, EvolutionResult, SpeciesId } from '../types' import type { Creature, EvolutionResult, SpeciesId } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution' import { getNextEvolution } from '../data/evolution'
/** /**
@@ -29,7 +29,7 @@ export function checkEvolution(creature: Creature): EvolutionResult | null {
* Returns the updated creature with new species and recalculated data. * Returns the updated creature with new species and recalculated data.
*/ */
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature { export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
const newSpecies = SPECIES_DATA[targetSpeciesId] const newSpecies = getSpeciesData(targetSpeciesId)
return { return {
...creature, ...creature,

View File

@@ -1,12 +1,12 @@
import type { Creature } from '../types' import type { Creature } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { levelFromXp, xpForLevel } from '../data/xpTable' import { levelFromXp, xpForLevel } from '../data/xpTable'
/** /**
* Award XP to a creature. Returns updated creature and whether level up occurred. * Award XP to a creature. Returns updated creature and whether level up occurred.
*/ */
export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } { export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } {
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
if (creature.level >= 100) { if (creature.level >= 100) {
return { creature, leveledUp: false, newLevel: creature.level } return { creature, leveledUp: false, newLevel: creature.level }
} }
@@ -38,7 +38,7 @@ export function awardXP(creature: Creature, amount: number): { creature: Creatur
* Get XP needed to reach next level from current state. * Get XP needed to reach next level from current state.
*/ */
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } { export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
const currentLevelXp = xpForLevel(creature.level, species.growthRate) const currentLevelXp = xpForLevel(creature.level, species.growthRate)
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
const needed = nextLevelXp - currentLevelXp const needed = nextLevelXp - currentLevelXp

View File

@@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import type { SpeciesId, SpriteCache } from '../types' import type { SpeciesId, SpriteCache } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { getSpritesDir } from './storage' import { getSpritesDir } from './storage'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons' const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
@@ -134,6 +134,6 @@ function stripAnsi(str: string): string {
* Get species name with dex number for display. * Get species name with dex number for display.
*/ */
export function getSpeciesDisplay(speciesId: SpeciesId): string { export function getSpeciesDisplay(speciesId: SpeciesId): string {
const data = SPECIES_DATA[speciesId] const data = getSpeciesData(speciesId)
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}` return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
} }

View File

@@ -4,13 +4,14 @@ import { homedir } from 'node:os'
import type { BuddyData, SpeciesId } from '../types' import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types' import { ALL_SPECIES_IDS } from '../types'
import { generateCreature } from './creature' import { generateCreature } from './creature'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json') const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
/** /**
* Load buddy data from disk. Returns default data if file doesn't exist. * Load buddy data from disk. Returns default data if file doesn't exist.
* Auto-migrates legacy data without `party` field.
*/ */
export function loadBuddyData(): BuddyData { export function loadBuddyData(): BuddyData {
if (!existsSync(BUDDY_DATA_PATH)) { if (!existsSync(BUDDY_DATA_PATH)) {
@@ -22,7 +23,8 @@ export function loadBuddyData(): BuddyData {
if (data.version !== 1) { if (data.version !== 1) {
return migrateData(data) return migrateData(data)
} }
return data // Migrate legacy data without party field
return ensurePartyField(data)
} catch { } catch {
return getDefaultBuddyData() return getDefaultBuddyData()
} }
@@ -51,7 +53,7 @@ export function getDefaultBuddyData(): BuddyData {
return { return {
version: 1, version: 1,
activeCreatureId: creature.id, party: [creature.id, null, null, null, null, null],
creatures: [creature], creatures: [creature],
eggs: [], eggs: [],
dex: [ dex: [
@@ -122,14 +124,14 @@ export function migrateFromLegacy(
creature.friendship = 120 // Existing partner bonus creature.friendship = 120 // Existing partner bonus
// Preserve nickname if it's not the default // Preserve nickname if it's not the default
const speciesInfo = SPECIES_DATA[speciesId] const speciesInfo = getSpeciesData(speciesId)
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
creature.nickname = storedCompanion.name creature.nickname = storedCompanion.name
} }
return { return {
version: 1, version: 1,
activeCreatureId: creature.id, party: [creature.id, null, null, null, null, null],
creatures: [creature], creatures: [creature],
eggs: [], eggs: [],
dex: [ dex: [
@@ -204,3 +206,85 @@ export function incrementTurns(data: BuddyData): BuddyData {
}, },
} }
} }
/**
* 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
// 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 }
}
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]
party[slotIndex] = null
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]
const b = party[indexB]
party[indexA] = b
party[indexB] = a
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) {
// 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 }
}

View File

@@ -19,7 +19,7 @@ export type {
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from './types' export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from './types'
// Data // Data
export { SPECIES_DATA, DEX_TO_SPECIES } from './data/species' export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './data/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping' export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable' export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names' export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names'
@@ -32,7 +32,7 @@ export { awardXP, getXpProgress } from './core/experience'
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort' export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution' export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg' export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns } from './core/storage' export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns, addToParty, removeFromParty, swapPartySlots, setActivePartyMember } from './core/storage'
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache' export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
// Sprites // Sprites

View File

@@ -107,7 +107,9 @@ export type DexEntry = {
// buddy-data.json complete structure // buddy-data.json complete structure
export type BuddyData = { export type BuddyData = {
version: 1 version: 1
activeCreatureId: string | null /** @deprecated Use party[0] instead. Kept for backward compat during migration. */
activeCreatureId?: string | null
party: (string | null)[] // Always length 6, party[0] = active buddy
creatures: Creature[] creatures: Creature[]
eggs: Egg[] eggs: Egg[]
dex: DexEntry[] dex: DexEntry[]

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink' import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types' import type { BuddyData, Creature, SpeciesId } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types' import { STAT_NAMES, STAT_LABELS } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names' import { SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature' import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience' import { getXpProgress } from '../core/experience'
@@ -42,7 +42,7 @@ const TYPE_COLORS: Record<string, Color> = {
* Redesigned companion card with Pokémon-style stats display. * Redesigned companion card with Pokémon-style stats display.
*/ */
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) { export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature) const stats = calculateStats(creature)
const xp = getXpProgress(creature) const xp = getXpProgress(creature)
const genderSymbol = getGenderSymbol(creature.gender) const genderSymbol = getGenderSymbol(creature.gender)
@@ -66,7 +66,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar
// Evolution hint // Evolution hint
const evoHint = nextEvo ? ( const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv.{nextEvo.minLevel}</Text> <Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null ) : null
return ( return (

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink' import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types' import type { SpeciesId } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { loadSprite } from '../core/spriteCache' import { loadSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback' import { getFallbackSprite } from '../sprites/fallback'
@@ -35,8 +35,8 @@ export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionA
const fromSprite = getSpriteLines(fromSpecies) const fromSprite = getSpriteLines(fromSpecies)
const toSprite = getSpriteLines(toSpecies) const toSprite = getSpriteLines(toSpecies)
const fromName = SPECIES_DATA[fromSpecies].name const fromName = getSpeciesData(fromSpecies).name
const toName = SPECIES_DATA[toSpecies].name const toName = getSpeciesData(toSpecies).name
// Frame logic: // Frame logic:
// 0-3: old sprite with flash (alternate blank) // 0-3: old sprite with flash (alternate blank)

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink' import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, SpeciesId } from '../types' import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types' import { ALL_SPECIES_IDS } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution' import { getNextEvolution } from '../data/evolution'
const CYAN: Color = 'ansi:cyan' const CYAN: Color = 'ansi:cyan'
@@ -52,7 +52,7 @@ export function PokedexView({ buddyData }: PokedexViewProps) {
{chains.map((chain, ci) => ( {chains.map((chain, ci) => (
<Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}> <Box key={ci} flexDirection="column" marginTop={ci > 0 ? 0 : 0}>
{chain.map((speciesId, si) => { {chain.map((speciesId, si) => {
const species = SPECIES_DATA[speciesId] const species = getSpeciesData(speciesId)
const entry = dexMap.get(speciesId) const entry = dexMap.get(speciesId)
const discovered = !!entry const discovered = !!entry
const isActive = buddyData.activeCreatureId const isActive = buddyData.activeCreatureId

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink' import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId, StatName } from '../types' import type { SpeciesId, StatName } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types' import { STAT_NAMES, STAT_LABELS } from '../types'
import { SPECIES_DATA } from '../data/species' import { getSpeciesData } from '../data/species'
import { SPECIES_PERSONALITY } from '../data/names' import { SPECIES_PERSONALITY } from '../data/names'
import { getNextEvolution } from '../data/evolution' import { getNextEvolution } from '../data/evolution'
import { StatBar } from './StatBar' import { StatBar } from './StatBar'
@@ -32,7 +32,7 @@ interface SpeciesDetailProps {
* Detailed species info page — base stats, evolution chain, flavor text. * Detailed species info page — base stats, evolution chain, flavor text.
*/ */
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) { export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
const species = SPECIES_DATA[speciesId] const species = getSpeciesData(speciesId)
const nextEvo = getNextEvolution(speciesId) const nextEvo = getNextEvolution(speciesId)
// Type badges // Type badges
@@ -163,7 +163,7 @@ function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
<React.Fragment key={sid}> <React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>} {i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}> <Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
{SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name} {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
</Text> </Text>
{i < chain.length - 1 && getNextEvolution(sid) && ( {i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text> <Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>

View File

@@ -11,11 +11,12 @@ import {
loadBuddyData, loadBuddyData,
getActiveCreature, getActiveCreature,
getCreatureName, getCreatureName,
getXpProgress,
loadSprite, loadSprite,
getFallbackSprite, getFallbackSprite,
renderAnimatedSprite, renderAnimatedSprite,
getIdleAnimMode, getIdleAnimMode,
SPECIES_DATA, getSpeciesData,
type Creature, type Creature,
type AnimMode, type AnimMode,
} from '@claude-code-best/pokemon'; } from '@claude-code-best/pokemon';
@@ -138,7 +139,10 @@ function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode
export function CompanionSprite(): React.ReactNode { export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction); const reaction = useAppState(s => s.companionReaction);
const petAt = useAppState(s => s.companionPetAt); const petAt = useAppState(s => s.companionPetAt);
const xpInfo = useAppState(s => s.companionXpInfo);
const focused = useAppState(s => s.footerSelection === 'companion'); const focused = useAppState(s => s.footerSelection === 'companion');
// Subscribe to creature changes so we re-render immediately after switch
const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const setAppState = useSetAppState(); const setAppState = useSetAppState();
const { columns } = useTerminalSize(); const { columns } = useTerminalSize();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
@@ -179,7 +183,7 @@ export function CompanionSprite(): React.ReactNode {
const creature = getPokemonCreature(); const creature = getPokemonCreature();
if (!creature || getGlobalConfig().companionMuted) return null; if (!creature || getGlobalConfig().companionMuted) return null;
const species = SPECIES_DATA[creature.speciesId]; const species = getSpeciesData(creature.speciesId);
const name = getCreatureName(creature); const name = getCreatureName(creature);
const color = creature.isShiny ? 'warning' : 'claude'; const color = creature.isShiny ? 'warning' : 'claude';
const colWidth = spriteColWidth(stringWidth(name)); const colWidth = spriteColWidth(stringWidth(name));
@@ -195,6 +199,13 @@ export function CompanionSprite(): React.ReactNode {
const quip = const quip =
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name; const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name;
const xpLabel = xpInfo
? xpInfo.leveledUp
? ` ↑Lv.${xpInfo.level}`
: ` Lv.${xpInfo.level} +${xpInfo.xpGained}xp`
: creature.level > 1
? ` Lv.${creature.level}`
: '';
return ( return (
<Box paddingX={1} alignSelf="flex-end"> <Box paddingX={1} alignSelf="flex-end">
<Text> <Text>
@@ -211,6 +222,11 @@ export function CompanionSprite(): React.ReactNode {
> >
{label} {label}
</Text> </Text>
{xpLabel && (
<Text dimColor bold={xpInfo?.leveledUp} color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
{xpLabel}
</Text>
)}
</Text> </Text>
</Box> </Box>
); );
@@ -229,6 +245,12 @@ export function CompanionSprite(): React.ReactNode {
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines; const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines;
const xpStatus = xpInfo
? xpInfo.leveledUp
? `↑Lv.${xpInfo.level}`
: `+${xpInfo.xpGained}xp`
: null;
const spriteColumn = ( const spriteColumn = (
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}> <Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
{displayLines.map((line, i) => ( {displayLines.map((line, i) => (
@@ -239,6 +261,9 @@ export function CompanionSprite(): React.ReactNode {
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}> <Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
{focused ? ` ${name} ` : name} {focused ? ` ${name} ` : name}
</Text> </Text>
<Text dimColor color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
Lv.{creature.level} {xpStatus ?? ''}
</Text>
</Box> </Box>
); );
@@ -260,6 +285,7 @@ export function CompanionSprite(): React.ReactNode {
// Floating bubble overlay for fullscreen mode // Floating bubble overlay for fullscreen mode
export function CompanionFloatingBubble(): React.ReactNode { export function CompanionFloatingBubble(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction); const reaction = useAppState(s => s.companionReaction);
const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const [{ tick, forReaction }, setTick] = useState({ const [{ tick, forReaction }, setTick] = useState({
tick: 0, tick: 0,
forReaction: reaction, forReaction: reaction,

View File

@@ -15,7 +15,7 @@ import {
getActiveCreature, getActiveCreature,
getCreatureName, getCreatureName,
calculateStats, calculateStats,
SPECIES_DATA, getSpeciesData,
type Creature, type Creature,
} from '@claude-code-best/pokemon' } from '@claude-code-best/pokemon'
@@ -120,7 +120,7 @@ async function callBuddyReactAPI(
const orgId = getGlobalConfig().oauthAccount?.organizationUuid const orgId = getGlobalConfig().oauthAccount?.organizationUuid
if (!orgId) return null if (!orgId) return null
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
const name = getCreatureName(creature) const name = getCreatureName(creature)
const stats = calculateStats(creature) const stats = calculateStats(creature)

View File

@@ -6,7 +6,7 @@ import {
loadBuddyData, loadBuddyData,
getActiveCreature, getActiveCreature,
getCreatureName, getCreatureName,
SPECIES_DATA, getSpeciesData,
} from '@claude-code-best/pokemon' } from '@claude-code-best/pokemon'
export function companionIntroText(name: string, species: string): string { export function companionIntroText(name: string, species: string): string {
@@ -26,7 +26,7 @@ export function getCompanionIntroAttachment(
if (!creature || getGlobalConfig().companionMuted) return [] if (!creature || getGlobalConfig().companionMuted) return []
const name = getCreatureName(creature) const name = getCreatureName(creature)
const species = SPECIES_DATA[creature.speciesId] const species = getSpeciesData(creature.speciesId)
// Skip if already announced for this companion. // Skip if already announced for this companion.
for (const msg of messages ?? []) { for (const msg of messages ?? []) {

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink'; import { Box, Text, Pane, Tab, Tabs, useInput, type Color } from '@anthropic/ink';
import { useSetAppState } from '../../state/AppState.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js'; import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { Select } from '../../components/CustomSelect/select.js'; import { Select } from '../../components/CustomSelect/select.js';
@@ -12,15 +13,15 @@ import {
type Creature, type Creature,
type SpeciesId, type SpeciesId,
} from '@claude-code-best/pokemon'; } from '@claude-code-best/pokemon';
import { SPECIES_DATA } from '@claude-code-best/pokemon'; import { getSpeciesData, ensureSpeciesData } from '@claude-code-best/pokemon';
import { getNextEvolution } from '@claude-code-best/pokemon'; import { getNextEvolution } from '@claude-code-best/pokemon';
import { calculateStats, getCreatureName, getTotalEV, getActiveCreature, saveBuddyData, EGG_REQUIRED_DAYS } from '@claude-code-best/pokemon'; import { calculateStats, getCreatureName, getTotalEV, getActiveCreature, saveBuddyData, EGG_REQUIRED_DAYS, addToParty, swapPartySlots, removeFromParty } from '@claude-code-best/pokemon';
import { getXpProgress } from '@claude-code-best/pokemon'; import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon'; import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon'; import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan'; const CYAN: Color = 'ansi:cyan';
const YELLOW: Color = 'ansi:yellow'; const YELLOW: Color = 'ansi:yellow';
@@ -54,9 +55,15 @@ interface BuddyPanelProps {
export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) { export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) {
const [selectedTab, setSelectedTab] = useState('Buddy'); const [selectedTab, setSelectedTab] = useState('Buddy');
const [data, setData] = useState(buddyData); const [data, setData] = useState(buddyData);
const setAppState = useSetAppState();
useExitOnCtrlCDWithKeybindings(); useExitOnCtrlCDWithKeybindings();
// Trigger species data refresh from API (fire-and-forget)
React.useEffect(() => {
ensureSpeciesData();
}, []);
const handleEscape = () => { const handleEscape = () => {
onClose('buddy panel closed'); onClose('buddy panel closed');
}; };
@@ -66,27 +73,21 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
isActive: true, isActive: true,
}); });
const creature = getActiveCreature(data); const updateData = (updated: BuddyData) => {
const handleSwitchCreature = (creatureId: string) => {
const updated = { ...data, activeCreatureId: creatureId };
setData(updated); setData(updated);
saveBuddyData(updated); saveBuddyData(updated);
setAppState(prev => ({ ...prev, companionCreatureChangedAt: Date.now() }));
}; };
const tabs = [ const tabs = [
<Tab key="buddy" title="Buddy"> <Tab key="buddy" title="Buddy">
{creature ? ( <PartyView data={data} onUpdate={updateData} isActive={selectedTab === 'Buddy'} />
<BuddyTab creature={creature} buddyData={data} spriteLines={spriteLines} />
) : (
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
)}
</Tab>, </Tab>,
<Tab key="dex" title="Pokédex"> <Tab key="dex" title="Pokédex">
<DexTab <DexTab
buddyData={data} buddyData={data}
isActive={selectedTab === 'Pokédex'} isActive={selectedTab === 'Pokédex'}
onSwitchCreature={handleSwitchCreature} onUpdate={updateData}
onClose={() => onClose('buddy panel closed')} onClose={() => onClose('buddy panel closed')}
/> />
</Tab>, </Tab>,
@@ -104,18 +105,131 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
); );
} }
// ─── Buddy Tab ──────────────────────────────────────── // ─── Party View (replaces BuddyTab) ─────────────────────
function BuddyTab({ function PartyView({
data,
onUpdate,
isActive,
}: {
data: BuddyData;
onUpdate: (data: BuddyData) => void;
spriteLines?: string[];
isActive: boolean;
}) {
const [focusedSlot, setFocusedSlot] = useState(0);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [tick, setTick] = useState(0); // force re-render on navigation
useInput((_input, key) => {
if (!isActive) return;
if (_input === 'a' || _input === 'A') {
setFocusedSlot(prev => (prev > 0 ? prev - 1 : 5));
setTick(t => t + 1);
setStatusMsg(null);
} else if (_input === 'd' || _input === 'D') {
setFocusedSlot(prev => (prev < 5 ? prev + 1 : 0));
setTick(t => t + 1);
setStatusMsg(null);
} else if (key.return) {
if (focusedSlot === 0) {
setStatusMsg('This is your active buddy!');
return;
}
const updated = swapPartySlots(data, 0, focusedSlot);
onUpdate(updated);
setStatusMsg('Swapped with active buddy!');
} else if (_input === 'x' || _input === 'X') {
const creatureId = data.party[focusedSlot];
if (!creatureId) return;
const updated = removeFromParty(data, focusedSlot);
onUpdate(updated);
setStatusMsg('Removed from party.');
}
});
// Resolve creature for the focused slot (tick forces re-read)
const _tick = tick; // reference tick to avoid unused warning
const focusedCreatureId = data.party[focusedSlot];
const focusedCreature = focusedCreatureId
? data.creatures.find(c => c.id === focusedCreatureId) ?? null
: null;
// Load sprite for focused creature (not just active)
const focusedSprite = focusedCreature
? (loadSprite(focusedCreature.speciesId)?.lines ?? getFallbackSprite(focusedCreature.speciesId))
: undefined;
return (
<Box flexDirection="column">
{/* Party slots row */}
<Box flexDirection="row" justifyContent="center">
{data.party.map((creatureId, i) => {
const creature = creatureId ? data.creatures.find(c => c.id === creatureId) : null;
const isActiveSlot = i === 0;
const isFocused = i === focusedSlot;
return (
<Box key={i} flexDirection="column" alignItems="center" width={14} marginX={0}>
<Box borderStyle={isFocused ? 'round' : undefined} borderColor={isFocused ? CYAN : undefined} paddingX={1}>
<Text>
{isActiveSlot && !isFocused && <Text color={YELLOW}></Text>}
{isFocused && <Text color={CYAN}></Text>}
{creature ? (
<Text bold={isFocused} color={isFocused ? CYAN : GRAY}>
{getCreatureName(creature).length > 8
? getCreatureName(creature).slice(0, 7) + '…'
: getCreatureName(creature)}
</Text>
) : (
<Text color={GRAY}>---</Text>
)}
</Text>
</Box>
<Text color={creature ? GRAY : undefined} dimColor={!creature}>
{creature ? `Lv.${creature.level}` : ' '}
</Text>
</Box>
);
})}
</Box>
{/* Status message */}
{statusMsg && (
<Box justifyContent="center">
<Text color={GRAY} italic>{statusMsg}</Text>
</Box>
)}
{/* Hint */}
<Box justifyContent="center">
<Text color={GRAY} dimColor>a/d navigate · Enter swap · X remove</Text>
</Box>
{/* Selected creature detail — key forces remount on slot change */}
{focusedCreature ? (
<CreatureDetail key={focusedCreature.id} creature={focusedCreature} spriteLines={focusedSprite} isActive={data.party[0] === focusedCreature.id} />
) : (
<Box flexDirection="column" alignItems="center" marginTop={1}>
<Text color={GRAY} italic>Empty slot add from Pokédex tab</Text>
</Box>
)}
</Box>
);
}
// ─── Creature Detail ─────────────────────────────────────
function CreatureDetail({
creature, creature,
buddyData,
spriteLines, spriteLines,
isActive,
}: { }: {
creature: Creature; creature: Creature;
buddyData: BuddyData;
spriteLines?: string[]; spriteLines?: string[];
isActive: boolean;
}) { }) {
const species = SPECIES_DATA[creature.speciesId]; const species = getSpeciesData(creature.speciesId);
const stats = calculateStats(creature); const stats = calculateStats(creature);
const xp = getXpProgress(creature); const xp = getXpProgress(creature);
const genderSymbol = getGenderSymbol(creature.gender); const genderSymbol = getGenderSymbol(creature.gender);
@@ -137,7 +251,7 @@ function BuddyTab({
const evoHint = nextEvo ? ( const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={GRAY}>
{' '} {' '}
<Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv. <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.
{nextEvo.minLevel} {nextEvo.minLevel}
</Text> </Text>
) : null; ) : null;
@@ -151,6 +265,7 @@ function BuddyTab({
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text> <Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge} {shinyBadge}
<Text bold> Lv.{creature.level}</Text> <Text bold> Lv.{creature.level}</Text>
{isActive && <Text color={YELLOW}> Active</Text>}
</Box> </Box>
<Box> <Box>
@@ -253,38 +368,38 @@ function BuddyTab({
function DexTab({ function DexTab({
buddyData, buddyData,
isActive, isActive,
onSwitchCreature, onUpdate,
onClose, onClose,
}: { }: {
buddyData: BuddyData; buddyData: BuddyData;
isActive: boolean; isActive: boolean;
onSwitchCreature: (creatureId: string) => void; onUpdate: (data: BuddyData) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d])); const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d]));
const collected = buddyData.dex.length; const collected = buddyData.dex.length;
const total = ALL_SPECIES_IDS.length; const total = ALL_SPECIES_IDS.length;
const flatSpecies = groupByChain().flat(); const flatSpecies = groupByChain().flat();
const partySet = new Set(buddyData.party.filter((id): id is string => id !== null));
const [focusedId, setFocusedId] = useState<SpeciesId>(flatSpecies[0]); const [focusedId, setFocusedId] = useState<SpeciesId>(flatSpecies[0]);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
// Build options for the Select component // Build options for the Select component
const options = flatSpecies.map(speciesId => { const options = flatSpecies.map(speciesId => {
const species = SPECIES_DATA[speciesId]; const species = getSpeciesData(speciesId);
const entry = dexMap.get(speciesId); const entry = dexMap.get(speciesId);
const discovered = !!entry; const discovered = !!entry;
const isActiveCreature = buddyData.activeCreatureId const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === speciesId);
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
: false;
return { return {
label: ( label: (
<Text> <Text>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text> <Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={discovered ? WHITE : GRAY} bold={isActiveCreature}> <Text color={discovered ? WHITE : GRAY} bold={inParty}>
{discovered ? (species.names.zh ?? species.name) : '???'} {discovered ? (species.names.zh ?? species.name) : '???'}
</Text> </Text>
{isActiveCreature && <Text color={YELLOW}> </Text>} {inParty && <Text color={YELLOW}> </Text>}
</Text> </Text>
), ),
value: speciesId, value: speciesId,
@@ -293,13 +408,11 @@ function DexTab({
}); });
// Right panel data // Right panel data
const focusedSpecies = SPECIES_DATA[focusedId]; const focusedSpecies = getSpeciesData(focusedId);
const focusedEntry = dexMap.get(focusedId); const focusedEntry = dexMap.get(focusedId);
const focusedDiscovered = !!focusedEntry; const focusedDiscovered = !!focusedEntry;
const focusedOwned = buddyData.creatures.find(c => c.speciesId === focusedId); const focusedOwned = buddyData.creatures.find(c => c.speciesId === focusedId);
const focusedIsActive = buddyData.activeCreatureId const focusedInParty = focusedOwned ? partySet.has(focusedOwned.id) : false;
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === focusedId)
: false;
const spriteLines = focusedDiscovered const spriteLines = focusedDiscovered
? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId)) ? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId))
@@ -307,6 +420,25 @@ function DexTab({
const maxBase = 130; const maxBase = 130;
const handleAddToParty = (speciesId: SpeciesId) => {
const creature = buddyData.creatures.find(c => c.speciesId === speciesId);
if (!creature) return;
// Already in party?
if (partySet.has(creature.id)) {
setStatusMsg('Already in party!');
return;
}
const result = addToParty(buddyData, creature.id);
if (result.added) {
onUpdate(result.data);
setStatusMsg(`Added ${getCreatureName(creature)} to party!`);
} else {
setStatusMsg('Party is full! Remove a member first.');
}
};
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Header */} {/* Header */}
@@ -328,13 +460,8 @@ function DexTab({
<Box width={20}> <Box width={20}>
<Select <Select
options={options} options={options}
onFocus={(value: SpeciesId) => setFocusedId(value)} onFocus={(value: SpeciesId) => { setFocusedId(value); setStatusMsg(null); }}
onChange={(value: SpeciesId) => { onChange={(value: SpeciesId) => handleAddToParty(value)}
const creature = buddyData.creatures.find(c => c.speciesId === value);
if (creature && creature.id !== buddyData.activeCreatureId) {
onSwitchCreature(creature.id);
}
}}
onCancel={onClose} onCancel={onClose}
visibleOptionCount={flatSpecies.length} visibleOptionCount={flatSpecies.length}
hideIndexes hideIndexes
@@ -421,7 +548,7 @@ function DexTab({
<React.Fragment key={sid}> <React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>} {i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === focusedId ? CYAN : GRAY} bold={sid === focusedId}> <Text color={sid === focusedId ? CYAN : GRAY} bold={sid === focusedId}>
{SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name} {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
</Text> </Text>
{next && <Text color={GRAY}> Lv.{next.minLevel}</Text>} {next && <Text color={GRAY}> Lv.{next.minLevel}</Text>}
</React.Fragment> </React.Fragment>
@@ -441,11 +568,13 @@ function DexTab({
{/* Status */} {/* Status */}
<Box marginTop={0}> <Box marginTop={0}>
{focusedOwned ? ( {statusMsg ? (
focusedIsActive ? ( <Text color={GREEN} italic>{statusMsg}</Text>
<Text color={GREEN}> Current buddy</Text> ) : focusedOwned ? (
focusedInParty ? (
<Text color={GREEN}> In party</Text>
) : ( ) : (
<Text color={CYAN}>Enter switch to this buddy</Text> <Text color={CYAN}>Enter add to party</Text>
) )
) : ( ) : (
<Text color={GRAY}>Not owned</Text> <Text color={GRAY}>Not owned</Text>

View File

@@ -23,9 +23,13 @@ import {
fetchAndCacheSprite, fetchAndCacheSprite,
loadSprite, loadSprite,
getFallbackSprite, getFallbackSprite,
SPECIES_DATA, getSpeciesData,
generateCreature,
addToParty,
ALL_SPECIES_IDS,
type BuddyData, type BuddyData,
type Creature, type Creature,
type SpeciesId,
} from '@claude-code-best/pokemon' } from '@claude-code-best/pokemon'
import { BuddyPanel } from './BuddyPanel.js' import { BuddyPanel } from './BuddyPanel.js'
@@ -36,8 +40,8 @@ import { BuddyPanel } from './BuddyPanel.js'
function getOrInitBuddyData(): BuddyData { function getOrInitBuddyData(): BuddyData {
let data = loadBuddyData() let data = loadBuddyData()
// If no active creature, check for legacy companion to migrate // If no active creature (party empty), check for legacy companion to migrate
if (!data.activeCreatureId || data.creatures.length === 0) { if (!getActiveCreature(data) || data.creatures.length === 0) {
const legacyCompanion = getGlobalConfig().companion const legacyCompanion = getGlobalConfig().companion
if (legacyCompanion) { if (legacyCompanion) {
data = migrateFromLegacy(legacyCompanion) data = migrateFromLegacy(legacyCompanion)
@@ -147,39 +151,63 @@ export async function call(
return null return null
} }
// ── /buddy switch — switch active creature ── // ── /buddy give-me-pokemon <species> [level] — admin: grant any Pokémon ──
if (sub === 'switch') { if (sub.startsWith('give-me-pokemon')) {
const data = getOrInitBuddyData() const parts = sub.split(/\s+/)
if (data.creatures.length <= 1) { const speciesArg = parts[1]?.toLowerCase()
onDone('You only have one buddy!', { display: 'system' }) const levelArg = parts[2] ? parseInt(parts[2], 10) : undefined
return null
}
const lines = data.creatures.map((c, i) => {
const name = getCreatureName(c)
const species = SPECIES_DATA[c.speciesId]
const active = c.id === data.activeCreatureId ? ' ← active' : ''
return `${i + 1}. ${name} (${species.names.zh ?? species.name}) Lv.${c.level}${active}`
})
onDone(
['Switch buddy:', ...lines, '', 'Use: /buddy switch <number>'].join('\n'),
{ display: 'system' },
)
return null
}
if (sub.startsWith('switch ')) { if (!speciesArg) {
const num = parseInt(sub.slice(7).trim(), 10) const available = ALL_SPECIES_IDS.join(', ')
const data = getOrInitBuddyData() onDone(`Usage: /buddy give-me-pokemon <species> [level]\nAvailable: ${available}`, { display: 'system' })
if (isNaN(num) || num < 1 || num > data.creatures.length) {
onDone('Invalid number. Use /buddy switch to see list.', {
display: 'system',
})
return null return null
} }
const creature = data.creatures[num - 1]!
data.activeCreatureId = creature.id // Validate species (match by partial name or full id)
const match = ALL_SPECIES_IDS.find(id =>
id === speciesArg || id.includes(speciesArg),
)
if (!match) {
onDone(`Unknown species "${speciesArg}". Available: ${ALL_SPECIES_IDS.join(', ')}`, { display: 'system' })
return null
}
const data = getOrInitBuddyData()
// Create the creature
const creature = generateCreature(match)
if (levelArg && !isNaN(levelArg) && levelArg >= 1 && levelArg <= 100) {
creature.level = levelArg
}
// Add to creatures and dex
data.creatures.push(creature)
const existingDex = data.dex.find(d => d.speciesId === match)
if (existingDex) {
existingDex.caughtCount++
existingDex.bestLevel = Math.max(existingDex.bestLevel, creature.level)
} else {
data.dex.push({
speciesId: match,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: creature.level,
})
}
// Try to add to party (first empty slot)
const partyResult = addToParty(data, creature.id)
if (partyResult.added) {
Object.assign(data, partyResult.data)
}
// If party full, creature stays in creatures[] but not in party
saveBuddyData(data) saveBuddyData(data)
onDone(`Switched to ${getCreatureName(creature)}!`, { display: 'system' }) setState?.(prev => ({ ...prev, companionCreatureChangedAt: Date.now() }))
const species = getSpeciesData(match)
const name = creature.nickname ?? species.name
onDone(`Got ${name} (${species.names.zh ?? species.name}) Lv.${creature.level}!`, { display: 'system' })
return null return null
} }

View File

@@ -3482,6 +3482,21 @@ export function REPL({
// 3. Award conversation XP // 3. Award conversation XP
const _xpResult = _awardXP(_evolved, 5 + _toolNames.length); const _xpResult = _awardXP(_evolved, 5 + _toolNames.length);
_data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _xpResult.creature : c)); _data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _xpResult.creature : c));
// 3b. Update companion XP info for status display
{
const { getXpProgress: _getXp } = await import('@claude-code-best/pokemon');
const _prog = _getXp(_xpResult.creature);
setAppState(prev => ({
...prev,
companionXpInfo: {
level: _xpResult.newLevel,
xpGained: 5 + _toolNames.length,
xpCurrent: _prog.current,
xpNeeded: _prog.needed,
leveledUp: _xpResult.leveledUp,
},
}));
}
// 4. Advance egg steps // 4. Advance egg steps
if (_data.eggs.length > 0) { if (_data.eggs.length > 0) {
_data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3)); _data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3));

View File

@@ -173,6 +173,10 @@ export type AppState = DeepImmutable<{
companionEvolving?: { from: string; to: string } companionEvolving?: { from: string; to: string }
// Egg steps update counter (triggers UI refresh) // Egg steps update counter (triggers UI refresh)
companionEggSteps?: number companionEggSteps?: number
// XP info for companion status display (set after each turn)
companionXpInfo?: { level: number; xpGained: number; xpCurrent: number; xpNeeded: number; leveledUp: boolean }
// Timestamp when active creature was switched — triggers CompanionSprite refresh
companionCreatureChangedAt?: number
// TODO (ashwin): see if we can use utility-types DeepReadonly for this // TODO (ashwin): see if we can use utility-types DeepReadonly for this
mcp: { mcp: {
clients: MCPServerConnection[] clients: MCPServerConnection[]