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;
using System.Text.Json.Serialization;
using XFEEliteDangerous.Client.Storage;

namespace XFEEliteDangerous.Core;

/// <summary>Confirmed upload-only fields, retained separately from the editable inventory.</summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record NativeSavegameUploadState
{
    public required int OnFootDistanceTravelled {get;init;}
    public required bool NearPlanet {get;init;}
    public JsonElement? OnboardingObjectives {get;init;}
    public bool? OnboardingObjectivesComplete {get;init;}
    public IReadOnlyDictionary<uint,JsonElement> VesselExtras {get;init;}=new Dictionary<uint,JsonElement>();
}

/// <summary>
/// PUT writer 23F8220/23F8FB0 sends a runtime delta, not the GET document.
/// Validate and construct the whole candidate before changing the account.
/// No credits, cargo, ship membership, market transactions or world creation are inferred.
/// </summary>
internal static class NativeSavegameUpload
{
    private static readonly string[] Objectives=["ShipVendorAndTutorial","SuitWeaponsVendor","CreateLoadoutWithTerminal",
        "CompleteFirstMission","CommanderFirstShip","VisitShipVendor","FinishShipTutorial","VisitSuitWeaponsVendor",
        "PurchaseUtilitySuit","AccessTerminal","ApplyLoadout","GoToTurboLift","SelectShipHangarFloor","BoardShip",
        "AccessMissionBoardTerminal","AcceptMission"];

    internal static NativeDataReply Handle(AccountState account,bool selected,byte[] body)
    {
        if(!selected)throw new NativeDataException(409,"Select a private commander before saving.");
        if(body.Length>1024*1024)throw new NativeDataException(413,"Native save upload exceeds the private limit.");
        var original=account.NativeSave??throw new NativeDataException(409,"Native save is not initialized.");
        var baseline=original.ExportCurrent(account);
        using var document=NativeJson.Parse(body);var root=document.RootElement;
        NativeJson.Fields(root,"commander","vessels","onboardingObjectives","onboardingObjectivesComplete");
        var commander=NativeJson.Field(root,"commander");
        NativeJson.Fields(commander,"vehicleDistanceTravelled","onFootDistanceTravelled","startupState","nearplanet","gender","atrLevel","markedWanted");
        var profile=baseline.Profile with
        {
            VehicleDistanceTravelled=I32(commander,"vehicleDistanceTravelled"),StartupState=NativeJson.U32(commander,"startupState"),
            AtrLevel=NativeJson.U32(commander,"atrLevel"),MarkedWanted=NativeJson.Boolean(commander,"markedWanted"),
            Gender=commander.TryGetProperty("gender",out _)?NativeJson.Text(commander,"gender",1):baseline.Profile.Gender
        };
        var extras=new NativeSavegameUploadState
        {
            OnFootDistanceTravelled=I32(commander,"onFootDistanceTravelled"),NearPlanet=NativeJson.Boolean(commander,"nearplanet"),
            OnboardingObjectives=root.TryGetProperty("onboardingObjectives",out var onboarding)?Onboarding(onboarding):baseline.UploadState?.OnboardingObjectives,
            OnboardingObjectivesComplete=root.TryGetProperty("onboardingObjectivesComplete",out _)
                ?NativeJson.Boolean(root,"onboardingObjectivesComplete"):baseline.UploadState?.OnboardingObjectivesComplete,
            VesselExtras=new Dictionary<uint,JsonElement>(baseline.UploadState?.VesselExtras??new Dictionary<uint,JsonElement>())
        };
        var entries=NativeJson.Field(root,"vessels");
        if(entries.ValueKind!=JsonValueKind.Array||entries.GetArrayLength()>baseline.Snapshot.Vessels.Count)
            throw new NativeDataException(400,"Invalid native vessel delta collection.");
        var updates=new Dictionary<uint,NativeShipSnapshot>();
        foreach(var entry in entries.EnumerateArray())
        {
            NativeJson.Fields(entry,"ownedshipid","parentshipid","prototype","location","state");
            var id=NativeJson.U32(entry,"ownedshipid");
            var ship=baseline.Snapshot.Vessels.SingleOrDefault(value=>value.Id==id)
                ??throw new NativeDataException(404,"Native upload references an unowned vessel.");
            if(updates.ContainsKey(id))throw new NativeDataException(400,"Duplicate native vessel delta.");
            if(entry.TryGetProperty("parentshipid",out _)&&NativeJson.U32(entry,"parentshipid")!=ship.ParentShipId)
                throw new NativeDataException(409,"Native vessel parent changes require a separate transaction.");
            // This resource label describes the already identified vessel. It is
            // not a catalogue ID and must not replace the server's ship definition.
            _=NativeJson.Text(entry,"prototype",256);
            var location=NativeJson.Field(entry,"location");NativeJson.Fields(location,"ua","landed");
            NativeLocationStringValue position=ship.Location;
            if(location.TryGetProperty("ua",out _))
            {
                if(!NativeLocationStringCodec.TryParse(NativeJson.Text(location,"ua",512),out position))
                    throw new NativeDataException(400,"Invalid native uploaded location.");
                if((position.Address&0x007f_ffff_ffff_ffffUL)!=profile.CurrentSystem)
                    throw new NativeDataException(409,"A system transition requires its own confirmed system state.");
            }
            var extra=new JsonObject();
            if(extras.VesselExtras.TryGetValue(id,out var previous))extra=JsonNode.Parse(previous.GetRawText())!.AsObject();
            if(location.TryGetProperty("landed",out _))extra["landed"]=NativeJson.Boolean(location,"landed");
            ship=ApplyState(ship,NativeJson.Field(entry,"state"),extra) with {Location=position,
                LastLocation=position==ship.Location?ship.LastLocation:ship.Location};
            updates.Add(id,ship);
            ((Dictionary<uint,JsonElement>)extras.VesselExtras)[id]=JsonSerializer.SerializeToElement(extra,Wire.Json);
        }
        var vessels=baseline.Snapshot.Vessels.Select(ship=>updates.GetValueOrDefault(ship.Id,ship)).ToArray();
        var active=vessels.Single(ship=>ship.Id==baseline.Snapshot.CurrentVesselId);
        var candidate=baseline with{Profile=profile,UploadState=extras,
            Snapshot=baseline.Snapshot with{Vessels=vessels,Location=active.Location}};
        try
        {
            var inventory=candidate.CreateInventory(account,account.Commander.Revision);
            _=candidate.SerializeCurrent(withCommander(inventory));
            if(JsonSerializer.Serialize(candidate,Wire.Json)==JsonSerializer.Serialize(baseline,Wire.Json))return new(200,"<data />");
            if(account.Commander.Revision==long.MaxValue)throw new NativeDataException(409,"Native inventory revision exhausted.");
            inventory.Revision=account.Commander.Revision+1;
            account.NativeSave=candidate;account.Commander=inventory;
            // The native upload completion consumes the captured XML acknowledgement,
            // even though the upload itself and the GET save are JSON documents.
            return new(200,"<data />");
        }
        catch(Exception error)when(error is ArgumentException or InvalidOperationException or OverflowException)
        {throw new NativeDataException(400,"Native save delta is inconsistent with the private state.");}

        AccountState withCommander(CommanderState value)=>new(){NativeUserId=account.NativeUserId,Commander=value};
    }

