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 pub traits: CreatureTraits,
266}
267
268impl BuddyState {
269 pub fn compute() -> Self {
270 let store = super::stats::load();
271 let tokens_saved = store
272 .total_input_tokens
273 .saturating_sub(store.total_output_tokens);
274
275 let project_root = std::env::current_dir()
276 .map(|p| p.to_string_lossy().to_string())
277 .unwrap_or_default();
278 let gotcha_store = if !project_root.is_empty() {
279 super::gotcha_tracker::GotchaStore::load(&project_root)
280 } else {
281 super::gotcha_tracker::GotchaStore::new("none")
282 };
283
284 let bugs_prevented = gotcha_store.stats.total_prevented;
285 let errors_detected = gotcha_store.stats.total_errors_detected;
286
287 let species = Species::from_commands(&store.commands);
288 let rarity = Rarity::from_tokens_saved(tokens_saved);
289
290 let xp = tokens_saved / 1000 + store.total_commands * 5 + bugs_prevented * 100;
291 let level = ((xp as f64 / 50.0).sqrt().floor() as u32).min(99);
292 let xp_next_level = ((level + 1) as u64) * ((level + 1) as u64) * 50;
293
294 let streak_days = compute_streak(&store.daily);
295 let compression_rate = if store.total_input_tokens > 0 {
296 (tokens_saved as f64 / store.total_input_tokens as f64 * 100.0) as u8
297 } else {
298 0
299 };
300
301 let mood = compute_mood(
302 compression_rate,
303 errors_detected,
304 bugs_prevented,
305 streak_days,
306 &store,
307 );
308
309 let rpg_stats = compute_rpg_stats(
310 compression_rate,
311 bugs_prevented,
312 errors_detected,
313 streak_days,
314 store.commands.len(),
315 store.total_commands,
316 );
317
318 let seed = user_seed();
319 let traits = CreatureTraits::from_seed(seed);
320 let name = generate_name(seed);
321 let ascii_art = render_sprite(&traits, &mood);
322 let speech = generate_speech(&mood, tokens_saved, bugs_prevented, streak_days);
323
324 Self {
325 name,
326 species,
327 rarity,
328 level,
329 xp,
330 xp_next_level,
331 mood,
332 stats: rpg_stats,
333 speech,
334 tokens_saved,
335 bugs_prevented,
336 streak_days,
337 ascii_art,
338 traits,
339 }
340 }
341}
342
343fn compute_mood(
348 compression: u8,
349 errors: u64,
350 prevented: u64,
351 streak: u32,
352 store: &super::stats::StatsStore,
353) -> Mood {
354 let hours_since_last = store
355 .last_use
356 .as_ref()
357 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
358 .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_hours())
359 .unwrap_or(999);
360
361 if hours_since_last > 48 {
362 return Mood::Sleeping;
363 }
364
365 let recent_errors = store
366 .daily
367 .iter()
368 .rev()
369 .take(1)
370 .any(|d| d.input_tokens > 0 && d.output_tokens > d.input_tokens);
371
372 if compression > 60 && errors == 0 && streak >= 7 {
373 Mood::Ecstatic
374 } else if compression > 40 || prevented > 0 {
375 Mood::Happy
376 } else if recent_errors || (errors > 5 && prevented == 0) {
377 Mood::Worried
378 } else {
379 Mood::Content
380 }
381}
382
383fn compute_rpg_stats(
388 compression: u8,
389 prevented: u64,
390 errors: u64,
391 streak: u32,
392 unique_cmds: usize,
393 total_cmds: u64,
394) -> BuddyStats {
395 let compression_stat = compression.min(100);
396
397 let vigilance = if errors > 0 {
398 ((prevented as f64 / errors as f64) * 80.0).min(100.0) as u8
399 } else if prevented > 0 {
400 100
401 } else {
402 20
403 };
404
405 let endurance = (streak * 5).min(100) as u8;
406 let wisdom = (unique_cmds as u8).min(100);
407 let experience = if total_cmds > 0 {
408 ((total_cmds as f64).log10() * 25.0).min(100.0) as u8
409 } else {
410 0
411 };
412
413 BuddyStats {
414 compression: compression_stat,
415 vigilance,
416 endurance,
417 wisdom,
418 experience,
419 }
420}
421
422fn compute_streak(daily: &[super::stats::DayStats]) -> u32 {
427 if daily.is_empty() {
428 return 0;
429 }
430
431 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
432 let mut streak = 0u32;
433 let mut expected = today.clone();
434
435 for day in daily.iter().rev() {
436 if day.date == expected && day.commands > 0 {
437 streak += 1;
438 if let Ok(dt) = chrono::NaiveDate::parse_from_str(&expected, "%Y-%m-%d") {
439 expected = (dt - chrono::Duration::days(1))
440 .format("%Y-%m-%d")
441 .to_string();
442 } else {
443 break;
444 }
445 } else if day.date < expected {
446 break;
447 }
448 }
449 streak
450}
451
452fn generate_name(seed: u64) -> String {
457 const ADJ: &[&str] = &[
458 "Swift", "Quiet", "Bright", "Bold", "Clever", "Brave", "Lucky", "Tiny", "Cosmic", "Fuzzy",
459 "Nimble", "Jolly", "Mighty", "Gentle", "Witty", "Keen", "Sly", "Calm", "Wild", "Vivid",
460 "Dusk", "Dawn", "Neon", "Frost", "Solar", "Lunar", "Pixel", "Turbo", "Nano", "Mega",
461 ];
462 const NOUN: &[&str] = &[
463 "Ember", "Reef", "Spark", "Byte", "Flux", "Echo", "Drift", "Glitch", "Pulse", "Shade",
464 "Orbit", "Fern", "Rust", "Zinc", "Flint", "Quartz", "Maple", "Cedar", "Opal", "Moss",
465 "Ridge", "Cove", "Peak", "Dune", "Vale", "Brook", "Cliff", "Storm", "Blaze", "Mist",
466 ];
467
468 let adj_idx = (seed >> 8) as usize % ADJ.len();
469 let noun_idx = (seed >> 16) as usize % NOUN.len();
470 format!("{} {}", ADJ[adj_idx], NOUN[noun_idx])
471}
472
473fn generate_speech(mood: &Mood, tokens_saved: u64, bugs_prevented: u64, streak: u32) -> String {
478 match mood {
479 Mood::Ecstatic => {
480 if bugs_prevented > 0 {
481 format!("{bugs_prevented} bugs prevented! We're unstoppable!")
482 } else {
483 format!("{} tokens saved! On fire!", format_compact(tokens_saved))
484 }
485 }
486 Mood::Happy => {
487 if streak >= 3 {
488 format!("{streak}-day streak! Keep going!")
489 } else if bugs_prevented > 0 {
490 format!("Caught {bugs_prevented} bugs before they happened!")
491 } else {
492 format!("{} tokens saved so far!", format_compact(tokens_saved))
493 }
494 }
495 Mood::Content => "Watching your code... all good.".to_string(),
496 Mood::Worried => "I see some errors. Let's fix them!".to_string(),
497 Mood::Sleeping => "Zzz... wake me with some code!".to_string(),
498 }
499}
500
501fn format_compact(n: u64) -> String {
502 if n >= 1_000_000_000 {
503 format!("{:.1}B", n as f64 / 1_000_000_000.0)
504 } else if n >= 1_000_000 {
505 format!("{:.1}M", n as f64 / 1_000_000.0)
506 } else if n >= 1_000 {
507 format!("{:.1}K", n as f64 / 1_000.0)
508 } else {
509 format!("{n}")
510 }
511}
512
513const W: usize = 16;
520
521fn pad(s: &str) -> String {
522 let len = s.chars().count();
523 if len >= W {
524 s.to_string()
525 } else {
526 format!("{}{}", s, " ".repeat(W - len))
527 }
528}
529
530pub fn render_sprite(traits: &CreatureTraits, mood: &Mood) -> Vec<String> {
531 let eye_l = mood_eye_left(mood);
532 let eye_r = mood_eye_right(mood);
533
534 let ear_line = ear_part(traits.ears);
535 let head_top = head_top_part(traits.head);
536 let eye_line = eye_part(traits.head, traits.eyes, eye_l, eye_r, mood);
537 let mouth_line = mouth_part(traits.head, traits.mouth);
538 let head_bottom = head_bottom_part(traits.head, traits.body);
539 let body_line = body_part(traits.body, traits.markings);
540 let leg_line = leg_part(traits.legs, traits.tail);
541
542 vec![
543 pad(&ear_line),
544 pad(&head_top),
545 pad(&eye_line),
546 pad(&mouth_line),
547 pad(&head_bottom),
548 pad(&body_line),
549 pad(&leg_line),
550 ]
551}
552
553fn mood_eye_left(mood: &Mood) -> &'static str {
554 match mood {
555 Mood::Ecstatic => "*",
556 Mood::Happy => "o",
557 Mood::Content => "-",
558 Mood::Worried => ">",
559 Mood::Sleeping => "u",
560 }
561}
562
563fn mood_eye_right(mood: &Mood) -> &'static str {
564 match mood {
565 Mood::Ecstatic => "*",
566 Mood::Happy => "o",
567 Mood::Content => "-",
568 Mood::Worried => "<",
569 Mood::Sleeping => "u",
570 }
571}
572
573fn ear_part(idx: u8) -> String {
574 match idx % 12 {
575 0 => " ".into(),
576 1 => " \\\\ // ".into(),
577 2 => " || || ".into(),
578 3 => " /\\ /\\ ".into(),
579 4 => " ~' '~ ".into(),
580 5 => " >> << ".into(),
581 6 => " ** ** ".into(),
582 7 => " .'' ''. ".into(),
583 8 => " ~~ ~~ ".into(),
584 9 => " ## ## ".into(),
585 10 => " ^^ ^^ ".into(),
586 _ => " <> <> ".into(),
587 }
588}
589
590fn head_top_part(idx: u8) -> String {
591 match idx % 12 {
592 0 => " .----. ".into(),
593 1 => " +----+ ".into(),
594 2 => " /\\ ".into(),
595 3 => " .===-. ".into(),
596 4 => " .------. ".into(),
597 5 => " .--. ".into(),
598 6 => " /~~~~\\ ".into(),
599 7 => " {----} ".into(),
600 8 => " <----> ".into(),
601 9 => " .^~~^. ".into(),
602 10 => " /****\\ ".into(),
603 _ => " (----) ".into(),
604 }
605}
606
607fn eye_part(head: u8, _eye_idx: u8, el: &str, er: &str, _mood: &Mood) -> String {
608 let bracket = head_bracket(head);
609 match head % 12 {
610 2 => format!(" / {} {} \\ ", el, er),
611 5 => format!(" {} {} {} {} ", bracket.0, el, er, bracket.1),
612 _ => format!(" {} {} {} {} ", bracket.0, el, er, bracket.1),
613 }
614}
615
616fn head_bracket(head: u8) -> (&'static str, &'static str) {
617 match head % 12 {
618 0 => ("/", "\\"),
619 1 => ("|", "|"),
620 2 => ("/", "\\"),
621 3 => ("/", "\\"),
622 4 => ("(", ")"),
623 5 => ("|", "|"),
624 6 => ("|", "|"),
625 7 => ("{", "}"),
626 8 => ("<", ">"),
627 9 => ("(", ")"),
628 10 => ("/", "\\"),
629 _ => ("(", ")"),
630 }
631}
632
633fn mouth_part(head: u8, mouth: u8) -> String {
634 let m = match mouth % 10 {
635 0 => "\\_/",
636 1 => " w ",
637 2 => " ^ ",
638 3 => " ~ ",
639 4 => "===",
640 5 => " o ",
641 6 => " 3 ",
642 7 => " v ",
643 8 => "---",
644 _ => " U ",
645 };
646 let bracket = head_bracket(head);
647 format!(" {} {} {} ", bracket.0, m, bracket.1)
648}
649
650fn head_bottom_part(head: u8, _body: u8) -> String {
651 match head % 12 {
652 0 => " '----' ".into(),
653 1 => " +----+ ".into(),
654 2 => " \\/ ".into(),
655 3 => " '====-' ".into(),
656 4 => " '------' ".into(),
657 5 => " '--' ".into(),
658 6 => " \\~~~~/$ ".into(),
659 7 => " {----} ".into(),
660 8 => " <----> ".into(),
661 9 => " '^~~^' ".into(),
662 10 => " \\****/ ".into(),
663 _ => " (----) ".into(),
664 }
665}
666
667fn body_part(body: u8, markings: u8) -> String {
668 let mark = match markings % 6 {
669 0 => " ",
670 1 => "||| ",
671 2 => "... ",
672 3 => ">>> ",
673 4 => "~~~ ",
674 _ => "::: ",
675 };
676 match body % 10 {
677 0 => format!(" /|{}|\\ ", &mark[..4]),
678 1 => format!(" |{}| ", &mark[..4]),
679 2 => format!(" ({}{})", &mark[..4], " "),
680 3 => format!(" [{}] ", &mark[..4]),
681 4 => format!(" ~{}~ ", &mark[..4]),
682 5 => format!(" <{}{}> ", &mark[..3], " "),
683 6 => format!(" {{{}}} ", &mark[..4]),
684 7 => format!(" |{}| ", &mark[..4]),
685 8 => format!(" ({}() ", &mark[..4]),
686 _ => format!(" /{}\\ ", &mark[..4]),
687 }
688}
689
690fn leg_part(legs: u8, tail: u8) -> String {
691 let t = match tail % 8 {
692 0 => "",
693 1 => "~",
694 2 => ">",
695 3 => ")",
696 4 => "^",
697 5 => "*",
698 6 => "=",
699 _ => "/",
700 };
701 let base = match legs % 10 {
702 0 => " /| |\\",
703 1 => " ~~ ~~",
704 2 => " _/| |\\_",
705 3 => " || ||",
706 4 => " /\\ /\\",
707 5 => " <> <>",
708 6 => " () ()",
709 7 => " }{ }{",
710 8 => " // \\\\",
711 _ => " \\/ \\/",
712 };
713 if t.is_empty() {
714 format!("{base} ")
715 } else {
716 format!("{base} {t} ")
717 }
718}
719
720pub fn format_buddy_block(state: &BuddyState, theme: &super::theme::Theme) -> String {
725 let r = super::theme::rst();
726 let a = theme.accent.fg();
727 let m = theme.muted.fg();
728 let p = theme.primary.fg();
729 let rarity_color = state.rarity.color_code();
730
731 let info_lines = [
732 format!(
733 "{a}{}{r} | {p}{}{r} | {rarity_color}{}{r} | Lv.{}{r}",
734 state.name,
735 state.species.label(),
736 state.rarity.label(),
737 state.level,
738 ),
739 format!(
740 "{m}Mood: {} | XP: {}{r}",
741 state.mood.label(),
742 format_compact(state.xp),
743 ),
744 format!("{m}\"{}\"{r}", state.speech),
745 ];
746
747 let mut lines = Vec::with_capacity(9);
748 lines.push(String::new());
749 for (i, sprite_line) in state.ascii_art.iter().enumerate() {
750 let info = if i < info_lines.len() {
751 &info_lines[i]
752 } else {
753 ""
754 };
755 lines.push(format!(" {p}{sprite_line}{r} {info}"));
756 }
757 lines.push(String::new());
758 lines.join("\n")
759}
760
761pub fn format_buddy_full(state: &BuddyState, theme: &super::theme::Theme) -> String {
762 let r = super::theme::rst();
763 let a = theme.accent.fg();
764 let m = theme.muted.fg();
765 let p = theme.primary.fg();
766 let s = theme.success.fg();
767 let w = theme.warning.fg();
768 let b = super::theme::bold();
769 let rarity_color = state.rarity.color_code();
770
771 let mut out = Vec::new();
772
773 out.push(String::new());
774 out.push(format!(" {b}{a}Token Guardian{r}"));
775 out.push(String::new());
776
777 for line in &state.ascii_art {
778 out.push(format!(" {p}{line}{r}"));
779 }
780 out.push(String::new());
781
782 out.push(format!(
783 " {b}{a}{}{r} {m}the {}{r} {rarity_color}{}{r} {m}Lv.{}{r}",
784 state.name,
785 state.species.label(),
786 state.rarity.label(),
787 state.level,
788 ));
789 out.push(format!(
790 " {m}Mood: {} | XP: {} / {} | Streak: {}d{r}",
791 state.mood.label(),
792 format_compact(state.xp),
793 format_compact(state.xp_next_level),
794 state.streak_days,
795 ));
796 out.push(format!(
797 " {m}Tokens saved: {} | Bugs prevented: {}{r}",
798 format_compact(state.tokens_saved),
799 state.bugs_prevented,
800 ));
801 out.push(String::new());
802
803 out.push(format!(" {b}Stats{r}"));
804 out.push(format!(
805 " {s}Compression{r} {}",
806 stat_bar(state.stats.compression, theme)
807 ));
808 out.push(format!(
809 " {w}Vigilance {r} {}",
810 stat_bar(state.stats.vigilance, theme)
811 ));
812 out.push(format!(
813 " {p}Endurance {r} {}",
814 stat_bar(state.stats.endurance, theme)
815 ));
816 out.push(format!(
817 " {a}Wisdom {r} {}",
818 stat_bar(state.stats.wisdom, theme)
819 ));
820 out.push(format!(
821 " {m}Experience {r} {}",
822 stat_bar(state.stats.experience, theme)
823 ));
824 out.push(String::new());
825
826 out.push(format!(" {m}\"{}\"{r}", state.speech));
827 out.push(String::new());
828
829 out.join("\n")
830}
831
832fn stat_bar(value: u8, theme: &super::theme::Theme) -> String {
833 let filled = (value as usize) / 5;
834 let empty = 20 - filled;
835 let r = super::theme::rst();
836 let g = theme.success.fg();
837 let m = theme.muted.fg();
838 format!(
839 "{g}{}{m}{}{r} {value}/100",
840 "█".repeat(filled),
841 "░".repeat(empty),
842 )
843}
844
845#[cfg(test)]
850mod tests {
851 use super::*;
852
853 #[test]
854 fn species_from_cargo_commands() {
855 let mut cmds = HashMap::new();
856 cmds.insert(
857 "cargo build".to_string(),
858 super::super::stats::CommandStats {
859 count: 50,
860 input_tokens: 1000,
861 output_tokens: 500,
862 },
863 );
864 assert_eq!(Species::from_commands(&cmds), Species::Crab);
865 }
866
867 #[test]
868 fn species_mixed_is_dragon() {
869 let mut cmds = HashMap::new();
870 cmds.insert(
871 "cargo build".to_string(),
872 super::super::stats::CommandStats {
873 count: 10,
874 input_tokens: 0,
875 output_tokens: 0,
876 },
877 );
878 cmds.insert(
879 "npm install".to_string(),
880 super::super::stats::CommandStats {
881 count: 10,
882 input_tokens: 0,
883 output_tokens: 0,
884 },
885 );
886 cmds.insert(
887 "python app.py".to_string(),
888 super::super::stats::CommandStats {
889 count: 10,
890 input_tokens: 0,
891 output_tokens: 0,
892 },
893 );
894 assert_eq!(Species::from_commands(&cmds), Species::Dragon);
895 }
896
897 #[test]
898 fn species_empty_is_egg() {
899 let cmds = HashMap::new();
900 assert_eq!(Species::from_commands(&cmds), Species::Egg);
901 }
902
903 #[test]
904 fn rarity_levels() {
905 assert_eq!(Rarity::from_tokens_saved(0), Rarity::Egg);
906 assert_eq!(Rarity::from_tokens_saved(5_000), Rarity::Egg);
907 assert_eq!(Rarity::from_tokens_saved(50_000), Rarity::Common);
908 assert_eq!(Rarity::from_tokens_saved(500_000), Rarity::Uncommon);
909 assert_eq!(Rarity::from_tokens_saved(5_000_000), Rarity::Rare);
910 assert_eq!(Rarity::from_tokens_saved(50_000_000), Rarity::Epic);
911 assert_eq!(Rarity::from_tokens_saved(500_000_000), Rarity::Legendary);
912 }
913
914 #[test]
915 fn name_is_deterministic() {
916 let s = user_seed();
917 let n1 = generate_name(s);
918 let n2 = generate_name(s);
919 assert_eq!(n1, n2);
920 }
921
922 #[test]
923 fn format_compact_values() {
924 assert_eq!(format_compact(500), "500");
925 assert_eq!(format_compact(1_500), "1.5K");
926 assert_eq!(format_compact(2_500_000), "2.5M");
927 assert_eq!(format_compact(3_000_000_000), "3.0B");
928 }
929
930 #[test]
931 fn procedural_sprite_returns_7_lines() {
932 for seed in [0u64, 1, 42, 999, 12345, 69_119_999, u64::MAX] {
933 let traits = CreatureTraits::from_seed(seed);
934 for mood in &[
935 Mood::Ecstatic,
936 Mood::Happy,
937 Mood::Content,
938 Mood::Worried,
939 Mood::Sleeping,
940 ] {
941 let sp = render_sprite(&traits, mood);
942 assert_eq!(sp.len(), 7, "sprite for seed={seed}, mood={mood:?}");
943 }
944 }
945 }
946
947 #[test]
948 fn creature_traits_are_deterministic() {
949 let t1 = CreatureTraits::from_seed(42);
950 let t2 = CreatureTraits::from_seed(42);
951 assert_eq!(t1.head, t2.head);
952 assert_eq!(t1.eyes, t2.eyes);
953 assert_eq!(t1.mouth, t2.mouth);
954 assert_eq!(t1.ears, t2.ears);
955 assert_eq!(t1.body, t2.body);
956 assert_eq!(t1.legs, t2.legs);
957 assert_eq!(t1.tail, t2.tail);
958 assert_eq!(t1.markings, t2.markings);
959 }
960
961 #[test]
962 fn different_seeds_produce_different_traits() {
963 let t1 = CreatureTraits::from_seed(1);
964 let t2 = CreatureTraits::from_seed(9999);
965 let same = t1.head == t2.head
966 && t1.eyes == t2.eyes
967 && t1.mouth == t2.mouth
968 && t1.ears == t2.ears
969 && t1.body == t2.body
970 && t1.legs == t2.legs
971 && t1.tail == t2.tail
972 && t1.markings == t2.markings;
973 assert!(
974 !same,
975 "seeds 1 and 9999 should differ in at least one trait"
976 );
977 }
978
979 #[test]
980 fn total_combinations_is_69m() {
981 assert_eq!(12u64 * 10 * 10 * 12 * 10 * 10 * 8 * 6, 69_120_000);
982 }
983
984 #[test]
985 fn xp_next_level_increases() {
986 let lv1 = (1u64 + 1) * (1 + 1) * 50;
987 let lv10 = (10u64 + 1) * (10 + 1) * 50;
988 assert!(lv10 > lv1);
989 }
990}