Spaces:
Sleeping
Sleeping
Claw Web commited on
Commit ·
0544bf7
1
Parent(s): 49cbb33
feat: Full Buddy companion pet system (6 modules matching original)
Browse files- buddy/types.ts: Full type system (BuddyState, BuddyStats, evolution stages, XP rewards)
- buddy/sprites.ts: Sprite configs for 6 evolution stages x 10 moods
- buddy/companion.ts: State management, leveling, XP, evolution, persistence, decay
- buddy/prompt.ts: Personality, context-aware messages, 17 achievements
- buddy/useBuddyNotification.tsx: React hook for state/notifications/events
- buddy/index.ts: Barrel export
- BuddySprite.tsx: Full UI with stats panel, achievements, notifications, interactions
- Home.tsx: Integrated useBuddy hook with full props
- client/src/buddy/companion.ts +376 -0
- client/src/buddy/index.ts +7 -0
- client/src/buddy/prompt.ts +283 -0
- client/src/buddy/sprites.ts +164 -0
- client/src/buddy/types.ts +108 -0
- client/src/buddy/useBuddyNotification.tsx +161 -0
- client/src/components/BuddySprite.tsx +347 -67
- client/src/pages/Home.tsx +13 -2
client/src/buddy/companion.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/companion.ts — Matches original buddy/companion.ts ────────────
|
| 2 |
+
// State management, leveling, XP, evolution, persistence
|
| 3 |
+
|
| 4 |
+
import type {
|
| 5 |
+
BuddyState,
|
| 6 |
+
BuddyMood,
|
| 7 |
+
BuddyStats,
|
| 8 |
+
BuddyNotification,
|
| 9 |
+
BuddyAchievement,
|
| 10 |
+
} from "./types";
|
| 11 |
+
import { xpForLevel, stageForLevel, XP_REWARDS } from "./types";
|
| 12 |
+
import { ACHIEVEMENTS } from "./prompt";
|
| 13 |
+
|
| 14 |
+
const STORAGE_KEY = "claw-buddy-state";
|
| 15 |
+
const NOTIFICATIONS_KEY = "claw-buddy-notifications";
|
| 16 |
+
|
| 17 |
+
// ─── Default state ─────────────────────────────────────────────────────────
|
| 18 |
+
|
| 19 |
+
function defaultStats(): BuddyStats {
|
| 20 |
+
return {
|
| 21 |
+
level: 1,
|
| 22 |
+
xp: 0,
|
| 23 |
+
xpToNextLevel: xpForLevel(1),
|
| 24 |
+
totalToolCalls: 0,
|
| 25 |
+
totalSessions: 0,
|
| 26 |
+
totalFilesCreated: 0,
|
| 27 |
+
totalLinesWritten: 0,
|
| 28 |
+
totalBugsFixed: 0,
|
| 29 |
+
totalTestsPassed: 0,
|
| 30 |
+
streak: 0,
|
| 31 |
+
longestStreak: 0,
|
| 32 |
+
favoriteLanguage: "unknown",
|
| 33 |
+
languageStats: {},
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function defaultState(): BuddyState {
|
| 38 |
+
const now = Date.now();
|
| 39 |
+
return {
|
| 40 |
+
name: "Buddy",
|
| 41 |
+
mood: "idle",
|
| 42 |
+
stage: "egg",
|
| 43 |
+
stats: defaultStats(),
|
| 44 |
+
happiness: 80,
|
| 45 |
+
energy: 100,
|
| 46 |
+
hunger: 100,
|
| 47 |
+
lastInteraction: now,
|
| 48 |
+
lastFed: now,
|
| 49 |
+
createdAt: now,
|
| 50 |
+
achievements: [],
|
| 51 |
+
outfit: "default",
|
| 52 |
+
accessories: [],
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// ─── Persistence ───────────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
export function loadBuddyState(): BuddyState {
|
| 59 |
+
try {
|
| 60 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 61 |
+
if (raw) {
|
| 62 |
+
const parsed = JSON.parse(raw) as BuddyState;
|
| 63 |
+
// Validate and migrate if needed
|
| 64 |
+
if (!parsed.stats) parsed.stats = defaultStats();
|
| 65 |
+
if (!parsed.achievements) parsed.achievements = [];
|
| 66 |
+
if (parsed.happiness === undefined) parsed.happiness = 80;
|
| 67 |
+
if (parsed.energy === undefined) parsed.energy = 100;
|
| 68 |
+
if (parsed.hunger === undefined) parsed.hunger = 100;
|
| 69 |
+
return parsed;
|
| 70 |
+
}
|
| 71 |
+
} catch {
|
| 72 |
+
// Corrupted state, reset
|
| 73 |
+
}
|
| 74 |
+
return defaultState();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function saveBuddyState(state: BuddyState): void {
|
| 78 |
+
try {
|
| 79 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
| 80 |
+
} catch {
|
| 81 |
+
// Storage full or unavailable
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export function loadNotifications(): BuddyNotification[] {
|
| 86 |
+
try {
|
| 87 |
+
const raw = localStorage.getItem(NOTIFICATIONS_KEY);
|
| 88 |
+
return raw ? JSON.parse(raw) : [];
|
| 89 |
+
} catch {
|
| 90 |
+
return [];
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function saveNotifications(notifications: BuddyNotification[]): void {
|
| 95 |
+
try {
|
| 96 |
+
// Keep only last 50 notifications
|
| 97 |
+
const trimmed = notifications.slice(-50);
|
| 98 |
+
localStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify(trimmed));
|
| 99 |
+
} catch {}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ─── XP and Leveling ───────────────────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
export function addXp(
|
| 105 |
+
state: BuddyState,
|
| 106 |
+
amount: number,
|
| 107 |
+
reason: string
|
| 108 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 109 |
+
const notifications: BuddyNotification[] = [];
|
| 110 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 111 |
+
|
| 112 |
+
newState.stats.xp += amount;
|
| 113 |
+
newState.lastInteraction = Date.now();
|
| 114 |
+
|
| 115 |
+
// Level up check
|
| 116 |
+
while (newState.stats.xp >= newState.stats.xpToNextLevel) {
|
| 117 |
+
newState.stats.xp -= newState.stats.xpToNextLevel;
|
| 118 |
+
newState.stats.level += 1;
|
| 119 |
+
newState.stats.xpToNextLevel = xpForLevel(newState.stats.level);
|
| 120 |
+
|
| 121 |
+
// Check evolution
|
| 122 |
+
const newStage = stageForLevel(newState.stats.level);
|
| 123 |
+
const evolved = newStage !== newState.stage;
|
| 124 |
+
newState.stage = newStage;
|
| 125 |
+
|
| 126 |
+
notifications.push({
|
| 127 |
+
id: `levelup-${newState.stats.level}-${Date.now()}`,
|
| 128 |
+
type: "levelup",
|
| 129 |
+
title: `Level Up! 🎉`,
|
| 130 |
+
message: `Buddy reached level ${newState.stats.level}!`,
|
| 131 |
+
timestamp: Date.now(),
|
| 132 |
+
dismissed: false,
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
if (evolved) {
|
| 136 |
+
notifications.push({
|
| 137 |
+
id: `evolution-${newStage}-${Date.now()}`,
|
| 138 |
+
type: "evolution",
|
| 139 |
+
title: `Evolution! 🌟`,
|
| 140 |
+
message: `Buddy evolved to ${newStage}!`,
|
| 141 |
+
icon: "🌟",
|
| 142 |
+
timestamp: Date.now(),
|
| 143 |
+
dismissed: false,
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Happiness boost on level up
|
| 148 |
+
newState.happiness = Math.min(100, newState.happiness + 10);
|
| 149 |
+
newState.energy = Math.min(100, newState.energy + 5);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Check achievements
|
| 153 |
+
const newAchievements = checkAchievements(newState);
|
| 154 |
+
for (const achievement of newAchievements) {
|
| 155 |
+
if (!newState.achievements.includes(achievement.id)) {
|
| 156 |
+
newState.achievements.push(achievement.id);
|
| 157 |
+
notifications.push({
|
| 158 |
+
id: `achievement-${achievement.id}-${Date.now()}`,
|
| 159 |
+
type: "achievement",
|
| 160 |
+
title: `Achievement Unlocked! 🏆`,
|
| 161 |
+
message: `${achievement.icon} ${achievement.name}: ${achievement.description}`,
|
| 162 |
+
icon: achievement.icon,
|
| 163 |
+
timestamp: Date.now(),
|
| 164 |
+
dismissed: false,
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return { state: newState, notifications };
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ─── Event handlers ────────────────────────────────────────────────────────
|
| 173 |
+
|
| 174 |
+
export function onToolCall(
|
| 175 |
+
state: BuddyState,
|
| 176 |
+
toolName: string
|
| 177 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 178 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 179 |
+
newState.stats.totalToolCalls += 1;
|
| 180 |
+
|
| 181 |
+
// Detect language from tool context
|
| 182 |
+
if (toolName === "write_file" || toolName === "edit_file") {
|
| 183 |
+
newState.stats.totalFilesCreated += 1;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
return addXp(newState, XP_REWARDS.tool_call, `tool: ${toolName}`);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
export function onFileCreated(
|
| 190 |
+
state: BuddyState,
|
| 191 |
+
filePath: string,
|
| 192 |
+
lines: number
|
| 193 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 194 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 195 |
+
newState.stats.totalFilesCreated += 1;
|
| 196 |
+
newState.stats.totalLinesWritten += lines;
|
| 197 |
+
|
| 198 |
+
// Detect language
|
| 199 |
+
const ext = filePath.split(".").pop()?.toLowerCase() || "unknown";
|
| 200 |
+
const langMap: Record<string, string> = {
|
| 201 |
+
ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript",
|
| 202 |
+
py: "Python", rs: "Rust", go: "Go", java: "Java", cpp: "C++", c: "C",
|
| 203 |
+
rb: "Ruby", php: "PHP", swift: "Swift", kt: "Kotlin", cs: "C#",
|
| 204 |
+
};
|
| 205 |
+
const lang = langMap[ext] || ext;
|
| 206 |
+
newState.stats.languageStats[lang] = (newState.stats.languageStats[lang] || 0) + lines;
|
| 207 |
+
|
| 208 |
+
// Update favorite language
|
| 209 |
+
let maxLines = 0;
|
| 210 |
+
for (const [l, count] of Object.entries(newState.stats.languageStats)) {
|
| 211 |
+
if (count > maxLines) {
|
| 212 |
+
maxLines = count;
|
| 213 |
+
newState.stats.favoriteLanguage = l;
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
let xp = XP_REWARDS.file_created;
|
| 218 |
+
// Bonus XP for milestones
|
| 219 |
+
if (newState.stats.totalLinesWritten >= 1000 && state.stats.totalLinesWritten < 1000) {
|
| 220 |
+
xp += XP_REWARDS.lines_1000;
|
| 221 |
+
} else if (newState.stats.totalLinesWritten >= 100 && state.stats.totalLinesWritten < 100) {
|
| 222 |
+
xp += XP_REWARDS.lines_100;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
return addXp(newState, xp, `file: ${filePath}`);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export function onSessionCompleted(
|
| 229 |
+
state: BuddyState
|
| 230 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 231 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 232 |
+
newState.stats.totalSessions += 1;
|
| 233 |
+
|
| 234 |
+
let xp = XP_REWARDS.session_completed;
|
| 235 |
+
if (newState.stats.totalSessions === 1) {
|
| 236 |
+
xp += XP_REWARDS.first_session;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
return addXp(newState, xp, "session completed");
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
export function onBugFixed(
|
| 243 |
+
state: BuddyState
|
| 244 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 245 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 246 |
+
newState.stats.totalBugsFixed += 1;
|
| 247 |
+
return addXp(newState, XP_REWARDS.bug_fixed, "bug fixed");
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
export function onTestPassed(
|
| 251 |
+
state: BuddyState
|
| 252 |
+
): { state: BuddyState; notifications: BuddyNotification[] } {
|
| 253 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 254 |
+
newState.stats.totalTestsPassed += 1;
|
| 255 |
+
return addXp(newState, XP_REWARDS.test_passed, "test passed");
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// ─── Mood calculation ──────────────────────────────────────────────────────
|
| 259 |
+
|
| 260 |
+
export function calculateMood(
|
| 261 |
+
state: BuddyState,
|
| 262 |
+
isStreaming: boolean,
|
| 263 |
+
hasMessages: boolean
|
| 264 |
+
): BuddyMood {
|
| 265 |
+
const now = Date.now();
|
| 266 |
+
const idleMinutes = (now - state.lastInteraction) / 60000;
|
| 267 |
+
|
| 268 |
+
// Working state takes priority
|
| 269 |
+
if (isStreaming) return "working";
|
| 270 |
+
|
| 271 |
+
// Sleeping after 30 min idle
|
| 272 |
+
if (idleMinutes > 30) return "sleeping";
|
| 273 |
+
|
| 274 |
+
// Tired after 15 min idle
|
| 275 |
+
if (idleMinutes > 15) return "tired";
|
| 276 |
+
|
| 277 |
+
// Low happiness = confused
|
| 278 |
+
if (state.happiness < 30) return "confused";
|
| 279 |
+
|
| 280 |
+
// Low energy = tired
|
| 281 |
+
if (state.energy < 20) return "tired";
|
| 282 |
+
|
| 283 |
+
// Recent level up = celebrating
|
| 284 |
+
// (checked via notifications externally)
|
| 285 |
+
|
| 286 |
+
// Has messages = happy
|
| 287 |
+
if (hasMessages) return "happy";
|
| 288 |
+
|
| 289 |
+
return "idle";
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// ─── Decay over time ───────────────────────────────────────────────────────
|
| 293 |
+
|
| 294 |
+
export function applyTimeDecay(state: BuddyState): BuddyState {
|
| 295 |
+
const now = Date.now();
|
| 296 |
+
const hoursSinceInteraction = (now - state.lastInteraction) / 3600000;
|
| 297 |
+
const hoursSinceFed = (now - state.lastFed) / 3600000;
|
| 298 |
+
|
| 299 |
+
const newState = { ...state };
|
| 300 |
+
|
| 301 |
+
// Hunger decreases over time (1 point per hour)
|
| 302 |
+
newState.hunger = Math.max(0, 100 - Math.floor(hoursSinceFed * 1));
|
| 303 |
+
|
| 304 |
+
// Happiness decreases if hungry or idle (0.5 per hour)
|
| 305 |
+
if (newState.hunger < 30 || hoursSinceInteraction > 4) {
|
| 306 |
+
newState.happiness = Math.max(0, state.happiness - Math.floor(hoursSinceInteraction * 0.5));
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Energy regenerates when sleeping (idle), decreases when working
|
| 310 |
+
if (hoursSinceInteraction > 1) {
|
| 311 |
+
newState.energy = Math.min(100, state.energy + Math.floor(hoursSinceInteraction * 2));
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
return newState;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
export function feedBuddy(state: BuddyState): BuddyState {
|
| 318 |
+
return {
|
| 319 |
+
...state,
|
| 320 |
+
hunger: 100,
|
| 321 |
+
happiness: Math.min(100, state.happiness + 15),
|
| 322 |
+
energy: Math.min(100, state.energy + 10),
|
| 323 |
+
lastFed: Date.now(),
|
| 324 |
+
lastInteraction: Date.now(),
|
| 325 |
+
};
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
export function petBuddy(state: BuddyState): BuddyState {
|
| 329 |
+
return {
|
| 330 |
+
...state,
|
| 331 |
+
happiness: Math.min(100, state.happiness + 10),
|
| 332 |
+
lastInteraction: Date.now(),
|
| 333 |
+
};
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
// ─── Achievements ──────────────────────────────────────────────────────────
|
| 337 |
+
|
| 338 |
+
function checkAchievements(state: BuddyState): BuddyAchievement[] {
|
| 339 |
+
return ACHIEVEMENTS.filter(
|
| 340 |
+
(a) => !state.achievements.includes(a.id) && a.condition(state.stats)
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// ─── Streak management ─────────────────────────────────────────────────────
|
| 345 |
+
|
| 346 |
+
export function updateStreak(state: BuddyState): BuddyState {
|
| 347 |
+
const now = new Date();
|
| 348 |
+
const lastDate = new Date(state.lastInteraction);
|
| 349 |
+
|
| 350 |
+
const isNewDay =
|
| 351 |
+
now.getFullYear() !== lastDate.getFullYear() ||
|
| 352 |
+
now.getMonth() !== lastDate.getMonth() ||
|
| 353 |
+
now.getDate() !== lastDate.getDate();
|
| 354 |
+
|
| 355 |
+
if (!isNewDay) return state;
|
| 356 |
+
|
| 357 |
+
const diffDays = Math.floor(
|
| 358 |
+
(now.getTime() - lastDate.getTime()) / 86400000
|
| 359 |
+
);
|
| 360 |
+
|
| 361 |
+
const newState = { ...state, stats: { ...state.stats } };
|
| 362 |
+
|
| 363 |
+
if (diffDays === 1) {
|
| 364 |
+
// Consecutive day
|
| 365 |
+
newState.stats.streak += 1;
|
| 366 |
+
newState.stats.longestStreak = Math.max(
|
| 367 |
+
newState.stats.longestStreak,
|
| 368 |
+
newState.stats.streak
|
| 369 |
+
);
|
| 370 |
+
} else if (diffDays > 1) {
|
| 371 |
+
// Streak broken
|
| 372 |
+
newState.stats.streak = 1;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
return newState;
|
| 376 |
+
}
|
client/src/buddy/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/index.ts — Barrel export ──────────────────────────────────────
|
| 2 |
+
export * from "./types";
|
| 3 |
+
export * from "./sprites";
|
| 4 |
+
export * from "./companion";
|
| 5 |
+
export * from "./prompt";
|
| 6 |
+
export { useBuddy } from "./useBuddyNotification";
|
| 7 |
+
export type { UseBuddyReturn } from "./useBuddyNotification";
|
client/src/buddy/prompt.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/prompt.ts — Matches original buddy/prompt.ts ──────────────────
|
| 2 |
+
// Personality, context-aware responses, achievements definitions
|
| 3 |
+
|
| 4 |
+
import type { BuddyAchievement, BuddyEvolutionStage, BuddyMood, BuddyStats } from "./types";
|
| 5 |
+
|
| 6 |
+
// ─── Context-aware messages per mood ───────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
export const MOOD_MESSAGES: Record<BuddyMood, string[]> = {
|
| 9 |
+
idle: [
|
| 10 |
+
"Ready to code! 🚀",
|
| 11 |
+
"What shall we build?",
|
| 12 |
+
"I'm here to help!",
|
| 13 |
+
"Need anything?",
|
| 14 |
+
"Let's make something awesome!",
|
| 15 |
+
"Waiting for your command...",
|
| 16 |
+
"Got any bugs to squash? 🐛",
|
| 17 |
+
],
|
| 18 |
+
thinking: [
|
| 19 |
+
"Hmm, let me think...",
|
| 20 |
+
"Processing...",
|
| 21 |
+
"Analyzing the code...",
|
| 22 |
+
"Working on it...",
|
| 23 |
+
"Almost there...",
|
| 24 |
+
],
|
| 25 |
+
happy: [
|
| 26 |
+
"Great work! 🎉",
|
| 27 |
+
"That looks awesome!",
|
| 28 |
+
"We're on fire! 🔥",
|
| 29 |
+
"Keep it up!",
|
| 30 |
+
"Nailed it! 💪",
|
| 31 |
+
],
|
| 32 |
+
working: [
|
| 33 |
+
"On it! ⚡",
|
| 34 |
+
"Executing...",
|
| 35 |
+
"Working hard!",
|
| 36 |
+
"Building...",
|
| 37 |
+
"Almost done!",
|
| 38 |
+
],
|
| 39 |
+
sleeping: [
|
| 40 |
+
"zzz...",
|
| 41 |
+
"*snore*",
|
| 42 |
+
"💤",
|
| 43 |
+
"dreaming of code...",
|
| 44 |
+
],
|
| 45 |
+
excited: [
|
| 46 |
+
"WOW! 🤩",
|
| 47 |
+
"This is amazing!",
|
| 48 |
+
"I can't wait!",
|
| 49 |
+
"Let's GOOO! 🚀",
|
| 50 |
+
"SO COOL!",
|
| 51 |
+
],
|
| 52 |
+
confused: [
|
| 53 |
+
"Hmm, that's odd...",
|
| 54 |
+
"Not sure about this...",
|
| 55 |
+
"Let me check again...",
|
| 56 |
+
"Something's off...",
|
| 57 |
+
],
|
| 58 |
+
proud: [
|
| 59 |
+
"Look what we built! ✨",
|
| 60 |
+
"We're getting better!",
|
| 61 |
+
"Level up! 🎮",
|
| 62 |
+
"Achievement unlocked!",
|
| 63 |
+
],
|
| 64 |
+
tired: [
|
| 65 |
+
"Need a break...",
|
| 66 |
+
"Getting sleepy...",
|
| 67 |
+
"Maybe some coffee? ☕",
|
| 68 |
+
"*yawn*",
|
| 69 |
+
],
|
| 70 |
+
celebrating: [
|
| 71 |
+
"WOOHOO! 🎊",
|
| 72 |
+
"PARTY TIME! 🥳",
|
| 73 |
+
"WE DID IT! 🏆",
|
| 74 |
+
"LEGENDARY! 🌟",
|
| 75 |
+
],
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
// ─── Stage-specific greetings ──────────────────────────────────────────────
|
| 79 |
+
|
| 80 |
+
export const STAGE_GREETINGS: Record<BuddyEvolutionStage, string[]> = {
|
| 81 |
+
egg: [
|
| 82 |
+
"I'm still growing...",
|
| 83 |
+
"Almost ready to hatch!",
|
| 84 |
+
"Keep coding, I'll hatch soon!",
|
| 85 |
+
],
|
| 86 |
+
baby: [
|
| 87 |
+
"Hi! I'm learning!",
|
| 88 |
+
"What does this code do?",
|
| 89 |
+
"I'm so excited to help!",
|
| 90 |
+
],
|
| 91 |
+
junior: [
|
| 92 |
+
"Ready for action!",
|
| 93 |
+
"I know a few tricks now!",
|
| 94 |
+
"Let's build something cool!",
|
| 95 |
+
],
|
| 96 |
+
senior: [
|
| 97 |
+
"I've seen a lot of code.",
|
| 98 |
+
"Trust the process.",
|
| 99 |
+
"Clean code is happy code.",
|
| 100 |
+
],
|
| 101 |
+
master: [
|
| 102 |
+
"The code flows through me.",
|
| 103 |
+
"I sense a bug nearby...",
|
| 104 |
+
"Let's architect something great.",
|
| 105 |
+
],
|
| 106 |
+
legendary: [
|
| 107 |
+
"I am one with the codebase.",
|
| 108 |
+
"All bugs tremble before us.",
|
| 109 |
+
"We've transcended mere coding.",
|
| 110 |
+
],
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// ─── Level up messages ─────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
export function getLevelUpMessage(level: number): string {
|
| 116 |
+
if (level <= 5) return `Level ${level}! Keep going! 🌱`;
|
| 117 |
+
if (level <= 10) return `Level ${level}! Getting stronger! 💪`;
|
| 118 |
+
if (level <= 20) return `Level ${level}! You're a pro! ⚡`;
|
| 119 |
+
if (level <= 50) return `Level ${level}! Master coder! 👑`;
|
| 120 |
+
return `Level ${level}! LEGENDARY! 🌟`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function getEvolutionMessage(stage: BuddyEvolutionStage): string {
|
| 124 |
+
const messages: Record<BuddyEvolutionStage, string> = {
|
| 125 |
+
egg: "A mysterious egg appeared...",
|
| 126 |
+
baby: "The egg hatched! A baby coder is born! 🐣",
|
| 127 |
+
junior: "Buddy evolved into a Junior Developer! 🌱",
|
| 128 |
+
senior: "Buddy evolved into a Senior Developer! ⚡",
|
| 129 |
+
master: "Buddy evolved into a Master! 👑",
|
| 130 |
+
legendary: "Buddy achieved LEGENDARY status! 🌟✨",
|
| 131 |
+
};
|
| 132 |
+
return messages[stage];
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// ─── Achievements ──────────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
export const ACHIEVEMENTS: BuddyAchievement[] = [
|
| 138 |
+
{
|
| 139 |
+
id: "first_steps",
|
| 140 |
+
name: "First Steps",
|
| 141 |
+
description: "Complete your first session",
|
| 142 |
+
icon: "👣",
|
| 143 |
+
condition: (s: BuddyStats) => s.totalSessions >= 1,
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: "tool_master",
|
| 147 |
+
name: "Tool Master",
|
| 148 |
+
description: "Use 100 tool calls",
|
| 149 |
+
icon: "🔧",
|
| 150 |
+
condition: (s: BuddyStats) => s.totalToolCalls >= 100,
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
id: "file_creator",
|
| 154 |
+
name: "File Creator",
|
| 155 |
+
description: "Create 10 files",
|
| 156 |
+
icon: "📄",
|
| 157 |
+
condition: (s: BuddyStats) => s.totalFilesCreated >= 10,
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
id: "prolific_writer",
|
| 161 |
+
name: "Prolific Writer",
|
| 162 |
+
description: "Write 1,000 lines of code",
|
| 163 |
+
icon: "✍️",
|
| 164 |
+
condition: (s: BuddyStats) => s.totalLinesWritten >= 1000,
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
id: "novelist",
|
| 168 |
+
name: "Code Novelist",
|
| 169 |
+
description: "Write 10,000 lines of code",
|
| 170 |
+
icon: "📚",
|
| 171 |
+
condition: (s: BuddyStats) => s.totalLinesWritten >= 10000,
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
id: "bug_hunter",
|
| 175 |
+
name: "Bug Hunter",
|
| 176 |
+
description: "Fix 5 bugs",
|
| 177 |
+
icon: "🐛",
|
| 178 |
+
condition: (s: BuddyStats) => s.totalBugsFixed >= 5,
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
id: "exterminator",
|
| 182 |
+
name: "Exterminator",
|
| 183 |
+
description: "Fix 50 bugs",
|
| 184 |
+
icon: "🔫",
|
| 185 |
+
condition: (s: BuddyStats) => s.totalBugsFixed >= 50,
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
id: "test_lover",
|
| 189 |
+
name: "Test Lover",
|
| 190 |
+
description: "Pass 10 tests",
|
| 191 |
+
icon: "✅",
|
| 192 |
+
condition: (s: BuddyStats) => s.totalTestsPassed >= 10,
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
id: "streak_3",
|
| 196 |
+
name: "Consistent",
|
| 197 |
+
description: "3-day coding streak",
|
| 198 |
+
icon: "🔥",
|
| 199 |
+
condition: (s: BuddyStats) => s.streak >= 3,
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
id: "streak_7",
|
| 203 |
+
name: "Dedicated",
|
| 204 |
+
description: "7-day coding streak",
|
| 205 |
+
icon: "🔥🔥",
|
| 206 |
+
condition: (s: BuddyStats) => s.streak >= 7,
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
id: "streak_30",
|
| 210 |
+
name: "Unstoppable",
|
| 211 |
+
description: "30-day coding streak",
|
| 212 |
+
icon: "🔥🔥🔥",
|
| 213 |
+
condition: (s: BuddyStats) => s.streak >= 30,
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
id: "level_10",
|
| 217 |
+
name: "Rising Star",
|
| 218 |
+
description: "Reach level 10",
|
| 219 |
+
icon: "⭐",
|
| 220 |
+
condition: (s: BuddyStats) => s.level >= 10,
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
id: "level_25",
|
| 224 |
+
name: "Veteran",
|
| 225 |
+
description: "Reach level 25",
|
| 226 |
+
icon: "🌟",
|
| 227 |
+
condition: (s: BuddyStats) => s.level >= 25,
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
id: "level_50",
|
| 231 |
+
name: "Legend",
|
| 232 |
+
description: "Reach level 50",
|
| 233 |
+
icon: "👑",
|
| 234 |
+
condition: (s: BuddyStats) => s.level >= 50,
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
id: "polyglot",
|
| 238 |
+
name: "Polyglot",
|
| 239 |
+
description: "Write code in 5+ languages",
|
| 240 |
+
icon: "🌍",
|
| 241 |
+
condition: (s: BuddyStats) => Object.keys(s.languageStats).length >= 5,
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
id: "sessions_50",
|
| 245 |
+
name: "Regular",
|
| 246 |
+
description: "Complete 50 sessions",
|
| 247 |
+
icon: "📊",
|
| 248 |
+
condition: (s: BuddyStats) => s.totalSessions >= 50,
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
id: "tool_calls_1000",
|
| 252 |
+
name: "Power User",
|
| 253 |
+
description: "Use 1,000 tool calls",
|
| 254 |
+
icon: "⚡",
|
| 255 |
+
condition: (s: BuddyStats) => s.totalToolCalls >= 1000,
|
| 256 |
+
},
|
| 257 |
+
];
|
| 258 |
+
|
| 259 |
+
// ─── Random message picker ─────────────────────────────────────────────────
|
| 260 |
+
|
| 261 |
+
export function getRandomMessage(mood: BuddyMood): string {
|
| 262 |
+
const messages = MOOD_MESSAGES[mood] || MOOD_MESSAGES.idle;
|
| 263 |
+
return messages[Math.floor(Math.random() * messages.length)];
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
export function getRandomGreeting(stage: BuddyEvolutionStage): string {
|
| 267 |
+
const greetings = STAGE_GREETINGS[stage] || STAGE_GREETINGS.baby;
|
| 268 |
+
return greetings[Math.floor(Math.random() * greetings.length)];
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// ─── Stats summary for display ─────────────────────────────────────────────
|
| 272 |
+
|
| 273 |
+
export function formatStatsSummary(stats: BuddyStats): string {
|
| 274 |
+
return [
|
| 275 |
+
`Level ${stats.level} (${stats.xp}/${stats.xpToNextLevel} XP)`,
|
| 276 |
+
`${stats.totalToolCalls} tool calls`,
|
| 277 |
+
`${stats.totalFilesCreated} files created`,
|
| 278 |
+
`${stats.totalLinesWritten.toLocaleString()} lines written`,
|
| 279 |
+
`${stats.totalBugsFixed} bugs fixed`,
|
| 280 |
+
`${stats.streak}-day streak (best: ${stats.longestStreak})`,
|
| 281 |
+
`Favorite: ${stats.favoriteLanguage}`,
|
| 282 |
+
].join(" | ");
|
| 283 |
+
}
|
client/src/buddy/sprites.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/sprites.ts — Matches original buddy/sprites.ts ────────────────
|
| 2 |
+
// Sprite definitions for each evolution stage and mood
|
| 3 |
+
|
| 4 |
+
import type { BuddyEvolutionStage, BuddyMood, BuddySpriteConfig } from "./types";
|
| 5 |
+
|
| 6 |
+
// ─── Face sprites per stage + mood ─────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
const EGG_FACES: Record<BuddyMood, string> = {
|
| 9 |
+
idle: "🥚",
|
| 10 |
+
thinking: "🥚 ...",
|
| 11 |
+
happy: "🥚 ✨",
|
| 12 |
+
working: "🥚 💫",
|
| 13 |
+
sleeping: "🥚 💤",
|
| 14 |
+
excited: "🥚 ❗",
|
| 15 |
+
confused: "🥚 ❓",
|
| 16 |
+
proud: "🥚 ⭐",
|
| 17 |
+
tired: "🥚 😴",
|
| 18 |
+
celebrating: "🥚 🎉",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const BABY_FACES: Record<BuddyMood, string> = {
|
| 22 |
+
idle: "(• ◡ •)",
|
| 23 |
+
thinking: "(• ᴗ •) ...",
|
| 24 |
+
happy: "(◕ ᗜ ◕)",
|
| 25 |
+
working: "(⌐■_■)",
|
| 26 |
+
sleeping: "(- ᴗ -)ᶻᶻ",
|
| 27 |
+
excited: "(★ ᗜ ★)",
|
| 28 |
+
confused: "(• _ •)?",
|
| 29 |
+
proud: "(⌐■_■)b",
|
| 30 |
+
tired: "(- _ -)~",
|
| 31 |
+
celebrating: "\\(◕ ᗜ ◕)/",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const JUNIOR_FACES: Record<BuddyMood, string> = {
|
| 35 |
+
idle: "ᕙ(• ◡ •)ᕗ",
|
| 36 |
+
thinking: "(• ᴗ •)🔍",
|
| 37 |
+
happy: "ᕙ(◕ ᗜ ◕)ᕗ",
|
| 38 |
+
working: "(⌐■_■)⚡",
|
| 39 |
+
sleeping: "(- ᴗ -)ᶻᶻᶻ",
|
| 40 |
+
excited: "ᕙ(★ ᗜ ★)ᕗ",
|
| 41 |
+
confused: "(• _ •)❓",
|
| 42 |
+
proud: "(⌐■_■)✨",
|
| 43 |
+
tired: "(- _ -)💤",
|
| 44 |
+
celebrating: "\\(★ ᗜ ★)/🎉",
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const SENIOR_FACES: Record<BuddyMood, string> = {
|
| 48 |
+
idle: "⚡(• ◡ •)⚡",
|
| 49 |
+
thinking: "🧠(• ᴗ •)",
|
| 50 |
+
happy: "⚡(◕ ᗜ ◕)⚡",
|
| 51 |
+
working: "💻(⌐■_■)💻",
|
| 52 |
+
sleeping: "🌙(- ᴗ -)🌙",
|
| 53 |
+
excited: "🔥(★ ᗜ ★)🔥",
|
| 54 |
+
confused: "🤔(• _ •)",
|
| 55 |
+
proud: "👑(⌐■_■)",
|
| 56 |
+
tired: "☕(- _ -)",
|
| 57 |
+
celebrating: "🎊\\(★ ᗜ ★)/🎊",
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const MASTER_FACES: Record<BuddyMood, string> = {
|
| 61 |
+
idle: "👑⚡(◉ ◡ ◉)⚡👑",
|
| 62 |
+
thinking: "🧠⚡(◉ ᴗ ◉)⚡",
|
| 63 |
+
happy: "👑⚡(◉ ᗜ ◉)⚡👑",
|
| 64 |
+
working: "💻⚡(⌐◉_◉)⚡💻",
|
| 65 |
+
sleeping: "🌙✨(- ◡ -)✨🌙",
|
| 66 |
+
excited: "🔥⚡(★ ᗜ ★)⚡🔥",
|
| 67 |
+
confused: "🤔(◉ _ ◉)❓",
|
| 68 |
+
proud: "👑✨(⌐◉_◉)✨👑",
|
| 69 |
+
tired: "☕(- ◡ -)~",
|
| 70 |
+
celebrating: "🎊🔥\\(★ ᗜ ★)/🔥🎊",
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const LEGENDARY_FACES: Record<BuddyMood, string> = {
|
| 74 |
+
idle: "🌟👑(◉ ◡ ◉)👑🌟",
|
| 75 |
+
thinking: "🧠💫(◉ ᴗ ◉)💫",
|
| 76 |
+
happy: "🌟👑(◉ ᗜ ◉)👑🌟",
|
| 77 |
+
working: "⚡💻(⌐◉_◉)💻⚡",
|
| 78 |
+
sleeping: "🌙🌟(- ◡ -)🌟🌙",
|
| 79 |
+
excited: "🌟🔥(★ ᗜ ★)🔥🌟",
|
| 80 |
+
confused: "🌟(◉ _ ◉)❓",
|
| 81 |
+
proud: "🌟👑✨(⌐◉_◉)✨👑🌟",
|
| 82 |
+
tired: "🌟☕(- ◡ -)☕🌟",
|
| 83 |
+
celebrating: "🌟🎊🔥\\(★ ᗜ ★)/🔥🎊🌟",
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const STAGE_FACES: Record<BuddyEvolutionStage, Record<BuddyMood, string>> = {
|
| 87 |
+
egg: EGG_FACES,
|
| 88 |
+
baby: BABY_FACES,
|
| 89 |
+
junior: JUNIOR_FACES,
|
| 90 |
+
senior: SENIOR_FACES,
|
| 91 |
+
master: MASTER_FACES,
|
| 92 |
+
legendary: LEGENDARY_FACES,
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// ─── Colors per stage ──────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
const STAGE_COLORS: Record<BuddyEvolutionStage, string> = {
|
| 98 |
+
egg: "from-gray-400/20 to-gray-500/20 border-gray-400/30",
|
| 99 |
+
baby: "from-blue-500/20 to-cyan-500/20 border-blue-500/30",
|
| 100 |
+
junior: "from-green-500/20 to-emerald-500/20 border-green-500/30",
|
| 101 |
+
senior: "from-purple-500/20 to-pink-500/20 border-purple-500/30",
|
| 102 |
+
master: "from-orange-500/20 to-amber-500/20 border-orange-500/30",
|
| 103 |
+
legendary: "from-yellow-400/30 to-amber-400/30 border-yellow-400/50",
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// ─── Mood-specific color overrides ─────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
const MOOD_COLOR_OVERRIDES: Partial<Record<BuddyMood, string>> = {
|
| 109 |
+
thinking: "from-purple-500/20 to-pink-500/20 border-purple-500/30",
|
| 110 |
+
working: "from-orange-500/20 to-amber-500/20 border-orange-500/30",
|
| 111 |
+
sleeping: "from-indigo-500/10 to-slate-500/10 border-indigo-500/20",
|
| 112 |
+
excited: "from-yellow-400/30 to-amber-400/30 border-yellow-400/50",
|
| 113 |
+
celebrating: "from-pink-500/20 to-rose-500/20 border-pink-500/30",
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
// ─── Size per stage ────────────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
export const STAGE_SIZE: Record<BuddyEvolutionStage, { width: string; height: string; fontSize: string }> = {
|
| 119 |
+
egg: { width: "w-10", height: "h-10", fontSize: "text-[9px]" },
|
| 120 |
+
baby: { width: "w-14", height: "h-14", fontSize: "text-[11px]" },
|
| 121 |
+
junior: { width: "w-16", height: "h-16", fontSize: "text-[12px]" },
|
| 122 |
+
senior: { width: "w-18", height: "h-18", fontSize: "text-[13px]" },
|
| 123 |
+
master: { width: "w-20", height: "h-20", fontSize: "text-[11px]" },
|
| 124 |
+
legendary: { width: "w-22", height: "h-22", fontSize: "text-[10px]" },
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
// ─── Animations per mood ───────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
export const MOOD_ANIMATIONS: Partial<Record<BuddyMood, string>> = {
|
| 130 |
+
working: "animate-pulse",
|
| 131 |
+
excited: "animate-bounce",
|
| 132 |
+
celebrating: "animate-bounce",
|
| 133 |
+
sleeping: "opacity-60",
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
// ─── Public API ────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
export function getSpriteConfig(
|
| 139 |
+
stage: BuddyEvolutionStage,
|
| 140 |
+
mood: BuddyMood
|
| 141 |
+
): BuddySpriteConfig {
|
| 142 |
+
const face = STAGE_FACES[stage]?.[mood] ?? STAGE_FACES.baby.idle;
|
| 143 |
+
const color = MOOD_COLOR_OVERRIDES[mood] ?? STAGE_COLORS[stage] ?? STAGE_COLORS.baby;
|
| 144 |
+
const animation = MOOD_ANIMATIONS[mood];
|
| 145 |
+
|
| 146 |
+
return {
|
| 147 |
+
face,
|
| 148 |
+
color,
|
| 149 |
+
animation,
|
| 150 |
+
accessorySlots: [],
|
| 151 |
+
};
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export function getStageLabel(stage: BuddyEvolutionStage): string {
|
| 155 |
+
const labels: Record<BuddyEvolutionStage, string> = {
|
| 156 |
+
egg: "Egg",
|
| 157 |
+
baby: "Baby",
|
| 158 |
+
junior: "Junior Dev",
|
| 159 |
+
senior: "Senior Dev",
|
| 160 |
+
master: "Master",
|
| 161 |
+
legendary: "Legendary",
|
| 162 |
+
};
|
| 163 |
+
return labels[stage] ?? "Unknown";
|
| 164 |
+
}
|
client/src/buddy/types.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/types.ts — Matches original buddy/types.ts ─────────────────────
|
| 2 |
+
// Full type system for the Claw Buddy companion pet
|
| 3 |
+
|
| 4 |
+
export type BuddyMood =
|
| 5 |
+
| "idle"
|
| 6 |
+
| "thinking"
|
| 7 |
+
| "happy"
|
| 8 |
+
| "working"
|
| 9 |
+
| "sleeping"
|
| 10 |
+
| "excited"
|
| 11 |
+
| "confused"
|
| 12 |
+
| "proud"
|
| 13 |
+
| "tired"
|
| 14 |
+
| "celebrating";
|
| 15 |
+
|
| 16 |
+
export type BuddyEvolutionStage =
|
| 17 |
+
| "egg" // Level 0-2: just hatched
|
| 18 |
+
| "baby" // Level 3-5: learning
|
| 19 |
+
| "junior" // Level 6-10: growing
|
| 20 |
+
| "senior" // Level 11-20: experienced
|
| 21 |
+
| "master" // Level 21-50: expert
|
| 22 |
+
| "legendary"; // Level 51+: legendary status
|
| 23 |
+
|
| 24 |
+
export interface BuddyStats {
|
| 25 |
+
level: number;
|
| 26 |
+
xp: number;
|
| 27 |
+
xpToNextLevel: number;
|
| 28 |
+
totalToolCalls: number;
|
| 29 |
+
totalSessions: number;
|
| 30 |
+
totalFilesCreated: number;
|
| 31 |
+
totalLinesWritten: number;
|
| 32 |
+
totalBugsFixed: number;
|
| 33 |
+
totalTestsPassed: number;
|
| 34 |
+
streak: number; // consecutive days of coding
|
| 35 |
+
longestStreak: number;
|
| 36 |
+
favoriteLanguage: string;
|
| 37 |
+
languageStats: Record<string, number>; // language -> lines
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface BuddyState {
|
| 41 |
+
name: string;
|
| 42 |
+
mood: BuddyMood;
|
| 43 |
+
stage: BuddyEvolutionStage;
|
| 44 |
+
stats: BuddyStats;
|
| 45 |
+
happiness: number; // 0-100
|
| 46 |
+
energy: number; // 0-100
|
| 47 |
+
hunger: number; // 0-100 (decreases over time)
|
| 48 |
+
lastInteraction: number; // timestamp
|
| 49 |
+
lastFed: number; // timestamp
|
| 50 |
+
createdAt: number; // timestamp
|
| 51 |
+
achievements: string[]; // unlocked achievement IDs
|
| 52 |
+
outfit: string; // current outfit/skin
|
| 53 |
+
accessories: string[]; // equipped accessories
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface BuddyAchievement {
|
| 57 |
+
id: string;
|
| 58 |
+
name: string;
|
| 59 |
+
description: string;
|
| 60 |
+
icon: string;
|
| 61 |
+
condition: (stats: BuddyStats) => boolean;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export interface BuddyNotification {
|
| 65 |
+
id: string;
|
| 66 |
+
type: "levelup" | "achievement" | "evolution" | "mood" | "streak" | "tip";
|
| 67 |
+
title: string;
|
| 68 |
+
message: string;
|
| 69 |
+
icon?: string;
|
| 70 |
+
timestamp: number;
|
| 71 |
+
dismissed: boolean;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export interface BuddySpriteConfig {
|
| 75 |
+
face: string;
|
| 76 |
+
color: string;
|
| 77 |
+
animation?: string;
|
| 78 |
+
accessorySlots: string[];
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// XP rewards for different actions (matches original)
|
| 82 |
+
export const XP_REWARDS = {
|
| 83 |
+
tool_call: 5,
|
| 84 |
+
file_created: 10,
|
| 85 |
+
file_edited: 3,
|
| 86 |
+
bug_fixed: 25,
|
| 87 |
+
test_passed: 15,
|
| 88 |
+
session_completed: 20,
|
| 89 |
+
streak_day: 50,
|
| 90 |
+
first_session: 100,
|
| 91 |
+
lines_100: 10,
|
| 92 |
+
lines_1000: 50,
|
| 93 |
+
} as const;
|
| 94 |
+
|
| 95 |
+
// Level thresholds
|
| 96 |
+
export function xpForLevel(level: number): number {
|
| 97 |
+
// Quadratic scaling: level 1 = 100 XP, level 10 = 1000 XP, level 50 = 25000 XP
|
| 98 |
+
return Math.floor(100 * level * (1 + level * 0.1));
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export function stageForLevel(level: number): BuddyEvolutionStage {
|
| 102 |
+
if (level <= 2) return "egg";
|
| 103 |
+
if (level <= 5) return "baby";
|
| 104 |
+
if (level <= 10) return "junior";
|
| 105 |
+
if (level <= 20) return "senior";
|
| 106 |
+
if (level <= 50) return "master";
|
| 107 |
+
return "legendary";
|
| 108 |
+
}
|
client/src/buddy/useBuddyNotification.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── buddy/useBuddyNotification.tsx — Matches original ───────────────────
|
| 2 |
+
// React hook for buddy notifications, state management, and event handling
|
| 3 |
+
|
| 4 |
+
import { useState, useEffect, useCallback, useRef } from "react";
|
| 5 |
+
import type { BuddyState, BuddyMood, BuddyNotification } from "./types";
|
| 6 |
+
import {
|
| 7 |
+
loadBuddyState,
|
| 8 |
+
saveBuddyState,
|
| 9 |
+
loadNotifications,
|
| 10 |
+
saveNotifications,
|
| 11 |
+
onToolCall,
|
| 12 |
+
onFileCreated,
|
| 13 |
+
onSessionCompleted,
|
| 14 |
+
calculateMood,
|
| 15 |
+
applyTimeDecay,
|
| 16 |
+
feedBuddy,
|
| 17 |
+
petBuddy,
|
| 18 |
+
updateStreak,
|
| 19 |
+
} from "./companion";
|
| 20 |
+
|
| 21 |
+
export interface UseBuddyReturn {
|
| 22 |
+
state: BuddyState;
|
| 23 |
+
mood: BuddyMood;
|
| 24 |
+
notifications: BuddyNotification[];
|
| 25 |
+
undismissedCount: number;
|
| 26 |
+
// Actions
|
| 27 |
+
handleToolCall: (toolName: string) => void;
|
| 28 |
+
handleFileCreated: (filePath: string, lines: number) => void;
|
| 29 |
+
handleSessionCompleted: () => void;
|
| 30 |
+
feed: () => void;
|
| 31 |
+
pet: () => void;
|
| 32 |
+
dismissNotification: (id: string) => void;
|
| 33 |
+
dismissAll: () => void;
|
| 34 |
+
renameBuddy: (name: string) => void;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function useBuddy(
|
| 38 |
+
isStreaming: boolean,
|
| 39 |
+
hasMessages: boolean
|
| 40 |
+
): UseBuddyReturn {
|
| 41 |
+
const [state, setState] = useState<BuddyState>(() => loadBuddyState());
|
| 42 |
+
const [notifications, setNotifications] = useState<BuddyNotification[]>(
|
| 43 |
+
() => loadNotifications()
|
| 44 |
+
);
|
| 45 |
+
const stateRef = useRef(state);
|
| 46 |
+
stateRef.current = state;
|
| 47 |
+
|
| 48 |
+
// Apply time decay on mount and every 5 minutes
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const decayed = applyTimeDecay(state);
|
| 51 |
+
const streaked = updateStreak(decayed);
|
| 52 |
+
if (
|
| 53 |
+
streaked.hunger !== state.hunger ||
|
| 54 |
+
streaked.happiness !== state.happiness ||
|
| 55 |
+
streaked.energy !== state.energy ||
|
| 56 |
+
streaked.stats.streak !== state.stats.streak
|
| 57 |
+
) {
|
| 58 |
+
setState(streaked);
|
| 59 |
+
saveBuddyState(streaked);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const interval = setInterval(() => {
|
| 63 |
+
const current = stateRef.current;
|
| 64 |
+
const updated = applyTimeDecay(current);
|
| 65 |
+
if (
|
| 66 |
+
updated.hunger !== current.hunger ||
|
| 67 |
+
updated.happiness !== current.happiness ||
|
| 68 |
+
updated.energy !== current.energy
|
| 69 |
+
) {
|
| 70 |
+
setState(updated);
|
| 71 |
+
saveBuddyState(updated);
|
| 72 |
+
}
|
| 73 |
+
}, 300_000); // 5 minutes
|
| 74 |
+
|
| 75 |
+
return () => clearInterval(interval);
|
| 76 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 77 |
+
}, []);
|
| 78 |
+
|
| 79 |
+
// Calculate mood reactively
|
| 80 |
+
const mood = calculateMood(state, isStreaming, hasMessages);
|
| 81 |
+
|
| 82 |
+
// Persist state changes
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
saveBuddyState(state);
|
| 85 |
+
}, [state]);
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
saveNotifications(notifications);
|
| 89 |
+
}, [notifications]);
|
| 90 |
+
|
| 91 |
+
// ─── Event handlers ────────────────────────────────────────────────────
|
| 92 |
+
|
| 93 |
+
const addNotifications = useCallback((newNotifs: BuddyNotification[]) => {
|
| 94 |
+
if (newNotifs.length > 0) {
|
| 95 |
+
setNotifications((prev) => [...prev, ...newNotifs]);
|
| 96 |
+
}
|
| 97 |
+
}, []);
|
| 98 |
+
|
| 99 |
+
const handleToolCall = useCallback(
|
| 100 |
+
(toolName: string) => {
|
| 101 |
+
const result = onToolCall(stateRef.current, toolName);
|
| 102 |
+
setState(result.state);
|
| 103 |
+
addNotifications(result.notifications);
|
| 104 |
+
},
|
| 105 |
+
[addNotifications]
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
const handleFileCreated = useCallback(
|
| 109 |
+
(filePath: string, lines: number) => {
|
| 110 |
+
const result = onFileCreated(stateRef.current, filePath, lines);
|
| 111 |
+
setState(result.state);
|
| 112 |
+
addNotifications(result.notifications);
|
| 113 |
+
},
|
| 114 |
+
[addNotifications]
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
const handleSessionCompleted = useCallback(() => {
|
| 118 |
+
const result = onSessionCompleted(stateRef.current);
|
| 119 |
+
setState(result.state);
|
| 120 |
+
addNotifications(result.notifications);
|
| 121 |
+
}, [addNotifications]);
|
| 122 |
+
|
| 123 |
+
const feed = useCallback(() => {
|
| 124 |
+
setState((prev) => feedBuddy(prev));
|
| 125 |
+
}, []);
|
| 126 |
+
|
| 127 |
+
const pet = useCallback(() => {
|
| 128 |
+
setState((prev) => petBuddy(prev));
|
| 129 |
+
}, []);
|
| 130 |
+
|
| 131 |
+
const dismissNotification = useCallback((id: string) => {
|
| 132 |
+
setNotifications((prev) =>
|
| 133 |
+
prev.map((n) => (n.id === id ? { ...n, dismissed: true } : n))
|
| 134 |
+
);
|
| 135 |
+
}, []);
|
| 136 |
+
|
| 137 |
+
const dismissAll = useCallback(() => {
|
| 138 |
+
setNotifications((prev) => prev.map((n) => ({ ...n, dismissed: true })));
|
| 139 |
+
}, []);
|
| 140 |
+
|
| 141 |
+
const renameBuddy = useCallback((name: string) => {
|
| 142 |
+
setState((prev) => ({ ...prev, name: name.trim() || "Buddy" }));
|
| 143 |
+
}, []);
|
| 144 |
+
|
| 145 |
+
const undismissedCount = notifications.filter((n) => !n.dismissed).length;
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
state,
|
| 149 |
+
mood,
|
| 150 |
+
notifications,
|
| 151 |
+
undismissedCount,
|
| 152 |
+
handleToolCall,
|
| 153 |
+
handleFileCreated,
|
| 154 |
+
handleSessionCompleted,
|
| 155 |
+
feed,
|
| 156 |
+
pet,
|
| 157 |
+
dismissNotification,
|
| 158 |
+
dismissAll,
|
| 159 |
+
renameBuddy,
|
| 160 |
+
};
|
| 161 |
+
}
|
client/src/components/BuddySprite.tsx
CHANGED
|
@@ -1,45 +1,48 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
interface BuddySpriteProps {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
visible: boolean;
|
| 6 |
-
mood?: "idle" | "thinking" | "happy" | "working" | "sleeping";
|
| 7 |
position?: "bottom-right" | "bottom-left";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
onClick?: () => void;
|
| 9 |
}
|
| 10 |
|
| 11 |
-
const MOOD_FACES: Record<string, string> = {
|
| 12 |
-
idle: "(• ◡ •)",
|
| 13 |
-
thinking: "(• ᴗ •) ...",
|
| 14 |
-
happy: "(◕ ᗜ ◕)",
|
| 15 |
-
working: "(⌐■_■)",
|
| 16 |
-
sleeping: "(- ᴗ -)ᶻᶻ",
|
| 17 |
-
};
|
| 18 |
-
|
| 19 |
-
const MOOD_COLORS: Record<string, string> = {
|
| 20 |
-
idle: "from-blue-500/20 to-cyan-500/20 border-blue-500/30",
|
| 21 |
-
thinking: "from-purple-500/20 to-pink-500/20 border-purple-500/30",
|
| 22 |
-
happy: "from-green-500/20 to-emerald-500/20 border-green-500/30",
|
| 23 |
-
working: "from-orange-500/20 to-amber-500/20 border-orange-500/30",
|
| 24 |
-
sleeping: "from-indigo-500/10 to-slate-500/10 border-indigo-500/20",
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
-
const IDLE_MESSAGES = [
|
| 28 |
-
"Ready to code!",
|
| 29 |
-
"What shall we build?",
|
| 30 |
-
"I'm here to help!",
|
| 31 |
-
"Need anything?",
|
| 32 |
-
"Let's go! 🚀",
|
| 33 |
-
];
|
| 34 |
-
|
| 35 |
export function BuddySprite({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
visible,
|
| 37 |
-
mood = "idle",
|
| 38 |
position = "bottom-right",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
onClick,
|
| 40 |
}: BuddySpriteProps) {
|
| 41 |
const [message, setMessage] = useState("");
|
| 42 |
const [bobOffset, setBobOffset] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
// Gentle bobbing animation
|
| 45 |
useEffect(() => {
|
|
@@ -52,68 +55,345 @@ export function BuddySprite({
|
|
| 52 |
return () => clearInterval(interval);
|
| 53 |
}, [visible, mood]);
|
| 54 |
|
| 55 |
-
// Random
|
| 56 |
useEffect(() => {
|
| 57 |
-
if (!visible || mood
|
| 58 |
setMessage("");
|
| 59 |
return;
|
| 60 |
}
|
| 61 |
-
const
|
| 62 |
-
const msg =
|
|
|
|
|
|
|
| 63 |
setMessage(msg);
|
| 64 |
-
setTimeout(() => setMessage(""),
|
| 65 |
};
|
| 66 |
-
const interval = setInterval(
|
| 67 |
-
|
| 68 |
-
const timeout = setTimeout(showMessage, 2000);
|
| 69 |
return () => {
|
| 70 |
clearInterval(interval);
|
| 71 |
clearTimeout(timeout);
|
| 72 |
};
|
| 73 |
-
}, [visible, mood]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
if (!visible) return null;
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
"fixed z-40 flex flex-col items-center gap-1 cursor-pointer select-none transition-all duration-300",
|
| 81 |
-
position === "bottom-right" ? "right-6 bottom-6" : "left-6 bottom-6"
|
| 82 |
-
)}
|
| 83 |
-
style={{ transform: `translateY(${bobOffset}px)` }}
|
| 84 |
-
onClick={onClick}
|
| 85 |
-
title="Claw Buddy — Click to interact"
|
| 86 |
-
>
|
| 87 |
-
{/* Speech bubble */}
|
| 88 |
-
{message && (
|
| 89 |
-
<div className="bg-popover text-popover-foreground text-xs px-3 py-1.5 rounded-lg shadow-md border border-border max-w-[160px] text-center animate-in fade-in slide-in-from-bottom-2 duration-200">
|
| 90 |
-
{message}
|
| 91 |
-
<div
|
| 92 |
-
className={cn(
|
| 93 |
-
"absolute -bottom-1 w-2 h-2 bg-popover border-b border-r border-border rotate-45",
|
| 94 |
-
"left-1/2 -translate-x-1/2"
|
| 95 |
-
)}
|
| 96 |
-
/>
|
| 97 |
-
</div>
|
| 98 |
-
)}
|
| 99 |
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
<div
|
| 102 |
className={cn(
|
| 103 |
-
"
|
| 104 |
-
|
| 105 |
-
mood === "working" && "animate-pulse"
|
| 106 |
)}
|
|
|
|
|
|
|
|
|
|
| 107 |
>
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
</span>
|
| 111 |
</div>
|
| 112 |
|
| 113 |
-
{/*
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</span>
|
| 117 |
</div>
|
| 118 |
);
|
| 119 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── BuddySprite.tsx — Full companion pet UI (matches original 6-module system) ──
|
| 2 |
+
import { useState, useEffect, useCallback } from "react";
|
| 3 |
import { cn } from "@/lib/utils";
|
| 4 |
+
import type { BuddyState, BuddyMood, BuddyEvolutionStage, BuddyNotification } from "@/buddy/types";
|
| 5 |
+
import { getSpriteConfig, STAGE_SIZE, getStageLabel } from "@/buddy/sprites";
|
| 6 |
+
import { getRandomMessage, getRandomGreeting, formatStatsSummary, ACHIEVEMENTS } from "@/buddy/prompt";
|
| 7 |
|
| 8 |
interface BuddySpriteProps {
|
| 9 |
+
state: BuddyState;
|
| 10 |
+
mood: BuddyMood;
|
| 11 |
+
notifications: BuddyNotification[];
|
| 12 |
+
undismissedCount: number;
|
| 13 |
visible: boolean;
|
|
|
|
| 14 |
position?: "bottom-right" | "bottom-left";
|
| 15 |
+
onFeed: () => void;
|
| 16 |
+
onPet: () => void;
|
| 17 |
+
onDismissNotification: (id: string) => void;
|
| 18 |
+
onDismissAll: () => void;
|
| 19 |
+
onRename: (name: string) => void;
|
| 20 |
onClick?: () => void;
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
export function BuddySprite({
|
| 24 |
+
state,
|
| 25 |
+
mood,
|
| 26 |
+
notifications,
|
| 27 |
+
undismissedCount,
|
| 28 |
visible,
|
|
|
|
| 29 |
position = "bottom-right",
|
| 30 |
+
onFeed,
|
| 31 |
+
onPet,
|
| 32 |
+
onDismissNotification,
|
| 33 |
+
onDismissAll,
|
| 34 |
+
onRename,
|
| 35 |
onClick,
|
| 36 |
}: BuddySpriteProps) {
|
| 37 |
const [message, setMessage] = useState("");
|
| 38 |
const [bobOffset, setBobOffset] = useState(0);
|
| 39 |
+
const [showPanel, setShowPanel] = useState(false);
|
| 40 |
+
const [panelTab, setPanelTab] = useState<"stats" | "achievements" | "notifications">("stats");
|
| 41 |
+
const [isRenaming, setIsRenaming] = useState(false);
|
| 42 |
+
const [renameValue, setRenameValue] = useState(state.name);
|
| 43 |
+
|
| 44 |
+
const sprite = getSpriteConfig(state.stage, mood);
|
| 45 |
+
const sizeConfig = STAGE_SIZE[state.stage] || STAGE_SIZE.baby;
|
| 46 |
|
| 47 |
// Gentle bobbing animation
|
| 48 |
useEffect(() => {
|
|
|
|
| 55 |
return () => clearInterval(interval);
|
| 56 |
}, [visible, mood]);
|
| 57 |
|
| 58 |
+
// Random messages
|
| 59 |
useEffect(() => {
|
| 60 |
+
if (!visible || mood === "sleeping" || mood === "working") {
|
| 61 |
setMessage("");
|
| 62 |
return;
|
| 63 |
}
|
| 64 |
+
const showMsg = () => {
|
| 65 |
+
const msg = Math.random() > 0.5
|
| 66 |
+
? getRandomMessage(mood)
|
| 67 |
+
: getRandomGreeting(state.stage);
|
| 68 |
setMessage(msg);
|
| 69 |
+
setTimeout(() => setMessage(""), 4000);
|
| 70 |
};
|
| 71 |
+
const interval = setInterval(showMsg, 12000);
|
| 72 |
+
const timeout = setTimeout(showMsg, 2000);
|
|
|
|
| 73 |
return () => {
|
| 74 |
clearInterval(interval);
|
| 75 |
clearTimeout(timeout);
|
| 76 |
};
|
| 77 |
+
}, [visible, mood, state.stage]);
|
| 78 |
+
|
| 79 |
+
// Show notification messages
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
const latest = notifications.filter((n) => !n.dismissed).slice(-1)[0];
|
| 82 |
+
if (latest) {
|
| 83 |
+
setMessage(`${latest.icon || "🔔"} ${latest.title}`);
|
| 84 |
+
const t = setTimeout(() => setMessage(""), 5000);
|
| 85 |
+
return () => clearTimeout(t);
|
| 86 |
+
}
|
| 87 |
+
}, [notifications]);
|
| 88 |
+
|
| 89 |
+
const handleClick = useCallback(() => {
|
| 90 |
+
if (onClick) onClick();
|
| 91 |
+
setShowPanel((prev) => !prev);
|
| 92 |
+
}, [onClick]);
|
| 93 |
+
|
| 94 |
+
const handleRenameSubmit = useCallback(() => {
|
| 95 |
+
onRename(renameValue);
|
| 96 |
+
setIsRenaming(false);
|
| 97 |
+
}, [onRename, renameValue]);
|
| 98 |
|
| 99 |
if (!visible) return null;
|
| 100 |
|
| 101 |
+
const xpPercent = state.stats.xpToNextLevel > 0
|
| 102 |
+
? Math.round((state.stats.xp / state.stats.xpToNextLevel) * 100)
|
| 103 |
+
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
return (
|
| 106 |
+
<>
|
| 107 |
+
{/* Main buddy sprite */}
|
| 108 |
<div
|
| 109 |
className={cn(
|
| 110 |
+
"fixed z-50 flex flex-col items-center gap-1 cursor-pointer select-none transition-all duration-300",
|
| 111 |
+
position === "bottom-right" ? "right-6 bottom-6" : "left-6 bottom-6"
|
|
|
|
| 112 |
)}
|
| 113 |
+
style={{ transform: `translateY(${bobOffset}px)` }}
|
| 114 |
+
onClick={handleClick}
|
| 115 |
+
title={`${state.name} — Level ${state.stats.level} ${getStageLabel(state.stage)} — Click to interact`}
|
| 116 |
>
|
| 117 |
+
{/* Speech bubble */}
|
| 118 |
+
{message && (
|
| 119 |
+
<div className="bg-popover text-popover-foreground text-xs px-3 py-1.5 rounded-lg shadow-md border border-border max-w-[180px] text-center animate-in fade-in slide-in-from-bottom-2 duration-200">
|
| 120 |
+
{message}
|
| 121 |
+
<div
|
| 122 |
+
className={cn(
|
| 123 |
+
"absolute -bottom-1 w-2 h-2 bg-popover border-b border-r border-border rotate-45",
|
| 124 |
+
"left-1/2 -translate-x-1/2"
|
| 125 |
+
)}
|
| 126 |
+
/>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{/* Notification badge */}
|
| 131 |
+
{undismissedCount > 0 && (
|
| 132 |
+
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center z-10">
|
| 133 |
+
{undismissedCount > 9 ? "9+" : undismissedCount}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
|
| 137 |
+
{/* Buddy body */}
|
| 138 |
+
<div
|
| 139 |
+
className={cn(
|
| 140 |
+
"rounded-2xl bg-gradient-to-br border flex items-center justify-center shadow-lg backdrop-blur-sm transition-all duration-500",
|
| 141 |
+
sizeConfig.width,
|
| 142 |
+
sizeConfig.height,
|
| 143 |
+
sprite.color,
|
| 144 |
+
sprite.animation
|
| 145 |
+
)}
|
| 146 |
+
>
|
| 147 |
+
<span className={cn("font-mono text-foreground/80 whitespace-nowrap", sizeConfig.fontSize)}>
|
| 148 |
+
{sprite.face}
|
| 149 |
+
</span>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* XP bar */}
|
| 153 |
+
<div className="w-full max-w-[56px] h-1 bg-muted/30 rounded-full overflow-hidden">
|
| 154 |
+
<div
|
| 155 |
+
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full transition-all duration-500"
|
| 156 |
+
style={{ width: `${xpPercent}%` }}
|
| 157 |
+
/>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Label */}
|
| 161 |
+
<span className="text-[9px] text-muted-foreground/60 font-medium">
|
| 162 |
+
{state.name} Lv.{state.stats.level}
|
| 163 |
</span>
|
| 164 |
</div>
|
| 165 |
|
| 166 |
+
{/* Interaction panel */}
|
| 167 |
+
{showPanel && (
|
| 168 |
+
<div
|
| 169 |
+
className={cn(
|
| 170 |
+
"fixed z-50 bg-popover text-popover-foreground rounded-xl shadow-2xl border border-border w-[320px] max-h-[420px] overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300",
|
| 171 |
+
position === "bottom-right" ? "right-6 bottom-28" : "left-6 bottom-28"
|
| 172 |
+
)}
|
| 173 |
+
>
|
| 174 |
+
{/* Header */}
|
| 175 |
+
<div className="p-3 border-b border-border bg-muted/30">
|
| 176 |
+
<div className="flex items-center justify-between">
|
| 177 |
+
<div className="flex items-center gap-2">
|
| 178 |
+
<span className="text-lg">{sprite.face}</span>
|
| 179 |
+
<div>
|
| 180 |
+
{isRenaming ? (
|
| 181 |
+
<div className="flex gap-1">
|
| 182 |
+
<input
|
| 183 |
+
className="text-sm font-bold bg-background border border-border rounded px-1 w-24"
|
| 184 |
+
value={renameValue}
|
| 185 |
+
onChange={(e) => setRenameValue(e.target.value)}
|
| 186 |
+
onKeyDown={(e) => e.key === "Enter" && handleRenameSubmit()}
|
| 187 |
+
autoFocus
|
| 188 |
+
/>
|
| 189 |
+
<button
|
| 190 |
+
className="text-xs text-muted-foreground hover:text-foreground"
|
| 191 |
+
onClick={handleRenameSubmit}
|
| 192 |
+
>
|
| 193 |
+
OK
|
| 194 |
+
</button>
|
| 195 |
+
</div>
|
| 196 |
+
) : (
|
| 197 |
+
<span
|
| 198 |
+
className="text-sm font-bold cursor-pointer hover:underline"
|
| 199 |
+
onClick={() => setIsRenaming(true)}
|
| 200 |
+
>
|
| 201 |
+
{state.name}
|
| 202 |
+
</span>
|
| 203 |
+
)}
|
| 204 |
+
<div className="text-[10px] text-muted-foreground">
|
| 205 |
+
{getStageLabel(state.stage)} — Level {state.stats.level}
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
<button
|
| 210 |
+
className="text-muted-foreground hover:text-foreground text-sm"
|
| 211 |
+
onClick={() => setShowPanel(false)}
|
| 212 |
+
>
|
| 213 |
+
X
|
| 214 |
+
</button>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Status bars */}
|
| 218 |
+
<div className="mt-2 space-y-1">
|
| 219 |
+
<StatusBar label="XP" value={xpPercent} color="from-blue-500 to-cyan-500" text={`${state.stats.xp}/${state.stats.xpToNextLevel}`} />
|
| 220 |
+
<StatusBar label="HP" value={state.happiness} color="from-green-500 to-emerald-500" />
|
| 221 |
+
<StatusBar label="EN" value={state.energy} color="from-yellow-500 to-amber-500" />
|
| 222 |
+
<StatusBar label="FD" value={state.hunger} color="from-orange-500 to-red-500" />
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* Action buttons */}
|
| 226 |
+
<div className="flex gap-2 mt-2">
|
| 227 |
+
<button
|
| 228 |
+
className="flex-1 text-xs bg-green-500/20 hover:bg-green-500/30 border border-green-500/30 rounded-lg py-1 transition-colors"
|
| 229 |
+
onClick={onFeed}
|
| 230 |
+
>
|
| 231 |
+
Feed
|
| 232 |
+
</button>
|
| 233 |
+
<button
|
| 234 |
+
className="flex-1 text-xs bg-pink-500/20 hover:bg-pink-500/30 border border-pink-500/30 rounded-lg py-1 transition-colors"
|
| 235 |
+
onClick={onPet}
|
| 236 |
+
>
|
| 237 |
+
Pet
|
| 238 |
+
</button>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
{/* Tabs */}
|
| 243 |
+
<div className="flex border-b border-border">
|
| 244 |
+
{(["stats", "achievements", "notifications"] as const).map((tab) => (
|
| 245 |
+
<button
|
| 246 |
+
key={tab}
|
| 247 |
+
className={cn(
|
| 248 |
+
"flex-1 text-xs py-2 transition-colors",
|
| 249 |
+
panelTab === tab
|
| 250 |
+
? "text-foreground border-b-2 border-primary"
|
| 251 |
+
: "text-muted-foreground hover:text-foreground"
|
| 252 |
+
)}
|
| 253 |
+
onClick={() => setPanelTab(tab)}
|
| 254 |
+
>
|
| 255 |
+
{tab === "stats" ? "Stats" : tab === "achievements" ? "Achievements" : `Notifs (${undismissedCount})`}
|
| 256 |
+
</button>
|
| 257 |
+
))}
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
{/* Tab content */}
|
| 261 |
+
<div className="p-3 overflow-y-auto max-h-[200px] text-xs">
|
| 262 |
+
{panelTab === "stats" && (
|
| 263 |
+
<div className="space-y-2">
|
| 264 |
+
<StatRow label="Total Tool Calls" value={state.stats.totalToolCalls.toLocaleString()} />
|
| 265 |
+
<StatRow label="Files Created" value={state.stats.totalFilesCreated.toLocaleString()} />
|
| 266 |
+
<StatRow label="Lines Written" value={state.stats.totalLinesWritten.toLocaleString()} />
|
| 267 |
+
<StatRow label="Bugs Fixed" value={state.stats.totalBugsFixed.toLocaleString()} />
|
| 268 |
+
<StatRow label="Tests Passed" value={state.stats.totalTestsPassed.toLocaleString()} />
|
| 269 |
+
<StatRow label="Sessions" value={state.stats.totalSessions.toLocaleString()} />
|
| 270 |
+
<StatRow label="Streak" value={`${state.stats.streak} days (best: ${state.stats.longestStreak})`} />
|
| 271 |
+
<StatRow label="Favorite Language" value={state.stats.favoriteLanguage} />
|
| 272 |
+
{Object.keys(state.stats.languageStats).length > 0 && (
|
| 273 |
+
<div className="mt-2 pt-2 border-t border-border">
|
| 274 |
+
<div className="text-muted-foreground mb-1">Languages:</div>
|
| 275 |
+
{Object.entries(state.stats.languageStats)
|
| 276 |
+
.sort(([, a], [, b]) => b - a)
|
| 277 |
+
.slice(0, 8)
|
| 278 |
+
.map(([lang, lines]) => (
|
| 279 |
+
<StatRow key={lang} label={lang} value={`${lines.toLocaleString()} lines`} />
|
| 280 |
+
))}
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
|
| 286 |
+
{panelTab === "achievements" && (
|
| 287 |
+
<div className="space-y-2">
|
| 288 |
+
{ACHIEVEMENTS.map((a) => {
|
| 289 |
+
const unlocked = state.achievements.includes(a.id);
|
| 290 |
+
return (
|
| 291 |
+
<div
|
| 292 |
+
key={a.id}
|
| 293 |
+
className={cn(
|
| 294 |
+
"flex items-center gap-2 p-2 rounded-lg border",
|
| 295 |
+
unlocked
|
| 296 |
+
? "bg-green-500/10 border-green-500/30"
|
| 297 |
+
: "bg-muted/20 border-border opacity-50"
|
| 298 |
+
)}
|
| 299 |
+
>
|
| 300 |
+
<span className="text-base">{a.icon}</span>
|
| 301 |
+
<div>
|
| 302 |
+
<div className="font-medium">{a.name}</div>
|
| 303 |
+
<div className="text-muted-foreground text-[10px]">{a.description}</div>
|
| 304 |
+
</div>
|
| 305 |
+
{unlocked && <span className="ml-auto text-green-500 text-xs">Unlocked</span>}
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
})}
|
| 309 |
+
</div>
|
| 310 |
+
)}
|
| 311 |
+
|
| 312 |
+
{panelTab === "notifications" && (
|
| 313 |
+
<div className="space-y-2">
|
| 314 |
+
{undismissedCount > 0 && (
|
| 315 |
+
<button
|
| 316 |
+
className="text-[10px] text-muted-foreground hover:text-foreground underline"
|
| 317 |
+
onClick={onDismissAll}
|
| 318 |
+
>
|
| 319 |
+
Dismiss all
|
| 320 |
+
</button>
|
| 321 |
+
)}
|
| 322 |
+
{notifications.length === 0 && (
|
| 323 |
+
<div className="text-muted-foreground text-center py-4">No notifications yet</div>
|
| 324 |
+
)}
|
| 325 |
+
{[...notifications]
|
| 326 |
+
.reverse()
|
| 327 |
+
.slice(0, 20)
|
| 328 |
+
.map((n) => (
|
| 329 |
+
<div
|
| 330 |
+
key={n.id}
|
| 331 |
+
className={cn(
|
| 332 |
+
"flex items-start gap-2 p-2 rounded-lg border",
|
| 333 |
+
n.dismissed ? "opacity-40 border-border" : "bg-blue-500/10 border-blue-500/30"
|
| 334 |
+
)}
|
| 335 |
+
>
|
| 336 |
+
<span className="text-sm">{n.icon || "🔔"}</span>
|
| 337 |
+
<div className="flex-1">
|
| 338 |
+
<div className="font-medium">{n.title}</div>
|
| 339 |
+
<div className="text-muted-foreground text-[10px]">{n.message}</div>
|
| 340 |
+
<div className="text-muted-foreground/50 text-[9px] mt-0.5">
|
| 341 |
+
{new Date(n.timestamp).toLocaleTimeString()}
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
{!n.dismissed && (
|
| 345 |
+
<button
|
| 346 |
+
className="text-muted-foreground hover:text-foreground text-[10px]"
|
| 347 |
+
onClick={() => onDismissNotification(n.id)}
|
| 348 |
+
>
|
| 349 |
+
X
|
| 350 |
+
</button>
|
| 351 |
+
)}
|
| 352 |
+
</div>
|
| 353 |
+
))}
|
| 354 |
+
</div>
|
| 355 |
+
)}
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
)}
|
| 359 |
+
</>
|
| 360 |
+
);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// ─── Helper components ─────────────────────────────────────────────────────
|
| 364 |
+
|
| 365 |
+
function StatusBar({
|
| 366 |
+
label,
|
| 367 |
+
value,
|
| 368 |
+
color,
|
| 369 |
+
text,
|
| 370 |
+
}: {
|
| 371 |
+
label: string;
|
| 372 |
+
value: number;
|
| 373 |
+
color: string;
|
| 374 |
+
text?: string;
|
| 375 |
+
}) {
|
| 376 |
+
return (
|
| 377 |
+
<div className="flex items-center gap-1.5">
|
| 378 |
+
<span className="text-[9px] text-muted-foreground w-5 text-right font-mono">{label}</span>
|
| 379 |
+
<div className="flex-1 h-1.5 bg-muted/30 rounded-full overflow-hidden">
|
| 380 |
+
<div
|
| 381 |
+
className={cn("h-full bg-gradient-to-r rounded-full transition-all duration-500", color)}
|
| 382 |
+
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
|
| 383 |
+
/>
|
| 384 |
+
</div>
|
| 385 |
+
<span className="text-[9px] text-muted-foreground w-8 font-mono">
|
| 386 |
+
{text || `${value}%`}
|
| 387 |
</span>
|
| 388 |
</div>
|
| 389 |
);
|
| 390 |
}
|
| 391 |
+
|
| 392 |
+
function StatRow({ label, value }: { label: string; value: string }) {
|
| 393 |
+
return (
|
| 394 |
+
<div className="flex justify-between items-center">
|
| 395 |
+
<span className="text-muted-foreground">{label}</span>
|
| 396 |
+
<span className="font-mono">{value}</span>
|
| 397 |
+
</div>
|
| 398 |
+
);
|
| 399 |
+
}
|
client/src/pages/Home.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { CronManagerPanel } from "@/components/CronManagerPanel";
|
|
| 13 |
import { PluginMarketplace } from "@/components/PluginMarketplace";
|
| 14 |
import { ThinkingBlock, extractThinkingBlocks } from "@/components/ThinkingBlock";
|
| 15 |
import { BuddySprite } from "@/components/BuddySprite";
|
|
|
|
| 16 |
import { useChat, type ChatMessage } from "@/hooks/useChat";
|
| 17 |
import {
|
| 18 |
Zap,
|
|
@@ -58,6 +59,9 @@ export default function Home() {
|
|
| 58 |
const [pluginMarketplaceOpen, setPluginMarketplaceOpen] = useState(false);
|
| 59 |
const [buddyVisible, setBuddyVisible] = useState(true);
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
// Plan mode & effort state
|
| 62 |
const [effortLevel, setEffortLevel] = useState<EffortLevel>("high");
|
| 63 |
const [planMode, setPlanMode] = useState(false);
|
|
@@ -998,9 +1002,16 @@ export default function Home() {
|
|
| 998 |
|
| 999 |
{/* Buddy Sprite */}
|
| 1000 |
<BuddySprite
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
visible={buddyVisible}
|
| 1002 |
-
|
| 1003 |
-
|
|
|
|
|
|
|
|
|
|
| 1004 |
/>
|
| 1005 |
|
| 1006 |
{/* Plugin Marketplace panel */}
|
|
|
|
| 13 |
import { PluginMarketplace } from "@/components/PluginMarketplace";
|
| 14 |
import { ThinkingBlock, extractThinkingBlocks } from "@/components/ThinkingBlock";
|
| 15 |
import { BuddySprite } from "@/components/BuddySprite";
|
| 16 |
+
import { useBuddy } from "@/buddy";
|
| 17 |
import { useChat, type ChatMessage } from "@/hooks/useChat";
|
| 18 |
import {
|
| 19 |
Zap,
|
|
|
|
| 59 |
const [pluginMarketplaceOpen, setPluginMarketplaceOpen] = useState(false);
|
| 60 |
const [buddyVisible, setBuddyVisible] = useState(true);
|
| 61 |
|
| 62 |
+
// Buddy companion pet system
|
| 63 |
+
const buddy = useBuddy(isStreaming, messages.length > 0);
|
| 64 |
+
|
| 65 |
// Plan mode & effort state
|
| 66 |
const [effortLevel, setEffortLevel] = useState<EffortLevel>("high");
|
| 67 |
const [planMode, setPlanMode] = useState(false);
|
|
|
|
| 1002 |
|
| 1003 |
{/* Buddy Sprite */}
|
| 1004 |
<BuddySprite
|
| 1005 |
+
state={buddy.state}
|
| 1006 |
+
mood={buddy.mood}
|
| 1007 |
+
notifications={buddy.notifications}
|
| 1008 |
+
undismissedCount={buddy.undismissedCount}
|
| 1009 |
visible={buddyVisible}
|
| 1010 |
+
onFeed={buddy.feed}
|
| 1011 |
+
onPet={buddy.pet}
|
| 1012 |
+
onDismissNotification={buddy.dismissNotification}
|
| 1013 |
+
onDismissAll={buddy.dismissAll}
|
| 1014 |
+
onRename={buddy.renameBuddy}
|
| 1015 |
/>
|
| 1016 |
|
| 1017 |
{/* Plugin Marketplace panel */}
|