using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
public sealed record NativeTradeJourneyResult(bool Passed,TradeJourneyCheckpoint Checkpoint,TradeProfitSnapshot Profit,long OpeningCredits,long? ClosingCredits,string? Failure,DateTimeOffset Finished);
public sealed class NativeTradeJourneyIncompleteException(NativeTradeJourneyResult result,Exception? cause):InvalidOperationException(
cause is null?"跑商批次未完成,原始货物和回执已保留。":$"跑商批次未完成:{cause.Message};原始货物和回执已保留。",cause)
{public NativeTradeJourneyResult Result{get;}=result;}
/// A resumable production cargo journey, driven entirely by the toolbox.
public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func player,Func check,string directory)
{
public string? JournalDirectory{get;init;}
public event Action? Status;
public async Task RunAsync(TradeLeg leg,CancellationToken token)
{
if(leg.Orders.Count!=1)throw new ArgumentException("可恢复批次需要唯一的原始货物订单。");
Directory.CreateDirectory(directory);using var lease=new FileStream(Path.Combine(directory,"runner.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
var checkpointPath=Path.Combine(directory,"journey.json");var resultPath=Path.Combine(directory,"result.json");var planPath=Path.Combine(directory,"leg.json");
var initial=player();var order=leg.Orders[0];
var checkpoint=File.Exists(checkpointPath)?TradeJourneyCheckpoints.Load(checkpointPath)
:new(initial.CommanderId,initial.CurrentShipId??throw new InvalidDataException("原始船舶身份缺失。"),DateTimeOffset.UtcNow);
if(checkpoint.Commander!=initial.CommanderId||checkpoint.ShipId!=initial.CurrentShipId)throw new InvalidDataException("恢复批次属于另一艘船或指挥官。");
if(File.Exists(planPath))
{
var saved=JsonSerializer.Deserialize(await File.ReadAllTextAsync(planPath,token),ToolboxSettings.JsonOptions)!;
if(saved.From.MarketId!=leg.From.MarketId||saved.To.MarketId!=leg.To.MarketId||saved.Orders.Count!=1||saved.Orders[0]!=order)
throw new InvalidDataException("恢复批次的站点或订单与原始计划不一致。");
}
else await WriteAsync(planPath,leg,token);
if(File.Exists(resultPath)&&checkpoint.Sold is not null)
{
var completed=JsonSerializer.Deserialize(await File.ReadAllTextAsync(resultPath,token),ToolboxSettings.JsonOptions);
if(completed?.Passed==true)return completed;
}
await driver.PreflightAsync(token);await check(token);
using var ledger=new TradeProfitLedger(Path.Combine(directory,"ledger"));var sync=new object();
var openingPath=Path.Combine(directory,"opening-wallet.json");long opening;
if(File.Exists(openingPath))
{using var saved=JsonDocument.Parse(await File.ReadAllTextAsync(openingPath,token));opening=saved.RootElement.GetProperty("credits").GetInt64();}
else
{
if(checkpoint.Bought is not null)throw new InvalidDataException("恢复批次缺少原始起始钱包,拒绝推算余额。");
opening=(await driver.RefreshWalletAsync(token)).Credits;await WriteAsync(openingPath,new{credits=opening},token);
}
void RecordTrade(ConfirmedTradeReceipt receipt)
{
lock(sync)
{
if(receipt.CommanderId!=checkpoint.Commander||receipt.ShipId!=checkpoint.ShipId||receipt.Commodity!=order.Commodity
||receipt.QuantityDelta!=(receipt.QuantityDelta>0?order.Quantity:-order.Quantity)
||receipt.MarketId!=(receipt.QuantityDelta>0?leg.From.MarketId:leg.To.MarketId))throw new InvalidDataException("原始交易回执与本批次不符。");
checkpoint=receipt.QuantityDelta>0?checkpoint with{Bought=receipt}:checkpoint with{Sold=receipt};
TradeJourneyCheckpoints.Save(checkpointPath,checkpoint);ledger.RecordTrade(receipt);
}
}
void RecordService(ConfirmedDockedServiceReceipt receipt)=>ledger.Record(receipt.ToCashEntry());
driver.TradeConfirmed+=RecordTrade;driver.ServiceConfirmed+=RecordService;
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);Task? services=null;long? closing=null;string? failure=null;Exception? cause=null;
var saleWasAlreadyConfirmed=checkpoint.Sold is not null;
try
{
if(checkpoint.Bought is {} bought)
{
ledger.RecordTrade(bought);
if(player().Docked&&player().MarketId==leg.From.MarketId)
await driver.ServiceDockedAsync(new(leg.From.MarketId,order.Quantity),check,stop.Token);
}
else
{
if(!player().Docked||player().MarketId!=leg.From.MarketId||player().CargoCount!=0)throw new InvalidDataException("采购前未确认原站停靠及空货舱。");
TradeJourneyCheckpoints.Save(checkpointPath,checkpoint);Status?.Invoke("正在采购并完成站内维护");
services=driver.ServiceDockedAsync(new(leg.From.MarketId,order.Quantity),check,stop.Token);
var action=new TradeAction(TradeActionKind.Buy,leg,order);var at=DateTimeOffset.UtcNow;
var receipt=await driver.ExecuteTradeAsync(action,check,stop.Token);receipt.Validate(action,player(),at);
await services;services=null;
}
if(checkpoint.Sold is {} sold)ledger.RecordTrade(sold);
else
{
if(!player().Docked||player().MarketId!=leg.To.MarketId)
{
Status?.Invoke("正在运送本批货物");await driver.ExecuteAsync(new(TradeActionKind.Travel,leg,null),check,stop.Token);
}
Status?.Invoke("正在卖出并完成站内维护");services=driver.ServiceDockedAsync(new(leg.To.MarketId,order.Quantity),check,stop.Token);
var action=new TradeAction(TradeActionKind.Sell,leg,order);var at=DateTimeOffset.UtcNow;
var receipt=await driver.ExecuteTradeAsync(action,check,stop.Token);receipt.Validate(action,player(),at);
await services;services=null;
}
// A prior worker may have persisted the sale before its services
// finished; service verification remains mandatory on resume.
if(saleWasAlreadyConfirmed)await driver.ServiceDockedAsync(new(leg.To.MarketId,order.Quantity),check,stop.Token);
closing=(await driver.RefreshWalletAsync(stop.Token)).Credits;
if(JournalDirectory is {} journals&&opening+ledger.Snapshot.CashChange!=closing)
await ExternalServiceCosts.ReconcileAsync(journals,directory,checkpoint,leg,ledger,opening,closing.Value,stop.Token);
if(!player().Docked||player().MarketId!=leg.To.MarketId||player().CargoCount!=0||checkpoint.Sold is null||opening+ledger.Snapshot.CashChange!=closing)
throw new InvalidDataException("本批次原始余额、空货舱或实际停靠尚未完整核对。");
}
catch(Exception error){cause=error;failure=error.ToString();stop.Cancel();}
finally
{
if(services is not null)try{await services;}catch(Exception error){failure??=error.ToString();}
driver.TradeConfirmed-=RecordTrade;driver.ServiceConfirmed-=RecordService;await driver.ReleaseAllAsync();
}
var result=new NativeTradeJourneyResult(failure is null,checkpoint,ledger.Snapshot,opening,closing,failure,DateTimeOffset.UtcNow);
var attempts=Path.Combine(directory,"attempts");Directory.CreateDirectory(attempts);
await WriteAsync(Path.Combine(attempts,Guid.NewGuid().ToString("N")+".json"),result,CancellationToken.None);
await WriteAsync(resultPath,result,CancellationToken.None);
if(!result.Passed)throw new NativeTradeJourneyIncompleteException(result,cause);
return result;
}
private static async Task WriteAsync(string path,object value,CancellationToken token)
{
var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
try{await File.WriteAllTextAsync(temporary,JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions),token);File.Move(temporary,path,true);}
finally{if(File.Exists(temporary))File.Delete(temporary);}
}
}