diff --git a/app/Console/Commands/Scheduler/DeleteInactivePlayers.php b/app/Console/Commands/Scheduler/DeleteInactivePlayers.php new file mode 100644 index 000000000..16d3a4a14 --- /dev/null +++ b/app/Console/Commands/Scheduler/DeleteInactivePlayers.php @@ -0,0 +1,63 @@ +inactivePlayerDeletionDays(); + + // 0 = feature disabled (this is the default). + if ($days <= 0) { + $this->info('Inactive player deletion is disabled (inactive_player_deletion_days = 0).'); + + return Command::SUCCESS; + } + + $cutoff = now()->subDays($days)->timestamp; + $deletedCount = 0; + $failedCount = 0; + + // Admins are excluded to protect the server operator and system accounts, mirroring the + // existing "administrators cannot be banned" rule. Vacation mode does NOT exempt a player. + // The users.time column holds the last-activity UNIX timestamp. + User::withoutRole('admin') + ->whereNotNull('time') + ->where('time', '<', $cutoff) + ->chunkById(200, function ($users) use ($playerServiceFactory, &$deletedCount, &$failedCount): void { + foreach ($users as $user) { + try { + // Load with a fresh cache so the deletion acts on the player's current + // planets, moons and missions rather than any stale cached state. + $playerServiceFactory->make($user->id, true)->deleteInactiveAccount(); + $deletedCount++; + } catch (Throwable $e) { + $failedCount++; + $this->error("Failed to delete inactive player #{$user->id}: " . $e->getMessage()); + } + } + }); + + $this->info("Deleted {$deletedCount} inactive player(s) with no activity in the last {$days} day(s)."); + + if ($failedCount > 0) { + $this->warn("{$failedCount} player(s) could not be deleted; see errors above."); + } + + return $failedCount > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/ServerSettingsController.php b/app/Http/Controllers/Admin/ServerSettingsController.php index a36b701e5..e459ea210 100644 --- a/app/Http/Controllers/Admin/ServerSettingsController.php +++ b/app/Http/Controllers/Admin/ServerSettingsController.php @@ -73,6 +73,7 @@ public function index(PlayerService $player, SettingsService $settingsService): 'expedition_weight_items' => $settingsService->expeditionWeightItems(), 'hamill_probability' => $settingsService->hamillManoeuvreChance(), 'highscore_admin_visible' => $settingsService->highscoreAdminVisible(), + 'inactive_player_deletion_days' => $settingsService->inactivePlayerDeletionDays(), ]); } @@ -144,6 +145,8 @@ public function update(SettingsService $settingsService): RedirectResponse $settingsService->set('highscore_admin_visible', request('highscore_admin_visible', 0)); + $settingsService->set('inactive_player_deletion_days', request('inactive_player_deletion_days', 0)); + // Clear highscore cache when admin visibility setting changes $this->clearHighscoreCache(); diff --git a/app/Services/PlanetService.php b/app/Services/PlanetService.php index c702703f1..17ae6ee77 100644 --- a/app/Services/PlanetService.php +++ b/app/Services/PlanetService.php @@ -217,19 +217,22 @@ public function isValidPlanetName(string $name): bool /** * Abandon (delete) the current planet. Careful: this action is irreversible! * + * @param bool $force When true, skip the user-facing sanity checks (last remaining planet and + * active fleet missions). Used when the planet is removed as part of a full account deletion, + * where these guards do not apply because the whole account is going away. * @return void */ - public function abandonPlanet(): void + public function abandonPlanet(bool $force = false): void { // Sanity check: disallow abandoning the last remaining planet of user. - if ($this->isPlanet() && $this->player->planets->planetCount() < 2) { + if (!$force && $this->isPlanet() && $this->player->planets->planetCount() < 2) { throw new RuntimeException('Cannot abandon only remaining planet.'); } // Sanity check: disallow abandoning a planet with active fleet missions. // Moons are exempt: moon destruction redirects incoming fleets and nulls out planet // references for any remaining missions, so active missions are handled gracefully. - if ($this->isPlanet()) { + if (!$force && $this->isPlanet()) { $fleetMissionService = resolve(FleetMissionService::class); $activeMissions = $fleetMissionService->getActiveMissionsByPlanetIds([$this->planet->id]); @@ -240,7 +243,10 @@ public function abandonPlanet(): void // If this is a planet and has a moon, delete the moon first if ($this->isPlanet() && $this->hasMoon()) { - $this->moon()->abandonPlanet(); + // TODO (#146): once "Destroyed Planet" logic is implemented, abandoning a planet with a + // moon should transition the moon into the destroyed-planet flow here instead of simply + // deleting it. For now the moon is abandoned (deleted) directly. + $this->moon()->abandonPlanet($force); } // Anonymize the planet in all tables where it is referenced. @@ -274,6 +280,9 @@ public function abandonPlanet(): void // TODO: add feature test to check that abandoning a planet works correctly in various scenarios. + // TODO (#146): once "Destroyed Planet" logic is implemented, this is where an abandoned planet + // should transition into the destroyed-planet state (e.g. leaving a destroyed-planet marker in + // the galaxy) instead of being hard-deleted. Until #146 lands we simply remove the planet row. // Delete the planet from the database $this->planet->delete(); } diff --git a/app/Services/PlayerService.php b/app/Services/PlayerService.php index 90e9bc54d..d188f74e6 100644 --- a/app/Services/PlayerService.php +++ b/app/Services/PlayerService.php @@ -916,6 +916,56 @@ public function delete(): void $this->user->delete(); } + /** + * Permanently delete an inactive player: abandon all of their planets (and moons) and remove + * the account with all associated records. + * + * Unlike delete(), planets are removed through the abandonment flow (PlanetService::abandonPlanet()) + * so galaxy slots are freed and incoming missions from other players are handled gracefully. The + * abandonment guards (last remaining planet / active fleet missions) are bypassed because the whole + * account is being removed. Careful: this action is irreversible! + * + * @return void + */ + public function deleteInactiveAccount(): void + { + DB::transaction(function () { + // Delete the player's own fleet missions. Child (return) missions reference their parent + // via parent_id, so remove those first to satisfy the self-referencing foreign key. + $missions = FleetMission::where('user_id', $this->getId())->get(); + foreach ($missions as $mission) { + FleetMission::where('parent_id', $mission->id)->delete(); + $mission->delete(); + } + + // Abandon every planet; each call cascades to its moon. Force bypasses the user-facing + // guards which do not apply during full account deletion. Abandoning frees the galaxy slot + // and nulls out any remaining incoming missions from other players. + foreach ($this->planets->allPlanets() as $planet) { + $planet->abandonPlanet(true); + } + + // Delete remaining user-scoped records that no database cascade covers. + Message::where('user_id', $this->getId())->delete(); + Highscore::where('player_id', $this->getId())->delete(); + UserTech::where('user_id', $this->getId())->delete(); + + // Remove any lingering login sessions for this user (the sessions table has no foreign key). + DB::table('sessions')->where('user_id', $this->getId())->delete(); + + // Note: battle and espionage reports are intentionally kept. Their planet_user_id foreign + // key is ON DELETE SET NULL, so the reports are preserved for the other party involved. + + // Clear the current planet reference before deleting the user (foreign key constraint). + $this->user->planet_current = null; + $this->user->save(); + + // Delete the user. Cascade deletes handle notes, buddies, alliance membership, chat + // messages, dark matter transactions, bans, etc. + $this->user->delete(); + }); + } + /** * Get is the player building the object or not * diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index a9290fc28..28cc3454c 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -728,6 +728,17 @@ public function highscoreAdminVisible(): bool return (bool)$this->get('highscore_admin_visible', 0); } + /** + * Returns the number of days of inactivity after which a player is + * permanently deleted. Returns 0 when the feature is disabled (default). + * + * @return int + */ + public function inactivePlayerDeletionDays(): int + { + return (int)$this->get('inactive_player_deletion_days', 0); + } + /** * Returns the server rules content (BBCode). * diff --git a/database/migrations/2026_07_08_000000_add_inactive_player_deletion_setting.php b/database/migrations/2026_07_08_000000_add_inactive_player_deletion_setting.php new file mode 100644 index 000000000..715e0c80a --- /dev/null +++ b/database/migrations/2026_07_08_000000_add_inactive_player_deletion_setting.php @@ -0,0 +1,30 @@ +updateOrInsert( + ['key' => 'inactive_player_deletion_days'], + ['value' => '0', 'created_at' => now(), 'updated_at' => now()] + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + DB::table('settings')->where('key', 'inactive_player_deletion_days')->delete(); + } +}; diff --git a/resources/views/ingame/admin/serversettings.blade.php b/resources/views/ingame/admin/serversettings.blade.php index ffa9a809d..14439983a 100644 --- a/resources/views/ingame/admin/serversettings.blade.php +++ b/resources/views/ingame/admin/serversettings.blade.php @@ -125,6 +125,18 @@ +

