Skip to main content

fallout_render/
lib.rs

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