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}