generated from nhcarrigan/template
3f30997f0e
## Explanation This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking. ## Included Changes - **Resizable chat input** with drag handle (#58 partial) - **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58) - **Scroll position persistence** per conversation tab - **/skill command** for invoking Claude Code skills (#57) - **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59) - **Auto-update checker** on startup (#17) - **Resizable character panel** with full-height sprites (#10) - **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19) ## Closes Closes #10, #17, #19, #57, #58, #59 ## Attestations - [x] I have read and agree to the Code of Conduct - [x] I have read and agree to the Community Guidelines - [x] My contribution complies with the Contributor Covenant - [x] I have run the linter and resolved any errors - [x] My pull request uses an appropriate title, matching the conventional commit standards - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request - [x] All new and existing tests pass locally with my changes - [x] Code coverage remains at or above the configured threshold ## Documentation N/A - Internal app features ## Versioning Minor - My pull request introduces new non-breaking features. --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #61 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
834 lines
29 KiB
Rust
834 lines
29 KiB
Rust
use chrono::{DateTime, Datelike, Timelike, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use tauri_plugin_store::StoreExt;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
|
pub enum AchievementId {
|
|
// Token Milestones
|
|
FirstSteps, // 1,000 tokens
|
|
GrowingStrong, // 10,000 tokens
|
|
BlossomingCoder, // 100,000 tokens
|
|
TokenMaster, // 1,000,000 tokens
|
|
|
|
// Code Generation
|
|
HelloWorld, // First code block
|
|
CodeWizard, // 100 code blocks
|
|
ThousandBlocks, // 1,000 code blocks
|
|
|
|
// File Operations
|
|
FileManipulator, // 10 files edited
|
|
FileArchitect, // 100 files edited
|
|
|
|
// Conversation milestones
|
|
ConversationStarter, // 10 messages
|
|
ChattyKathy, // 100 messages
|
|
Conversationalist, // 1,000 messages
|
|
|
|
// Tool usage
|
|
Toolsmith, // 5 different tools
|
|
ToolMaster, // 10 different tools
|
|
|
|
// Time-based achievements
|
|
EarlyBird, // Started session 5-7 AM
|
|
NightOwl, // Coding after midnight
|
|
AllNighter, // Worked 2-5 AM
|
|
WeekendWarrior, // Coding on weekend
|
|
DedicatedDeveloper, // 30 days in a row
|
|
|
|
// Search and exploration
|
|
Explorer, // 50 searches
|
|
MasterSearcher, // 500 searches
|
|
|
|
// Session achievements
|
|
QuickSession, // Productive session < 5 min
|
|
FocusedWork, // 30 min session
|
|
DeepDive, // 2 hour session
|
|
MarathonSession, // 5+ hour session
|
|
|
|
// Special achievements
|
|
FirstMessage, // First message sent
|
|
FirstTool, // First tool used
|
|
FirstCodeBlock, // First code generated
|
|
FirstFileEdit, // First file edit
|
|
Polyglot, // 5+ languages in one session
|
|
SpeedCoder, // 10 code blocks in 10 minutes
|
|
ClaudeConnoisseur, // Used all Claude models
|
|
MarathonCoder, // 10k tokens in one session
|
|
|
|
// Relationship & Greetings
|
|
GoodMorning, // Say "good morning"
|
|
GoodNight, // Say "good night" or "goodnight"
|
|
ThankYou, // Say "thank you" or "thanks"
|
|
LoveYou, // Say "love you" or "ily"
|
|
|
|
// Personality & Fun
|
|
EmojiUser, // Use an emoji in a message
|
|
QuestionMaster, // Use "?" in 20 messages
|
|
CapsLock, // Send a message in ALL CAPS
|
|
PleaseAndThankYou, // Use "please" in messages
|
|
|
|
// Git & Development
|
|
GitGuru, // Use git commands 10 times
|
|
TestWriter, // Create test files
|
|
Debugger, // Fix bugs (messages with "fix", "bug", "error")
|
|
|
|
// Tool Mastery
|
|
BashMaster, // Use Bash tool 50 times
|
|
FileExplorer, // Use Read tool 100 times
|
|
SearchExpert, // Use Grep tool 50 times
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Achievement {
|
|
pub id: AchievementId,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub icon: String,
|
|
pub unlocked_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AchievementProgress {
|
|
pub unlocked: HashSet<AchievementId>,
|
|
pub newly_unlocked: Vec<AchievementId>, // Achievements unlocked but not yet notified
|
|
#[serde(skip)]
|
|
pub session_start: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl AchievementProgress {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
unlocked: HashSet::new(),
|
|
newly_unlocked: Vec::new(),
|
|
session_start: None,
|
|
}
|
|
}
|
|
|
|
pub fn unlock(&mut self, achievement: AchievementId) -> bool {
|
|
if self.unlocked.insert(achievement.clone()) {
|
|
self.newly_unlocked.push(achievement);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn take_newly_unlocked(&mut self) -> Vec<AchievementId> {
|
|
std::mem::take(&mut self.newly_unlocked)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn is_unlocked(&self, achievement: &AchievementId) -> bool {
|
|
self.unlocked.contains(achievement)
|
|
}
|
|
|
|
pub fn start_session(&mut self) {
|
|
self.session_start = Some(Utc::now());
|
|
}
|
|
}
|
|
|
|
impl Default for AchievementProgress {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub fn get_achievement_info(id: &AchievementId) -> Achievement {
|
|
match id {
|
|
// Token Milestones
|
|
AchievementId::FirstSteps => Achievement {
|
|
id: id.clone(),
|
|
name: "First Steps!".to_string(),
|
|
description: "Used 1,000 tokens".to_string(),
|
|
icon: "🌱".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::GrowingStrong => Achievement {
|
|
id: id.clone(),
|
|
name: "Growing Strong!".to_string(),
|
|
description: "Used 10,000 tokens".to_string(),
|
|
icon: "🌸".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::BlossomingCoder => Achievement {
|
|
id: id.clone(),
|
|
name: "Blossoming Coder!".to_string(),
|
|
description: "Used 100,000 tokens".to_string(),
|
|
icon: "🌺".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::TokenMaster => Achievement {
|
|
id: id.clone(),
|
|
name: "Token Master!".to_string(),
|
|
description: "Used 1,000,000 tokens".to_string(),
|
|
icon: "🌟".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Code Generation
|
|
AchievementId::HelloWorld => Achievement {
|
|
id: id.clone(),
|
|
name: "Hello World!".to_string(),
|
|
description: "Generated your first code block".to_string(),
|
|
icon: "📝".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::CodeWizard => Achievement {
|
|
id: id.clone(),
|
|
name: "Code Wizard!".to_string(),
|
|
description: "Generated 100 code blocks".to_string(),
|
|
icon: "🎯".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::ThousandBlocks => Achievement {
|
|
id: id.clone(),
|
|
name: "Thousand Blocks".to_string(),
|
|
description: "1,000 code blocks! You're a code machine!".to_string(),
|
|
icon: "🏗️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// File Operations
|
|
AchievementId::FileManipulator => Achievement {
|
|
id: id.clone(),
|
|
name: "File Manipulator".to_string(),
|
|
description: "Edited 10 files".to_string(),
|
|
icon: "📝".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FileArchitect => Achievement {
|
|
id: id.clone(),
|
|
name: "File Architect".to_string(),
|
|
description: "Created or edited 100 files".to_string(),
|
|
icon: "🏛️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Conversation milestones
|
|
AchievementId::ConversationStarter => Achievement {
|
|
id: id.clone(),
|
|
name: "Conversation Starter".to_string(),
|
|
description: "Exchanged 10 messages".to_string(),
|
|
icon: "💬".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::ChattyKathy => Achievement {
|
|
id: id.clone(),
|
|
name: "Chatty Kathy".to_string(),
|
|
description: "100 messages exchanged".to_string(),
|
|
icon: "🗣️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::Conversationalist => Achievement {
|
|
id: id.clone(),
|
|
name: "Master Conversationalist".to_string(),
|
|
description: "1,000 messages! We're really connecting!".to_string(),
|
|
icon: "💖".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Tool usage
|
|
AchievementId::Toolsmith => Achievement {
|
|
id: id.clone(),
|
|
name: "Toolsmith".to_string(),
|
|
description: "Used 5 different tools".to_string(),
|
|
icon: "🔨".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::ToolMaster => Achievement {
|
|
id: id.clone(),
|
|
name: "Tool Master".to_string(),
|
|
description: "Used 10 different tools efficiently".to_string(),
|
|
icon: "🛠️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Time-based achievements
|
|
AchievementId::EarlyBird => Achievement {
|
|
id: id.clone(),
|
|
name: "Early Bird".to_string(),
|
|
description: "Started a session between 5 AM and 7 AM".to_string(),
|
|
icon: "🌅".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::NightOwl => Achievement {
|
|
id: id.clone(),
|
|
name: "Night Owl".to_string(),
|
|
description: "Coding after midnight".to_string(),
|
|
icon: "🦉".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::AllNighter => Achievement {
|
|
id: id.clone(),
|
|
name: "All Nighter".to_string(),
|
|
description: "Worked through the night (2 AM - 5 AM)".to_string(),
|
|
icon: "🌙".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::WeekendWarrior => Achievement {
|
|
id: id.clone(),
|
|
name: "Weekend Warrior".to_string(),
|
|
description: "Coding on a weekend".to_string(),
|
|
icon: "⚔️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::DedicatedDeveloper => Achievement {
|
|
id: id.clone(),
|
|
name: "Dedicated Developer".to_string(),
|
|
description: "Coded for 30 days in a row".to_string(),
|
|
icon: "🏆".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Search and exploration
|
|
AchievementId::Explorer => Achievement {
|
|
id: id.clone(),
|
|
name: "Explorer".to_string(),
|
|
description: "Used search tools 50 times".to_string(),
|
|
icon: "🔍".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::MasterSearcher => Achievement {
|
|
id: id.clone(),
|
|
name: "Master Searcher".to_string(),
|
|
description: "Searched 500 times across files".to_string(),
|
|
icon: "🕵️♀️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Session achievements
|
|
AchievementId::QuickSession => Achievement {
|
|
id: id.clone(),
|
|
name: "Quick Session".to_string(),
|
|
description: "Completed a productive session in under 5 minutes".to_string(),
|
|
icon: "⚡".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FocusedWork => Achievement {
|
|
id: id.clone(),
|
|
name: "Focused Work".to_string(),
|
|
description: "Worked for 30 minutes straight".to_string(),
|
|
icon: "🎯".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::DeepDive => Achievement {
|
|
id: id.clone(),
|
|
name: "Deep Dive".to_string(),
|
|
description: "Worked for 2 hours continuously".to_string(),
|
|
icon: "🏊♀️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::MarathonSession => Achievement {
|
|
id: id.clone(),
|
|
name: "Marathon Session".to_string(),
|
|
description: "5+ hour coding session!".to_string(),
|
|
icon: "🏃♀️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Special achievements
|
|
AchievementId::FirstMessage => Achievement {
|
|
id: id.clone(),
|
|
name: "First Message".to_string(),
|
|
description: "Sent your first message to Hikari".to_string(),
|
|
icon: "✨".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FirstTool => Achievement {
|
|
id: id.clone(),
|
|
name: "First Tool".to_string(),
|
|
description: "Used your first tool".to_string(),
|
|
icon: "🔧".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FirstCodeBlock => Achievement {
|
|
id: id.clone(),
|
|
name: "First Code".to_string(),
|
|
description: "Generated your first code block".to_string(),
|
|
icon: "📦".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FirstFileEdit => Achievement {
|
|
id: id.clone(),
|
|
name: "First Edit".to_string(),
|
|
description: "Made your first file edit".to_string(),
|
|
icon: "✏️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::Polyglot => Achievement {
|
|
id: id.clone(),
|
|
name: "Polyglot".to_string(),
|
|
description: "Generated code in 5+ languages in one session".to_string(),
|
|
icon: "🌍".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::SpeedCoder => Achievement {
|
|
id: id.clone(),
|
|
name: "Speed Coder".to_string(),
|
|
description: "Generated 10 code blocks in 10 minutes".to_string(),
|
|
icon: "🚀".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::ClaudeConnoisseur => Achievement {
|
|
id: id.clone(),
|
|
name: "Claude Connoisseur".to_string(),
|
|
description: "Used all available Claude models".to_string(),
|
|
icon: "🎨".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::MarathonCoder => Achievement {
|
|
id: id.clone(),
|
|
name: "Marathon Coder".to_string(),
|
|
description: "10,000 tokens in a single session".to_string(),
|
|
icon: "🏃♂️".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Relationship & Greetings
|
|
AchievementId::GoodMorning => Achievement {
|
|
id: id.clone(),
|
|
name: "Good Morning!".to_string(),
|
|
description: "Greeted Hikari with a good morning".to_string(),
|
|
icon: "🌅".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::GoodNight => Achievement {
|
|
id: id.clone(),
|
|
name: "Good Night".to_string(),
|
|
description: "Said good night to Hikari".to_string(),
|
|
icon: "🌙".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::ThankYou => Achievement {
|
|
id: id.clone(),
|
|
name: "Grateful Heart".to_string(),
|
|
description: "Thanked Hikari for her help".to_string(),
|
|
icon: "💝".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::LoveYou => Achievement {
|
|
id: id.clone(),
|
|
name: "Love Connection".to_string(),
|
|
description: "Expressed love to Hikari".to_string(),
|
|
icon: "💕".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Personality & Fun
|
|
AchievementId::EmojiUser => Achievement {
|
|
id: id.clone(),
|
|
name: "Emoji Enthusiast".to_string(),
|
|
description: "Used an emoji in your message".to_string(),
|
|
icon: "😊".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::QuestionMaster => Achievement {
|
|
id: id.clone(),
|
|
name: "Question Master".to_string(),
|
|
description: "Asked 20 questions".to_string(),
|
|
icon: "❓".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::CapsLock => Achievement {
|
|
id: id.clone(),
|
|
name: "CAPS LOCK ENGAGED".to_string(),
|
|
description: "SENT A MESSAGE IN ALL CAPS".to_string(),
|
|
icon: "📢".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::PleaseAndThankYou => Achievement {
|
|
id: id.clone(),
|
|
name: "Polite Programmer".to_string(),
|
|
description: "Said please in a request".to_string(),
|
|
icon: "🎩".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Git & Development
|
|
AchievementId::GitGuru => Achievement {
|
|
id: id.clone(),
|
|
name: "Git Guru".to_string(),
|
|
description: "Used git commands 10 times".to_string(),
|
|
icon: "🌿".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::TestWriter => Achievement {
|
|
id: id.clone(),
|
|
name: "Test Writer".to_string(),
|
|
description: "Created test files".to_string(),
|
|
icon: "🧪".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::Debugger => Achievement {
|
|
id: id.clone(),
|
|
name: "Bug Squasher".to_string(),
|
|
description: "Fixed bugs and errors".to_string(),
|
|
icon: "🐛".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
|
|
// Tool Mastery
|
|
AchievementId::BashMaster => Achievement {
|
|
id: id.clone(),
|
|
name: "Bash Master".to_string(),
|
|
description: "Used Bash tool 50 times".to_string(),
|
|
icon: "🐚".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::FileExplorer => Achievement {
|
|
id: id.clone(),
|
|
name: "File Explorer".to_string(),
|
|
description: "Read 100 files".to_string(),
|
|
icon: "📂".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
AchievementId::SearchExpert => Achievement {
|
|
id: id.clone(),
|
|
name: "Search Expert".to_string(),
|
|
description: "Used Grep tool 50 times".to_string(),
|
|
icon: "🔎".to_string(),
|
|
unlocked_at: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Check achievements based on message content
|
|
pub fn check_message_achievements(
|
|
message: &str,
|
|
progress: &mut AchievementProgress,
|
|
) -> Vec<AchievementId> {
|
|
let mut newly_unlocked = Vec::new();
|
|
let message_lower = message.to_lowercase();
|
|
|
|
println!("Checking message achievements for: {}", message);
|
|
|
|
// Relationship & Greetings
|
|
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
|
newly_unlocked.push(AchievementId::GoodMorning);
|
|
}
|
|
if (message_lower.contains("good night") || message_lower.contains("goodnight"))
|
|
&& progress.unlock(AchievementId::GoodNight)
|
|
{
|
|
newly_unlocked.push(AchievementId::GoodNight);
|
|
}
|
|
if (message_lower.contains("thank you")
|
|
|| message_lower.contains("thanks")
|
|
|| message_lower.contains("thx"))
|
|
&& progress.unlock(AchievementId::ThankYou)
|
|
{
|
|
newly_unlocked.push(AchievementId::ThankYou);
|
|
}
|
|
if (message_lower.contains("love you") || message_lower.contains("ily"))
|
|
&& progress.unlock(AchievementId::LoveYou)
|
|
{
|
|
newly_unlocked.push(AchievementId::LoveYou);
|
|
}
|
|
|
|
// Personality & Fun
|
|
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
|
|
newly_unlocked.push(AchievementId::EmojiUser);
|
|
}
|
|
if message == message.to_uppercase()
|
|
&& message.len() > 5
|
|
&& message.chars().any(|c| c.is_alphabetic())
|
|
&& progress.unlock(AchievementId::CapsLock)
|
|
{
|
|
newly_unlocked.push(AchievementId::CapsLock);
|
|
}
|
|
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
|
|
newly_unlocked.push(AchievementId::PleaseAndThankYou);
|
|
}
|
|
|
|
// Git & Development patterns in messages
|
|
if (message_lower.contains("fix")
|
|
|| message_lower.contains("bug")
|
|
|| message_lower.contains("error"))
|
|
&& progress.unlock(AchievementId::Debugger)
|
|
{
|
|
newly_unlocked.push(AchievementId::Debugger);
|
|
}
|
|
|
|
newly_unlocked
|
|
}
|
|
|
|
// Check which achievements should be unlocked based on current stats
|
|
pub fn check_achievements(
|
|
stats: &crate::stats::UsageStats,
|
|
progress: &mut AchievementProgress,
|
|
) -> Vec<AchievementId> {
|
|
let mut newly_unlocked = Vec::new();
|
|
|
|
println!(
|
|
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
|
stats.messages_exchanged,
|
|
stats.total_input_tokens + stats.total_output_tokens,
|
|
stats.code_blocks_generated
|
|
);
|
|
println!("Currently unlocked: {:?}", progress.unlocked);
|
|
|
|
// Token milestones
|
|
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
|
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
|
println!("Unlocked FirstSteps achievement!");
|
|
newly_unlocked.push(AchievementId::FirstSteps);
|
|
}
|
|
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
|
newly_unlocked.push(AchievementId::GrowingStrong);
|
|
}
|
|
if total_tokens >= 100_000 && progress.unlock(AchievementId::BlossomingCoder) {
|
|
newly_unlocked.push(AchievementId::BlossomingCoder);
|
|
}
|
|
if total_tokens >= 1_000_000 && progress.unlock(AchievementId::TokenMaster) {
|
|
newly_unlocked.push(AchievementId::TokenMaster);
|
|
}
|
|
|
|
// Code generation
|
|
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::HelloWorld) {
|
|
newly_unlocked.push(AchievementId::HelloWorld);
|
|
}
|
|
if stats.code_blocks_generated >= 100 && progress.unlock(AchievementId::CodeWizard) {
|
|
newly_unlocked.push(AchievementId::CodeWizard);
|
|
}
|
|
if stats.code_blocks_generated >= 1000 && progress.unlock(AchievementId::ThousandBlocks) {
|
|
newly_unlocked.push(AchievementId::ThousandBlocks);
|
|
}
|
|
|
|
// File operations
|
|
if stats.files_edited >= 10 && progress.unlock(AchievementId::FileManipulator) {
|
|
newly_unlocked.push(AchievementId::FileManipulator);
|
|
}
|
|
let total_files = stats.files_edited + stats.files_created;
|
|
if total_files >= 100 && progress.unlock(AchievementId::FileArchitect) {
|
|
newly_unlocked.push(AchievementId::FileArchitect);
|
|
}
|
|
|
|
// Conversation milestones
|
|
if stats.messages_exchanged >= 1 && progress.unlock(AchievementId::FirstMessage) {
|
|
newly_unlocked.push(AchievementId::FirstMessage);
|
|
}
|
|
if stats.messages_exchanged >= 10 && progress.unlock(AchievementId::ConversationStarter) {
|
|
newly_unlocked.push(AchievementId::ConversationStarter);
|
|
}
|
|
if stats.messages_exchanged >= 100 && progress.unlock(AchievementId::ChattyKathy) {
|
|
newly_unlocked.push(AchievementId::ChattyKathy);
|
|
}
|
|
if stats.messages_exchanged >= 1000 && progress.unlock(AchievementId::Conversationalist) {
|
|
newly_unlocked.push(AchievementId::Conversationalist);
|
|
}
|
|
|
|
// Tool usage
|
|
let unique_tools = stats.tools_usage.len();
|
|
if unique_tools >= 5 && progress.unlock(AchievementId::Toolsmith) {
|
|
newly_unlocked.push(AchievementId::Toolsmith);
|
|
}
|
|
if unique_tools >= 10 && progress.unlock(AchievementId::ToolMaster) {
|
|
newly_unlocked.push(AchievementId::ToolMaster);
|
|
}
|
|
|
|
// Search and exploration
|
|
let search_tools = ["Glob", "Grep", "search", "Task"];
|
|
let search_count: u64 = search_tools
|
|
.iter()
|
|
.filter_map(|tool| stats.tools_usage.get(*tool))
|
|
.sum();
|
|
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
|
newly_unlocked.push(AchievementId::Explorer);
|
|
}
|
|
if search_count >= 500 && progress.unlock(AchievementId::MasterSearcher) {
|
|
newly_unlocked.push(AchievementId::MasterSearcher);
|
|
}
|
|
|
|
// Session duration achievements
|
|
let session_secs = stats.session_duration_seconds;
|
|
if session_secs < 300
|
|
&& stats.session_messages_exchanged >= 5
|
|
&& progress.unlock(AchievementId::QuickSession)
|
|
{
|
|
newly_unlocked.push(AchievementId::QuickSession);
|
|
}
|
|
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
|
|
newly_unlocked.push(AchievementId::FocusedWork);
|
|
}
|
|
if session_secs >= 7200 && progress.unlock(AchievementId::DeepDive) {
|
|
newly_unlocked.push(AchievementId::DeepDive);
|
|
}
|
|
if session_secs >= 18000 && progress.unlock(AchievementId::MarathonSession) {
|
|
newly_unlocked.push(AchievementId::MarathonSession);
|
|
}
|
|
|
|
// Session token achievement
|
|
let session_tokens = stats.session_input_tokens + stats.session_output_tokens;
|
|
if session_tokens >= 10000 && progress.unlock(AchievementId::MarathonCoder) {
|
|
newly_unlocked.push(AchievementId::MarathonCoder);
|
|
}
|
|
|
|
// Special first-time achievements
|
|
if !stats.tools_usage.is_empty() && progress.unlock(AchievementId::FirstTool) {
|
|
newly_unlocked.push(AchievementId::FirstTool);
|
|
}
|
|
if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) {
|
|
newly_unlocked.push(AchievementId::FirstCodeBlock);
|
|
}
|
|
if stats.files_edited >= 1 && progress.unlock(AchievementId::FirstFileEdit) {
|
|
newly_unlocked.push(AchievementId::FirstFileEdit);
|
|
}
|
|
|
|
// Speed coder - need to track time for this
|
|
// TODO: Implement tracking for 10 code blocks in 10 minutes
|
|
|
|
// Polyglot - need to track languages
|
|
// TODO: Implement tracking for multiple programming languages
|
|
|
|
// Claude Connoisseur - check model usage
|
|
// TODO: Track different Claude models used
|
|
|
|
// Tool mastery achievements
|
|
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
|
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
|
newly_unlocked.push(AchievementId::BashMaster);
|
|
}
|
|
}
|
|
if let Some(read_count) = stats.tools_usage.get("Read") {
|
|
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
|
newly_unlocked.push(AchievementId::FileExplorer);
|
|
}
|
|
}
|
|
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
|
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
|
newly_unlocked.push(AchievementId::SearchExpert);
|
|
}
|
|
}
|
|
|
|
// Git Guru - check git command usage in Bash
|
|
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
|
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
|
// TODO: More specific git command tracking
|
|
newly_unlocked.push(AchievementId::GitGuru);
|
|
}
|
|
}
|
|
|
|
// Time-based achievements
|
|
if let Some(session_start) = progress.session_start {
|
|
let hour = session_start.hour();
|
|
let weekday = session_start.weekday();
|
|
|
|
// Early bird - 5 AM to 7 AM
|
|
if (5..=7).contains(&hour) && progress.unlock(AchievementId::EarlyBird) {
|
|
newly_unlocked.push(AchievementId::EarlyBird);
|
|
}
|
|
|
|
// Night owl - after midnight
|
|
let current_hour = Utc::now().hour();
|
|
if current_hour < 6 && progress.unlock(AchievementId::NightOwl) {
|
|
newly_unlocked.push(AchievementId::NightOwl);
|
|
}
|
|
|
|
// All nighter - 2 AM to 5 AM
|
|
if (2..=5).contains(¤t_hour) && progress.unlock(AchievementId::AllNighter) {
|
|
newly_unlocked.push(AchievementId::AllNighter);
|
|
}
|
|
|
|
// Weekend warrior
|
|
use chrono::Weekday;
|
|
if (weekday == Weekday::Sat || weekday == Weekday::Sun)
|
|
&& progress.unlock(AchievementId::WeekendWarrior)
|
|
{
|
|
newly_unlocked.push(AchievementId::WeekendWarrior);
|
|
}
|
|
}
|
|
|
|
// Dedicated Developer - need to track consecutive days
|
|
// TODO: Implement 30 days in a row tracking
|
|
|
|
newly_unlocked
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AchievementUnlockedEvent {
|
|
pub achievement: Achievement,
|
|
}
|
|
|
|
// Save achievements to persistent store
|
|
pub async fn save_achievements(
|
|
app: &tauri::AppHandle,
|
|
progress: &AchievementProgress,
|
|
) -> Result<(), String> {
|
|
let store = app.store("achievements.json").map_err(|e| e.to_string())?;
|
|
|
|
// Create a serializable version with just the unlocked achievement IDs
|
|
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
|
|
|
println!("Saving achievements: {:?}", unlocked_list);
|
|
|
|
store.set(
|
|
"unlocked",
|
|
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
|
|
);
|
|
store.save().map_err(|e| e.to_string())?;
|
|
|
|
println!("Achievements saved successfully");
|
|
Ok(())
|
|
}
|
|
|
|
// Load achievements from persistent store
|
|
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
|
println!("Loading achievements from store...");
|
|
|
|
let store = match app.store("achievements.json") {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
println!("Failed to open achievements store: {}", e);
|
|
return AchievementProgress::new();
|
|
}
|
|
};
|
|
|
|
let mut progress = AchievementProgress::new();
|
|
|
|
// Get unlocked achievements
|
|
if let Some(unlocked_value) = store.get("unlocked") {
|
|
println!("Found unlocked value in store: {:?}", unlocked_value);
|
|
if let Ok(unlocked_list) =
|
|
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
|
{
|
|
println!("Loaded {} achievements", unlocked_list.len());
|
|
for achievement_id in unlocked_list {
|
|
progress.unlocked.insert(achievement_id);
|
|
}
|
|
} else {
|
|
println!("Failed to parse unlocked achievements");
|
|
}
|
|
} else {
|
|
println!("No unlocked achievements found in store");
|
|
}
|
|
|
|
progress
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_achievement_unlock() {
|
|
let mut progress = AchievementProgress::new();
|
|
|
|
// First unlock should return true
|
|
assert!(progress.unlock(AchievementId::FirstSteps));
|
|
assert!(progress.is_unlocked(&AchievementId::FirstSteps));
|
|
|
|
// Second unlock of same achievement should return false
|
|
assert!(!progress.unlock(AchievementId::FirstSteps));
|
|
|
|
// Newly unlocked should contain the achievement
|
|
let newly = progress.take_newly_unlocked();
|
|
assert_eq!(newly.len(), 1);
|
|
assert_eq!(newly[0], AchievementId::FirstSteps);
|
|
|
|
// After taking, newly unlocked should be empty
|
|
let newly = progress.take_newly_unlocked();
|
|
assert!(newly.is_empty());
|
|
}
|
|
}
|