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

namespace XFEEliteDangerous.Core;

[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeSavegameGameVersion([property: JsonRequired] string Name, [property: JsonRequired] string Semver,
    [property: JsonRequired] uint Version, [property: JsonRequired] uint Dev);
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeProvingGroundSnapshot([property: JsonRequired] uint EliteRank, [property: JsonRequired] uint Rank,
    [property: JsonRequired] uint RankXp, [property: JsonRequired] uint TotalXp);

// These markers deliberately have no extension-data bag. Unknown nonempty input must be rejected, not erased.
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeEmptyObject;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeEmptyArray;

[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeEmptyMicroresources
{
    public required NativeEmptyObject Inventory { get; init; }
    public required NativeEmptyObject Discovered { get; init; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeEmptyOnboardingObjectives
{
    public required NativeEmptyObject Objectives { get; init; }
    public required NativeEmptyObject ExtraData { get; init; }
}

/// <summary>
/// Explicit declaration of genuinely empty private collections. Do not use this to project or discard an
/// existing nonempty profile. Unknown nonempty schemas are unsupported; there is no implicit empty fallback.
/// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeFullSavegameEmptyCollections
{
    public required NativeEmptyObject Rank { get; init; }
    public required NativeEmptyObject Constants { get; init; }
    public required NativeEmptyObject Pinned { get; init; }
    public required NativeEmptyArray CurrentCrew { get; init; }
    public required NativeEmptyArray Suits { get; init; }
    public required NativeEmptyArray WeaponRack { get; init; }
    public required NativeEmptyArray SystemHyperspacePermits { get; init; }
    public required NativeEmptyArray RegionHyperspacePermits { get; init; }
    public required NativeEmptyArray LandingPermits { get; init; }
    public required NativeEmptyArray LegacyFines { get; init; }
    public required NativeEmptyArray LegalStates { get; init; }
    public required NativeEmptyArray TaxiBookings { get; init; }
    public required NativeEmptyMicroresources Microresources { get; init; }
    public required NativeEmptyObject ShipLocker { get; init; }
    public required NativeEmptyOnboardingObjectives OnboardingObjectives { get; init; }
}

/// <summary>
/// Additional explicit data required by the ordinary non-Cqc/non-multicrew full reader. Identity, credits,
/// alive, location and selected vessel IDs have a single source in NativeSavegameSnapshot.
/// Avatar text is caller-supplied opaque content; empty strings select the verified fresh no-avatar skip
/// branch, without proving that later spawning accepts it. StartupState is never defaulted or interpreted.
/// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeFullSavegameProfile
{
    public required string Name { get; init; }
    public required long LoanCeiling { get; init; }
    public required long Debt { get; init; }
    public required uint StartupState { get; init; }
    public required string Gender { get; init; }
    public required NativeProvingGroundSnapshot ProvingGround { get; init; }
    public required uint BattleRating { get; init; }
    public required string Avatar { get; init; }
    public required string AvatarXml { get; init; }
    public required int VehicleDistanceTravelled { get; init; }
    public required uint AtrLevel { get; init; }
    public required int Notoriety { get; init; }
    public required bool MarkedWanted { get; init; }
    public required string CurrentSystemKey { get; init; }
    public required ulong CurrentSystem { get; init; }
    public required NativeSavegameGameVersion GameVersion { get; init; }
    public required NativeFullSavegameEmptyCollections EmptyCollections { get; init; }
}

/// <summary>
/// Composes the startup ship document with explicit full-reader profile fields and genuine empty inventory
/// containers. This validates the pinned field/type and key-pair contracts only, not world resources,
/// native parser execution, avatar semantics, account ownership or playable readiness.
/// </summary>
public static class NativeFullSavegameDocument
{
    public static string SerializeComplete(NativeSavegameSnapshot snapshot, NativeSavegameCatalogue catalogue,
        NativeFullSavegameProfile profile)
    {
        ArgumentNullException.ThrowIfNull(profile);
        ArgumentNullException.ThrowIfNull(snapshot);
        if(snapshot.Vessels is null||snapshot.Vessels.Any(ship=>ship is null||ship.LastLocation is null))
            throw new ArgumentException("The full fleet reader requires each vessel's explicit previous UA location.",nameof(snapshot));
        ValidateProfile(profile);
        // Reuse the strict ship/module/location validator. No partial document is returned on failure.
        using var startup = JsonDocument.Parse(NativeSavegameDocument.Serialize(snapshot, catalogue));
        using var output = new MemoryStream();
        using (var writer = new Utf8JsonWriter(output))
        {
            writer.WriteStartObject();
            writer.WriteStartObject("gameVersion");
            writer.WriteString("name", profile.GameVersion.Name); writer.WriteString("semver", profile.GameVersion.Semver);
            writer.WriteNumber("version", profile.GameVersion.Version); writer.WriteNumber("dev", profile.GameVersion.Dev);
            writer.WriteEndObject();
            writer.WriteStartObject("commander");
            writer.WriteNumber("id", snapshot.CommanderNativeId); writer.WriteString("name", profile.Name);
            foreach (var field in startup.RootElement.GetProperty("commander").EnumerateObject()) field.WriteTo(writer);
            writer.WriteNumber("loanCeiling", profile.LoanCeiling); writer.WriteNumber("debt", profile.Debt);
            EmptyObject(writer, "rank"); writer.WriteNumber("startupState", profile.StartupState);
            writer.WriteString("gender", profile.Gender);
            writer.WriteStartObject("provingGround");
            writer.WriteNumber("eliteRank", profile.ProvingGround.EliteRank); writer.WriteNumber("rank", profile.ProvingGround.Rank);
            writer.WriteNumber("rankXP", profile.ProvingGround.RankXp); writer.WriteNumber("totalXP", profile.ProvingGround.TotalXp);
            writer.WriteEndObject();
            writer.WriteNumber("battleRating", profile.BattleRating); writer.WriteString("avatar", profile.Avatar);
            writer.WriteString("avatarXML", profile.AvatarXml); writer.WriteNumber("vehicleDistanceTravelled", profile.VehicleDistanceTravelled);
            writer.WriteNumber("atrLevel", profile.AtrLevel); writer.WriteNumber("notoriety", profile.Notoriety);
            writer.WriteBoolean("markedWanted", profile.MarkedWanted);
            writer.WriteString("currentSystemKey", profile.CurrentSystemKey); writer.WriteNumber("currentSystem", profile.CurrentSystem);
            EmptyArray(writer, "currentCrew"); writer.WriteEndObject();
            writer.WritePropertyName("vessels"); startup.RootElement.GetProperty("vessels").WriteTo(writer);
            EmptyObject(writer, "constants"); EmptyObject(writer, "pinned");
            foreach (var name in new[] { "suits", "weaponRack", "systemHyperspacePermits", "regionHyperspacePermits",
                         "landingPermits", "legacyFines", "legalStates", "taxiBookings" }) EmptyArray(writer, name);
            writer.WriteStartObject("microresources"); EmptyObject(writer, "inventory"); EmptyObject(writer, "discovered"); writer.WriteEndObject();
            EmptyObject(writer, "shipLocker");
            writer.WriteStartObject("onboardingObjectives"); EmptyObject(writer, "objectives"); EmptyObject(writer, "extraData"); writer.WriteEndObject();
            writer.WriteEndObject();
        }
        return Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length));
    }

    private static void ValidateProfile(NativeFullSavegameProfile profile)
    {
        Text(profile.Name, nameof(profile.Name), 64, false);
        Text(profile.Gender, nameof(profile.Gender), 16, false);
        if (!string.Equals(profile.Gender, "M", StringComparison.OrdinalIgnoreCase)
            && !string.Equals(profile.Gender, "F", StringComparison.OrdinalIgnoreCase))
            Invalid("Explicit M/F gender is required; unknown text would retain an unset native constructor value.");
        Text(profile.Avatar, nameof(profile.Avatar), 262_144, true);
        Text(profile.AvatarXml, nameof(profile.AvatarXml), 262_144, true);
        Required(profile.GameVersion, nameof(profile.GameVersion));
        Text(profile.GameVersion.Name, "Game version name", 200, true);
        Text(profile.GameVersion.Semver, "Game version semver", 200, true);
        Required(profile.ProvingGround, nameof(profile.ProvingGround));
        if (profile.CurrentSystemKey is null || profile.CurrentSystemKey.Length is < 1 or > 20)
            Invalid("CurrentSystemKey must be a bounded complete UInt64 decimal string.");
        if (!ulong.TryParse(profile.CurrentSystemKey, NumberStyles.None, CultureInfo.InvariantCulture, out var key)
            || key.ToString(CultureInfo.InvariantCulture) != profile.CurrentSystemKey)
            Invalid("CurrentSystemKey must be a canonical complete UInt64 decimal string.");
        if (key == profile.CurrentSystem || ((key >> 51) & 0x1FF0UL) != ((key ^ profile.CurrentSystem) & 0x1FF0UL))
            Invalid("CurrentSystemKey and CurrentSystem fail the pinned native pair check; no world is inferred from that check.");
        Required(profile.EmptyCollections, nameof(profile.EmptyCollections));
        var empty = profile.EmptyCollections;
        Required(empty.Rank, "Empty rank"); Required(empty.Constants, "Empty constants"); Required(empty.Pinned, "Empty pinned");
        Required(empty.CurrentCrew, "Empty current crew"); Required(empty.Suits, "Empty suits"); Required(empty.WeaponRack, "Empty weapon rack");
        Required(empty.SystemHyperspacePermits, "Empty system permits"); Required(empty.RegionHyperspacePermits, "Empty region permits");
        Required(empty.LandingPermits, "Empty landing permits"); Required(empty.LegacyFines, "Empty legacy fines");
        Required(empty.LegalStates, "Empty legal states"); Required(empty.TaxiBookings, "Empty taxi bookings");
        Required(empty.ShipLocker, "Empty ship locker"); Required(empty.Microresources, "Empty microresources");
        Required(empty.Microresources.Inventory, "Empty microresource inventory"); Required(empty.Microresources.Discovered, "Empty discovered microresources");
        Required(empty.OnboardingObjectives, "Empty onboarding objectives");
        Required(empty.OnboardingObjectives.Objectives, "Empty onboarding objective entries");
        Required(empty.OnboardingObjectives.ExtraData, "Empty onboarding extra data");
    }

    private static void Text(string? value, string name, int maximum, bool allowEmpty)
    {
        // Avatar/XML may contain legitimate whitespace; opaque content is not parsed or proclaimed valid.
        if (value is null || value.Length > maximum || (!allowEmpty && string.IsNullOrWhiteSpace(value)) || value.Contains('\0'))
            Invalid($"{name} must be explicitly supplied bounded text without NUL.");
    }
    private static void Required(object? value, string name)
    {
        if (value is null) Invalid($"{name} must be explicitly declared; missing state is not replaced with an empty container.");
    }
    private static void EmptyObject(Utf8JsonWriter writer, string name) { writer.WriteStartObject(name); writer.WriteEndObject(); }
    private static void EmptyArray(Utf8JsonWriter writer, string name) { writer.WriteStartArray(name); writer.WriteEndArray(); }
    [System.Diagnostics.CodeAnalysis.DoesNotReturn]
    private static void Invalid(string message) => throw new ArgumentException(message);
}