1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub enum Species {
10 Egg,
11 Crab,
12 Snake,
13 Owl,
14 Gopher,
15 Whale,
16 Fox,
17 Dragon,
18}
19
20impl Species {
21 pub fn label(&self) -> &'static str {
22 match self {
23 Self::Egg => "Egg",
24 Self::Crab => "Crab",
25 Self::Snake => "Snake",
26 Self::Owl => "Owl",
27 Self::Gopher => "Gopher",
28 Self::Whale => "Whale",
29 Self::Fox => "Fox",
30 Self::Dragon => "Dragon",
31 }
32 }
33
34 pub fn from_commands(commands: &HashMap<String, super::stats::CommandStats>) -> Self {
35 let mut scores: HashMap<&str, u64> = HashMap::new();
36
37 for (cmd, stats) in commands {
38 let lang = classify_command(cmd);
39 if !lang.is_empty() {
40 *scores.entry(lang).or_default() += stats.count;
41 }
42 }
43
44 if scores.is_empty() {
45 return Self::Egg;
46 }
47
48 let total: u64 = scores.values().sum();
49 let (top_lang, top_count) = scores
50 .iter()
51 .max_by_key(|(_, c)| **c)
52 .map(|(l, c)| (*l, *c))
53 .unwrap_or(("", 0));
54
55 let dominance = top_count as f64 / total as f64;
56
57 if dominance < 0.4 {
58 return Self::Dragon;
59 }
60
61 match top_lang {
62 "rust" => Self::Crab,
63 "python" => Self::Snake,
64 "js" => Self::Owl,
65 "go" => Self::Gopher,
66 "docker" => Self::Whale,
67 "git" => Self::Fox,
68 _ => Self::Dragon,
69 }
70 }
71}
72
73fn classify_command(cmd: &str) -> &'static str {
74 let lower = cmd.to_lowercase();
75 if lower.starts_with("cargo") || lower.starts_with("rustc") {
76 "rust"
77 } else if lower.starts_with("python")
78 || lower.starts_with("pip")
79 || lower.starts_with("uv ")
80 || lower.starts_with("pytest")
81 || lower.starts_with("ruff")
82 {
83 "python"
84 } else if lower.starts_with("npm")
85 || lower.starts_with("pnpm")
86 || lower.starts_with("yarn")
87 || lower.starts_with("tsc")
88 || lower.starts_with("jest")
89 || lower.starts_with("vitest")
90 || lower.starts_with("node")
91 || lower.starts_with("bun")
92 {
93 "js"
94 } else if lower.starts_with("go ") {
95 "go"
96 } else if lower.starts_with("docker") || lower.starts_with("kubectl") {
97 "docker"
98 } else if lower.starts_with("git ") {
99 "git"
100 } else {
101 ""
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
110pub enum Rarity {
111 Egg,
112 Common,
113 Uncommon,
114 Rare,
115 Epic,
116 Legendary,
117}
118
119impl Rarity {
120 pub fn from_tokens_saved(saved: u64) -> Self {
121 match saved {
122 0..=9_999 => Self::Egg,
123 10_000..=99_999 => Self::Common,
124 100_000..=999_999 => Self::Uncommon,
125 1_000_000..=9_999_999 => Self::Rare,
126 10_000_000..=99_999_999 => Self::Epic,
127 _ => Self::Legendary,
128 }
129 }
130
131 pub fn label(&self) -> &'static str {
132 match self {
133 Self::Egg => "Egg",
134 Self::Common => "Common",
135 Self::Uncommon => "Uncommon",
136 Self::Rare => "Rare",
137 Self::Epic => "Epic",
138 Self::Legendary => "Legendary",
139 }
140 }
141
142 pub fn color_code(&self) -> &'static str {
143 match self {
144 Self::Egg => "\x1b[37m",
145 Self::Common => "\x1b[37m",
146 Self::Uncommon => "\x1b[32m",
147 Self::Rare => "\x1b[34m",
148 Self::Epic => "\x1b[35m",
149 Self::Legendary => "\x1b[33m",
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub enum Mood {
160 Ecstatic,
161 Happy,
162 Content,
163 Worried,
164 Sleeping,
165}
166
167impl Mood {
168 pub fn label(&self) -> &'static str {
169 match self {
170 Self::Ecstatic => "Ecstatic",
171 Self::Happy => "Happy",
172 Self::Content => "Content",
173 Self::Worried => "Worried",
174 Self::Sleeping => "Sleeping",
175 }
176 }
177
178 pub fn icon(&self) -> &'static str {
179 match self {
180 Self::Ecstatic => "*_*",
181 Self::Happy => "o_o",
182 Self::Content => "-_-",
183 Self::Worried => ">_<",
184 Self::Sleeping => "u_u",
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct BuddyStats {
195 pub compression: u8,
196 pub vigilance: u8,
197 pub endurance: u8,
198 pub wisdom: u8,
199 pub experience: u8,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct CreatureTraits {
209 pub head: u8,
210 pub eyes: u8,
211 pub mouth: u8,
212 pub ears: u8,
213 pub body: u8,
214 pub legs: u8,
215 pub tail: u8,
216 pub markings: u8,
217}
218
219impl CreatureTraits {
220 pub fn from_seed(seed: u64) -> Self {
221 Self {
222 head: (seed % 12) as u8,
223 eyes: ((seed / 12) % 10) as u8,
224 mouth: ((seed / 120) % 10) as u8,
225 ears: ((seed / 1_200) % 12) as u8,
226 body: ((seed / 14_400) % 10) as u8,
227 legs: ((seed / 144_000) % 10) as u8,
228 tail: ((seed / 1_440_000) % 8) as u8,
229 markings: ((seed / 11_520_000) % 6) as u8,
230 }
231 }
232}
233
234fn user_seed() -> u64 {
235 dirs::home_dir()
236 .map(|p| {
237 use std::collections::hash_map::DefaultHasher;
238 use std::hash::{Hash, Hasher};
239 let mut h = DefaultHasher::new();
240 p.hash(&mut h);
241 h.finish()
242 })
243 .unwrap_or(42)
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct BuddyState {
252 pub name: String,
253 pub species: Species,
254 pub rarity: Rarity,
255 pub level: u32,
256 pub xp: u64,
257 pub xp_next_level: u64,
258 pub mood: Mood,
259 pub stats: BuddyStats,
260 pub speech: String,
261 pub tokens_saved: u64,
262 pub bugs_prevented: u64,
263 pub streak_days: u32,
264 pub ascii_art: Vec<String>,
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub ascii_frames: Vec<Vec<String>>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub anim_ms: Option<u32>,
269 pub traits: CreatureTraits,
270}
271
272impl BuddyState {
273 pub fn compute() -> Self {
274 let store = super::stats::load();
275 let tokens_saved = store
276 .total_input_tokens
277 .saturating_sub(store.total_output_tokens);
278
279 let project_root = detect_project_root_for_buddy();
280 let gotcha_store = if !project_root.is_empty() {
281 super::gotcha_tracker::GotchaStore::load(&project_root)
282 } else {
283 super::gotcha_tracker::GotchaStore::new("none")
284 };
285
286 let bugs_prevented = gotcha_store.stats.total_prevented;
287 let errors_detected = gotcha_store.stats.total_errors_detected;
288
289 let species = Species::from_commands(&store.commands);
290 let rarity = Rarity::from_tokens_saved(tokens_saved);
291
292 let xp = tokens_saved / 1000 + store.total_commands * 5 + bugs_prevented * 100;
293 let level = ((xp as f64 / 50.0).sqrt().floor() as u32).min(99);
294 let xp_next_level = ((level + 1) as u64) * ((level + 1) as u64) * 50;
295
296 let streak_days = compute_streak(&store.daily);
297 let compression_rate = if store.total_input_tokens > 0 {
298 (tokens_saved as f64 / store.total_input_tokens as f64 * 100.0) as u8
299 } else {
300 0
301 };
302
303 let mood = compute_mood(
304 compression_rate,
305 errors_detected,
306 bugs_prevented,
307 streak_days,
308 &store,
309 );
310
311 let rpg_stats = compute_rpg_stats(
312 compression_rate,
313 bugs_prevented,
314 errors_detected,
315 streak_days,
316 store.commands.len(),
317 store.total_commands,
318 );
319
320 let seed = user_seed();
321 let traits = CreatureTraits::from_seed(seed);
322 let name = generate_name(seed);
323 let sprite = render_sprite_pack(&traits, &mood, level);
324 let ascii_art = sprite.base.clone();
325 let speech = generate_speech(&mood, tokens_saved, bugs_prevented, streak_days);
326
327 Self {
328 name,
329 species,
330 rarity,
331 level,
332 xp,
333 xp_next_level,
334 mood,
335 stats: rpg_stats,
336 speech,
337 tokens_saved,
338 bugs_prevented,
339 streak_days,
340 ascii_art,
341 ascii_frames: sprite.frames,
342 anim_ms: sprite.anim_ms,
343 traits,
344 }
345 }
346}
347
348fn detect_project_root_for_buddy() -> String {
349 if let Some(session) = super::session::SessionState::load_latest() {
350 if let Some(root) = session.project_root.as_deref() {
351 if !root.trim().is_empty() {
352 return root.to_string();
353 }
354 }
355 if let Some(cwd) = session.shell_cwd.as_deref() {
356 if !cwd.trim().is_empty() {
357 return super::protocol::detect_project_root_or_cwd(cwd);
358 }
359 }
360 if let Some(last) = session.files_touched.last() {
361 if !last.path.trim().is_empty() {
362 if let Some(parent) = std::path::Path::new(&last.path).parent() {
363 let p = parent.to_string_lossy().to_string();
364 return super::protocol::detect_project_root_or_cwd(&p);
365 }
366 }
367 }
368 }
369 std::env::current_dir()
370 .map(|p| super::protocol::detect_project_root_or_cwd(&p.to_string_lossy()))
371 .unwrap_or_default()
372}
373
374struct SpritePack {
375 base: Vec<String>,
376 frames: Vec<Vec<String>>,
377 anim_ms: Option<u32>,
378}
379
380fn sprite_tier(level: u32) -> u8 {
381 if level >= 75 {
382 4
383 } else if level >= 50 {
384 3
385 } else if level >= 25 {
386 2
387 } else if level >= 10 {
388 1
389 } else {
390 0
391 }
392}
393
394fn tier_anim_ms(tier: u8) -> Option<u32> {
395 match tier {
396 0 => None,
397 1 => Some(950),
398 2 => Some(700),
399 3 => Some(520),
400 _ => Some(380),
401 }
402}
403
404fn render_sprite_pack(traits: &CreatureTraits, mood: &Mood, level: u32) -> SpritePack {
405 let base = render_sprite(traits, mood);
406 let tier = sprite_tier(level);
407 if tier == 0 {
408 return SpritePack {
409 base,
410 frames: Vec::new(),
411 anim_ms: None,
412 };
413 }
414
415 let mut frames = Vec::new();
416 frames.push(base.clone());
417
418 let blink = match mood {
420 Mood::Sleeping => ("u", "u"),
421 _ => (".", "."),
422 };
423 frames.push(render_sprite_with_eyes(traits, mood, blink.0, blink.1));
424
425 if tier >= 2 {
427 let mut s = base.clone();
428 if let Some(l0) = s.get_mut(0) {
429 *l0 = sparkle_edges(l0, '*', '+');
430 }
431 frames.push(s);
432 }
433 if tier >= 3 {
434 let mut s = base.clone();
435 for line in &mut s {
436 *line = shift(line, 1);
437 }
438 frames.push(s);
439 }
440 if tier >= 4 {
441 let mut s = base.clone();
442 for (i, line) in s.iter_mut().enumerate() {
443 let (l, r) = if i % 2 == 0 { ('+', '+') } else { ('*', '*') };
444 *line = edge_aura(line, l, r);
445 }
446 frames.push(s);
447 }
448
449 SpritePack {
450 base,
451 frames,
452 anim_ms: tier_anim_ms(tier),
453 }
454}
455
456fn render_sprite_with_eyes(
457 traits: &CreatureTraits,
458 mood: &Mood,
459 el: &str,
460 er: &str,
461) -> Vec<String> {
462 let ear_line = ear_part(traits.ears);
463 let head_top = head_top_part(traits.head);
464 let eye_line = eye_part(traits.head, traits.eyes, el, er, mood);
465 let mouth_line = mouth_part(traits.head, traits.mouth);
466 let head_bottom = head_bottom_part(traits.head, traits.body);
467 let body_line = body_part(traits.body, traits.markings);
468 let leg_line = leg_part(traits.legs, traits.tail);
469
470 vec![
471 pad(&ear_line),
472 pad(&head_top),
473 pad(&eye_line),
474 pad(&mouth_line),
475 pad(&head_bottom),
476 pad(&body_line),
477 pad(&leg_line),
478 ]
479}
480
481fn sparkle_edges(line: &str, left: char, right: char) -> String {
482 let mut chars: Vec<char> = line.chars().collect();
483 if !chars.is_empty() {
484 chars[0] = left;
485 let last = chars.len().saturating_sub(1);
486 chars[last] = right;
487 }
488 pad(&chars.into_iter().collect::<String>())
489}
490
491fn edge_aura(line: &str, left: char, right: char) -> String {
492 let mut chars: Vec<char> = line.chars().collect();
493 if chars.len() >= 2 {
494 chars[0] = left;
495 let last = chars.len() - 1;
496 chars[last] = right;
497 }
498 pad(&chars.into_iter().collect::<String>())
499}
500
501fn shift(line: &str, offset: i32) -> String {
502 if offset == 0 {
503 return pad(line);
504 }
505 let s = pad(line);
506 let mut chars: Vec<char> = s.chars().collect();
507 if chars.is_empty() {
508 return s;
509 }
510 if offset > 0 {
511 for _ in 0..offset {
512 chars.insert(0, ' ');
513 chars.pop();
514 }
515 } else {
516 for _ in 0..(-offset) {
517 chars.remove(0);
518 chars.push(' ');
519 }
520 }
521 chars.into_iter().collect()
522}
523
524fn sprite_lines_for_tick(state: &BuddyState, tick: Option<u64>) -> &[String] {
525 if let Some(t) = tick {
526 if !state.ascii_frames.is_empty() {
527 let idx = (t as usize) % state.ascii_frames.len();
528 return &state.ascii_frames[idx];
529 }
530 }
531 &state.ascii_art
532}
533
534fn compute_mood(
539 compression: u8,
540 errors: u64,
541 prevented: u64,
542 streak: u32,
543 store: &super::stats::StatsStore,
544) -> Mood {
545 let hours_since_last = store
546 .last_use
547 .as_ref()
548 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
549 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_hours())
550 .unwrap_or(999);
551
552 if hours_since_last > 48 {
553 return Mood::Sleeping;
554 }
555
556 let recent_errors = store
557 .daily
558 .iter()
559 .rev()
560 .take(1)
561 .any(|d| d.input_tokens > 0 && d.output_tokens > d.input_tokens);
562
563 if compression > 60 && errors == 0 && streak >= 7 {
564 Mood::Ecstatic
565 } else if compression > 40 || prevented > 0 {
566 Mood::Happy
567 } else if recent_errors || (errors > 5 && prevented == 0) {
568 Mood::Worried
569 } else {
570 Mood::Content
571 }
572}
573
574fn compute_rpg_stats(
579 compression: u8,
580 prevented: u64,
581 errors: u64,
582 streak: u32,
583 unique_cmds: usize,
584 total_cmds: u64,
585) -> BuddyStats {
586 let compression_stat = compression.min(100);
587
588 let vigilance = if errors > 0 {
589 ((prevented as f64 / errors as f64) * 80.0).min(100.0) as u8
590 } else if prevented > 0 {
591 100
592 } else {
593 20
594 };
595
596 let endurance = (streak * 5).min(100) as u8;
597 let wisdom = (unique_cmds as u8).min(100);
598 let experience = if total_cmds > 0 {
599 ((total_cmds as f64).log10() * 25.0).min(100.0) as u8
600 } else {
601 0
602 };
603
604 BuddyStats {
605 compression: compression_stat,
606 vigilance,
607 endurance,
608 wisdom,
609 experience,
610 }
611}
612
613fn compute_streak(daily: &[super::stats::DayStats]) -> u32 {
618 if daily.is_empty() {
619 return 0;
620 }
621
622 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
623 let mut streak = 0u32;
624 let mut expected = today.clone();
625
626 for day in daily.iter().rev() {
627 if day.date == expected && day.commands > 0 {
628 streak += 1;
629 if let Ok(dt) = chrono::NaiveDate::parse_from_str(&expected, "%Y-%m-%d") {
630 expected = (dt - chrono::Duration::days(1))
631 .format("%Y-%m-%d")
632 .to_string();
633 } else {
634 break;
635 }
636 } else if day.date < expected {
637 break;
638 }
639 }
640 streak
641}
642
643fn generate_name(seed: u64) -> String {
648 const ADJ: &[&str] = &[
649 "Swift", "Quiet", "Bright", "Bold", "Clever", "Brave", "Lucky", "Tiny", "Cosmic", "Fuzzy",
650 "Nimble", "Jolly", "Mighty", "Gentle", "Witty", "Keen", "Sly", "Calm", "Wild", "Vivid",
651 "Dusk", "Dawn", "Neon", "Frost", "Solar", "Lunar", "Pixel", "Turbo", "Nano", "Mega",
652 ];
653 const NOUN: &[&str] = &[
654 "Ember", "Reef", "Spark", "Byte", "Flux", "Echo", "Drift", "Glitch", "Pulse", "Shade",
655 "Orbit", "Fern", "Rust", "Zinc", "Flint", "Quartz", "Maple", "Cedar", "Opal", "Moss",
656 "Ridge", "Cove", "Peak", "Dune", "Vale", "Brook", "Cliff", "Storm", "Blaze", "Mist",
657 ];
658
659 let adj_idx = (seed >> 8) as usize % ADJ.len();
660 let noun_idx = (seed >> 16) as usize % NOUN.len();
661 format!("{} {}", ADJ[adj_idx], NOUN[noun_idx])
662}
663
664fn generate_speech(mood: &Mood, tokens_saved: u64, bugs_prevented: u64, streak: u32) -> String {
669 match mood {
670 Mood::Ecstatic => {
671 if bugs_prevented > 0 {
672 format!("{bugs_prevented} bugs prevented! We're unstoppable!")
673 } else {
674 format!("{} tokens saved! On fire!", format_compact(tokens_saved))
675 }
676 }
677 Mood::Happy => {
678 if streak >= 3 {
679 format!("{streak}-day streak! Keep going!")
680 } else if bugs_prevented > 0 {
681 format!("Caught {bugs_prevented} bugs before they happened!")
682 } else {
683 format!("{} tokens saved so far!", format_compact(tokens_saved))
684 }
685 }
686 Mood::Content => "Watching your code... all good.".to_string(),
687 Mood::Worried => "I see some errors. Let's fix them!".to_string(),
688 Mood::Sleeping => "Zzz... wake me with some code!".to_string(),
689 }
690}
691
692fn format_compact(n: u64) -> String {
693 if n >= 1_000_000_000 {
694 format!("{:.1}B", n as f64 / 1_000_000_000.0)
695 } else if n >= 1_000_000 {
696 format!("{:.1}M", n as f64 / 1_000_000.0)
697 } else if n >= 1_000 {
698 format!("{:.1}K", n as f64 / 1_000.0)
699 } else {
700 format!("{n}")
701 }
702}
703
704const W: usize = 16;
711
712fn pad(s: &str) -> String {
713 let len = s.chars().count();
714 if len >= W {
715 s.to_string()
716 } else {
717 format!("{}{}", s, " ".repeat(W - len))
718 }
719}
720
721pub fn render_sprite(traits: &CreatureTraits, mood: &Mood) -> Vec<String> {
722 let eye_l = mood_eye_left(mood);
723 let eye_r = mood_eye_right(mood);
724
725 let ear_line = ear_part(traits.ears);
726 let head_top = head_top_part(traits.head);
727 let eye_line = eye_part(traits.head, traits.eyes, eye_l, eye_r, mood);
728 let mouth_line = mouth_part(traits.head, traits.mouth);
729 let head_bottom = head_bottom_part(traits.head, traits.body);
730 let body_line = body_part(traits.body, traits.markings);
731 let leg_line = leg_part(traits.legs, traits.tail);
732
733 vec![
734 pad(&ear_line),
735 pad(&head_top),
736 pad(&eye_line),
737 pad(&mouth_line),
738 pad(&head_bottom),
739 pad(&body_line),
740 pad(&leg_line),
741 ]
742}
743
744fn mood_eye_left(mood: &Mood) -> &'static str {
745 match mood {
746 Mood::Ecstatic => "*",
747 Mood::Happy => "o",
748 Mood::Content => "-",
749 Mood::Worried => ">",
750 Mood::Sleeping => "u",
751 }
752}
753
754fn mood_eye_right(mood: &Mood) -> &'static str {
755 match mood {
756 Mood::Ecstatic => "*",
757 Mood::Happy => "o",
758 Mood::Content => "-",
759 Mood::Worried => "<",
760 Mood::Sleeping => "u",
761 }
762}
763
764fn ear_part(idx: u8) -> String {
765 match idx % 12 {
766 0 => " /\\ /\\".into(),
767 1 => " / \\/ \\".into(),
768 2 => " \\\\ //".into(),
769 3 => " || ||".into(),
770 4 => " ~' '~".into(),
771 5 => " >> <<".into(),
772 6 => " ** **".into(),
773 7 => " .'' ''. ".into(),
774 8 => " ~~ ~~".into(),
775 9 => " ## ##".into(),
776 10 => " ^^ ^^".into(),
777 _ => " <> <>".into(),
778 }
779}
780
781fn head_top_part(idx: u8) -> String {
782 match idx % 12 {
783 0 => " .____. ".into(),
784 1 => " .----. ".into(),
785 2 => " /----\\ ".into(),
786 3 => " .====. ".into(),
787 4 => " .------. ".into(),
788 5 => " .____. ".into(),
789 6 => " /~~~~\\ ".into(),
790 7 => " {____} ".into(),
791 8 => " <____> ".into(),
792 9 => " .^--^. ".into(),
793 10 => " /****\\ ".into(),
794 _ => " (____) ".into(),
795 }
796}
797
798fn eye_part(head: u8, _eye_idx: u8, el: &str, er: &str, _mood: &Mood) -> String {
799 let bracket = head_bracket(head);
800 match head % 12 {
801 2 => format!(" / {} {} \\ ", el, er),
802 _ => format!(" {} {} {} {} ", bracket.0, el, er, bracket.1),
803 }
804}
805
806fn head_bracket(head: u8) -> (&'static str, &'static str) {
807 match head % 12 {
808 0 => ("/", "\\"),
809 1 => ("|", "|"),
810 2 => ("/", "\\"),
811 3 => ("/", "\\"),
812 4 => ("(", ")"),
813 5 => ("|", "|"),
814 6 => ("|", "|"),
815 7 => ("{", "}"),
816 8 => ("<", ">"),
817 9 => ("(", ")"),
818 10 => ("/", "\\"),
819 _ => ("(", ")"),
820 }
821}
822
823fn mouth_part(head: u8, mouth: u8) -> String {
824 let m = match mouth % 10 {
825 0 => "\\_/",
826 1 => " w ",
827 2 => " ^ ",
828 3 => " ~ ",
829 4 => "===",
830 5 => " o ",
831 6 => " 3 ",
832 7 => " v ",
833 8 => "---",
834 _ => " U ",
835 };
836 let bracket = head_bracket(head);
837 format!(" {} {} {} ", bracket.0, m, bracket.1)
838}
839
840fn head_bottom_part(head: u8, _body: u8) -> String {
841 match head % 12 {
842 0 => " '----' ".into(),
843 1 => " '+--+' ".into(),
844 2 => " \\____/ ".into(),
845 3 => " '====' ".into(),
846 4 => " '------' ".into(),
847 5 => " '----' ".into(),
848 6 => " \\~~~~/ ".into(),
849 7 => " {____} ".into(),
850 8 => " <____> ".into(),
851 9 => " '^--^' ".into(),
852 10 => " \\****/ ".into(),
853 _ => " (____) ".into(),
854 }
855}
856
857fn body_part(body: u8, markings: u8) -> String {
858 let mark = match markings % 6 {
859 0 => " ",
860 1 => "||||",
861 2 => "....",
862 3 => ">>>>",
863 4 => "~~~~",
864 _ => "::::",
865 };
866 match body % 10 {
867 0 => format!(" /{mark}\\ "),
868 1 => format!(" |{mark}| "),
869 2 => format!(" ({mark}) "),
870 3 => format!(" [{mark}] "),
871 4 => format!(" ~{mark}~ "),
872 5 => format!(" <{mark}> "),
873 6 => format!(" {{{mark}}} "),
874 7 => format!(" |{mark}| "),
875 8 => format!(" /{mark}\\ "),
876 _ => format!(" _{mark}_ "),
877 }
878}
879
880fn leg_part(legs: u8, tail: u8) -> String {
881 let t = match tail % 8 {
882 0 => "",
883 1 => "~",
884 2 => ">",
885 3 => ")",
886 4 => "^",
887 5 => "*",
888 6 => "=",
889 _ => "/",
890 };
891 let base = match legs % 10 {
892 0 => " /| |\\",
893 1 => " ~~ ~~",
894 2 => " _/| |\\_",
895 3 => " || ||",
896 4 => " /\\ /\\",
897 5 => " <> <>",
898 6 => " () ()",
899 7 => " }{ }{",
900 8 => " // \\\\",
901 _ => " \\/ \\/",
902 };
903 if t.is_empty() {
904 format!("{base} ")
905 } else {
906 format!("{base} {t} ")
907 }
908}
909
910pub fn format_buddy_block(state: &BuddyState, theme: &super::theme::Theme) -> String {
915 format_buddy_block_at(state, theme, None)
916}
917
918pub fn format_buddy_block_at(
919 state: &BuddyState,
920 theme: &super::theme::Theme,
921 tick: Option<u64>,
922) -> String {
923 let r = super::theme::rst();
924 let a = theme.accent.fg();
925 let m = theme.muted.fg();
926 let p = theme.primary.fg();
927 let rarity_color = state.rarity.color_code();
928
929 let info_lines = [
930 format!(
931 "{a}{}{r} | {p}{}{r} | {rarity_color}{}{r} | Lv.{}{r}",
932 state.name,
933 state.species.label(),
934 state.rarity.label(),
935 state.level,
936 ),
937 format!(
938 "{m}Mood: {} | XP: {}{r}",
939 state.mood.label(),
940 format_compact(state.xp),
941 ),
942 format!("{m}\"{}\"{r}", state.speech),
943 ];
944
945 let mut lines = Vec::with_capacity(9);
946 lines.push(String::new());
947 let sprite = sprite_lines_for_tick(state, tick);
948 for (i, sprite_line) in sprite.iter().enumerate() {
949 let info = if i < info_lines.len() {
950 &info_lines[i]
951 } else {
952 ""
953 };
954 lines.push(format!(" {p}{sprite_line}{r} {info}"));
955 }
956 lines.push(String::new());
957 lines.join("\n")
958}
959
960pub fn format_buddy_full(state: &BuddyState, theme: &super::theme::Theme) -> String {
961 let r = super::theme::rst();
962 let a = theme.accent.fg();
963 let m = theme.muted.fg();
964 let p = theme.primary.fg();
965 let s = theme.success.fg();
966 let w = theme.warning.fg();
967 let b = super::theme::bold();
968 let rarity_color = state.rarity.color_code();
969
970 let mut out = Vec::new();
971
972 out.push(String::new());
973 out.push(format!(" {b}{a}Token Guardian{r}"));
974 out.push(String::new());
975
976 for line in &state.ascii_art {
977 out.push(format!(" {p}{line}{r}"));
978 }
979 out.push(String::new());
980
981 out.push(format!(
982 " {b}{a}{}{r} {m}the {}{r} {rarity_color}{}{r} {m}Lv.{}{r}",
983 state.name,
984 state.species.label(),
985 state.rarity.label(),
986 state.level,
987 ));
988 out.push(format!(
989 " {m}Mood: {} | XP: {} / {} | Streak: {}d{r}",
990 state.mood.label(),
991 format_compact(state.xp),
992 format_compact(state.xp_next_level),
993 state.streak_days,
994 ));
995 out.push(format!(
996 " {m}Tokens saved: {} | Bugs prevented: {}{r}",
997 format_compact(state.tokens_saved),
998 state.bugs_prevented,
999 ));
1000 out.push(String::new());
1001
1002 out.push(format!(" {b}Stats{r}"));
1003 out.push(format!(
1004 " {s}Compression{r} {}",
1005 stat_bar(state.stats.compression, theme)
1006 ));
1007 out.push(format!(
1008 " {w}Vigilance {r} {}",
1009 stat_bar(state.stats.vigilance, theme)
1010 ));
1011 out.push(format!(
1012 " {p}Endurance {r} {}",
1013 stat_bar(state.stats.endurance, theme)
1014 ));
1015 out.push(format!(
1016 " {a}Wisdom {r} {}",
1017 stat_bar(state.stats.wisdom, theme)
1018 ));
1019 out.push(format!(
1020 " {m}Experience {r} {}",
1021 stat_bar(state.stats.experience, theme)
1022 ));
1023 out.push(String::new());
1024
1025 out.push(format!(" {m}\"{}\"{r}", state.speech));
1026 out.push(String::new());
1027
1028 out.join("\n")
1029}
1030
1031fn stat_bar(value: u8, theme: &super::theme::Theme) -> String {
1032 let filled = (value as usize) / 5;
1033 let empty = 20 - filled;
1034 let r = super::theme::rst();
1035 let g = theme.success.fg();
1036 let m = theme.muted.fg();
1037 format!(
1038 "{g}{}{m}{}{r} {value}/100",
1039 "█".repeat(filled),
1040 "░".repeat(empty),
1041 )
1042}
1043
1044#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 #[test]
1053 fn species_from_cargo_commands() {
1054 let mut cmds = HashMap::new();
1055 cmds.insert(
1056 "cargo build".to_string(),
1057 super::super::stats::CommandStats {
1058 count: 50,
1059 input_tokens: 1000,
1060 output_tokens: 500,
1061 },
1062 );
1063 assert_eq!(Species::from_commands(&cmds), Species::Crab);
1064 }
1065
1066 #[test]
1067 fn species_mixed_is_dragon() {
1068 let mut cmds = HashMap::new();
1069 cmds.insert(
1070 "cargo build".to_string(),
1071 super::super::stats::CommandStats {
1072 count: 10,
1073 input_tokens: 0,
1074 output_tokens: 0,
1075 },
1076 );
1077 cmds.insert(
1078 "npm install".to_string(),
1079 super::super::stats::CommandStats {
1080 count: 10,
1081 input_tokens: 0,
1082 output_tokens: 0,
1083 },
1084 );
1085 cmds.insert(
1086 "python app.py".to_string(),
1087 super::super::stats::CommandStats {
1088 count: 10,
1089 input_tokens: 0,
1090 output_tokens: 0,
1091 },
1092 );
1093 assert_eq!(Species::from_commands(&cmds), Species::Dragon);
1094 }
1095
1096 #[test]
1097 fn species_empty_is_egg() {
1098 let cmds = HashMap::new();
1099 assert_eq!(Species::from_commands(&cmds), Species::Egg);
1100 }
1101
1102 #[test]
1103 fn rarity_levels() {
1104 assert_eq!(Rarity::from_tokens_saved(0), Rarity::Egg);
1105 assert_eq!(Rarity::from_tokens_saved(5_000), Rarity::Egg);
1106 assert_eq!(Rarity::from_tokens_saved(50_000), Rarity::Common);
1107 assert_eq!(Rarity::from_tokens_saved(500_000), Rarity::Uncommon);
1108 assert_eq!(Rarity::from_tokens_saved(5_000_000), Rarity::Rare);
1109 assert_eq!(Rarity::from_tokens_saved(50_000_000), Rarity::Epic);
1110 assert_eq!(Rarity::from_tokens_saved(500_000_000), Rarity::Legendary);
1111 }
1112
1113 #[test]
1114 fn name_is_deterministic() {
1115 let s = user_seed();
1116 let n1 = generate_name(s);
1117 let n2 = generate_name(s);
1118 assert_eq!(n1, n2);
1119 }
1120
1121 #[test]
1122 fn format_compact_values() {
1123 assert_eq!(format_compact(500), "500");
1124 assert_eq!(format_compact(1_500), "1.5K");
1125 assert_eq!(format_compact(2_500_000), "2.5M");
1126 assert_eq!(format_compact(3_000_000_000), "3.0B");
1127 }
1128
1129 #[test]
1130 fn procedural_sprite_returns_7_lines() {
1131 for seed in [0u64, 1, 42, 999, 12345, 69_119_999, u64::MAX] {
1132 let traits = CreatureTraits::from_seed(seed);
1133 for mood in &[
1134 Mood::Ecstatic,
1135 Mood::Happy,
1136 Mood::Content,
1137 Mood::Worried,
1138 Mood::Sleeping,
1139 ] {
1140 let sp = render_sprite(&traits, mood);
1141 assert_eq!(sp.len(), 7, "sprite for seed={seed}, mood={mood:?}");
1142 }
1143 }
1144 }
1145
1146 #[test]
1147 fn creature_traits_are_deterministic() {
1148 let t1 = CreatureTraits::from_seed(42);
1149 let t2 = CreatureTraits::from_seed(42);
1150 assert_eq!(t1.head, t2.head);
1151 assert_eq!(t1.eyes, t2.eyes);
1152 assert_eq!(t1.mouth, t2.mouth);
1153 assert_eq!(t1.ears, t2.ears);
1154 assert_eq!(t1.body, t2.body);
1155 assert_eq!(t1.legs, t2.legs);
1156 assert_eq!(t1.tail, t2.tail);
1157 assert_eq!(t1.markings, t2.markings);
1158 }
1159
1160 #[test]
1161 fn different_seeds_produce_different_traits() {
1162 let t1 = CreatureTraits::from_seed(1);
1163 let t2 = CreatureTraits::from_seed(9999);
1164 let same = t1.head == t2.head
1165 && t1.eyes == t2.eyes
1166 && t1.mouth == t2.mouth
1167 && t1.ears == t2.ears
1168 && t1.body == t2.body
1169 && t1.legs == t2.legs
1170 && t1.tail == t2.tail
1171 && t1.markings == t2.markings;
1172 assert!(
1173 !same,
1174 "seeds 1 and 9999 should differ in at least one trait"
1175 );
1176 }
1177
1178 #[test]
1179 fn total_combinations_is_69m() {
1180 assert_eq!(12u64 * 10 * 10 * 12 * 10 * 10 * 8 * 6, 69_120_000);
1181 }
1182
1183 #[test]
1184 fn xp_next_level_increases() {
1185 let lv1 = (1u64 + 1) * (1 + 1) * 50;
1186 let lv10 = (10u64 + 1) * (10 + 1) * 50;
1187 assert!(lv10 > lv1);
1188 }
1189}