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 ears = ear_part(traits.ears);
463 let head_top = head_top_part(traits.head);
464 let face = face_line(traits.head, traits.eyes, el, er);
465 let mouth = mouth_line(traits.head, traits.mouth);
466 let neck = neck_part(traits.head);
467 let body = body_part(traits.body, traits.markings);
468 let feet = leg_part(traits.legs, traits.tail);
469
470 vec![
471 pad(&ears),
472 pad(&head_top),
473 pad(&face),
474 pad(&mouth),
475 pad(&neck),
476 pad(&body),
477 pad(&feet),
478 ]
479}
480
481fn sparkle_edges(line: &str, left: char, right: char) -> String {
482 let s = pad(line);
483 let mut chars: Vec<char> = s.chars().collect();
484 if chars.len() >= 2 {
485 chars[0] = left;
486 let last = chars.len() - 1;
487 chars[last] = right;
488 }
489 chars.into_iter().collect()
490}
491
492fn edge_aura(line: &str, left: char, right: char) -> String {
493 let s = pad(line);
494 let mut chars: Vec<char> = s.chars().collect();
495 if chars.len() >= 2 {
496 chars[0] = left;
497 let last = chars.len() - 1;
498 chars[last] = right;
499 }
500 chars.into_iter().collect()
501}
502
503fn shift(line: &str, offset: i32) -> String {
504 if offset == 0 {
505 return pad(line);
506 }
507 let s = pad(line);
508 let mut chars: Vec<char> = s.chars().collect();
509 if chars.is_empty() {
510 return s;
511 }
512 if offset > 0 {
513 for _ in 0..offset {
514 chars.insert(0, ' ');
515 chars.pop();
516 }
517 } else {
518 for _ in 0..(-offset) {
519 chars.remove(0);
520 chars.push(' ');
521 }
522 }
523 chars.into_iter().collect()
524}
525
526fn sprite_lines_for_tick(state: &BuddyState, tick: Option<u64>) -> &[String] {
527 if let Some(t) = tick {
528 if !state.ascii_frames.is_empty() {
529 let idx = (t as usize) % state.ascii_frames.len();
530 return &state.ascii_frames[idx];
531 }
532 }
533 &state.ascii_art
534}
535
536fn compute_mood(
541 compression: u8,
542 errors: u64,
543 prevented: u64,
544 streak: u32,
545 store: &super::stats::StatsStore,
546) -> Mood {
547 let hours_since_last = store
548 .last_use
549 .as_ref()
550 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
551 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_hours())
552 .unwrap_or(999);
553
554 if hours_since_last > 48 {
555 return Mood::Sleeping;
556 }
557
558 let recent_errors = store
559 .daily
560 .iter()
561 .rev()
562 .take(1)
563 .any(|d| d.input_tokens > 0 && d.output_tokens > d.input_tokens);
564
565 if compression > 60 && errors == 0 && streak >= 7 {
566 Mood::Ecstatic
567 } else if compression > 40 || prevented > 0 {
568 Mood::Happy
569 } else if recent_errors || (errors > 5 && prevented == 0) {
570 Mood::Worried
571 } else {
572 Mood::Content
573 }
574}
575
576fn compute_rpg_stats(
581 compression: u8,
582 prevented: u64,
583 errors: u64,
584 streak: u32,
585 unique_cmds: usize,
586 total_cmds: u64,
587) -> BuddyStats {
588 let compression_stat = compression.min(100);
589
590 let vigilance = if errors > 0 {
591 ((prevented as f64 / errors as f64) * 80.0).min(100.0) as u8
592 } else if prevented > 0 {
593 100
594 } else {
595 20
596 };
597
598 let endurance = (streak * 5).min(100) as u8;
599 let wisdom = (unique_cmds as u8).min(100);
600 let experience = if total_cmds > 0 {
601 ((total_cmds as f64).log10() * 25.0).min(100.0) as u8
602 } else {
603 0
604 };
605
606 BuddyStats {
607 compression: compression_stat,
608 vigilance,
609 endurance,
610 wisdom,
611 experience,
612 }
613}
614
615fn compute_streak(daily: &[super::stats::DayStats]) -> u32 {
620 if daily.is_empty() {
621 return 0;
622 }
623
624 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
625 let mut streak = 0u32;
626 let mut expected = today.clone();
627
628 for day in daily.iter().rev() {
629 if day.date == expected && day.commands > 0 {
630 streak += 1;
631 if let Ok(dt) = chrono::NaiveDate::parse_from_str(&expected, "%Y-%m-%d") {
632 expected = (dt - chrono::Duration::days(1))
633 .format("%Y-%m-%d")
634 .to_string();
635 } else {
636 break;
637 }
638 } else if day.date < expected {
639 break;
640 }
641 }
642 streak
643}
644
645fn generate_name(seed: u64) -> String {
650 const ADJ: &[&str] = &[
651 "Swift", "Quiet", "Bright", "Bold", "Clever", "Brave", "Lucky", "Tiny", "Cosmic", "Fuzzy",
652 "Nimble", "Jolly", "Mighty", "Gentle", "Witty", "Keen", "Sly", "Calm", "Wild", "Vivid",
653 "Dusk", "Dawn", "Neon", "Frost", "Solar", "Lunar", "Pixel", "Turbo", "Nano", "Mega",
654 ];
655 const NOUN: &[&str] = &[
656 "Ember", "Reef", "Spark", "Byte", "Flux", "Echo", "Drift", "Glitch", "Pulse", "Shade",
657 "Orbit", "Fern", "Rust", "Zinc", "Flint", "Quartz", "Maple", "Cedar", "Opal", "Moss",
658 "Ridge", "Cove", "Peak", "Dune", "Vale", "Brook", "Cliff", "Storm", "Blaze", "Mist",
659 ];
660
661 let adj_idx = (seed >> 8) as usize % ADJ.len();
662 let noun_idx = (seed >> 16) as usize % NOUN.len();
663 format!("{} {}", ADJ[adj_idx], NOUN[noun_idx])
664}
665
666fn generate_speech(mood: &Mood, tokens_saved: u64, bugs_prevented: u64, streak: u32) -> String {
671 match mood {
672 Mood::Ecstatic => {
673 if bugs_prevented > 0 {
674 format!("{bugs_prevented} bugs prevented! We're unstoppable!")
675 } else {
676 format!("{} tokens saved! On fire!", format_compact(tokens_saved))
677 }
678 }
679 Mood::Happy => {
680 if streak >= 3 {
681 format!("{streak}-day streak! Keep going!")
682 } else if bugs_prevented > 0 {
683 format!("Caught {bugs_prevented} bugs before they happened!")
684 } else {
685 format!("{} tokens saved so far!", format_compact(tokens_saved))
686 }
687 }
688 Mood::Content => "Watching your code... all good.".to_string(),
689 Mood::Worried => "I see some errors. Let's fix them!".to_string(),
690 Mood::Sleeping => "Zzz... wake me with some code!".to_string(),
691 }
692}
693
694fn format_compact(n: u64) -> String {
695 if n >= 1_000_000_000 {
696 format!("{:.1}B", n as f64 / 1_000_000_000.0)
697 } else if n >= 1_000_000 {
698 format!("{:.1}M", n as f64 / 1_000_000.0)
699 } else if n >= 1_000 {
700 format!("{:.1}K", n as f64 / 1_000.0)
701 } else {
702 format!("{n}")
703 }
704}
705
706const W: usize = 20;
713
714fn pad(s: &str) -> String {
715 let len = s.chars().count();
716 if len >= W {
717 s.chars().take(W).collect()
718 } else {
719 let left = (W - len) / 2;
720 let right = W - len - left;
721 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
722 }
723}
724
725pub fn render_sprite(traits: &CreatureTraits, mood: &Mood) -> Vec<String> {
726 let (el, er) = mood_eyes(mood);
727 let ears = ear_part(traits.ears);
728 let head_top = head_top_part(traits.head);
729 let face = face_line(traits.head, traits.eyes, el, er);
730 let mouth = mouth_line(traits.head, traits.mouth);
731 let neck = neck_part(traits.head);
732 let body = body_part(traits.body, traits.markings);
733 let feet = leg_part(traits.legs, traits.tail);
734
735 vec![
736 pad(&ears),
737 pad(&head_top),
738 pad(&face),
739 pad(&mouth),
740 pad(&neck),
741 pad(&body),
742 pad(&feet),
743 ]
744}
745
746fn mood_eyes(mood: &Mood) -> (&'static str, &'static str) {
747 match mood {
748 Mood::Ecstatic => ("*", "*"),
749 Mood::Happy => ("o", "o"),
750 Mood::Content => ("-", "-"),
751 Mood::Worried => (">", "<"),
752 Mood::Sleeping => ("u", "u"),
753 }
754}
755
756fn ear_part(idx: u8) -> String {
757 match idx % 12 {
758 0 => r" /\ /\".into(),
759 1 => r" / \ / \".into(),
760 2 => r" () ()".into(),
761 3 => r" || ||".into(),
762 4 => r" ~' '~".into(),
763 5 => r" >> <<".into(),
764 6 => r" ** **".into(),
765 7 => r" .' '.".into(),
766 8 => r" ~~ ~~".into(),
767 9 => r" ^^ ^^".into(),
768 10 => r" {} {}".into(),
769 _ => r" <> <>".into(),
770 }
771}
772
773fn head_top_part(idx: u8) -> String {
774 match idx % 12 {
775 0 => " .--------. ".into(),
776 1 => " +--------+ ".into(),
777 2 => " /--------\\ ".into(),
778 3 => " .========. ".into(),
779 4 => " (--------) ".into(),
780 5 => " .~~~~~~~~. ".into(),
781 6 => " /~~~~~~~~\\ ".into(),
782 7 => " {--------} ".into(),
783 8 => " <--------> ".into(),
784 9 => " .'^----^'. ".into(),
785 10 => " /********\\ ".into(),
786 _ => " (________) ".into(),
787 }
788}
789
790fn head_bracket(head: u8) -> (char, char) {
791 match head % 12 {
792 0 => ('|', '|'),
793 1 => ('|', '|'),
794 2 => ('/', '\\'),
795 3 => ('|', '|'),
796 4 => ('(', ')'),
797 5 => ('|', '|'),
798 6 => ('/', '\\'),
799 7 => ('{', '}'),
800 8 => ('<', '>'),
801 9 => ('(', ')'),
802 10 => ('/', '\\'),
803 _ => ('(', ')'),
804 }
805}
806
807fn face_line(head: u8, eye_idx: u8, el: &str, er: &str) -> String {
808 let (bl, br) = head_bracket(head);
809 let deco = match eye_idx % 10 {
810 0 => (" ", " "),
811 1 => ("'", "'"),
812 2 => (".", "."),
813 3 => ("~", "~"),
814 4 => ("*", "*"),
815 5 => ("`", "`"),
816 6 => ("^", "^"),
817 7 => (",", ","),
818 8 => (":", ":"),
819 _ => (" ", " "),
820 };
821 format!(" {bl} {}{el} {er}{} {br} ", deco.0, deco.1)
822}
823
824fn mouth_line(head: u8, mouth: u8) -> String {
825 let (bl, br) = head_bracket(head);
826 let m = match mouth % 10 {
827 0 => " \\_/ ",
828 1 => " w ",
829 2 => " ^ ",
830 3 => " ~ ",
831 4 => " === ",
832 5 => " o ",
833 6 => " 3 ",
834 7 => " v ",
835 8 => " --- ",
836 _ => " U ",
837 };
838 format!(" {bl} {} {br} ", m)
839}
840
841fn neck_part(head: u8) -> String {
842 match head % 12 {
843 0 => " '--------' ".into(),
844 1 => " +--------+ ".into(),
845 2 => " \\--------/ ".into(),
846 3 => " '========' ".into(),
847 4 => " (--------) ".into(),
848 5 => " '~~~~~~~~' ".into(),
849 6 => " \\~~~~~~~~/ ".into(),
850 7 => " {--------} ".into(),
851 8 => " <--------> ".into(),
852 9 => " '.^----^.' ".into(),
853 10 => " \\********/ ".into(),
854 _ => " (__________) ".into(),
855 }
856}
857
858fn body_part(body: u8, markings: u8) -> String {
859 let fill = match markings % 6 {
860 0 => " ",
861 1 => " |||| ",
862 2 => " .... ",
863 3 => " >><< ",
864 4 => " ~~~~ ",
865 _ => " :::: ",
866 };
867 match body % 10 {
868 0 => format!(" /{fill}\\ "),
869 1 => format!(" |{fill}| "),
870 2 => format!(" ({fill}) "),
871 3 => format!(" [{fill}] "),
872 4 => format!(" ~{fill}~ "),
873 5 => format!(" <{fill}> "),
874 6 => format!(" {{{fill}}} "),
875 7 => format!(" |{fill}| "),
876 8 => format!(" /{fill}\\ "),
877 _ => format!(" _{fill}_ "),
878 }
879}
880
881fn leg_part(legs: u8, tail: u8) -> String {
882 let t = match tail % 8 {
883 0 => ' ',
884 1 => '~',
885 2 => '>',
886 3 => ')',
887 4 => '^',
888 5 => '*',
889 6 => '=',
890 _ => '/',
891 };
892 let base = match legs % 10 {
893 0 => " /| |\\",
894 1 => " ~~ ~~",
895 2 => "_/| |\\_",
896 3 => " || ||",
897 4 => " /\\ /\\",
898 5 => " <> <>",
899 6 => " () ()",
900 7 => " }{ }{",
901 8 => " // \\\\",
902 _ => " \\/ \\/",
903 };
904 if t == ' ' {
905 pad(base)
906 } else {
907 pad(&format!("{base} {t}"))
908 }
909}
910
911pub fn format_buddy_block(state: &BuddyState, theme: &super::theme::Theme) -> String {
916 format_buddy_block_at(state, theme, None)
917}
918
919pub fn format_buddy_block_at(
920 state: &BuddyState,
921 theme: &super::theme::Theme,
922 tick: Option<u64>,
923) -> String {
924 let r = super::theme::rst();
925 let a = theme.accent.fg();
926 let m = theme.muted.fg();
927 let p = theme.primary.fg();
928 let rarity_color = state.rarity.color_code();
929
930 let info_lines = [
931 format!(
932 "{a}{}{r} | {p}{}{r} | {rarity_color}{}{r} | Lv.{}{r}",
933 state.name,
934 state.species.label(),
935 state.rarity.label(),
936 state.level,
937 ),
938 format!(
939 "{m}Mood: {} | XP: {}{r}",
940 state.mood.label(),
941 format_compact(state.xp),
942 ),
943 format!("{m}\"{}\"{r}", state.speech),
944 ];
945
946 let mut lines = Vec::with_capacity(9);
947 lines.push(String::new());
948 let sprite = sprite_lines_for_tick(state, tick);
949 for (i, sprite_line) in sprite.iter().enumerate() {
950 let info = if i < info_lines.len() {
951 &info_lines[i]
952 } else {
953 ""
954 };
955 lines.push(format!(" {p}{sprite_line}{r} {info}"));
956 }
957 lines.push(String::new());
958 lines.join("\n")
959}
960
961pub fn format_buddy_full(state: &BuddyState, theme: &super::theme::Theme) -> String {
962 let r = super::theme::rst();
963 let a = theme.accent.fg();
964 let m = theme.muted.fg();
965 let p = theme.primary.fg();
966 let s = theme.success.fg();
967 let w = theme.warning.fg();
968 let b = super::theme::bold();
969 let rarity_color = state.rarity.color_code();
970
971 let mut out = Vec::new();
972
973 out.push(String::new());
974 out.push(format!(" {b}{a}Token Guardian{r}"));
975 out.push(String::new());
976
977 for line in &state.ascii_art {
978 out.push(format!(" {p}{line}{r}"));
979 }
980 out.push(String::new());
981
982 out.push(format!(
983 " {b}{a}{}{r} {m}the {}{r} {rarity_color}{}{r} {m}Lv.{}{r}",
984 state.name,
985 state.species.label(),
986 state.rarity.label(),
987 state.level,
988 ));
989 out.push(format!(
990 " {m}Mood: {} | XP: {} / {} | Streak: {}d{r}",
991 state.mood.label(),
992 format_compact(state.xp),
993 format_compact(state.xp_next_level),
994 state.streak_days,
995 ));
996 out.push(format!(
997 " {m}Tokens saved: {} | Bugs prevented: {}{r}",
998 format_compact(state.tokens_saved),
999 state.bugs_prevented,
1000 ));
1001 out.push(String::new());
1002
1003 out.push(format!(" {b}Stats{r}"));
1004 out.push(format!(
1005 " {s}Compression{r} {}",
1006 stat_bar(state.stats.compression, theme)
1007 ));
1008 out.push(format!(
1009 " {w}Vigilance {r} {}",
1010 stat_bar(state.stats.vigilance, theme)
1011 ));
1012 out.push(format!(
1013 " {p}Endurance {r} {}",
1014 stat_bar(state.stats.endurance, theme)
1015 ));
1016 out.push(format!(
1017 " {a}Wisdom {r} {}",
1018 stat_bar(state.stats.wisdom, theme)
1019 ));
1020 out.push(format!(
1021 " {m}Experience {r} {}",
1022 stat_bar(state.stats.experience, theme)
1023 ));
1024 out.push(String::new());
1025
1026 out.push(format!(" {m}\"{}\"{r}", state.speech));
1027 out.push(String::new());
1028
1029 out.join("\n")
1030}
1031
1032fn stat_bar(value: u8, theme: &super::theme::Theme) -> String {
1033 let filled = (value as usize) / 5;
1034 let empty = 20 - filled;
1035 let r = super::theme::rst();
1036 let g = theme.success.fg();
1037 let m = theme.muted.fg();
1038 format!(
1039 "{g}{}{m}{}{r} {value}/100",
1040 "█".repeat(filled),
1041 "░".repeat(empty),
1042 )
1043}
1044
1045#[cfg(test)]
1050mod tests {
1051 use super::*;
1052
1053 #[test]
1054 fn species_from_cargo_commands() {
1055 let mut cmds = HashMap::new();
1056 cmds.insert(
1057 "cargo build".to_string(),
1058 super::super::stats::CommandStats {
1059 count: 50,
1060 input_tokens: 1000,
1061 output_tokens: 500,
1062 },
1063 );
1064 assert_eq!(Species::from_commands(&cmds), Species::Crab);
1065 }
1066
1067 #[test]
1068 fn species_mixed_is_dragon() {
1069 let mut cmds = HashMap::new();
1070 cmds.insert(
1071 "cargo build".to_string(),
1072 super::super::stats::CommandStats {
1073 count: 10,
1074 input_tokens: 0,
1075 output_tokens: 0,
1076 },
1077 );
1078 cmds.insert(
1079 "npm install".to_string(),
1080 super::super::stats::CommandStats {
1081 count: 10,
1082 input_tokens: 0,
1083 output_tokens: 0,
1084 },
1085 );
1086 cmds.insert(
1087 "python app.py".to_string(),
1088 super::super::stats::CommandStats {
1089 count: 10,
1090 input_tokens: 0,
1091 output_tokens: 0,
1092 },
1093 );
1094 assert_eq!(Species::from_commands(&cmds), Species::Dragon);
1095 }
1096
1097 #[test]
1098 fn species_empty_is_egg() {
1099 let cmds = HashMap::new();
1100 assert_eq!(Species::from_commands(&cmds), Species::Egg);
1101 }
1102
1103 #[test]
1104 fn rarity_levels() {
1105 assert_eq!(Rarity::from_tokens_saved(0), Rarity::Egg);
1106 assert_eq!(Rarity::from_tokens_saved(5_000), Rarity::Egg);
1107 assert_eq!(Rarity::from_tokens_saved(50_000), Rarity::Common);
1108 assert_eq!(Rarity::from_tokens_saved(500_000), Rarity::Uncommon);
1109 assert_eq!(Rarity::from_tokens_saved(5_000_000), Rarity::Rare);
1110 assert_eq!(Rarity::from_tokens_saved(50_000_000), Rarity::Epic);
1111 assert_eq!(Rarity::from_tokens_saved(500_000_000), Rarity::Legendary);
1112 }
1113
1114 #[test]
1115 fn name_is_deterministic() {
1116 let s = user_seed();
1117 let n1 = generate_name(s);
1118 let n2 = generate_name(s);
1119 assert_eq!(n1, n2);
1120 }
1121
1122 #[test]
1123 fn format_compact_values() {
1124 assert_eq!(format_compact(500), "500");
1125 assert_eq!(format_compact(1_500), "1.5K");
1126 assert_eq!(format_compact(2_500_000), "2.5M");
1127 assert_eq!(format_compact(3_000_000_000), "3.0B");
1128 }
1129
1130 #[test]
1131 fn procedural_sprite_returns_7_lines() {
1132 for seed in [0u64, 1, 42, 999, 12345, 69_119_999, u64::MAX] {
1133 let traits = CreatureTraits::from_seed(seed);
1134 for mood in &[
1135 Mood::Ecstatic,
1136 Mood::Happy,
1137 Mood::Content,
1138 Mood::Worried,
1139 Mood::Sleeping,
1140 ] {
1141 let sp = render_sprite(&traits, mood);
1142 assert_eq!(sp.len(), 7, "sprite for seed={seed}, mood={mood:?}");
1143 }
1144 }
1145 }
1146
1147 #[test]
1148 fn creature_traits_are_deterministic() {
1149 let t1 = CreatureTraits::from_seed(42);
1150 let t2 = CreatureTraits::from_seed(42);
1151 assert_eq!(t1.head, t2.head);
1152 assert_eq!(t1.eyes, t2.eyes);
1153 assert_eq!(t1.mouth, t2.mouth);
1154 assert_eq!(t1.ears, t2.ears);
1155 assert_eq!(t1.body, t2.body);
1156 assert_eq!(t1.legs, t2.legs);
1157 assert_eq!(t1.tail, t2.tail);
1158 assert_eq!(t1.markings, t2.markings);
1159 }
1160
1161 #[test]
1162 fn different_seeds_produce_different_traits() {
1163 let t1 = CreatureTraits::from_seed(1);
1164 let t2 = CreatureTraits::from_seed(9999);
1165 let same = t1.head == t2.head
1166 && t1.eyes == t2.eyes
1167 && t1.mouth == t2.mouth
1168 && t1.ears == t2.ears
1169 && t1.body == t2.body
1170 && t1.legs == t2.legs
1171 && t1.tail == t2.tail
1172 && t1.markings == t2.markings;
1173 assert!(
1174 !same,
1175 "seeds 1 and 9999 should differ in at least one trait"
1176 );
1177 }
1178
1179 #[test]
1180 fn total_combinations_is_69m() {
1181 assert_eq!(12u64 * 10 * 10 * 12 * 10 * 10 * 8 * 6, 69_120_000);
1182 }
1183
1184 #[test]
1185 fn xp_next_level_increases() {
1186 let lv1 = (1u64 + 1) * (1 + 1) * 50;
1187 let lv10 = (10u64 + 1) * (10 + 1) * 50;
1188 assert!(lv10 > lv1);
1189 }
1190}