diff --git a/app/Services/FleetMissionService.php b/app/Services/FleetMissionService.php index de9c5a38b..12f34713f 100644 --- a/app/Services/FleetMissionService.php +++ b/app/Services/FleetMissionService.php @@ -282,28 +282,12 @@ public function getActiveFleetMissionsForCurrentPlayer(): Collection $planetIds[] = $planet->getPlanetId(); } - $currentTime = Date::now()->timestamp; - $missions = $query->where(function ($query) use ($planetIds) { $query->where('user_id', $this->player->getId()) ->orWhereIn('planet_id_to', $planetIds); }) ->where('canceled', 0) // Exclude canceled missions - ->where(function ($query) use ($currentTime) { - // Include unprocessed missions - $query->where('processed', 0) - // Also include ACS Defend outbound missions that are processed but still in hold time - // (ACS Defend is marked processed=1 immediately at arrival, before hold time ends) - ->orWhere(function ($query) use ($currentTime) { - $query->where('mission_type', 5) - ->whereNull('parent_id') - ->where('processed', 1) - ->where('time_arrival', '<=', $currentTime) - // IMPORTANT: Holding time is always real time (not affected by fleet speed) - ->whereRaw('time_arrival + time_holding > ?', [$currentTime]); - }); - // Note: Expeditions stay processed=0 during hold time, so they're already included above - }) + ->where('processed', 0) ->get(); // Order the list taking into account the time_holding. This ensures that the order of missions is correct diff --git a/tests/Feature/FleetDispatch/FleetDispatchMultiDefenderBattleTest.php b/tests/Feature/FleetDispatch/FleetDispatchMultiDefenderBattleTest.php index b71a3a6d8..549798ebc 100644 --- a/tests/Feature/FleetDispatch/FleetDispatchMultiDefenderBattleTest.php +++ b/tests/Feature/FleetDispatch/FleetDispatchMultiDefenderBattleTest.php @@ -926,4 +926,146 @@ public function testMultipleDefendersWithDifferentTechLevels(): void $totalDefenderLightFighters = $defenderStartUnits['light_fighter'] ?? 0; $this->assertEquals(45, $totalDefenderLightFighters, 'All defending fleets should participate with their units'); } + + /** + * Regression test for #1321: an ACS Defend fleet destroyed in battle must NOT re-appear + * in the fleet widget (getActiveFleetMissionsForCurrentPlayer) after its hold time expires. + * + * A fleet destroyed during hold is marked processed=1 while its time_arrival/time_holding + * are left unchanged. Before the fix, getActiveFleetMissionsForCurrentPlayer() had an extra + * clause that re-included processed ACS Defend outbound missions in the window + * [time_arrival, time_arrival + time_holding), so the destroyed mission re-surfaced in the + * widget for a full holding time after the hold had already ended. The fix reduces the query + * to "processed = 0" only, so a destroyed (processed=1) mission is never returned. + */ + public function testDestroyedAcsDefendMissionNotReturnedByActiveFleetMissionsAfterHoldExpires(): void + { + $this->basicSetup(); + $this->createBuddyPlayer(); + + // Create ACS defender and send a small defend fleet that will be wiped out in battle. + $acsDefender = $this->createAcsDefender(); + $acsDefender['planet']->addUnit('light_fighter', 5); + $acsDefender['planet']->addResources(new Resources(0, 0, 1000000, 0)); + + $acsDefendFleet = new UnitCollection(); + $acsDefendFleet->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 5); + + $fleetMissionService = resolve(FleetMissionService::class, ['player' => $acsDefender['planet']->getPlayer()]); + $acsDefendMission = $fleetMissionService->createNewFromPlanet( + $acsDefender['planet'], + $this->buddyPlanet()->getPlanetCoordinates(), + PlanetType::Planet, + 5, // ACS Defend mission type + $acsDefendFleet, + new Resources(0, 0, 0, 0), + 10, // 100% speed + 2 // Hold for 2 hours + ); + + // Advance time so the ACS defend fleet arrives and starts holding. + // With the current architecture time_arrival includes the hold time, so the physical + // arrival (start of hold) is time_arrival - time_holding. + $physicalArrivalTime = $acsDefendMission->time_arrival - $acsDefendMission->time_holding; + $this->travelTo(Date::createFromTimestamp($physicalArrivalTime + 10)); + $this->reloadApplication(); + $this->get('/overview'); + + // Send an overwhelming attack that destroys the defending fleet during the hold. + $attackFleet = new UnitCollection(); + $attackFleet->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 100); + $this->dispatchFleet( + $this->buddyPlanet()->getPlanetCoordinates(), + $attackFleet, + new Resources(0, 0, 0, 0), + PlanetType::Planet + ); + + $attackerFleetMissionService = resolve(FleetMissionService::class, ['player' => $this->planetService->getPlayer()]); + $attackMission = $attackerFleetMissionService->getActiveFleetMissionsForCurrentPlayer()->first(); + if ($attackMission === null) { + $this->fail('No active attack mission found.'); + } + + // Advance time for the attack to arrive: the battle occurs and destroys the ACS defend fleet. + $this->travelTo(Date::createFromTimestamp($attackMission->time_arrival + 10)); + $this->reloadApplication(); + $this->playerSetAllMessagesRead(); + $this->get('/overview'); + + // Sanity check: the destroyed outbound ACS defend mission is now marked processed=1 + // (its time_arrival/time_holding are unchanged, so it sits in the old buggy window). + $acsDefendMissionReloaded = FleetMission::find($acsDefendMission->id); + if ($acsDefendMissionReloaded === null) { + $this->fail('Fleet mission not found.'); + } + $this->assertEquals(1, $acsDefendMissionReloaded->processed, 'Destroyed ACS defend fleet should be marked as processed'); + + // Advance time PAST the hold end (time_arrival). This is exactly the window in which the + // old query re-surfaced the destroyed mission in the fleet widget. + $this->travelTo(Date::createFromTimestamp($acsDefendMission->time_arrival + 10)); + $this->reloadApplication(); + + // The destroyed mission must NOT be returned by the fleet widget query for the defender. + $defenderPlayerService = resolve(PlayerService::class, ['player_id' => $acsDefender['user']->id]); + $defenderFleetMissionService = resolve(FleetMissionService::class, ['player' => $defenderPlayerService]); + $destroyedMissionStillActive = $defenderFleetMissionService->getActiveFleetMissionsForCurrentPlayer() + ->contains(fn ($mission) => (int) $mission->id === (int) $acsDefendMission->id); + + $this->assertFalse( + $destroyedMissionStillActive, + 'A destroyed ACS Defend mission must not re-appear in the fleet widget after its hold expires.' + ); + } + + /** + * Companion regression test for #1321: a normal (non-destroyed) ACS Defend fleet must still be + * returned by getActiveFleetMissionsForCurrentPlayer() while it is holding at the target. + * + * The outbound mission stays processed=0 for the whole hold (processed becomes 1 only when the + * hold ends), so the "processed = 0" query must keep it visible during hold. This locks in both + * sides of the fix: destroyed (processed=1) missions disappear, normal holding ones stay. + */ + public function testHoldingAcsDefendMissionReturnedByActiveFleetMissionsDuringHold(): void + { + $this->basicSetup(); + $this->createBuddyPlayer(); + + // Create ACS defender and send a defend fleet; it is never attacked, so it keeps holding. + $acsDefender = $this->createAcsDefender(); + $acsDefender['planet']->addUnit('light_fighter', 20); + $acsDefender['planet']->addResources(new Resources(0, 0, 1000000, 0)); + + $acsDefendFleet = new UnitCollection(); + $acsDefendFleet->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 20); + + $fleetMissionService = resolve(FleetMissionService::class, ['player' => $acsDefender['planet']->getPlayer()]); + $acsDefendMission = $fleetMissionService->createNewFromPlanet( + $acsDefender['planet'], + $this->buddyPlanet()->getPlanetCoordinates(), + PlanetType::Planet, + 5, // ACS Defend mission type + $acsDefendFleet, + new Resources(0, 0, 0, 0), + 10, // 100% speed + 2 // Hold for 2 hours + ); + + // Advance time to within the hold period (fleet has arrived and is holding, not destroyed). + $physicalArrivalTime = $acsDefendMission->time_arrival - $acsDefendMission->time_holding; + $this->travelTo(Date::createFromTimestamp($physicalArrivalTime + 10)); + $this->reloadApplication(); + $this->get('/overview'); + + // The holding (processed=0) outbound mission must be returned by the fleet widget query. + $defenderPlayerService = resolve(PlayerService::class, ['player_id' => $acsDefender['user']->id]); + $defenderFleetMissionService = resolve(FleetMissionService::class, ['player' => $defenderPlayerService]); + $holdingMissionActive = $defenderFleetMissionService->getActiveFleetMissionsForCurrentPlayer() + ->contains(fn ($mission) => (int) $mission->id === (int) $acsDefendMission->id); + + $this->assertTrue( + $holdingMissionActive, + 'A normal ACS Defend mission must still be shown in the fleet widget while it is holding.' + ); + } }