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