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('Dark Matter regeneration settings.')