claude-code-best/src/commands/buddy/BuddyPanel.tsx
2026-04-23 11:20:24 +08:00

1175 lines
40 KiB
TypeScript

import * as React from 'react';
import { useState } from 'react';
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 {
STAT_NAMES,
STAT_LABELS,
ALL_SPECIES_IDS,
type BuddyData,
type Creature,
type SpeciesId,
} 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, addToParty, swapPartySlots, removeFromParty, compactParty } from '@claude-code-best/pokemon';
import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite, fetchAndCacheSprite, SpeciesPicker } from '@claude-code-best/pokemon';
import type { 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');
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');
};
useKeybinding('confirm:no', handleEscape, {
context: 'Settings',
isActive: true,
});
// Tab / Shift+Tab to switch between tabs
const TAB_ORDER = ['Buddy', 'PC Box', 'Pokédex', 'Egg']
useInput((_input, key) => {
if (key.tab) {
setSelectedTab(prev => {
const idx = TAB_ORDER.indexOf(prev)
if (key.shift) {
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!
}
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]!
})
}
});
const updateData = (updated: BuddyData) => {
setData(updated);
saveBuddyData(updated);
setAppState(prev => ({ ...prev, companionCreatureChangedAt: Date.now() }));
};
const tabs = [
<Tab key="buddy" title="Buddy">
<PartyView data={data} onUpdate={updateData} isActive={selectedTab === 'Buddy'} />
</Tab>,
<Tab key="pc" title="PC Box">
<PcBoxTab data={data} onUpdate={updateData} isActive={selectedTab === 'PC Box'} />
</Tab>,
<Tab key="dex" title="Pokédex">
<DexTab
buddyData={data}
isActive={selectedTab === 'Pokédex'}
onUpdate={updateData}
onClose={() => onClose('buddy panel closed')}
/>
</Tab>,
<Tab key="egg" title="Egg">
<EggTab buddyData={data} />
</Tab>,
];
return (
<Pane color="permission">
<Tabs color="permission" selectedTab={selectedTab} onTabChange={setSelectedTab} disableNavigation={true}>
{tabs}
</Tabs>
</Pane>
);
}
// ─── Party View (replaces 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
const [spriteTick, setSpriteTick] = useState(0); // force re-render after sprite fetch
useInput((_input, key) => {
if (!isActive) return;
if (key.leftArrow) {
setFocusedSlot(prev => (prev > 0 ? prev - 1 : 5));
setTick(t => t + 1);
setStatusMsg(null);
} else if (key.rightArrow) {
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;
// Async-fetch sprite for focused creature if not cached
React.useEffect(() => {
if (!focusedCreature) return;
if (loadSprite(focusedCreature.speciesId)) return;
fetchAndCacheSprite(focusedCreature.speciesId).then((sprite) => {
if (sprite) setSpriteTick(t => t + 1);
});
}, [focusedCreature?.speciesId]);
// Also prefetch sprites for all party members on mount
React.useEffect(() => {
for (const id of data.party) {
if (!id) continue;
const c = data.creatures.find(cr => cr.id === id);
if (c && !loadSprite(c.speciesId)) {
fetchAndCacheSprite(c.speciesId).then((sprite) => {
if (sprite) setSpriteTick(t => t + 1);
});
}
}
}, []);
// Consume spriteTick to avoid unused warning
void spriteTick;
// 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>/ 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,
spriteLines,
isActive,
}: {
creature: Creature;
spriteLines?: string[];
isActive: boolean;
}) {
const species = getSpeciesData(creature.speciesId);
const stats = calculateStats(creature);
const xp = getXpProgress(creature);
const genderSymbol = getGenderSymbol(creature.gender);
const name = getCreatureName(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}>{getSpeciesData(nextEvo.to).name}</Text> Lv.
{nextEvo.minLevel}
</Text>
) : null;
return (
<Box flexDirection="column" alignItems="center">
<Box>
<Text bold color={CYAN}>
{name}
</Text>
<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>
<Text color={GRAY}>{species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
</Box>
{spriteLines && (
<Box marginY={0}>
<SpriteAnimator
lines={spriteLines}
color={creature.isShiny ? YELLOW : CYAN}
tickMs={500}
/>
</Box>
)}
{species.flavorText && (
<Box>
<Text color={GRAY} italic>
"{species.flavorText}"
</Text>
</Box>
)}
<Box flexDirection="column" marginTop={0}>
<Box>
<Box width={28}>
<Text color={GRAY}> Stats </Text>
</Box>
<Box>
<Text color={GRAY}> Base </Text>
</Box>
</Box>
{STAT_NAMES.map(stat => {
const baseVal = species.baseStats[stat];
const baseFilled = Math.round((baseVal / 130) * 12);
const ev = creature.ev[stat];
const evText = ev > 0 ? <Text color={GREEN}>({ev})</Text> : null;
return (
<Box key={stat}>
<Box width={28}>
<StatBar label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
{evText}
</Box>
<Box>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(baseFilled)}
{'░'.repeat(12 - baseFilled)}
</Text>
<Text> {String(baseVal).padStart(3)}</Text>
</Box>
</Box>
);
})}
</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" alignItems="center">
<Box>
<Text color={GRAY}>EV </Text>
<Text color={totalEV >= 510 ? GREEN : 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 ──────────────────────────────────────────
const BAR_WIDTH = 30
const MAX_VISIBLE_DEX = 15
const GEN_RANGES = [
{ label: 'Gen I', start: 1, end: 151 },
{ label: 'Gen II', start: 152, end: 251 },
{ label: 'Gen III', start: 252, end: 386 },
{ label: 'Gen IV', start: 387, end: 493 },
{ label: 'Gen V', start: 494, end: 649 },
{ label: 'Gen VI', start: 650, end: 721 },
{ label: 'Gen VII', start: 722, end: 809 },
{ label: 'Gen VIII',start: 810, end: 905 },
{ label: 'Gen IX', start: 906, end: 1025 },
]
function DexTab({
buddyData,
isActive,
onUpdate,
onClose,
}: {
buddyData: BuddyData;
isActive: boolean;
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 percent = total > 0 ? collected / total : 0;
const partySet = new Set(buddyData.party.filter((id): id is string => id !== null));
const [mode, setMode] = useState<'stats' | 'search' | 'detail'>('stats');
const [focusedId, setFocusedId] = useState<SpeciesId>(buddyData.dex[0]?.speciesId ?? 'bulbasaur');
const [dexCursor, setDexCursor] = useState(0);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [dexSpriteTick, setDexSpriteTick] = useState(0);
// Prefetch sprite for focused dex species
React.useEffect(() => {
if (!loadSprite(focusedId)) {
fetchAndCacheSprite(focusedId).then(s => { if (s) setDexSpriteTick(t => t + 1) });
}
}, [focusedId]);
void dexSpriteTick;
// Sorted discovered species
const discovered = buddyData.dex
.slice()
.sort((a, b) => getSpeciesData(a.speciesId).dexNumber - getSpeciesData(b.speciesId).dexNumber);
// Per-gen stats
const genStats = GEN_RANGES.map(g => {
const genSpecies = ALL_SPECIES_IDS.filter(id => {
const n = getSpeciesData(id).dexNumber
return n >= g.start && n <= g.end
})
const collectedNums = new Set(buddyData.dex.map(e => getSpeciesData(e.speciesId).dexNumber))
const genCollected = genSpecies.filter(id => collectedNums.has(getSpeciesData(id).dexNumber)).length
return { ...g, total: genSpecies.length, collected: genCollected }
})
// Input handling for stats & detail modes
useInput((_input, key) => {
if (!isActive) return;
if (mode === 'search') return; // SpeciesPicker handles its own input
if (mode === 'stats') {
if (_input.toLowerCase() === 's') {
setMode('search');
return;
}
if (key.upArrow) {
setDexCursor(prev => Math.max(0, prev - 1));
setStatusMsg(null);
return;
}
if (key.downArrow) {
setDexCursor(prev => Math.min(discovered.length - 1, prev + 1));
setStatusMsg(null);
return;
}
if (key.return && discovered.length > 0) {
const entry = discovered[dexCursor];
if (entry) {
setFocusedId(entry.speciesId);
setMode('detail');
setStatusMsg(null);
}
return;
}
if (key.escape) {
onClose();
return;
}
return;
}
if (mode === 'detail') {
if (_input.toLowerCase() === 's') {
setMode('search');
return;
}
if (key.escape) {
setMode('stats');
return;
}
if (key.return) {
handleAddToParty(focusedId);
return;
}
}
});
const handleAddToParty = (speciesId: SpeciesId) => {
const creature = buddyData.creatures.find(c => c.speciesId === speciesId);
if (!creature) return;
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!');
}
};
// ─── Search mode (SpeciesPicker) ───
if (mode === 'search') {
return (
<SpeciesPicker
onSelect={(speciesId) => {
setFocusedId(speciesId);
setMode('detail');
setStatusMsg(null);
}}
onCancel={() => setMode('stats')}
title="搜索精灵"
/>
);
}
// ─── Detail mode ───
if (mode === 'detail') {
const species = getSpeciesData(focusedId);
const entry = dexMap.get(focusedId);
const discovered_ = !!entry;
const owned = buddyData.creatures.find(c => c.speciesId === focusedId);
const inParty = owned ? partySet.has(owned.id) : false;
const sprite = discovered_ ? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId)) : null;
const maxBase = 130;
return (
<Box flexDirection="column">
<Box justifyContent="space-between">
<Text bold color={CYAN}>Pokédex Detail</Text>
<Text color={GRAY}>{collected}/{total} {(percent * 100).toFixed(1)}%</Text>
</Box>
{/* Sprite (centered, full width) */}
{discovered_ ? (
<>
{sprite && (
<Box flexDirection="column" alignItems="center">
{sprite.map((line, i) => <Text key={i} color={CYAN}>{line}</Text>)}
</Box>
)}
<Box justifyContent="center">
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text bold color={WHITE}>{species.name}</Text>
</Box>
<Box justifyContent="center">
{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.toUpperCase()}</Text>
</React.Fragment>
))}
<Text color={GRAY}> {getGenderInfoText(species.genderRate)}</Text>
</Box>
{/* Evolution chain */}
{(() => {
const chain = getChainFor(focusedId);
if (chain.length <= 1) return null;
return (
<Box flexDirection="column">
<Text color={GRAY}>Evolution:</Text>
<Box>
{chain.map((sid, i) => {
const next = getNextEvolution(sid);
return (
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === focusedId ? CYAN : GRAY} bold={sid === focusedId}>
{getSpeciesData(sid).name}
</Text>
{next && <Text color={GRAY}> Lv.{next.minLevel}</Text>}
</React.Fragment>
);
})}
</Box>
</Box>
);
})()}
</>
) : (
<Box flexDirection="column" alignItems="center" marginTop={1}>
<Text color={GRAY}>{' ??? '}</Text>
<Text color={GRAY}>{' / \\'}</Text>
<Text color={GRAY}>{' | ? |'}</Text>
<Text color={GRAY}>{' \\_/'}</Text>
<Text bold color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} ???</Text>
<Text color={GRAY} italic>Undiscovered species...</Text>
</Box>
)}
{discovered_ && species.flavorText && (
<Box>
<Text color={GRAY} italic>"{species.flavorText}"</Text>
</Box>
)}
{/* Bottom: base stats */}
{discovered_ && (
<Box flexDirection="column">
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map(stat => {
const val = species.baseStats[stat];
const filled = Math.round((val / maxBase) * 12);
return (
<Box key={stat}>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(filled)}{'░'.repeat(12 - filled)}
</Text>
<Text> {String(val).padStart(3)}</Text>
</Box>
);
})}
<Box>
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
<Text color={GRAY}>{'─'.repeat(12)}</Text>
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
</Box>
</Box>
)}
{/* Status */}
<Box marginTop={0}>
{statusMsg ? (
<Text color={GREEN} italic>{statusMsg}</Text>
) : owned ? (
inParty ? <Text color={GREEN}> In party</Text> : <Text color={CYAN}>[Enter] </Text>
) : (
<Text color={GRAY}>Not owned</Text>
)}
</Box>
<Box>
<Text color={GRAY}>[S] · [Esc] </Text>
</Box>
</Box>
);
}
// ─── Stats mode (default) ───
// Visible window of discovered species
const halfVis = Math.floor(MAX_VISIBLE_DEX / 2);
let startIdx = dexCursor - halfVis;
if (startIdx < 0) startIdx = 0;
if (startIdx + MAX_VISIBLE_DEX > discovered.length) startIdx = Math.max(0, discovered.length - MAX_VISIBLE_DEX);
const visibleDex = discovered.slice(startIdx, startIdx + MAX_VISIBLE_DEX);
return (
<Box flexDirection="column">
{/* Header */}
<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 bold color={GREEN}>{(percent * 100).toFixed(1)}%</Text>
</Text>
</Box>
{/* Fixed-width progress bar */}
<Box>
<Text color={GREEN}>{'█'.repeat(Math.round(percent * BAR_WIDTH))}</Text>
<Text color={GRAY}>{'░'.repeat(BAR_WIDTH - Math.round(percent * BAR_WIDTH))}</Text>
<Text> {Math.floor(percent * 100)}%</Text>
</Box>
<Box flexDirection="row">
{/* Left: discovered species list */}
<Box flexDirection="column" width={24}>
{discovered.length > 0 ? (
<>
{startIdx > 0 && <Text color={GRAY}> more</Text>}
{visibleDex.map((entry, vi) => {
const actualIdx = startIdx + vi;
const species = getSpeciesData(entry.speciesId);
const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === species.id);
const isCursor = actualIdx === dexCursor;
return (
<Box key={species.id}>
<Text color={isCursor ? GREEN : GRAY}>{isCursor ? ' ▸' : ' '}</Text>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={isCursor ? WHITE : GRAY} bold={inParty}>
{species.name}
</Text>
{inParty && <Text color={YELLOW}></Text>}
</Box>
);
})}
{startIdx + MAX_VISIBLE_DEX < discovered.length && (
<Text color={GRAY}> {discovered.length - startIdx - MAX_VISIBLE_DEX} more</Text>
)}
</>
) : (
<Text dimColor> No species discovered yet</Text>
)}
</Box>
{/* Divider */}
<Box flexDirection="column">
<Text color={GRAY}></Text>
</Box>
{/* Right: gen stats */}
<Box flexDirection="column" flexGrow={1} marginLeft={1}>
<Text color={GRAY}> </Text>
{genStats.map(g => {
const p = g.total > 0 ? g.collected / g.total : 0;
const miniBar = '█'.repeat(Math.round(p * 10)) + '░'.repeat(10 - Math.round(p * 10));
return (
<Box key={g.label}>
<Text color={GRAY}>{g.label.padEnd(8)}</Text>
<Text color={p >= 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}</Text>
<Text> <Text bold>{g.collected}</Text><Text color={GRAY}>/{g.total}</Text></Text>
</Box>
);
})}
</Box>
</Box>
{/* Footer */}
<Box marginTop={0}>
<Text color={GRAY}>Turns:{buddyData.stats.totalTurns} Days:{buddyData.stats.consecutiveDays} Eggs:{buddyData.stats.totalEggsObtained} Evos:{buddyData.stats.totalEvolutions}</Text>
</Box>
{buddyData.eggs.length > 0 && (
<Box><Text color={YELLOW}>🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps</Text></Box>
)}
<Box>
<Text color={CYAN}>[] · [Enter] · [S] · [Esc] </Text>
</Box>
</Box>
);
}
// ─── Egg Tab ──────────────────────────────────────────
function EggTab({ buddyData }: { buddyData: BuddyData }) {
const eggs = buddyData.eggs;
if (eggs.length === 0) {
// Include today in progress even if updateDailyStats hasn't run yet
const today = new Date().toISOString().split('T')[0];
const lastDate = buddyData.stats.lastActiveDate;
let effectiveDays = buddyData.stats.consecutiveDays;
if (lastDate !== today) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
effectiveDays = lastDate === yesterdayStr ? effectiveDays + 1 : 1;
}
const progress = Math.min(effectiveDays, EGG_REQUIRED_DAYS);
const filled = Math.round((progress / EGG_REQUIRED_DAYS) * 10);
const empty = 10 - filled;
const daysLeft = Math.max(0, EGG_REQUIRED_DAYS - effectiveDays);
return (
<Box flexDirection="column">
<Text bold color={CYAN}>
Egg
</Text>
<Text color={GRAY}>No egg currently. Keep coding!</Text>
<Box marginTop={0}>
<Text color={GRAY}>Egg progress </Text>
<Text color={progress >= EGG_REQUIRED_DAYS ? GREEN : YELLOW}>
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</Text>
<Text> {progress}/{EGG_REQUIRED_DAYS} days</Text>
</Box>
{daysLeft > 0 ? (
<Text color={GRAY}>Next egg: {daysLeft} more day{daysLeft > 1 ? 's' : ''}</Text>
) : (
<Text color={GREEN}>Ready! Keep coding to trigger an egg.</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>
);
}
// ─── PC Box Tab ──────────────────────────────────────
const BOX_COLS = 6
const BOX_SIZE = 30
const PARTY_SLOTS = 6
type Panel = 'party' | 'box'
function PcBoxTab({ data, onUpdate, isActive }: { data: BuddyData; onUpdate: (d: BuddyData) => void; isActive: boolean }) {
const [boxIdx, setBoxIdx] = useState(0)
const [panel, setPanel] = useState<Panel>('box')
const [partyCursor, setPartyCursor] = useState(0) // 0-5
const [boxCursor, setBoxCursor] = useState(0) // 0-29
const [held, setHeld] = useState<{ id: string; from: Panel; partySlot?: number; boxSlot?: number } | null>(null)
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const partySet = new Set(data.party.filter((id): id is string => id !== null))
const box = data.boxes[boxIdx]!
const boxRows = Math.ceil(BOX_SIZE / BOX_COLS)
// Currently selected creature
const selectedCreature = panel === 'party'
? (data.party[partyCursor] ? data.creatures.find(c => c.id === data.party[partyCursor]) ?? null : null)
: (box.slots[boxCursor] ? data.creatures.find(c => c.id === box.slots[boxCursor]) ?? null : null)
useInput((_input, key) => {
if (!isActive) return
// Switch box with ,/. regardless of panel
if (_input === ',' || _input === '<') {
setBoxIdx(prev => (prev - 1 + data.boxes.length) % data.boxes.length)
setStatusMsg(null)
return
}
if (_input === '.' || _input === '>') {
setBoxIdx(prev => (prev + 1) % data.boxes.length)
setStatusMsg(null)
return
}
if (panel === 'party') {
// Party: up/down navigate, left/right does nothing
if (key.upArrow) { setPartyCursor(prev => Math.max(0, prev - 1)); setStatusMsg(null); return }
if (key.downArrow) { setPartyCursor(prev => Math.min(PARTY_SLOTS - 1, prev + 1)); setStatusMsg(null); return }
if (key.rightArrow) { setPanel('box'); setStatusMsg(null); return }
if (_input === ' ') {
const slotId = data.party[partyCursor]
if (!held && !slotId) return
if (!held) {
// Pick up from party
setHeld({ id: slotId!, from: 'party', partySlot: partyCursor })
setStatusMsg(`Picked up ${getCreatureName(data.creatures.find(c => c.id === slotId!)!)}`)
return
}
// Place / swap into party slot
let updated = { ...data, party: [...data.party], boxes: data.boxes.map(b => ({ ...b, slots: [...b.slots] })) }
if (slotId) {
// Swap
updated.party[partyCursor] = held.id
if (held.from === 'party') {
updated.party[held.partySlot!] = slotId
} else {
updated.boxes[boxIdx]!.slots[held.boxSlot!] = slotId
}
} else {
// Empty party slot: place held
updated.party[partyCursor] = held.id
if (held.from === 'party') {
updated.party[held.partySlot!] = null
} else {
updated.boxes[boxIdx]!.slots[held.boxSlot!] = null
}
}
updated.party = compactParty(updated.party)
onUpdate(updated)
setHeld(null)
setStatusMsg('Done!')
return
}
// Cancel held
if (key.escape && held) { setHeld(null); setStatusMsg('Cancelled'); return }
return
}
// ─── Box panel ───
if (panel === 'box') {
if (key.leftArrow) {
if (boxCursor % BOX_COLS === 0) {
setPanel('party')
} else {
setBoxCursor(prev => prev - 1)
}
setStatusMsg(null)
return
}
if (key.rightArrow) {
setBoxCursor(prev => (prev % BOX_COLS === BOX_COLS - 1 ? prev : prev + 1))
setStatusMsg(null)
return
}
if (key.upArrow) {
setBoxCursor(prev => (prev < BOX_COLS ? prev + BOX_SIZE - BOX_COLS : prev - BOX_COLS))
setStatusMsg(null)
return
}
if (key.downArrow) {
setBoxCursor(prev => (prev >= BOX_SIZE - BOX_COLS ? prev - BOX_SIZE + BOX_COLS : prev + BOX_COLS))
setStatusMsg(null)
return
}
// Tab to party
if (_input.toLowerCase() === 'p') { setPanel('party'); setStatusMsg(null); return }
if (_input === ' ') {
const slotId = box.slots[boxCursor]
if (!held && !slotId) return
if (!held) {
// Pick up from box
setHeld({ id: slotId!, from: 'box', boxSlot: boxCursor })
setStatusMsg(`Picked up ${getCreatureName(data.creatures.find(c => c.id === slotId!)!)}`)
return
}
// Place / swap into box slot — direct slot manipulation
let updated = { ...data, party: [...data.party], boxes: data.boxes.map(b => ({ ...b, slots: [...b.slots] })) }
if (slotId) {
// Swap
updated.boxes[boxIdx]!.slots[boxCursor] = held.id
if (held.from === 'box') {
updated.boxes[boxIdx]!.slots[held.boxSlot!] = slotId
} else {
updated.party[held.partySlot!] = slotId
}
} else {
// Empty box slot: place held
if (held.from === 'party') {
const partyCount = updated.party.filter(Boolean).length
if (partyCount <= 1) {
setStatusMsg('Cannot deposit last party member!')
return
}
}
updated.boxes[boxIdx]!.slots[boxCursor] = held.id
if (held.from === 'box') {
updated.boxes[boxIdx]!.slots[held.boxSlot!] = null
} else {
updated.party[held.partySlot!] = null
}
}
updated.party = compactParty(updated.party)
onUpdate(updated)
setHeld(null)
setStatusMsg('Done!')
return
}
if (key.escape && held) { setHeld(null); setStatusMsg('Cancelled'); return }
if (key.escape && !held) { setPanel('party'); return }
}
})
return (
<Box flexDirection="column">
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>PC Box</Text>
{held && <Text color={YELLOW}> Carrying: {getCreatureName(data.creatures.find(c => c.id === held.id)!)}</Text>}
</Box>
<Text color={GRAY}>{box.name} ({boxIdx + 1}/{data.boxes.length})</Text>
</Box>
<Box flexDirection="row">
{/* Left: Party */}
<Box flexDirection="column" width={16} borderStyle="round" borderColor={panel === 'party' ? CYAN : GRAY} paddingX={1}>
<Text bold color={CYAN}>Party</Text>
{data.party.map((slotId, i) => {
const c = slotId ? data.creatures.find(cr => cr.id === slotId) : null
const isCursor = panel === 'party' && i === partyCursor
return (
<Box key={i}>
<Text color={isCursor ? CYAN : GRAY}>{isCursor ? '▸' : ' '}</Text>
{c ? (
<Text>
<Text color={i === 0 ? YELLOW : WHITE} bold={isCursor}>
{getCreatureName(c).length > 8 ? getCreatureName(c).slice(0, 7) + '..' : getCreatureName(c)}
</Text>
<Text color={GRAY}> {c.level}</Text>
{c.isShiny && <Text color={YELLOW}></Text>}
</Text>
) : (
<Text color={GRAY}>---</Text>
)}
</Box>
)
})}
<Text color={GRAY}>Total: {data.creatures.length}</Text>
</Box>
{/* Right: Box grid */}
<Box flexDirection="column" borderStyle="round" borderColor={panel === 'box' ? CYAN : GRAY} paddingX={1} flexGrow={1}>
{Array.from({ length: boxRows }, (_, row) => (
<Box key={row}>
{Array.from({ length: BOX_COLS }, (_, col) => {
const slotIdx = row * BOX_COLS + col
const slotId = box.slots[slotIdx]
const c = slotId ? data.creatures.find(cr => cr.id === slotId) : null
const isCursor = panel === 'box' && slotIdx === boxCursor
return (
<Box key={col} width={11}>
{c ? (
<Text color={isCursor ? CYAN : GRAY} bold={isCursor}>
{isCursor ? '[' : ' '}
{getCreatureName(c).length > 5 ? getCreatureName(c).slice(0, 4) + '.' : getCreatureName(c).padEnd(5)}
{isCursor ? ']' : ' '}
{String(c.level).padStart(2)}
</Text>
) : (
<Text color={isCursor ? CYAN : undefined}>{isCursor ? '[ --- ]' : ' ... '}</Text>
)}
</Box>
)
})}
</Box>
))}
</Box>
</Box>
{/* Selected creature info */}
{selectedCreature && (
<Box flexDirection="column">
<Text color={GRAY}> {getCreatureName(selectedCreature)} </Text>
<Box>
<Text color={CYAN}>Lv.{selectedCreature.level}</Text>
<Text color={GRAY}> {getSpeciesData(selectedCreature.speciesId).name}</Text>
<Text color={GRAY}> {getGenderSymbol(selectedCreature.gender)}</Text>
{selectedCreature.isShiny && <Text color={YELLOW}> SHINY</Text>}
{partySet.has(selectedCreature.id) && <Text color={GREEN}> Party</Text>}
</Box>
</Box>
)}
{statusMsg && (
<Box><Text color={GREEN} italic>{statusMsg}</Text></Box>
)}
<Box>
<Text color={GRAY}>[Space] / · [Esc] / · [,/.] · [Tab] </Text>
</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 getGenderInfoText(genderRate: number): string {
if (genderRate === -1) return 'Genderless';
if (genderRate === 0) return '♂ 100%';
if (genderRate === 8) return '♀ 100%';
return `${(genderRate / 8) * 100}%`;
}
/** Build full evolution chain for a species by walking backwards then forwards */
function getChainFor(speciesId: SpeciesId): SpeciesId[] {
const chain: SpeciesId[] = []
// Walk backwards to find the base form
let current: SpeciesId | undefined = speciesId
const visited = new Set<string>()
while (current) {
if (visited.has(current)) break
visited.add(current)
chain.unshift(current)
// Find pre-evolution
const dex = getSpeciesData(current)
// Check if any species evolves into current
const preEvo = ALL_SPECIES_IDS.find(id => {
const next = getNextEvolution(id)
return next?.to === current
})
if (preEvo) {
current = preEvo
} else {
break
}
}
// Walk forwards from each node to find branches
const fullChain: SpeciesId[] = [...chain]
for (const sid of [...chain]) {
const next = getNextEvolution(sid)
if (next && !fullChain.includes(next.to)) {
fullChain.push(next.to)
}
}
return fullChain
}