using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed class NativeOrderReconciliationRequiredException(JsonElement receipt):IOException("原始服务器请求未确认,正在恢复官方会话并核对原单。")
{
public JsonElement Receipt{get;}=receipt.Clone();
public static bool Present(Exception error)=>error is NativeOrderReconciliationRequiredException||error.InnerException is {} inner&&Present(inner);
}
/// <summary>Never resubmits an uncertain key. A fresh official client plus original server inventory must prove non-application.</summary>
public static class NativeOrderReconciliation
{
private static string Marker(string pending)=>pending.Replace(".pending.json",".not-applied-after-reload.json",StringComparison.Ordinal);
private static string Hash(byte[] bytes)=>Convert.ToHexStringLower(SHA256.HashData(bytes));
public static bool IsServerFailure(JsonElement value)=>value.TryGetProperty("state",out var state)&&state.GetInt32()==4
&&value.TryGetProperty("error",out var error)&&error.TryGetInt32(out var code)&&code is >=500 and <=599
&&value.TryGetProperty("mutationsSent",out var sent)&&sent.GetInt32()==1
&&value.TryGetProperty("serviceKind",out var kind)&&kind.GetInt32()==1
&&value.TryGetProperty("serverConfirmed",out var server)&&server.ValueKind==JsonValueKind.False
&&value.TryGetProperty("clientApplied",out var applied)&&applied.ValueKind==JsonValueKind.False;
public static bool IsResolved(string pending,PlayerState current)
{
var marker=Marker(pending);var status=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
if(!File.Exists(marker)||!File.Exists(status))return false;
using var proof=JsonDocument.Parse(File.ReadAllText(marker));var p=proof.RootElement;
return p.GetProperty("commanderId").GetString()==current.CommanderId&&p.GetProperty("shipId").GetInt64()==current.CurrentShipId
&&p.GetProperty("status").GetString()=="official-reload-proved-not-applied"
&&p.GetProperty("pendingSha256").GetString()==Hash(File.ReadAllBytes(pending))&&p.GetProperty("statusSha256").GetString()==Hash(File.ReadAllBytes(status));
}
public static async Task WaitForResolutionAsync(string pending,IOriginalCommerceChannel channel,Func<PlayerState> player,string? journals,Action<string>? progress,CancellationToken token)
{
var statusPath=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
if(!File.Exists(statusPath))return;
var originalStatus=await File.ReadAllBytesAsync(statusPath,token);using var status=JsonDocument.Parse(originalStatus);
if(!IsServerFailure(status.RootElement))return;
var bytes=await File.ReadAllBytesAsync(pending,token);using var original=JsonDocument.Parse(bytes);var row=original.RootElement;
if(row.GetProperty("channel").GetString()==channel.Identity)throw new NativeOrderReconciliationRequiredException(status.RootElement);
while(!IsResolved(pending,player()))
{
token.ThrowIfCancellationRequested();progress?.Invoke("正在只读核对原订单、服务器货舱和官方会话余额,不重复提交。");
try{if(await TryResolveAsync(pending,bytes,originalStatus,row,channel,player(),journals,token))return;}
catch(Exception error)when(NavigationRecovery.CanRetry(error,token)){}
await Task.Delay(1000,token);
}
}
private static async Task<bool> TryResolveAsync(string pending,byte[] originalBytes,byte[] originalStatus,JsonElement row,
IOriginalCommerceChannel channel,PlayerState player,string? journals,CancellationToken token)
{
var created=row.GetProperty("created").GetDateTimeOffset();var intent=row.GetProperty("intent");var before=intent.GetProperty("before");var market=intent.GetProperty("market").GetInt64();
var parts=channel.Identity.Split(':');var old=row.GetProperty("channel").GetString()!.Split(':');
if(parts.Length!=2||old.Length!=2||parts[0]==old[0]||!long.TryParse(parts[1],out var processStarted)||processStarted<=created.UtcTicks
||!player.InGame||!player.Docked||player.MarketId!=market||!player.CargoReliable||!player.CreditsReliable||player.LoadGameAt is null||player.LoadGameAt<=created
||player.CommanderId!=row.GetProperty("CommanderId").GetString()||player.CurrentShipId!=row.GetProperty("CurrentShipId").GetInt64()
||player.CargoCapacity!=before.GetProperty("capacity").GetInt32())return false;
var fees=journals is null?[]:await ExternalServiceCosts.ReadAsync(journals,player.CommanderId,player.CurrentShipId!.Value,created,[market],token);
var expected=before.GetProperty("credits").GetInt64()-fees.Sum(f=>f.Entry.Amount);
var current=await channel.CallAsync(new{op="commerce.inventory"},token);
if(!current.GetProperty("verified").GetBoolean()||!before.GetProperty("verified").GetBoolean()||current.GetProperty("credits").GetInt64()!=expected
||player.Credits!=expected||current.GetProperty("count").GetInt32()!=before.GetProperty("count").GetInt32()||player.CargoCount!=before.GetProperty("count").GetInt32())return false;
var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market,refreshClient=false},token);var generation=queued.GetProperty("generation").GetUInt64();
var actual=await NavigationRecovery.ReadWithRecoveryAsync(async cancellation=>
{
var result=await channel.CallAsync(new{op="commerce.market.status",generation,maximum=50},cancellation);
if(result.GetProperty("state").GetInt32()<3)throw new FlightTelemetryUnavailableException("等待原始服务器货舱快照。");
if(result.GetProperty("state").GetInt32()!=3)throw new FlightTelemetryUnavailableException("服务器只读货舱查询暂未成功。");return result;
},null,token);
if(!actual.TryGetProperty("cargo",out var cargo)||cargo.ValueKind!=JsonValueKind.Object)return false;
var commodity=CommodityCatalog.Key(intent.GetProperty("Commodity").GetString()!);long total=0,quantity=0;
foreach(var item in cargo.EnumerateObject())
{if(!item.Value.TryGetInt32(out var count)||count<0)return false;total+=count;if(CommodityCatalog.Key(item.Name)==commodity)quantity+=count;}
var delta=intent.GetProperty("delta").GetInt32();var wanted=delta<0?Math.Abs((long)delta):0;
if(total!=before.GetProperty("count").GetInt32()||quantity!=wanted||total!=wanted
||actual.TryGetProperty("serverCredits",out var serverCredits)&&serverCredits.GetInt64()!=expected)return false;
var proof=new{status="official-reload-proved-not-applied",commanderId=player.CommanderId,shipId=player.CurrentShipId,
pendingSha256=Hash(originalBytes),statusSha256=Hash(originalStatus),channel=channel.Identity,player.LoadGameAt,
actualClientInventory=current,originalServerCargo=cargo,knownExternalExpenses=fees,expectedCredits=expected,originalRequestReplayed=false,at=DateTimeOffset.UtcNow};
await File.WriteAllTextAsync(Marker(pending)+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);File.Move(Marker(pending)+".tmp",Marker(pending),true);return true;
}
}
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed class NativeOrderReconciliationRequiredException(JsonElement receipt):IOException("原始服务器请求未确认,正在恢复官方会话并核对原单。")
{
public JsonElement Receipt{get;}=receipt.Clone();
public static bool Present(Exception error)=>error is NativeOrderReconciliationRequiredException||error.InnerException is {} inner&&Present(inner);
}
/// <summary>Never resubmits an uncertain key. A fresh official client plus original server inventory must prove non-application.</summary>
public static class NativeOrderReconciliation
{
private static string Marker(string pending)=>pending.Replace(".pending.json",".not-applied-after-reload.json",StringComparison.Ordinal);
private static string Hash(byte[] bytes)=>Convert.ToHexStringLower(SHA256.HashData(bytes));
public static bool IsServerFailure(JsonElement value)=>value.TryGetProperty("state",out var state)&&state.GetInt32()==4
&&value.TryGetProperty("error",out var error)&&error.TryGetInt32(out var code)&&code is >=500 and <=599
&&value.TryGetProperty("mutationsSent",out var sent)&&sent.GetInt32()==1
&&value.TryGetProperty("serviceKind",out var kind)&&kind.GetInt32()==1
&&value.TryGetProperty("serverConfirmed",out var server)&&server.ValueKind==JsonValueKind.False
&&value.TryGetProperty("clientApplied",out var applied)&&applied.ValueKind==JsonValueKind.False;
public static bool IsResolved(string pending,PlayerState current)
{
var marker=Marker(pending);var status=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
if(!File.Exists(marker)||!File.Exists(status))return false;
using var proof=JsonDocument.Parse(File.ReadAllText(marker));var p=proof.RootElement;
return p.GetProperty("commanderId").GetString()==current.CommanderId&&p.GetProperty("shipId").GetInt64()==current.CurrentShipId
&&p.GetProperty("status").GetString()=="official-reload-proved-not-applied"
&&p.GetProperty("pendingSha256").GetString()==Hash(File.ReadAllBytes(pending))&&p.GetProperty("statusSha256").GetString()==Hash(File.ReadAllBytes(status));
}
public static async Task WaitForResolutionAsync(string pending,IOriginalCommerceChannel channel,Func<PlayerState> player,string? journals,Action<string>? progress,CancellationToken token)
{
var statusPath=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
if(!File.Exists(statusPath))return;
var originalStatus=await File.ReadAllBytesAsync(statusPath,token);using var status=JsonDocument.Parse(originalStatus);
if(!IsServerFailure(status.RootElement))return;
var bytes=await File.ReadAllBytesAsync(pending,token);using var original=JsonDocument.Parse(bytes);var row=original.RootElement;
if(row.GetProperty("channel").GetString()==channel.Identity)throw new NativeOrderReconciliationRequiredException(status.RootElement);
while(!IsResolved(pending,player()))
{
token.ThrowIfCancellationRequested();progress?.Invoke("正在只读核对原订单、服务器货舱和官方会话余额,不重复提交。");
try{if(await TryResolveAsync(pending,bytes,originalStatus,row,channel,player(),journals,token))return;}
catch(Exception error)when(NavigationRecovery.CanRetry(error,token)){}
await Task.Delay(1000,token);
}
}
private static async Task<bool> TryResolveAsync(string pending,byte[] originalBytes,byte[] originalStatus,JsonElement row,
IOriginalCommerceChannel channel,PlayerState player,string? journals,CancellationToken token)
{
var created=row.GetProperty("created").GetDateTimeOffset();var intent=row.GetProperty("intent");var before=intent.GetProperty("before");var market=intent.GetProperty("market").GetInt64();
var parts=channel.Identity.Split(':');var old=row.GetProperty("channel").GetString()!.Split(':');
if(parts.Length!=2||old.Length!=2||parts[0]==old[0]||!long.TryParse(parts[1],out var processStarted)||processStarted<=created.UtcTicks
||!player.InGame||!player.Docked||player.MarketId!=market||!player.CargoReliable||!player.CreditsReliable||player.LoadGameAt is null||player.LoadGameAt<=created
||player.CommanderId!=row.GetProperty("CommanderId").GetString()||player.CurrentShipId!=row.GetProperty("CurrentShipId").GetInt64()
||player.CargoCapacity!=before.GetProperty("capacity").GetInt32())return false;
var fees=journals is null?[]:await ExternalServiceCosts.ReadAsync(journals,player.CommanderId,player.CurrentShipId!.Value,created,[market],token);
var expected=before.GetProperty("credits").GetInt64()-fees.Sum(f=>f.Entry.Amount);
var current=await channel.CallAsync(new{op="commerce.inventory"},token);
if(!current.GetProperty("verified").GetBoolean()||!before.GetProperty("verified").GetBoolean()||current.GetProperty("credits").GetInt64()!=expected
||player.Credits!=expected||current.GetProperty("count").GetInt32()!=before.GetProperty("count").GetInt32()||player.CargoCount!=before.GetProperty("count").GetInt32())return false;
var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market,refreshClient=false},token);var generation=queued.GetProperty("generation").GetUInt64();
var actual=await NavigationRecovery.ReadWithRecoveryAsync(async cancellation=>
{
var result=await channel.CallAsync(new{op="commerce.market.status",generation,maximum=50},cancellation);
if(result.GetProperty("state").GetInt32()<3)throw new FlightTelemetryUnavailableException("等待原始服务器货舱快照。");
if(result.GetProperty("state").GetInt32()!=3)throw new FlightTelemetryUnavailableException("服务器只读货舱查询暂未成功。");return result;
},null,token);
if(!actual.TryGetProperty("cargo",out var cargo)||cargo.ValueKind!=JsonValueKind.Object)return false;
var commodity=CommodityCatalog.Key(intent.GetProperty("Commodity").GetString()!);long total=0,quantity=0;
foreach(var item in cargo.EnumerateObject())
{if(!item.Value.TryGetInt32(out var count)||count<0)return false;total+=count;if(CommodityCatalog.Key(item.Name)==commodity)quantity+=count;}
var delta=intent.GetProperty("delta").GetInt32();var wanted=delta<0?Math.Abs((long)delta):0;
if(total!=before.GetProperty("count").GetInt32()||quantity!=wanted||total!=wanted
||actual.TryGetProperty("serverCredits",out var serverCredits)&&serverCredits.GetInt64()!=expected)return false;
var proof=new{status="official-reload-proved-not-applied",commanderId=player.CommanderId,shipId=player.CurrentShipId,
pendingSha256=Hash(originalBytes),statusSha256=Hash(originalStatus),channel=channel.Identity,player.LoadGameAt,
actualClientInventory=current,originalServerCargo=cargo,knownExternalExpenses=fees,expectedCredits=expected,originalRequestReplayed=false,at=DateTimeOffset.UtcNow};
await File.WriteAllTextAsync(Marker(pending)+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);File.Move(Marker(pending)+".tmp",Marker(pending),true);return true;
}
}