using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>Completes accounting after an optional post-sale market read failed; original receipts and failed results remain immutable.</summary>
public static class HistoricalTradeCompletion
{
public const string FileName="confirmed-sale-completion.json";
public sealed record Candidate(NativeTradeJourneyResult Original,string ResultHash,string CheckpointHash,string LegHash,string LedgerHash);
private sealed record Proof(int Schema,string Commander,long ShipId,long MarketId,DateTimeOffset VerifiedAt,long HistoricalClosingCredits,long CurrentObservedCredits,
string ResultHash,string CheckpointHash,string LegHash,string LedgerHash,bool CurrentEmptyCargoVerified,bool MaintenanceVerified,bool NativeResultRewritten);
private static string Hash(string path)=>Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path)));
public static Candidate? ReadCandidate(string directory,TradeLeg leg,PlayerState player)
{
var resultPath=Path.Combine(directory,"result.json");var checkpointPath=Path.Combine(directory,"journey.json");var legPath=Path.Combine(directory,"leg.json");
var openingPath=Path.Combine(directory,"opening-wallet.json");var ledgerPath=Path.Combine(directory,"ledger");
if(!File.Exists(resultPath)||!File.Exists(checkpointPath)||!File.Exists(legPath)||!File.Exists(openingPath)||!Directory.Exists(ledgerPath))return null;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
if(checkpoint.Commander!=player.CommanderId||checkpoint.ShipId!=player.CurrentShipId||checkpoint.Bought is not {} bought||checkpoint.Sold is not {} sold)return null;
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||result.Passed||result.Checkpoint!=checkpoint||result.Failure is not {} failure
||!failure.Contains("MarketAsync",StringComparison.Ordinal)||!failure.Contains("原始错误码 1",StringComparison.Ordinal)
||leg.Orders.Count!=1||bought.MarketId!=leg.From.MarketId||sold.MarketId!=leg.To.MarketId||result.Finished<sold.ConfirmedAt)return null;
var order=leg.Orders[0];
if(bought.Commodity!=order.Commodity||sold.Commodity!=order.Commodity||bought.QuantityDelta!=order.Quantity||sold.QuantityDelta!=-order.Quantity
||bought.UnitPrice>order.BuyPrice||sold.UnitPrice<order.SellPrice||bought.CargoBefore!=0||sold.CargoAfter!=0
||bought.CargoAfter!=order.Quantity||sold.CargoBefore!=order.Quantity)return null;
using var opening=JsonDocument.Parse(File.ReadAllText(openingPath));
if(opening.RootElement.GetProperty("credits").GetInt64()!=result.OpeningCredits)return null;
using(var ledger=new TradeProfitLedger(ledgerPath))
{
var profit=ledger.Snapshot;
if(profit!=result.Profit||profit.CargoInvestment!=0||profit.Purchases!=(decimal)bought.UnitPrice*order.Quantity
||profit.Sales!=(decimal)sold.UnitPrice*order.Quantity||result.OpeningCredits+profit.CashChange!=sold.CreditsAfter)return null;
}
var entries=Directory.EnumerateFiles(ledgerPath,"*.entry.json").Order(StringComparer.Ordinal).Select(path=>new{file=Path.GetFileName(path),hash=Hash(path)}).ToArray();
var ledgerHash=Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(entries)));
return new(result,Hash(resultPath),Hash(checkpointPath),Hash(legPath),ledgerHash);
}
public static async Task SealAsync(string directory,TradeLeg leg,PlayerState player,long currentCredits,CancellationToken token)
{
var candidate=ReadCandidate(directory,leg,player)??throw new InvalidDataException("原批次的成交回执与历史余额未形成完整闭环。");
if(!player.InGame||!player.Docked||player.MarketId!=leg.To.MarketId||!player.CargoReliable||player.CargoCount!=0||!player.CreditsReliable||player.Credits!=currentCredits)
throw new InvalidDataException("未核对当前目的站、空舱和实际钱包,原批次保持未结算。");
var proof=new Proof(1,player.CommanderId,player.CurrentShipId!.Value,leg.To.MarketId,DateTimeOffset.UtcNow,candidate.Original.Checkpoint.Sold!.CreditsAfter,currentCredits,
candidate.ResultHash,candidate.CheckpointHash,candidate.LegHash,candidate.LedgerHash,true,true,false);
var path=Path.Combine(directory,FileName);await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);File.Move(path+".tmp",path,true);
}
public static bool IsRecovered(string directory,TradeLeg leg,PlayerState player)
{
var path=Path.Combine(directory,FileName);if(!File.Exists(path))return false;
var proof=JsonSerializer.Deserialize<Proof>(File.ReadAllText(path),ToolboxSettings.JsonOptions);var candidate=ReadCandidate(directory,leg,player);
return proof is not null&&candidate is not null&&proof.Schema==1&&!proof.NativeResultRewritten&&proof.CurrentEmptyCargoVerified&&proof.MaintenanceVerified
&&proof.Commander==player.CommanderId&&proof.ShipId==player.CurrentShipId&&proof.MarketId==leg.To.MarketId
&&proof.HistoricalClosingCredits==candidate.Original.Checkpoint.Sold!.CreditsAfter&&proof.CurrentObservedCredits>=0&&proof.VerifiedAt>=candidate.Original.Finished
&&proof.ResultHash==candidate.ResultHash&&proof.CheckpointHash==candidate.CheckpointHash&&proof.LegHash==candidate.LegHash&&proof.LedgerHash==candidate.LedgerHash;
}
}
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>Completes accounting after an optional post-sale market read failed; original receipts and failed results remain immutable.</summary>
public static class HistoricalTradeCompletion
{
public const string FileName="confirmed-sale-completion.json";
public sealed record Candidate(NativeTradeJourneyResult Original,string ResultHash,string CheckpointHash,string LegHash,string LedgerHash);
private sealed record Proof(int Schema,string Commander,long ShipId,long MarketId,DateTimeOffset VerifiedAt,long HistoricalClosingCredits,long CurrentObservedCredits,
string ResultHash,string CheckpointHash,string LegHash,string LedgerHash,bool CurrentEmptyCargoVerified,bool MaintenanceVerified,bool NativeResultRewritten);
private static string Hash(string path)=>Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path)));
public static Candidate? ReadCandidate(string directory,TradeLeg leg,PlayerState player)
{
var resultPath=Path.Combine(directory,"result.json");var checkpointPath=Path.Combine(directory,"journey.json");var legPath=Path.Combine(directory,"leg.json");
var openingPath=Path.Combine(directory,"opening-wallet.json");var ledgerPath=Path.Combine(directory,"ledger");
if(!File.Exists(resultPath)||!File.Exists(checkpointPath)||!File.Exists(legPath)||!File.Exists(openingPath)||!Directory.Exists(ledgerPath))return null;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
if(checkpoint.Commander!=player.CommanderId||checkpoint.ShipId!=player.CurrentShipId||checkpoint.Bought is not {} bought||checkpoint.Sold is not {} sold)return null;
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||result.Passed||result.Checkpoint!=checkpoint||result.Failure is not {} failure
||!failure.Contains("MarketAsync",StringComparison.Ordinal)||!failure.Contains("原始错误码 1",StringComparison.Ordinal)
||leg.Orders.Count!=1||bought.MarketId!=leg.From.MarketId||sold.MarketId!=leg.To.MarketId||result.Finished<sold.ConfirmedAt)return null;
var order=leg.Orders[0];
if(bought.Commodity!=order.Commodity||sold.Commodity!=order.Commodity||bought.QuantityDelta!=order.Quantity||sold.QuantityDelta!=-order.Quantity
||bought.UnitPrice>order.BuyPrice||sold.UnitPrice<order.SellPrice||bought.CargoBefore!=0||sold.CargoAfter!=0
||bought.CargoAfter!=order.Quantity||sold.CargoBefore!=order.Quantity)return null;
using var opening=JsonDocument.Parse(File.ReadAllText(openingPath));
if(opening.RootElement.GetProperty("credits").GetInt64()!=result.OpeningCredits)return null;
using(var ledger=new TradeProfitLedger(ledgerPath))
{
var profit=ledger.Snapshot;
if(profit!=result.Profit||profit.CargoInvestment!=0||profit.Purchases!=(decimal)bought.UnitPrice*order.Quantity
||profit.Sales!=(decimal)sold.UnitPrice*order.Quantity||result.OpeningCredits+profit.CashChange!=sold.CreditsAfter)return null;
}
var entries=Directory.EnumerateFiles(ledgerPath,"*.entry.json").Order(StringComparer.Ordinal).Select(path=>new{file=Path.GetFileName(path),hash=Hash(path)}).ToArray();
var ledgerHash=Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(entries)));
return new(result,Hash(resultPath),Hash(checkpointPath),Hash(legPath),ledgerHash);
}
public static async Task SealAsync(string directory,TradeLeg leg,PlayerState player,long currentCredits,CancellationToken token)
{
var candidate=ReadCandidate(directory,leg,player)??throw new InvalidDataException("原批次的成交回执与历史余额未形成完整闭环。");
if(!player.InGame||!player.Docked||player.MarketId!=leg.To.MarketId||!player.CargoReliable||player.CargoCount!=0||!player.CreditsReliable||player.Credits!=currentCredits)
throw new InvalidDataException("未核对当前目的站、空舱和实际钱包,原批次保持未结算。");
var proof=new Proof(1,player.CommanderId,player.CurrentShipId!.Value,leg.To.MarketId,DateTimeOffset.UtcNow,candidate.Original.Checkpoint.Sold!.CreditsAfter,currentCredits,
candidate.ResultHash,candidate.CheckpointHash,candidate.LegHash,candidate.LedgerHash,true,true,false);
var path=Path.Combine(directory,FileName);await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);File.Move(path+".tmp",path,true);
}
public static bool IsRecovered(string directory,TradeLeg leg,PlayerState player)
{
var path=Path.Combine(directory,FileName);if(!File.Exists(path))return false;
var proof=JsonSerializer.Deserialize<Proof>(File.ReadAllText(path),ToolboxSettings.JsonOptions);var candidate=ReadCandidate(directory,leg,player);
return proof is not null&&candidate is not null&&proof.Schema==1&&!proof.NativeResultRewritten&&proof.CurrentEmptyCargoVerified&&proof.MaintenanceVerified
&&proof.Commander==player.CommanderId&&proof.ShipId==player.CurrentShipId&&proof.MarketId==leg.To.MarketId
&&proof.HistoricalClosingCredits==candidate.Original.Checkpoint.Sold!.CreditsAfter&&proof.CurrentObservedCredits>=0&&proof.VerifiedAt>=candidate.Original.Finished
&&proof.ResultHash==candidate.ResultHash&&proof.CheckpointHash==candidate.CheckpointHash&&proof.LegHash==candidate.LegHash&&proof.LedgerHash==candidate.LedgerHash;
}
}