    private static NativeShipSnapshot ApplyState(NativeShipSnapshot ship,JsonElement state,JsonObject extra)
    {
        if(state.ValueKind!=JsonValueKind.Object)throw new NativeDataException(400,"Expected native vessel state.");
        var groups=ship.Firegroups.ToDictionary(value=>value.Index);var capacitors=ship.Capacitors.ToDictionary(value=>value.Index);
        foreach(var field in state.EnumerateObject())
        {
            var key=field.Name;
            switch(key)
            {
                case "cockpitBreached":ship=ship with{CockpitBreached=NativeJson.Boolean(state,key)};break;
                case "fuel.lvl":ship=ship with{Fuel=ship.Fuel with{Level=Quantity(state,key,ship.Fuel.Capacity)}};break;
                case "reserve.lvl":ship=ship with{Fuel=ship.Fuel with{ReserveLevel=Quantity(state,key,ship.Fuel.Capacity)}};break;
                case "health.hull":ship=ship with{Health=ship.Health with{HullMillion=Million(state,key)}};break;
                case "health.shield":ship=ship with{Health=ship.Health with{ShieldMillion=Million(state,key)}};break;
                case "health.integrity":ship=ship with{Health=ship.Health with{IntegrityMillion=Million(state,key)}};break;
                case "health.paintwork":ship=ship with{Health=ship.Health with{PaintworkMillion=Million(state,key)}};break;
                case "health.shieldup":ship=ship with{Health=ship.Health with{ShieldUp=NativeJson.Boolean(state,key)}};break;
                case "oxygenRemaining":ship=ship with{OxygenRemainingMilliseconds=NativeJson.U32(state,key)};break;
                case "radarRangeSelection":ship=ship with{RadarRangeSelection=NativeJson.U32(state,key)};break;
                case "weaponMode":ship=ship with{WeaponMode=NativeJson.U32(state,key)};break;
                case "firegroupSelected":case "health.caustic":case "health.scorch":case "fuel.superchargedFSD":
                    extra[key]=NativeJson.U32(state,key);break;
                case "health.shielddamagetimer":extra[key]=Quantity(state,key,float.MaxValue);break;
                case "purchaseData":extra[key]=NativeJson.Text(state,key,32_768);break;
                default:
                    var path=key.Split('.');
                    if(path.Length==3&&path[0] is "firegroups" or "capacitors"&&uint.TryParse(path[1],NumberStyles.None,CultureInfo.InvariantCulture,out var index))
                    {
                        if(path[0]=="firegroups"&&index<8&&path[2] is "primary" or "secondary")
                        {
                            var group=groups.GetValueOrDefault(index,new(index,[],[]));
                            var indices=Indices(NativeJson.Text(state,key,8192));
                            groups[index]=path[2]=="primary"?group with{Primary=indices}:group with{Secondary=indices};break;
                        }
                        if(path[0]=="capacitors"&&index<16&&path[2] is "energy" or "pips")
                        {
                            var capacitor=capacitors.GetValueOrDefault(index,new(index,0,null));
                            //23FA714 scales the absolute capacitor energy by
                            //one million. It is not a normalized health ratio.
                            capacitors[index]=path[2]=="energy"?capacitor with{EnergyMillion=NativeJson.U32(state,key)}
                                :capacitor with{Pips=Byte(state,key)};break;
                        }
                    }
                    if(key.StartsWith("modules.",StringComparison.Ordinal))
                    {ship=Module(ship,state,key);break;}
                    throw new NativeDataException(501,"Native save contains an unsupported runtime branch: "+key);
            }
        }
        return ship with{Firegroups=groups.Values.OrderBy(value=>value.Index).ToArray(),Capacitors=capacitors.Values.OrderBy(value=>value.Index).ToArray()};
    }

