diff --git a/packages/cli/bin/contracts.dart b/packages/cli/bin/contracts.dart index 303f6c1c..6699a54e 100644 --- a/packages/cli/bin/contracts.dart +++ b/packages/cli/bin/contracts.dart @@ -28,7 +28,7 @@ void printContracts( Future command(FileSystem fs, ArgResults argResults) async { final db = await defaultDatabase(); final printAll = argResults['all'] as bool; - final contractCache = ContractCache.load(fs)!; + final contractCache = await ContractSnapshot.load(db); final marketPrices = await MarketPrices.load(db); printContracts( 'completed', diff --git a/packages/cli/bin/deals_nearby.dart b/packages/cli/bin/deals_nearby.dart index 0b759087..f4b9a3d8 100644 --- a/packages/cli/bin/deals_nearby.dart +++ b/packages/cli/bin/deals_nearby.dart @@ -30,7 +30,7 @@ Future cliMain(FileSystem fs, ArgResults argResults) async { final behaviorCache = BehaviorCache.load(fs); final shipCache = ShipCache.load(fs)!; final agentCache = await AgentCache.load(db); - final contractCache = ContractCache.load(fs)!; + final contractCache = await ContractSnapshot.load(db); final centralCommand = CentralCommand(behaviorCache: behaviorCache, shipCache: shipCache); diff --git a/packages/cli/lib/behavior/central_command.dart b/packages/cli/lib/behavior/central_command.dart index 3e94fb00..28605906 100644 --- a/packages/cli/lib/behavior/central_command.dart +++ b/packages/cli/lib/behavior/central_command.dart @@ -266,7 +266,7 @@ class CentralCommand { /// Procurement contracts converted to sell opps. Iterable contractSellOpps( AgentCache agentCache, - ContractCache contractCache, + ContractSnapshot contractCache, ) { return sellOppsForContracts( agentCache, @@ -297,7 +297,7 @@ class CentralCommand { /// Find next deal for the given [ship], considering all deals in progress. CostedDeal? findNextDealAndLog( AgentCache agentCache, - ContractCache contractCache, + ContractSnapshot contractCache, MarketPrices marketPrices, SystemsCache systemsCache, SystemConnectivity systemConnectivity, @@ -788,7 +788,7 @@ int _minimumFloatRequired(Contract contract) { /// Procurement contracts converted to sell opps. Iterable sellOppsForContracts( AgentCache agentCache, - ContractCache contractCache, { + ContractSnapshot contractCache, { required int Function(Contract, TradeSymbol) remainingUnitsNeededForContract, }) sync* { for (final contract in affordableContracts(agentCache, contractCache)) { @@ -815,7 +815,7 @@ Iterable sellOppsForContracts( /// complete. Iterable affordableContracts( AgentCache agentCache, - ContractCache contractsCache, + ContractSnapshot contractsCache, ) { // We should only use the contract trader when we have enough credits to // complete the entire contract. Otherwise we're just sinking credits into a diff --git a/packages/cli/lib/behavior/trader.dart b/packages/cli/lib/behavior/trader.dart index c1658bf7..da9bc45a 100644 --- a/packages/cli/lib/behavior/trader.dart +++ b/packages/cli/lib/behavior/trader.dart @@ -250,7 +250,7 @@ Future _handleContractDealAtDestination( const Duration(minutes: 10), ); final neededGood = contract.goodNeeded(costedDeal.tradeSymbol); - final maybeResponse = await _deliverContractGoodsIfPossible( + final maybeContract = await _deliverContractGoodsIfPossible( api, db, caches.agent, @@ -262,14 +262,14 @@ Future _handleContractDealAtDestination( ); // If we've delivered enough, complete the contract. - if (maybeResponse != null && - maybeResponse.contract.goodNeeded(contractGood)!.amountNeeded <= 0) { + if (maybeContract != null && + maybeContract.goodNeeded(contractGood)!.amountNeeded <= 0) { await _completeContract( api, db, caches, ship, - maybeResponse.contract, + maybeContract, ); } return JobResult.complete(); @@ -285,7 +285,9 @@ Future _completeContract( final response = await api.contracts.fulfillContract(contract.id); final data = response!.data; await caches.agent.updateAgent(Agent.fromOpenApi(data.agent)); - caches.contracts.updateContract(data.contract); + await db.upsertContract( + Contract.fromOpenApi(data.contract, DateTime.timestamp()), + ); final contactTransaction = ContractTransaction.fulfillment( contract: contract, @@ -301,14 +303,14 @@ Future _completeContract( shipInfo(ship, 'Contract complete!'); } -Future _deliverContractGoodsIfPossible( +Future _deliverContractGoodsIfPossible( Api api, Database db, AgentCache agentCache, - ContractCache contractCache, + ContractSnapshot contractCache, ShipCache shipCache, Ship ship, - Contract contract, + Contract beforeDelivery, ContractDeliverGood goods, ) async { final tradeSymbol = goods.tradeSymbolObject; @@ -323,15 +325,15 @@ Future _deliverContractGoodsIfPossible( // Contract has already been fulfilled.","code":4504,"data": // {"contractId":"cljysnr2wt47as60cvz377bhh"}}} jobAssert( - !contract.fulfilled, - 'Contract ${contract.id} already fulfilled.', + !beforeDelivery.fulfilled, + 'Contract ${beforeDelivery.id} already fulfilled.', const Duration(minutes: 10), ); - if (!contract.accepted) { + if (!beforeDelivery.accepted) { shipErr( ship, - 'Contract ${contract.id} not accepted? Accepting before delivering.', + 'Contract ${beforeDelivery.id} not accepted? Accepting before delivery.', ); await acceptContractAndLog( api, @@ -339,22 +341,25 @@ Future _deliverContractGoodsIfPossible( contractCache, agentCache, ship, - contract, + beforeDelivery, ); } // And we have the desired cargo. final response = await deliverContract( + db, api, ship, shipCache, contractCache, - contract, + beforeDelivery, tradeSymbol: tradeSymbol, units: unitsBefore, ); + final afterDelivery = + Contract.fromOpenApi(response.contract, DateTime.timestamp()); final deliver = assertNotNull( - response.contract.goodNeeded(tradeSymbol), + afterDelivery.goodNeeded(tradeSymbol), 'No ContractDeliverGood for $tradeSymbol?', const Duration(minutes: 10), ); @@ -363,7 +368,7 @@ Future _deliverContractGoodsIfPossible( 'Delivered $unitsBefore ${goods.tradeSymbol} ' 'to ${goods.destinationSymbol}; ' '${deliver.unitsFulfilled}/${deliver.unitsRequired}, ' - '${approximateDuration(contract.timeUntilDeadline)} to deadline', + '${approximateDuration(afterDelivery.timeUntilDeadline)} to deadline', ); // Update our cargo counts after delivering the contract goods. @@ -372,7 +377,7 @@ Future _deliverContractGoodsIfPossible( // Record the delivery transaction. final contactTransaction = ContractTransaction.delivery( - contract: contract, + contract: afterDelivery, shipSymbol: ship.shipSymbol, waypointSymbol: ship.waypointSymbol, unitsDelivered: unitsDelivered, @@ -383,7 +388,7 @@ Future _deliverContractGoodsIfPossible( agentCache.agent.credits, ); await db.insertTransaction(transaction); - return response; + return afterDelivery; } /// Handle construction deal at destination. @@ -547,7 +552,7 @@ String describeExpectedContractProfit( Future acceptContractsIfNeeded( Api api, Database db, - ContractCache contractCache, + ContractSnapshot contractCache, MarketPrices marketPrices, AgentCache agentCache, ShipCache shipCache, @@ -557,7 +562,7 @@ Future acceptContractsIfNeeded( final contracts = contractCache.activeContracts; if (contracts.isEmpty) { final contract = - await negotiateContractAndLog(api, ship, shipCache, contractCache); + await negotiateContractAndLog(db, api, ship, shipCache, contractCache); shipInfo(ship, describeExpectedContractProfit(marketPrices, contract)); return null; } diff --git a/packages/cli/lib/cache/caches.dart b/packages/cli/lib/cache/caches.dart index 055a3410..26b723a0 100644 --- a/packages/cli/lib/cache/caches.dart +++ b/packages/cli/lib/cache/caches.dart @@ -78,7 +78,7 @@ class Caches { final ShipCache ships; /// The contract cache. - final ContractCache contracts; + ContractSnapshot contracts; /// Known shipyard listings. final ShipyardListingCache shipyardListings; @@ -151,8 +151,7 @@ class Caches { ); final markets = MarketCache(db, api, static.tradeGoods); // Intentionally force refresh contracts in case we've been offline. - final contracts = - await ContractCache.loadOrFetch(api, fs: fs, forceRefresh: true); + final contracts = await fetchContracts(db, api); final behaviors = BehaviorCache.load(fs); final jumpGates = await JumpGateSnapshot.load(db); @@ -168,9 +167,6 @@ class Caches { // Make sure factions are loaded. final factions = await loadFactions(db, api.factions); - // We rarely modify contracts, so save them out here too. - contracts.save(); - return Caches( agent: agent, ships: ships, @@ -215,8 +211,8 @@ class Caches { // and might notice that a ship has arrived before the ship logic gets // to run and update the status. await ships.ensureUpToDate(api); - await contracts.ensureUpToDate(api); await agent.ensureAgentUpToDate(api); + contracts = await contracts.ensureUpToDate(db, api); marketListings = await MarketListingSnapshot.load(db); } diff --git a/packages/cli/lib/cache/contract_cache.dart b/packages/cli/lib/cache/contract_cache.dart index fa035917..2ca919c3 100644 --- a/packages/cli/lib/cache/contract_cache.dart +++ b/packages/cli/lib/cache/contract_cache.dart @@ -1,67 +1,29 @@ import 'package:cli/api.dart'; -import 'package:cli/cache/json_list_store.dart'; -import 'package:cli/cache/response_cache.dart'; +import 'package:cli/compare.dart'; +import 'package:cli/logger.dart'; import 'package:cli/net/queries.dart'; import 'package:collection/collection.dart'; -import 'package:file/file.dart'; +import 'package:db/db.dart'; import 'package:types/types.dart'; -/// In-memory cache of contacts. -class ContractCache extends ResponseListCache { +/// Snapshot of contracts in the database. +class ContractSnapshot { /// Creates a new contract cache. - ContractCache( - super.contracts, { - required super.fs, - super.checkEvery = 100, - super.path = defaultPath, - }) : super(refreshEntries: (Api api) => allMyContracts(api).toList()); + ContractSnapshot(this.contracts); /// Load the ContractCache from the file system. - static ContractCache? load(FileSystem fs, {String path = defaultPath}) { - final contracts = JsonListStore.loadRecords( - fs, - path, - (j) => Contract.fromJson(j)!, - ); - if (contracts != null) { - return ContractCache(contracts, fs: fs, path: path); - } - return null; - } - - /// Creates a new ContractCache from the Api or FileSystem if provided. - static Future loadOrFetch( - Api api, { - required FileSystem fs, - String path = defaultPath, - bool forceRefresh = false, - }) async { - if (!forceRefresh) { - final cached = load(fs, path: path); - if (cached != null) { - return cached; - } - } - final contracts = await allMyContracts(api).toList(); - return ContractCache(contracts, fs: fs, path: path); + static Future load(Database db) async { + final contracts = await db.allContracts(); + return ContractSnapshot(contracts.toList()); } - /// The default path to the contracts cache. - static const String defaultPath = 'data/contracts.json'; - /// Contracts in the cache. - List get contracts => records; + final List contracts; - /// Updates a single contract in the cache. - void updateContract(Contract contract) { - final index = contracts.indexWhere((c) => c.id == contract.id); - if (index == -1) { - contracts.add(contract); - } else { - contracts[index] = contract; - } - save(); - } + /// Number of requests between checks to ensure ships are up to date. + final int requestsBetweenChecks = 100; + + int _requestsSinceLastCheck = 0; /// Returns a list of all completed contracts. List get completedContracts => @@ -82,4 +44,36 @@ class ContractCache extends ResponseListCache { /// Looks up the contract by id. Contract? contract(String id) => contracts.firstWhereOrNull((c) => c.id == id); + + /// Fetches a new snapshot and logs if different from this one. + // TODO(eseidel): This does not belong in this class. + Future ensureUpToDate(Database db, Api api) async { + _requestsSinceLastCheck++; + if (_requestsSinceLastCheck < requestsBetweenChecks) { + return this; + } + _requestsSinceLastCheck = 0; + + final newContracts = await fetchContracts(db, api); + final newContractsJson = + newContracts.contracts.map((c) => c.toOpenApi().toJson()).toList(); + final oldContractsJson = + contracts.map((c) => c.toOpenApi().toJson()).toList(); + // Our contracts class has a timestamp which we don't want to compare, so + // compare the OpenAPI JSON instead. + if (jsonMatches(newContractsJson, oldContractsJson)) { + logger.warn('Contracts changed, updating cache.'); + return newContracts; + } + return this; + } +} + +/// Fetches all of the user's contracts. +Future fetchContracts(Database db, Api api) async { + final contracts = await allMyContracts(api).toList(); + for (final contract in contracts) { + await db.upsertContract(contract); + } + return ContractSnapshot(contracts); } diff --git a/packages/cli/lib/cache/ship_cache.dart b/packages/cli/lib/cache/ship_cache.dart index 6ffebe2d..8dbf6a0b 100644 --- a/packages/cli/lib/cache/ship_cache.dart +++ b/packages/cli/lib/cache/ship_cache.dart @@ -14,7 +14,6 @@ class ShipCache extends ResponseListCache { ShipCache( super.ships, { required super.fs, - super.checkEvery = 100, super.path = defaultPath, }) : super(refreshEntries: (Api api) => allMyShips(api).toList()); diff --git a/packages/cli/lib/net/actions.dart b/packages/cli/lib/net/actions.dart index bc164dc8..e081fc77 100644 --- a/packages/cli/lib/net/actions.dart +++ b/packages/cli/lib/net/actions.dart @@ -535,16 +535,18 @@ Future useJumpGateAndLog( /// Negotiate a contract for [ship] and log. Future negotiateContractAndLog( + Database db, Api api, Ship ship, ShipCache shipCache, - ContractCache contractCache, + ContractSnapshot contractCache, ) async { await dockIfNeeded(api, shipCache, ship); final response = await api.fleet.negotiateContract(ship.symbol); final contractData = response!.data; - final contract = contractData.contract; - contractCache.updateContract(contract); + final contract = + Contract.fromOpenApi(contractData.contract, DateTime.timestamp()); + await db.upsertContract(contract); shipInfo(ship, 'Negotiated contract: ${contractDescription(contract)}'); return contract; } @@ -553,7 +555,7 @@ Future negotiateContractAndLog( Future acceptContractAndLog( Api api, Database db, - ContractCache contractCache, + ContractSnapshot contractCache, AgentCache agentCache, Ship ship, Contract contract, @@ -561,7 +563,9 @@ Future acceptContractAndLog( final response = await api.contracts.acceptContract(contract.id); final data = response!.data; await agentCache.updateAgent(Agent.fromOpenApi(data.agent)); - contractCache.updateContract(data.contract); + await db.upsertContract( + Contract.fromOpenApi(data.contract, DateTime.timestamp()), + ); shipInfo(ship, 'Accepted: ${contractDescription(contract)}.'); shipInfo( ship, diff --git a/packages/cli/lib/net/direct.dart b/packages/cli/lib/net/direct.dart index c55d9319..1a70a26b 100644 --- a/packages/cli/lib/net/direct.dart +++ b/packages/cli/lib/net/direct.dart @@ -3,6 +3,7 @@ import 'package:cli/cache/agent_cache.dart'; import 'package:cli/cache/construction_cache.dart'; import 'package:cli/cache/contract_cache.dart'; import 'package:cli/cache/ship_cache.dart'; +import 'package:db/db.dart'; import 'package:types/types.dart'; // This is for direct non-logging actions @@ -113,10 +114,11 @@ Future extractResourcesWithSurvey( /// Deliver [units] of [tradeSymbol] to [contract] Future deliverContract( + Database db, Api api, Ship ship, ShipCache shipCache, - ContractCache contractCache, + ContractSnapshot contractCache, Contract contract, { required TradeSymbol tradeSymbol, required int units, @@ -129,7 +131,9 @@ Future deliverContract( final response = await api.contracts .deliverContract(contract.id, deliverContractRequest: request); final data = response!.data; - contractCache.updateContract(data.contract); + await db.upsertContract( + Contract.fromOpenApi(data.contract, DateTime.timestamp()), + ); ship.cargo = data.cargo; shipCache.updateShip(ship); return data; diff --git a/packages/cli/lib/net/queries.dart b/packages/cli/lib/net/queries.dart index bce5f63a..4fab84dd 100644 --- a/packages/cli/lib/net/queries.dart +++ b/packages/cli/lib/net/queries.dart @@ -57,7 +57,10 @@ Stream allMyContracts(Api api) { return fetchAllPages(api, (api, page) async { final response = await api.contracts.getContracts(page: page, limit: pageLimit); - return (response!.data, response.meta); + final now = DateTime.timestamp(); + final contracts = + response!.data.map((c) => Contract.fromOpenApi(c, now)).toList(); + return (contracts, response.meta); }); } diff --git a/packages/cli/test/behavior/central_command_test.dart b/packages/cli/test/behavior/central_command_test.dart index 86f767cd..3db8e134 100644 --- a/packages/cli/test/behavior/central_command_test.dart +++ b/packages/cli/test/behavior/central_command_test.dart @@ -16,9 +16,7 @@ class _MockApi extends Mock implements Api {} class _MockBehaviorCache extends Mock implements BehaviorCache {} -class _MockContract extends Mock implements Contract {} - -class _MockContractCache extends Mock implements ContractCache {} +class _MockContractCache extends Mock implements ContractSnapshot {} class _MockContractTerms extends Mock implements ContractTerms {} @@ -147,7 +145,6 @@ void main() { final ship = _MockShip(); // TODO(eseidel): Contracts are disabled under 100000 credits. final agent = Agent.test(credits: 100000); - final fs = MemoryFileSystem.test(); final db = _MockDatabase(); final agentCache = AgentCache(agent, db); when(() => ship.symbol).thenReturn('S'); @@ -171,8 +168,10 @@ void main() { ), ], ), - expiration: hourFromNow, deadlineToAccept: hourFromNow, + accepted: false, + fulfilled: false, + timestamp: now, ); final contract2 = Contract( id: '2', @@ -190,11 +189,13 @@ void main() { ), ], ), - expiration: hourFromNow, deadlineToAccept: hourFromNow, + accepted: false, + fulfilled: false, + timestamp: now, ); final contracts = [contract1, contract2]; - final contractCache = ContractCache(contracts, fs: fs); + final contractCache = ContractSnapshot(contracts); final active = contractCache.activeContracts; expect(active.length, 2); final affordable = affordableContracts(agentCache, contractCache).toList(); @@ -220,10 +221,8 @@ void main() { ]); final centralCommand = CentralCommand(behaviorCache: behaviorCache, shipCache: shipCache); - final contract = _MockContract(); final contractTerms = _MockContractTerms(); - when(() => contract.terms).thenReturn(contractTerms); - when(() => contract.id).thenReturn('C'); + final contract = Contract.test(id: 'C', terms: contractTerms); final good = ContractDeliverGood( tradeSymbol: tradeSymbol.value, destinationSymbol: 'W', @@ -578,6 +577,7 @@ void main() { }); test('sellOppsForContracts', () { + final now = DateTime(2021); final contractCache = _MockContractCache(); final contract = Contract( id: '2', @@ -595,8 +595,10 @@ void main() { ), ], ), - expiration: DateTime(2021), deadlineToAccept: DateTime(2021), + accepted: false, + fulfilled: false, + timestamp: now, ); when(() => contractCache.activeContracts).thenReturn([contract]); diff --git a/packages/cli/test/behavior/trader_test.dart b/packages/cli/test/behavior/trader_test.dart index 5f04d6db..61113f59 100644 --- a/packages/cli/test/behavior/trader_test.dart +++ b/packages/cli/test/behavior/trader_test.dart @@ -511,6 +511,7 @@ void main() { test('trade contracts smoke test', () async { registerFallbackValue(Duration.zero); const shipSymbol = ShipSymbol('S', 1); + final now = DateTime(2021); final api = _MockApi(); final db = _MockDatabase(); @@ -525,11 +526,14 @@ void main() { id: 'id', factionSymbol: 'factionSymbol', type: ContractTypeEnum.PROCUREMENT, - expiration: DateTime(2021), + deadlineToAccept: DateTime(2021), terms: ContractTerms( deadline: DateTime(2021), payment: ContractPayment(onAccepted: 100, onFulfilled: 100), ), + accepted: false, + fulfilled: false, + timestamp: now, ); final agent = Agent.test(); @@ -553,7 +557,7 @@ void main() { (_) => Future.value( NegotiateContract200Response( data: NegotiateContract200ResponseData( - contract: contract, + contract: contract.toOpenApi(), ), ), ), @@ -655,7 +659,6 @@ void main() { (_) async => OrbitShip200Response(data: OrbitShip200ResponseData(nav: shipNav)), ); - final now = DateTime(2021); when( () => fleetApi.navigateShip( shipSymbol.symbol, @@ -697,6 +700,9 @@ void main() { ), ); + registerFallbackValue(Contract.fallbackValue()); + when(() => db.upsertContract(any())).thenAnswer((_) async {}); + final logger = _MockLogger(); final waitUntil = await runWithLogger( logger, @@ -1027,6 +1033,7 @@ void main() { final centralCommand = _MockCentralCommand(); final caches = mockCaches(); + final now = DateTime(2021); final start = WaypointSymbol.fromString('S-A-B'); final end = WaypointSymbol.fromString('S-A-C'); @@ -1074,7 +1081,7 @@ void main() { id: 'contract_id', factionSymbol: 'factionSymbol', type: ContractTypeEnum.PROCUREMENT, - expiration: DateTime(2021), + deadlineToAccept: DateTime(2021), terms: ContractTerms( deadline: DateTime(2021), payment: ContractPayment(onAccepted: 100, onFulfilled: 100), @@ -1087,6 +1094,9 @@ void main() { ), ], ), + accepted: false, + fulfilled: false, + timestamp: now, ); when(() => caches.contracts.contract(contract.id)).thenReturn(contract); final deal = Deal( @@ -1170,7 +1180,7 @@ void main() { (_) => Future.value( DeliverContract200Response( data: DeliverContract200ResponseData( - contract: contract, + contract: contract.toOpenApi(), cargo: shipCargo, ), ), @@ -1183,7 +1193,7 @@ void main() { (invocation) => Future.value( AcceptContract200Response( data: AcceptContract200ResponseData( - contract: contract, + contract: contract.toOpenApi(), agent: agent.toOpenApi(), ), ), @@ -1196,6 +1206,8 @@ void main() { .thenAnswer((_) => Future.value()); when(() => db.insertTransaction(any())).thenAnswer((_) => Future.value()); + registerFallbackValue(Contract.fallbackValue()); + when(() => db.upsertContract(any())).thenAnswer((_) async {}); final state = BehaviorState(const ShipSymbol('S', 1), Behavior.trader) ..deal = costedDeal; diff --git a/packages/cli/test/cache/agent_cache_test.dart b/packages/cli/test/cache/agent_cache_test.dart deleted file mode 100644 index 361e63e2..00000000 --- a/packages/cli/test/cache/agent_cache_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:cli/api.dart'; -import 'package:cli/cache/agent_cache.dart'; -import 'package:cli/logger.dart'; -import 'package:db/db.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; -import 'package:types/types.dart'; - -class _MockAgentsApi extends Mock implements AgentsApi {} - -class _MockApi extends Mock implements Api {} - -class _MockLogger extends Mock implements Logger {} - -class _MockDatabase extends Mock implements Database {} - -void main() { - test('AgentCache smoke test', () { - final api = _MockApi(); - final agent = Agent.test(credits: 1); - final newAgent = Agent.test(credits: 2); - final agents = _MockAgentsApi(); - when(() => api.agents).thenReturn(agents); - when(agents.getMyAgent).thenAnswer( - (_) => Future.value(GetMyAgent200Response(data: newAgent.toOpenApi())), - ); - final db = _MockDatabase(); - registerFallbackValue(Agent.test()); - when(() => db.upsertAgent(any())).thenAnswer((_) => Future.value()); - final cache = AgentCache(agent, db, requestsBetweenChecks: 3); - expect(cache.agent, agent); - cache.ensureAgentUpToDate(api); - verifyNever(agents.getMyAgent); - cache.ensureAgentUpToDate(api); - verifyNever(agents.getMyAgent); - final logger = _MockLogger(); - runWithLogger(logger, () { - cache.ensureAgentUpToDate(api); - }); - verify(agents.getMyAgent).called(1); - }); -} diff --git a/packages/cli/test/cache/caches_mock.dart b/packages/cli/test/cache/caches_mock.dart index 4ee75fff..a39aeffb 100644 --- a/packages/cli/test/cache/caches_mock.dart +++ b/packages/cli/test/cache/caches_mock.dart @@ -9,7 +9,7 @@ class _MockChartingCache extends Mock implements ChartingCache {} class _MockConstructionCache extends Mock implements ConstructionCache {} -class _MockContractCache extends Mock implements ContractCache {} +class _MockContractCache extends Mock implements ContractSnapshot {} class _MockJumpGateSnapshot extends Mock implements JumpGateSnapshot {} diff --git a/packages/cli/test/cache/contract_cache_test.dart b/packages/cli/test/cache/contract_cache_test.dart deleted file mode 100644 index 7cb6ff18..00000000 --- a/packages/cli/test/cache/contract_cache_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:convert'; - -import 'package:cli/api.dart'; -import 'package:cli/cache/contract_cache.dart'; -import 'package:file/memory.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; -import 'package:types/types.dart'; - -class _MockApi extends Mock implements Api {} - -void main() { - test('ContractCache load/save', () async { - final fs = MemoryFileSystem.test(); - final api = _MockApi(); - final moonLanding = DateTime.utc(1969, 7, 20, 20, 18, 04); - final contract = Contract( - id: 'id', - factionSymbol: 'faction', - type: ContractTypeEnum.PROCUREMENT, - terms: ContractTerms( - deadline: moonLanding, - payment: ContractPayment(onAccepted: 1000, onFulfilled: 1000), - deliver: [ - ContractDeliverGood( - tradeSymbol: 'T', - destinationSymbol: 'W', - unitsFulfilled: 0, - unitsRequired: 10, - ), - ], - ), - expiration: moonLanding, - deadlineToAccept: moonLanding, - ); - final contracts = [contract]; - ContractCache(contracts, fs: fs).save(); - final contractCache2 = await ContractCache.loadOrFetch(api, fs: fs); - expect(contractCache2.contracts.length, contracts.length); - // Contract.toJson doesn't recurse (openapi gen bug), so use jsonEncode. - expect( - jsonEncode(contractCache2.contracts.first), - jsonEncode(contracts.first), - ); - - // If the system clock is ever before the moon landing, some of these - // may fail. - expect(contractCache2.completedContracts, isEmpty); - expect(contractCache2.unacceptedContracts.length, 1); - - expect(contractCache2.activeContracts, isEmpty); - expect(contractCache2.expiredContracts.length, 1); - // Contract == isn't implemented, so we would need to use - // contractCache if we wanted the same object. Instead we check id. - expect(contractCache2.expiredContracts.first.id, contracts.first.id); - expect( - contractCache2.contract(contract.id)?.factionSymbol, - contract.factionSymbol, - ); - expect(contractCache2.contract('nope'), isNull); - - final updatedContract = Contract( - id: 'id', - factionSymbol: 'faction2', - type: ContractTypeEnum.PROCUREMENT, - terms: ContractTerms( - deadline: moonLanding, - payment: ContractPayment(onAccepted: 1000, onFulfilled: 1000), - deliver: [ - ContractDeliverGood( - tradeSymbol: 'T', - destinationSymbol: 'W', - unitsFulfilled: 0, - unitsRequired: 10, - ), - ], - ), - expiration: moonLanding, - deadlineToAccept: moonLanding, - ); - contractCache2.updateContract(updatedContract); - expect(contractCache2.contracts.length, contracts.length); - expect( - contractCache2.contract(contract.id)?.factionSymbol, - updatedContract.factionSymbol, - ); - }); -} diff --git a/packages/cli/test/cache/response_cache_test.dart b/packages/cli/test/cache/response_cache_test.dart deleted file mode 100644 index 4f187924..00000000 --- a/packages/cli/test/cache/response_cache_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:cli/api.dart'; -import 'package:cli/cache/response_cache.dart'; -import 'package:cli/logger.dart'; -import 'package:file/memory.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class _MockApi extends Mock implements Api {} - -class _MockLogger extends Mock implements Logger {} - -void main() { - test('ResponseListCache', () async { - final fs = MemoryFileSystem.test(); - final api = _MockApi(); - - final responseCache = ResponseListCache( - [1], - refreshEntries: (_) async => [2], - fs: fs, - path: 'test.json', - checkEvery: 3, - ); - - Future ensureUpToDate() async { - final logger = _MockLogger(); - await runWithLogger(logger, () async { - await responseCache.ensureUpToDate(api); - }); - return logger; - } - - expect(responseCache.records.first, 1); - await ensureUpToDate(); - expect(responseCache.records.first, 1); - await ensureUpToDate(); - expect(responseCache.records.first, 1); - final logger = await ensureUpToDate(); - expect(responseCache.records.first, 2); - verify( - () => logger.warn( - 'int list changed, updating cache.', - ), - ).called(1); - - final file = fs.file('test.json'); - expect(file.existsSync(), true); - expect(file.readAsStringSync(), '[\n 2\n]'); - - responseCache.replaceEntries([3]); - expect(responseCache.records.first, 3); - }); -} diff --git a/packages/cli/test/net/actions_test.dart b/packages/cli/test/net/actions_test.dart index cae59429..570650f8 100644 --- a/packages/cli/test/net/actions_test.dart +++ b/packages/cli/test/net/actions_test.dart @@ -14,9 +14,7 @@ class _MockDatabase extends Mock implements Database {} class _MockChartingCache extends Mock implements ChartingCache {} -class _MockContract extends Mock implements Contract {} - -class _MockContractCache extends Mock implements ContractCache {} +class _MockContractCache extends Mock implements ContractSnapshot {} class _MockContractsApi extends Mock implements ContractsApi {} @@ -781,10 +779,9 @@ void main() { when(() => ship.nav).thenReturn(shipNav); when(() => shipNav.waypointSymbol).thenReturn('S-A-W'); final contractCache = _MockContractCache(); - final contract = _MockContract(); - when(() => contract.id).thenReturn('C-1'); - when(() => contract.terms).thenReturn( - ContractTerms( + final contract = Contract.test( + id: 'C-1', + terms: ContractTerms( deadline: DateTime(2021), payment: ContractPayment(onAccepted: 100, onFulfilled: 1000), ), @@ -802,7 +799,7 @@ void main() { (invocation) => Future.value( AcceptContract200Response( data: AcceptContract200ResponseData( - contract: contract, + contract: contract.toOpenApi(), agent: agent.toOpenApi(), ), ), @@ -810,6 +807,8 @@ void main() { ); when(() => db.insertTransaction(any())).thenAnswer((_) async {}); + registerFallbackValue(Contract.fallbackValue()); + when(() => db.upsertContract(any())).thenAnswer((_) async {}); await runWithLogger(logger, () async { await acceptContractAndLog( diff --git a/packages/cli/test/printing_test.dart b/packages/cli/test/printing_test.dart index f857b60a..f30007d7 100644 --- a/packages/cli/test/printing_test.dart +++ b/packages/cli/test/printing_test.dart @@ -48,8 +48,10 @@ void main() { ), ], ), - expiration: deadline, deadlineToAccept: deadline, + accepted: false, + fulfilled: false, + timestamp: now, ); expect( contractDescription( diff --git a/packages/db/lib/db.dart b/packages/db/lib/db.dart index 11550e2c..858b7657 100644 --- a/packages/db/lib/db.dart +++ b/packages/db/lib/db.dart @@ -2,6 +2,7 @@ import 'package:db/config.dart'; import 'package:db/src/agent.dart'; import 'package:db/src/chart.dart'; import 'package:db/src/construction.dart'; +import 'package:db/src/contract.dart'; import 'package:db/src/extraction.dart'; import 'package:db/src/faction.dart'; import 'package:db/src/jump_gate.dart'; @@ -382,4 +383,14 @@ class Database { final query = getJumpGateQuery(waypointSymbol); return queryOne(query, jumpGateFromColumnMap); } + + /// Get all contracts from the database. + Future> allContracts() async { + return queryMany(allContractsQuery(), contractFromColumnMap); + } + + /// Upsert a contract into the database. + Future upsertContract(Contract contract) async { + await insertOne(upsertContractQuery(contract)); + } } diff --git a/packages/db/lib/src/contract.dart b/packages/db/lib/src/contract.dart new file mode 100644 index 00000000..8d1e4765 --- /dev/null +++ b/packages/db/lib/src/contract.dart @@ -0,0 +1,27 @@ +import 'package:db/src/query.dart'; +import 'package:types/types.dart'; + +/// Query to get all contracts. +Query allContractsQuery() => const Query('SELECT * FROM contract'); + +/// Upsert a contract. +Query upsertContractQuery(Contract contract) { + return Query( + 'INSERT INTO contract (id, json) VALUES (?, ?) ON CONFLICT(id) ' + 'DO UPDATE SET json = ?', + parameters: contractToColumnMap(contract), + ); +} + +/// Converts a contract from a column map. +Contract contractFromColumnMap(Map map) { + return Contract.fromJson(map['json'] as Map); +} + +/// Converts a contract to a column map. +Map contractToColumnMap(Contract contract) { + return { + 'id': contract.id, + 'json': contract.toJson(), + }; +} diff --git a/packages/db/sql/tables/16_contract.sql b/packages/db/sql/tables/16_contract.sql new file mode 100644 index 00000000..7abe4de9 --- /dev/null +++ b/packages/db/sql/tables/16_contract.sql @@ -0,0 +1,7 @@ +-- Holds a record of contract. +CREATE TABLE IF NOT EXISTS "contract_" ( + -- The id of the contract. + "id" text NOT NULL PRIMARY KEY, + -- The Contract object as json. + "json" json NOT NULL, +); \ No newline at end of file diff --git a/packages/types/lib/api.dart b/packages/types/lib/api.dart index 641298cb..296be514 100644 --- a/packages/types/lib/api.dart +++ b/packages/types/lib/api.dart @@ -1,272 +1,20 @@ import 'dart:convert'; -import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; import 'package:openapi/api.dart' - hide Agent, JumpGate, System, SystemWaypoint, Waypoint; + hide Agent, Contract, JumpGate, System, SystemWaypoint, Waypoint; import 'package:types/src/mount.dart'; +import 'package:types/src/position.dart'; +import 'package:types/src/symbol.dart'; export 'package:openapi/api.dart' - hide Agent, JumpGate, System, SystemWaypoint, Waypoint; + hide Agent, Contract, JumpGate, System, SystemWaypoint, Waypoint; /// The default implementation of getNow for production. /// Used for tests for overriding the current time. DateTime defaultGetNow() => DateTime.timestamp(); -/// A position within an unspecified coordinate space. -@immutable -class Position { - const Position._(this.x, this.y); - - /// The x coordinate. - final int x; - - /// The y coordinate. - final int y; -} - -/// An x, y position within the System coordinate space. -@immutable -class SystemPosition extends Position { - /// Construct a SystemPosition with the given x and y. - const SystemPosition(super.x, super.y) : super._(); - - /// Returns the distance between this position and the given position. - int distanceTo(SystemPosition other) { - // Use euclidean distance. - final dx = other.x - x; - final dy = other.y - y; - return sqrt(dx * dx + dy * dy).round(); - } -} - -/// An x, y position within the Waypoint coordinate space. -@immutable -class WaypointPosition extends Position { - /// Construct a WaypointPosition with the given x and y. - const WaypointPosition(super.x, super.y, this.system) : super._(); - - /// The system symbol of the waypoint. - final SystemSymbol system; - - /// Returns the distance between this position and the given position. - double distanceTo(WaypointPosition other) { - if (system != other.system) { - throw ArgumentError( - 'Waypoints must be in the same system: $this, $other', - ); - } - // Use euclidean distance. - final dx = other.x - x; - final dy = other.y - y; - return sqrt(dx * dx + dy * dy); - } -} - -/// Type-safe representation of a Waypoint Symbol -@immutable -class WaypointSymbol { - const WaypointSymbol._(this.waypoint, this.system); - - /// Create a WaypointSymbol from a json string. - factory WaypointSymbol.fromJson(String json) => - WaypointSymbol.fromString(json); - - /// Create a WaypointSymbol from a string. - factory WaypointSymbol.fromString(String symbol) { - if (_countHyphens(symbol) != 2) { - throw ArgumentError('Invalid waypoint symbol: $symbol'); - } - final systemSymbol = SystemSymbol.fromString( - symbol.substring(0, symbol.lastIndexOf('-')), - ); - return WaypointSymbol._(symbol, systemSymbol); - } - - /// Create a WaypointSymbol from json or null if the json is null. - static WaypointSymbol? fromJsonOrNull(String? json) => - json == null ? null : WaypointSymbol.fromJson(json); - - /// The full waypoint symbol. - final String waypoint; - - /// The system symbol of the waypoint. - final SystemSymbol system; - - /// The sector symbol of the waypoint. - String get sector { - // Avoid splitting the string if we don't have to. - final firstHyphen = waypoint.indexOf('-'); - return waypoint.substring(0, firstHyphen); - } - - /// Just the waypoint name (no sector or system) - String get waypointName { - // Avoid splitting the string if we don't have to. - final lastHyphen = waypoint.lastIndexOf('-'); - return waypoint.substring(lastHyphen + 1); - } - - /// Returns true if the waypoint is from the given system. - /// Faster than converting to a SystemSymbol and comparing. - // TODO(eseidel): This can be removed now. - bool hasSystem(SystemSymbol systemSymbol) { - // Avoid constructing a new SystemSymbol if we don't have to. - return system == systemSymbol; - } - - /// Returns the System as a string to pass to OpenAPI. - String get systemString => system.system; - - /// Just the system and waypoint name (no sector) - String get sectorLocalName { - // Avoid splitting the string if we don't have to. - final firstHyphen = waypoint.indexOf('-'); - return waypoint.substring(firstHyphen + 1); - } - - @override - String toString() => sectorLocalName; - - /// Returns the json representation of the waypoint. - String toJson() => waypoint; - - // Use a direct override rather than Equatable, because this code is - // extremely hot. - @override - bool operator ==(Object other) => - identical(this, other) || - other is WaypointSymbol && - runtimeType == other.runtimeType && - waypoint == other.waypoint; - - @override - int get hashCode => waypoint.hashCode; -} - -// We used to use split(), but that shows up in hot code paths. -/// Returns the number of hypens in the given string. -int _countHyphens(String str) { - var count = 0; - for (var i = 0; i < str.length; i++) { - if (str[i] == '-') { - count++; - } - } - return count; -} - -/// Type-safe representation of a System Symbol -@immutable -class SystemSymbol { - const SystemSymbol._(this.system); - - /// Create a SystemSymbol from a string. - factory SystemSymbol.fromString(String symbol) { - if (_countHyphens(symbol) != 1) { - throw ArgumentError('Invalid system symbol: $symbol'); - } - return SystemSymbol._(symbol); - } - - /// Create a SystemSymbol from a json string. - factory SystemSymbol.fromJson(String json) => SystemSymbol.fromString(json); - - /// The sector symbol of the system. - String get sector { - // Avoid splitting the string if we don't have to. - final firstHyphen = system.indexOf('-'); - return system.substring(0, firstHyphen); - } - - /// Just the system name (no sector) - String get systemName { - // Avoid splitting the string if we don't have to. - final lastHyphen = system.lastIndexOf('-'); - return system.substring(lastHyphen + 1); - } - - /// The full system symbol. - final String system; - - /// Convert to JSON. - String toJson() => system; - - @override - String toString() => system; - - // Use a direct override rather than Equatable, because this code is - // extremely hot. - @override - bool operator ==(Object other) => - identical(this, other) || - other is SystemSymbol && - runtimeType == other.runtimeType && - system == other.system; - - @override - int get hashCode => system.hashCode; -} - -/// Parsed ShipSymbol which can be compared/sorted. -@immutable -class ShipSymbol extends Equatable implements Comparable { - /// Create a ShipSymbol from name and number part. - /// The number part is given in decimal, but will be represented in hex. - const ShipSymbol(this.agentName, this.number); - - /// Create a ShipSymbol from a string. - ShipSymbol.fromString(String symbol) - : agentName = _parseAgentName(symbol), - number = int.parse(symbol.split('-').last, radix: 16); - - /// Create a ShipSymbol from a json string. - factory ShipSymbol.fromJson(String json) => ShipSymbol.fromString(json); - - static String _parseAgentName(String symbol) { - final parts = symbol.split('-'); - // Hyphens are allowed in the agent name, but the last part is always the - // number, there must be at least one hyphen. - if (parts.length < 2) { - throw ArgumentError('Invalid ship symbol: $symbol'); - } - final nameParts = parts.sublist(0, parts.length - 1); - return nameParts.join('-'); - } - - /// The name part of the ship symbol. - final String agentName; - - /// The number part of the ship symbol. - final int number; - - @override - List get props => [agentName, number]; - - /// The number part in hex. - String get hexNumber => number.toRadixString(16).toUpperCase(); - - /// The full ship symbol. - String get symbol => '$agentName-$hexNumber'; - - @override - int compareTo(ShipSymbol other) { - final nameCompare = agentName.compareTo(other.agentName); - if (nameCompare != 0) { - return nameCompare; - } - return number.compareTo(other.number); - } - - @override - String toString() => symbol; - - /// Returns the json representation of the ship symbol. - String toJson() => symbol; -} - /// Returns true if the given trait is minable. bool isMinableTrait(WaypointTraitSymbol trait) { return trait.value.endsWith('DEPOSITS'); @@ -554,25 +302,6 @@ extension ShipNavRouteWaypointUtils on ShipNavRouteWaypoint { position.distanceTo(other.position); } -/// Extensions onto Contract to make it easier to work with. -extension ContractUtils on Contract { - // bool needsItem(String tradeSymbol) => goodNeeded(tradeSymbol) != null; - - /// Returns the ContractDeliverGood for the given trade good symbol or null if - /// the contract doesn't need that good. - ContractDeliverGood? goodNeeded(TradeSymbol tradeSymbol) { - return terms.deliver - .firstWhereOrNull((item) => item.tradeSymbol == tradeSymbol.value); - } - - /// Returns the duration until the contract deadline. - Duration get timeUntilDeadline => - terms.deadline.difference(DateTime.timestamp()); - - /// Returns true if the contract has expired. - bool get isExpired => timeUntilDeadline.isNegative; -} - /// Extensions onto ContractDeliverGood to make it easier to work with. extension ContractDeliverGoodUtils on ContractDeliverGood { /// Returns the amount of the given trade good the contract needs. diff --git a/packages/types/lib/src/construction.dart b/packages/types/lib/src/construction.dart index bcc33248..ea46710f 100644 --- a/packages/types/lib/src/construction.dart +++ b/packages/types/lib/src/construction.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; import 'package:types/api.dart'; +import 'package:types/src/symbol.dart'; /// A class to hold transaction data from construction delivery. @immutable diff --git a/packages/types/lib/src/contract.dart b/packages/types/lib/src/contract.dart new file mode 100644 index 00000000..d2f13473 --- /dev/null +++ b/packages/types/lib/src/contract.dart @@ -0,0 +1,147 @@ +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:openapi/api.dart' as openapi; +import 'package:types/api.dart'; + +/// A contract to deliver goods to a faction. +class Contract { + /// Returns a new [Contract] instance. + Contract({ + required this.id, + required this.factionSymbol, + required this.type, + required this.terms, + required this.deadlineToAccept, + required this.accepted, + required this.fulfilled, + required this.timestamp, + }); + + /// Makes a new Contract for testing. + @visibleForTesting + Contract.test({ + required this.id, + required this.terms, + String? factionSymbol, + openapi.ContractTypeEnum? type, + DateTime? deadlineToAccept, + bool? accepted, + bool? fulfilled, + DateTime? timestamp, + }) : factionSymbol = factionSymbol ?? 'faction', + type = type ?? openapi.ContractTypeEnum.PROCUREMENT, + deadlineToAccept = deadlineToAccept ?? DateTime.timestamp(), + accepted = accepted ?? false, + fulfilled = fulfilled ?? false, + timestamp = timestamp ?? DateTime.timestamp(); + + /// Makes a new Contract with a fallback value for testing. + @visibleForTesting + Contract.fallbackValue() + : id = 'fallback', + factionSymbol = 'faction', + type = openapi.ContractTypeEnum.PROCUREMENT, + terms = openapi.ContractTerms( + payment: ContractPayment( + onFulfilled: 0, + onAccepted: 0, + ), + deadline: DateTime.timestamp(), + deliver: [], + ), + deadlineToAccept = DateTime.timestamp(), + accepted = false, + fulfilled = false, + timestamp = DateTime.timestamp(); + + /// Makes a Contract from OpenAPI. + Contract.fromOpenApi(openapi.Contract contract, this.timestamp) + : id = contract.id, + factionSymbol = contract.factionSymbol, + type = contract.type, + terms = contract.terms, + accepted = contract.accepted, + fulfilled = contract.fulfilled, + deadlineToAccept = contract.deadlineToAccept!; + + /// Makes a Contract from JSON. + Contract.fromJson(Map json) + : id = json['id'] as String, + factionSymbol = json['factionSymbol'] as String, + type = openapi.ContractTypeEnum.fromJson(json['type'] as String)!, + terms = openapi.ContractTerms.fromJson( + json['terms'] as Map, + )!, + deadlineToAccept = DateTime.parse(json['deadlineToAccept'] as String), + accepted = json['accepted'] as bool, + fulfilled = json['fulfilled'] as bool, + timestamp = DateTime.parse(json['timestamp'] as String); + + /// Converts the Contract to JSON. + Map toJson() { + return { + 'id': id, + 'factionSymbol': factionSymbol, + 'type': type.value, + 'terms': terms.toJson(), + 'deadlineToAccept': deadlineToAccept.toIso8601String(), + 'accepted': accepted, + 'fulfilled': fulfilled, + 'timestamp': timestamp.toIso8601String(), + }; + } + + /// ID of the contract. + String id; + + /// The symbol of the faction that this contract is for. + String factionSymbol; + + /// Type of contract. + openapi.ContractTypeEnum type; + + /// The terms of the contract. + openapi.ContractTerms terms; + + /// Whether the contract has been accepted by the agent + bool accepted; + + /// Whether the contract has been fulfilled + bool fulfilled; + + /// The time at which the contract is no longer available to be accepted + DateTime deadlineToAccept; + + /// Time when this contract was fetched from the server. + DateTime timestamp; + + /// Returns the contract as OpenAPI object. + openapi.Contract toOpenApi() { + return openapi.Contract( + id: id, + factionSymbol: factionSymbol, + type: type, + terms: terms, + accepted: accepted, + fulfilled: fulfilled, + deadlineToAccept: deadlineToAccept, + expiration: deadlineToAccept, + ); + } + + // bool needsItem(String tradeSymbol) => goodNeeded(tradeSymbol) != null; + + /// Returns the ContractDeliverGood for the given trade good symbol or null if + /// the contract doesn't need that good. + openapi.ContractDeliverGood? goodNeeded(openapi.TradeSymbol tradeSymbol) { + return terms.deliver + .firstWhereOrNull((item) => item.tradeSymbol == tradeSymbol.value); + } + + /// Returns the duration until the contract deadline. + Duration get timeUntilDeadline => + terms.deadline.difference(DateTime.timestamp()); + + /// Returns true if the contract has expired. + bool get isExpired => timeUntilDeadline.isNegative; +} diff --git a/packages/types/lib/src/contract_transaction.dart b/packages/types/lib/src/contract_transaction.dart index 5c48796a..6f9dfdb8 100644 --- a/packages/types/lib/src/contract_transaction.dart +++ b/packages/types/lib/src/contract_transaction.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; -import 'package:types/api.dart'; +import 'package:types/src/contract.dart'; +import 'package:types/src/symbol.dart'; /// The type of contract action. enum ContractAction { diff --git a/packages/types/lib/src/mount.dart b/packages/types/lib/src/mount.dart index 9245fa68..1a026b30 100644 --- a/packages/types/lib/src/mount.dart +++ b/packages/types/lib/src/mount.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'package:more/collection.dart'; import 'package:types/api.dart'; import 'package:types/behavior.dart'; +import 'package:types/src/symbol.dart'; /// Symbols of all cargo modules. final kCargoModules = { diff --git a/packages/types/lib/src/position.dart b/packages/types/lib/src/position.dart new file mode 100644 index 00000000..c9efeeb8 --- /dev/null +++ b/packages/types/lib/src/position.dart @@ -0,0 +1,54 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; +import 'package:types/src/symbol.dart'; + +/// A position within an unspecified coordinate space. +@immutable +class Position { + const Position._(this.x, this.y); + + /// The x coordinate. + final int x; + + /// The y coordinate. + final int y; +} + +/// An x, y position within the System coordinate space. +@immutable +class SystemPosition extends Position { + /// Construct a SystemPosition with the given x and y. + const SystemPosition(super.x, super.y) : super._(); + + /// Returns the distance between this position and the given position. + int distanceTo(SystemPosition other) { + // Use euclidean distance. + final dx = other.x - x; + final dy = other.y - y; + return sqrt(dx * dx + dy * dy).round(); + } +} + +/// An x, y position within the Waypoint coordinate space. +@immutable +class WaypointPosition extends Position { + /// Construct a WaypointPosition with the given x and y. + const WaypointPosition(super.x, super.y, this.system) : super._(); + + /// The system symbol of the waypoint. + final SystemSymbol system; + + /// Returns the distance between this position and the given position. + double distanceTo(WaypointPosition other) { + if (system != other.system) { + throw ArgumentError( + 'Waypoints must be in the same system: $this, $other', + ); + } + // Use euclidean distance. + final dx = other.x - x; + final dy = other.y - y; + return sqrt(dx * dx + dy * dy); + } +} diff --git a/packages/types/lib/src/symbol.dart b/packages/types/lib/src/symbol.dart new file mode 100644 index 00000000..6defaf14 --- /dev/null +++ b/packages/types/lib/src/symbol.dart @@ -0,0 +1,204 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// Type-safe representation of a Waypoint Symbol +@immutable +class WaypointSymbol { + const WaypointSymbol._(this.waypoint, this.system); + + /// Create a WaypointSymbol from a json string. + factory WaypointSymbol.fromJson(String json) => + WaypointSymbol.fromString(json); + + /// Create a WaypointSymbol from a string. + factory WaypointSymbol.fromString(String symbol) { + if (_countHyphens(symbol) != 2) { + throw ArgumentError('Invalid waypoint symbol: $symbol'); + } + final systemSymbol = SystemSymbol.fromString( + symbol.substring(0, symbol.lastIndexOf('-')), + ); + return WaypointSymbol._(symbol, systemSymbol); + } + + /// Create a WaypointSymbol from json or null if the json is null. + static WaypointSymbol? fromJsonOrNull(String? json) => + json == null ? null : WaypointSymbol.fromJson(json); + + /// The full waypoint symbol. + final String waypoint; + + /// The system symbol of the waypoint. + final SystemSymbol system; + + /// The sector symbol of the waypoint. + String get sector { + // Avoid splitting the string if we don't have to. + final firstHyphen = waypoint.indexOf('-'); + return waypoint.substring(0, firstHyphen); + } + + /// Just the waypoint name (no sector or system) + String get waypointName { + // Avoid splitting the string if we don't have to. + final lastHyphen = waypoint.lastIndexOf('-'); + return waypoint.substring(lastHyphen + 1); + } + + /// Returns true if the waypoint is from the given system. + /// Faster than converting to a SystemSymbol and comparing. + // TODO(eseidel): This can be removed now. + bool hasSystem(SystemSymbol systemSymbol) { + // Avoid constructing a new SystemSymbol if we don't have to. + return system == systemSymbol; + } + + /// Returns the System as a string to pass to OpenAPI. + String get systemString => system.system; + + /// Just the system and waypoint name (no sector) + String get sectorLocalName { + // Avoid splitting the string if we don't have to. + final firstHyphen = waypoint.indexOf('-'); + return waypoint.substring(firstHyphen + 1); + } + + @override + String toString() => sectorLocalName; + + /// Returns the json representation of the waypoint. + String toJson() => waypoint; + + // Use a direct override rather than Equatable, because this code is + // extremely hot. + @override + bool operator ==(Object other) => + identical(this, other) || + other is WaypointSymbol && + runtimeType == other.runtimeType && + waypoint == other.waypoint; + + @override + int get hashCode => waypoint.hashCode; +} + +// We used to use split(), but that shows up in hot code paths. +/// Returns the number of hypens in the given string. +int _countHyphens(String str) { + var count = 0; + for (var i = 0; i < str.length; i++) { + if (str[i] == '-') { + count++; + } + } + return count; +} + +/// Type-safe representation of a System Symbol +@immutable +class SystemSymbol { + const SystemSymbol._(this.system); + + /// Create a SystemSymbol from a string. + factory SystemSymbol.fromString(String symbol) { + if (_countHyphens(symbol) != 1) { + throw ArgumentError('Invalid system symbol: $symbol'); + } + return SystemSymbol._(symbol); + } + + /// Create a SystemSymbol from a json string. + factory SystemSymbol.fromJson(String json) => SystemSymbol.fromString(json); + + /// The sector symbol of the system. + String get sector { + // Avoid splitting the string if we don't have to. + final firstHyphen = system.indexOf('-'); + return system.substring(0, firstHyphen); + } + + /// Just the system name (no sector) + String get systemName { + // Avoid splitting the string if we don't have to. + final lastHyphen = system.lastIndexOf('-'); + return system.substring(lastHyphen + 1); + } + + /// The full system symbol. + final String system; + + /// Convert to JSON. + String toJson() => system; + + @override + String toString() => system; + + // Use a direct override rather than Equatable, because this code is + // extremely hot. + @override + bool operator ==(Object other) => + identical(this, other) || + other is SystemSymbol && + runtimeType == other.runtimeType && + system == other.system; + + @override + int get hashCode => system.hashCode; +} + +/// Parsed ShipSymbol which can be compared/sorted. +@immutable +class ShipSymbol extends Equatable implements Comparable { + /// Create a ShipSymbol from name and number part. + /// The number part is given in decimal, but will be represented in hex. + const ShipSymbol(this.agentName, this.number); + + /// Create a ShipSymbol from a string. + ShipSymbol.fromString(String symbol) + : agentName = _parseAgentName(symbol), + number = int.parse(symbol.split('-').last, radix: 16); + + /// Create a ShipSymbol from a json string. + factory ShipSymbol.fromJson(String json) => ShipSymbol.fromString(json); + + static String _parseAgentName(String symbol) { + final parts = symbol.split('-'); + // Hyphens are allowed in the agent name, but the last part is always the + // number, there must be at least one hyphen. + if (parts.length < 2) { + throw ArgumentError('Invalid ship symbol: $symbol'); + } + final nameParts = parts.sublist(0, parts.length - 1); + return nameParts.join('-'); + } + + /// The name part of the ship symbol. + final String agentName; + + /// The number part of the ship symbol. + final int number; + + @override + List get props => [agentName, number]; + + /// The number part in hex. + String get hexNumber => number.toRadixString(16).toUpperCase(); + + /// The full ship symbol. + String get symbol => '$agentName-$hexNumber'; + + @override + int compareTo(ShipSymbol other) { + final nameCompare = agentName.compareTo(other.agentName); + if (nameCompare != 0) { + return nameCompare; + } + return number.compareTo(other.number); + } + + @override + String toString() => symbol; + + /// Returns the json representation of the ship symbol. + String toJson() => symbol; +} diff --git a/packages/types/lib/system.dart b/packages/types/lib/system.dart index d64daae2..691e0e76 100644 --- a/packages/types/lib/system.dart +++ b/packages/types/lib/system.dart @@ -1,6 +1,8 @@ import 'package:meta/meta.dart'; import 'package:openapi/api.dart' as openapi; import 'package:types/api.dart'; +import 'package:types/src/position.dart'; +import 'package:types/src/symbol.dart'; /// A type representing the unchanging values of a waypoint. @immutable diff --git a/packages/types/lib/transaction.dart b/packages/types/lib/transaction.dart index 3b24a6fa..6d845ebd 100644 --- a/packages/types/lib/transaction.dart +++ b/packages/types/lib/transaction.dart @@ -2,7 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; import 'package:types/api.dart'; import 'package:types/src/construction.dart'; -import 'package:types/src/contract.dart'; +import 'package:types/src/contract_transaction.dart'; +import 'package:types/src/symbol.dart'; /// The accounting type of a transaction. enum AccountingType { diff --git a/packages/types/lib/types.dart b/packages/types/lib/types.dart index efe58f2c..8411bacc 100644 --- a/packages/types/lib/types.dart +++ b/packages/types/lib/types.dart @@ -5,6 +5,7 @@ export 'route.dart'; export 'src/agent.dart'; export 'src/construction.dart'; export 'src/contract.dart'; +export 'src/contract_transaction.dart'; export 'src/deal.dart'; export 'src/export.dart'; export 'src/extraction.dart'; @@ -12,9 +13,11 @@ export 'src/jump_gate.dart'; export 'src/market_listing.dart'; export 'src/market_price.dart'; export 'src/mount.dart'; +export 'src/position.dart'; export 'src/shipyard_listing.dart'; export 'src/shipyard_price.dart'; export 'src/survey.dart'; +export 'src/symbol.dart'; export 'src/trading.dart'; export 'system.dart'; export 'transaction.dart'; diff --git a/packages/types/test/contract_test.dart b/packages/types/test/contract_test.dart index 457d1e35..6d28e9a7 100644 --- a/packages/types/test/contract_test.dart +++ b/packages/types/test/contract_test.dart @@ -2,16 +2,12 @@ import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; import 'package:types/types.dart'; -class _MockContract extends Mock implements Contract {} - class _MockContractTerms extends Mock implements ContractTerms {} void main() { test('Contract types', () { - final contract = _MockContract(); - when(() => contract.id).thenReturn('id'); final contractTerms = _MockContractTerms(); - when(() => contract.terms).thenReturn(contractTerms); + final contract = Contract.test(id: 'id', terms: contractTerms); when(() => contractTerms.payment) .thenReturn(ContractPayment(onAccepted: 10, onFulfilled: 20));