From 970fcd627f1b8887df68255a74650c6cdf0f9794 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 21 Apr 2026 21:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=88=E6=98=AF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E5=A4=A7=E5=A0=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pokemon/src/__tests__/creature.test.ts | 4 +- packages/pokemon/src/__tests__/egg.test.ts | 5 +- packages/pokemon/src/__tests__/gender.test.ts | 8 +- packages/pokemon/src/core/creature.ts | 18 +- packages/pokemon/src/core/egg.ts | 4 +- packages/pokemon/src/core/evolution.ts | 4 +- packages/pokemon/src/core/experience.ts | 6 +- packages/pokemon/src/core/spriteCache.ts | 4 +- packages/pokemon/src/core/storage.ts | 94 +++++++- packages/pokemon/src/index.ts | 4 +- packages/pokemon/src/types.ts | 4 +- packages/pokemon/src/ui/CompanionCard.tsx | 6 +- packages/pokemon/src/ui/EvolutionAnim.tsx | 6 +- packages/pokemon/src/ui/PokedexView.tsx | 4 +- packages/pokemon/src/ui/SpeciesDetail.tsx | 6 +- src/buddy/CompanionSprite.tsx | 30 ++- src/buddy/companionReact.ts | 4 +- src/buddy/prompt.ts | 4 +- src/commands/buddy/BuddyPanel.tsx | 217 ++++++++++++++---- src/commands/buddy/buddy.ts | 92 +++++--- src/screens/REPL.tsx | 15 ++ src/state/AppStateStore.ts | 4 + 22 files changed, 417 insertions(+), 126 deletions(-) diff --git a/packages/pokemon/src/__tests__/creature.test.ts b/packages/pokemon/src/__tests__/creature.test.ts index 0a82cee8b..29cf36b78 100644 --- a/packages/pokemon/src/__tests__/creature.test.ts +++ b/packages/pokemon/src/__tests__/creature.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from 'bun:test' import type { SpeciesId, Creature } from '../types' import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel } from '../core/creature' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' describe('generateCreature', () => { test('creates a creature with correct defaults', () => { @@ -10,7 +10,7 @@ describe('generateCreature', () => { expect(c.level).toBe(1) expect(c.xp).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.id).toBeTruthy() expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true) diff --git a/packages/pokemon/src/__tests__/egg.test.ts b/packages/pokemon/src/__tests__/egg.test.ts index 84fca74bd..913a7c0fb 100644 --- a/packages/pokemon/src/__tests__/egg.test.ts +++ b/packages/pokemon/src/__tests__/egg.test.ts @@ -4,10 +4,11 @@ import type { BuddyData } from '../types' import { generateCreature } from '../core/creature' function makeBuddyData(overrides: Partial = {}): BuddyData { + const creature = generateCreature('bulbasaur') return { version: 1, - activeCreatureId: 'test', - creatures: [generateCreature('bulbasaur')], + party: [creature.id, null, null, null, null, null], + creatures: [creature], eggs: [], dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }], stats: { diff --git a/packages/pokemon/src/__tests__/gender.test.ts b/packages/pokemon/src/__tests__/gender.test.ts index 05d3a1206..14839aa8b 100644 --- a/packages/pokemon/src/__tests__/gender.test.ts +++ b/packages/pokemon/src/__tests__/gender.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'bun:test' import { determineGender, getGenderSymbol } from '../core/gender' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' describe('determineGender', () => { test('genderless species', () => { @@ -8,12 +8,12 @@ describe('determineGender', () => { // Venusaur has genderRate 1 (12.5% female) // For testing genderless, we'd need a species with genderRate -1 // None in MVP are genderless, so test the basic logic - const pikachu = SPECIES_DATA.pikachu + const pikachu = getSpeciesData('pikachu') expect(pikachu.genderRate).toBe(4) }) test('pikachu 50% female ratio', () => { - const pikachu = SPECIES_DATA.pikachu + const pikachu = getSpeciesData('pikachu') let males = 0 let females = 0 for (let seed = 0; seed < 1000; seed++) { @@ -27,7 +27,7 @@ describe('determineGender', () => { }) test('starters are ~12.5% female', () => { - const bulbasaur = SPECIES_DATA.bulbasaur + const bulbasaur = getSpeciesData('bulbasaur') let females = 0 for (let seed = 0; seed < 1000; seed++) { if (determineGender(bulbasaur, seed) === 'female') females++ diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts index eeec4d250..8fec40c50 100644 --- a/packages/pokemon/src/core/creature.ts +++ b/packages/pokemon/src/core/creature.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import type { Creature, SpeciesId, StatName, StatsResult } from '../types' import { STAT_NAMES } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { determineGender } from './gender' import { levelFromXp } from '../data/xpTable' @@ -9,7 +9,7 @@ import { levelFromXp } from '../data/xpTable' * Generate a new creature of the given species. */ 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) // 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 */ export function calculateStats(creature: Creature): StatsResult { - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) const level = creature.level const result: StatsResult = {} as StatsResult @@ -67,14 +67,14 @@ export function calculateStats(creature: Creature): StatsResult { */ export function getCreatureName(creature: Creature): string { 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). */ export function recalculateLevel(creature: Creature): Creature { - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) const newLevel = levelFromXp(creature.totalXp, species.growthRate) if (newLevel !== creature.level) { return { ...creature, level: newLevel } @@ -84,10 +84,12 @@ export function recalculateLevel(creature: Creature): Creature { /** * 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 { - if (!buddyData.activeCreatureId) return null - return buddyData.creatures.find((c) => c.id === buddyData.activeCreatureId) ?? null +export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null { + const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null + if (!activeId) return null + return buddyData.creatures.find((c) => c.id === activeId) ?? null } /** diff --git a/packages/pokemon/src/core/egg.ts b/packages/pokemon/src/core/egg.ts index 56570eecf..7a44d12d4 100644 --- a/packages/pokemon/src/core/egg.ts +++ b/packages/pokemon/src/core/egg.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import type { BuddyData, Creature, Egg, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { generateCreature } from './creature' /** 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)] // 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) return { diff --git a/packages/pokemon/src/core/evolution.ts b/packages/pokemon/src/core/evolution.ts index bd302b6ef..479e45f26 100644 --- a/packages/pokemon/src/core/evolution.ts +++ b/packages/pokemon/src/core/evolution.ts @@ -1,5 +1,5 @@ import type { Creature, EvolutionResult, SpeciesId } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' 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. */ export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature { - const newSpecies = SPECIES_DATA[targetSpeciesId] + const newSpecies = getSpeciesData(targetSpeciesId) return { ...creature, diff --git a/packages/pokemon/src/core/experience.ts b/packages/pokemon/src/core/experience.ts index e3ba3a7a4..cade67a25 100644 --- a/packages/pokemon/src/core/experience.ts +++ b/packages/pokemon/src/core/experience.ts @@ -1,12 +1,12 @@ import type { Creature } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { levelFromXp, xpForLevel } from '../data/xpTable' /** * Award XP to a creature. Returns updated creature and whether level up occurred. */ export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } { - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) if (creature.level >= 100) { 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. */ 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 nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp const needed = nextLevelXp - currentLevelXp diff --git a/packages/pokemon/src/core/spriteCache.ts b/packages/pokemon/src/core/spriteCache.ts index 2709dbadb..b363de9fb 100644 --- a/packages/pokemon/src/core/spriteCache.ts +++ b/packages/pokemon/src/core/spriteCache.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { SpeciesId, SpriteCache } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { getSpritesDir } from './storage' 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. */ export function getSpeciesDisplay(speciesId: SpeciesId): string { - const data = SPECIES_DATA[speciesId] + const data = getSpeciesData(speciesId) return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}` } diff --git a/packages/pokemon/src/core/storage.ts b/packages/pokemon/src/core/storage.ts index f3c1a606e..1fbbfa66c 100644 --- a/packages/pokemon/src/core/storage.ts +++ b/packages/pokemon/src/core/storage.ts @@ -4,13 +4,14 @@ import { homedir } from 'node:os' import type { BuddyData, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' 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_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') /** * Load buddy data from disk. Returns default data if file doesn't exist. + * Auto-migrates legacy data without `party` field. */ export function loadBuddyData(): BuddyData { if (!existsSync(BUDDY_DATA_PATH)) { @@ -22,7 +23,8 @@ export function loadBuddyData(): BuddyData { if (data.version !== 1) { return migrateData(data) } - return data + // Migrate legacy data without party field + return ensurePartyField(data) } catch { return getDefaultBuddyData() } @@ -51,7 +53,7 @@ export function getDefaultBuddyData(): BuddyData { return { version: 1, - activeCreatureId: creature.id, + party: [creature.id, null, null, null, null, null], creatures: [creature], eggs: [], dex: [ @@ -122,14 +124,14 @@ export function migrateFromLegacy( creature.friendship = 120 // Existing partner bonus // Preserve nickname if it's not the default - const speciesInfo = SPECIES_DATA[speciesId] + const speciesInfo = getSpeciesData(speciesId) if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { creature.nickname = storedCompanion.name } return { version: 1, - activeCreatureId: creature.id, + party: [creature.id, null, null, null, null, null], creatures: [creature], eggs: [], 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 } +} diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts index 024eb82b0..752894ffe 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -19,7 +19,7 @@ export type { export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from './types' // 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 { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable' 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 { 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 } from './core/storage' +export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns, addToParty, removeFromParty, swapPartySlots, setActivePartyMember } from './core/storage' export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache' // Sprites diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index 293d581e9..de00df06f 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -107,7 +107,9 @@ export type DexEntry = { // buddy-data.json complete structure export type BuddyData = { 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[] eggs: Egg[] dex: DexEntry[] diff --git a/packages/pokemon/src/ui/CompanionCard.tsx b/packages/pokemon/src/ui/CompanionCard.tsx index e289d7be4..cc1e02453 100644 --- a/packages/pokemon/src/ui/CompanionCard.tsx +++ b/packages/pokemon/src/ui/CompanionCard.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { BuddyData, Creature, SpeciesId } from '../types' import { STAT_NAMES, STAT_LABELS } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names' import { calculateStats, getCreatureName, getTotalEV } from '../core/creature' import { getXpProgress } from '../core/experience' @@ -42,7 +42,7 @@ const TYPE_COLORS: Record = { * Redesigned companion card with Pokémon-style stats display. */ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) { - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) const stats = calculateStats(creature) const xp = getXpProgress(creature) const genderSymbol = getGenderSymbol(creature.gender) @@ -66,7 +66,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar // Evolution hint const evoHint = nextEvo ? ( - {SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name} Lv.{nextEvo.minLevel} + {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv.{nextEvo.minLevel} ) : null return ( diff --git a/packages/pokemon/src/ui/EvolutionAnim.tsx b/packages/pokemon/src/ui/EvolutionAnim.tsx index 503811e63..eff5184b0 100644 --- a/packages/pokemon/src/ui/EvolutionAnim.tsx +++ b/packages/pokemon/src/ui/EvolutionAnim.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { SpeciesId } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { loadSprite } from '../core/spriteCache' import { getFallbackSprite } from '../sprites/fallback' @@ -35,8 +35,8 @@ export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionA const fromSprite = getSpriteLines(fromSpecies) const toSprite = getSpriteLines(toSpecies) - const fromName = SPECIES_DATA[fromSpecies].name - const toName = SPECIES_DATA[toSpecies].name + const fromName = getSpeciesData(fromSpecies).name + const toName = getSpeciesData(toSpecies).name // Frame logic: // 0-3: old sprite with flash (alternate blank) diff --git a/packages/pokemon/src/ui/PokedexView.tsx b/packages/pokemon/src/ui/PokedexView.tsx index e72080c2c..72a518822 100644 --- a/packages/pokemon/src/ui/PokedexView.tsx +++ b/packages/pokemon/src/ui/PokedexView.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { BuddyData, SpeciesId } from '../types' import { ALL_SPECIES_IDS } from '../types' -import { SPECIES_DATA } from '../data/species' +import { getSpeciesData } from '../data/species' import { getNextEvolution } from '../data/evolution' const CYAN: Color = 'ansi:cyan' @@ -52,7 +52,7 @@ export function PokedexView({ buddyData }: PokedexViewProps) { {chains.map((chain, ci) => ( 0 ? 0 : 0}> {chain.map((speciesId, si) => { - const species = SPECIES_DATA[speciesId] + const species = getSpeciesData(speciesId) const entry = dexMap.get(speciesId) const discovered = !!entry const isActive = buddyData.activeCreatureId diff --git a/packages/pokemon/src/ui/SpeciesDetail.tsx b/packages/pokemon/src/ui/SpeciesDetail.tsx index 5b25c83e0..a05022884 100644 --- a/packages/pokemon/src/ui/SpeciesDetail.tsx +++ b/packages/pokemon/src/ui/SpeciesDetail.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Box, Text, type Color } from '@anthropic/ink' import type { SpeciesId, StatName } 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 { getNextEvolution } from '../data/evolution' import { StatBar } from './StatBar' @@ -32,7 +32,7 @@ interface SpeciesDetailProps { * Detailed species info page — base stats, evolution chain, flavor text. */ export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) { - const species = SPECIES_DATA[speciesId] + const species = getSpeciesData(speciesId) const nextEvo = getNextEvolution(speciesId) // Type badges @@ -163,7 +163,7 @@ function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) { {i > 0 && } - {SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name} + {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name} {i < chain.length - 1 && getNextEvolution(sid) && ( Lv.{getNextEvolution(sid)!.minLevel} diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index dbfe5f2f4..1bf57ac52 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -11,11 +11,12 @@ import { loadBuddyData, getActiveCreature, getCreatureName, + getXpProgress, loadSprite, getFallbackSprite, renderAnimatedSprite, getIdleAnimMode, - SPECIES_DATA, + getSpeciesData, type Creature, type AnimMode, } from '@claude-code-best/pokemon'; @@ -138,7 +139,10 @@ function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode export function CompanionSprite(): React.ReactNode { const reaction = useAppState(s => s.companionReaction); const petAt = useAppState(s => s.companionPetAt); + const xpInfo = useAppState(s => s.companionXpInfo); 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 { columns } = useTerminalSize(); const [tick, setTick] = useState(0); @@ -179,7 +183,7 @@ export function CompanionSprite(): React.ReactNode { const creature = getPokemonCreature(); if (!creature || getGlobalConfig().companionMuted) return null; - const species = SPECIES_DATA[creature.speciesId]; + const species = getSpeciesData(creature.speciesId); const name = getCreatureName(creature); const color = creature.isShiny ? 'warning' : 'claude'; const colWidth = spriteColWidth(stringWidth(name)); @@ -195,6 +199,13 @@ export function CompanionSprite(): React.ReactNode { const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; 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 ( @@ -211,6 +222,11 @@ export function CompanionSprite(): React.ReactNode { > {label} + {xpLabel && ( + + {xpLabel} + + )} ); @@ -229,6 +245,12 @@ export function CompanionSprite(): React.ReactNode { const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines; + const xpStatus = xpInfo + ? xpInfo.leveledUp + ? `↑Lv.${xpInfo.level}` + : `+${xpInfo.xpGained}xp` + : null; + const spriteColumn = ( {displayLines.map((line, i) => ( @@ -239,6 +261,9 @@ export function CompanionSprite(): React.ReactNode { {focused ? ` ${name} ` : name} + + Lv.{creature.level} {xpStatus ?? ''} + ); @@ -260,6 +285,7 @@ export function CompanionSprite(): React.ReactNode { // Floating bubble overlay for fullscreen mode export function CompanionFloatingBubble(): React.ReactNode { const reaction = useAppState(s => s.companionReaction); + const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt); const [{ tick, forReaction }, setTick] = useState({ tick: 0, forReaction: reaction, diff --git a/src/buddy/companionReact.ts b/src/buddy/companionReact.ts index 85fae9f12..eb970e090 100644 --- a/src/buddy/companionReact.ts +++ b/src/buddy/companionReact.ts @@ -15,7 +15,7 @@ import { getActiveCreature, getCreatureName, calculateStats, - SPECIES_DATA, + getSpeciesData, type Creature, } from '@claude-code-best/pokemon' @@ -120,7 +120,7 @@ async function callBuddyReactAPI( const orgId = getGlobalConfig().oauthAccount?.organizationUuid if (!orgId) return null - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) const name = getCreatureName(creature) const stats = calculateStats(creature) diff --git a/src/buddy/prompt.ts b/src/buddy/prompt.ts index ab49cf9e7..641cbb22e 100644 --- a/src/buddy/prompt.ts +++ b/src/buddy/prompt.ts @@ -6,7 +6,7 @@ import { loadBuddyData, getActiveCreature, getCreatureName, - SPECIES_DATA, + getSpeciesData, } from '@claude-code-best/pokemon' export function companionIntroText(name: string, species: string): string { @@ -26,7 +26,7 @@ export function getCompanionIntroAttachment( if (!creature || getGlobalConfig().companionMuted) return [] const name = getCreatureName(creature) - const species = SPECIES_DATA[creature.speciesId] + const species = getSpeciesData(creature.speciesId) // Skip if already announced for this companion. for (const msg of messages ?? []) { diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx index 07426c623..3438215bf 100644 --- a/src/commands/buddy/BuddyPanel.tsx +++ b/src/commands/buddy/BuddyPanel.tsx @@ -1,6 +1,7 @@ import * as React 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 { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; import { Select } from '../../components/CustomSelect/select.js'; @@ -12,15 +13,15 @@ import { type Creature, type SpeciesId, } 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 { 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 { getGenderSymbol } 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 YELLOW: Color = 'ansi:yellow'; @@ -54,9 +55,15 @@ interface BuddyPanelProps { export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) { const [selectedTab, setSelectedTab] = useState('Buddy'); const [data, setData] = useState(buddyData); + const setAppState = useSetAppState(); useExitOnCtrlCDWithKeybindings(); + // Trigger species data refresh from API (fire-and-forget) + React.useEffect(() => { + ensureSpeciesData(); + }, []); + const handleEscape = () => { onClose('buddy panel closed'); }; @@ -66,27 +73,21 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) isActive: true, }); - const creature = getActiveCreature(data); - - const handleSwitchCreature = (creatureId: string) => { - const updated = { ...data, activeCreatureId: creatureId }; + const updateData = (updated: BuddyData) => { setData(updated); saveBuddyData(updated); + setAppState(prev => ({ ...prev, companionCreatureChangedAt: Date.now() })); }; const tabs = [ - {creature ? ( - - ) : ( - No buddy yet. Keep coding! - )} + , onClose('buddy panel closed')} /> , @@ -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(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 ( + + {/* Party slots row */} + + {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 ( + + + + {isActiveSlot && !isFocused && } + {isFocused && } + {creature ? ( + + {getCreatureName(creature).length > 8 + ? getCreatureName(creature).slice(0, 7) + '…' + : getCreatureName(creature)} + + ) : ( + --- + )} + + + + {creature ? `Lv.${creature.level}` : ' '} + + + ); + })} + + + {/* Status message */} + {statusMsg && ( + + {statusMsg} + + )} + + {/* Hint */} + + a/d navigate · Enter swap · X remove + + + {/* Selected creature detail — key forces remount on slot change */} + {focusedCreature ? ( + + ) : ( + + Empty slot — add from Pokédex tab + + )} + + ); +} + +// ─── Creature Detail ───────────────────────────────────── + +function CreatureDetail({ creature, - buddyData, spriteLines, + isActive, }: { creature: Creature; - buddyData: BuddyData; spriteLines?: string[]; + isActive: boolean; }) { - const species = SPECIES_DATA[creature.speciesId]; + const species = getSpeciesData(creature.speciesId); const stats = calculateStats(creature); const xp = getXpProgress(creature); const genderSymbol = getGenderSymbol(creature.gender); @@ -137,7 +251,7 @@ function BuddyTab({ const evoHint = nextEvo ? ( {' '} - → {SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name} Lv. + → {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv. {nextEvo.minLevel} ) : null; @@ -151,6 +265,7 @@ function BuddyTab({ #{String(species.dexNumber).padStart(3, '0')} {shinyBadge} Lv.{creature.level} + {isActive && ★ Active} @@ -253,38 +368,38 @@ function BuddyTab({ function DexTab({ buddyData, isActive, - onSwitchCreature, + onUpdate, onClose, }: { buddyData: BuddyData; isActive: boolean; - onSwitchCreature: (creatureId: string) => void; + onUpdate: (data: BuddyData) => void; onClose: () => void; }) { const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d])); const collected = buddyData.dex.length; const total = ALL_SPECIES_IDS.length; const flatSpecies = groupByChain().flat(); + const partySet = new Set(buddyData.party.filter((id): id is string => id !== null)); const [focusedId, setFocusedId] = useState(flatSpecies[0]); + const [statusMsg, setStatusMsg] = useState(null); // Build options for the Select component const options = flatSpecies.map(speciesId => { - const species = SPECIES_DATA[speciesId]; + const species = getSpeciesData(speciesId); const entry = dexMap.get(speciesId); const discovered = !!entry; - const isActiveCreature = buddyData.activeCreatureId - ? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId) - : false; + const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === speciesId); return { label: ( #{String(species.dexNumber).padStart(3, '0')} - + {discovered ? (species.names.zh ?? species.name) : '???'} - {isActiveCreature && } + {inParty && } ), value: speciesId, @@ -293,13 +408,11 @@ function DexTab({ }); // Right panel data - const focusedSpecies = SPECIES_DATA[focusedId]; + const focusedSpecies = getSpeciesData(focusedId); const focusedEntry = dexMap.get(focusedId); const focusedDiscovered = !!focusedEntry; const focusedOwned = buddyData.creatures.find(c => c.speciesId === focusedId); - const focusedIsActive = buddyData.activeCreatureId - ? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === focusedId) - : false; + const focusedInParty = focusedOwned ? partySet.has(focusedOwned.id) : false; const spriteLines = focusedDiscovered ? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId)) @@ -307,6 +420,25 @@ function DexTab({ 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 ( {/* Header */} @@ -328,13 +460,8 @@ function DexTab({