diff --git a/packages/cli/lib/behavior/mount_from_delivery.dart b/packages/cli/lib/behavior/mount_from_delivery.dart index cce9f5a3..97afeca3 100644 --- a/packages/cli/lib/behavior/mount_from_delivery.dart +++ b/packages/cli/lib/behavior/mount_from_delivery.dart @@ -29,19 +29,25 @@ extension on Ship { } } -/// Change mounts on a ship. -Future advanceMountFromDelivery( +/// Init the change-mounts job. +Future doInitJob( + BehaviorState state, Api api, Database db, CentralCommand centralCommand, Caches caches, - BehaviorState state, Ship ship, { DateTime Function() getNow = defaultGetNow, }) async { - final toMount = state.mountToAdd; + final hqSystem = caches.agent.headquartersSymbol.systemSymbol; + final hqWaypoints = await caches.waypoints.waypointsInSystem(hqSystem); + final shipyard = assertNotNull( + hqWaypoints.firstWhereOrNull((w) => w.hasShipyard), + 'No shipyard in $hqSystem', + const Duration(days: 1), + ); + final shipyardSymbol = shipyard.waypointSymbol; - // Re-validate every loop in case resuming from error. final template = assertNotNull( centralCommand.templateForShip(ship), 'No template.', @@ -50,138 +56,185 @@ Future advanceMountFromDelivery( final needed = mountsToAddToShip(ship, template); jobAssert(needed.isNotEmpty, 'No mounts needed.', const Duration(hours: 1)); - // We've already started a change-mount job, continue. - if (toMount != null) { - shipInfo(ship, 'Changing mounts. Mounting $toMount.'); - final currentWaypoint = - await caches.waypoints.waypoint(ship.waypointSymbol); - if (!currentWaypoint.hasShipyard) { - shipErr(ship, 'Unexpectedly off course during change mount.'); - state.isComplete = true; - return null; - } + final available = centralCommand.unclaimedMountsAt(shipyardSymbol); + // If there is a mount ready for us to claim, claim it? + // Decide on the mount and "claim" it by saving it in our state. + final toClaim = assertNotNull( + _pickMountFromAvailable(available, needed), + 'No unclaimed mounts at $shipyardSymbol.', + const Duration(minutes: 10), + ); + shipInfo(ship, 'Claiming mount: $toClaim.'); + state.mountToAdd = toClaim; + return JobResult.complete(); +} + +/// Pickup the mount from the delivery ship. +Future doPickupJob( + BehaviorState state, + Api api, + Database db, + CentralCommand centralCommand, + Caches caches, + Ship ship, { + DateTime Function() getNow = defaultGetNow, +}) async { + // TODO(eseidel): Pickup location should be saved in state. + final hqSystem = caches.agent.headquartersSymbol.systemSymbol; + final hqWaypoints = await caches.waypoints.waypointsInSystem(hqSystem); + final shipyard = assertNotNull( + hqWaypoints.firstWhereOrNull((w) => w.hasShipyard), + 'No shipyard in $hqSystem', + const Duration(days: 1), + ); + final shipyardSymbol = shipyard.waypointSymbol; - final tradeSymbol = tradeSymbolForMountSymbol(toMount); - final deliveryShip = assertNotNull( - centralCommand.getDeliveryShip(ship.shipSymbol, tradeSymbol), - 'No delivery ship for $tradeSymbol.', - const Duration(minutes: 10), + if (ship.waypointSymbol != shipyardSymbol) { + final waitUntil = await beingNewRouteAndLog( + api, + ship, + state, + caches.ships, + caches.systems, + caches.routePlanner, + centralCommand, + shipyardSymbol, ); + return JobResult.wait(waitUntil); + } - // We could match the docking status instead. - if (!deliveryShip.isDocked) { - shipErr(ship, 'Delivery ship undocked during change mount, docking it.'); - // Terrible hack. - await dockIfNeeded(api, caches.ships, deliveryShip); - } + final tradeSymbol = tradeSymbolForMountSymbol(state.mountToAdd!); + final deliveryShip = assertNotNull( + centralCommand.getDeliveryShip(ship.shipSymbol, tradeSymbol), + 'No delivery ship for $tradeSymbol.', + const Duration(minutes: 10), + ); - await dockIfNeeded(api, caches.ships, ship); - - if (ship.countUnits(tradeSymbol) < 1) { - try { - // Get it from the delivery ship. - await transferCargoAndLog( - api, - caches.ships, - from: deliveryShip, - to: ship, - tradeSymbol: tradeSymbol, - units: 1, - ); - } on ApiException catch (e) { - shipErr(ship, 'Failed to transfer mount: $e'); - jobAssert( - false, - 'Failed to transfer mount.', - const Duration(minutes: 10), - ); - } - } + // We could match the docking status instead. + if (!deliveryShip.isDocked) { + shipErr(ship, 'Delivery ship undocked during change mount, docking it.'); + // Terrible hack. + await dockIfNeeded(api, caches.ships, deliveryShip); + } - // TODO(eseidel): This should only remove mounts if we absolutely need to. - // This could end up removing mounts before we need to. - final toRemove = mountsToRemoveFromShip(ship, template); - if (toRemove.isNotEmpty) { - // Unmount existing mounts if needed. - for (final mount in toRemove) { - await removeMountAndLog( - api, - db, - caches.agent, - caches.ships, - ship, - mount, - ); - } + await dockIfNeeded(api, caches.ships, ship); + + if (ship.countUnits(tradeSymbol) < 1) { + try { + // Get it from the delivery ship. + await transferCargoAndLog( + api, + caches.ships, + from: deliveryShip, + to: ship, + tradeSymbol: tradeSymbol, + units: 1, + ); + } on ApiException catch (e) { + shipErr(ship, 'Failed to transfer mount: $e'); + jobAssert( + false, + 'Failed to transfer mount.', + const Duration(minutes: 10), + ); } + } + return JobResult.complete(); +} - // 🛸#6 🔧 MOUNT_MINING_LASER_II on ESEIDEL-6 for 3,600c -> 🏦 89,172c - // Mount the new mount. - await installMountAndLog( - api, - db, - caches.agent, - caches.ships, - ship, - toMount, - ); +/// Actually change the mounts on the ship. +Future doChangeMounts( + BehaviorState state, + Api api, + Database db, + CentralCommand centralCommand, + Caches caches, + Ship ship, { + DateTime Function() getNow = defaultGetNow, +}) async { + final template = assertNotNull( + centralCommand.templateForShip(ship), + 'No template.', + const Duration(hours: 1), + ); - // Give the delivery ship our extra mount if we have one. - final extraMounts = ship.mountsInCargo(); - if (extraMounts.isNotEmpty) { - // This could send more items than deliveryShip has space for. - for (final cargoItem in extraMounts) { - await transferCargoAndLog( - api, - caches.ships, - from: ship, - to: deliveryShip, - tradeSymbol: cargoItem.tradeSymbol, - units: cargoItem.units, - ); - } + // TODO(eseidel): This should only remove mounts if we absolutely need to. + // This could end up removing mounts before we need to. + final toRemove = mountsToRemoveFromShip(ship, template); + if (toRemove.isNotEmpty) { + // Unmount existing mounts if needed. + for (final mount in toRemove) { + await removeMountAndLog( + api, + db, + caches.agent, + caches.ships, + ship, + mount, + ); } - - // We're done. - state.isComplete = true; - jobAssert( - false, - 'Mounting complete!', - const Duration(hours: 1), - ); - return null; } - final hqSystem = caches.agent.headquartersSymbol.systemSymbol; - final hqWaypoints = await caches.waypoints.waypointsInSystem(hqSystem); - final shipyard = assertNotNull( - hqWaypoints.firstWhereOrNull((w) => w.hasShipyard), - 'No shipyard in $hqSystem', - const Duration(days: 1), + // 🛸#6 🔧 MOUNT_MINING_LASER_II on ESEIDEL-6 for 3,600c -> 🏦 89,172c + // Mount the new mount. + await installMountAndLog( + api, + db, + caches.agent, + caches.ships, + ship, + state.mountToAdd!, ); - final shipyardSymbol = shipyard.waypointSymbol; + return JobResult.complete(); +} - final available = centralCommand.unclaimedMountsAt(shipyardSymbol); - // If there is a mount ready for us to claim, claim it? - // Decide on the mount and "claim" it by saving it in our state. - final toClaim = assertNotNull( - _pickMountFromAvailable(available, needed), - 'No unclaimed mounts at $shipyardSymbol.', +/// Give the delivery ship any extra mounts we have. +Future doGiveExtraMounts( + BehaviorState state, + Api api, + Database db, + CentralCommand centralCommand, + Caches caches, + Ship ship, { + DateTime Function() getNow = defaultGetNow, +}) async { + final tradeSymbol = tradeSymbolForMountSymbol(state.mountToAdd!); + final deliveryShip = assertNotNull( + centralCommand.getDeliveryShip(ship.shipSymbol, tradeSymbol), + 'No delivery ship for $tradeSymbol.', const Duration(minutes: 10), ); - shipInfo(ship, 'Claiming mount: $toClaim.'); - state.mountToAdd = toClaim; - // Go to the shipyard. - final waitUntil = beingNewRouteAndLog( - api, - ship, - state, - caches.ships, - caches.systems, - caches.routePlanner, - centralCommand, - shipyardSymbol, + // Give the delivery ship our extra mount if we have one. + final extraMounts = ship.mountsInCargo(); + if (extraMounts.isNotEmpty) { + // This could send more items than deliveryShip has space for. + for (final cargoItem in extraMounts) { + await transferCargoAndLog( + api, + caches.ships, + from: ship, + to: deliveryShip, + tradeSymbol: cargoItem.tradeSymbol, + units: cargoItem.units, + ); + } + } + + // We're done. + state.isComplete = true; + jobAssert( + false, + 'Mounting complete!', + const Duration(hours: 1), ); - return waitUntil; + return JobResult.complete(); } + +/// Advance the behavior of the given ship. +final advanceMountFromDelivery = const MultiJob('Mount from Delivery', [ + doInitJob, + doPickupJob, + doChangeMounts, + doGiveExtraMounts, +]).run; diff --git a/packages/cli/test/behavior/change_mounts_test.dart b/packages/cli/test/behavior/change_mounts_test.dart index 9ef6f550..43f83d45 100644 --- a/packages/cli/test/behavior/change_mounts_test.dart +++ b/packages/cli/test/behavior/change_mounts_test.dart @@ -1,3 +1,4 @@ +import 'package:cli/behavior/behavior.dart'; import 'package:cli/behavior/central_command.dart'; import 'package:cli/behavior/mount_from_delivery.dart'; import 'package:cli/cache/caches.dart'; @@ -8,6 +9,8 @@ import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; import 'package:types/types.dart'; +class _MockAgent extends Mock implements Agent {} + class _MockAgentCache extends Mock implements AgentCache {} class _MockApi extends Mock implements Api {} @@ -45,6 +48,9 @@ void main() { final fleetApi = _MockFleetApi(); when(() => api.fleet).thenReturn(fleetApi); final agentCache = _MockAgentCache(); + final agent = _MockAgent(); + when(() => agentCache.agent).thenReturn(agent); + when(() => agent.credits).thenReturn(1000000); final ship = _MockShip(); final systemsCache = _MockSystemsCache(); final waypointCache = _MockWaypointCache(); @@ -61,10 +67,10 @@ void main() { final now = DateTime(2021); DateTime getNow() => now; - const shipSymbol = ShipSymbol('S', 1); + const shipSymbol = ShipSymbol('S', 2); when(() => ship.symbol).thenReturn(shipSymbol.symbol); when(() => ship.nav).thenReturn(shipNav); - when(() => shipNav.status).thenReturn(ShipNavStatus.IN_ORBIT); + when(() => shipNav.status).thenReturn(ShipNavStatus.DOCKED); final symbol = WaypointSymbol.fromString('S-A-W'); when(() => shipNav.waypointSymbol).thenReturn(symbol.waypoint); when(() => shipNav.systemSymbol).thenReturn(symbol.system); @@ -80,6 +86,19 @@ void main() { final shipEngine = _MockShipEngine(); when(() => shipEngine.speed).thenReturn(10); when(() => ship.engine).thenReturn(shipEngine); + const toMount = TradeSymbol.MOUNT_SURVEYOR_II; + final cargoItem = ShipCargoItem( + symbol: toMount.value, + name: '', + description: '', + units: 1, + ); + final shipCargo = ShipCargo( + capacity: 10, + units: 1, + inventory: [cargoItem], + ); + when(() => ship.cargo).thenReturn(shipCargo); final waypoint = _MockWaypoint(); when(() => waypoint.symbol).thenReturn(symbol.waypoint); @@ -115,21 +134,92 @@ void main() { when(() => centralCommand.unclaimedMountsAt(symbol)) .thenReturn(MountSymbolSet.from([ShipMountSymbolEnum.SURVEYOR_II])); + final deliveryShip = _MockShip(); + const deliveryShipSymbol = ShipSymbol('S', 1); + when(() => deliveryShip.symbol).thenReturn(deliveryShipSymbol.symbol); + final deliveryShipNav = _MockShipNav(); + when(() => deliveryShip.nav).thenReturn(deliveryShipNav); + when(() => deliveryShipNav.status).thenReturn(ShipNavStatus.DOCKED); + when( + () => centralCommand.getDeliveryShip(shipSymbol, toMount), + ).thenReturn(deliveryShip); + // Empty, just needed for the "transfer extra" step. + final deliveryShipCargo = ShipCargo(capacity: 10, units: 10); + when(() => deliveryShip.cargo).thenReturn(deliveryShipCargo); + + when( + () => fleetApi.installMount( + shipSymbol.symbol, + installMountRequest: InstallMountRequest(symbol: toMount.value), + ), + ).thenAnswer( + (_) => Future.value( + InstallMount201Response( + data: InstallMount201ResponseData( + agent: agent, + cargo: shipCargo, + transaction: ShipModificationTransaction( + waypointSymbol: symbol.waypoint, + tradeSymbol: TradeSymbol.MOUNT_SURVEYOR_II.value, + totalPrice: 100, + shipSymbol: shipSymbol.symbol, + timestamp: DateTime(2021), + ), + ), + ), + ), + ); + registerFallbackValue(Transaction.fallbackValue()); + when(() => db.insertTransaction(any())).thenAnswer((_) => Future.value()); + + // This shouldn't be needed, it's trying to transfer the "extra mount" + // back to the delivery ship, because our mocks never update the + // inventory of the ship after performing the install to no longer include + // the mount we just installed. + when( + () => fleetApi.transferCargo( + shipSymbol.symbol, + transferCargoRequest: TransferCargoRequest( + shipSymbol: deliveryShipSymbol.symbol, + tradeSymbol: toMount, + units: 1, + ), + ), + ).thenAnswer( + (_) => Future.value( + TransferCargo200Response( + data: Jettison200ResponseData( + cargo: shipCargo, + ), + ), + ), + ); + final state = BehaviorState(shipSymbol, Behavior.mountFromDelivery); final logger = _MockLogger(); - final waitUntil = await runWithLogger( - logger, - () => advanceMountFromDelivery( - api, - db, - centralCommand, - caches, - state, - ship, - getNow: getNow, + expect( + () async { + final waitUntil = await runWithLogger( + logger, + () => advanceMountFromDelivery( + api, + db, + centralCommand, + caches, + state, + ship, + getNow: getNow, + ), + ); + return waitUntil; + }, + throwsA( + const JobException( + 'Mounting complete!', + Duration(hours: 1), + ), ), ); - expect(waitUntil, isNull); }); }