< Summary

Information
Class: RaidLoop.Client.Services.StashStorage
Assembly: RaidLoop.Client
File(s): /home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Services/StashStorage.cs
Line coverage
41%
Covered lines: 89
Uncovered lines: 125
Coverable lines: 214
Total lines: 435
Line coverage: 41.5%
Branch coverage
45%
Covered branches: 63
Total branches: 138
Branch coverage: 45.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
LoadAsync()61.11%711845.45%
TryReadLegacyRandomCharacter(...)10.71%4462818.91%
SaveAsync()100%210%
CreateDefaultSave()100%210%
NormalizeSave(...)50%22100%
NormalizeLegacyRandomCharacterStats(...)80%262075%
MoveProperty(...)10%681016.66%
TryGetNodeProperty(...)100%44100%
TryGetString(...)0%110100%
TryReadItemListFromNode(...)0%2040%
TryReadPlayerStatsFromNode(...)0%2040%
HasProperty(...)75%5460%
ExtractLegacyCharacterInventory(...)33.33%11650%
EnsureKnifeFallback(...)100%44100%
NormalizeItems(...)100%11100%
NormalizeOnPersonItems(...)100%11100%
NormalizeItem(...)75%292480%

File(s)

/home/runner/work/RaidLoop/RaidLoop/src/RaidLoop.Client/Services/StashStorage.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Text.Json;
 3using System.Text.Json.Nodes;
 4using Microsoft.JSInterop;
 5using RaidLoop.Core;
 6
 7namespace RaidLoop.Client.Services;
 8
 9public sealed class StashStorage
 10{
 11    private const string SaveKey = "raidloop.save.v3";
 112    private static readonly JsonSerializerOptions WebJsonOptions = new(JsonSerializerDefaults.Web);
 13    private readonly IJSRuntime _js;
 14
 715    public StashStorage(IJSRuntime js)
 16    {
 717        _js = js;
 718    }
 19
 20    public async Task<GameSave> LoadAsync()
 21    {
 22        string? raw;
 23        try
 24        {
 725            raw = await _js.InvokeAsync<string?>("raidLoopStorage.load", SaveKey);
 726        }
 027        catch
 28        {
 029            return CreateDefaultSave();
 30        }
 31
 732        if (string.IsNullOrWhiteSpace(raw))
 33        {
 034            return CreateDefaultSave();
 35        }
 36
 37        try
 38        {
 739            var normalizedRaw = NormalizeLegacyRandomCharacterStats(raw);
 740            var save = JsonSerializer.Deserialize<GameSave>(normalizedRaw, WebJsonOptions);
 741            if (save is null)
 42            {
 043                return CreateDefaultSave();
 44            }
 45
 746            if ((save.RandomCharacter is null || string.IsNullOrWhiteSpace(save.RandomCharacter.Name))
 747                && TryReadLegacyRandomCharacter(raw, out var legacyRandomCharacter))
 48            {
 049                save = save with { RandomCharacter = legacyRandomCharacter };
 50            }
 51
 752            var onPerson = save.OnPersonItems ?? [];
 53            // Migration from v3 shape where inventory was List<Item> CharacterInventory.
 754            if (onPerson.Count == 0)
 55            {
 656                var legacyItems = ExtractLegacyCharacterInventory(raw);
 657                if (legacyItems.Count > 0)
 58                {
 059                    onPerson = legacyItems.Select(i => new OnPersonEntry(i, false)).ToList();
 60                }
 61            }
 62
 763            save = save with { OnPersonItems = onPerson };
 764            return NormalizeSave(save);
 65        }
 066        catch
 67        {
 68            // Migration path from v1 (stash-only payload)
 69            try
 70            {
 071                var legacyStash = JsonSerializer.Deserialize<List<Item>>(raw);
 072                if (legacyStash is not null)
 73                {
 074                    return NormalizeSave(new GameSave(
 075                        MainStash: legacyStash,
 076                        RandomCharacterAvailableAt: DateTimeOffset.MinValue,
 077                        RandomCharacter: null,
 078                        Money: 500,
 079                        OnPersonItems: []));
 80                }
 081            }
 082            catch
 83            {
 84                // fall through to default
 085            }
 86
 087            return CreateDefaultSave();
 88        }
 789    }
 90
 91    private static bool TryReadLegacyRandomCharacter(string raw, out RandomCharacterState randomCharacter)
 92    {
 593        randomCharacter = null!;
 94        try
 95        {
 596            var root = JsonNode.Parse(raw) as JsonObject;
 597            if (root is null)
 98            {
 099                return false;
 100            }
 101
 5102            if (!TryGetNodeProperty(root, "RandomCharacter", out var characterNode)
 5103                && !TryGetNodeProperty(root, "randomCharacter", out characterNode))
 104            {
 5105                return false;
 106            }
 107
 0108            if (characterNode is not JsonObject characterObject)
 109            {
 0110                return false;
 111            }
 112
 0113            var name = TryGetString(characterObject, "Name", out var canonicalName)
 0114                ? canonicalName
 0115                : TryGetString(characterObject, "name", out var legacyName)
 0116                    ? legacyName
 0117                    : string.Empty;
 118
 0119            var inventory = TryGetNodeProperty(characterObject, "Inventory", out var canonicalInventory)
 0120                ? TryReadItemListFromNode(canonicalInventory, out var parsedCanonicalInventory)
 0121                    ? parsedCanonicalInventory
 0122                    : []
 0123                : TryGetNodeProperty(characterObject, "inventory", out var legacyInventory)
 0124                    ? TryReadItemListFromNode(legacyInventory, out var parsedLegacyInventory)
 0125                        ? parsedLegacyInventory
 0126                        : []
 0127                    : [];
 128
 0129            var stats = TryGetNodeProperty(characterObject, "Stats", out var canonicalStats)
 0130                ? TryReadPlayerStatsFromNode(canonicalStats, out var parsedCanonicalStats)
 0131                    ? parsedCanonicalStats
 0132                    : PlayerStats.Default
 0133                : TryGetNodeProperty(characterObject, "stats", out var legacyStats)
 0134                    ? TryReadPlayerStatsFromNode(legacyStats, out var parsedLegacyStats)
 0135                        ? parsedLegacyStats
 0136                        : PlayerStats.Default
 0137                    : PlayerStats.Default;
 138
 0139            randomCharacter = new RandomCharacterState(name, inventory, stats);
 0140            return true;
 141        }
 0142        catch
 143        {
 0144            return false;
 145        }
 5146    }
 147
 148    public async Task SaveAsync(GameSave save)
 149    {
 0150        var payload = JsonSerializer.Serialize(save);
 0151        await _js.InvokeVoidAsync("raidLoopStorage.save", SaveKey, payload);
 0152    }
 153
 154    private static GameSave CreateDefaultSave()
 155    {
 0156        return NormalizeSave(new GameSave(
 0157            MainStash:
 0158            [
 0159                ItemCatalog.Create("Makarov"),
 0160                ItemCatalog.Create("PPSH"),
 0161                ItemCatalog.Create("AK74"),
 0162                ItemCatalog.Create("6B2 body armor"),
 0163                ItemCatalog.Create("6B13 assault armor"),
 0164                ItemCatalog.Create("Small Backpack"),
 0165                ItemCatalog.Create("Tactical Backpack"),
 0166                ItemCatalog.Create("Medkit"),
 0167                ItemCatalog.Create("Bandage"),
 0168                ItemCatalog.Create("Ammo Box")
 0169            ],
 0170            RandomCharacterAvailableAt: DateTimeOffset.MinValue,
 0171            RandomCharacter: null,
 0172            Money: 500,
 0173            OnPersonItems: []));
 174    }
 175
 176    private static GameSave NormalizeSave(GameSave save)
 177    {
 7178        var money = Math.Max(0, save.Money);
 7179        var stash = NormalizeItems(save.MainStash);
 7180        var inventory = NormalizeOnPersonItems(save.OnPersonItems ?? []);
 7181        EnsureKnifeFallback(stash, inventory);
 7182        return save with { Money = money, MainStash = stash, OnPersonItems = inventory };
 183    }
 184
 185    private static string NormalizeLegacyRandomCharacterStats(string raw)
 186    {
 187        try
 188        {
 7189            var root = JsonNode.Parse(raw) as JsonObject;
 7190            if (root is null)
 191            {
 0192                return raw;
 193            }
 194
 7195            if (TryGetNodeProperty(root, "randomCharacter", out var camelRandomCharacter) && camelRandomCharacter is not
 196            {
 2197                if (!root.ContainsKey("RandomCharacter"))
 198                {
 1199                    root["RandomCharacter"] = camelRandomCharacter.DeepClone();
 1200                    root.Remove("randomCharacter");
 201                }
 202            }
 203
 7204            if (TryGetNodeProperty(root, "RandomCharacter", out var randomCharacterNode) && randomCharacterNode is JsonO
 205            {
 2206                MoveProperty(randomCharacter, "name", "Name");
 2207                MoveProperty(randomCharacter, "inventory", "Inventory");
 2208                MoveProperty(randomCharacter, "stats", "Stats");
 209
 2210                if (TryGetNodeProperty(randomCharacter, "stats", out var camelStats) && camelStats is not null && !HasPr
 211                {
 0212                    randomCharacter["Stats"] = camelStats.DeepClone();
 0213                    randomCharacter.Remove("stats");
 214                }
 215
 2216                if (!HasProperty(randomCharacter, "Stats"))
 217                {
 2218                    randomCharacter["Stats"] = JsonSerializer.SerializeToNode(PlayerStats.Default);
 219                }
 220            }
 221
 7222            return root.ToJsonString();
 223        }
 0224        catch
 225        {
 0226            return raw;
 227        }
 7228    }
 229
 230    private static void MoveProperty(JsonObject node, string sourceName, string targetName)
 231    {
 6232        if (string.Equals(sourceName, targetName, StringComparison.OrdinalIgnoreCase))
 233        {
 6234            return;
 235        }
 236
 0237        if (HasProperty(node, targetName))
 238        {
 0239            return;
 240        }
 241
 0242        foreach (var entry in node)
 243        {
 0244            if (!string.Equals(entry.Key, sourceName, StringComparison.OrdinalIgnoreCase))
 245            {
 246                continue;
 247            }
 248
 0249            if (entry.Value is null)
 250            {
 0251                return;
 252            }
 253
 0254            node[targetName] = entry.Value.DeepClone();
 0255            node.Remove(entry.Key);
 0256            return;
 257        }
 0258    }
 259
 260    private static bool TryGetNodeProperty(JsonObject root, string propertyName, [NotNullWhen(true)] out JsonNode? value
 261    {
 184262        foreach (var entry in root)
 263        {
 78264            if (!string.Equals(entry.Key, propertyName, StringComparison.OrdinalIgnoreCase))
 265            {
 266                continue;
 267            }
 268
 24269            value = entry.Value;
 24270            return value is not null;
 271        }
 272
 2273        value = null;
 2274        return false;
 24275    }
 276
 277    private static bool TryGetString(JsonObject root, string propertyName, [NotNullWhen(true)] out string? value)
 278    {
 0279        foreach (var entry in root)
 280        {
 0281            if (!string.Equals(entry.Key, propertyName, StringComparison.OrdinalIgnoreCase))
 282            {
 283                continue;
 284            }
 285
 0286            if (entry.Value is JsonValue jsonValue
 0287                && jsonValue.TryGetValue<string>(out var parsedValue)
 0288                && parsedValue is not null)
 289            {
 0290                value = parsedValue;
 0291                return true;
 292            }
 293
 294            break;
 295        }
 296
 0297        value = string.Empty;
 0298        return false;
 0299    }
 300
 301    private static bool TryReadItemListFromNode(JsonNode items, out List<Item> parsedItems)
 302    {
 303        try
 304        {
 0305            if (items is not JsonArray)
 306            {
 0307                parsedItems = [];
 0308                return false;
 309            }
 310
 0311            var legacy = JsonSerializer.Deserialize<List<Item>>(items.ToJsonString(), WebJsonOptions);
 0312            parsedItems = legacy ?? [];
 0313            return true;
 314        }
 0315        catch
 316        {
 0317            parsedItems = [];
 0318            return false;
 319        }
 0320    }
 321
 322    private static bool TryReadPlayerStatsFromNode(JsonNode stats, out PlayerStats parsedStats)
 323    {
 324        try
 325        {
 0326            if (stats is not JsonObject)
 327            {
 0328                parsedStats = PlayerStats.Default;
 0329                return false;
 330            }
 331
 0332            var legacy = JsonSerializer.Deserialize<PlayerStats>(stats.ToJsonString(), WebJsonOptions);
 0333            parsedStats = legacy ?? PlayerStats.Default;
 0334            return true;
 335        }
 0336        catch
 337        {
 0338            parsedStats = PlayerStats.Default;
 0339            return false;
 340        }
 0341    }
 342
 343    private static bool HasProperty(JsonObject node, string propertyName)
 344    {
 12345        foreach (var entry in node)
 346        {
 4347            if (string.Equals(entry.Key, propertyName, StringComparison.OrdinalIgnoreCase))
 348            {
 0349                return true;
 350            }
 351        }
 352
 2353        return false;
 0354    }
 355
 356    private static List<Item> ExtractLegacyCharacterInventory(string raw)
 357    {
 358        try
 359        {
 6360            using var doc = JsonDocument.Parse(raw);
 6361            if (!doc.RootElement.TryGetProperty("CharacterInventory", out var invNode) || invNode.ValueKind != JsonValue
 362            {
 6363                return [];
 364            }
 365
 0366            var legacy = JsonSerializer.Deserialize<List<Item>>(invNode.GetRawText());
 0367            return legacy ?? [];
 368        }
 0369        catch
 370        {
 0371            return [];
 372        }
 6373    }
 374
 375    private static void EnsureKnifeFallback(List<Item> stash, List<OnPersonEntry> onPerson)
 376    {
 13377        var stashHasWeapon = stash.Any(item => item.Type == ItemType.Weapon);
 8378        var onPersonHasWeapon = onPerson.Any(entry => entry.Item.Type == ItemType.Weapon);
 7379        if (!stashHasWeapon && !onPersonHasWeapon)
 380        {
 1381            stash.Add(ItemCatalog.Create("Rusty Knife"));
 382        }
 7383    }
 384
 385    private static List<Item> NormalizeItems(List<Item> items)
 386    {
 7387        return items
 7388            .Select(NormalizeItem)
 7389            .ToList();
 390    }
 391
 392    private static List<OnPersonEntry> NormalizeOnPersonItems(List<OnPersonEntry> items)
 393    {
 7394        return items
 1395            .Select(i => i with { Item = NormalizeItem(i.Item) })
 7396            .ToList();
 397    }
 398
 399    private static Item NormalizeItem(Item item)
 400    {
 8401        if (item.ItemDefId > 0 && ItemCatalog.TryGetByItemDefId(item.ItemDefId, out var catalogItemById) && catalogItemB
 402        {
 5403            return catalogItemById;
 404        }
 405
 3406        if (item.ItemDefId == 0
 3407            && !string.IsNullOrWhiteSpace(item.Key)
 3408            && ItemCatalog.TryGetByKey(item.Key, out var catalogItemByKey)
 3409            && catalogItemByKey is not null)
 410        {
 0411            return catalogItemByKey;
 412        }
 413
 3414        if (item.ItemDefId > 0)
 415        {
 1416            return item;
 417        }
 418
 2419        var normalizedName = CombatBalance.NormalizeItemName(item.Name);
 2420        var normalizedType = normalizedName switch
 2421        {
 0422            "Bandage" => ItemType.Sellable,
 0423            "Ammo Box" => ItemType.Sellable,
 0424            "Medkit" => ItemType.Consumable,
 2425            _ => item.Type
 2426        };
 427
 2428        if (ItemCatalog.TryGet(normalizedName, out var catalogItem))
 429        {
 1430            return catalogItem!;
 431        }
 432
 1433        return item with { Name = normalizedName, Type = normalizedType };
 434    }
 435}