@lang('Player deletion settings.')

+ +
+
+ +
+ +
+
@lang('Number of days of inactivity after which a player is permanently deleted and their planets are abandoned. Set to 0 to disable.')
+
+
+

@lang('Dark Matter regeneration settings.')

diff --git a/routes/console.php b/routes/console.php index b99a1ea4b..bf5756cb7 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use OGame\Console\Commands\Scheduler\CleanupWreckFields; use OGame\Console\Commands\Scheduler\DarkMatterRegenerateCommand; +use OGame\Console\Commands\Scheduler\DeleteInactivePlayers; use OGame\Console\Commands\Scheduler\DeleteOldMessages; use OGame\Console\Commands\Scheduler\GenerateAllianceHighscores; use OGame\Console\Commands\Scheduler\GenerateHighscoreRanks; @@ -36,3 +37,6 @@ // Process Dark Matter regeneration every 5 minutes Schedule::command(DarkMatterRegenerateCommand::class)->everyFiveMinutes()->withoutOverlapping(); + +// Delete players that have been inactive beyond the configured threshold (0 = disabled) +Schedule::command(DeleteInactivePlayers::class)->daily()->withoutOverlapping(); diff --git a/tests/Feature/DeleteInactivePlayersCommandTest.php b/tests/Feature/DeleteInactivePlayersCommandTest.php new file mode 100644 index 000000000..d12951521 --- /dev/null +++ b/tests/Feature/DeleteInactivePlayersCommandTest.php @@ -0,0 +1,206 @@ + User IDs created during tests, deleted in tearDown. */ + private array $createdUserIds = []; + + protected function tearDown(): void + { + if (!empty($this->createdUserIds)) { + DB::table('users_tech')->whereIn('user_id', $this->createdUserIds)->delete(); + User::whereIn('id', $this->createdUserIds)->delete(); + } + + parent::tearDown(); + } + + /** + * Set the inactivity deletion threshold (in days). + */ + private function setDeletionDays(int $days): void + { + resolve(SettingsService::class)->set('inactive_player_deletion_days', $days); + } + + /** + * Set the last-activity timestamp of a user (stored as a UNIX timestamp string). + */ + private function setLastActivityDaysAgo(int $userId, int $daysAgo): void + { + $user = User::findOrFail($userId); + $user->time = (string)Date::now()->subDays($daysAgo)->timestamp; + $user->save(); + } + + /** + * Creates a factory user and tracks it for cleanup in tearDown. + * + * @param array $attributes + */ + private function createTrackedUser(array $attributes = []): User + { + $user = User::factory()->create($attributes); + $this->createdUserIds[] = $user->id; + + return $user; + } + + /** + * When the setting is 0 the feature is disabled and no player is deleted. + */ + public function testCommandIsDisabledWhenDaysIsZero(): void + { + $this->setDeletionDays(0); + $this->setLastActivityDaysAgo($this->currentUserId, 500); + + // @phpstan-ignore-next-line + $this->artisan(self::COMMAND)->assertSuccessful(); + + $this->assertDatabaseHas('users', ['id' => $this->currentUserId]); + } + + /** + * A player inactive beyond the threshold is deleted, while a recently active player is kept. + */ + public function testDeletesInactivePlayerButKeepsActivePlayer(): void + { + $this->setDeletionDays(35); + + // Current user (with planets) is inactive. + $this->setLastActivityDaysAgo($this->currentUserId, 40); + + // A second player that is still active must be preserved. + $activeUser = $this->createTrackedUser(); + $activeUser->time = (string)Date::now()->timestamp; + $activeUser->save(); + + // @phpstan-ignore-next-line + $this->artisan(self::COMMAND)->assertSuccessful(); + + $this->assertDatabaseMissing('users', ['id' => $this->currentUserId]); + $this->assertDatabaseMissing('planets', ['user_id' => $this->currentUserId]); + $this->assertDatabaseHas('users', ['id' => $activeUser->id]); + } + + /** + * Vacation mode does NOT exempt a player from deletion (issue #922 constraint). + */ + public function testVacationModeDoesNotExemptPlayer(): void + { + $this->setDeletionDays(35); + + $user = User::findOrFail($this->currentUserId); + $user->vacation_mode = true; + $user->vacation_mode_activated_at = Date::now(); + $user->save(); + $this->setLastActivityDaysAgo($this->currentUserId, 40); + + // @phpstan-ignore-next-line + $this->artisan(self::COMMAND)->assertSuccessful(); + + $this->assertDatabaseMissing('users', ['id' => $this->currentUserId]); + } + + /** + * Admin players are excluded from automatic deletion (mirrors "administrators cannot be banned"). + */ + public function testExcludesAdminPlayers(): void + { + $this->setDeletionDays(35); + + $user = User::findOrFail($this->currentUserId); + $user->assignRole('admin'); + $this->setLastActivityDaysAgo($this->currentUserId, 40); + + // @phpstan-ignore-next-line + $this->artisan(self::COMMAND)->assertSuccessful(); + + $this->assertDatabaseHas('users', ['id' => $this->currentUserId]); + + // Clean up the role assignment so the account is not left as an admin. + $user->removeRole('admin'); + } + + /** + * Full-wipe integrity: planets, moons and queues are removed and the player's own fleet + * missions are deleted, while battle/espionage reports are preserved (planet_user_id nulled). + */ + public function testDeletionAbandonsPlanetsAndPreservesReports(): void + { + $this->setDeletionDays(35); + + $userId = $this->currentUserId; + $mainPlanet = $this->planetService; + $mainPlanetId = $mainPlanet->getPlanetId(); + $coords = $mainPlanet->getPlanetCoordinates(); + + // Give the main planet a moon so moon removal is exercised. + $planetServiceFactory = resolve(PlanetServiceFactory::class); + $moon = $planetServiceFactory->createMoonForPlanet($mainPlanet, 2000000, 20); + $moonId = $moon->getPlanetId(); + + // Create a building queue entry on the main planet. + $this->planetAddResources(new Resources(10000, 10000, 0, 0)); + $this->addResourceBuildRequest('metal_mine'); + $this->assertDatabaseHas('building_queues', ['planet_id' => $mainPlanetId]); + + // The player's own outgoing fleet mission must be deleted. + $ownMissionId = DB::table('fleet_missions')->insertGetId([ + 'user_id' => $userId, + 'planet_id_from' => $mainPlanetId, + 'mission_type' => 3, + 'time_departure' => Date::now()->timestamp, + 'time_arrival' => Date::now()->addHour()->timestamp, + 'created_at' => Date::now(), + 'updated_at' => Date::now(), + ]); + + // An espionage report about the player must survive (FK is ON DELETE SET NULL). + $reportId = DB::table('espionage_reports')->insertGetId([ + 'planet_galaxy' => $coords->galaxy, + 'planet_system' => $coords->system, + 'planet_position' => $coords->position, + 'planet_type' => PlanetType::Planet->value, + 'planet_user_id' => $userId, + 'player_info' => json_encode(['username' => 'Test']), + 'resources' => json_encode(['metal' => 0, 'crystal' => 0, 'deuterium' => 0]), + 'created_at' => Date::now(), + 'updated_at' => Date::now(), + ]); + + $this->setLastActivityDaysAgo($userId, 40); + + // @phpstan-ignore-next-line + $this->artisan(self::COMMAND)->assertSuccessful(); + + // Account and all planet data are gone. + $this->assertDatabaseMissing('users', ['id' => $userId]); + $this->assertDatabaseMissing('planets', ['id' => $mainPlanetId]); + $this->assertDatabaseMissing('planets', ['id' => $moonId]); + $this->assertDatabaseMissing('planets', ['user_id' => $userId]); + $this->assertDatabaseMissing('building_queues', ['planet_id' => $mainPlanetId]); + + // The player's own fleet mission is deleted. + $this->assertDatabaseMissing('fleet_missions', ['id' => $ownMissionId]); + + // The espionage report is preserved but its planet_user_id is nulled by the SET NULL FK. + $this->assertDatabaseHas('espionage_reports', ['id' => $reportId, 'planet_user_id' => null]); + } +}