using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class NativeTradeCommandDriverTests
{
private sealed class Channel:IOriginalCommerceChannel
{
public string Identity{get;}=Guid.NewGuid().ToString();
public PlayerState Player=new(){Ship="Type9",InGame=true,Docked=true,MarketId=1,CommanderId="native-commerce-test",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),Credits=10000,
CreditsReliable=true,CargoReliable=true,CargoCapacity=16,StationServicesKnown=true,StationServices=["refuel","repair","rearm"]};
public long Credits=10000;public int Cargo,Mutations,Overlapping,MaximumPending,DockingPendingReads,ReadinessProbes,MarketReads,ServiceReads;public bool FuelNeeded=true,DropAcknowledgement;
public List<long> MarketReadMarkets=[];
public ulong ActualMarket=1;public Action? OnReadinessProbe;
public long RepairNeeded,IntegrityNeeded,RearmNeeded;public int IntegrityWear;public bool KeepServiceOutstanding;
public bool QuoteSnapshotsSupported=true,FailPostTradeReadOnce;public int FreshnessFailures,PostTradeFreshnessFailures;public int ReadFailureCode=1;private bool currentQuote;private ulong quotedGeneration;private readonly HashSet<string> consumedServices=[];
public Func<int,ulong,JsonElement?>? ServiceReplyFactory;
public bool ProfileRefreshSupported=true,MarketWalletOptional=true,MarketHasCredits=true,MissingServerCargo;public long ServerCreditsOffset;public int ProfileRefreshes,ServerCargoOffset;
public string Commodity="biowaste";public uint CommodityId=128049244;public int UnitBuy=68,UnitSell=80;public bool BatchSupported;
public bool Goals;public long Contribution;public int CgReads;
private ulong generation;private int pending;
private readonly Dictionary<ulong,JsonElement> replies=[];private readonly Dictionary<string,ulong> keys=[];
private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value);
public async Task<JsonElement> CallAsync(object request,CancellationToken token)
{
var command=Json(request);var op=command.GetProperty("op").GetString();
if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,formalCommodityTrading=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported,marketWalletOptional=MarketWalletOptional});
if(op is "cg.read" or "cg.read.mine")
{
if(!Goals||command.GetProperty("marketId").GetInt64()!=99)throw new InvalidOperationException("CG was queried outside the configured destination");
CgReads++;string Hex(string text)=>Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text));
var body=JsonSerializer.Serialize(new{initiatives=new Dictionary<string,object>{["42"]=new{id=42,activityType="tradelist",goalType="trade",market_id=99,
expiry=DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds(),complete=false,final_state="active",hidden="0",systemName=Hex("System99"),marketName=Hex("Station99"),
bulletin=new{title=Hex("Another trade CG")},@dynamic=new{commanderContributionQty=Contribution,commanderSubscribed=true,complete=false}}}});
var id=++generation;replies[id]=Json(new{state=3,generation=id,kind=op=="cg.read.mine"?2:0,mutationsSent=0,fresh=true,responseBody=body,
proof=Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(body)))});return Json(new{generation=id});
}
if(op=="cg.status")return replies[command.GetProperty("generation").GetUInt64()];
if(op=="commerce.inventory")return Json(new{verified=true,count=Cargo,capacity=Player.CargoCapacity,credits=Credits});
if(op=="commerce.docked.status")
{
ReadinessProbes++;OnReadinessProbe?.Invoke();var ready=DockingPendingReads--<=0;
return Json(new{ready,serviceReady=ready,actualMarketId=ActualMarket,stage=ready?"ready":"docking",mutationsSent=0});
}
if(op=="commerce.market.status")return replies[command.GetProperty("generation").GetUInt64()];
if(op=="commerce.operation.status")
{
var key=command.GetProperty("operationKey").GetString()!;
return keys.TryGetValue(key,out var known)?Json(new{known=true,receiptAvailable=true,generation=known}):Json(new{known=false});
}
if(op=="station.services.inspect")
{
ServiceReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Service prices requested before native docking settled.");
var id=++generation;quotedGeneration=id;currentQuote=true;consumedServices.Clear();
if(ServiceReplyFactory?.Invoke(ServiceReads,id) is {} overridden)
{replies[id]=overridden;return Json(new{generation=id,mutationsSent=0});}
replies[id]=Json(new{state=3,generation=id,servicePrices=new{fuel=new{costToRefuel=FuelNeeded?5:0},repair=new{costToRepair=RepairNeeded,shipIntegrityCosts=new{costToRepair=IntegrityNeeded,wear=IntegrityWear}},ammunition=new{costToRearm=RearmNeeded}}});return Json(new{generation=id});
}
if(op=="commerce.market.read")
{
MarketReadMarkets.Add(command.GetProperty("marketId").GetInt64());
if(FailPostTradeReadOnce&&Cargo>0){FailPostTradeReadOnce=false;throw new IOException("Post-order read failed after original cargo/wallet confirmation.");}
MarketReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Market requested before native docking settled.");
currentQuote=false;
if(command.TryGetProperty("refreshClient",out var refresh)&&refresh.ValueKind==JsonValueKind.True)ProfileRefreshes++;
var id=++generation;
if(FreshnessFailures-->0){replies[id]=Json(new{generation=id,state=4,error=ReadFailureCode,mutationsSent=0,reconciliationRequired=false});return Json(new{generation=id});}
var response=new Dictionary<string,object?>{["state"]=3,["generation"]=id,["profileApplied"]=false,["serverCargoCount"]=Cargo+ServerCargoOffset,
["commodityCount"]=1,["commodities"]=new[]{new{id=CommodityId,name=Commodity,buyPrice=UnitBuy,sellPrice=UnitSell,stock=100000,demand=100000}}};
if(!MissingServerCargo)response["cargo"]=Cargo+ServerCargoOffset==0?new Dictionary<string,int>():new(){[Commodity]=Cargo+ServerCargoOffset};
if(MarketHasCredits)response["serverCredits"]=Credits+ServerCreditsOffset;
replies[id]=Json(response);return Json(new{generation=id});
}
if(op is "station.services" or "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal")
{
if(op=="station.services")
{
var service=command.GetProperty("action").GetString()!;
var snapshot=command.TryGetProperty("quoteGeneration",out var quoteId);
if(snapshot? !QuoteSnapshotsSupported||quoteId.GetUInt64()!=quotedGeneration||consumedServices.Contains(service):!currentQuote)
throw new InvalidOperationException("Fresh original service quote required.");
consumedServices.Add(service);
}
currentQuote=false;
var count=Interlocked.Increment(ref pending);MaximumPending=Math.Max(MaximumPending,count);if(count>1)Overlapping++;
var before=Credits;var cargoBefore=Cargo;await Task.Delay(25,token);var trade=op is "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal";var delta=trade?command.GetProperty("quantityDelta").GetInt32():0;
var action=trade?"":command.GetProperty("action").GetString();
var price=trade?delta>0?UnitBuy:UnitSell:action switch{"refuel"=>5,"repair"=>Math.Max(RepairNeeded,10),"integrity"=>IntegrityNeeded,"rearm"=>Math.Max(RearmNeeded,10),_=>throw new InvalidOperationException("Unexpected service.")};
Credits-=trade?price*delta:price;
if(trade){if(Goals&&delta<0&&Player.MarketId==99)Contribution-=delta;Cargo+=delta;Player=Player with{CargoCount=Cargo,Inventory=Cargo>0?[new(Commodity,Commodity,Cargo)]:[]};}
else if(!KeepServiceOutstanding)
switch(action){case "refuel":FuelNeeded=false;break;case "repair":RepairNeeded=0;break;case "integrity":IntegrityNeeded=0;IntegrityWear=0;break;case "rearm":RearmNeeded=0;break;}
var id=++generation;var key=command.GetProperty("operationKey").GetString()!;Mutations++;keys[key]=id;
if(trade&&PostTradeFreshnessFailures>0){FreshnessFailures=PostTradeFreshnessFailures;PostTradeFreshnessFailures=0;}
replies[id]=Json(new{state=3,generation=id,serverConfirmed=true,clientApplied=true,creditsBefore=before,credits=Credits,cargoBefore,cargoAfter=Cargo,after=Cargo,unitPrice=price});Interlocked.Decrement(ref pending);
if(DropAcknowledgement){DropAcknowledgement=false;throw new IOException("Lost acknowledgement after server acceptance.");}
return Json(new{generation=id});
}
throw new InvalidOperationException("Unexpected native operation: "+op);
}
}
private static NativeTradeCommandDriver Driver(Channel channel,string directory,NativeTradeBatchPolicy? batch=null,string? journals=null)
{
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,(_,_,_)=>throw new InvalidOperationException("No flight in this test."),_=>Task.CompletedTask,batchPolicy:batch){JournalDirectory=journals};
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
}
private static TradeAction Buy()=>new(TradeActionKind.Buy,new(){From=Check.Station(1),To=Check.Station(2),Orders=[new("biowaste","Biowaste",1,68,80)]},new("biowaste","Biowaste",1,68,80));
private static Task WriteLocalFine(TemporaryDirectory directory,Channel channel)=>File.WriteAllLinesAsync(directory.File("Journal.fine.log"),new object[]
{
new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Transit authority"}},
new{@event="CommitCrime",Faction="Transit authority",Fine=400}
}.Select(value=>JsonSerializer.Serialize(value)));
[Test]public async Task ATransitPortFineCannotBlockSessionPreflightOrFlightToTheActualTradingStation()
{
using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);var travelled=false;var messages=new List<string>();
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory.Path,
(station,_,_)=>{Check.Equal(2L,station.MarketId);travelled=true;return Task.CompletedTask;},_=>Task.CompletedTask){JournalDirectory=directory.Path};
driver.Status+=messages.Add;
using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2));
await driver.PreflightAsync(timeout.Token);
await driver.ExecuteAsync(new(TradeActionKind.Travel,Buy().Leg,null),_=>Task.CompletedTask,timeout.Token);
Check.That(travelled);Check.Equal(0,channel.Mutations);Check.Equal(0,messages.Count);
Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
}
[Test]public async Task TheSameFineStillBlocksAnActualPurchaseUntilPaidWithoutSubmittingOrRepeatingAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);
var driver=Driver(channel,directory.Path,journals:directory.Path);var messages=new List<string>();driver.Status+=messages.Add;
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2)))await driver.PreflightAsync(timeout.Token);
var cancelled=false;
using(var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(200)))
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(1,messages.Count);
Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
await File.AppendAllLinesAsync(directory.File("Journal.fine.log"),[JsonSerializer.Serialize(new{@event="PayFines",Faction="Transit authority",Amount=400})]);
var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task TransientMarketFreshnessFailureRetriesTheReadWithoutDuplicatingTheTrade()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=2};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);Check.Equal(4,channel.MarketReads);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
Check.That(Directory.GetFiles(directory.Path,"*.pending.json").All(path=>new FileInfo(path).Length>0));
}
[Test] public async Task RepeatedMarketFreshnessFailuresStopAfterFourReadsBeforeAnyOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){stopped=true;}
Check.That(stopped);Check.Equal(4,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task AConfirmedOrderSurvivesOptionalMarketFreshnessFailureWithoutResubmission()
{
using var directory=new TemporaryDirectory();var channel=new Channel{PostTradeFreshnessFailures=10};var driver=Driver(channel,directory.Path);
var confirmed=new List<ConfirmedTradeReceipt>();driver.TradeConfirmed+=confirmed.Add;
await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(5,channel.MarketReads);Check.Equal(1,confirmed.Count);
Check.Equal(receipt,confirmed.Single());Check.Equal(1,receipt.CargoAfter);Check.Equal(9932m,(decimal)receipt.CreditsAfter);
Check.Equal(1,Directory.GetFiles(directory.Path,"market-refresh-pending-*.json").Length);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
// The next purchase must still obtain its own fresh pre-order quote.
var refused=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){refused=true;}
Check.That(refused);Check.Equal(1,channel.Mutations);
}
[Test] public async Task MarketIdentityRejectionDoesNotUseTheFreshnessRetry()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10,ReadFailureCode=2};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidOperationException){stopped=true;}
Check.That(stopped);Check.Equal(1,channel.MarketReads);Check.Equal(0,channel.Mutations);
}
[Test] public async Task TheKnownUnsupportedMarketSchemaIsRejectedBeforeCreatingAnyOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true,MarketWalletOptional=false};var driver=Driver(channel,directory.Path,new(16,100));var refused=false;
try{await driver.PreflightAsync(default);}catch(NativeTradeSdkUpgradeRequiredException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task SourceQuoteCrossChecksTheActualClientWithoutInventingAProfile()
{
using var directory=new TemporaryDirectory();var channel=new Channel{MarketHasCredits=false};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(0,channel.ProfileRefreshes);Check.Equal(1,channel.Mutations);Check.Equal(1,channel.Player.CargoCount);Check.Equal(9932L,channel.Credits);
}
[Test] public async Task ServerCargoMismatchCannotStartAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServerCargoOffset=1};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task MissingServerInventoryCannotBeTreatedAsAnEmptyHold()
{
using var directory=new TemporaryDirectory();var channel=new Channel{MissingServerCargo=true};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task StaleClientWalletCannotBeUsedAsTheNextPurchaseBalance()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServerCreditsOffset=-26};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task ParallelTradeAndMaintenanceShareTheActualWalletAcrossDrivers()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();
var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
var services=new List<ConfirmedDockedServiceReceipt>();one.ServiceConfirmed+=services.Add;
var maintenance=one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
var trade=two.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
await Task.WhenAll(maintenance,trade);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Overlapping);Check.Equal(1,channel.MaximumPending);
Check.Equal(9927L,channel.Credits);Check.Equal(9927m,channel.Player.Credits);Check.Equal(1,channel.Player.CargoCount);
Check.Equal(2,Directory.GetFiles(directory,"*.confirmed.json").Length);
Check.Equal(1,services.Count);Check.Equal(5m,services[0].ToCashEntry().Amount);Check.Equal(TradeCashKind.Fuel,services[0].ToCashEntry().Kind);
}
[Test] public async Task LostAcknowledgementQueriesTheSameOrderWithoutChargingAgain()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel{DropAcknowledgement=true,FuelNeeded=false};
var driver=Driver(channel,directory);await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(9932L,receipt.CreditsAfter);Check.Equal(1,receipt.CargoAfter);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
}
[Test] public async Task StationsWithoutMaintenanceDoNotSubmitAnyServiceRequests()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();channel.Player=channel.Player with{StationServices=[]};
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);Check.Equal(0,channel.Mutations);
Check.Equal(0,channel.ServiceReads);
}
[Test] public async Task AllServicesUseOneQuoteAndOneFinalCompletionRead()
{
var channel=new Channel{RepairNeeded=12,IntegrityNeeded=17,IntegrityWear=1,RearmNeeded=4};
var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-batch-"+Guid.NewGuid().ToString("N")));
var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(2,channel.ServiceReads);Check.Equal(4,channel.Mutations);Check.Equal(4,receipts.Count);Check.Equal(9956L,channel.Credits);
Check.Equal(44m,receipts.Sum(receipt=>receipt.ToCashEntry().Amount));Check.Equal(1,channel.MaximumPending);
}
[Test] public async Task AlreadyMaintainedShipNeedsOnlyOneReadAndNoOrders()
{
var channel=new Channel{FuelNeeded=false};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-clean-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
private static JsonElement ServiceReadFailure(ulong generation,int mutations=0,bool reconciliation=false,int kind=3,long market=1,int error=1)
=>JsonSerializer.SerializeToElement(new{state=4,generation,marketId=market,error,mutationsSent=mutations,reconciliationRequired=reconciliation,
serviceKind=kind,quantityDelta=0,serverConfirmed=false,clientApplied=false});
[Test]public async Task StaleServiceReadRecoversBeforeOneMaintenanceOrderAndPersistsReadOnlyEvidence()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(attempt,id)=>attempt<=2?ServiceReadFailure(id):null};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(4,channel.ServiceReads);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
var failures=Directory.GetFiles(directory.Path,"service-readiness-*.json");Check.Equal(2,failures.Length);
foreach(var file in failures){using var json=JsonDocument.Parse(File.ReadAllText(file));Check.That(json.RootElement.GetProperty("readOnly").GetBoolean()
&&json.RootElement.GetProperty("safeToRepeatRead").GetBoolean()&&!json.RootElement.GetProperty("monetaryRequestReplayed").GetBoolean());}
}
[Test]public async Task ServiceReadinessBudgetStopsAtEightUnsubmittedReadsWithoutAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(_,id)=>ServiceReadFailure(id)};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);NativeServiceSnapshotUnavailableException? failure=null;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(NativeServiceSnapshotUnavailableException error){failure=error;}
Check.That(failure is not null&&failure.Attempts==NativeServiceSnapshotUnavailableException.MaximumAttempts&&File.Exists(failure.DiagnosticPath));
Check.Equal(8,channel.ServiceReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test]public async Task AServiceMutationPendingOrderForeignMarketOrNonFreshnessFailureNeverRetriesTheRead()
{
foreach(var scenario in new[]{"sent","pending","wrong-kind","wrong-market","other-error","missing-proof"})
{
using var directory=new TemporaryDirectory();var channel=new Channel();
channel.ServiceReplyFactory=(_,id)=>scenario switch
{"sent"=>ServiceReadFailure(id,mutations:1),"pending"=>ServiceReadFailure(id,reconciliation:true),"wrong-kind"=>ServiceReadFailure(id,kind:4),
"wrong-market"=>ServiceReadFailure(id,market:2),"other-error"=>ServiceReadFailure(id,error:2),_=>JsonSerializer.SerializeToElement(new{state=4,generation=id,error=1})};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var refused=false;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
}
[Test]public async Task SessionDockingChangesAndUserCancellationCannotStartASecondServiceRead()
{
foreach(var scenario in new[]{"commander","ship","session","docking","cancel"})
{
using var directory=new TemporaryDirectory();using var stop=new CancellationTokenSource();var channel=new Channel();
channel.ServiceReplyFactory=(_,id)=>
{
if(scenario=="cancel")stop.Cancel();
else channel.Player=scenario switch{"commander"=>channel.Player with{CommanderId="other"},"ship"=>channel.Player with{CurrentShipId=8},
"session"=>channel.Player with{LoadGameAt=DateTimeOffset.UtcNow},_=>channel.Player with{Docked=false}};
return ServiceReadFailure(id);
};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var stopped=false;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,stop.Token);}
catch(Exception error)when(error is InvalidOperationException or OperationCanceledException){stopped=true;}
Check.That(stopped);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
}
[Test] public async Task ConcurrentMaintenanceDoesNotChargeTheSameQuotedServiceTwice()
{
var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-services-concurrent-"+Guid.NewGuid().ToString("N"));
var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
await Task.WhenAll(one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default),two.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default));
Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.Equal(3,channel.ServiceReads);
}
[Test]public async Task FlightRefuellingOnlyPaysFuelAndKeepsItsConfirmedReceiptWithoutRepairingOrRearming()
{
using var directory=new TemporaryDirectory();var channel=new Channel{RepairNeeded=50001,IntegrityNeeded=20,RearmNeeded=15};
var driver=Driver(channel,directory.Path);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;
await driver.PreflightAsync(default);await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.That(!channel.FuelNeeded);
Check.Equal(50001L,channel.RepairNeeded);Check.Equal(20L,channel.IntegrityNeeded);Check.Equal(15L,channel.RearmNeeded);
Check.Equal(1,receipts.Count);Check.Equal("refuel",receipts[0].Action);Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);Check.Equal(1,channel.Mutations);
}
[Test]public async Task FlightRefuellingCannotSpendReservedFundsOrPublishAnUnrelatedServiceAsFuel()
{
using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true};
var driver=Driver(channel,directory.Path,new(16,9998));await driver.PreflightAsync(default);var rejected=false;
try{await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);}catch(InvalidOperationException){rejected=true;}
Check.That(rejected);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
var observed=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=observed.Add;
Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"repair",1,10000,9995,DateTimeOffset.UtcNow)));
Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new("invalid","refuel",1,10000,9995,DateTimeOffset.UtcNow)));
Check.Equal(0,observed.Count);
driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"refuel",1,10000,9995,DateTimeOffset.UtcNow));Check.Equal(1,observed.Count);
}
[Test] public async Task OutstandingFinalServiceBlocksDepartureButKeepsItsConfirmedExpense()
{
var channel=new Channel{KeepServiceOutstanding=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-services-incomplete-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(1,channel.Mutations);Check.Equal(1,receipts.Count);Check.Equal(5m,receipts[0].ToCashEntry().Amount);
Check.Equal(2,channel.ServiceReads);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
}
[Test] public async Task InvalidBatchQuoteRefusesEveryServiceBeforeAnyDebit()
{
var channel=new Channel{RepairNeeded=50001};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-limit-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
}
[Test] public async Task OlderPluginRefreshesRequiredQuoteBeforeEachDebit()
{
var channel=new Channel{QuoteSnapshotsSupported=false,RepairNeeded=12};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-legacy-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(2,channel.Mutations);Check.Equal(9983L,channel.Credits);Check.Equal(4,channel.ServiceReads);
}
private static NativeTradeCommandDriver JourneyDriver(Channel channel,string directory)
{
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,async(destination,check,token)=>
{
await check(token);channel.ActualMarket=(ulong)destination.MarketId;channel.Player=channel.Player with{MarketId=destination.MarketId};
},_=>Task.CompletedTask);
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
}
[Test] public async Task ProductionJourneyReconcilesServicesAndNeverReplaysACompletedBatch()
{
var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-production-journey-"+Guid.NewGuid().ToString("N"));
var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
var result=await journey.RunAsync(Buy().Leg,default);
Check.That(result.Passed);Check.Equal(7m,result.Profit.NetProfit);Check.Equal(10007L,result.ClosingCredits);Check.Equal(3,channel.Mutations);
var completed=await journey.RunAsync(Buy().Leg,default);Check.That(completed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(0,channel.Cargo);
}
[Test] public async Task AppliedPurchaseSurvivesFollowupFailureAndResumesWithoutAnotherBuy()
{
var channel=new Channel{FailPostTradeReadOnce=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-production-resume-"+Guid.NewGuid().ToString("N"));
var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
var failed=false;try{await journey.RunAsync(Buy().Leg,default);}catch(InvalidOperationException){failed=true;}
Check.That(failed);var checkpoint=TradeJourneyCheckpoints.Load(Path.Combine(directory,"journey.json"));Check.That(checkpoint.Bought is not null&&checkpoint.Sold is null);
var resumed=await new NativeTradeJourney(JourneyDriver(channel,Path.Combine(directory,"operations")),()=>channel.Player,_=>Task.CompletedTask,directory).RunAsync(Buy().Leg,default);
Check.That(resumed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(7m,resumed.Profit.NetProfit);Check.Equal(0,channel.Cargo);
Check.Equal(2,Directory.GetFiles(Path.Combine(directory,"attempts"),"*.json").Length);
}
[Test] public void VerifiedNativeWalletCannotOverrideAnotherSessionOrNewerJournalBalance()
{
var channel=new Channel();var at=DateTimeOffset.UtcNow;var observation=new NativeWalletObservation(channel.Player.CommanderId,7,channel.Player.LoadGameAt!.Value,9000,at);
Check.Equal(9000m,observation.Project(channel.Player).Credits);
var newer=channel.Player with{Credits=11000,CreditsAt=at.AddSeconds(1)};Check.That(ReferenceEquals(newer,observation.Project(newer)));
var other=channel.Player with{LoadGameAt=channel.Player.LoadGameAt!.Value.AddMinutes(1)};Check.That(ReferenceEquals(other,observation.Project(other)));
}
[Test] public async Task JournalDockedWaitsForNativeDockingBeforeReadingOrTrading()
{
var channel=new Channel{DockingPendingReads=2,FuelNeeded=false};
var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-readiness-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.That(channel.ReadinessProbes>=3);Check.Equal(2,channel.MarketReads);Check.Equal(1,channel.Mutations);
}
[Test] public async Task DifferentNativeMarketRefusesAllMarketAndMonetaryRequests()
{
var channel=new Channel{ActualMarket=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-market-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);
}
[Test] public async Task CancellingNativeDockingWaitSubmitsNoOrder()
{
using var stop=new CancellationTokenSource();var channel=new Channel{DockingPendingReads=int.MaxValue,OnReadinessProbe=stop.Cancel};
var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-cancel-"+Guid.NewGuid().ToString("N"));var driver=Driver(channel,directory);await driver.PreflightAsync(default);var cancelled=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory,"*.pending.json").Length);
}
[Test] public async Task MaintenanceWaitsForNativeDockingBeforeQuotingAndCharging()
{
var channel=new Channel{DockingPendingReads=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-maintenance-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.That(channel.ReadinessProbes>=3);Check.That(channel.ServiceReads>0);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
}
private static Channel CgChannel()
{
var channel=new Channel{Credits=25000000,Commodity="uranium",CommodityId=128049172,UnitBuy=6000,UnitSell=57104,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{Credits=25000000,CargoCapacity=192};return channel;
}
private static TradeLeg CgLeg(int quantity=192)
=>new(){From=Check.Station(1),To=Check.Station(4210924803),Orders=[new("uranium","Uranium",quantity,6000,57104)]};
[Test] public async Task ExplicitCgBatchesReconcileFullQuantityCostAndActualSaleProceeds()
{
var channel=CgChannel();var directory=Path.Combine(Path.GetTempPath(),"xfe-cg-batch-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory,new(192,1000000));await driver.PreflightAsync(default);
var leg=CgLeg();var buy=new TradeAction(TradeActionKind.Buy,leg,leg.Orders[0]);
var bought=await driver.ExecuteTradeAsync(buy,_=>Task.CompletedTask,default);bought.Validate(buy,channel.Player,channel.Player.LoadGameAt!.Value);
Check.Equal(23848000L,bought.CreditsAfter);Check.Equal(192,bought.QuantityDelta);Check.Equal(192,channel.Cargo);
channel.ActualMarket=4210924803;channel.Player=channel.Player with{MarketId=4210924803};
var sell=new TradeAction(TradeActionKind.Sell,leg,leg.Orders[0]);var sold=await driver.ExecuteTradeAsync(sell,_=>Task.CompletedTask,default);sold.Validate(sell,channel.Player,channel.Player.LoadGameAt!.Value);
Check.Equal(34811968L,sold.CreditsAfter);Check.Equal(-192,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(2,channel.Mutations);
}
[Test] public async Task DefaultAcceptanceDriverStillRefusesCgBatches()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-default-"+Guid.NewGuid().ToString("N")));await driver.PreflightAsync(default);
var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);
}
[Test] public async Task CgBatchCannotSpendTheReservedWalletBalance()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-reserve-"+Guid.NewGuid().ToString("N")),new(192,24000000));await driver.PreflightAsync(default);
var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
}
[Test] public async Task CgBatchRefusesAQuantityAboveTheVerifiedHold()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-capacity-"+Guid.NewGuid().ToString("N")),new(192,1000000));await driver.PreflightAsync(default);
var leg=CgLeg(193);var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
}
[Test] public async Task FormalTradeUsesTheEntireCurrentShipAtArbitraryDestination()
{
using var directory=new TemporaryDirectory();
foreach(var size in new[]{1,192,752,2048})
{
var channel=new Channel{Credits=10000000,Commodity="gold",CommodityId=123456,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{Ship="type9",CargoCapacity=size,Credits=channel.Credits};
var order=new TradeOrder("gold","Gold",size,68,80);var leg=new TradeLeg{From=Check.Station(1),To=Check.Station(99),Orders=[order]};
var driver=Driver(channel,Path.Combine(directory.Path,size.ToString()),new(size,1000));await driver.PreflightAsync(default);
var bought=await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,order),_=>Task.CompletedTask,default);
Check.Equal(size,bought.QuantityDelta);channel.Player=channel.Player with{MarketId=99};channel.ActualMarket=99;
var sold=await driver.ExecuteTradeAsync(new(TradeActionKind.Sell,leg,order),_=>Task.CompletedTask,default);
Check.Equal(-size,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(10000000L+12*size,channel.Credits);
}
}
[Test] public async Task OrdinaryEngineRepeatsBothDirectionsWithoutAnyCgCalls()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var navigationCalls=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>{if(++navigationCalls==1)throw new InvalidOperationException("Stop flight control before selecting a local destination");channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=2};
var cycles=0;engine.Progress+=p=>{if(p.Phase=="cycle-completed")cycles++;};
await engine.RunAsync(default);Check.That(navigationCalls>=3);Check.Equal(2,cycles);Check.Equal(4,channel.Mutations);Check.Equal(0,channel.Cargo);Check.Equal(1L,channel.Player.MarketId!.Value);
}
[Test] public async Task DepartureQueueRecoveryContinuesEmptyOrLoadedWithoutRepeatingOrders()
{
foreach(var loaded in new[]{false,true})
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var failed=false;var recovered=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if(!failed&&(channel.Cargo>0)==loaded)
{failed=true;channel.Player=channel.Player with{Docked=false};throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));}
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
Task Recover(CancellationToken _)
{recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,Recover){MaximumCycles=1};
await engine.RunAsync(default);
Check.That(failed);Check.Equal(1,recovered);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
}
}
[Test] public async Task HealthTelemetryRefusalAfterPurchaseWaitsAndContinuesTheOriginalCargo()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var refusals=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if(channel.Cargo>0&&refusals++<2)throw new InvalidOperationException("Native main-ship health unavailable or stale");
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
await engine.RunAsync(default);
Check.That(refusals>=3);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
}
[Test] public async Task TransientFlightReadsDoNotSpendEmptyOrLoadedSessionRecoveryAttempts()
{
foreach(var loaded in new[]{false,true})foreach(var departure in new[]{false,true})
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var reads=0;var recovered=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if((channel.Cargo>0)==loaded&&++reads<=5)
{
if(reads<=4)throw new FlightTelemetryUnavailableException("fixture transient during scene transition");
channel.Player=channel.Player with{Docked=false};
if(departure)throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));
throw new DockingHandoffException("fixture landing handoff",station.MarketId);
}
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,_=>
{recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}){MaximumCycles=1};
await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(8));
Check.Equal(1,recovered);Check.That(reads>=6);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
}
}
[Test] public async Task CgEngineUsesUserDestinationAndConfirmsItsActualContribution()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>{channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path,
Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
await engine.RunAsync(default);Check.Equal(752L,channel.Contribution);Check.That(channel.CgReads>=2);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
}
[Test]public Task DynamicCgSupplyExcludesAKnownFineWithoutChangingTheUserConfigurationOrPayingIt()=>VerifyDynamicFineSelection(false);
[Test]public Task AFineNewlyConfirmedOnArrivalChangesSourceBeforeReadingItsMarketOrBuying()=>VerifyDynamicFineSelection(true);
[Test]public Task DynamicSelectionCannotIgnoreTheConfirmedFineExclusion()=>VerifyDynamicFineSelection(false,ignoreExclusion:true);
[Test]public Task AnUnconfirmedOriginalOperationPreventsFineBasedSourceReplacement()=>VerifyDynamicFineSelection(false,unconfirmed:true);
private static async Task VerifyDynamicFineSelection(bool fineOnArrival,bool ignoreExclusion=false,bool unconfirmed=false)
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
channel.Player=channel.Player with{CargoCapacity=16,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var journal=directory.File("Journal.dynamic-fine.log");var operations=Path.Combine(directory.Path,"operations");
Task AddFine()=>File.WriteAllLinesAsync(journal,new object[]
{
new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Bowen authority"}},
new{@event="CommitCrime",Faction="Bowen authority",Fine=400}
}.Select(value=>JsonSerializer.Serialize(value)));
if(!fineOnArrival)await AddFine();
var visited=new List<long>();var selections=0;var restrictedArrival=false;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,operations,async(station,_,token)=>
{
visited.Add(station.MarketId);channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;
if(fineOnArrival&&station.MarketId==1)await AddFine();
if(unconfirmed&&station.MarketId==99)await File.WriteAllTextAsync(Path.Combine(operations,"still-unknown.pending.json"),"{}",token);
},_=>Task.CompletedTask,batchPolicy:new(16,1000)){JournalDirectory=directory.Path};
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);
var originalSource=Check.Station(1);var destination=Check.Station(99);var configuration=new AutomaticTradeConfiguration
{Source=originalSource,Destination=destination,WorkingDirectory=directory.Path,Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
Task<AutomaticSupplyChoice?> Select(AutomaticSupplyRequest request,CancellationToken token)
{
token.ThrowIfCancellationRequested();selections++;
Check.Equal(channel.Player.CommanderId,request.Player.CommanderId);Check.Equal(99L,request.Configuration.Destination.MarketId);
var market=request.ExcludedSourceMarkets.Contains(1)&&!ignoreExclusion?2L:1L;
if(!fineOnArrival||selections>1)Check.That(request.ExcludedSourceMarkets.Contains(1));
var source=Check.Station(market);var order=new TradeOrder("biowaste","Biowaste",16,68,80);
var delivery=new TradeLeg{From=source,To=destination,Orders=[order],EstimatedMinutes=5};
return Task.FromResult<AutomaticSupplyChoice?>(new(source,delivery,100000,100000,16,5,0,192,10));
}
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,selectSource:Select){MaximumCycles=1};
engine.Progress+=progress=>restrictedArrival|=progress.Phase=="source-access-restricted";
Exception? failure=null;using var budget=new CancellationTokenSource(TimeSpan.FromSeconds(8));
try{await engine.RunAsync(budget.Token);}catch(Exception error){failure=error;}
Check.That(!budget.IsCancellationRequested,"The engine must finish or reject the exact source proof, not wait indefinitely on its fine.");
if(ignoreExclusion||unconfirmed)
{
Check.That(failure is not null);Check.Equal(0,channel.Mutations);
if(ignoreExclusion){Check.That(failure is InvalidDataException);Check.Equal(1,selections);}
if(unconfirmed){Check.Equal(0,selections);Check.Equal("{}",await File.ReadAllTextAsync(Path.Combine(operations,"still-unknown.pending.json")));}
}
else
{
if(failure is not null)throw failure;
Check.That(selections>=2);Check.That(visited.Contains(2));Check.Equal(fineOnArrival,restrictedArrival);
Check.That(!channel.MarketReadMarkets.Contains(1));Check.Equal(2,channel.Mutations);Check.Equal(16L,channel.Contribution);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
}
Check.Equal(1L,configuration.Source.MarketId);Check.Equal(99L,configuration.Destination.MarketId);
Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
}
[Test] public void CgBatchRequiresAnActualWalletEvenIfTheReliabilityFlagWasSet()
{
var channel=CgChannel();var leg=CgLeg();var policy=new NativeTradeBatchPolicy(192,1000000);
Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=null,CreditsReliable=true}));
Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=-1,CreditsReliable=true}));
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class NativeTradeCommandDriverTests
{
private sealed class Channel:IOriginalCommerceChannel
{
public string Identity{get;}=Guid.NewGuid().ToString();
public PlayerState Player=new(){Ship="Type9",InGame=true,Docked=true,MarketId=1,CommanderId="native-commerce-test",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),Credits=10000,
CreditsReliable=true,CargoReliable=true,CargoCapacity=16,StationServicesKnown=true,StationServices=["refuel","repair","rearm"]};
public long Credits=10000;public int Cargo,Mutations,Overlapping,MaximumPending,DockingPendingReads,ReadinessProbes,MarketReads,ServiceReads;public bool FuelNeeded=true,DropAcknowledgement;
public List<long> MarketReadMarkets=[];
public ulong ActualMarket=1;public Action? OnReadinessProbe;
public long RepairNeeded,IntegrityNeeded,RearmNeeded;public int IntegrityWear;public bool KeepServiceOutstanding;
public bool QuoteSnapshotsSupported=true,FailPostTradeReadOnce;public int FreshnessFailures,PostTradeFreshnessFailures;public int ReadFailureCode=1;private bool currentQuote;private ulong quotedGeneration;private readonly HashSet<string> consumedServices=[];
public Func<int,ulong,JsonElement?>? ServiceReplyFactory;
public bool ProfileRefreshSupported=true,MarketWalletOptional=true,MarketHasCredits=true,MissingServerCargo;public long ServerCreditsOffset;public int ProfileRefreshes,ServerCargoOffset;
public string Commodity="biowaste";public uint CommodityId=128049244;public int UnitBuy=68,UnitSell=80;public bool BatchSupported;
public bool Goals;public long Contribution;public int CgReads;
private ulong generation;private int pending;
private readonly Dictionary<ulong,JsonElement> replies=[];private readonly Dictionary<string,ulong> keys=[];
private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value);
public async Task<JsonElement> CallAsync(object request,CancellationToken token)
{
var command=Json(request);var op=command.GetProperty("op").GetString();
if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,formalCommodityTrading=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported,marketWalletOptional=MarketWalletOptional});
if(op is "cg.read" or "cg.read.mine")
{
if(!Goals||command.GetProperty("marketId").GetInt64()!=99)throw new InvalidOperationException("CG was queried outside the configured destination");
CgReads++;string Hex(string text)=>Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text));
var body=JsonSerializer.Serialize(new{initiatives=new Dictionary<string,object>{["42"]=new{id=42,activityType="tradelist",goalType="trade",market_id=99,
expiry=DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds(),complete=false,final_state="active",hidden="0",systemName=Hex("System99"),marketName=Hex("Station99"),
bulletin=new{title=Hex("Another trade CG")},@dynamic=new{commanderContributionQty=Contribution,commanderSubscribed=true,complete=false}}}});
var id=++generation;replies[id]=Json(new{state=3,generation=id,kind=op=="cg.read.mine"?2:0,mutationsSent=0,fresh=true,responseBody=body,
proof=Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(body)))});return Json(new{generation=id});
}
if(op=="cg.status")return replies[command.GetProperty("generation").GetUInt64()];
if(op=="commerce.inventory")return Json(new{verified=true,count=Cargo,capacity=Player.CargoCapacity,credits=Credits});
if(op=="commerce.docked.status")
{
ReadinessProbes++;OnReadinessProbe?.Invoke();var ready=DockingPendingReads--<=0;
return Json(new{ready,serviceReady=ready,actualMarketId=ActualMarket,stage=ready?"ready":"docking",mutationsSent=0});
}
if(op=="commerce.market.status")return replies[command.GetProperty("generation").GetUInt64()];
if(op=="commerce.operation.status")
{
var key=command.GetProperty("operationKey").GetString()!;
return keys.TryGetValue(key,out var known)?Json(new{known=true,receiptAvailable=true,generation=known}):Json(new{known=false});
}
if(op=="station.services.inspect")
{
ServiceReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Service prices requested before native docking settled.");
var id=++generation;quotedGeneration=id;currentQuote=true;consumedServices.Clear();
if(ServiceReplyFactory?.Invoke(ServiceReads,id) is {} overridden)
{replies[id]=overridden;return Json(new{generation=id,mutationsSent=0});}
replies[id]=Json(new{state=3,generation=id,servicePrices=new{fuel=new{costToRefuel=FuelNeeded?5:0},repair=new{costToRepair=RepairNeeded,shipIntegrityCosts=new{costToRepair=IntegrityNeeded,wear=IntegrityWear}},ammunition=new{costToRearm=RearmNeeded}}});return Json(new{generation=id});
}
if(op=="commerce.market.read")
{
MarketReadMarkets.Add(command.GetProperty("marketId").GetInt64());
if(FailPostTradeReadOnce&&Cargo>0){FailPostTradeReadOnce=false;throw new IOException("Post-order read failed after original cargo/wallet confirmation.");}
MarketReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Market requested before native docking settled.");
currentQuote=false;
if(command.TryGetProperty("refreshClient",out var refresh)&&refresh.ValueKind==JsonValueKind.True)ProfileRefreshes++;
var id=++generation;
if(FreshnessFailures-->0){replies[id]=Json(new{generation=id,state=4,error=ReadFailureCode,mutationsSent=0,reconciliationRequired=false});return Json(new{generation=id});}
var response=new Dictionary<string,object?>{["state"]=3,["generation"]=id,["profileApplied"]=false,["serverCargoCount"]=Cargo+ServerCargoOffset,
["commodityCount"]=1,["commodities"]=new[]{new{id=CommodityId,name=Commodity,buyPrice=UnitBuy,sellPrice=UnitSell,stock=100000,demand=100000}}};
if(!MissingServerCargo)response["cargo"]=Cargo+ServerCargoOffset==0?new Dictionary<string,int>():new(){[Commodity]=Cargo+ServerCargoOffset};
if(MarketHasCredits)response["serverCredits"]=Credits+ServerCreditsOffset;
replies[id]=Json(response);return Json(new{generation=id});
}
if(op is "station.services" or "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal")
{
if(op=="station.services")
{
var service=command.GetProperty("action").GetString()!;
var snapshot=command.TryGetProperty("quoteGeneration",out var quoteId);
if(snapshot? !QuoteSnapshotsSupported||quoteId.GetUInt64()!=quotedGeneration||consumedServices.Contains(service):!currentQuote)
throw new InvalidOperationException("Fresh original service quote required.");
consumedServices.Add(service);
}
currentQuote=false;
var count=Interlocked.Increment(ref pending);MaximumPending=Math.Max(MaximumPending,count);if(count>1)Overlapping++;
var before=Credits;var cargoBefore=Cargo;await Task.Delay(25,token);var trade=op is "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal";var delta=trade?command.GetProperty("quantityDelta").GetInt32():0;
var action=trade?"":command.GetProperty("action").GetString();
var price=trade?delta>0?UnitBuy:UnitSell:action switch{"refuel"=>5,"repair"=>Math.Max(RepairNeeded,10),"integrity"=>IntegrityNeeded,"rearm"=>Math.Max(RearmNeeded,10),_=>throw new InvalidOperationException("Unexpected service.")};
Credits-=trade?price*delta:price;
if(trade){if(Goals&&delta<0&&Player.MarketId==99)Contribution-=delta;Cargo+=delta;Player=Player with{CargoCount=Cargo,Inventory=Cargo>0?[new(Commodity,Commodity,Cargo)]:[]};}
else if(!KeepServiceOutstanding)
switch(action){case "refuel":FuelNeeded=false;break;case "repair":RepairNeeded=0;break;case "integrity":IntegrityNeeded=0;IntegrityWear=0;break;case "rearm":RearmNeeded=0;break;}
var id=++generation;var key=command.GetProperty("operationKey").GetString()!;Mutations++;keys[key]=id;
if(trade&&PostTradeFreshnessFailures>0){FreshnessFailures=PostTradeFreshnessFailures;PostTradeFreshnessFailures=0;}
replies[id]=Json(new{state=3,generation=id,serverConfirmed=true,clientApplied=true,creditsBefore=before,credits=Credits,cargoBefore,cargoAfter=Cargo,after=Cargo,unitPrice=price});Interlocked.Decrement(ref pending);
if(DropAcknowledgement){DropAcknowledgement=false;throw new IOException("Lost acknowledgement after server acceptance.");}
return Json(new{generation=id});
}
throw new InvalidOperationException("Unexpected native operation: "+op);
}
}
private static NativeTradeCommandDriver Driver(Channel channel,string directory,NativeTradeBatchPolicy? batch=null,string? journals=null)
{
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,(_,_,_)=>throw new InvalidOperationException("No flight in this test."),_=>Task.CompletedTask,batchPolicy:batch){JournalDirectory=journals};
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
}
private static TradeAction Buy()=>new(TradeActionKind.Buy,new(){From=Check.Station(1),To=Check.Station(2),Orders=[new("biowaste","Biowaste",1,68,80)]},new("biowaste","Biowaste",1,68,80));
private static Task WriteLocalFine(TemporaryDirectory directory,Channel channel)=>File.WriteAllLinesAsync(directory.File("Journal.fine.log"),new object[]
{
new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Transit authority"}},
new{@event="CommitCrime",Faction="Transit authority",Fine=400}
}.Select(value=>JsonSerializer.Serialize(value)));
[Test]public async Task ATransitPortFineCannotBlockSessionPreflightOrFlightToTheActualTradingStation()
{
using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);var travelled=false;var messages=new List<string>();
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory.Path,
(station,_,_)=>{Check.Equal(2L,station.MarketId);travelled=true;return Task.CompletedTask;},_=>Task.CompletedTask){JournalDirectory=directory.Path};
driver.Status+=messages.Add;
using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2));
await driver.PreflightAsync(timeout.Token);
await driver.ExecuteAsync(new(TradeActionKind.Travel,Buy().Leg,null),_=>Task.CompletedTask,timeout.Token);
Check.That(travelled);Check.Equal(0,channel.Mutations);Check.Equal(0,messages.Count);
Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
}
[Test]public async Task TheSameFineStillBlocksAnActualPurchaseUntilPaidWithoutSubmittingOrRepeatingAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);
var driver=Driver(channel,directory.Path,journals:directory.Path);var messages=new List<string>();driver.Status+=messages.Add;
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2)))await driver.PreflightAsync(timeout.Token);
var cancelled=false;
using(var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(200)))
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(1,messages.Count);
Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
await File.AppendAllLinesAsync(directory.File("Journal.fine.log"),[JsonSerializer.Serialize(new{@event="PayFines",Faction="Transit authority",Amount=400})]);
var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task TransientMarketFreshnessFailureRetriesTheReadWithoutDuplicatingTheTrade()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=2};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);Check.Equal(4,channel.MarketReads);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
Check.That(Directory.GetFiles(directory.Path,"*.pending.json").All(path=>new FileInfo(path).Length>0));
}
[Test] public async Task RepeatedMarketFreshnessFailuresStopAfterFourReadsBeforeAnyOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){stopped=true;}
Check.That(stopped);Check.Equal(4,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task AConfirmedOrderSurvivesOptionalMarketFreshnessFailureWithoutResubmission()
{
using var directory=new TemporaryDirectory();var channel=new Channel{PostTradeFreshnessFailures=10};var driver=Driver(channel,directory.Path);
var confirmed=new List<ConfirmedTradeReceipt>();driver.TradeConfirmed+=confirmed.Add;
await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(5,channel.MarketReads);Check.Equal(1,confirmed.Count);
Check.Equal(receipt,confirmed.Single());Check.Equal(1,receipt.CargoAfter);Check.Equal(9932m,(decimal)receipt.CreditsAfter);
Check.Equal(1,Directory.GetFiles(directory.Path,"market-refresh-pending-*.json").Length);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
// The next purchase must still obtain its own fresh pre-order quote.
var refused=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){refused=true;}
Check.That(refused);Check.Equal(1,channel.Mutations);
}
[Test] public async Task MarketIdentityRejectionDoesNotUseTheFreshnessRetry()
{
using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10,ReadFailureCode=2};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidOperationException){stopped=true;}
Check.That(stopped);Check.Equal(1,channel.MarketReads);Check.Equal(0,channel.Mutations);
}
[Test] public async Task TheKnownUnsupportedMarketSchemaIsRejectedBeforeCreatingAnyOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true,MarketWalletOptional=false};var driver=Driver(channel,directory.Path,new(16,100));var refused=false;
try{await driver.PreflightAsync(default);}catch(NativeTradeSdkUpgradeRequiredException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task SourceQuoteCrossChecksTheActualClientWithoutInventingAProfile()
{
using var directory=new TemporaryDirectory();var channel=new Channel{MarketHasCredits=false};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(0,channel.ProfileRefreshes);Check.Equal(1,channel.Mutations);Check.Equal(1,channel.Player.CargoCount);Check.Equal(9932L,channel.Credits);
}
[Test] public async Task ServerCargoMismatchCannotStartAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServerCargoOffset=1};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task MissingServerInventoryCannotBeTreatedAsAnEmptyHold()
{
using var directory=new TemporaryDirectory();var channel=new Channel{MissingServerCargo=true};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task StaleClientWalletCannotBeUsedAsTheNextPurchaseBalance()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServerCreditsOffset=-26};var driver=Driver(channel,directory.Path);
await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task ParallelTradeAndMaintenanceShareTheActualWalletAcrossDrivers()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();
var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
var services=new List<ConfirmedDockedServiceReceipt>();one.ServiceConfirmed+=services.Add;
var maintenance=one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
var trade=two.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
await Task.WhenAll(maintenance,trade);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Overlapping);Check.Equal(1,channel.MaximumPending);
Check.Equal(9927L,channel.Credits);Check.Equal(9927m,channel.Player.Credits);Check.Equal(1,channel.Player.CargoCount);
Check.Equal(2,Directory.GetFiles(directory,"*.confirmed.json").Length);
Check.Equal(1,services.Count);Check.Equal(5m,services[0].ToCashEntry().Amount);Check.Equal(TradeCashKind.Fuel,services[0].ToCashEntry().Kind);
}
[Test] public async Task LostAcknowledgementQueriesTheSameOrderWithoutChargingAgain()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel{DropAcknowledgement=true,FuelNeeded=false};
var driver=Driver(channel,directory);await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(9932L,receipt.CreditsAfter);Check.Equal(1,receipt.CargoAfter);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
}
[Test] public async Task StationsWithoutMaintenanceDoNotSubmitAnyServiceRequests()
{
var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();channel.Player=channel.Player with{StationServices=[]};
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);Check.Equal(0,channel.Mutations);
Check.Equal(0,channel.ServiceReads);
}
[Test] public async Task AllServicesUseOneQuoteAndOneFinalCompletionRead()
{
var channel=new Channel{RepairNeeded=12,IntegrityNeeded=17,IntegrityWear=1,RearmNeeded=4};
var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-batch-"+Guid.NewGuid().ToString("N")));
var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(2,channel.ServiceReads);Check.Equal(4,channel.Mutations);Check.Equal(4,receipts.Count);Check.Equal(9956L,channel.Credits);
Check.Equal(44m,receipts.Sum(receipt=>receipt.ToCashEntry().Amount));Check.Equal(1,channel.MaximumPending);
}
[Test] public async Task AlreadyMaintainedShipNeedsOnlyOneReadAndNoOrders()
{
var channel=new Channel{FuelNeeded=false};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-clean-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
private static JsonElement ServiceReadFailure(ulong generation,int mutations=0,bool reconciliation=false,int kind=3,long market=1,int error=1)
=>JsonSerializer.SerializeToElement(new{state=4,generation,marketId=market,error,mutationsSent=mutations,reconciliationRequired=reconciliation,
serviceKind=kind,quantityDelta=0,serverConfirmed=false,clientApplied=false});
[Test]public async Task StaleServiceReadRecoversBeforeOneMaintenanceOrderAndPersistsReadOnlyEvidence()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(attempt,id)=>attempt<=2?ServiceReadFailure(id):null};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(4,channel.ServiceReads);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
var failures=Directory.GetFiles(directory.Path,"service-readiness-*.json");Check.Equal(2,failures.Length);
foreach(var file in failures){using var json=JsonDocument.Parse(File.ReadAllText(file));Check.That(json.RootElement.GetProperty("readOnly").GetBoolean()
&&json.RootElement.GetProperty("safeToRepeatRead").GetBoolean()&&!json.RootElement.GetProperty("monetaryRequestReplayed").GetBoolean());}
}
[Test]public async Task ServiceReadinessBudgetStopsAtEightUnsubmittedReadsWithoutAnOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(_,id)=>ServiceReadFailure(id)};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);NativeServiceSnapshotUnavailableException? failure=null;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(NativeServiceSnapshotUnavailableException error){failure=error;}
Check.That(failure is not null&&failure.Attempts==NativeServiceSnapshotUnavailableException.MaximumAttempts&&File.Exists(failure.DiagnosticPath));
Check.Equal(8,channel.ServiceReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test]public async Task AServiceMutationPendingOrderForeignMarketOrNonFreshnessFailureNeverRetriesTheRead()
{
foreach(var scenario in new[]{"sent","pending","wrong-kind","wrong-market","other-error","missing-proof"})
{
using var directory=new TemporaryDirectory();var channel=new Channel();
channel.ServiceReplyFactory=(_,id)=>scenario switch
{"sent"=>ServiceReadFailure(id,mutations:1),"pending"=>ServiceReadFailure(id,reconciliation:true),"wrong-kind"=>ServiceReadFailure(id,kind:4),
"wrong-market"=>ServiceReadFailure(id,market:2),"other-error"=>ServiceReadFailure(id,error:2),_=>JsonSerializer.SerializeToElement(new{state=4,generation=id,error=1})};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var refused=false;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
}
[Test]public async Task SessionDockingChangesAndUserCancellationCannotStartASecondServiceRead()
{
foreach(var scenario in new[]{"commander","ship","session","docking","cancel"})
{
using var directory=new TemporaryDirectory();using var stop=new CancellationTokenSource();var channel=new Channel();
channel.ServiceReplyFactory=(_,id)=>
{
if(scenario=="cancel")stop.Cancel();
else channel.Player=scenario switch{"commander"=>channel.Player with{CommanderId="other"},"ship"=>channel.Player with{CurrentShipId=8},
"session"=>channel.Player with{LoadGameAt=DateTimeOffset.UtcNow},_=>channel.Player with{Docked=false}};
return ServiceReadFailure(id);
};
var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var stopped=false;
try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,stop.Token);}
catch(Exception error)when(error is InvalidOperationException or OperationCanceledException){stopped=true;}
Check.That(stopped);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
}
}
[Test] public async Task ConcurrentMaintenanceDoesNotChargeTheSameQuotedServiceTwice()
{
var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-services-concurrent-"+Guid.NewGuid().ToString("N"));
var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
await Task.WhenAll(one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default),two.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default));
Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.Equal(3,channel.ServiceReads);
}
[Test]public async Task FlightRefuellingOnlyPaysFuelAndKeepsItsConfirmedReceiptWithoutRepairingOrRearming()
{
using var directory=new TemporaryDirectory();var channel=new Channel{RepairNeeded=50001,IntegrityNeeded=20,RearmNeeded=15};
var driver=Driver(channel,directory.Path);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;
await driver.PreflightAsync(default);await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);
Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.That(!channel.FuelNeeded);
Check.Equal(50001L,channel.RepairNeeded);Check.Equal(20L,channel.IntegrityNeeded);Check.Equal(15L,channel.RearmNeeded);
Check.Equal(1,receipts.Count);Check.Equal("refuel",receipts[0].Action);Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);Check.Equal(1,channel.Mutations);
}
[Test]public async Task FlightRefuellingCannotSpendReservedFundsOrPublishAnUnrelatedServiceAsFuel()
{
using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true};
var driver=Driver(channel,directory.Path,new(16,9998));await driver.PreflightAsync(default);var rejected=false;
try{await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);}catch(InvalidOperationException){rejected=true;}
Check.That(rejected);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
var observed=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=observed.Add;
Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"repair",1,10000,9995,DateTimeOffset.UtcNow)));
Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new("invalid","refuel",1,10000,9995,DateTimeOffset.UtcNow)));
Check.Equal(0,observed.Count);
driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"refuel",1,10000,9995,DateTimeOffset.UtcNow));Check.Equal(1,observed.Count);
}
[Test] public async Task OutstandingFinalServiceBlocksDepartureButKeepsItsConfirmedExpense()
{
var channel=new Channel{KeepServiceOutstanding=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-services-incomplete-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(1,channel.Mutations);Check.Equal(1,receipts.Count);Check.Equal(5m,receipts[0].ToCashEntry().Amount);
Check.Equal(2,channel.ServiceReads);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
}
[Test] public async Task InvalidBatchQuoteRefusesEveryServiceBeforeAnyDebit()
{
var channel=new Channel{RepairNeeded=50001};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-limit-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
}
[Test] public async Task OlderPluginRefreshesRequiredQuoteBeforeEachDebit()
{
var channel=new Channel{QuoteSnapshotsSupported=false,RepairNeeded=12};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-legacy-"+Guid.NewGuid().ToString("N")));
await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.Equal(2,channel.Mutations);Check.Equal(9983L,channel.Credits);Check.Equal(4,channel.ServiceReads);
}
private static NativeTradeCommandDriver JourneyDriver(Channel channel,string directory)
{
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,async(destination,check,token)=>
{
await check(token);channel.ActualMarket=(ulong)destination.MarketId;channel.Player=channel.Player with{MarketId=destination.MarketId};
},_=>Task.CompletedTask);
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
}
[Test] public async Task ProductionJourneyReconcilesServicesAndNeverReplaysACompletedBatch()
{
var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-production-journey-"+Guid.NewGuid().ToString("N"));
var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
var result=await journey.RunAsync(Buy().Leg,default);
Check.That(result.Passed);Check.Equal(7m,result.Profit.NetProfit);Check.Equal(10007L,result.ClosingCredits);Check.Equal(3,channel.Mutations);
var completed=await journey.RunAsync(Buy().Leg,default);Check.That(completed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(0,channel.Cargo);
}
[Test] public async Task AppliedPurchaseSurvivesFollowupFailureAndResumesWithoutAnotherBuy()
{
var channel=new Channel{FailPostTradeReadOnce=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-production-resume-"+Guid.NewGuid().ToString("N"));
var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
var failed=false;try{await journey.RunAsync(Buy().Leg,default);}catch(InvalidOperationException){failed=true;}
Check.That(failed);var checkpoint=TradeJourneyCheckpoints.Load(Path.Combine(directory,"journey.json"));Check.That(checkpoint.Bought is not null&&checkpoint.Sold is null);
var resumed=await new NativeTradeJourney(JourneyDriver(channel,Path.Combine(directory,"operations")),()=>channel.Player,_=>Task.CompletedTask,directory).RunAsync(Buy().Leg,default);
Check.That(resumed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(7m,resumed.Profit.NetProfit);Check.Equal(0,channel.Cargo);
Check.Equal(2,Directory.GetFiles(Path.Combine(directory,"attempts"),"*.json").Length);
}
[Test] public void VerifiedNativeWalletCannotOverrideAnotherSessionOrNewerJournalBalance()
{
var channel=new Channel();var at=DateTimeOffset.UtcNow;var observation=new NativeWalletObservation(channel.Player.CommanderId,7,channel.Player.LoadGameAt!.Value,9000,at);
Check.Equal(9000m,observation.Project(channel.Player).Credits);
var newer=channel.Player with{Credits=11000,CreditsAt=at.AddSeconds(1)};Check.That(ReferenceEquals(newer,observation.Project(newer)));
var other=channel.Player with{LoadGameAt=channel.Player.LoadGameAt!.Value.AddMinutes(1)};Check.That(ReferenceEquals(other,observation.Project(other)));
}
[Test] public async Task JournalDockedWaitsForNativeDockingBeforeReadingOrTrading()
{
var channel=new Channel{DockingPendingReads=2,FuelNeeded=false};
var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-readiness-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
Check.That(channel.ReadinessProbes>=3);Check.Equal(2,channel.MarketReads);Check.Equal(1,channel.Mutations);
}
[Test] public async Task DifferentNativeMarketRefusesAllMarketAndMonetaryRequests()
{
var channel=new Channel{ActualMarket=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-market-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);var refused=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
Check.That(refused);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);
}
[Test] public async Task CancellingNativeDockingWaitSubmitsNoOrder()
{
using var stop=new CancellationTokenSource();var channel=new Channel{DockingPendingReads=int.MaxValue,OnReadinessProbe=stop.Cancel};
var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-cancel-"+Guid.NewGuid().ToString("N"));var driver=Driver(channel,directory);await driver.PreflightAsync(default);var cancelled=false;
try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory,"*.pending.json").Length);
}
[Test] public async Task MaintenanceWaitsForNativeDockingBeforeQuotingAndCharging()
{
var channel=new Channel{DockingPendingReads=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-maintenance-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
Check.That(channel.ReadinessProbes>=3);Check.That(channel.ServiceReads>0);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
}
private static Channel CgChannel()
{
var channel=new Channel{Credits=25000000,Commodity="uranium",CommodityId=128049172,UnitBuy=6000,UnitSell=57104,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{Credits=25000000,CargoCapacity=192};return channel;
}
private static TradeLeg CgLeg(int quantity=192)
=>new(){From=Check.Station(1),To=Check.Station(4210924803),Orders=[new("uranium","Uranium",quantity,6000,57104)]};
[Test] public async Task ExplicitCgBatchesReconcileFullQuantityCostAndActualSaleProceeds()
{
var channel=CgChannel();var directory=Path.Combine(Path.GetTempPath(),"xfe-cg-batch-"+Guid.NewGuid().ToString("N"));
var driver=Driver(channel,directory,new(192,1000000));await driver.PreflightAsync(default);
var leg=CgLeg();var buy=new TradeAction(TradeActionKind.Buy,leg,leg.Orders[0]);
var bought=await driver.ExecuteTradeAsync(buy,_=>Task.CompletedTask,default);bought.Validate(buy,channel.Player,channel.Player.LoadGameAt!.Value);
Check.Equal(23848000L,bought.CreditsAfter);Check.Equal(192,bought.QuantityDelta);Check.Equal(192,channel.Cargo);
channel.ActualMarket=4210924803;channel.Player=channel.Player with{MarketId=4210924803};
var sell=new TradeAction(TradeActionKind.Sell,leg,leg.Orders[0]);var sold=await driver.ExecuteTradeAsync(sell,_=>Task.CompletedTask,default);sold.Validate(sell,channel.Player,channel.Player.LoadGameAt!.Value);
Check.Equal(34811968L,sold.CreditsAfter);Check.Equal(-192,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(2,channel.Mutations);
}
[Test] public async Task DefaultAcceptanceDriverStillRefusesCgBatches()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-default-"+Guid.NewGuid().ToString("N")));await driver.PreflightAsync(default);
var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);
}
[Test] public async Task CgBatchCannotSpendTheReservedWalletBalance()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-reserve-"+Guid.NewGuid().ToString("N")),new(192,24000000));await driver.PreflightAsync(default);
var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
}
[Test] public async Task CgBatchRefusesAQuantityAboveTheVerifiedHold()
{
var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-capacity-"+Guid.NewGuid().ToString("N")),new(192,1000000));await driver.PreflightAsync(default);
var leg=CgLeg(193);var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
}
[Test] public async Task FormalTradeUsesTheEntireCurrentShipAtArbitraryDestination()
{
using var directory=new TemporaryDirectory();
foreach(var size in new[]{1,192,752,2048})
{
var channel=new Channel{Credits=10000000,Commodity="gold",CommodityId=123456,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{Ship="type9",CargoCapacity=size,Credits=channel.Credits};
var order=new TradeOrder("gold","Gold",size,68,80);var leg=new TradeLeg{From=Check.Station(1),To=Check.Station(99),Orders=[order]};
var driver=Driver(channel,Path.Combine(directory.Path,size.ToString()),new(size,1000));await driver.PreflightAsync(default);
var bought=await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,order),_=>Task.CompletedTask,default);
Check.Equal(size,bought.QuantityDelta);channel.Player=channel.Player with{MarketId=99};channel.ActualMarket=99;
var sold=await driver.ExecuteTradeAsync(new(TradeActionKind.Sell,leg,order),_=>Task.CompletedTask,default);
Check.Equal(-size,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(10000000L+12*size,channel.Credits);
}
}
[Test] public async Task OrdinaryEngineRepeatsBothDirectionsWithoutAnyCgCalls()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var navigationCalls=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>{if(++navigationCalls==1)throw new InvalidOperationException("Stop flight control before selecting a local destination");channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=2};
var cycles=0;engine.Progress+=p=>{if(p.Phase=="cycle-completed")cycles++;};
await engine.RunAsync(default);Check.That(navigationCalls>=3);Check.Equal(2,cycles);Check.Equal(4,channel.Mutations);Check.Equal(0,channel.Cargo);Check.Equal(1L,channel.Player.MarketId!.Value);
}
[Test] public async Task DepartureQueueRecoveryContinuesEmptyOrLoadedWithoutRepeatingOrders()
{
foreach(var loaded in new[]{false,true})
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var failed=false;var recovered=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if(!failed&&(channel.Cargo>0)==loaded)
{failed=true;channel.Player=channel.Player with{Docked=false};throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));}
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
Task Recover(CancellationToken _)
{recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,Recover){MaximumCycles=1};
await engine.RunAsync(default);
Check.That(failed);Check.Equal(1,recovered);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
}
}
[Test] public async Task HealthTelemetryRefusalAfterPurchaseWaitsAndContinuesTheOriginalCargo()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var refusals=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if(channel.Cargo>0&&refusals++<2)throw new InvalidOperationException("Native main-ship health unavailable or stale");
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
await engine.RunAsync(default);
Check.That(refusals>=3);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
}
[Test] public async Task TransientFlightReadsDoNotSpendEmptyOrLoadedSessionRecoveryAttempts()
{
foreach(var loaded in new[]{false,true})foreach(var departure in new[]{false,true})
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var reads=0;var recovered=0;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>
{
if((channel.Cargo>0)==loaded&&++reads<=5)
{
if(reads<=4)throw new FlightTelemetryUnavailableException("fixture transient during scene transition");
channel.Player=channel.Player with{Docked=false};
if(departure)throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));
throw new DockingHandoffException("fixture landing handoff",station.MarketId);
}
channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,_=>
{recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}){MaximumCycles=1};
await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(8));
Check.Equal(1,recovered);Check.That(reads>=6);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
}
}
[Test] public async Task CgEngineUsesUserDestinationAndConfirmsItsActualContribution()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
(station,_,_)=>{channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path,
Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
await engine.RunAsync(default);Check.Equal(752L,channel.Contribution);Check.That(channel.CgReads>=2);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
}
[Test]public Task DynamicCgSupplyExcludesAKnownFineWithoutChangingTheUserConfigurationOrPayingIt()=>VerifyDynamicFineSelection(false);
[Test]public Task AFineNewlyConfirmedOnArrivalChangesSourceBeforeReadingItsMarketOrBuying()=>VerifyDynamicFineSelection(true);
[Test]public Task DynamicSelectionCannotIgnoreTheConfirmedFineExclusion()=>VerifyDynamicFineSelection(false,ignoreExclusion:true);
[Test]public Task AnUnconfirmedOriginalOperationPreventsFineBasedSourceReplacement()=>VerifyDynamicFineSelection(false,unconfirmed:true);
private static async Task VerifyDynamicFineSelection(bool fineOnArrival,bool ignoreExclusion=false,bool unconfirmed=false)
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
channel.Player=channel.Player with{CargoCapacity=16,Credits=channel.Credits,Ship="type9",Rebuy=1000};
var journal=directory.File("Journal.dynamic-fine.log");var operations=Path.Combine(directory.Path,"operations");
Task AddFine()=>File.WriteAllLinesAsync(journal,new object[]
{
new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Bowen authority"}},
new{@event="CommitCrime",Faction="Bowen authority",Fine=400}
}.Select(value=>JsonSerializer.Serialize(value)));
if(!fineOnArrival)await AddFine();
var visited=new List<long>();var selections=0;var restrictedArrival=false;
var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,operations,async(station,_,token)=>
{
visited.Add(station.MarketId);channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;
if(fineOnArrival&&station.MarketId==1)await AddFine();
if(unconfirmed&&station.MarketId==99)await File.WriteAllTextAsync(Path.Combine(operations,"still-unknown.pending.json"),"{}",token);
},_=>Task.CompletedTask,batchPolicy:new(16,1000)){JournalDirectory=directory.Path};
driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);
var originalSource=Check.Station(1);var destination=Check.Station(99);var configuration=new AutomaticTradeConfiguration
{Source=originalSource,Destination=destination,WorkingDirectory=directory.Path,Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
Task<AutomaticSupplyChoice?> Select(AutomaticSupplyRequest request,CancellationToken token)
{
token.ThrowIfCancellationRequested();selections++;
Check.Equal(channel.Player.CommanderId,request.Player.CommanderId);Check.Equal(99L,request.Configuration.Destination.MarketId);
var market=request.ExcludedSourceMarkets.Contains(1)&&!ignoreExclusion?2L:1L;
if(!fineOnArrival||selections>1)Check.That(request.ExcludedSourceMarkets.Contains(1));
var source=Check.Station(market);var order=new TradeOrder("biowaste","Biowaste",16,68,80);
var delivery=new TradeLeg{From=source,To=destination,Orders=[order],EstimatedMinutes=5};
return Task.FromResult<AutomaticSupplyChoice?>(new(source,delivery,100000,100000,16,5,0,192,10));
}
var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,selectSource:Select){MaximumCycles=1};
engine.Progress+=progress=>restrictedArrival|=progress.Phase=="source-access-restricted";
Exception? failure=null;using var budget=new CancellationTokenSource(TimeSpan.FromSeconds(8));
try{await engine.RunAsync(budget.Token);}catch(Exception error){failure=error;}
Check.That(!budget.IsCancellationRequested,"The engine must finish or reject the exact source proof, not wait indefinitely on its fine.");
if(ignoreExclusion||unconfirmed)
{
Check.That(failure is not null);Check.Equal(0,channel.Mutations);
if(ignoreExclusion){Check.That(failure is InvalidDataException);Check.Equal(1,selections);}
if(unconfirmed){Check.Equal(0,selections);Check.Equal("{}",await File.ReadAllTextAsync(Path.Combine(operations,"still-unknown.pending.json")));}
}
else
{
if(failure is not null)throw failure;
Check.That(selections>=2);Check.That(visited.Contains(2));Check.Equal(fineOnArrival,restrictedArrival);
Check.That(!channel.MarketReadMarkets.Contains(1));Check.Equal(2,channel.Mutations);Check.Equal(16L,channel.Contribution);Check.Equal(0,channel.Cargo);
Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
}
Check.Equal(1L,configuration.Source.MarketId);Check.Equal(99L,configuration.Destination.MarketId);
Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
}
[Test] public void CgBatchRequiresAnActualWalletEvenIfTheReliabilityFlagWasSet()
{
var channel=CgChannel();var leg=CgLeg();var policy=new NativeTradeBatchPolicy(192,1000000);
Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=null,CreditsReliable=true}));
Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=-1,CreditsReliable=true}));
}
}