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}