using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>One durable round per confirmed outbound sale, completed at home for return routes.</summary>
public static class AutomaticTradeRounds
{
private sealed record Completion(int Schema,long SourceMarketId,long DestinationMarketId,bool ReturnTrade,string Commander,long ShipId,
string OutboundPurchaseKey,string OutboundSaleKey,string OutboundJourney,DateTimeOffset OutboundSoldAt,
DateTimeOffset CompletedAt,string Evidence,string? ReturnOperationKey=null);
private sealed record Journey(string Directory,TradeLeg Leg,TradeJourneyCheckpoint Checkpoint);
private sealed record Arrival(DateTimeOffset At,string Evidence,string? OperationKey);
private static bool SameReceipt(ConfirmedTradeReceipt? first,ConfirmedTradeReceipt? second)
=>first is null?second is null:second is not null&&first.OperationKey==second.OperationKey&&first.MarketId==second.MarketId
&&first.Commodity==second.Commodity&&first.QuantityDelta==second.QuantityDelta&&first.UnitPrice==second.UnitPrice
&&first.CreditsBefore==second.CreditsBefore&&first.CreditsAfter==second.CreditsAfter&&first.CargoBefore==second.CargoBefore&&first.CargoAfter==second.CargoAfter
&&first.CommanderId==second.CommanderId&&first.ShipId==second.ShipId&&first.SessionStartedAt==second.SessionStartedAt&&first.ConfirmedAt==second.ConfirmedAt
&&first.ServerConfirmed==second.ServerConfirmed&&first.ClientApplied==second.ClientApplied;
private static bool SameCheckpoint(TradeJourneyCheckpoint first,TradeJourneyCheckpoint second)
=>first.Commander==second.Commander&&first.ShipId==second.ShipId&&first.StartedAt==second.StartedAt
&&SameReceipt(first.Bought,second.Bought)&&SameReceipt(first.Sold,second.Sold);
public static int Refresh(AutomaticTradeConfiguration configuration,PlayerState current)
{
var root=configuration.WorkingDirectory;
bool Outbound(TradeLeg leg)=>leg.To.MarketId==configuration.Destination.MarketId
&&(configuration.UsesDynamicSources?configuration.Commodities.Length>0&&leg.Orders.Count>0
&&leg.From.MarketId!=configuration.Destination.MarketId&&leg.Orders.All(o=>configuration.Commodities.Contains(o.Commodity,StringComparer.Ordinal))
:leg.From.MarketId==configuration.Source.MarketId);
bool SameGoal(Journey journey)
{
if(configuration.Mode!=AutomaticTradeMode.CommunityGoal||configuration.CommunityGoalId is null)return true;
var file=Path.Combine(journey.Directory,"membership-before.json");if(!File.Exists(file))return false;
using var proof=JsonDocument.Parse(File.ReadAllText(file));
return proof.RootElement.GetProperty("goalId").GetUInt64()==configuration.CommunityGoalId;
}
if(!Directory.Exists(root)||string.IsNullOrWhiteSpace(current.CommanderId)||current.CurrentShipId is not {} ship)return 0;
var completed=new List<Journey>();var purchases=new List<ConfirmedTradeReceipt>();
foreach(var directory in Directory.EnumerateDirectories(root,"journey-*"))
{
var checkpointPath=Path.Combine(directory,"journey.json");
if(!File.Exists(checkpointPath))continue;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
if(checkpoint.Commander!=current.CommanderId||checkpoint.ShipId!=ship)continue;
if(checkpoint.Bought is {} purchase)purchases.Add(purchase);
var resultPath=Path.Combine(directory,"result.json");var legPath=Path.Combine(directory,"leg.json");
if(!File.Exists(resultPath)||!File.Exists(legPath))continue;
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||result.Checkpoint is null||!SameCheckpoint(result.Checkpoint,checkpoint)||checkpoint.Bought is not {} bought||checkpoint.Sold is not {} sold)continue;
var leg=JsonSerializer.Deserialize<TradeLeg>(File.ReadAllText(legPath),ToolboxSettings.JsonOptions);
if(leg is null||!result.Passed&&!HistoricalTradeCompletion.IsRecovered(directory,leg,current))continue;
if(leg is null||leg.Orders.Count!=1||bought.MarketId!=leg.From.MarketId||sold.MarketId!=leg.To.MarketId
||bought.Commodity!=leg.Orders[0].Commodity||bought.QuantityDelta!=leg.Orders[0].Quantity||sold.ConfirmedAt>result.Finished)continue;
completed.Add(new(directory,leg,checkpoint));
}
var candidates=completed.Where(journey=>Outbound(journey.Leg)&&SameGoal(journey))
.DistinctBy(journey=>journey.Checkpoint.Sold!.OperationKey).OrderBy(journey=>journey.Checkpoint.Bought!.ConfirmedAt).ToArray();
// Two distinct batches cannot both occupy this ship's cargo hold over
// overlapping intervals. Ambiguous records must not inflate rounds.
var outbound=candidates.Where(journey=>!candidates.Any(other=>other!=journey
&&other.Checkpoint.Bought!.ConfirmedAt<journey.Checkpoint.Sold!.ConfirmedAt
&&journey.Checkpoint.Bought!.ConfirmedAt<other.Checkpoint.Sold!.ConfirmedAt)).ToArray();
if(outbound.Length==0)return 0;
var scope=JsonSerializer.Serialize(new{source=configuration.UsesDynamicSources?0:configuration.Source.MarketId,destination=configuration.Destination.MarketId,configuration.ReturnTrade,
goal=configuration.Mode==AutomaticTradeMode.CommunityGoal?configuration.CommunityGoalId:null,
commander=current.CommanderId,ship},ToolboxSettings.JsonOptions);
var records=Path.Combine(root,"rounds-v1",Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope))));
var count=0;
for(var index=0;index<outbound.Length;index++)
{
var journey=outbound[index];
var bought=journey.Checkpoint.Bought!;var sold=journey.Checkpoint.Sold!;var path=Path.Combine(records,sold.OperationKey+".json");
if(File.Exists(path))
{
var existing=JsonSerializer.Deserialize<Completion>(File.ReadAllText(path),ToolboxSettings.JsonOptions);
if(existing is null||existing.Schema!=1||existing.SourceMarketId!=journey.Leg.From.MarketId||existing.DestinationMarketId!=configuration.Destination.MarketId
||existing.ReturnTrade!=configuration.ReturnTrade||existing.Commander!=current.CommanderId||existing.ShipId!=ship
||existing.OutboundPurchaseKey!=bought.OperationKey||existing.OutboundSaleKey!=sold.OperationKey||existing.OutboundSoldAt!=sold.ConfirmedAt
||existing.CompletedAt<sold.ConfirmedAt)throw new InvalidDataException("已完成轮次记录与原始交易回执不一致。");
count++;continue;
}
Arrival? arrival;
if(!configuration.ReturnTrade)arrival=new(sold.ConfirmedAt,"confirmed-outbound-sale",sold.OperationKey);
else
{
// A later purchase at the source also proves a return that
// completed just before a restart or the next journey began.
var home=journey.Leg.From.MarketId;
var witnesses=purchases.Where(receipt=>receipt.MarketId==home&&receipt.ConfirmedAt>=sold.ConfirmedAt&&receipt.OperationKey!=bought.OperationKey)
.Select(receipt=>new Arrival(receipt.ConfirmedAt,"confirmed-purchase-at-source",receipt.OperationKey))
.Concat(completed.Where(other=>other.Leg.From.MarketId==configuration.Destination.MarketId&&other.Leg.To.MarketId==home
&&other.Checkpoint.Sold!.ConfirmedAt>=sold.ConfirmedAt)
.Select(other=>new Arrival(other.Checkpoint.Sold!.ConfirmedAt,"confirmed-return-sale",other.Checkpoint.Sold.OperationKey))).ToList();
if(current.InGame&¤t.Docked&¤t.MarketId==home&¤t.LocationAt is {} dockedAt
&&dockedAt>=sold.ConfirmedAt&&dockedAt<=DateTimeOffset.UtcNow.AddSeconds(1))
witnesses.Add(new(dockedAt,"journal-docked-at-source",null));
// Home must have been reached before the next outbound buy;
// one later docking cannot close multiple prior rounds.
var nextPurchase=index+1<outbound.Length?outbound[index+1].Checkpoint.Bought!.ConfirmedAt:(DateTimeOffset?)null;
arrival=witnesses.Where(witness=>nextPurchase is null||witness.At<=nextPurchase).OrderBy(witness=>witness.At).FirstOrDefault();
}
if(arrival is null)continue;
var value=new Completion(1,journey.Leg.From.MarketId,configuration.Destination.MarketId,configuration.ReturnTrade,current.CommanderId,ship,
bought.OperationKey,sold.OperationKey,Path.GetFileName(journey.Directory),sold.ConfirmedAt,arrival.At,arrival.Evidence,arrival.OperationKey);
Directory.CreateDirectory(records);var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
try
{
using(var file=new FileStream(temporary,FileMode.CreateNew,FileAccess.Write,FileShare.Read,4096,FileOptions.WriteThrough))
{JsonSerializer.Serialize(file,value,ToolboxSettings.JsonOptions);file.Flush(true);}
File.Move(temporary,path,false);
}
finally{if(File.Exists(temporary))File.Delete(temporary);}
count++;
}
return count;
}
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>One durable round per confirmed outbound sale, completed at home for return routes.</summary>
public static class AutomaticTradeRounds
{
private sealed record Completion(int Schema,long SourceMarketId,long DestinationMarketId,bool ReturnTrade,string Commander,long ShipId,
string OutboundPurchaseKey,string OutboundSaleKey,string OutboundJourney,DateTimeOffset OutboundSoldAt,
DateTimeOffset CompletedAt,string Evidence,string? ReturnOperationKey=null);
private sealed record Journey(string Directory,TradeLeg Leg,TradeJourneyCheckpoint Checkpoint);
private sealed record Arrival(DateTimeOffset At,string Evidence,string? OperationKey);
private static bool SameReceipt(ConfirmedTradeReceipt? first,ConfirmedTradeReceipt? second)
=>first is null?second is null:second is not null&&first.OperationKey==second.OperationKey&&first.MarketId==second.MarketId
&&first.Commodity==second.Commodity&&first.QuantityDelta==second.QuantityDelta&&first.UnitPrice==second.UnitPrice
&&first.CreditsBefore==second.CreditsBefore&&first.CreditsAfter==second.CreditsAfter&&first.CargoBefore==second.CargoBefore&&first.CargoAfter==second.CargoAfter
&&first.CommanderId==second.CommanderId&&first.ShipId==second.ShipId&&first.SessionStartedAt==second.SessionStartedAt&&first.ConfirmedAt==second.ConfirmedAt
&&first.ServerConfirmed==second.ServerConfirmed&&first.ClientApplied==second.ClientApplied;
private static bool SameCheckpoint(TradeJourneyCheckpoint first,TradeJourneyCheckpoint second)
=>first.Commander==second.Commander&&first.ShipId==second.ShipId&&first.StartedAt==second.StartedAt
&&SameReceipt(first.Bought,second.Bought)&&SameReceipt(first.Sold,second.Sold);
public static int Refresh(AutomaticTradeConfiguration configuration,PlayerState current)
{
var root=configuration.WorkingDirectory;
bool Outbound(TradeLeg leg)=>leg.To.MarketId==configuration.Destination.MarketId
&&(configuration.UsesDynamicSources?configuration.Commodities.Length>0&&leg.Orders.Count>0
&&leg.From.MarketId!=configuration.Destination.MarketId&&leg.Orders.All(o=>configuration.Commodities.Contains(o.Commodity,StringComparer.Ordinal))
:leg.From.MarketId==configuration.Source.MarketId);
bool SameGoal(Journey journey)
{
if(configuration.Mode!=AutomaticTradeMode.CommunityGoal||configuration.CommunityGoalId is null)return true;
var file=Path.Combine(journey.Directory,"membership-before.json");if(!File.Exists(file))return false;
using var proof=JsonDocument.Parse(File.ReadAllText(file));
return proof.RootElement.GetProperty("goalId").GetUInt64()==configuration.CommunityGoalId;
}
if(!Directory.Exists(root)||string.IsNullOrWhiteSpace(current.CommanderId)||current.CurrentShipId is not {} ship)return 0;
var completed=new List<Journey>();var purchases=new List<ConfirmedTradeReceipt>();
foreach(var directory in Directory.EnumerateDirectories(root,"journey-*"))
{
var checkpointPath=Path.Combine(directory,"journey.json");
if(!File.Exists(checkpointPath))continue;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
if(checkpoint.Commander!=current.CommanderId||checkpoint.ShipId!=ship)continue;
if(checkpoint.Bought is {} purchase)purchases.Add(purchase);
var resultPath=Path.Combine(directory,"result.json");var legPath=Path.Combine(directory,"leg.json");
if(!File.Exists(resultPath)||!File.Exists(legPath))continue;
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||result.Checkpoint is null||!SameCheckpoint(result.Checkpoint,checkpoint)||checkpoint.Bought is not {} bought||checkpoint.Sold is not {} sold)continue;
var leg=JsonSerializer.Deserialize<TradeLeg>(File.ReadAllText(legPath),ToolboxSettings.JsonOptions);
if(leg is null||!result.Passed&&!HistoricalTradeCompletion.IsRecovered(directory,leg,current))continue;
if(leg is null||leg.Orders.Count!=1||bought.MarketId!=leg.From.MarketId||sold.MarketId!=leg.To.MarketId
||bought.Commodity!=leg.Orders[0].Commodity||bought.QuantityDelta!=leg.Orders[0].Quantity||sold.ConfirmedAt>result.Finished)continue;
completed.Add(new(directory,leg,checkpoint));
}
var candidates=completed.Where(journey=>Outbound(journey.Leg)&&SameGoal(journey))
.DistinctBy(journey=>journey.Checkpoint.Sold!.OperationKey).OrderBy(journey=>journey.Checkpoint.Bought!.ConfirmedAt).ToArray();
// Two distinct batches cannot both occupy this ship's cargo hold over
// overlapping intervals. Ambiguous records must not inflate rounds.
var outbound=candidates.Where(journey=>!candidates.Any(other=>other!=journey
&&other.Checkpoint.Bought!.ConfirmedAt<journey.Checkpoint.Sold!.ConfirmedAt
&&journey.Checkpoint.Bought!.ConfirmedAt<other.Checkpoint.Sold!.ConfirmedAt)).ToArray();
if(outbound.Length==0)return 0;
var scope=JsonSerializer.Serialize(new{source=configuration.UsesDynamicSources?0:configuration.Source.MarketId,destination=configuration.Destination.MarketId,configuration.ReturnTrade,
goal=configuration.Mode==AutomaticTradeMode.CommunityGoal?configuration.CommunityGoalId:null,
commander=current.CommanderId,ship},ToolboxSettings.JsonOptions);
var records=Path.Combine(root,"rounds-v1",Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope))));
var count=0;
for(var index=0;index<outbound.Length;index++)
{
var journey=outbound[index];
var bought=journey.Checkpoint.Bought!;var sold=journey.Checkpoint.Sold!;var path=Path.Combine(records,sold.OperationKey+".json");
if(File.Exists(path))
{
var existing=JsonSerializer.Deserialize<Completion>(File.ReadAllText(path),ToolboxSettings.JsonOptions);
if(existing is null||existing.Schema!=1||existing.SourceMarketId!=journey.Leg.From.MarketId||existing.DestinationMarketId!=configuration.Destination.MarketId
||existing.ReturnTrade!=configuration.ReturnTrade||existing.Commander!=current.CommanderId||existing.ShipId!=ship
||existing.OutboundPurchaseKey!=bought.OperationKey||existing.OutboundSaleKey!=sold.OperationKey||existing.OutboundSoldAt!=sold.ConfirmedAt
||existing.CompletedAt<sold.ConfirmedAt)throw new InvalidDataException("已完成轮次记录与原始交易回执不一致。");
count++;continue;
}
Arrival? arrival;
if(!configuration.ReturnTrade)arrival=new(sold.ConfirmedAt,"confirmed-outbound-sale",sold.OperationKey);
else
{
// A later purchase at the source also proves a return that
// completed just before a restart or the next journey began.
var home=journey.Leg.From.MarketId;
var witnesses=purchases.Where(receipt=>receipt.MarketId==home&&receipt.ConfirmedAt>=sold.ConfirmedAt&&receipt.OperationKey!=bought.OperationKey)
.Select(receipt=>new Arrival(receipt.ConfirmedAt,"confirmed-purchase-at-source",receipt.OperationKey))
.Concat(completed.Where(other=>other.Leg.From.MarketId==configuration.Destination.MarketId&&other.Leg.To.MarketId==home
&&other.Checkpoint.Sold!.ConfirmedAt>=sold.ConfirmedAt)
.Select(other=>new Arrival(other.Checkpoint.Sold!.ConfirmedAt,"confirmed-return-sale",other.Checkpoint.Sold.OperationKey))).ToList();
if(current.InGame&¤t.Docked&¤t.MarketId==home&¤t.LocationAt is {} dockedAt
&&dockedAt>=sold.ConfirmedAt&&dockedAt<=DateTimeOffset.UtcNow.AddSeconds(1))
witnesses.Add(new(dockedAt,"journal-docked-at-source",null));
// Home must have been reached before the next outbound buy;
// one later docking cannot close multiple prior rounds.
var nextPurchase=index+1<outbound.Length?outbound[index+1].Checkpoint.Bought!.ConfirmedAt:(DateTimeOffset?)null;
arrival=witnesses.Where(witness=>nextPurchase is null||witness.At<=nextPurchase).OrderBy(witness=>witness.At).FirstOrDefault();
}
if(arrival is null)continue;
var value=new Completion(1,journey.Leg.From.MarketId,configuration.Destination.MarketId,configuration.ReturnTrade,current.CommanderId,ship,
bought.OperationKey,sold.OperationKey,Path.GetFileName(journey.Directory),sold.ConfirmedAt,arrival.At,arrival.Evidence,arrival.OperationKey);
Directory.CreateDirectory(records);var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
try
{
using(var file=new FileStream(temporary,FileMode.CreateNew,FileAccess.Write,FileShare.Read,4096,FileOptions.WriteThrough))
{JsonSerializer.Serialize(file,value,ToolboxSettings.JsonOptions);file.Flush(true);}
File.Move(temporary,path,false);
}
finally{if(File.Exists(temporary))File.Delete(temporary);}
count++;
}
return count;
}
}