Skip to main content

lean_ctx/core/
buddy.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4// ---------------------------------------------------------------------------
5// Species (derived from dominant toolchain in commands)
6// ---------------------------------------------------------------------------
7
8#[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// ---------------------------------------------------------------------------
106// Rarity
107// ---------------------------------------------------------------------------
108
109#[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// ---------------------------------------------------------------------------
155// Mood
156// ---------------------------------------------------------------------------
157
158#[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// ---------------------------------------------------------------------------
190// RPG Stats
191// ---------------------------------------------------------------------------
192
193#[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// ---------------------------------------------------------------------------
203// Procedural creature traits (8 axes, 69M+ combinations)
204// 12 x 10 x 10 x 12 x 10 x 10 x 8 x 6 = 69,120,000
205// ---------------------------------------------------------------------------
206
207#[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// ---------------------------------------------------------------------------
247// BuddyState (full computed state)
248// ---------------------------------------------------------------------------
249
250#[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
343// ---------------------------------------------------------------------------
344// Mood computation
345// ---------------------------------------------------------------------------
346
347fn 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
383// ---------------------------------------------------------------------------
384// RPG stats
385// ---------------------------------------------------------------------------
386
387fn 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
422// ---------------------------------------------------------------------------
423// Streak
424// ---------------------------------------------------------------------------
425
426fn 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
452// ---------------------------------------------------------------------------
453// Name generator -- Adjective + Noun (deterministic, ~900 combos)
454// ---------------------------------------------------------------------------
455
456fn 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
473// ---------------------------------------------------------------------------
474// Speech bubble
475// ---------------------------------------------------------------------------
476
477fn 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
513// ---------------------------------------------------------------------------
514// Procedural sprite renderer (7 lines, ~16 chars)
515// 12 heads x 10 eyes x 10 mouths x 12 ears x 10 bodies x 10 legs x 8 tails x 6 markings
516// = 69,120,000 unique creatures
517// ---------------------------------------------------------------------------
518
519const 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
720// ---------------------------------------------------------------------------
721// Terminal format
722// ---------------------------------------------------------------------------
723
724pub 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// ---------------------------------------------------------------------------
846// Tests
847// ---------------------------------------------------------------------------
848
849#[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}