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    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub ascii_frames: Vec<Vec<String>>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub anim_ms: Option<u32>,
269    pub traits: CreatureTraits,
270}
271
272impl BuddyState {
273    pub fn compute() -> Self {
274        let store = super::stats::load();
275        let tokens_saved = store
276            .total_input_tokens
277            .saturating_sub(store.total_output_tokens);
278
279        let project_root = detect_project_root_for_buddy();
280        let gotcha_store = if !project_root.is_empty() {
281            super::gotcha_tracker::GotchaStore::load(&project_root)
282        } else {
283            super::gotcha_tracker::GotchaStore::new("none")
284        };
285
286        let bugs_prevented = gotcha_store.stats.total_prevented;
287        let errors_detected = gotcha_store.stats.total_errors_detected;
288
289        let species = Species::from_commands(&store.commands);
290        let rarity = Rarity::from_tokens_saved(tokens_saved);
291
292        let xp = tokens_saved / 1000 + store.total_commands * 5 + bugs_prevented * 100;
293        let level = ((xp as f64 / 50.0).sqrt().floor() as u32).min(99);
294        let xp_next_level = ((level + 1) as u64) * ((level + 1) as u64) * 50;
295
296        let streak_days = compute_streak(&store.daily);
297        let compression_rate = if store.total_input_tokens > 0 {
298            (tokens_saved as f64 / store.total_input_tokens as f64 * 100.0) as u8
299        } else {
300            0
301        };
302
303        let mood = compute_mood(
304            compression_rate,
305            errors_detected,
306            bugs_prevented,
307            streak_days,
308            &store,
309        );
310
311        let rpg_stats = compute_rpg_stats(
312            compression_rate,
313            bugs_prevented,
314            errors_detected,
315            streak_days,
316            store.commands.len(),
317            store.total_commands,
318        );
319
320        let seed = user_seed();
321        let traits = CreatureTraits::from_seed(seed);
322        let name = generate_name(seed);
323        let sprite = render_sprite_pack(&traits, &mood, level);
324        let ascii_art = sprite.base.clone();
325        let speech = generate_speech(&mood, tokens_saved, bugs_prevented, streak_days);
326
327        Self {
328            name,
329            species,
330            rarity,
331            level,
332            xp,
333            xp_next_level,
334            mood,
335            stats: rpg_stats,
336            speech,
337            tokens_saved,
338            bugs_prevented,
339            streak_days,
340            ascii_art,
341            ascii_frames: sprite.frames,
342            anim_ms: sprite.anim_ms,
343            traits,
344        }
345    }
346}
347
348fn detect_project_root_for_buddy() -> String {
349    if let Some(session) = super::session::SessionState::load_latest() {
350        if let Some(root) = session.project_root.as_deref() {
351            if !root.trim().is_empty() {
352                return root.to_string();
353            }
354        }
355        if let Some(cwd) = session.shell_cwd.as_deref() {
356            if !cwd.trim().is_empty() {
357                return super::protocol::detect_project_root_or_cwd(cwd);
358            }
359        }
360        if let Some(last) = session.files_touched.last() {
361            if !last.path.trim().is_empty() {
362                if let Some(parent) = std::path::Path::new(&last.path).parent() {
363                    let p = parent.to_string_lossy().to_string();
364                    return super::protocol::detect_project_root_or_cwd(&p);
365                }
366            }
367        }
368    }
369    std::env::current_dir()
370        .map(|p| super::protocol::detect_project_root_or_cwd(&p.to_string_lossy()))
371        .unwrap_or_default()
372}
373
374struct SpritePack {
375    base: Vec<String>,
376    frames: Vec<Vec<String>>,
377    anim_ms: Option<u32>,
378}
379
380fn sprite_tier(level: u32) -> u8 {
381    if level >= 75 {
382        4
383    } else if level >= 50 {
384        3
385    } else if level >= 25 {
386        2
387    } else if level >= 10 {
388        1
389    } else {
390        0
391    }
392}
393
394fn tier_anim_ms(tier: u8) -> Option<u32> {
395    match tier {
396        0 => None,
397        1 => Some(950),
398        2 => Some(700),
399        3 => Some(520),
400        _ => Some(380),
401    }
402}
403
404fn render_sprite_pack(traits: &CreatureTraits, mood: &Mood, level: u32) -> SpritePack {
405    let base = render_sprite(traits, mood);
406    let tier = sprite_tier(level);
407    if tier == 0 {
408        return SpritePack {
409            base,
410            frames: Vec::new(),
411            anim_ms: None,
412        };
413    }
414
415    let mut frames = Vec::new();
416    frames.push(base.clone());
417
418    // Frame 1: blink
419    let blink = match mood {
420        Mood::Sleeping => ("u", "u"),
421        _ => (".", "."),
422    };
423    frames.push(render_sprite_with_eyes(traits, mood, blink.0, blink.1));
424
425    // Frame 2+: level-based effects
426    if tier >= 2 {
427        let mut s = base.clone();
428        if let Some(l0) = s.get_mut(0) {
429            *l0 = sparkle_edges(l0, '*', '+');
430        }
431        frames.push(s);
432    }
433    if tier >= 3 {
434        let mut s = base.clone();
435        for line in &mut s {
436            *line = shift(line, 1);
437        }
438        frames.push(s);
439    }
440    if tier >= 4 {
441        let mut s = base.clone();
442        for (i, line) in s.iter_mut().enumerate() {
443            let (l, r) = if i % 2 == 0 { ('+', '+') } else { ('*', '*') };
444            *line = edge_aura(line, l, r);
445        }
446        frames.push(s);
447    }
448
449    SpritePack {
450        base,
451        frames,
452        anim_ms: tier_anim_ms(tier),
453    }
454}
455
456fn render_sprite_with_eyes(
457    traits: &CreatureTraits,
458    mood: &Mood,
459    el: &str,
460    er: &str,
461) -> Vec<String> {
462    let ear_line = ear_part(traits.ears);
463    let head_top = head_top_part(traits.head);
464    let eye_line = eye_part(traits.head, traits.eyes, el, er, mood);
465    let mouth_line = mouth_part(traits.head, traits.mouth);
466    let head_bottom = head_bottom_part(traits.head, traits.body);
467    let body_line = body_part(traits.body, traits.markings);
468    let leg_line = leg_part(traits.legs, traits.tail);
469
470    vec![
471        pad(&ear_line),
472        pad(&head_top),
473        pad(&eye_line),
474        pad(&mouth_line),
475        pad(&head_bottom),
476        pad(&body_line),
477        pad(&leg_line),
478    ]
479}
480
481fn sparkle_edges(line: &str, left: char, right: char) -> String {
482    let mut chars: Vec<char> = line.chars().collect();
483    if !chars.is_empty() {
484        chars[0] = left;
485        let last = chars.len().saturating_sub(1);
486        chars[last] = right;
487    }
488    pad(&chars.into_iter().collect::<String>())
489}
490
491fn edge_aura(line: &str, left: char, right: char) -> String {
492    let mut chars: Vec<char> = line.chars().collect();
493    if chars.len() >= 2 {
494        chars[0] = left;
495        let last = chars.len() - 1;
496        chars[last] = right;
497    }
498    pad(&chars.into_iter().collect::<String>())
499}
500
501fn shift(line: &str, offset: i32) -> String {
502    if offset == 0 {
503        return pad(line);
504    }
505    let s = pad(line);
506    let mut chars: Vec<char> = s.chars().collect();
507    if chars.is_empty() {
508        return s;
509    }
510    if offset > 0 {
511        for _ in 0..offset {
512            chars.insert(0, ' ');
513            chars.pop();
514        }
515    } else {
516        for _ in 0..(-offset) {
517            chars.remove(0);
518            chars.push(' ');
519        }
520    }
521    chars.into_iter().collect()
522}
523
524fn sprite_lines_for_tick(state: &BuddyState, tick: Option<u64>) -> &[String] {
525    if let Some(t) = tick {
526        if !state.ascii_frames.is_empty() {
527            let idx = (t as usize) % state.ascii_frames.len();
528            return &state.ascii_frames[idx];
529        }
530    }
531    &state.ascii_art
532}
533
534// ---------------------------------------------------------------------------
535// Mood computation
536// ---------------------------------------------------------------------------
537
538fn compute_mood(
539    compression: u8,
540    errors: u64,
541    prevented: u64,
542    streak: u32,
543    store: &super::stats::StatsStore,
544) -> Mood {
545    let hours_since_last = store
546        .last_use
547        .as_ref()
548        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
549        .map(|dt| (chrono::Utc::now() - dt.with_timezone(&chrono::Utc)).num_hours())
550        .unwrap_or(999);
551
552    if hours_since_last > 48 {
553        return Mood::Sleeping;
554    }
555
556    let recent_errors = store
557        .daily
558        .iter()
559        .rev()
560        .take(1)
561        .any(|d| d.input_tokens > 0 && d.output_tokens > d.input_tokens);
562
563    if compression > 60 && errors == 0 && streak >= 7 {
564        Mood::Ecstatic
565    } else if compression > 40 || prevented > 0 {
566        Mood::Happy
567    } else if recent_errors || (errors > 5 && prevented == 0) {
568        Mood::Worried
569    } else {
570        Mood::Content
571    }
572}
573
574// ---------------------------------------------------------------------------
575// RPG stats
576// ---------------------------------------------------------------------------
577
578fn compute_rpg_stats(
579    compression: u8,
580    prevented: u64,
581    errors: u64,
582    streak: u32,
583    unique_cmds: usize,
584    total_cmds: u64,
585) -> BuddyStats {
586    let compression_stat = compression.min(100);
587
588    let vigilance = if errors > 0 {
589        ((prevented as f64 / errors as f64) * 80.0).min(100.0) as u8
590    } else if prevented > 0 {
591        100
592    } else {
593        20
594    };
595
596    let endurance = (streak * 5).min(100) as u8;
597    let wisdom = (unique_cmds as u8).min(100);
598    let experience = if total_cmds > 0 {
599        ((total_cmds as f64).log10() * 25.0).min(100.0) as u8
600    } else {
601        0
602    };
603
604    BuddyStats {
605        compression: compression_stat,
606        vigilance,
607        endurance,
608        wisdom,
609        experience,
610    }
611}
612
613// ---------------------------------------------------------------------------
614// Streak
615// ---------------------------------------------------------------------------
616
617fn compute_streak(daily: &[super::stats::DayStats]) -> u32 {
618    if daily.is_empty() {
619        return 0;
620    }
621
622    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
623    let mut streak = 0u32;
624    let mut expected = today.clone();
625
626    for day in daily.iter().rev() {
627        if day.date == expected && day.commands > 0 {
628            streak += 1;
629            if let Ok(dt) = chrono::NaiveDate::parse_from_str(&expected, "%Y-%m-%d") {
630                expected = (dt - chrono::Duration::days(1))
631                    .format("%Y-%m-%d")
632                    .to_string();
633            } else {
634                break;
635            }
636        } else if day.date < expected {
637            break;
638        }
639    }
640    streak
641}
642
643// ---------------------------------------------------------------------------
644// Name generator -- Adjective + Noun (deterministic, ~900 combos)
645// ---------------------------------------------------------------------------
646
647fn generate_name(seed: u64) -> String {
648    const ADJ: &[&str] = &[
649        "Swift", "Quiet", "Bright", "Bold", "Clever", "Brave", "Lucky", "Tiny", "Cosmic", "Fuzzy",
650        "Nimble", "Jolly", "Mighty", "Gentle", "Witty", "Keen", "Sly", "Calm", "Wild", "Vivid",
651        "Dusk", "Dawn", "Neon", "Frost", "Solar", "Lunar", "Pixel", "Turbo", "Nano", "Mega",
652    ];
653    const NOUN: &[&str] = &[
654        "Ember", "Reef", "Spark", "Byte", "Flux", "Echo", "Drift", "Glitch", "Pulse", "Shade",
655        "Orbit", "Fern", "Rust", "Zinc", "Flint", "Quartz", "Maple", "Cedar", "Opal", "Moss",
656        "Ridge", "Cove", "Peak", "Dune", "Vale", "Brook", "Cliff", "Storm", "Blaze", "Mist",
657    ];
658
659    let adj_idx = (seed >> 8) as usize % ADJ.len();
660    let noun_idx = (seed >> 16) as usize % NOUN.len();
661    format!("{} {}", ADJ[adj_idx], NOUN[noun_idx])
662}
663
664// ---------------------------------------------------------------------------
665// Speech bubble
666// ---------------------------------------------------------------------------
667
668fn generate_speech(mood: &Mood, tokens_saved: u64, bugs_prevented: u64, streak: u32) -> String {
669    match mood {
670        Mood::Ecstatic => {
671            if bugs_prevented > 0 {
672                format!("{bugs_prevented} bugs prevented! We're unstoppable!")
673            } else {
674                format!("{} tokens saved! On fire!", format_compact(tokens_saved))
675            }
676        }
677        Mood::Happy => {
678            if streak >= 3 {
679                format!("{streak}-day streak! Keep going!")
680            } else if bugs_prevented > 0 {
681                format!("Caught {bugs_prevented} bugs before they happened!")
682            } else {
683                format!("{} tokens saved so far!", format_compact(tokens_saved))
684            }
685        }
686        Mood::Content => "Watching your code... all good.".to_string(),
687        Mood::Worried => "I see some errors. Let's fix them!".to_string(),
688        Mood::Sleeping => "Zzz... wake me with some code!".to_string(),
689    }
690}
691
692fn format_compact(n: u64) -> String {
693    if n >= 1_000_000_000 {
694        format!("{:.1}B", n as f64 / 1_000_000_000.0)
695    } else if n >= 1_000_000 {
696        format!("{:.1}M", n as f64 / 1_000_000.0)
697    } else if n >= 1_000 {
698        format!("{:.1}K", n as f64 / 1_000.0)
699    } else {
700        format!("{n}")
701    }
702}
703
704// ---------------------------------------------------------------------------
705// Procedural sprite renderer (7 lines, ~16 chars)
706// 12 heads x 10 eyes x 10 mouths x 12 ears x 10 bodies x 10 legs x 8 tails x 6 markings
707// = 69,120,000 unique creatures
708// ---------------------------------------------------------------------------
709
710const W: usize = 16;
711
712fn pad(s: &str) -> String {
713    let len = s.chars().count();
714    if len >= W {
715        s.to_string()
716    } else {
717        format!("{}{}", s, " ".repeat(W - len))
718    }
719}
720
721pub fn render_sprite(traits: &CreatureTraits, mood: &Mood) -> Vec<String> {
722    let eye_l = mood_eye_left(mood);
723    let eye_r = mood_eye_right(mood);
724
725    let ear_line = ear_part(traits.ears);
726    let head_top = head_top_part(traits.head);
727    let eye_line = eye_part(traits.head, traits.eyes, eye_l, eye_r, mood);
728    let mouth_line = mouth_part(traits.head, traits.mouth);
729    let head_bottom = head_bottom_part(traits.head, traits.body);
730    let body_line = body_part(traits.body, traits.markings);
731    let leg_line = leg_part(traits.legs, traits.tail);
732
733    vec![
734        pad(&ear_line),
735        pad(&head_top),
736        pad(&eye_line),
737        pad(&mouth_line),
738        pad(&head_bottom),
739        pad(&body_line),
740        pad(&leg_line),
741    ]
742}
743
744fn mood_eye_left(mood: &Mood) -> &'static str {
745    match mood {
746        Mood::Ecstatic => "*",
747        Mood::Happy => "o",
748        Mood::Content => "-",
749        Mood::Worried => ">",
750        Mood::Sleeping => "u",
751    }
752}
753
754fn mood_eye_right(mood: &Mood) -> &'static str {
755    match mood {
756        Mood::Ecstatic => "*",
757        Mood::Happy => "o",
758        Mood::Content => "-",
759        Mood::Worried => "<",
760        Mood::Sleeping => "u",
761    }
762}
763
764fn ear_part(idx: u8) -> String {
765    match idx % 12 {
766        0 => "      /\\  /\\".into(),
767        1 => "     /  \\/  \\".into(),
768        2 => "     \\\\    //".into(),
769        3 => "      ||  ||".into(),
770        4 => "     ~'    '~".into(),
771        5 => "      >>  <<".into(),
772        6 => "      **  **".into(),
773        7 => "     .''  ''. ".into(),
774        8 => "      ~~  ~~".into(),
775        9 => "      ##  ##".into(),
776        10 => "      ^^  ^^".into(),
777        _ => "      <>  <>".into(),
778    }
779}
780
781fn head_top_part(idx: u8) -> String {
782    match idx % 12 {
783        0 => "     .____.    ".into(),
784        1 => "     .----.    ".into(),
785        2 => "     /----\\    ".into(),
786        3 => "     .====.    ".into(),
787        4 => "    .------.   ".into(),
788        5 => "     .____.    ".into(),
789        6 => "     /~~~~\\    ".into(),
790        7 => "     {____}    ".into(),
791        8 => "     <____>    ".into(),
792        9 => "     .^--^.    ".into(),
793        10 => "     /****\\    ".into(),
794        _ => "     (____)    ".into(),
795    }
796}
797
798fn eye_part(head: u8, _eye_idx: u8, el: &str, er: &str, _mood: &Mood) -> String {
799    let bracket = head_bracket(head);
800    match head % 12 {
801        2 => format!("    / {} {} \\   ", el, er),
802        _ => format!("   {} {} {} {}   ", bracket.0, el, er, bracket.1),
803    }
804}
805
806fn head_bracket(head: u8) -> (&'static str, &'static str) {
807    match head % 12 {
808        0 => ("/", "\\"),
809        1 => ("|", "|"),
810        2 => ("/", "\\"),
811        3 => ("/", "\\"),
812        4 => ("(", ")"),
813        5 => ("|", "|"),
814        6 => ("|", "|"),
815        7 => ("{", "}"),
816        8 => ("<", ">"),
817        9 => ("(", ")"),
818        10 => ("/", "\\"),
819        _ => ("(", ")"),
820    }
821}
822
823fn mouth_part(head: u8, mouth: u8) -> String {
824    let m = match mouth % 10 {
825        0 => "\\_/",
826        1 => " w ",
827        2 => " ^ ",
828        3 => " ~ ",
829        4 => "===",
830        5 => " o ",
831        6 => " 3 ",
832        7 => " v ",
833        8 => "---",
834        _ => " U ",
835    };
836    let bracket = head_bracket(head);
837    format!("   {}  {}  {}   ", bracket.0, m, bracket.1)
838}
839
840fn head_bottom_part(head: u8, _body: u8) -> String {
841    match head % 12 {
842        0 => "     '----'    ".into(),
843        1 => "     '+--+'    ".into(),
844        2 => "     \\____/    ".into(),
845        3 => "     '===='    ".into(),
846        4 => "    '------'   ".into(),
847        5 => "     '----'    ".into(),
848        6 => "     \\~~~~/    ".into(),
849        7 => "     {____}    ".into(),
850        8 => "     <____>    ".into(),
851        9 => "     '^--^'    ".into(),
852        10 => "     \\****/    ".into(),
853        _ => "     (____)    ".into(),
854    }
855}
856
857fn body_part(body: u8, markings: u8) -> String {
858    let mark = match markings % 6 {
859        0 => "    ",
860        1 => "||||",
861        2 => "....",
862        3 => ">>>>",
863        4 => "~~~~",
864        _ => "::::",
865    };
866    match body % 10 {
867        0 => format!("     /{mark}\\    "),
868        1 => format!("     |{mark}|    "),
869        2 => format!("    ({mark})     "),
870        3 => format!("    [{mark}]     "),
871        4 => format!("    ~{mark}~     "),
872        5 => format!("     <{mark}>    "),
873        6 => format!("     {{{mark}}}    "),
874        7 => format!("     |{mark}|    "),
875        8 => format!("    /{mark}\\     "),
876        _ => format!("    _{mark}_     "),
877    }
878}
879
880fn leg_part(legs: u8, tail: u8) -> String {
881    let t = match tail % 8 {
882        0 => "",
883        1 => "~",
884        2 => ">",
885        3 => ")",
886        4 => "^",
887        5 => "*",
888        6 => "=",
889        _ => "/",
890    };
891    let base = match legs % 10 {
892        0 => "   /|    |\\",
893        1 => "   ~~    ~~",
894        2 => "  _/|    |\\_",
895        3 => "   ||    ||",
896        4 => "   /\\    /\\",
897        5 => "   <>    <>",
898        6 => "   ()    ()",
899        7 => "   }{    }{",
900        8 => "   //    \\\\",
901        _ => "   \\/    \\/",
902    };
903    if t.is_empty() {
904        format!("{base}    ")
905    } else {
906        format!("{base} {t}  ")
907    }
908}
909
910// ---------------------------------------------------------------------------
911// Terminal format
912// ---------------------------------------------------------------------------
913
914pub fn format_buddy_block(state: &BuddyState, theme: &super::theme::Theme) -> String {
915    format_buddy_block_at(state, theme, None)
916}
917
918pub fn format_buddy_block_at(
919    state: &BuddyState,
920    theme: &super::theme::Theme,
921    tick: Option<u64>,
922) -> String {
923    let r = super::theme::rst();
924    let a = theme.accent.fg();
925    let m = theme.muted.fg();
926    let p = theme.primary.fg();
927    let rarity_color = state.rarity.color_code();
928
929    let info_lines = [
930        format!(
931            "{a}{}{r} | {p}{}{r} | {rarity_color}{}{r} | Lv.{}{r}",
932            state.name,
933            state.species.label(),
934            state.rarity.label(),
935            state.level,
936        ),
937        format!(
938            "{m}Mood: {} | XP: {}{r}",
939            state.mood.label(),
940            format_compact(state.xp),
941        ),
942        format!("{m}\"{}\"{r}", state.speech),
943    ];
944
945    let mut lines = Vec::with_capacity(9);
946    lines.push(String::new());
947    let sprite = sprite_lines_for_tick(state, tick);
948    for (i, sprite_line) in sprite.iter().enumerate() {
949        let info = if i < info_lines.len() {
950            &info_lines[i]
951        } else {
952            ""
953        };
954        lines.push(format!("  {p}{sprite_line}{r}  {info}"));
955    }
956    lines.push(String::new());
957    lines.join("\n")
958}
959
960pub fn format_buddy_full(state: &BuddyState, theme: &super::theme::Theme) -> String {
961    let r = super::theme::rst();
962    let a = theme.accent.fg();
963    let m = theme.muted.fg();
964    let p = theme.primary.fg();
965    let s = theme.success.fg();
966    let w = theme.warning.fg();
967    let b = super::theme::bold();
968    let rarity_color = state.rarity.color_code();
969
970    let mut out = Vec::new();
971
972    out.push(String::new());
973    out.push(format!("  {b}{a}Token Guardian{r}"));
974    out.push(String::new());
975
976    for line in &state.ascii_art {
977        out.push(format!("    {p}{line}{r}"));
978    }
979    out.push(String::new());
980
981    out.push(format!(
982        "  {b}{a}{}{r}  {m}the {}{r}  {rarity_color}{}{r}  {m}Lv.{}{r}",
983        state.name,
984        state.species.label(),
985        state.rarity.label(),
986        state.level,
987    ));
988    out.push(format!(
989        "  {m}Mood: {}  |  XP: {} / {}  |  Streak: {}d{r}",
990        state.mood.label(),
991        format_compact(state.xp),
992        format_compact(state.xp_next_level),
993        state.streak_days,
994    ));
995    out.push(format!(
996        "  {m}Tokens saved: {}  |  Bugs prevented: {}{r}",
997        format_compact(state.tokens_saved),
998        state.bugs_prevented,
999    ));
1000    out.push(String::new());
1001
1002    out.push(format!("  {b}Stats{r}"));
1003    out.push(format!(
1004        "  {s}Compression{r}  {}",
1005        stat_bar(state.stats.compression, theme)
1006    ));
1007    out.push(format!(
1008        "  {w}Vigilance  {r}  {}",
1009        stat_bar(state.stats.vigilance, theme)
1010    ));
1011    out.push(format!(
1012        "  {p}Endurance  {r}  {}",
1013        stat_bar(state.stats.endurance, theme)
1014    ));
1015    out.push(format!(
1016        "  {a}Wisdom     {r}  {}",
1017        stat_bar(state.stats.wisdom, theme)
1018    ));
1019    out.push(format!(
1020        "  {m}Experience {r}  {}",
1021        stat_bar(state.stats.experience, theme)
1022    ));
1023    out.push(String::new());
1024
1025    out.push(format!("  {m}\"{}\"{r}", state.speech));
1026    out.push(String::new());
1027
1028    out.join("\n")
1029}
1030
1031fn stat_bar(value: u8, theme: &super::theme::Theme) -> String {
1032    let filled = (value as usize) / 5;
1033    let empty = 20 - filled;
1034    let r = super::theme::rst();
1035    let g = theme.success.fg();
1036    let m = theme.muted.fg();
1037    format!(
1038        "{g}{}{m}{}{r} {value}/100",
1039        "█".repeat(filled),
1040        "░".repeat(empty),
1041    )
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Tests
1046// ---------------------------------------------------------------------------
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051
1052    #[test]
1053    fn species_from_cargo_commands() {
1054        let mut cmds = HashMap::new();
1055        cmds.insert(
1056            "cargo build".to_string(),
1057            super::super::stats::CommandStats {
1058                count: 50,
1059                input_tokens: 1000,
1060                output_tokens: 500,
1061            },
1062        );
1063        assert_eq!(Species::from_commands(&cmds), Species::Crab);
1064    }
1065
1066    #[test]
1067    fn species_mixed_is_dragon() {
1068        let mut cmds = HashMap::new();
1069        cmds.insert(
1070            "cargo build".to_string(),
1071            super::super::stats::CommandStats {
1072                count: 10,
1073                input_tokens: 0,
1074                output_tokens: 0,
1075            },
1076        );
1077        cmds.insert(
1078            "npm install".to_string(),
1079            super::super::stats::CommandStats {
1080                count: 10,
1081                input_tokens: 0,
1082                output_tokens: 0,
1083            },
1084        );
1085        cmds.insert(
1086            "python app.py".to_string(),
1087            super::super::stats::CommandStats {
1088                count: 10,
1089                input_tokens: 0,
1090                output_tokens: 0,
1091            },
1092        );
1093        assert_eq!(Species::from_commands(&cmds), Species::Dragon);
1094    }
1095
1096    #[test]
1097    fn species_empty_is_egg() {
1098        let cmds = HashMap::new();
1099        assert_eq!(Species::from_commands(&cmds), Species::Egg);
1100    }
1101
1102    #[test]
1103    fn rarity_levels() {
1104        assert_eq!(Rarity::from_tokens_saved(0), Rarity::Egg);
1105        assert_eq!(Rarity::from_tokens_saved(5_000), Rarity::Egg);
1106        assert_eq!(Rarity::from_tokens_saved(50_000), Rarity::Common);
1107        assert_eq!(Rarity::from_tokens_saved(500_000), Rarity::Uncommon);
1108        assert_eq!(Rarity::from_tokens_saved(5_000_000), Rarity::Rare);
1109        assert_eq!(Rarity::from_tokens_saved(50_000_000), Rarity::Epic);
1110        assert_eq!(Rarity::from_tokens_saved(500_000_000), Rarity::Legendary);
1111    }
1112
1113    #[test]
1114    fn name_is_deterministic() {
1115        let s = user_seed();
1116        let n1 = generate_name(s);
1117        let n2 = generate_name(s);
1118        assert_eq!(n1, n2);
1119    }
1120
1121    #[test]
1122    fn format_compact_values() {
1123        assert_eq!(format_compact(500), "500");
1124        assert_eq!(format_compact(1_500), "1.5K");
1125        assert_eq!(format_compact(2_500_000), "2.5M");
1126        assert_eq!(format_compact(3_000_000_000), "3.0B");
1127    }
1128
1129    #[test]
1130    fn procedural_sprite_returns_7_lines() {
1131        for seed in [0u64, 1, 42, 999, 12345, 69_119_999, u64::MAX] {
1132            let traits = CreatureTraits::from_seed(seed);
1133            for mood in &[
1134                Mood::Ecstatic,
1135                Mood::Happy,
1136                Mood::Content,
1137                Mood::Worried,
1138                Mood::Sleeping,
1139            ] {
1140                let sp = render_sprite(&traits, mood);
1141                assert_eq!(sp.len(), 7, "sprite for seed={seed}, mood={mood:?}");
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn creature_traits_are_deterministic() {
1148        let t1 = CreatureTraits::from_seed(42);
1149        let t2 = CreatureTraits::from_seed(42);
1150        assert_eq!(t1.head, t2.head);
1151        assert_eq!(t1.eyes, t2.eyes);
1152        assert_eq!(t1.mouth, t2.mouth);
1153        assert_eq!(t1.ears, t2.ears);
1154        assert_eq!(t1.body, t2.body);
1155        assert_eq!(t1.legs, t2.legs);
1156        assert_eq!(t1.tail, t2.tail);
1157        assert_eq!(t1.markings, t2.markings);
1158    }
1159
1160    #[test]
1161    fn different_seeds_produce_different_traits() {
1162        let t1 = CreatureTraits::from_seed(1);
1163        let t2 = CreatureTraits::from_seed(9999);
1164        let same = t1.head == t2.head
1165            && t1.eyes == t2.eyes
1166            && t1.mouth == t2.mouth
1167            && t1.ears == t2.ears
1168            && t1.body == t2.body
1169            && t1.legs == t2.legs
1170            && t1.tail == t2.tail
1171            && t1.markings == t2.markings;
1172        assert!(
1173            !same,
1174            "seeds 1 and 9999 should differ in at least one trait"
1175        );
1176    }
1177
1178    #[test]
1179    fn total_combinations_is_69m() {
1180        assert_eq!(12u64 * 10 * 10 * 12 * 10 * 10 * 8 * 6, 69_120_000);
1181    }
1182
1183    #[test]
1184    fn xp_next_level_increases() {
1185        let lv1 = (1u64 + 1) * (1 + 1) * 50;
1186        let lv10 = (10u64 + 1) * (10 + 1) * 50;
1187        assert!(lv10 > lv1);
1188    }
1189}