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