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 = 41;
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 stat = session.stat(row);
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        if stat.bonus != 0 {
651            write!(line, "{:02} ({:+})", stat.total, stat.bonus)
652                .expect("writing to String cannot fail");
653        } else {
654            write!(line, "{:02}", stat.total).expect("writing to String cannot fail");
655        }
656
657        let mid = &middle_cols[row];
658        let mid_val = match row {
659            0 => format!("{:03}/{:03}", current_hp, max_hp),
660            1 => format!("{:03}", session.stat(mid.idx).total),
661            2 => format!("{:02}", session.stat(mid.idx).total),
662            3 => format!("{:02}", session.stat(mid.idx).total),
663            4 => format!("{:03}%", session.stat(mid.idx).total),
664            5 => format!("{:03}%", session.stat(mid.idx).total),
665            6 => format!("{:03}%", session.stat(mid.idx).total),
666            _ => unreachable!(),
667        };
668        let mid_start = 38 - mid.label.len();
669        while line.len() < mid_start {
670            line.push(' ');
671        }
672        line.push_str(mid.label);
673        line.push_str(": ");
674        line.push_str(&mid_val);
675
676        if let Some(ref right) = right_cols[row] {
677            let right_val = match row {
678                0 => format!("{:02}", session.stat(right.idx).total),
679                1 => format!("{:02}", session.stat(right.idx).total),
680                2 => format!("{:03}%", session.stat(right.idx).total),
681                3 => format!("{} lbs.", session.stat(right.idx).total),
682                _ => unreachable!(),
683            };
684            let right_start = 64 - right.label.len();
685            while line.len() < right_start {
686                line.push(' ');
687            }
688            line.push_str(right.label);
689            line.push_str(": ");
690            line.push_str(&right_val);
691        }
692
693        writeln!(&mut out, "{line}").expect("writing to String cannot fail");
694    }
695    writeln!(&mut out).expect("writing to String cannot fail");
696    writeln!(&mut out).expect("writing to String cannot fail");
697
698    let traits = session.selected_traits();
699    let perks = session.active_perks();
700    let skills = session.skills();
701    let kills = if options.verbose {
702        session.all_kill_counts()
703    } else {
704        session.nonzero_kill_counts()
705    };
706    let inventory = session.inventory();
707
708    write_traits_perks_karma_grid(
709        &mut out,
710        &traits,
711        &perks,
712        snapshot.karma,
713        snapshot.reputation,
714    );
715    writeln!(&mut out).expect("writing to String cannot fail");
716    write_skills_kills_grid(&mut out, &skills, &kills);
717    writeln!(&mut out).expect("writing to String cannot fail");
718    write_inventory_section(
719        session,
720        &mut out,
721        &inventory,
722        resolved_inventory,
723        total_weight_lbs,
724    );
725    writeln!(&mut out).expect("writing to String cannot fail");
726
727    out
728}
729
730fn write_traits_perks_karma_grid(
731    out: &mut String,
732    traits: &[TraitEntry],
733    perks: &[PerkEntry],
734    karma: i32,
735    reputation: i32,
736) {
737    writeln!(
738        out,
739        " ::: Traits :::           ::: Perks :::           ::: Karma :::"
740    )
741    .expect("writing to String cannot fail");
742
743    let trait_lines: Vec<String> = if traits.is_empty() {
744        vec!["none".to_string()]
745    } else {
746        traits.iter().map(|entry| entry.name.clone()).collect()
747    };
748    let perk_lines: Vec<String> = if perks.is_empty() {
749        vec!["none".to_string()]
750    } else {
751        perks
752            .iter()
753            .map(|entry| {
754                if entry.rank > 1 {
755                    format!("{} ({})", entry.name, entry.rank)
756                } else {
757                    entry.name.clone()
758                }
759            })
760            .collect()
761    };
762    let karma_lines = [
763        format!("Karma: {karma}"),
764        format!("Reputation: {reputation}"),
765    ];
766
767    let row_count = trait_lines
768        .len()
769        .max(perk_lines.len())
770        .max(karma_lines.len());
771    for row in 0..row_count {
772        let left = trait_lines.get(row).map(String::as_str).unwrap_or("");
773        let middle = perk_lines.get(row).map(String::as_str).unwrap_or("");
774        let right = karma_lines.get(row).map(String::as_str).unwrap_or("");
775        let line = format!(
776            " {:<a$}{:<b$}{:<c$}",
777            fit_column(left, THREE_COL_WIDTH_A),
778            fit_column(middle, THREE_COL_WIDTH_B),
779            fit_column(right, THREE_COL_WIDTH_C),
780            a = THREE_COL_WIDTH_A,
781            b = THREE_COL_WIDTH_B,
782            c = THREE_COL_WIDTH_C
783        );
784        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
785    }
786}
787
788fn write_skills_kills_grid(out: &mut String, skills: &[SkillEntry], kills: &[KillCountEntry]) {
789    writeln!(out, " ::: Skills :::                ::: Kills :::")
790        .expect("writing to String cannot fail");
791
792    let skill_lines: Vec<String> = if skills.is_empty() {
793        vec!["none".to_string()]
794    } else {
795        skills
796            .iter()
797            .map(|entry| {
798                if entry.tagged {
799                    format!("{}: {} *", entry.name, entry.value)
800                } else {
801                    format!("{}: {}", entry.name, entry.value)
802                }
803            })
804            .collect()
805    };
806    let kill_lines: Vec<String> = if kills.is_empty() {
807        vec!["none".to_string()]
808    } else {
809        kills
810            .iter()
811            .map(|entry| format!("{}: {}", entry.name, entry.count))
812            .collect()
813    };
814
815    let row_count = skill_lines.len().max(kill_lines.len());
816    for row in 0..row_count {
817        let left = skill_lines.get(row).map(String::as_str).unwrap_or("");
818        let right = kill_lines.get(row).map(String::as_str).unwrap_or("");
819        let line = format!(
820            " {:<a$}{:<b$}",
821            fit_column(left, TWO_COL_WIDTH_LEFT),
822            fit_column(right, TWO_COL_WIDTH_RIGHT),
823            a = TWO_COL_WIDTH_LEFT,
824            b = TWO_COL_WIDTH_RIGHT
825        );
826        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
827    }
828}
829
830fn write_inventory_section(
831    session: &Session,
832    out: &mut String,
833    inventory: &[InventoryEntry],
834    resolved_inventory: Option<&[ResolvedInventoryEntry]>,
835    total_weight_lbs: Option<i32>,
836) {
837    writeln!(out, " ::: Inventory :::").expect("writing to String cannot fail");
838    writeln!(out).expect("writing to String cannot fail");
839
840    let caps = inventory
841        .iter()
842        .filter(|entry| entry.pid == INVENTORY_CAPS_PID)
843        .fold(0i64, |sum, entry| sum + i64::from(entry.quantity));
844    writeln!(
845        out,
846        "{:>52}",
847        format!("Caps: {}", format_number_with_commas_i64(caps))
848    )
849    .expect("writing to String cannot fail");
850
851    let carry_weight_lbs = session.stat(12).total;
852    let total_weight_label = match total_weight_lbs {
853        Some(value) => format!("{value}/{carry_weight_lbs} lbs."),
854        None => format!("unknown/{carry_weight_lbs} lbs."),
855    };
856    writeln!(out, "{:>52}", format!("Total Weight: {total_weight_label}"))
857        .expect("writing to String cannot fail");
858    writeln!(out).expect("writing to String cannot fail");
859
860    let rows: Vec<String> = if let Some(resolved) = resolved_inventory {
861        resolved
862            .iter()
863            .filter(|entry| entry.pid != INVENTORY_CAPS_PID)
864            .map(|entry| {
865                if let Some(name) = &entry.name {
866                    format!(
867                        "{}x {}",
868                        format_number_with_commas(entry.quantity),
869                        name,
870                    )
871                } else {
872                    format!(
873                        "{}x pid={:08X}",
874                        format_number_with_commas(entry.quantity),
875                        entry.pid as u32
876                    )
877                }
878            })
879            .collect()
880    } else {
881        inventory
882            .iter()
883            .filter(|entry| entry.pid != INVENTORY_CAPS_PID)
884            .map(|entry| {
885                format!(
886                    "{}x pid={:08X}",
887                    format_number_with_commas(entry.quantity),
888                    entry.pid as u32
889                )
890            })
891            .collect()
892    };
893    if rows.is_empty() {
894        writeln!(out, "  none").expect("writing to String cannot fail");
895        return;
896    }
897
898    for chunk in rows.chunks(3) {
899        let col1 = chunk.first().map(String::as_str).unwrap_or("");
900        let col2 = chunk.get(1).map(String::as_str).unwrap_or("");
901        let col3 = chunk.get(2).map(String::as_str).unwrap_or("");
902        let line = format!(
903            "  {:<a$}{:<b$}{:<c$}",
904            fit_column(col1, INVENTORY_COL_WIDTH_A),
905            fit_column(col2, INVENTORY_COL_WIDTH_B),
906            fit_column(col3, INVENTORY_COL_WIDTH_C),
907            a = INVENTORY_COL_WIDTH_A,
908            b = INVENTORY_COL_WIDTH_B,
909            c = INVENTORY_COL_WIDTH_C
910        );
911        writeln!(out, "{}", line.trim_end()).expect("writing to String cannot fail");
912    }
913}
914
915fn fit_column(value: &str, width: usize) -> String {
916    if value.chars().count() <= width {
917        return value.to_string();
918    }
919    if width <= 3 {
920        return value.chars().take(width).collect();
921    }
922
923    let mut out = String::with_capacity(width);
924    for ch in value.chars().take(width - 3) {
925        out.push(ch);
926    }
927    out.push_str("...");
928    out
929}
930
931fn centered_no_trailing(value: &str, width: usize) -> String {
932    let len = value.chars().count();
933    if len >= width {
934        return value.to_string();
935    }
936
937    let left_padding = (width - len) / 2;
938    format!("{}{}", " ".repeat(left_padding), value)
939}
940
941fn format_date(year: i16, month: i16, day: i16) -> String {
942    format!("{year:04}-{month:02}-{day:02}")
943}
944
945fn format_game_time(game_time: u32) -> String {
946    let hours = (game_time / 600) % 24;
947    let minutes = (game_time / 10) % 60;
948    format!("{:02}{:02}", hours, minutes)
949}
950
951fn format_number_with_commas(n: i32) -> String {
952    format_number_with_commas_i64(i64::from(n))
953}
954
955fn format_number_with_commas_i64(n: i64) -> String {
956    if n < 0 {
957        return format!("-{}", format_number_with_commas_i64(-n));
958    }
959    let s = n.to_string();
960    let mut result = String::with_capacity(s.len() + s.len() / 3);
961    for (i, c) in s.chars().enumerate() {
962        if i > 0 && (s.len() - i).is_multiple_of(3) {
963            result.push(',');
964        }
965        result.push(c);
966    }
967    result
968}
969
970fn month_to_name(month: i16) -> &'static str {
971    match month {
972        1 => "January",
973        2 => "February",
974        3 => "March",
975        4 => "April",
976        5 => "May",
977        6 => "June",
978        7 => "July",
979        8 => "August",
980        9 => "September",
981        10 => "October",
982        11 => "November",
983        12 => "December",
984        _ => "Unknown",
985    }
986}