mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 又是更新了一大堆
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user