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_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// ---------------------------------------------------------------------------
105// Rarity
106// ---------------------------------------------------------------------------
107
108#[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// ---------------------------------------------------------------------------
153// Mood
154// ---------------------------------------------------------------------------
155
156#[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// ---------------------------------------------------------------------------
188// RPG Stats
189// ---------------------------------------------------------------------------
190
191#[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// ---------------------------------------------------------------------------
201// Procedural creature traits (8 axes, 69M+ combinations)
202// 12 x 10 x 10 x 12 x 10 x 10 x 8 x 6 = 69,120,000
203// ---------------------------------------------------------------------------
204
205#[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// ---------------------------------------------------------------------------
243// BuddyState (full computed state)
244// ---------------------------------------------------------------------------
245
246#[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    // Frame 1: blink
413    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    // Frame 2+: level-based effects
420    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
530// ---------------------------------------------------------------------------
531// Mood computation
532// ---------------------------------------------------------------------------
533
534fn 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
571// ---------------------------------------------------------------------------
572// RPG stats
573// ---------------------------------------------------------------------------
574
575fn 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
610// ---------------------------------------------------------------------------
611// Streak
612// ---------------------------------------------------------------------------
613
614fn 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
640// ---------------------------------------------------------------------------
641// Name generator -- Adjective + Noun (deterministic, ~900 combos)
642// ---------------------------------------------------------------------------
643
644fn 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
661// ---------------------------------------------------------------------------
662// Speech bubble
663// ---------------------------------------------------------------------------
664
665fn 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
701// ---------------------------------------------------------------------------
702// Procedural sprite renderer (7 lines, 20 chars wide, center-aligned)
703// 12 heads x 10 eyes x 10 mouths x 12 ears x 10 bodies x 10 legs x 8 tails x 6 markings
704// = 69,120,000 unique creatures
705// ---------------------------------------------------------------------------
706
707const 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
896// ---------------------------------------------------------------------------
897// Terminal format
898// ---------------------------------------------------------------------------
899
900pub 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// ---------------------------------------------------------------------------
1031// Tests
1032// ---------------------------------------------------------------------------
1033
1034#[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}