fix(ui): match retail vitae and character info
Restore Vitae's omitted penalty paragraph, replace the invented character summary with gmCharacterInfoUI's ordered report and property meanings, preserve authored translucent body surfaces, and initialize the end-session button in its visible Normal DAT state. Release build and all 5,830 tests pass with five intentional skips. Connected visual gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d1d603105f
commit
16c21e299c
21 changed files with 771 additions and 303 deletions
|
|
@ -1,197 +1,410 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Per-window controller for the Character Information child in the production
|
||||
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
|
||||
///
|
||||
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
|
||||
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
|
||||
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
|
||||
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
|
||||
///
|
||||
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
|
||||
/// <list type="number">
|
||||
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
|
||||
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
|
||||
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
|
||||
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
|
||||
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
|
||||
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
|
||||
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
|
||||
/// </list>
|
||||
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &var_120)</c>
|
||||
/// with an empty StringInfo between sections).</para>
|
||||
///
|
||||
/// <para>StringInfo resolution is NOT ported — the full dat string-table lookup is a
|
||||
/// later refinement. For this pilot, labels are composed directly from the well-known
|
||||
/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.</para>
|
||||
/// Retained port of retail <c>gmCharacterInfoUI</c>. Retail writes one
|
||||
/// localized report into <c>m_pMainText</c>; it is not a second character
|
||||
/// sheet and therefore does not repeat name, level, title, or current vitals.
|
||||
/// </summary>
|
||||
public static class CharacterController
|
||||
{
|
||||
public const uint LayoutId = 0x2100006Eu;
|
||||
public const uint RootId = 0x10000183u;
|
||||
|
||||
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
|
||||
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
|
||||
public const uint MainTextId = 0x1000011du;
|
||||
public const uint MainTextId = 0x1000011Du;
|
||||
public const uint ScrollbarId = 0x1000011Eu;
|
||||
public const uint CloseId = 0x100000FCu;
|
||||
|
||||
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
|
||||
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
|
||||
private static readonly AugmentationDescriptor[] Augmentations =
|
||||
[
|
||||
new(0xDAu, "ID_CharacterInfo_Augmentation_Attribute_Strength", true),
|
||||
new(0xDBu, "ID_CharacterInfo_Augmentation_Attribute_Endurance", true),
|
||||
new(0xDCu, "ID_CharacterInfo_Augmentation_Attribute_Coordination", true),
|
||||
new(0xDDu, "ID_CharacterInfo_Augmentation_Attribute_Quickness", true),
|
||||
new(0xDEu, "ID_CharacterInfo_Augmentation_Attribute_Focus", true),
|
||||
new(0xDFu, "ID_CharacterInfo_Augmentation_Attribute_Self", true),
|
||||
new(0xF0u, "ID_CharacterInfo_Augmentation_Resist_Slash", true),
|
||||
new(0xF1u, "ID_CharacterInfo_Augmentation_Resist_Pierce", true),
|
||||
new(0xF2u, "ID_CharacterInfo_Augmentation_Resist_Blunt", true),
|
||||
new(0xF3u, "ID_CharacterInfo_Augmentation_Resist_Acid", true),
|
||||
new(0x147u, "ID_CharacterInfo_Augmentation_Resist_Nether", true),
|
||||
new(0xF4u, "ID_CharacterInfo_Augmentation_Resist_Fire", true),
|
||||
new(0xF5u, "ID_CharacterInfo_Augmentation_Resist_Frost", true),
|
||||
new(0xF6u, "ID_CharacterInfo_Augmentation_Resist_Lightning", true),
|
||||
new(0xE0u, "ID_CharacterInfo_Augmentation_Spec_Gearcraft", false),
|
||||
new(0xE1u, "ID_CharacterInfo_Augmentation_Spec_WeaponTinkering", false),
|
||||
new(0xE2u, "ID_CharacterInfo_Augmentation_Spec_MagicItemTinkering", false),
|
||||
new(0xE3u, "ID_CharacterInfo_Augmentation_Spec_ArmorTinkering", false),
|
||||
new(0xE4u, "ID_CharacterInfo_Augmentation_Spec_ItemTinkering", false),
|
||||
new(0x125u, "ID_CharacterInfo_Augmentation_Spec_Salvaging", false),
|
||||
new(0xE5u, "ID_CharacterInfo_Augmentation_ExtraPackSlot", false),
|
||||
new(0xE6u, "ID_CharacterInfo_Augmentation_IncreasedCarryingCapacity", true),
|
||||
new(0xE7u, "ID_CharacterInfo_Augmentation_LessDeathItemLoss", true),
|
||||
new(0xE8u, "ID_CharacterInfo_Augmentation_SpellsRemainPastDeath", false),
|
||||
new(0xE9u, "ID_CharacterInfo_Augmentation_CriticalDefense", false),
|
||||
new(0xEAu, "ID_CharacterInfo_Augmentation_BonusXP", false),
|
||||
new(0xEBu, "ID_CharacterInfo_Augmentation_BonusSalvage", true),
|
||||
new(0xECu, "ID_CharacterInfo_Augmentation_BonusImbueChance", false),
|
||||
new(0xEDu, "ID_CharacterInfo_Augmentation_FasterRegen", true),
|
||||
new(0xEEu, "ID_CharacterInfo_Augmentation_IncreasedSpellDuration", true),
|
||||
new(0x126u, "ID_CharacterInfo_Augmentation_Infused_CreatureMagic", true),
|
||||
new(0x127u, "ID_CharacterInfo_Augmentation_Infused_ItemMagic", true),
|
||||
new(0x128u, "ID_CharacterInfo_Augmentation_Infused_LifeMagic", true),
|
||||
new(0x129u, "ID_CharacterInfo_Augmentation_Infused_WarMagic", true),
|
||||
new(0x148u, "ID_CharacterInfo_Augmentation_Infused_VoidMagic", true),
|
||||
new(0x12Cu, "ID_CharacterInfo_Augmentation_SkilledMelee", true),
|
||||
new(0x12Du, "ID_CharacterInfo_Augmentation_SkilledMissile", true),
|
||||
new(0x12Eu, "ID_CharacterInfo_Augmentation_SkilledMagic", true),
|
||||
new(0x135u, "ID_CharacterInfo_Augmentation_DamageBonus", true),
|
||||
new(0x136u, "ID_CharacterInfo_Augmentation_DamageResist", true),
|
||||
new(0x12Au, "ID_CharacterInfo_Augmentation_CriticalExpertise", false),
|
||||
new(0x12Bu, "ID_CharacterInfo_Augmentation_CriticalPower", false),
|
||||
new(0x146u, "ID_CharacterInfo_Augmentation_JackOfAllTrades", false),
|
||||
];
|
||||
|
||||
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
|
||||
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
|
||||
internal static IReadOnlyList<string> AugmentationStringKeys
|
||||
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Bind the character report text element found in <paramref name="layout"/> to
|
||||
/// <paramref name="data"/>. The report is regenerated each frame via
|
||||
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
|
||||
/// <see cref="CharacterSheet"/> without re-binding.
|
||||
///
|
||||
/// <para>Production data resolves the element as a <see cref="UiText"/>
|
||||
/// (Type 12). A synthetic fallback is created only for deliberately reduced
|
||||
/// test layouts.</para>
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported 0x2100006E / 0x10000183 layout tree.</param>
|
||||
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
|
||||
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
|
||||
/// (falls back to the BitmapFont debug path).</param>
|
||||
public static void Bind(
|
||||
ImportedLayout layout,
|
||||
Func<CharacterSheet> data,
|
||||
UiDatFont? datFont = null,
|
||||
Action? close = null)
|
||||
Action? close = null,
|
||||
CharacterInfoStrings? strings = null)
|
||||
{
|
||||
// End-of-retail data authors m_pMainText under the Character Information
|
||||
// child. The fallback exists only so focused controller tests can bind a
|
||||
// deliberately reduced tree without fabricating production structure.
|
||||
var root = layout.Root;
|
||||
if (root is null) return;
|
||||
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
UiElement root = layout.Root;
|
||||
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
|
||||
{
|
||||
EventId = MainTextId,
|
||||
Name = "m_pMainText",
|
||||
Left = 12f,
|
||||
Top = 44f, // below the window header row
|
||||
Width = System.Math.Max(40f, root.Width - 24f),
|
||||
Height = System.Math.Max(40f, root.Height - 56f),
|
||||
Anchors = AnchorEdges.None,
|
||||
ZOrder = 1_000_000, // draw above the static chrome
|
||||
DatFont = datFont,
|
||||
EventId = MainTextId,
|
||||
Name = "m_pMainText",
|
||||
Left = 12f,
|
||||
Top = 44f,
|
||||
Width = Math.Max(40f, root.Width - 24f),
|
||||
Height = Math.Max(40f, root.Height - 56f),
|
||||
ZOrder = 1_000_000,
|
||||
DatFont = datFont,
|
||||
ClickThrough = false,
|
||||
};
|
||||
if (text.Parent is null)
|
||||
root.AddChild(text);
|
||||
else if (text.DatFont is null && datFont is not null)
|
||||
text.DatFont = datFont;
|
||||
|
||||
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
|
||||
text.PreserveEndOnLayout = false;
|
||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
|
||||
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = text.Scroll;
|
||||
text.LinesProvider = () => BuildReport(data());
|
||||
|
||||
if (layout.FindElement(CloseId) is UiButton closeButton)
|
||||
closeButton.OnClick = close;
|
||||
}
|
||||
|
||||
// ── Report builder ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the complete character report as a line list.
|
||||
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
|
||||
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
|
||||
{
|
||||
var lines = new List<UiText.Line>();
|
||||
var body = new StringBuilder();
|
||||
|
||||
// ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
|
||||
// Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
|
||||
// InqInt(0x2b) = NumDeaths.
|
||||
AppendHeader(lines, s.Name);
|
||||
Append(lines, $"Level {s.Level}");
|
||||
if (s.Race is not null)
|
||||
Append(lines, s.Race);
|
||||
if (s.Heritage is not null)
|
||||
Append(lines, s.Heritage);
|
||||
if (s.Title is not null)
|
||||
Append(lines, s.Title);
|
||||
AppendBlank(lines);
|
||||
// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths @ 0x004B8CB0.
|
||||
string? birth = sheet.BirthTimestamp is int timestamp
|
||||
? FormatRetailDate(timestamp)
|
||||
: sheet.BirthDate;
|
||||
if (!string.IsNullOrEmpty(birth))
|
||||
AppendTokens(body, strings.Birth, birth);
|
||||
|
||||
if (s.BirthDate is not null)
|
||||
Append(lines, $"Birth: {s.BirthDate}");
|
||||
if (s.PlayTime is not null)
|
||||
Append(lines, $"Age: {s.PlayTime}");
|
||||
Append(lines, $"Deaths: {s.Deaths}");
|
||||
AppendBlank(lines);
|
||||
string? played = sheet.TotalPlayTimeSeconds is int seconds
|
||||
? FormatRetailDuration(seconds)
|
||||
: sheet.PlayTime;
|
||||
if (!string.IsNullOrEmpty(played))
|
||||
AppendTokens(body, strings.Played, played);
|
||||
|
||||
// ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
|
||||
// Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
|
||||
// Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
|
||||
AppendHeader(lines, "Vitals");
|
||||
Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
|
||||
Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
|
||||
Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
|
||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
|
||||
// Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
|
||||
AppendHeader(lines, "Attributes");
|
||||
Append(lines, $"Strength: {s.Strength}");
|
||||
Append(lines, $"Endurance: {s.Endurance}");
|
||||
Append(lines, $"Quickness: {s.Quickness}");
|
||||
Append(lines, $"Coordination: {s.Coordination}");
|
||||
Append(lines, $"Focus: {s.Focus}");
|
||||
Append(lines, $"Self: {s.Self}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
|
||||
// Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
|
||||
AppendHeader(lines, "Skills");
|
||||
Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
|
||||
if (s.SpecializedSkillCredits > 0)
|
||||
Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
|
||||
// Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
|
||||
AppendHeader(lines, "Augmentations");
|
||||
if (s.AugmentationName is not null)
|
||||
Append(lines, s.AugmentationName);
|
||||
else
|
||||
Append(lines, "None");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
|
||||
// Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
|
||||
// When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
|
||||
// When aug > 0: shows "N augmentations (X.X% bonus)".
|
||||
AppendHeader(lines, "Encumbrance");
|
||||
Append(lines, $"Burden: {s.BurdenCurrent}");
|
||||
Append(lines, $"Capacity: {s.BurdenMax}");
|
||||
if (s.BurdenMax > 0)
|
||||
switch (sheet.Deaths)
|
||||
{
|
||||
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
|
||||
Append(lines, $"Load: {pct}%");
|
||||
case 0: body.Append(strings.DeathsNone); break;
|
||||
case 1: body.Append(strings.DeathsOne); break;
|
||||
case 2: body.Append(strings.DeathsTwo); break;
|
||||
default: AppendTokens(body, strings.DeathsMany, sheet.Deaths); break;
|
||||
}
|
||||
AppendSectionBreak(body);
|
||||
|
||||
return lines;
|
||||
// gmCharacterInfoUI::UpdateEnduranceInfo @ 0x004B8EB0.
|
||||
string resist = ResistanceGrade(sheet.Strength + sheet.Endurance,
|
||||
200, 260, 320, 380, 440);
|
||||
string regeneration = ResistanceGrade(
|
||||
sheet.Strength + (2 * sheet.Endurance),
|
||||
200, 346, 470, 580, 690);
|
||||
// ID_CharacterInfo_Resists references the named RESIST variable twice
|
||||
// (Natural and Drain) and REGEN once.
|
||||
body.Append(strings.Resists[0]).Append(resist)
|
||||
.Append(strings.Resists[1]).Append(resist)
|
||||
.Append(strings.Resists[2]).Append(regeneration)
|
||||
.Append(strings.Resists[3]);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateInnateAttributeInfo @ 0x004B87E0.
|
||||
AppendTokens(body, strings.Innates,
|
||||
sheet.Strength, sheet.Endurance, sheet.Coordination,
|
||||
sheet.Quickness, sheet.Focus, sheet.Self);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateFakeSkills @ 0x004B8930.
|
||||
AppendTokens(body, strings.Chess, sheet.ChessRank);
|
||||
AppendTokens(body, strings.Fishing, sheet.FishingSkill);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateAugmentations @ 0x004B9000: no heading
|
||||
// and no synthetic "None" line; only qualities greater than zero.
|
||||
AppendMastery(body, strings.MeleeMastery,
|
||||
Get(sheet, 0x162u), MeleeMasteryName);
|
||||
AppendMastery(body, strings.RangedMastery,
|
||||
Get(sheet, 0x163u), RangedMasteryName);
|
||||
AppendMastery(body, strings.SummoningMastery,
|
||||
Get(sheet, 0x16Au), SummoningMasteryName);
|
||||
foreach (AugmentationDescriptor descriptor in Augmentations)
|
||||
{
|
||||
int value = Get(sheet, descriptor.PropertyId);
|
||||
if (value <= 0
|
||||
|| !strings.AugmentationText.TryGetValue(
|
||||
descriptor.StringKey, out string[]? tokens))
|
||||
continue;
|
||||
if (descriptor.IncludeValue)
|
||||
AppendTokens(body, tokens, value);
|
||||
else
|
||||
body.Append(ResolvePlural(tokens[0], value));
|
||||
}
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateLoad @ 0x004B8A20.
|
||||
int capacity = BurdenMath.EncumbranceCapacity(
|
||||
sheet.Strength, sheet.EncumbranceAugmentations);
|
||||
float load = BurdenMath.LoadRatio(capacity, sheet.BurdenCurrent);
|
||||
if (load < 1f)
|
||||
body.Append(strings.LoadNone);
|
||||
else
|
||||
AppendTokens(body, strings.LoadBurdened,
|
||||
sheet.BurdenCurrent - capacity,
|
||||
BurdenMath.LoadPenaltyPercent(load));
|
||||
if (sheet.EncumbranceAugmentations > 0)
|
||||
AppendTokens(body, strings.LoadAugmentations,
|
||||
sheet.EncumbranceAugmentations,
|
||||
sheet.EncumbranceAugmentations * 20f);
|
||||
|
||||
return body.ToString().TrimEnd('\r', '\n');
|
||||
}
|
||||
|
||||
// ── Line helpers ──────────────────────────────────────────────────────────
|
||||
private static int Get(CharacterSheet sheet, uint id)
|
||||
=> sheet.CharacterInfoProperties.TryGetValue(id, out int value) ? value : 0;
|
||||
|
||||
private static void AppendHeader(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, HeaderColor));
|
||||
private static void AppendMastery(
|
||||
StringBuilder body,
|
||||
string[] tokens,
|
||||
int value,
|
||||
Func<int, string> name)
|
||||
{
|
||||
if (value > 0)
|
||||
AppendTokens(body, tokens, name(value));
|
||||
}
|
||||
|
||||
private static void Append(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, BodyColor));
|
||||
private static string MeleeMasteryName(int value) => value switch
|
||||
{
|
||||
1 => "Unarmed Weapons",
|
||||
2 => "Swords",
|
||||
3 => "Axes",
|
||||
4 => "Maces",
|
||||
5 => "Spears",
|
||||
6 => "Daggers",
|
||||
7 => "Staves",
|
||||
11 => "Two Handed Weapons",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static void AppendBlank(List<UiText.Line> lines)
|
||||
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
|
||||
private static string RangedMasteryName(int value) => value switch
|
||||
{
|
||||
8 => "Bows",
|
||||
9 => "Crossbows",
|
||||
10 => "Thrown Weapons",
|
||||
12 => "Magical Spells",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static string SummoningMasteryName(int value) => value switch
|
||||
{
|
||||
1 => "Primalist",
|
||||
2 => "Necromancer",
|
||||
3 => "Naturalist",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
internal static string ResistanceGrade(
|
||||
int value,
|
||||
int none,
|
||||
int poor,
|
||||
int mediocre,
|
||||
int hardy,
|
||||
int resilient)
|
||||
=> value <= none ? "None"
|
||||
: value <= poor ? "Poor"
|
||||
: value <= mediocre ? "Mediocre"
|
||||
: value <= hardy ? "Hardy"
|
||||
: value <= resilient ? "Resilient"
|
||||
: "Indomitable";
|
||||
|
||||
internal static string FormatRetailDuration(int totalSeconds)
|
||||
{
|
||||
long remaining = Math.Max(0, totalSeconds);
|
||||
(long seconds, string singular, string plural)[] units =
|
||||
[
|
||||
(31_536_000, "year", "years"),
|
||||
(2_592_000, "month", "months"),
|
||||
(604_800, "week", "weeks"),
|
||||
(86_400, "day", "days"),
|
||||
(3_600, "hour", "hours"),
|
||||
(60, "minute", "minutes"),
|
||||
(1, "second", "seconds"),
|
||||
];
|
||||
var parts = new List<string>();
|
||||
foreach (var unit in units)
|
||||
{
|
||||
long count = remaining / unit.seconds;
|
||||
remaining %= unit.seconds;
|
||||
if (count != 0)
|
||||
parts.Add($"{count} {(count == 1 ? unit.singular : unit.plural)}");
|
||||
}
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
|
||||
private static string FormatRetailDate(int unixSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(unixSeconds)
|
||||
.LocalDateTime.ToString("G", CultureInfo.CurrentCulture);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return unixSeconds.ToString(CultureInfo.CurrentCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendTokens(
|
||||
StringBuilder body,
|
||||
IReadOnlyList<string> tokens,
|
||||
params object[] values)
|
||||
{
|
||||
for (int i = 0; i < tokens.Count; i++)
|
||||
{
|
||||
int pluralValue = values.Length == 0 ? 0
|
||||
: NumericValue(values[Math.Min(i, values.Length - 1)]);
|
||||
body.Append(ResolvePlural(tokens[i], pluralValue));
|
||||
if (i < values.Length)
|
||||
body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePlural(string text, int value)
|
||||
=> text.Replace("{time[1]|times}", value == 1 ? "time" : "times",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static int NumericValue(object value) => value switch
|
||||
{
|
||||
byte v => v,
|
||||
short v => v,
|
||||
int v => v,
|
||||
long v => checked((int)v),
|
||||
float v => (int)v,
|
||||
double v => (int)v,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static void AppendSectionBreak(StringBuilder body)
|
||||
{
|
||||
if (body.Length != 0 && body[^1] != '\n') body.Append('\n');
|
||||
body.Append('\n');
|
||||
}
|
||||
|
||||
private readonly record struct AugmentationDescriptor(
|
||||
uint PropertyId,
|
||||
string StringKey,
|
||||
bool IncludeValue);
|
||||
}
|
||||
|
||||
public sealed record CharacterInfoStrings(
|
||||
string[] Birth,
|
||||
string[] Played,
|
||||
string DeathsNone,
|
||||
string DeathsOne,
|
||||
string DeathsTwo,
|
||||
string[] DeathsMany,
|
||||
string[] Resists,
|
||||
string[] Innates,
|
||||
string[] Chess,
|
||||
string[] Fishing,
|
||||
string LoadNone,
|
||||
string[] LoadBurdened,
|
||||
string[] LoadAugmentations,
|
||||
string[] MeleeMastery,
|
||||
string[] RangedMastery,
|
||||
string[] SummoningMastery,
|
||||
IReadOnlyDictionary<string, string[]> AugmentationText)
|
||||
{
|
||||
public static CharacterInfoStrings FromDat(
|
||||
Func<string, string[]?> resolve)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
CharacterInfoStrings fallback = English;
|
||||
string[] Get(string key, string[] defaultValue)
|
||||
=> resolve(key) is { Length: > 0 } value ? value : defaultValue;
|
||||
string GetOne(string key, string defaultValue)
|
||||
=> Get(key, [defaultValue])[0];
|
||||
|
||||
var augmentations = new Dictionary<string, string[]>();
|
||||
foreach (string key in CharacterController.AugmentationStringKeys)
|
||||
if (resolve(key) is { Length: > 0 } value)
|
||||
augmentations[key] = value;
|
||||
|
||||
return new CharacterInfoStrings(
|
||||
Get("ID_CharacterInfo_Birth", fallback.Birth),
|
||||
Get("ID_CharacterInfo_Played", fallback.Played),
|
||||
GetOne("ID_CharacterInfo_Deaths_None", fallback.DeathsNone),
|
||||
GetOne("ID_CharacterInfo_Deaths_One", fallback.DeathsOne),
|
||||
GetOne("ID_CharacterInfo_Deaths_Two", fallback.DeathsTwo),
|
||||
Get("ID_CharacterInfo_Deaths_Many", fallback.DeathsMany),
|
||||
Get("ID_CharacterInfo_Resists", fallback.Resists),
|
||||
Get("ID_CharacterInfo_Innates", fallback.Innates),
|
||||
Get("ID_CharacterInfo_Chess", fallback.Chess),
|
||||
Get("ID_CharacterInfo_Fishing", fallback.Fishing),
|
||||
GetOne("ID_CharacterInfo_Load_None", fallback.LoadNone),
|
||||
Get("ID_CharacterInfo_Load_Burdened", fallback.LoadBurdened),
|
||||
Get("ID_CharacterInfo_Load_Augmentations", fallback.LoadAugmentations),
|
||||
Get("ID_CharacterInfo_Mastery_Melee", fallback.MeleeMastery),
|
||||
Get("ID_CharacterInfo_Mastery_Ranged", fallback.RangedMastery),
|
||||
Get("ID_CharacterInfo_Mastery_Summoning", fallback.SummoningMastery),
|
||||
augmentations);
|
||||
}
|
||||
|
||||
public static CharacterInfoStrings English { get; } = new(
|
||||
["You were born on ", ".\n"],
|
||||
["You have played for ", ".\n"],
|
||||
"You have never died!\n",
|
||||
"You have died only once!\n",
|
||||
"You have died twice.\n",
|
||||
["You have died ", " times.\n"],
|
||||
["Natural Resistances: ", "\nDrain Resistances: ", "\nRegeneration Bonus: ", "\n"],
|
||||
["Innate Strength: ", "\nInnate Endurance: ", "\nInnate Coordination: ",
|
||||
"\nInnate Quickness: ", "\nInnate Focus: ", "\nInnate Self: ", "\n"],
|
||||
["Chess Rank: ", "\n"],
|
||||
["Fishing Skill: ", "\n"],
|
||||
"You are not overburdened at this time.\n",
|
||||
["You are currently overburdened by ",
|
||||
" burden units. This is reducing your Run, Jump, Melee Defense and Missile Defense skills by ",
|
||||
"%.\n"],
|
||||
["You have been augmented ",
|
||||
" times with the Might of the Seventh Mule. This has increased your carrying capacity by ",
|
||||
"%.\n"],
|
||||
["Your melee mastery is ", ".\n"],
|
||||
["Your ranged mastery is ", ".\n\n"],
|
||||
["Your summoning mastery is ", ".\n\n"],
|
||||
new Dictionary<string, string[]>());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue