mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 第一版可用 pokemon
This commit is contained in:
403
src/commands/buddy/BuddyPanel.tsx
Normal file
403
src/commands/buddy/BuddyPanel.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import {
|
||||
STAT_NAMES,
|
||||
STAT_LABELS,
|
||||
ALL_SPECIES_IDS,
|
||||
type BuddyData,
|
||||
type Creature,
|
||||
type SpeciesId,
|
||||
} from '@claude-code-best/pokemon';
|
||||
import { SPECIES_DATA } from '@claude-code-best/pokemon';
|
||||
import { SPECIES_PERSONALITY } from '@claude-code-best/pokemon';
|
||||
import { getNextEvolution } from '@claude-code-best/pokemon';
|
||||
import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '@claude-code-best/pokemon';
|
||||
import { getXpProgress } from '@claude-code-best/pokemon';
|
||||
import { getEVSummary } from '@claude-code-best/pokemon';
|
||||
import { getGenderSymbol } from '@claude-code-best/pokemon';
|
||||
import { StatBar } from '@claude-code-best/pokemon';
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
const CYAN: Color = 'ansi:cyan';
|
||||
const YELLOW: Color = 'ansi:yellow';
|
||||
const GREEN: Color = 'ansi:green';
|
||||
const BLUE: Color = 'ansi:blue';
|
||||
const RED: Color = 'ansi:red';
|
||||
const MAGENTA: Color = 'ansi:magenta';
|
||||
const WHITE: Color = 'ansi:whiteBright';
|
||||
const GRAY: Color = 'ansi:white';
|
||||
|
||||
const TYPE_COLORS: Record<string, Color> = {
|
||||
grass: 'ansi:green',
|
||||
poison: 'ansi:magenta',
|
||||
fire: 'ansi:red',
|
||||
flying: 'ansi:cyan',
|
||||
water: 'ansi:blue',
|
||||
electric: 'ansi:yellow',
|
||||
normal: 'ansi:white',
|
||||
};
|
||||
|
||||
interface BuddyPanelProps {
|
||||
buddyData: BuddyData;
|
||||
spriteLines?: string[];
|
||||
onClose: LocalJSXCommandOnDone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified buddy panel with tabs — same pattern as Settings.
|
||||
* ESC closes, ←/→ switch tabs, Ctrl+C/D double-press exits.
|
||||
*/
|
||||
export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) {
|
||||
const [selectedTab, setSelectedTab] = useState('Buddy');
|
||||
|
||||
useExitOnCtrlCDWithKeybindings();
|
||||
|
||||
const handleEscape = () => {
|
||||
onClose('buddy panel closed');
|
||||
};
|
||||
|
||||
useKeybinding('confirm:no', handleEscape, {
|
||||
context: 'Settings',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const creature = getActiveCreature(buddyData);
|
||||
|
||||
const tabs = [
|
||||
<Tab key="buddy" title="Buddy">
|
||||
{creature ? (
|
||||
<BuddyTab creature={creature} buddyData={buddyData} spriteLines={spriteLines} />
|
||||
) : (
|
||||
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
|
||||
)}
|
||||
</Tab>,
|
||||
<Tab key="dex" title="Pokédex">
|
||||
<DexTab buddyData={buddyData} />
|
||||
</Tab>,
|
||||
<Tab key="egg" title="Egg">
|
||||
<EggTab buddyData={buddyData} />
|
||||
</Tab>,
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<Tabs color="permission" selectedTab={selectedTab} onTabChange={setSelectedTab} initialHeaderFocused={true}>
|
||||
{tabs}
|
||||
</Tabs>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Buddy Tab ────────────────────────────────────────
|
||||
|
||||
function BuddyTab({
|
||||
creature,
|
||||
buddyData,
|
||||
spriteLines,
|
||||
}: {
|
||||
creature: Creature;
|
||||
buddyData: BuddyData;
|
||||
spriteLines?: string[];
|
||||
}) {
|
||||
const species = SPECIES_DATA[creature.speciesId];
|
||||
const stats = calculateStats(creature);
|
||||
const xp = getXpProgress(creature);
|
||||
const genderSymbol = getGenderSymbol(creature.gender);
|
||||
const name = getCreatureName(creature);
|
||||
const evSummary = getEVSummary(creature);
|
||||
const totalEV = getTotalEV(creature);
|
||||
const nextEvo = getNextEvolution(creature.speciesId);
|
||||
|
||||
const typeBadges = species.types
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.map((t, i) => (
|
||||
<React.Fragment key={t}>
|
||||
{i > 0 && <Text color={GRAY}>/</Text>}
|
||||
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.toUpperCase()}</Text>
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED;
|
||||
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null;
|
||||
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>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text bold color={CYAN}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||
{shinyBadge}
|
||||
</Box>
|
||||
<Text bold>Lv.{creature.level}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
||||
<Text> </Text>
|
||||
{typeBadges}
|
||||
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||
</Box>
|
||||
|
||||
{spriteLines && (
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
{spriteLines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text color={GRAY} italic>
|
||||
"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Stats ───</Text>
|
||||
{STAT_NAMES.map(stat => (
|
||||
<StatBar key={stat} label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>XP </Text>
|
||||
<Text color={BLUE}>
|
||||
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
{xp.current}/{xp.needed}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={GRAY}>EV </Text>
|
||||
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>♥ </Text>
|
||||
<Text color={friendshipColor}>
|
||||
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||
</Text>
|
||||
<Text> {creature.friendship}/255</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{evoHint && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>Next: </Text>
|
||||
{evoHint}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dex Tab ──────────────────────────────────────────
|
||||
|
||||
function DexTab({ buddyData }: { buddyData: BuddyData }) {
|
||||
const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d]));
|
||||
const collected = buddyData.dex.length;
|
||||
const total = ALL_SPECIES_IDS.length;
|
||||
const chains = groupByChain();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold color={CYAN}>
|
||||
Pokédex
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={collected === total ? GREEN : WHITE}>
|
||||
{collected}
|
||||
</Text>
|
||||
<Text color={GRAY}>/{total}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||
</Box>
|
||||
|
||||
{chains.map((chain, ci) => (
|
||||
<Box key={ci} flexDirection="column">
|
||||
{chain.map((speciesId, si) => {
|
||||
const species = SPECIES_DATA[speciesId];
|
||||
const entry = dexMap.get(speciesId);
|
||||
const discovered = !!entry;
|
||||
const isActive = buddyData.activeCreatureId
|
||||
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
|
||||
: false;
|
||||
const nextEvo = getNextEvolution(speciesId);
|
||||
|
||||
return (
|
||||
<Box key={speciesId}>
|
||||
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
||||
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
||||
{discovered ? (species.names.zh ?? species.name) : '???'}
|
||||
</Text>
|
||||
{discovered && (
|
||||
<Text>
|
||||
{' '}
|
||||
{species.types
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.map((t, ti) => (
|
||||
<React.Fragment key={t}>
|
||||
{ti > 0 && <Text color={GRAY}>/</Text>}
|
||||
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.slice(0, 3).toUpperCase()}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{discovered && entry ? (
|
||||
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||
) : (
|
||||
<Text color={GRAY}> ───</Text>
|
||||
)}
|
||||
{nextEvo && (
|
||||
<Text color={GRAY}>
|
||||
{' '}
|
||||
→<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box marginTop={0} flexDirection="column">
|
||||
<Text color={GRAY}>─── Stats ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Turns: </Text>
|
||||
<Text>{buddyData.stats.totalTurns}</Text>
|
||||
<Text color={GRAY}> Days: </Text>
|
||||
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>Eggs: </Text>
|
||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||
<Text color={GRAY}> Evolutions: </Text>
|
||||
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{buddyData.eggs.length > 0 && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||
<Text>
|
||||
{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}
|
||||
</Text>
|
||||
<Text color={GRAY}> steps</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Egg Tab ──────────────────────────────────────────
|
||||
|
||||
function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
||||
const eggs = buddyData.eggs;
|
||||
|
||||
if (eggs.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={CYAN}>
|
||||
Egg
|
||||
</Text>
|
||||
<Text color={GRAY}>No egg currently. Keep coding!</Text>
|
||||
{buddyData.stats.consecutiveDays < 7 && (
|
||||
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const egg = eggs[0]!;
|
||||
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100);
|
||||
const filled = Math.round(percentage / 10);
|
||||
const empty = 10 - filled;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={CYAN}>
|
||||
Egg Status
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
<Text> . </Text>
|
||||
<Text> / \ </Text>
|
||||
<Text> | | </Text>
|
||||
<Text> \_/ </Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text>
|
||||
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||
</Text>
|
||||
<Text color={YELLOW}>
|
||||
{'█'.repeat(filled)}
|
||||
{'░'.repeat(empty)}
|
||||
</Text>
|
||||
<Text>{percentage}%</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0} flexDirection="column" alignItems="center">
|
||||
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0} flexDirection="column">
|
||||
<Text color={GRAY}>─── Egg Stats ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Total eggs: </Text>
|
||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────
|
||||
|
||||
function getStatColor(stat: string): Color {
|
||||
const colors: Record<string, Color> = {
|
||||
hp: 'ansi:green',
|
||||
attack: 'ansi:red',
|
||||
defense: 'ansi:yellow',
|
||||
spAtk: 'ansi:blue',
|
||||
spDef: 'ansi:magenta',
|
||||
speed: 'ansi:cyan',
|
||||
};
|
||||
return colors[stat] ?? 'ansi:white';
|
||||
}
|
||||
|
||||
function groupByChain(): SpeciesId[][] {
|
||||
return [
|
||||
['bulbasaur', 'ivysaur', 'venusaur'],
|
||||
['charmander', 'charmeleon', 'charizard'],
|
||||
['squirtle', 'wartortle', 'blastoise'],
|
||||
['pikachu'],
|
||||
];
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
getCompanion,
|
||||
rollWithSeed,
|
||||
generateSeed,
|
||||
} from '../../buddy/companion.js'
|
||||
import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js'
|
||||
import { renderSprite } from '../../buddy/sprites.js'
|
||||
import { CompanionCard } from '../../buddy/CompanionCard.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
@@ -14,57 +6,46 @@ import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
saveBuddyData,
|
||||
getDefaultBuddyData,
|
||||
migrateFromLegacy,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
awardXP,
|
||||
advanceEggSteps,
|
||||
checkEvolution,
|
||||
checkEggEligibility,
|
||||
generateEgg,
|
||||
isEggReadyToHatch,
|
||||
hatchEgg,
|
||||
fetchAndCacheSprite,
|
||||
loadSprite,
|
||||
getFallbackSprite,
|
||||
SPECIES_DATA,
|
||||
type BuddyData,
|
||||
type Creature,
|
||||
} from '@claude-code-best/pokemon'
|
||||
import { BuddyPanel } from './BuddyPanel.js'
|
||||
|
||||
// Species → default name fragments for hatch (no API needed)
|
||||
const SPECIES_NAMES: Record<string, string> = {
|
||||
duck: 'Waddles',
|
||||
goose: 'Goosberry',
|
||||
blob: 'Gooey',
|
||||
cat: 'Whiskers',
|
||||
dragon: 'Ember',
|
||||
octopus: 'Inky',
|
||||
owl: 'Hoots',
|
||||
penguin: 'Waddleford',
|
||||
turtle: 'Shelly',
|
||||
snail: 'Trailblazer',
|
||||
ghost: 'Casper',
|
||||
axolotl: 'Axie',
|
||||
capybara: 'Chill',
|
||||
cactus: 'Spike',
|
||||
robot: 'Byte',
|
||||
rabbit: 'Flops',
|
||||
mushroom: 'Spore',
|
||||
chonk: 'Chonk',
|
||||
}
|
||||
/**
|
||||
* Load or initialize Pokémon buddy data.
|
||||
* Migrates from legacy buddy system if needed.
|
||||
*/
|
||||
function getOrInitBuddyData(): BuddyData {
|
||||
let data = loadBuddyData()
|
||||
|
||||
const SPECIES_PERSONALITY: Record<string, string> = {
|
||||
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
|
||||
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
|
||||
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
|
||||
cat: 'Independent and judgmental. Watches you type with mild disdain.',
|
||||
dragon:
|
||||
'Fiery and passionate about architecture. Hoards good variable names.',
|
||||
octopus:
|
||||
'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
|
||||
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
|
||||
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
|
||||
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
|
||||
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
|
||||
ghost:
|
||||
'Ethereal and appears at the worst possible moments with spooky insights.',
|
||||
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
|
||||
capybara: 'Zen master. Remains calm while everything around is on fire.',
|
||||
cactus:
|
||||
'Prickly on the outside but full of good intentions. Thrives on neglect.',
|
||||
robot: 'Efficient and literal. Processes feedback in binary.',
|
||||
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
|
||||
mushroom: 'Quietly insightful. Grows on you over time.',
|
||||
chonk:
|
||||
'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
|
||||
}
|
||||
// If no active creature, check for legacy companion to migrate
|
||||
if (!data.activeCreatureId || data.creatures.length === 0) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
data = migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(data)
|
||||
}
|
||||
}
|
||||
|
||||
function speciesLabel(species: string): string {
|
||||
return species.charAt(0).toUpperCase() + species.slice(1)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -89,18 +70,45 @@ export async function call(
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy pet — trigger heart animation + auto unmute ──
|
||||
// ── /buddy pet — trigger heart animation + XP + egg steps ──
|
||||
if (sub === 'pet') {
|
||||
const companion = getCompanion()
|
||||
if (!companion) {
|
||||
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
|
||||
const data = getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Auto-unmute on pet + trigger heart animation
|
||||
// Auto-unmute + heart animation
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
|
||||
|
||||
// Award pet XP
|
||||
const result = awardXP(creature, 2)
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? result.creature : c,
|
||||
)
|
||||
|
||||
// Advance egg steps
|
||||
if (data.eggs.length > 0) {
|
||||
data.eggs = data.eggs.map(egg => advanceEggSteps(egg, 5))
|
||||
|
||||
// Check hatch
|
||||
const readyEgg = data.eggs.find(isEggReadyToHatch)
|
||||
if (readyEgg) {
|
||||
const { buddyData: updatedData, creature: newCreature } = hatchEgg(
|
||||
data,
|
||||
readyEgg,
|
||||
)
|
||||
Object.assign(data, updatedData)
|
||||
onDone(`🥚 Egg hatched! You got a ${getCreatureName(newCreature)}!`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
saveBuddyData(data)
|
||||
|
||||
// Trigger a post-pet reaction
|
||||
triggerCompanionReaction(context.messages ?? [], reaction =>
|
||||
setState?.(prev =>
|
||||
@@ -110,60 +118,111 @@ export async function call(
|
||||
),
|
||||
)
|
||||
|
||||
onDone(`petted ${companion.name}`, { display: 'system' })
|
||||
if (!data.eggs.find(isEggReadyToHatch)) {
|
||||
onDone(`petted ${getCreatureName(creature)} (+2 XP)`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show existing or hatch ──
|
||||
const companion = getCompanion()
|
||||
// ── /buddy rename — rename current creature ──
|
||||
if (sub.startsWith('rename ')) {
|
||||
const nickname = sub.slice(7).trim()
|
||||
if (!nickname) {
|
||||
onDone('Usage: /buddy rename <name>', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
const data = getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? { ...c, nickname } : c,
|
||||
)
|
||||
saveBuddyData(data)
|
||||
onDone(`renamed to "${nickname}"`, { display: 'system' })
|
||||
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
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
return null
|
||||
}
|
||||
const creature = data.creatures[num - 1]!
|
||||
data.activeCreatureId = creature.id
|
||||
saveBuddyData(data)
|
||||
onDone(`Switched to ${getCreatureName(creature)}!`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show unified BuddyPanel ──
|
||||
const data = getOrInitBuddyData()
|
||||
let creature = getActiveCreature(data)
|
||||
|
||||
// Auto-unmute when viewing
|
||||
if (companion && getGlobalConfig().companionMuted) {
|
||||
if (getGlobalConfig().companionMuted) {
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
}
|
||||
|
||||
if (companion) {
|
||||
// Return JSX card — matches official vc8 component
|
||||
const lastReaction = context.getAppState?.()?.companionReaction
|
||||
return React.createElement(CompanionCard, {
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
||||
})
|
||||
// No creature → initialize new one
|
||||
if (!creature) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
const migrated = migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(migrated)
|
||||
creature = getActiveCreature(migrated)!
|
||||
} else {
|
||||
const defaultData = getDefaultBuddyData()
|
||||
saveBuddyData(defaultData)
|
||||
creature = getActiveCreature(defaultData)!
|
||||
}
|
||||
}
|
||||
|
||||
// ── No companion → hatch ──
|
||||
const seed = generateSeed()
|
||||
const r = rollWithSeed(seed)
|
||||
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
|
||||
const personality =
|
||||
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
|
||||
|
||||
const stored: StoredCompanion = {
|
||||
name,
|
||||
personality,
|
||||
seed,
|
||||
hatchedAt: Date.now(),
|
||||
// Pre-fetch sprite if not cached
|
||||
const spriteCached = loadSprite(creature.speciesId)
|
||||
if (!spriteCached) {
|
||||
fetchAndCacheSprite(creature.speciesId).catch(() => {})
|
||||
}
|
||||
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
|
||||
const spriteLines =
|
||||
spriteCached?.lines ?? getFallbackSprite(creature.speciesId)
|
||||
|
||||
const stars = RARITY_STARS[r.bones.rarity]
|
||||
const sprite = renderSprite(r.bones, 0)
|
||||
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
|
||||
// Reload data to get latest state after possible initialization
|
||||
const latestData = loadBuddyData()
|
||||
|
||||
const lines = [
|
||||
'A wild companion appeared!',
|
||||
'',
|
||||
...sprite,
|
||||
'',
|
||||
`${name} the ${speciesLabel(r.bones.species)}${shiny}`,
|
||||
`Rarity: ${stars} (${r.bones.rarity})`,
|
||||
`"${personality}"`,
|
||||
'',
|
||||
'Your companion will now appear beside your input box!',
|
||||
'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off',
|
||||
]
|
||||
onDone(lines.join('\n'), { display: 'system' })
|
||||
return null
|
||||
return React.createElement(BuddyPanel, {
|
||||
buddyData: latestData,
|
||||
spriteLines,
|
||||
onClose: () => {
|
||||
onDone('buddy panel closed', { display: 'system' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
|
||||
const buddy = {
|
||||
type: 'local-jsx',
|
||||
name: 'buddy',
|
||||
description: 'Hatch a coding companion · pet, off',
|
||||
argumentHint: '[pet|off]',
|
||||
description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off',
|
||||
argumentHint: '[pet|dex|egg|switch|rename <name>|on|off]',
|
||||
immediate: true,
|
||||
get isHidden() {
|
||||
return !isBuddyLive()
|
||||
|
||||
Reference in New Issue
Block a user