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