Skip to main content

fallout_render/
lib.rs

1use std::fmt::Write as _;
2
3use fallout_core::core_api::{
4    CharacterExport, Game as CoreGame, InventoryEntry, KillCountEntry, PerkEntry,
5    ResolvedInventoryEntry, Session, SkillEntry, StatEntry, TraitEntry,
6};
7use serde_json::{Map as JsonMap, Value as JsonValue};
8
9const THREE_COL_WIDTH_A: usize = 25;
10const THREE_COL_WIDTH_B: usize = 24;
11const THREE_COL_WIDTH_C: usize = 25;
12const TWO_COL_WIDTH_LEFT: usize = 30;
13const TWO_COL_WIDTH_RIGHT: usize = 44;
14const INVENTORY_COL_WIDTH_A: usize = 25;
15const INVENTORY_COL_WIDTH_B: usize = 25;
16const INVENTORY_COL_WIDTH_C: usize = 23;
17const INVENTORY_CAPS_PID: i32 = 41;
18const STAT_MAX_HP_INDEX: usize = 7;
19const STAT_AGE_INDEX: usize = 33;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum JsonStyle {
23    #[default]
24    CanonicalV1,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TextStyle {
29    #[default]
30    ClassicFallout,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub struct TextRenderOptions {
35    pub verbose: bool,
36}
37
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
39pub struct FieldSelection {
40    pub name: bool,
41    pub description: bool,
42    pub gender: bool,
43    pub age: bool,
44    pub level: bool,
45    pub xp: bool,
46    pub karma: bool,
47    pub reputation: bool,
48    pub skill_points: bool,
49    pub map_filename: bool,
50    pub elevation: bool,
51    pub game_date: bool,
52    pub save_date: bool,
53    pub traits: bool,
54    pub hp: bool,
55    pub max_hp: bool,
56    pub next_level_xp: bool,
57    pub game_time: bool,
58    pub special: bool,
59    pub derived_stats: bool,
60    pub skills: bool,
61    pub perks: bool,
62    pub kills: bool,
63    pub inventory: bool,
64}
65
66impl FieldSelection {
67    pub fn is_any_selected(&self) -> bool {
68        self.name
69            || self.description
70            || self.gender
71            || self.age
72            || self.level
73            || self.xp
74            || self.karma
75            || self.reputation
76            || self.skill_points
77            || self.map_filename
78            || self.elevation
79            || self.game_date
80            || self.save_date
81            || self.traits
82            || self.hp
83            || self.max_hp
84            || self.next_level_xp
85            || self.game_time
86            || self.special
87            || self.derived_stats
88            || self.skills
89            || self.perks
90            || self.kills
91            || self.inventory
92    }
93}
94
95pub fn render_json_full(session: &Session, style: JsonStyle) -> JsonValue {
96    render_json_full_with_inventory(session, style, None)
97}
98
99pub fn render_json_full_with_inventory(
100    session: &Session,
101    style: JsonStyle,
102    inventory: Option<&[ResolvedInventoryEntry]>,
103) -> JsonValue {
104    let export = session.export_character();
105    render_json_full_from_export_with_inventory(&export, style, inventory)
106}
107
108pub fn render_json_full_from_export(export: &CharacterExport, style: JsonStyle) -> JsonValue {
109    render_json_full_from_export_with_inventory(export, style, None)
110}
111
112pub fn render_json_full_from_export_with_inventory(
113    export: &CharacterExport,
114    style: JsonStyle,
115    inventory: Option<&[ResolvedInventoryEntry]>,
116) -> JsonValue {
117    match style {
118        JsonStyle::CanonicalV1 => JsonValue::Object(default_json(export, inventory)),
119    }
120}
121
122pub fn render_json_selected(
123    session: &Session,
124    fields: &FieldSelection,
125    style: JsonStyle,
126) -> JsonValue {
127    render_json_selected_with_inventory(session, fields, style, None)
128}
129
130pub fn render_json_selected_with_inventory(
131    session: &Session,
132    fields: &FieldSelection,
133    style: JsonStyle,
134    inventory: Option<&[ResolvedInventoryEntry]>,
135) -> JsonValue {
136    let export = session.export_character();
137    render_json_selected_from_export_with_inventory(&export, fields, style, inventory)
138}
139
140pub fn render_json_selected_from_export(
141    export: &CharacterExport,
142    fields: &FieldSelection,
143    style: JsonStyle,
144) -> JsonValue {
145    render_json_selected_from_export_with_inventory(export, fields, style, None)
146}
147
148pub fn render_json_selected_from_export_with_inventory(
149    export: &CharacterExport,
150    fields: &FieldSelection,
151    style: JsonStyle,
152    inventory: Option<&[ResolvedInventoryEntry]>,
153) -> JsonValue {
154    match style {
155        JsonStyle::CanonicalV1 => JsonValue::Object(selected_json(fields, export, inventory)),
156    }
157}
158
159pub fn render_classic_sheet(session: &Session) -> String {
160    render_classic_sheet_with_inventory(session, TextRenderOptions::default(), None, None)
161}
162
163pub fn render_text(session: &Session, style: TextStyle) -> String {
164    render_text_with_options(session, style, TextRenderOptions::default())
165}
166
167pub fn render_classic_sheet_with_options(session: &Session, options: TextRenderOptions) -> String {
168    render_classic_sheet_with_inventory(session, options, None, None)
169}
170
171pub fn render_classic_sheet_with_inventory(
172    session: &Session,
173    options: TextRenderOptions,
174    inventory: Option<&[ResolvedInventoryEntry]>,
175    total_weight_lbs: Option<i32>,
176) -> String {
177    render_classic_sheet_impl(session, options, inventory, total_weight_lbs)
178}
179
180pub fn render_text_with_options(
181    session: &Session,
182    style: TextStyle,
183    options: TextRenderOptions,
184) -> String {
185    match style {
186        TextStyle::ClassicFallout => render_classic_sheet_impl(session, options, None, None),
187    }
188}
189
190fn selected_json(
191    fields: &FieldSelection,
192    export: &CharacterExport,
193    inventory: Option<&[ResolvedInventoryEntry]>,
194) -> JsonMap<String, JsonValue> {
195    let mut out = JsonMap::new();
196
197    if fields.description {
198        out.insert(
199            "description".to_string(),
200            JsonValue::String(export.description.clone()),
201        );
202    }
203    if fields.game_date {
204        out.insert(
205            "game_date".to_string(),
206            JsonValue::String(format_date(
207                export.game_date.year,
208                export.game_date.month,
209                export.game_date.day,
210            )),
211        );
212    }
213    if fields.save_date {
214        out.insert(
215            "save_date".to_string(),
216            JsonValue::String(format_date(
217                export.save_date.year,
218                export.save_date.month,
219                export.save_date.day,
220            )),
221        );
222    }
223    if fields.game_time {
224        out.insert(
225            "game_time".to_string(),
226            JsonValue::String(format_game_time(export.game_time)),
227        );
228    }
229    if fields.name {
230        out.insert("name".to_string(), JsonValue::String(export.name.clone()));
231    }
232    if fields.gender {
233        out.insert(
234            "gender".to_string(),
235            JsonValue::String(export.gender.to_string()),
236        );
237    }
238    if fields.level {
239        out.insert("level".to_string(), JsonValue::from(export.level));
240    }
241    if fields.xp {
242        out.insert("xp".to_string(), JsonValue::from(export.xp));
243    }
244    if fields.next_level_xp {
245        out.insert(
246            "next_level_xp".to_string(),
247            JsonValue::from(export.next_level_xp),
248        );
249    }
250    if fields.skill_points {
251        out.insert(
252            "skill_points".to_string(),
253            JsonValue::from(export.skill_points),
254        );
255    }
256    if fields.map_filename {
257        out.insert("map".to_string(), JsonValue::String(export.map.clone()));
258    }
259    if fields.elevation {
260        out.insert("elevation".to_string(), JsonValue::from(export.elevation));
261    }
262    if fields.hp {
263        out.insert(
264            "hp".to_string(),
265            match export.hp {
266                Some(v) => JsonValue::from(v),
267                None => JsonValue::Null,
268            },
269        );
270    }
271    if fields.karma {
272        out.insert("karma".to_string(), JsonValue::from(export.karma));
273    }
274    if fields.reputation {
275        out.insert("reputation".to_string(), JsonValue::from(export.reputation));
276    }
277    if fields.special {
278        out.insert("special".to_string(), special_to_json(&export.special));
279    }
280    if fields.derived_stats {
281        out.insert("stats".to_string(), stats_to_json(&export.stats));
282    } else if fields.max_hp || fields.age {
283        out.insert(
284            "stats".to_string(),
285            selected_stats_to_json(&export.stats, fields.max_hp, fields.age),
286        );
287    }
288    if fields.traits {
289        out.insert("traits".to_string(), traits_to_json(&export.traits));
290    }
291    if fields.perks {
292        out.insert("perks".to_string(), perks_to_json(&export.perks));
293    }
294    if fields.skills {
295        out.insert("skills".to_string(), skills_to_json(&export.skills));
296        out.insert(
297            "tagged_skills".to_string(),
298            tagged_skills_to_json(&export.tagged_skills),
299        );
300    }
301    if fields.kills {
302        out.insert(
303            "kill_counts".to_string(),
304            kill_counts_to_json(&export.kill_counts),
305        );
306    }
307    if fields.inventory {
308        out.insert(
309            "inventory".to_string(),
310            inventory_to_json(&export.inventory, inventory),
311        );
312    }
313
314    out
315}
316
317fn default_json(
318    export: &CharacterExport,
319    inventory: Option<&[ResolvedInventoryEntry]>,
320) -> JsonMap<String, JsonValue> {
321    let mut out = JsonMap::new();
322
323    out.insert(
324        "game".to_string(),
325        JsonValue::String(match export.game {
326            CoreGame::Fallout1 => "Fallout1".to_string(),
327            CoreGame::Fallout2 => "Fallout2".to_string(),
328        }),
329    );
330    out.insert(
331        "description".to_string(),
332        JsonValue::String(export.description.clone()),
333    );
334    out.insert(
335        "game_date".to_string(),
336        JsonValue::String(format_date(
337            export.game_date.year,
338            export.game_date.month,
339            export.game_date.day,
340        )),
341    );
342    out.insert(
343        "save_date".to_string(),
344        JsonValue::String(format_date(
345            export.save_date.year,
346            export.save_date.month,
347            export.save_date.day,
348        )),
349    );
350    out.insert(
351        "game_time".to_string(),
352        JsonValue::String(format_game_time(export.game_time)),
353    );
354    out.insert("name".to_string(), JsonValue::String(export.name.clone()));
355    out.insert(
356        "gender".to_string(),
357        JsonValue::String(export.gender.to_string()),
358    );
359    out.insert("level".to_string(), JsonValue::from(export.level));
360    out.insert("xp".to_string(), JsonValue::from(export.xp));
361    out.insert(
362        "next_level_xp".to_string(),
363        JsonValue::from(export.next_level_xp),
364    );
365    out.insert(
366        "skill_points".to_string(),
367        JsonValue::from(export.skill_points),
368    );
369    out.insert("map".to_string(), JsonValue::String(export.map.clone()));
370    out.insert("map_id".to_string(), JsonValue::from(export.map_id));
371    out.insert("elevation".to_string(), JsonValue::from(export.elevation));
372    out.insert(
373        "global_var_count".to_string(),
374        JsonValue::from(export.global_var_count),
375    );
376    out.insert(
377        "hp".to_string(),
378        match export.hp {
379            Some(v) => JsonValue::from(v),
380            None => JsonValue::Null,
381        },
382    );
383    out.insert("karma".to_string(), JsonValue::from(export.karma));
384    out.insert("reputation".to_string(), JsonValue::from(export.reputation));
385
386    out.insert("special".to_string(), special_to_json(&export.special));
387    out.insert("stats".to_string(), stats_to_json(&export.stats));
388    out.insert("traits".to_string(), traits_to_json(&export.traits));
389    out.insert("perks".to_string(), perks_to_json(&export.perks));
390    out.insert("skills".to_string(), skills_to_json(&export.skills));
391    out.insert(
392        "tagged_skills".to_string(),
393        tagged_skills_to_json(&export.tagged_skills),
394    );
395    out.insert(
396        "kill_counts".to_string(),
397        kill_counts_to_json(&export.kill_counts),
398    );
399    out.insert(
400        "inventory".to_string(),
401        inventory_to_json(&export.inventory, inventory),
402    );
403
404    out
405}
406
407fn special_to_json(special: &[StatEntry]) -> JsonValue {
408    JsonValue::Array(special.iter().map(stat_entry_to_json).collect())
409}
410
411fn stats_to_json(stats: &[StatEntry]) -> JsonValue {
412    JsonValue::Array(stats.iter().map(stat_entry_to_json).collect())
413}
414
415fn selected_stats_to_json(
416    stats: &[StatEntry],
417    include_max_hp: bool,
418    include_age: bool,
419) -> JsonValue {
420    let mut selected: Vec<&StatEntry> = Vec::new();
421    if include_max_hp {
422        if let Some(max_hp) = stats.iter().find(|stat| stat.index == STAT_MAX_HP_INDEX) {
423            selected.push(max_hp);
424        }
425    }
426    if include_age {
427        if let Some(age) = stats.iter().find(|stat| stat.index == STAT_AGE_INDEX) {
428            selected.push(age);
429        }
430    }
431    JsonValue::Array(selected.into_iter().map(stat_entry_to_json).collect())
432}
433
434fn stat_entry_to_json(s: &StatEntry) -> JsonValue {
435    let mut m = JsonMap::new();
436    m.insert("name".to_string(), JsonValue::String(s.name.clone()));
437    m.insert("base".to_string(), JsonValue::from(s.base));
438    m.insert("bonus".to_string(), JsonValue::from(s.bonus));
439    m.insert("total".to_string(), JsonValue::from(s.total));
440    JsonValue::Object(m)
441}
442
443fn skills_to_json(skills: &[SkillEntry]) -> JsonValue {
444    JsonValue::Array(
445        skills
446            .iter()
447            .map(|s: &SkillEntry| {
448                let mut m = JsonMap::new();
449                m.insert("index".to_string(), JsonValue::from(s.index));
450                m.insert("name".to_string(), JsonValue::String(s.name.clone()));
451                m.insert("raw".to_string(), JsonValue::from(s.raw));
452                m.insert("tag_bonus".to_string(), JsonValue::from(s.tag_bonus));
453                m.insert("bonus".to_string(), JsonValue::from(s.bonus));
454                m.insert("total".to_string(), JsonValue::from(s.total));
455                JsonValue::Object(m)
456            })
457            .collect(),
458    )
459}
460
461fn tagged_skills_to_json(tagged_skills: &[usize]) -> JsonValue {
462    JsonValue::Array(tagged_skills.iter().copied().map(JsonValue::from).collect())
463}
464
465fn perks_to_json(perks: &[PerkEntry]) -> JsonValue {
466    JsonValue::Array(
467        perks
468            .iter()
469            .map(|p: &PerkEntry| {
470                let mut m = JsonMap::new();
471                m.insert("name".to_string(), JsonValue::String(p.name.clone()));
472                m.insert("rank".to_string(), JsonValue::from(p.rank));
473                JsonValue::Object(m)
474            })
475            .collect(),
476    )
477}
478
479fn kill_counts_to_json(kill_counts: &[KillCountEntry]) -> JsonValue {
480    JsonValue::Array(
481        kill_counts
482            .iter()
483            .map(|k: &KillCountEntry| {
484                let mut m = JsonMap::new();
485                m.insert("name".to_string(), JsonValue::String(k.name.clone()));
486                m.insert("count".to_string(), JsonValue::from(k.count));
487                JsonValue::Object(m)
488            })
489            .collect(),
490    )
491}
492
493fn inventory_to_json(
494    inventory: &[InventoryEntry],
495    resolved: Option<&[ResolvedInventoryEntry]>,
496) -> JsonValue {
497    if let Some(items) = resolved {
498        return JsonValue::Array(
499            items
500                .iter()
501                .map(|item| {
502                    let mut m = JsonMap::new();
503                    m.insert("quantity".to_string(), JsonValue::from(item.quantity));
504                    m.insert("pid".to_string(), JsonValue::from(item.pid));
505                    if let Some(name) = &item.name {
506                        m.insert("name".to_string(), JsonValue::String(name.clone()));
507                    }
508                    if let Some(base_weight) = item.base_weight {
509                        m.insert("base_weight".to_string(), JsonValue::from(base_weight));
510                    }
511                    if let Some(item_type) = item.item_type {
512                        m.insert("item_type".to_string(), JsonValue::from(item_type));
513                    }
514                    JsonValue::Object(m)
515                })
516                .collect(),
517        );
518    }
519
520    JsonValue::Array(
521        inventory
522            .iter()
523            .map(|item: &InventoryEntry| {
524                let mut m = JsonMap::new();
525                m.insert("quantity".to_string(), JsonValue::from(item.quantity));
526                m.insert("pid".to_string(), JsonValue::from(item.pid));
527                JsonValue::Object(m)
528            })
529            .collect(),
530    )
531}
532
533fn traits_to_json(traits: &[TraitEntry]) -> JsonValue {
534    JsonValue::Array(
535        traits
536            .iter()
537            .map(|t| JsonValue::String(t.name.clone()))
538            .collect(),
539    )
540}
541
542fn render_classic_sheet_impl(
543    session: &Session,
544    options: TextRenderOptions,
545    resolved_inventory: Option<&[ResolvedInventoryEntry]>,
546    total_weight_lbs: Option<i32>,
547) -> String {
548    let snapshot = session.snapshot();
549
550    let title = match session.game() {
551        CoreGame::Fallout1 => "FALLOUT",
552        CoreGame::Fallout2 => "FALLOUT II",
553    };
554    let subtitle = match session.game() {
555        CoreGame::Fallout1 => "VAULT-13 PERSONNEL RECORD",
556        CoreGame::Fallout2 => "PERSONNEL RECORD",
557    };
558    let date_time_str = format!(
559        "{:02} {} {}  {} hours",
560        snapshot.game_date.day,
561        month_to_name(snapshot.game_date.month),
562        snapshot.game_date.year,
563        format_game_time(snapshot.game_time),
564    );
565
566    let mut out = String::new();
567    writeln!(&mut out).expect("writing to String cannot fail");
568    writeln!(&mut out).expect("writing to String cannot fail");
569    writeln!(&mut out, "{}", centered_no_trailing(title, 76))
570        .expect("writing to String cannot fail");
571    writeln!(&mut out, "{}", centered_no_trailing(subtitle, 76))
572        .expect("writing to String cannot fail");
573    writeln!(&mut out, "{}", centered_no_trailing(&date_time_str, 76))
574        .expect("writing to String cannot fail");
575    writeln!(&mut out).expect("writing to String cannot fail");
576
577    let name_section = format!("  Name: {:<19}", snapshot.character_name);
578    let age_section = format!("Age: {:<17}", session.age());
579    writeln!(
580        &mut out,
581        "{}{}Gender: {}",
582        name_section, age_section, snapshot.gender
583    )
584    .expect("writing to String cannot fail");
585
586    let level_section = format!(" Level: {:02}", snapshot.level);
587    let xp_str = format_number_with_commas(snapshot.experience);
588    let next_xp_str = format_number_with_commas(session.next_level_xp());
589    let exp_section = format!("Exp: {:<13}", xp_str);
590    writeln!(
591        &mut out,
592        "{:<27}{}Next Level: {}",
593        level_section, exp_section, next_xp_str
594    )
595    .expect("writing to String cannot fail");
596    writeln!(&mut out).expect("writing to String cannot fail");
597
598    let special_names = [
599        "Strength",
600        "Perception",
601        "Endurance",
602        "Charisma",
603        "Intelligence",
604        "Agility",
605        "Luck",
606    ];
607
608    struct MiddleCol {
609        idx: usize,
610        label: &'static str,
611    }
612    let middle_cols = [
613        MiddleCol {
614            idx: 7,
615            label: "Hit Points",
616        },
617        MiddleCol {
618            idx: 9,
619            label: "Armor Class",
620        },
621        MiddleCol {
622            idx: 8,
623            label: "Action Points",
624        },
625        MiddleCol {
626            idx: 11,
627            label: "Melee Damage",
628        },
629        MiddleCol {
630            idx: 24,
631            label: "Damage Res.",
632        },
633        MiddleCol {
634            idx: 31,
635            label: "Radiation Res.",
636        },
637        MiddleCol {
638            idx: 32,
639            label: "Poison Res.",
640        },
641    ];
642
643    struct RightCol {
644        idx: usize,
645        label: &'static str,
646    }
647    let right_cols: [Option<RightCol>; 7] = [
648        Some(RightCol {
649            idx: 13,
650            label: "Sequence",
651        }),
652        Some(RightCol {
653            idx: 14,
654            label: "Healing Rate",
655        }),
656        Some(RightCol {
657            idx: 15,
658            label: "Critical Chance",
659        }),
660        Some(RightCol {
661            idx: 12,
662            label: "Carry Weight",
663        }),
664        None,
665        None,
666        None,
667    ];
668
669    let current_hp = session.current_hp().unwrap_or(0);
670    let max_hp = session.max_hp();
671
672    for row in 0..7 {
673        let stat = session.stat(row);
674        let mut line = String::with_capacity(80);
675        let left_pad = 15 - special_names[row].len();
676        for _ in 0..left_pad {
677            line.push(' ');
678        }
679        line.push_str(special_names[row]);
680        line.push_str(": ");
681        if stat.bonus != 0 {
682            write!(line, "{:02} ({:+})", stat.total, stat.bonus)
683                .expect("writing to String cannot fail");
684        } else {
685            write!(line, "{:02}", stat.total).expect("writing to String cannot fail");
686        }
687
688        let mid = &middle_cols[row];
689        let mid_val = match row {
690            0 => format!("{:03}/{:03}", current_hp, max_hp),
691            1 => format!("{:03}", session.stat(mid.idx).total),
692            2 => format!("{:02}", session.stat(mid.idx).total),
693            3 => format!("{:02}", session.stat(mid.idx).total),
694            4 => format!("{:03}%", session.stat(mid.idx).total),
695            5 => format!("{:03}%", session.stat(mid.idx).total),
696            6 => format!("{:03}%", session.stat(mid.idx).total),
697            _ => unreachable!(),
698        };
699        let mid_start = 38 - mid.label.len();
700        while line.len() < mid_start {
701            line.push(' ');
702        }
703        line.push_str(mid.label);
704        line.push_str(": ");
705        line.push_str(&mid_val);
706
707        if let Some(ref right) = right_cols[row] {
708            let right_val = match row {
709                0 => format!("{:02}", session.stat(right.idx).total),
710                1 => format!("{:02}", session.stat(right.idx).total),
711                2 => format!("{:03}%", session.stat(right.idx).total),
712                3 => format!("{} lbs.", session.stat(right.idx).total),
713                _ => unreachable!(),
714            };
715            let right_start = 64 - right.label.len();
716            while line.len() < right_start {
717                line.push(' ');
718            }
719            line.push_str(right.label);
720            line.push_str(": ");
721            line.push_str(&right_val);
722        }
723
724        writeln!(&mut out, "{line}").expect("writing to String cannot fail");
725    }
726    writeln!(&mut out).expect("writing to String cannot fail");
727    writeln!(&mut out).expect("writing to String cannot fail");
728
729    let traits = session.selected_traits();
730    let perks = session.active_perks();
731    let skills = session.skills();
732    let tagged_skill_indices = session.tagged_skill_indices();
733    let kills = if options.verbose {
734        session.all_kill_counts()
735    } else {
736        session.nonzero_kill_counts()
737    };
738    let inventory = session.inventory();
739
740    write_traits_perks_karma_grid(
741        &mut out,
742        &traits,
743        &perks,
744        snapshot.karma,
745        snapshot.reputation,
746    );
747    writeln!(&mut out).expect("writing to String cannot fail");
748    write_skills_kills_grid(&mut out, &skills, &tagged_skill_indices, &kills);
749    writeln!(&mut out).expect("writing to String cannot fail");
750    write_inventory_section(
751        session,
752        &mut out,
753        &inventory,
754        resolved_inventory,
755        total_weight_lbs,
756    );
757    writeln!(&mut out).expect("writing to String cannot fail");
758
759    out
760}
761
762fn write_traits_perks_karma_grid(
763    out: &mut String,
764    traits: &[TraitEntry],
765    perks: &[PerkEntry],
766    karma: i32,
767    reputation: i32,
768) {
769    writeln!(
770        out,
771        " ::: Traits :::           ::: Perks :::           ::: Karma :::"
772    )
773    .expect("writing to String cannot fail");
774
775    let trait_lines: Vec<String> = if traits.is_empty() {
776        vec!["none".to_string()]
777    } else {
778        traits.iter().map(|entry| entry.name.clone()).collect()
779    };
780    let perk_lines: Vec<String> = if perks.is_empty() {
781        vec!["none".to_string()]
782    } else {
783        perks
784            .iter()
785            .map(|entry| {
786                if entry.rank > 1 {
787                    format!("{} ({})", entry.name, entry.rank)
788                } else {
789                    entry.name.clone()
790                }
791            })
792            .collect()
793    };
794    let karma_lines = [
795        format!("Karma: {karma}"),
796        format!("Reputation: {reputation}"),
797    ];
798
799    let row_count = trait_lines
800        .len()
801        .max(perk_lines.len())
802        .max(karma_lines.len());
803    for row in 0..row_count {
804        let left = trait_lines.get(row).map(String::as_str).unwrap_or("");
805        let middle = perk_lines.get(row).map(String::as_str).unwrap_or("");
806        let right = karma_lines.get(row).map(String::as_str).unwrap_or("");
807        let line = format!(
808            " {:<a$}{:<b$}{:<c$}",
809            fit_column(left, THREE_COL_WIDTH_A),
810            fit_column(middle, THREE_COL_WIDTH_B),
811            fit_column(right, THREE_COL_WIDTH_C),
812            a = THREE_COL_WIDTH_A,
813            b = THREE_COL_WIDTH_B,
814            c = THREE_COL_WIDTH_C
815        );
816        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
817    }
818}
819
820fn write_skills_kills_grid(
821    out: &mut String,
822    skills: &[SkillEntry],
823    tagged_skill_indices: &[usize],
824    kills: &[KillCountEntry],
825) {
826    writeln!(out, " ::: Skills :::                ::: Kills :::")
827        .expect("writing to String cannot fail");
828
829    let skill_lines: Vec<String> = if skills.is_empty() {
830        vec!["none".to_string()]
831    } else {
832        skills
833            .iter()
834            .map(|entry| {
835                if tagged_skill_indices.contains(&entry.index) {
836                    format!("{}: {} *", entry.name, entry.total)
837                } else {
838                    format!("{}: {}", entry.name, entry.total)
839                }
840            })
841            .collect()
842    };
843    let kill_lines: Vec<String> = if kills.is_empty() {
844        vec!["none".to_string()]
845    } else {
846        kills
847            .iter()
848            .map(|entry| format!("{}: {}", entry.name, entry.count))
849            .collect()
850    };
851
852    let row_count = skill_lines.len().max(kill_lines.len());
853    for row in 0..row_count {
854        let left = skill_lines.get(row).map(String::as_str).unwrap_or("");
855        let right = kill_lines.get(row).map(String::as_str).unwrap_or("");
856        let line = format!(
857            " {:<a$}{:<b$}",
858            fit_column(left, TWO_COL_WIDTH_LEFT),
859            fit_column(right, TWO_COL_WIDTH_RIGHT),
860            a = TWO_COL_WIDTH_LEFT,
861            b = TWO_COL_WIDTH_RIGHT
862        );
863        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
864    }
865}
866
867fn write_inventory_section(
868    session: &Session,
869    out: &mut String,
870    inventory: &[InventoryEntry],
871    resolved_inventory: Option<&[ResolvedInventoryEntry]>,
872    total_weight_lbs: Option<i32>,
873) {
874    writeln!(out, " ::: Inventory :::").expect("writing to String cannot fail");
875    writeln!(out).expect("writing to String cannot fail");
876
877    let caps = inventory
878        .iter()
879        .filter(|entry| entry.pid == INVENTORY_CAPS_PID)
880        .fold(0i64, |sum, entry| sum + i64::from(entry.quantity));
881    writeln!(
882        out,
883        "{:>52}",
884        format!("Caps: {}", format_number_with_commas_i64(caps))
885    )
886    .expect("writing to String cannot fail");
887
888    let carry_weight_lbs = session.stat(12).total;
889    let total_weight_label = match total_weight_lbs {
890        Some(value) => format!("{value}/{carry_weight_lbs} lbs."),
891        None => format!("unknown/{carry_weight_lbs} lbs."),
892    };
893    writeln!(out, "{:>52}", format!("Total Weight: {total_weight_label}"))
894        .expect("writing to String cannot fail");
895    writeln!(out).expect("writing to String cannot fail");
896
897    let rows: Vec<String> = if let Some(resolved) = resolved_inventory {
898        resolved
899            .iter()
900            .filter(|entry| entry.pid != INVENTORY_CAPS_PID)
901            .map(|entry| {
902                if let Some(name) = &entry.name {
903                    format!("{}x {}", format_number_with_commas(entry.quantity), name,)
904                } else {
905                    format!(
906                        "{}x pid={:08X}",
907                        format_number_with_commas(entry.quantity),
908                        entry.pid as u32
909                    )
910                }
911            })
912            .collect()
913    } else {
914        inventory
915            .iter()
916            .filter(|entry| entry.pid != INVENTORY_CAPS_PID)
917            .map(|entry| {
918                format!(
919                    "{}x pid={:08X}",
920                    format_number_with_commas(entry.quantity),
921                    entry.pid as u32
922                )
923            })
924            .collect()
925    };
926    if rows.is_empty() {
927        writeln!(out, "  none").expect("writing to String cannot fail");
928        return;
929    }
930
931    for chunk in rows.chunks(3) {
932        let col1 = chunk.first().map(String::as_str).unwrap_or("");
933        let col2 = chunk.get(1).map(String::as_str).unwrap_or("");
934        let col3 = chunk.get(2).map(String::as_str).unwrap_or("");
935        let line = format!(
936            "  {:<a$}{:<b$}{:<c$}",
937            fit_column(col1, INVENTORY_COL_WIDTH_A),
938            fit_column(col2, INVENTORY_COL_WIDTH_B),
939            fit_column(col3, INVENTORY_COL_WIDTH_C),
940            a = INVENTORY_COL_WIDTH_A,
941            b = INVENTORY_COL_WIDTH_B,
942            c = INVENTORY_COL_WIDTH_C
943        );
944        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
945    }
946}
947
948fn fit_column(value: &str, width: usize) -> String {
949    if value.chars().count() <= width {
950        return value.to_string();
951    }
952    if width <= 3 {
953        return value.chars().take(width).collect();
954    }
955
956    let mut out = String::with_capacity(width);
957    for ch in value.chars().take(width - 3) {
958        out.push(ch);
959    }
960    out.push_str("...");
961    out
962}
963
964fn centered_no_trailing(value: &str, width: usize) -> String {
965    let len = value.chars().count();
966    if len >= width {
967        return value.to_string();
968    }
969
970    let left_padding = (width - len) / 2;
971    format!("{}{}", " ".repeat(left_padding), value)
972}
973
974fn format_date(year: i16, month: i16, day: i16) -> String {
975    format!("{year:04}-{month:02}-{day:02}")
976}
977
978fn format_game_time(game_time: u32) -> String {
979    let hours = (game_time / 600) % 24;
980    let minutes = (game_time / 10) % 60;
981    format!("{:02}{:02}", hours, minutes)
982}
983
984fn format_number_with_commas(n: i32) -> String {
985    format_number_with_commas_i64(i64::from(n))
986}
987
988fn format_number_with_commas_i64(n: i64) -> String {
989    if n < 0 {
990        return format!("-{}", format_number_with_commas_i64(-n));
991    }
992    let s = n.to_string();
993    let mut result = String::with_capacity(s.len() + s.len() / 3);
994    for (i, c) in s.chars().enumerate() {
995        if i > 0 && (s.len() - i).is_multiple_of(3) {
996            result.push(',');
997        }
998        result.push(c);
999    }
1000    result
1001}
1002
1003fn month_to_name(month: i16) -> &'static str {
1004    match month {
1005        1 => "January",
1006        2 => "February",
1007        3 => "March",
1008        4 => "April",
1009        5 => "May",
1010        6 => "June",
1011        7 => "July",
1012        8 => "August",
1013        9 => "September",
1014        10 => "October",
1015        11 => "November",
1016        12 => "December",
1017        _ => "Unknown",
1018    }
1019}