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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, Color> = {
* 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 ? (
<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
return (

View File

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

View File

@@ -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) => (
<Box key={ci} flexDirection="column" marginTop={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

View File

@@ -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 }) {
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<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>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>

View File

@@ -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 (
<Box paddingX={1} alignSelf="flex-end">
<Text>
@@ -211,6 +222,11 @@ export function CompanionSprite(): React.ReactNode {
>
{label}
</Text>
{xpLabel && (
<Text dimColor bold={xpInfo?.leveledUp} color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
{xpLabel}
</Text>
)}
</Text>
</Box>
);
@@ -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 = (
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
{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}>
{focused ? ` ${name} ` : name}
</Text>
<Text dimColor color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
Lv.{creature.level} {xpStatus ?? ''}
</Text>
</Box>
);
@@ -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,

View File

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

View File

@@ -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 ?? []) {

View File

@@ -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 = [
<Tab key="buddy" title="Buddy">
{creature ? (
<BuddyTab creature={creature} buddyData={data} spriteLines={spriteLines} />
) : (
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
)}
<PartyView data={data} onUpdate={updateData} isActive={selectedTab === 'Buddy'} />
</Tab>,
<Tab key="dex" title="Pokédex">
<DexTab
buddyData={data}
isActive={selectedTab === 'Pokédex'}
onSwitchCreature={handleSwitchCreature}
onUpdate={updateData}
onClose={() => onClose('buddy panel closed')}
/>
</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,
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 ? (
<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}
</Text>
) : null;
@@ -151,6 +265,7 @@ function BuddyTab({
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge}
<Text bold> Lv.{creature.level}</Text>
{isActive && <Text color={YELLOW}> Active</Text>}
</Box>
<Box>
@@ -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<SpeciesId>(flatSpecies[0]);
const [statusMsg, setStatusMsg] = useState<string | null>(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: (
<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) : '???'}
</Text>
{isActiveCreature && <Text color={YELLOW}> </Text>}
{inParty && <Text color={YELLOW}> </Text>}
</Text>
),
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 (
<Box flexDirection="column">
{/* Header */}
@@ -328,13 +460,8 @@ function DexTab({
<Box width={20}>
<Select
options={options}
onFocus={(value: SpeciesId) => setFocusedId(value)}
onChange={(value: SpeciesId) => {
const creature = buddyData.creatures.find(c => c.speciesId === value);
if (creature && creature.id !== buddyData.activeCreatureId) {
onSwitchCreature(creature.id);
}
}}
onFocus={(value: SpeciesId) => { setFocusedId(value); setStatusMsg(null); }}
onChange={(value: SpeciesId) => handleAddToParty(value)}
onCancel={onClose}
visibleOptionCount={flatSpecies.length}
hideIndexes
@@ -421,7 +548,7 @@ function DexTab({
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<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>
{next && <Text color={GRAY}> Lv.{next.minLevel}</Text>}
</React.Fragment>
@@ -441,11 +568,13 @@ function DexTab({
{/* Status */}
<Box marginTop={0}>
{focusedOwned ? (
focusedIsActive ? (
<Text color={GREEN}> Current buddy</Text>
{statusMsg ? (
<Text color={GREEN} italic>{statusMsg}</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>

View File

@@ -23,9 +23,13 @@ import {
fetchAndCacheSprite,
loadSprite,
getFallbackSprite,
SPECIES_DATA,
getSpeciesData,
generateCreature,
addToParty,
ALL_SPECIES_IDS,
type BuddyData,
type Creature,
type SpeciesId,
} from '@claude-code-best/pokemon'
import { BuddyPanel } from './BuddyPanel.js'
@@ -36,8 +40,8 @@ import { BuddyPanel } from './BuddyPanel.js'
function getOrInitBuddyData(): BuddyData {
let data = loadBuddyData()
// If no active creature, check for legacy companion to migrate
if (!data.activeCreatureId || data.creatures.length === 0) {
// 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)
@@ -147,39 +151,63 @@ export async function call(
return null
}
// ── /buddy switch — switch active creature ──
if (sub === 'switch') {
const data = getOrInitBuddyData()
if (data.creatures.length <= 1) {
onDone('You only have one buddy!', { display: 'system' })
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
}
// ── /buddy give-me-pokemon <species> [level] — admin: grant any Pokémon ──
if (sub.startsWith('give-me-pokemon')) {
const parts = sub.split(/\s+/)
const speciesArg = parts[1]?.toLowerCase()
const levelArg = parts[2] ? parseInt(parts[2], 10) : undefined
if (sub.startsWith('switch ')) {
const num = parseInt(sub.slice(7).trim(), 10)
const data = getOrInitBuddyData()
if (isNaN(num) || num < 1 || num > data.creatures.length) {
onDone('Invalid number. Use /buddy switch to see list.', {
display: 'system',
})
if (!speciesArg) {
const available = ALL_SPECIES_IDS.join(', ')
onDone(`Usage: /buddy give-me-pokemon <species> [level]\nAvailable: ${available}`, { display: 'system' })
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)
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
}

View File

@@ -3482,6 +3482,21 @@ export function REPL({
// 3. Award conversation XP
const _xpResult = _awardXP(_evolved, 5 + _toolNames.length);
_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
if (_data.eggs.length > 0) {
_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 }
// Egg steps update counter (triggers UI refresh)
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
mcp: {
clients: MCPServerConnection[]