XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace XFEEliteDangerous.Client;

/// <summary>
/// Replays only documented state fields from local journals. Catalogues are evidence, never owned inventory.
/// The result is a partial local copy, not a Frontier save or a playable game-server implementation.
/// </summary>
public sealed class JournalSnapshotReader
{
    private static readonly string[] Sidecars = ["Cargo.json", "ShipLocker.json", "Backpack.json", "ModulesInfo.json", "Outfitting.json", "Shipyard.json"];
    private sealed record Entry(DateTimeOffset Timestamp, long Order, string Owner, string File, JsonElement Data);

    public static string DefaultDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        "Saved Games", "Frontier Developments", "Elite Dangerous");

    public async Task<CommanderSnapshot> ReadAsync(string journalDirectory, CancellationToken cancellationToken = default)
    {
        var root = LocalPaths.Directory(journalDirectory);
        if (!Directory.Exists(root)) throw new DirectoryNotFoundException(AppText.Get("JournalSnapshotReader.Text024"));
        var entries = new List<Entry>();
        var evidence = new List<SourceFile>();
        var warnings = new List<string>();
        long sequence = 0;
        var currentOwner = "";
        foreach (var file in Directory.EnumerateFiles(root, "Journal.*.log").Order(StringComparer.OrdinalIgnoreCase))
        {
            cancellationToken.ThrowIfCancellationRequested();
            var name = Path.GetFileName(file);
            using var stream = GameInstallationInspector.OpenRead(file);
            using var reader = new StreamReader(stream);
            var lineNumber = 0;
            while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
            {
                lineNumber++;
                if (string.IsNullOrWhiteSpace(line)) continue;
                try
                {
                    using var document = JsonDocument.Parse(line);
                    var data = document.RootElement;
                    if (data.ValueKind != JsonValueKind.Object || !Timestamp(data, out var timestamp))
                    { warnings.Add(AppText.Format("JournalSnapshotReader.Text025", (name), (lineNumber))); continue; }
                    var eventName = Text(data, "event");
                    if (eventName is "Commander" or "NewCommander") currentOwner = Text(data, "Name") ?? currentOwner;
                    if (eventName == "LoadGame") currentOwner = Text(data, "Commander") ?? currentOwner;
                    entries.Add(new(timestamp, sequence++, currentOwner, name, data.Clone()));
                }
                catch (JsonException) { warnings.Add(AppText.Format("JournalSnapshotReader.Text026", (name), (lineNumber))); }
            }
            var hash = await GameInstallationInspector.InspectFileAsync(file, cancellationToken).ConfigureAwait(false);
            evidence.Add(new(name, hash.Sha256, hash.Length));
        }
        entries.Sort((a, b) => a.Timestamp != b.Timestamp ? a.Timestamp.CompareTo(b.Timestamp) : a.Order.CompareTo(b.Order));
        var session = entries.LastOrDefault(e => Text(e.Data, "event") == "LoadGame" && !string.IsNullOrWhiteSpace(e.Owner));
        if (session is null) throw new InvalidDataException(AppText.Get("JournalSnapshotReader.Text027"));
        var selected = entries.Where(e => string.Equals(e.Owner, session.Owner, StringComparison.Ordinal)).ToList();
        foreach (var sidecar in Sidecars)
        {
            var file = Path.Combine(root, sidecar);
            if (!File.Exists(file)) continue;
            try
            {
                using var stream = GameInstallationInspector.OpenRead(file);
                using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
                var data = document.RootElement;
                var hash = await GameInstallationInspector.InspectFileAsync(file, cancellationToken).ConfigureAwait(false);
                evidence.Add(new(sidecar, hash.Sha256, hash.Length));
                if (data.ValueKind != JsonValueKind.Object || !Timestamp(data, out var timestamp))
                { warnings.Add(AppText.Format("JournalSnapshotReader.Text028", (sidecar))); continue; }
                // Sidecars are shared between accounts. Only the newest commander's current session can claim them.
                if (timestamp < session.Timestamp)
                { warnings.Add(AppText.Format("JournalSnapshotReader.Text029", (sidecar))); continue; }
                if (sidecar is "Outfitting.json" or "Shipyard.json")
                { warnings.Add(AppText.Format("JournalSnapshotReader.Text030", (sidecar))); continue; }
                selected.Add(new(timestamp, sequence++, session.Owner, sidecar, data.Clone()));
            }
            catch (JsonException) { warnings.Add(AppText.Format("JournalSnapshotReader.Text031", (sidecar))); }
        }
        selected.Sort((a, b) => a.Timestamp != b.Timestamp ? a.Timestamp.CompareTo(b.Timestamp) : a.Order.CompareTo(b.Order));
        var replay = new Replay(session.Owner, warnings);
        foreach (var entry in selected) { cancellationToken.ThrowIfCancellationRequested(); replay.Apply(entry.Data, entry.Timestamp); }
        var version = entries.Where(e => Text(e.Data, "event") == "Fileheader").LastOrDefault();
        warnings.Add(AppText.Get("JournalSnapshotReader.Text032"));
        return replay.Snapshot(new("elite-journal", DateTimeOffset.UtcNow, selected.LastOrDefault()?.Timestamp,
            version is null ? null : Text(version.Data, "gameversion"), evidence, warnings.Distinct().ToArray()));
    }

    internal static string? Text(JsonElement data, string property) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
    private static string Id(JsonElement data, string property) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.ValueKind is JsonValueKind.Number or JsonValueKind.String ? value.ToString() : "";
    private static long Number(JsonElement data, string property, long fallback = 0) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.TryGetInt64Safe(out var number) ? number : fallback;
    private static double Fraction(JsonElement data, string property, double fallback = 1) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && double.IsFinite(number) ? number : fallback;
    private static bool Bool(JsonElement data, string property, bool fallback = true) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : fallback;
    private static IEnumerable<JsonElement> Array(JsonElement data, string property) => data.ValueKind == JsonValueKind.Object && data.TryGetProperty(property, out var value)
        && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray() : [];
    private static bool Timestamp(JsonElement data, out DateTimeOffset timestamp)
        => DateTimeOffset.TryParse(Text(data, "timestamp"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out timestamp);
    private static string Symbol(string? symbol)
    {
        var result = (symbol ?? "").Trim().TrimStart('$').TrimEnd(';');
        if (result.EndsWith("_name", StringComparison.OrdinalIgnoreCase)) result = result[..^5];
        return result.ToLowerInvariant();
    }

    private sealed class Replay(string name, List<string> warnings)
    {
        private readonly Dictionary<string, InventoryItem> cargo = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<string, MaterialItem> materials = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<string, MutableShip> ships = new(StringComparer.Ordinal);
        private string? activeShip;
        private long credits;
        private DateTimeOffset lastLoadoutAt;
        private DateTimeOffset lastCargoSnapshot;
        private bool creditBaseline;

        public void Apply(JsonElement data, DateTimeOffset timestamp)
        {
            var evt = Text(data, "event");
            switch (evt)
            {
                case "NewCommander":
                    cargo.Clear(); materials.Clear(); ships.Clear(); activeShip = null; credits = 0; creditBaseline = false;
                    break;
                case "LoadGame":
                    if (data.TryGetProperty("Credits", out _)) { credits = Number(data, "Credits"); creditBaseline = true; }
                    SetActive(data, "ShipID", "Ship"); break;
                case "Loadout":
                    var ship = SetActive(data, "ShipID", "Ship");
                    if (ship is null) break;
                    ship.Name = Text(data, "ShipName") ?? ship.Name;
                    ship.Ident = Text(data, "ShipIdent") ?? ship.Ident;
                    ship.Modules.Clear();
                    foreach (var module in Array(data, "Modules")) PutModule(ship, ReadModule(module));
                    lastLoadoutAt = timestamp; break;
                case "StoredShips":
                    var owned = new HashSet<string>(StringComparer.Ordinal);
                    if (activeShip is not null) owned.Add(activeShip);
                    foreach (var stored in Array(data, "ShipsHere").Concat(Array(data, "ShipsRemote")))
                    {
                        var storedShip = GetShip(stored, "ShipID", "ShipType");
                        if (storedShip is null) continue;
                        storedShip.Name = Text(stored, "Name") ?? storedShip.Name;
                        owned.Add(storedShip.Id);
                    }
                    foreach (var id in ships.Keys.Where(id => !owned.Contains(id)).ToArray()) ships.Remove(id);
                    break;
                case "ShipyardNew": SetActive(data, "NewShipID", "ShipType"); break;
                case "ShipyardSwap": SetActive(data, "ShipID", "ShipType"); break;
                case "ShipyardSell": ships.Remove(Id(data, "SellShipID")); break;
                case "ShipyardBuy":
                    var sold = Id(data, "SellShipID"); if (sold.Length > 0) ships.Remove(sold);
                    break;
                case "SellShipOnRebuy": ships.Remove(Id(data, "SellShipId")); break;
                case "SetUserShipName":
                    var named = GetShip(data, "ShipID", "Ship");
                    if (named is not null) { named.Name = Text(data, "UserShipName") ?? named.Name; named.Ident = Text(data, "UserShipId") ?? named.Ident; }
                    break;
                case "ModuleInfo":
                    if (timestamp < lastLoadoutAt || activeShip is null || !ships.TryGetValue(activeShip, out var current)) break;
                    foreach (var module in Array(data, "Modules"))
                    {
                        var slot = Text(module, "Slot") ?? "";
                        var item = Symbol(Text(module, "Item"));
                        // ModuleInfo is a power-priority supplement, not a complete loadout. Do not invent missing equipment.
                        if (current.Modules.TryGetValue(slot, out var old) && old.ItemId == item)
                            current.Modules[slot] = old with { Priority = (int)Number(module, "Priority", old.Priority) };
                    }
                    break;
                case "ModuleBuy": ChangeModule(data, "Slot", "BuyItem"); break;
                case "ModuleRetrieve": ChangeModule(data, "Slot", "RetrievedItem"); break;
                case "ModuleSell":
                case "ModuleStore":
                    var changed = GetShip(data, "ShipID", "Ship");
                    changed?.Modules.Remove(Text(data, "Slot") ?? "");
                    if (!string.IsNullOrEmpty(Text(data, "ReplacementItem"))) ChangeModule(data, "Slot", "ReplacementItem");
                    break;
                case "ModuleSwap":
                    var swapped = GetShip(data, "ShipID", "Ship");
                    if (swapped is null) break;
                    var from = Text(data, "FromSlot") ?? ""; var to = Text(data, "ToSlot") ?? "";
                    swapped.Modules.Remove(from, out var fromModule); swapped.Modules.Remove(to, out var toModule);
                    if (fromModule is not null) PutModule(swapped, fromModule with { Slot = to });
                    if (toModule is not null) PutModule(swapped, toModule with { Slot = from });
                    break;
                case "MassModuleStore":
                    var storedModules = GetShip(data, "ShipID", "Ship");
                    if (storedModules is not null)
                        foreach (var item in Array(data, "Items")) storedModules.Modules.Remove(Text(item, "Slot") ?? "");
                    break;
                case "Cargo":
                    if (data.TryGetProperty("Inventory", out var inventory) && inventory.ValueKind == JsonValueKind.Array)
                    {
                        cargo.Clear();
                        foreach (var item in inventory.EnumerateArray()) CargoDelta(Text(item, "Name"), Text(item, "Name_Localised"), Number(item, "Count"));
                        lastCargoSnapshot = timestamp;
                    }
                    break;
                case "CollectCargo": if (timestamp > lastCargoSnapshot) CargoDelta(Text(data, "Type"), Text(data, "Type_Localised"), Number(data, "Count", 1)); break;
                case "EjectCargo": if (timestamp > lastCargoSnapshot) CargoDelta(Text(data, "Type"), null, -Number(data, "Count")); break;
                case "MarketBuy": if (timestamp > lastCargoSnapshot) CargoDelta(Text(data, "Type"), Text(data, "Type_Localised"), Number(data, "Count")); break;
                case "MarketSell": if (timestamp > lastCargoSnapshot) CargoDelta(Text(data, "Type"), null, -Number(data, "Count")); break;
                case "Materials":
                    foreach (var category in new[] { "Raw", "Manufactured", "Encoded" }) ReplaceMaterials(data, category, category);
                    break;
                case "ShipLocker": case "Backpack":
                    foreach (var category in new[] { "Items", "Components", "Consumables", "Data" }) ReplaceMaterials(data, category, evt + "." + category);
                    break;
                case "MaterialCollected": MaterialDelta(Text(data, "Name"), Text(data, "Name_Localised"), Number(data, "Count"), Text(data, "Category") ?? "Unknown"); break;
                case "MaterialDiscarded": MaterialDelta(Text(data, "Name"), null, -Number(data, "Count"), Text(data, "Category") ?? "Unknown"); break;
                case "MaterialTrade":
                    foreach (var (key, sign) in new[] { ("Paid", -1), ("Received", 1) })
                        if (data.TryGetProperty(key, out var trade) && trade.ValueKind == JsonValueKind.Object)
                            MaterialDelta(Text(trade, "Material"), Text(trade, "Material_Localised"), sign * Number(trade, "Quantity"), Text(trade, "Category") ?? "Unknown");
                    break;
                case "EngineerCraft": case "Synthesis":
                    foreach (var ingredient in Array(data, evt == "EngineerCraft" ? "Ingredients" : "Materials"))
                    {
                        var id = Symbol(Text(ingredient, "Name"));
                        var category = materials.Values.FirstOrDefault(m => m.Id == id && !m.Category.Contains('.'))?.Category;
                        if (category is not null) MaterialDelta(id, null, -Number(ingredient, "Count"), category);
                    }
                    if (evt == "EngineerCraft" && activeShip is not null && ships.TryGetValue(activeShip, out var engineered))
                    {
                        var slot = Text(data, "Slot") ?? "";
                        if (engineered.Modules.TryGetValue(slot, out var old) && old.ItemId == Symbol(Text(data, "Module")))
                        {
                            var engineering = old.Engineering?.DeepClone().AsObject() ?? new JsonObject();
                            if (ReadEngineering(data) is { } update)
                                foreach (var property in update) engineering[property.Key] = property.Value?.DeepClone();
                            if (Text(data, "ApplyExperimentalEffect") is { } effect) engineering["ExperimentalEffect"] = effect;
                            engineered.Modules[slot] = old with { Engineering = engineering };
                        }
                    }
                    break;
            }
            ApplyCreditChange(data, evt);
        }

        private void ApplyCreditChange(JsonElement data, string? evt)
        {
            if (!creditBaseline || evt == "LoadGame") return;
            var delta = evt switch
            {
                "MarketBuy" or "BuyDrones" => -Number(data, "TotalCost"),
                "MarketSell" or "SellDrones" => Number(data, "TotalSale"),
                "ModuleBuy" => -Number(data, "BuyPrice") + Number(data, "SellPrice"),
                "ModuleSell" or "ModuleSellRemote" => Number(data, "SellPrice"),
                "ShipyardBuy" => -Number(data, "ShipPrice") + Number(data, "SellPrice"),
                "ShipyardSell" or "SellShipOnRebuy" => Number(data, "ShipPrice"),
                "BuySuit" or "BuyWeapon" or "BuyMicroResources" => -Number(data, "Price"),
                "SellSuit" or "SellWeapon" or "SellMicroResources" => Number(data, "Price"),
                "MissionCompleted" => Number(data, "Reward") - Number(data, "Donated"),
                "RedeemVoucher" => Number(data, "Amount"),
                "PayFines" or "PayBounties" or "PayLegacyFines" => -Number(data, "Amount"),
                "SellExplorationData" or "MultiSellExplorationData" => Number(data, "TotalEarnings"),
                "Resurrect" or "Repair" or "RepairAll" or "RefuelAll" or "RefuelPartial" or "BuyAmmo"
                    or "BuyTradeData" or "BuyExplorationData" or "FetchRemoteModule" or "ModuleStore" or "ModuleRetrieve" or "MassModuleStore"
                    or "ShipyardTransfer" or "RestockVehicle" or "BookTaxi" or "BookDropship" => -Number(data, "Cost"),
                "CancelTaxi" or "CancelDropship" => Number(data, "Refund"),
                _ => 0
            };
            try { credits = checked(credits + delta); }
            catch (OverflowException) { warnings.Add(AppText.Get("JournalSnapshotReader.Text033")); }
        }

        private MutableShip? SetActive(JsonElement data, string idField, string typeField)
        {
            var ship = GetShip(data, idField, typeField);
            if (ship is not null) activeShip = ship.Id;
            return ship;
        }
        private MutableShip? GetShip(JsonElement data, string idField, string typeField)
        {
            var id = Id(data, idField); var type = Symbol(Text(data, typeField));
            if (id.Length == 0 || type.Length == 0 || type.Contains("buggy", StringComparison.OrdinalIgnoreCase)
                || type.Contains("fighter", StringComparison.OrdinalIgnoreCase) || type.Contains("srv", StringComparison.OrdinalIgnoreCase)
                || type.Contains("suit", StringComparison.OrdinalIgnoreCase) || type.Contains("taxi", StringComparison.OrdinalIgnoreCase)
                || Bool(data, "Taxi", false) || type == "unknown") return null;
            if (!ships.TryGetValue(id, out var ship)) ships[id] = ship = new(id, type);
            ship.Type = type;
            if (Text(data, "ShipName") is { } shipName) ship.Name = shipName;
            if (Text(data, "ShipIdent") is { } ident) ship.Ident = ident;
            return ship;
        }
        private static ModuleSnapshot ReadModule(JsonElement module) => new(Text(module, "Slot") ?? "", Symbol(Text(module, "Item")),
            (int)Number(module, "Priority"), Bool(module, "On"), Fraction(module, "Health"), Number(module, "Value"),
            module.ValueKind == JsonValueKind.Object && module.TryGetProperty("Engineering", out var engineering) ? ReadEngineering(engineering) : null);
        private static JsonObject? ReadEngineering(JsonElement engineering)
        {
            if (engineering.ValueKind != JsonValueKind.Object) return null;
            var result = new JsonObject();
            // All engineering keys present in local 2026 Loadout events; credentials and unrelated event data are excluded.
            foreach (var property in new[] { "Engineer", "EngineerID", "BlueprintID", "BlueprintName", "Level", "Quality", "ExperimentalEffect", "ExperimentalEffect_Localised" })
                if (engineering.TryGetProperty(property, out var value) && value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
                    result[property] = JsonNode.Parse(value.GetRawText());
            if (engineering.TryGetProperty("Modifiers", out var modifiers) && modifiers.ValueKind == JsonValueKind.Array)
            {
                var items = new JsonArray();
                foreach (var modifier in modifiers.EnumerateArray())
                {
                    if (modifier.ValueKind != JsonValueKind.Object) continue;
                    var item = new JsonObject();
                    foreach (var property in new[] { "Label", "Value", "ValueStr", "OriginalValue", "LessIsGood" })
                        if (modifier.TryGetProperty(property, out var value) && value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
                            item[property] = JsonNode.Parse(value.GetRawText());
                    items.Add(item);
                }
                result["Modifiers"] = items;
            }
            return result.Count == 0 ? null : result;
        }
        private static void PutModule(MutableShip ship, ModuleSnapshot module)
        { if (module.Slot.Length > 0 && module.ItemId.Length > 0 && module.ItemId != "null") ship.Modules[module.Slot] = module; }
        private void ChangeModule(JsonElement data, string slotField, string itemField)
        {
            var ship = GetShip(data, "ShipID", "Ship");
            if (ship is null) return;
            PutModule(ship, new(Text(data, slotField) ?? "", Symbol(Text(data, itemField)), 0, true, 1, Number(data, "BuyPrice")));
        }
        private void CargoDelta(string? raw, string? displayName, long delta)
        {
            var id = Symbol(raw); if (id.Length == 0) return;
            cargo.TryGetValue(id, out var old);
            var count = Math.Max(0, (old?.Quantity ?? 0) + delta);
            if (count == 0) cargo.Remove(id); else cargo[id] = new(id, displayName ?? old?.Name ?? raw ?? id, count);
        }
        private void ReplaceMaterials(JsonElement data, string array, string category)
        {
            if (!data.TryGetProperty(array, out var items) || items.ValueKind != JsonValueKind.Array) return;
            foreach (var key in materials.Where(kv => kv.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).Select(kv => kv.Key).ToArray()) materials.Remove(key);
            foreach (var item in items.EnumerateArray()) MaterialDelta(Text(item, "Name"), Text(item, "Name_Localised"), Number(item, "Count"), category);
        }
        private void MaterialDelta(string? raw, string? displayName, long delta, string category)
        {
            var id = Symbol(raw); if (id.Length == 0) return;
            var key = category + ":" + id;
            materials.TryGetValue(key, out var old);
            var count = Math.Max(0, (old?.Quantity ?? 0) + delta);
            if (count == 0) materials.Remove(key); else materials[key] = new(id, displayName ?? old?.Name ?? raw ?? id, count, category);
        }
        public CommanderSnapshot Snapshot(SnapshotSource source) => new(name, credits,
            cargo.Values.OrderBy(x => x.Id, StringComparer.Ordinal).ToArray(),
            materials.Values.OrderBy(x => x.Category, StringComparer.Ordinal).ThenBy(x => x.Id, StringComparer.Ordinal).ToArray(),
            ships.Values.OrderBy(x => x.Id, StringComparer.Ordinal).Select(s => new ShipSnapshot(s.Id, s.Type, s.Name, s.Ident, s.Id == activeShip,
                s.Modules.Values.OrderBy(m => m.Slot, StringComparer.Ordinal).ToArray())).ToArray(), source);
        private sealed class MutableShip(string id, string type)
        {
            public string Id { get; } = id;
            public string Type { get; set; } = type;
            public string Name { get; set; } = "";
            public string Ident { get; set; } = "";
            public Dictionary<string, ModuleSnapshot> Modules { get; } = new(StringComparer.OrdinalIgnoreCase);
        }
    }
}

internal static class JournalNumbers
{
    public static bool TryGetInt64Safe(this JsonElement element, out long value)
    {
        value = 0;
        return element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out value);
    }
}