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 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 consumedServices=[]; public Func? 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 replies=[];private readonly Dictionary keys=[]; private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value); public async Task 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{["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{["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():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(); 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();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();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();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();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();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();driver.ServiceConfirmed+=observed.Add; Check.Throws(()=>driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"repair",1,10000,9995,DateTimeOffset.UtcNow))); Check.Throws(()=>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();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();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 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(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(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=null,CreditsReliable=true})); Check.Throws(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=-1,CreditsReliable=true})); } }