using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Data;
/// <summary>Deterministic journal projection. No network, game memory or account credentials.</summary>
public sealed class PlayerProjector
{
private readonly FleetProjector fleet = new();
private readonly CommanderProfileProjector profile=new();
private readonly XFEEDToolBox.Core.Navigation.NavigationProjector navigation=new();
private readonly XFEEDToolBox.Core.Engineering.EngineerProjector engineers = new();
public PlayerState State { get; private set; } = new();
private DateTimeOffset cargoAt;
private DateTimeOffset materialAt;
private DateTimeOffset lockerAt;
private DateTimeOffset backpackAt;
public void ConfirmStatusRead(DateTimeOffset eventAt,DateTimeOffset observedAt,DateTimeOffset? writtenAt=null)
{
if(State.Status is not null&&State.StatusAt==eventAt)
State=State with{StatusReadAt=observedAt,StatusWrittenAt=writtenAt};
}
public void Apply(JsonElement e)
{
var name = e.Text("event");
var time = e.Time() ?? State.UpdatedAt;
var s = State;
switch (name)
{
case "Fileheader": s = s with { GameVersion = e.Text("gameversion"), GameBuild = e.Text("build") }; break;
case "LoadGame":
var fid = e.Text("FID");
if (s.CommanderId != fid)
{
s = new() { GameVersion = s.GameVersion, GameBuild = s.GameBuild }; cargoAt = materialAt = lockerAt = backpackAt = default; fleet.Reset(); engineers.Reset(); navigation.Reset();profile.Reset();
}
s = s with
{
Commander = e.Text("Commander", s.Commander), CommanderId = fid,
Credits = e.Number("Credits") is { } c ? (decimal)c : s.Credits,
CreditsReliable = e.Get("Credits").ValueKind == JsonValueKind.Number,
Ship = e.Text("Ship_Localised", e.Text("Ship")), InGame = true, CreditsAt = time,
GameMode = e.Text("GameMode"), GroupName = e.Text("Group"), LoadGameAt = time,
StationServices=[],StationServicesKnown=false
};
break;
case "Loadout":
var shipName = e.Text("ShipName").Trim();
s = s with
{
Ship = shipName.Length > 0 ? shipName : e.Text("Ship"),
CargoCapacity = (int)e.Long("CargoCapacity", s.CargoCapacity), MaxJumpRange = e.Number("MaxJumpRange") ?? s.MaxJumpRange,
Rebuy = (decimal)(e.Number("Rebuy") ?? (double)s.Rebuy),
AdvancedDocking = e.Get("Modules").Items().Any(m => m.Text("Item").Equals("int_dockingcomputer_advanced", StringComparison.OrdinalIgnoreCase) && m.Flag("On")),
SupercruiseAssist = e.Get("Modules").Items().Any(m => m.Text("Item").Equals("int_supercruiseassist", StringComparison.OrdinalIgnoreCase) && m.Flag("On"))
};
break;
case "Location":
case "FSDJump":
case "CarrierJump":
s = s with { SystemName = e.Text("StarSystem", s.SystemName), Position = e.Position() ?? s.Position, Docked = e.Flag("Docked"), StationName = e.Text("StationName"), MarketId = e.Long("MarketID") is > 0 and var m ? m : null, LocationAt = time,
StationServices=e.Flag("Docked")?ReadStationServices(e):[],StationServicesKnown=e.Flag("Docked")&&e.Get("StationServices").ValueKind==JsonValueKind.Array };
break;
case "Docked":
s = s with { Docked = true, SystemName = e.Text("StarSystem", s.SystemName), StationName = e.Text("StationName"), MarketId = e.Long("MarketID"), LocationAt = time,
StationServices=ReadStationServices(e),StationServicesKnown=e.Get("StationServices").ValueKind==JsonValueKind.Array };
break;
case "Undocked": s = s with { Docked = false, StationName = "", MarketId = null, LocationAt = time,StationServices=[],StationServicesKnown=false }; break;
case "Cargo" when time >= cargoAt && e.Text("Vessel", "Ship") == "Ship":
if (e.Get("Inventory").ValueKind == JsonValueKind.Array)
{
var inventory = ReadInventory(e.Get("Inventory"), "货舱");
s = s with { Inventory = inventory, CargoCount = (int)e.Long("Count", inventory.Sum(x => x.Count)), CargoReliable = true };
}
else s = s with { CargoCount = (int)e.Long("Count", s.CargoCount), CargoReliable = e.Get("Count").ValueKind == JsonValueKind.Number && s.CargoReliable && s.Inventory.Sum(x => x.Count) == e.Long("Count") };
cargoAt = time;
s = s with { CargoAt = time };
break;
case "MarketBuy":
case "MarketSell":
var buy = name == "MarketBuy";
if (time >= cargoAt)
{
var count = (int)e.Long("Count");
s = ChangeCargo(s, e.Text("Type"), e.Text("Type_Localised", e.Text("Type")), buy ? count : -count);
cargoAt = time;
s = s with { CargoAt = time };
}
var total = e.Number(buy ? "TotalCost" : "TotalSale");
s = total is { } t ? Delta(s, buy ? -(decimal)t : (decimal)t) : s with { CreditsReliable = false };
break;
case "CollectCargo":
case "EjectCargo":
if (time >= cargoAt)
{
s = ChangeCargo(s, e.Text("Type"), e.Text("Type_Localised", e.Text("Type")), name == "CollectCargo" ? 1 : -(int)e.Long("Count", 1));
cargoAt = time;
s = s with { CargoAt = time };
}
break;
case "Materials" when time >= materialAt:
s = s with { Materials = new[] { "Raw", "Manufactured", "Encoded" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), MaterialsAt = time, MaterialsReliable = true };
materialAt = time;
break;
case "MaterialCollected" or "MaterialDiscarded" when time >= materialAt:
s = s with { Materials = ChangeInventory(s.Materials, e.Text("Name"), e.Text("Name_Localised", e.Text("Name")), (int)e.Long("Count") * (name == "MaterialCollected" ? 1 : -1), e.Text("Category")), MaterialsAt = time };
materialAt = time;
break;
case "MaterialTrade" when time >= materialAt:
foreach (var (field, direction) in new[] { ("Paid", -1), ("Received", 1) })
{
var item = e.Get(field);
if (item.Text("Material").Length == 0 || item.Get("Quantity").ValueKind != JsonValueKind.Number) { s = s with { MaterialsReliable = false }; continue; }
s = s with { Materials = ChangeInventory(s.Materials, item.Text("Material"), item.Text("Material_Localised", item.Text("Material")), (int)item.Long("Quantity") * direction, item.Text("Category")) };
}
materialAt = time; s = s with { MaterialsAt = time };
break;
case "EngineerCraft" when time >= materialAt:
case "Synthesis" when time >= materialAt:
var ingredients = e.Get(name == "EngineerCraft" ? "Ingredients" : "Materials");
if (ingredients.ValueKind != JsonValueKind.Array) s = s with { MaterialsReliable = false };
foreach (var item in ingredients.Items()) s = s with { Materials = ChangeInventory(s.Materials, item.Text("Name"), item.Text("Name"), -(int)item.Long("Count"), "") };
materialAt = time; s = s with { MaterialsAt = time };
break;
case "ShipLocker" when time >= lockerAt:
if (e.Get("Items").ValueKind == JsonValueKind.Array)
s = s with { Locker = new[] { "Items", "Components", "Consumables", "Data" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), LockerReliable = true };
else s = s with { LockerReliable = false };
lockerAt = time;
s = s with { LockerAt = time };
break;
case "Backpack" when time >= backpackAt:
if (e.Get("Items").ValueKind == JsonValueKind.Array)
s = s with { Backpack = new[] { "Items", "Components", "Consumables", "Data" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), BackpackReliable = true };
else s = s with { BackpackReliable = false };
backpackAt = time; s = s with { BackpackAt = time };
break;
case "Status" when s.StatusAt is null || time >= s.StatusAt:
var flags = e.Long("Flags");
if(e.Number("Balance") is >=0 and var balance)s=s with{Credits=(decimal)balance,CreditsReliable=true,CreditsAt=time};
s = s with { InDanger = (flags & ((1L << 22) | (1L << 23) | (1L << 20))) != 0, StatusAt = time, StatusReadAt=null,StatusWrittenAt=null,
Status = new(flags, e.Long("Flags2"), e.Get("Fuel").Number("FuelMain"), e.Get("Fuel").Number("FuelReservoir"), e.Number("Cargo"), e.Number("Latitude"), e.Number("Longitude"), e.Number("Altitude"), e.Number("Heading"), e.Get("Pips").Items().Where(x => x.ValueKind == JsonValueKind.Number).Select(x => x.GetInt32()).ToArray()) };
break;
case "UnderAttack":
case "Interdicted": s = s with { InDanger = true }; break;
case "Died": s = s with { InDanger = true, InGame = false }; break;
case "Resurrect":
// Rebuy resumes the same client without another LoadGame.
// Location/loadout/status must republish the new ship before
// any guarded input; the previous wallet is no longer reliable.
s = s with { InGame = true, InDanger = false, LoadGameAt = time, Docked = false,
StationName = "", MarketId = null, StationServices = [], StationServicesKnown = false,
CreditsReliable = false, CargoReliable = false, StatusReadAt = null };
break;
case "Shutdown": s = s with { InGame = false }; break;
case "RefuelAll":
case "RefuelPartial":
case "Repair":
case "RepairAll":
case "RestockVehicle":
case "BuyAmmo":
case "BuyDrones":
case "PayFines":
case "PayBounties":
case "FetchRemoteModule":
case "ShipyardTransfer":
var expense = e.Number("Cost") ?? e.Number("BuyPrice") ?? e.Number("TransferPrice");
s = expense is { } cost ? Delta(s, -(decimal)cost) : s with { CreditsReliable = false }; break;
case "ModuleBuy":
s = e.Number("BuyPrice") is { } modulePrice ? Delta(s, (decimal)((e.Number("SellPrice") ?? 0) - modulePrice)) : s with { CreditsReliable = false }; break;
case "ShipyardBuy":
s = e.Number("ShipPrice") is { } shipPrice ? Delta(s, (decimal)((e.Number("SellPrice") ?? 0) - shipPrice)) : s with { CreditsReliable = false }; break;
case "MissionCompleted":
s = Delta(s, (decimal)(e.Number("Reward") ?? 0));
if (time >= materialAt && e.Get("MaterialsReward").ValueKind == JsonValueKind.Array)
{
foreach (var item in e.Get("MaterialsReward").Items()) s = s with { Materials = ChangeInventory(s.Materials, item.Text("Name"), item.Text("Name_Localised", item.Text("Name")), (int)item.Long("Count"), item.Text("Category")) };
materialAt = time; s = s with { MaterialsAt = time };
}
break;
case "RedeemVoucher": s = Delta(s, (decimal)(e.Number("Amount") ?? 0)); break;
case "ModuleSell": s = Delta(s, (decimal)(e.Number("SellPrice") ?? 0)); break;
case "SellDrones": s = Delta(s, (decimal)(e.Number("TotalSale") ?? 0)); break;
case "SellExplorationData":
case "MultiSellExplorationData": s = Delta(s, (decimal)(e.Number("TotalEarnings") ?? 0)); break;
// Transactions with complex pricing cannot safely be inferred from one field.
case "ShipyardSell":
case "ShipyardSwap":
case "CarrierBankTransfer":
case "BuyMicroResources":
case "SellMicroResources":
case "TechnologyBroker": s = s with { CreditsReliable = false }; break;
}
if (s.Credits != State.Credits) s = s with { CreditsAt = time };
if (name == "EngineerContribution" && e.Text("Type").Equals("materials", StringComparison.OrdinalIgnoreCase) && time >= materialAt)
{
if (e.Text("Material").Length > 0 && e.Number("Quantity") is { } amount) { s = s with { Materials = ChangeInventory(s.Materials, e.Text("Material"), e.Text("Material"), -(int)amount, ""), MaterialsAt = time }; materialAt = time; }
else s = s with { MaterialsReliable = false };
}
if (name == "TechnologyBroker") s = s with { MaterialsReliable = false };
if (name is "TransferMicroResources" or "BuyMicroResources" or "SellMicroResources" or "TradeMicroResources" or "UseConsumable" or "CollectItems" or "DropItems") s = s with { LockerReliable = false, BackpackReliable = false };
navigation.Apply(e);
profile.Apply(e);
engineers.Apply(e, fleet.Ships, fleet.CurrentShipId, s.MarketId);
fleet.Apply(e, s.SystemName);
State = s with { UpdatedAt = time > s.UpdatedAt ? time : s.UpdatedAt, Ships = fleet.Ships, CurrentShipId = fleet.CurrentShipId, StoredModules = fleet.StoredModules, FleetAt = fleet.FleetAt, StoredModulesAt = fleet.StoredModulesAt,
Engineers = engineers.Engineers, EngineersAt = engineers.UpdatedAt, VerifiedEngineeringRolls = engineers.VerifiedQualityRolls, MismatchedEngineeringRolls = engineers.MismatchedQualityRolls, Navigation=navigation.State,Profile=profile.State };
}
private static PlayerState Delta(PlayerState s, decimal delta) => s with { Credits = s.Credits is { } c ? Math.Max(0, c + delta) : null };
private static IReadOnlyList<string> ReadStationServices(JsonElement e)=>e.Get("StationServices").Items()
.Where(x=>x.ValueKind==JsonValueKind.String).Select(x=>x.GetString()!.Trim().ToLowerInvariant()).Where(x=>x.Length>0).Distinct().ToArray();
private static PlayerState ChangeCargo(PlayerState s, string key, string label, int delta) => s with
{
Inventory = ChangeInventory(s.Inventory, key, label, delta, "货舱"), CargoCount = Math.Max(0, s.CargoCount + delta)
};
private static IReadOnlyList<InventoryItem> ChangeInventory(IReadOnlyList<InventoryItem> list, string key, string name, int delta, string group)
{
key = JsonFields.CommodityKey(key);
var result = list.ToList();
var at = result.FindIndex(x => x.Key == key && x.MissionId is null && x.Stolen == 0);
var count = Math.Max(0, (at >= 0 ? result[at].Count : 0) + delta);
if (at >= 0) result.RemoveAt(at);
if (count > 0) result.Add(new(key, at >= 0 ? list[at].Name : name, count, Group: group.Length > 0 ? group : at >= 0 ? list[at].Group : "未知分类"));
return result;
}
private static InventoryItem[] ReadInventory(JsonElement array, string group) => array.Items().Select(x =>
new InventoryItem(JsonFields.CommodityKey(x.Text("Name")), x.Text("Name_Localised", x.Text("Name")), (int)x.Long("Count"), (int)x.Long("Stolen"), group, x.Long("MissionID") is > 0 and var id ? id : null)).ToArray();
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Data;
/// <summary>Deterministic journal projection. No network, game memory or account credentials.</summary>
public sealed class PlayerProjector
{
private readonly FleetProjector fleet = new();
private readonly CommanderProfileProjector profile=new();
private readonly XFEEDToolBox.Core.Navigation.NavigationProjector navigation=new();
private readonly XFEEDToolBox.Core.Engineering.EngineerProjector engineers = new();
public PlayerState State { get; private set; } = new();
private DateTimeOffset cargoAt;
private DateTimeOffset materialAt;
private DateTimeOffset lockerAt;
private DateTimeOffset backpackAt;
public void ConfirmStatusRead(DateTimeOffset eventAt,DateTimeOffset observedAt,DateTimeOffset? writtenAt=null)
{
if(State.Status is not null&&State.StatusAt==eventAt)
State=State with{StatusReadAt=observedAt,StatusWrittenAt=writtenAt};
}
public void Apply(JsonElement e)
{
var name = e.Text("event");
var time = e.Time() ?? State.UpdatedAt;
var s = State;
switch (name)
{
case "Fileheader": s = s with { GameVersion = e.Text("gameversion"), GameBuild = e.Text("build") }; break;
case "LoadGame":
var fid = e.Text("FID");
if (s.CommanderId != fid)
{
s = new() { GameVersion = s.GameVersion, GameBuild = s.GameBuild }; cargoAt = materialAt = lockerAt = backpackAt = default; fleet.Reset(); engineers.Reset(); navigation.Reset();profile.Reset();
}
s = s with
{
Commander = e.Text("Commander", s.Commander), CommanderId = fid,
Credits = e.Number("Credits") is { } c ? (decimal)c : s.Credits,
CreditsReliable = e.Get("Credits").ValueKind == JsonValueKind.Number,
Ship = e.Text("Ship_Localised", e.Text("Ship")), InGame = true, CreditsAt = time,
GameMode = e.Text("GameMode"), GroupName = e.Text("Group"), LoadGameAt = time,
StationServices=[],StationServicesKnown=false
};
break;
case "Loadout":
var shipName = e.Text("ShipName").Trim();
s = s with
{
Ship = shipName.Length > 0 ? shipName : e.Text("Ship"),
CargoCapacity = (int)e.Long("CargoCapacity", s.CargoCapacity), MaxJumpRange = e.Number("MaxJumpRange") ?? s.MaxJumpRange,
Rebuy = (decimal)(e.Number("Rebuy") ?? (double)s.Rebuy),
AdvancedDocking = e.Get("Modules").Items().Any(m => m.Text("Item").Equals("int_dockingcomputer_advanced", StringComparison.OrdinalIgnoreCase) && m.Flag("On")),
SupercruiseAssist = e.Get("Modules").Items().Any(m => m.Text("Item").Equals("int_supercruiseassist", StringComparison.OrdinalIgnoreCase) && m.Flag("On"))
};
break;
case "Location":
case "FSDJump":
case "CarrierJump":
s = s with { SystemName = e.Text("StarSystem", s.SystemName), Position = e.Position() ?? s.Position, Docked = e.Flag("Docked"), StationName = e.Text("StationName"), MarketId = e.Long("MarketID") is > 0 and var m ? m : null, LocationAt = time,
StationServices=e.Flag("Docked")?ReadStationServices(e):[],StationServicesKnown=e.Flag("Docked")&&e.Get("StationServices").ValueKind==JsonValueKind.Array };
break;
case "Docked":
s = s with { Docked = true, SystemName = e.Text("StarSystem", s.SystemName), StationName = e.Text("StationName"), MarketId = e.Long("MarketID"), LocationAt = time,
StationServices=ReadStationServices(e),StationServicesKnown=e.Get("StationServices").ValueKind==JsonValueKind.Array };
break;
case "Undocked": s = s with { Docked = false, StationName = "", MarketId = null, LocationAt = time,StationServices=[],StationServicesKnown=false }; break;
case "Cargo" when time >= cargoAt && e.Text("Vessel", "Ship") == "Ship":
if (e.Get("Inventory").ValueKind == JsonValueKind.Array)
{
var inventory = ReadInventory(e.Get("Inventory"), "货舱");
s = s with { Inventory = inventory, CargoCount = (int)e.Long("Count", inventory.Sum(x => x.Count)), CargoReliable = true };
}
else s = s with { CargoCount = (int)e.Long("Count", s.CargoCount), CargoReliable = e.Get("Count").ValueKind == JsonValueKind.Number && s.CargoReliable && s.Inventory.Sum(x => x.Count) == e.Long("Count") };
cargoAt = time;
s = s with { CargoAt = time };
break;
case "MarketBuy":
case "MarketSell":
var buy = name == "MarketBuy";
if (time >= cargoAt)
{
var count = (int)e.Long("Count");
s = ChangeCargo(s, e.Text("Type"), e.Text("Type_Localised", e.Text("Type")), buy ? count : -count);
cargoAt = time;
s = s with { CargoAt = time };
}
var total = e.Number(buy ? "TotalCost" : "TotalSale");
s = total is { } t ? Delta(s, buy ? -(decimal)t : (decimal)t) : s with { CreditsReliable = false };
break;
case "CollectCargo":
case "EjectCargo":
if (time >= cargoAt)
{
s = ChangeCargo(s, e.Text("Type"), e.Text("Type_Localised", e.Text("Type")), name == "CollectCargo" ? 1 : -(int)e.Long("Count", 1));
cargoAt = time;
s = s with { CargoAt = time };
}
break;
case "Materials" when time >= materialAt:
s = s with { Materials = new[] { "Raw", "Manufactured", "Encoded" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), MaterialsAt = time, MaterialsReliable = true };
materialAt = time;
break;
case "MaterialCollected" or "MaterialDiscarded" when time >= materialAt:
s = s with { Materials = ChangeInventory(s.Materials, e.Text("Name"), e.Text("Name_Localised", e.Text("Name")), (int)e.Long("Count") * (name == "MaterialCollected" ? 1 : -1), e.Text("Category")), MaterialsAt = time };
materialAt = time;
break;
case "MaterialTrade" when time >= materialAt:
foreach (var (field, direction) in new[] { ("Paid", -1), ("Received", 1) })
{
var item = e.Get(field);
if (item.Text("Material").Length == 0 || item.Get("Quantity").ValueKind != JsonValueKind.Number) { s = s with { MaterialsReliable = false }; continue; }
s = s with { Materials = ChangeInventory(s.Materials, item.Text("Material"), item.Text("Material_Localised", item.Text("Material")), (int)item.Long("Quantity") * direction, item.Text("Category")) };
}
materialAt = time; s = s with { MaterialsAt = time };
break;
case "EngineerCraft" when time >= materialAt:
case "Synthesis" when time >= materialAt:
var ingredients = e.Get(name == "EngineerCraft" ? "Ingredients" : "Materials");
if (ingredients.ValueKind != JsonValueKind.Array) s = s with { MaterialsReliable = false };
foreach (var item in ingredients.Items()) s = s with { Materials = ChangeInventory(s.Materials, item.Text("Name"), item.Text("Name"), -(int)item.Long("Count"), "") };
materialAt = time; s = s with { MaterialsAt = time };
break;
case "ShipLocker" when time >= lockerAt:
if (e.Get("Items").ValueKind == JsonValueKind.Array)
s = s with { Locker = new[] { "Items", "Components", "Consumables", "Data" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), LockerReliable = true };
else s = s with { LockerReliable = false };
lockerAt = time;
s = s with { LockerAt = time };
break;
case "Backpack" when time >= backpackAt:
if (e.Get("Items").ValueKind == JsonValueKind.Array)
s = s with { Backpack = new[] { "Items", "Components", "Consumables", "Data" }.SelectMany(k => ReadInventory(e.Get(k), k)).ToArray(), BackpackReliable = true };
else s = s with { BackpackReliable = false };
backpackAt = time; s = s with { BackpackAt = time };
break;
case "Status" when s.StatusAt is null || time >= s.StatusAt:
var flags = e.Long("Flags");
if(e.Number("Balance") is >=0 and var balance)s=s with{Credits=(decimal)balance,CreditsReliable=true,CreditsAt=time};
s = s with { InDanger = (flags & ((1L << 22) | (1L << 23) | (1L << 20))) != 0, StatusAt = time, StatusReadAt=null,StatusWrittenAt=null,
Status = new(flags, e.Long("Flags2"), e.Get("Fuel").Number("FuelMain"), e.Get("Fuel").Number("FuelReservoir"), e.Number("Cargo"), e.Number("Latitude"), e.Number("Longitude"), e.Number("Altitude"), e.Number("Heading"), e.Get("Pips").Items().Where(x => x.ValueKind == JsonValueKind.Number).Select(x => x.GetInt32()).ToArray()) };
break;
case "UnderAttack":
case "Interdicted": s = s with { InDanger = true }; break;
case "Died": s = s with { InDanger = true, InGame = false }; break;
case "Resurrect":
// Rebuy resumes the same client without another LoadGame.
// Location/loadout/status must republish the new ship before
// any guarded input; the previous wallet is no longer reliable.
s = s with { InGame = true, InDanger = false, LoadGameAt = time, Docked = false,
StationName = "", MarketId = null, StationServices = [], StationServicesKnown = false,
CreditsReliable = false, CargoReliable = false, StatusReadAt = null };
break;
case "Shutdown": s = s with { InGame = false }; break;
case "RefuelAll":
case "RefuelPartial":
case "Repair":
case "RepairAll":
case "RestockVehicle":
case "BuyAmmo":
case "BuyDrones":
case "PayFines":
case "PayBounties":
case "FetchRemoteModule":
case "ShipyardTransfer":
var expense = e.Number("Cost") ?? e.Number("BuyPrice") ?? e.Number("TransferPrice");
s = expense is { } cost ? Delta(s, -(decimal)cost) : s with { CreditsReliable = false }; break;
case "ModuleBuy":
s = e.Number("BuyPrice") is { } modulePrice ? Delta(s, (decimal)((e.Number("SellPrice") ?? 0) - modulePrice)) : s with { CreditsReliable = false }; break;
case "ShipyardBuy":
s = e.Number("ShipPrice") is { } shipPrice ? Delta(s, (decimal)((e.Number("SellPrice") ?? 0) - shipPrice)) : s with { CreditsReliable = false }; break;
case "MissionCompleted":
s = Delta(s, (decimal)(e.Number("Reward") ?? 0));
if (time >= materialAt && e.Get("MaterialsReward").ValueKind == JsonValueKind.Array)
{
foreach (var item in e.Get("MaterialsReward").Items()) s = s with { Materials = ChangeInventory(s.Materials, item.Text("Name"), item.Text("Name_Localised", item.Text("Name")), (int)item.Long("Count"), item.Text("Category")) };
materialAt = time; s = s with { MaterialsAt = time };
}
break;
case "RedeemVoucher": s = Delta(s, (decimal)(e.Number("Amount") ?? 0)); break;
case "ModuleSell": s = Delta(s, (decimal)(e.Number("SellPrice") ?? 0)); break;
case "SellDrones": s = Delta(s, (decimal)(e.Number("TotalSale") ?? 0)); break;
case "SellExplorationData":
case "MultiSellExplorationData": s = Delta(s, (decimal)(e.Number("TotalEarnings") ?? 0)); break;
// Transactions with complex pricing cannot safely be inferred from one field.
case "ShipyardSell":
case "ShipyardSwap":
case "CarrierBankTransfer":
case "BuyMicroResources":
case "SellMicroResources":
case "TechnologyBroker": s = s with { CreditsReliable = false }; break;
}
if (s.Credits != State.Credits) s = s with { CreditsAt = time };
if (name == "EngineerContribution" && e.Text("Type").Equals("materials", StringComparison.OrdinalIgnoreCase) && time >= materialAt)
{
if (e.Text("Material").Length > 0 && e.Number("Quantity") is { } amount) { s = s with { Materials = ChangeInventory(s.Materials, e.Text("Material"), e.Text("Material"), -(int)amount, ""), MaterialsAt = time }; materialAt = time; }
else s = s with { MaterialsReliable = false };
}
if (name == "TechnologyBroker") s = s with { MaterialsReliable = false };
if (name is "TransferMicroResources" or "BuyMicroResources" or "SellMicroResources" or "TradeMicroResources" or "UseConsumable" or "CollectItems" or "DropItems") s = s with { LockerReliable = false, BackpackReliable = false };
navigation.Apply(e);
profile.Apply(e);
engineers.Apply(e, fleet.Ships, fleet.CurrentShipId, s.MarketId);
fleet.Apply(e, s.SystemName);
State = s with { UpdatedAt = time > s.UpdatedAt ? time : s.UpdatedAt, Ships = fleet.Ships, CurrentShipId = fleet.CurrentShipId, StoredModules = fleet.StoredModules, FleetAt = fleet.FleetAt, StoredModulesAt = fleet.StoredModulesAt,
Engineers = engineers.Engineers, EngineersAt = engineers.UpdatedAt, VerifiedEngineeringRolls = engineers.VerifiedQualityRolls, MismatchedEngineeringRolls = engineers.MismatchedQualityRolls, Navigation=navigation.State,Profile=profile.State };
}
private static PlayerState Delta(PlayerState s, decimal delta) => s with { Credits = s.Credits is { } c ? Math.Max(0, c + delta) : null };
private static IReadOnlyList<string> ReadStationServices(JsonElement e)=>e.Get("StationServices").Items()
.Where(x=>x.ValueKind==JsonValueKind.String).Select(x=>x.GetString()!.Trim().ToLowerInvariant()).Where(x=>x.Length>0).Distinct().ToArray();
private static PlayerState ChangeCargo(PlayerState s, string key, string label, int delta) => s with
{
Inventory = ChangeInventory(s.Inventory, key, label, delta, "货舱"), CargoCount = Math.Max(0, s.CargoCount + delta)
};
private static IReadOnlyList<InventoryItem> ChangeInventory(IReadOnlyList<InventoryItem> list, string key, string name, int delta, string group)
{
key = JsonFields.CommodityKey(key);
var result = list.ToList();
var at = result.FindIndex(x => x.Key == key && x.MissionId is null && x.Stolen == 0);
var count = Math.Max(0, (at >= 0 ? result[at].Count : 0) + delta);
if (at >= 0) result.RemoveAt(at);
if (count > 0) result.Add(new(key, at >= 0 ? list[at].Name : name, count, Group: group.Length > 0 ? group : at >= 0 ? list[at].Group : "未知分类"));
return result;
}
private static InventoryItem[] ReadInventory(JsonElement array, string group) => array.Items().Select(x =>
new InventoryItem(JsonFields.CommodityKey(x.Text("Name")), x.Text("Name_Localised", x.Text("Name")), (int)x.Long("Count"), (int)x.Long("Stolen"), group, x.Long("MissionID") is > 0 and var id ? id : null)).ToArray();
}