    private static NativeShipSnapshot Module(NativeShipSnapshot ship,JsonElement state,string key)
    {
        var slot=ship.Modules.Concat(ship.FixedModules).SingleOrDefault(value=>key.StartsWith("modules."+value.Name+".",StringComparison.Ordinal));
        if(slot?.Module is not{} module)throw new NativeDataException(409,"Native save module is absent from the recorded fit.");
        var member=key[(9+slot.Name.Length)..];
        module=member switch
        {
            "id"=>module with{Uid=NativeJson.U64(state,key)},
            "health"=>module with{HealthMillion=Million(state,key)},"on"=>module with{On=NativeJson.Boolean(state,key)},
            "priority"=>module with{Priority=NativeJson.U32(state,key)},"boost"=>module with{Boost=NativeJson.U32(state,key)},
            "damageOverride"=>module with{DamageOverride=NativeJson.U32(state,key)},
            "ammo.clip"=>module with{Ammo=new(NativeJson.U32(state,key),module.Ammo?.Hopper??0)},
            "ammo.hopper"=>module with{Ammo=new(module.Ammo?.Clip??0,NativeJson.U32(state,key))},
            _=>throw new NativeDataException(501,"Native save module field is not implemented: "+key)
        };
        NativeSlotSnapshot Replace(NativeSlotSnapshot value)=>ReferenceEquals(value,slot)?value with{Module=module}:value;
        return ship with{Modules=ship.Modules.Select(Replace).ToArray(),FixedModules=ship.FixedModules.Select(Replace).ToArray()};
    }

    internal static void Validate(NativeCommanderSave save)
    {
        if(save.UploadState is not{} upload)return;
        if(upload.OnboardingObjectives is{} objectives)_=Onboarding(objectives);
        if(upload.VesselExtras is null||upload.VesselExtras.Count>save.Snapshot.Vessels.Count)throw new ArgumentException("Invalid uploaded native runtime state.");
        foreach(var pair in upload.VesselExtras)
        {
            if(!save.Snapshot.Vessels.Any(ship=>ship.Id==pair.Key))throw new ArgumentException("Unowned uploaded native runtime state.");
            NativeJson.Fields(pair.Value,"landed","firegroupSelected","health.caustic","health.scorch","health.shielddamagetimer","fuel.superchargedFSD","purchaseData");
            foreach(var field in pair.Value.EnumerateObject())
                if(field.Name=="landed")_=NativeJson.Boolean(pair.Value,field.Name);
                else if(field.Name=="purchaseData")_=NativeJson.Text(pair.Value,field.Name,32_768);
                else if(field.Name=="health.shielddamagetimer")_=Quantity(pair.Value,field.Name,float.MaxValue);
                else _=NativeJson.U32(pair.Value,field.Name);
        }
    }

    internal static string Compose(string json,NativeCommanderSave save)
    {
        if(save.UploadState is not{} upload)return json;
        Validate(save);var root=JsonNode.Parse(json)!.AsObject();var commander=root["commander"]!.AsObject();
        commander["onFootDistanceTravelled"]=upload.OnFootDistanceTravelled;commander["nearplanet"]=upload.NearPlanet;
        if(upload.OnboardingObjectives is{} objectives)root["onboardingObjectives"]=JsonNode.Parse(objectives.GetRawText());
        if(upload.OnboardingObjectivesComplete is{} complete)root["onboardingObjectivesComplete"]=complete;
        foreach(var node in root["vessels"]!.AsArray())
        {
            var ship=node!.AsObject();if(!upload.VesselExtras.TryGetValue(ship["id"]!.GetValue<uint>(),out var fields))continue;
            foreach(var field in fields.EnumerateObject())
            {
                var target=ship;var path=field.Name=="landed"?new[]{"location","landed"}:field.Name.Split('.');
                for(var i=0;i<path.Length-1;i++)
                {if(target[path[i]] is null)target[path[i]]=new JsonObject();target=target[path[i]]!.AsObject();}
                target[path[^1]]=JsonNode.Parse(field.Value.GetRawText());
            }
        }
        return root.ToJsonString(Wire.Json);
    }

    private static JsonElement Onboarding(JsonElement node)
    {
        NativeJson.Fields(node,"objectives","extraData");var objectives=NativeJson.Field(node,"objectives");
        NativeJson.Fields(objectives,Objectives);foreach(var field in objectives.EnumerateObject())_=NativeJson.Boolean(objectives,field.Name);
        var data=NativeJson.Field(node,"extraData");NativeJson.Fields(data,"firstPurchasedSuit");
        if(data.TryGetProperty("firstPurchasedSuit",out _))_=NativeJson.Text(data,"firstPurchasedSuit",256);
        return node.Clone();
    }
    private static uint[] Indices(string text)
    {
        if(text.Length==0)return[];
        var values=text.Split(',');if(values.Length>1024)throw new NativeDataException(400,"Native firegroup exceeds its bound.");
        var result=new uint[values.Length];for(var i=0;i<result.Length;i++)
            if(!uint.TryParse(values[i].AsSpan().Trim(' '),NumberStyles.None,CultureInfo.InvariantCulture,out result[i])||result[i]>=1024)
                throw new NativeDataException(400,"Invalid native firegroup index.");
        if(result.Distinct().Count()!=result.Length)throw new NativeDataException(400,"Duplicate native firegroup index.");return result;
    }
    private static int I32(JsonElement node,string key)
    {var value=NativeJson.Field(node,key);return value.ValueKind==JsonValueKind.Number&&value.TryGetInt32(out var number)?number:throw new NativeDataException(400,"Invalid native signed distance.");}
    private static uint Million(JsonElement node,string key)
    {var value=NativeJson.U32(node,key);return value<=1_000_000?value:throw new NativeDataException(400,
        "Native normalized state '"+key+"'="+value.ToString(CultureInfo.InvariantCulture)+" exceeds 1000000.");}
    private static uint Byte(JsonElement node,string key)
    {var value=NativeJson.U32(node,key);return value<=255?value:throw new NativeDataException(400,"Native capacitor pips exceed the stored byte.");}
    private static float Quantity(JsonElement node,string key,float maximum)
    {
        var field=NativeJson.Field(node,key);
        return field.ValueKind==JsonValueKind.Number&&field.TryGetSingle(out var value)&&float.IsFinite(value)&&value>=0&&value<=maximum
            ?value:throw new NativeDataException(400,"Invalid native runtime quantity.");
    }
}