Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/Console/Commands/Scheduler/DeleteInactivePlayers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace OGame\Console\Commands\Scheduler;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use OGame\Factories\PlayerServiceFactory;
use OGame\Models\User;
use OGame\Services\SettingsService;
use Throwable;

#[Description('Permanently deletes players who have been inactive for the configured number of days.')]
#[Signature('ogamex:scheduler:delete-inactive-players')]
class DeleteInactivePlayers extends Command
{
/**
* Execute the console command.
*/
public function handle(SettingsService $settings, PlayerServiceFactory $playerServiceFactory): int
{
$days = $settings->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')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is more for @lanedirt: Should moderators be excluded alongside admin? It looks like a non-issue today since that role doesn't grant any permissions yet, but if it ever becomes a real staff role those accounts are exactly the infrequent-login users this targets. Cheap to add now: withoutRole(['admin', 'moderator']). There is only a handful of mentions of the role in the codebase and it currently doesn't do anything.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Geda173 Yes I agree, since this is being touched already, adding the moderator role exclusion too here is a good idea (even if the role itself is not actually implemented yet as of this moment).

->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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The player and planet factories are shared singletons with no way to clear them, so everything built for each deleted account stays in memory for the whole run. The batching limits query size but not memory. Likely a non-issue for nightly runs; I'd mainly want to know it's been tried against a large backlog, since that first run could be thousands of accounts. Most of the deployments will have only small groups of people, but something main.ogamex.dev with its many inactive accounts could be seriously in trouble.

$deletedCount++;
} catch (Throwable $e) {
$failedCount++;
$this->error("Failed to delete inactive player #{$user->id}: " . $e->getMessage());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as CleanupWreckFields, so take this as optional, but since scheduled output isn't captured anywhere, an account that fails to delete every night would be completely invisible. This being the one job that deletes accounts irreversibly, a Log::error() alongside the console message seems worth it.

}
}
});

$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;
}
}
3 changes: 3 additions & 0 deletions app/Http/Controllers/Admin/ServerSettingsController.php

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this setting permanently deletes accounts with no undo, could we put a floor on it? Right now 1 is accepted and would wipe everyone inactive for a day on the next run and it's an easy slip. I'd be much more comfortable if there was an dialogue box prompting the user to confirm the setting.

Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}

Expand Down Expand Up @@ -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();

Expand Down
17 changes: 13 additions & 4 deletions app/Services/PlanetService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
50 changes: 50 additions & 0 deletions app/Services/PlayerService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overlaps delete() above it quite heavily. Five of the statements are identical. My worry is drift: whoever adds the next player-related table updates one method and not the other. Since delete() is only called from dev commands now, could this become the single implementation with a flag for how planets get handled? The transaction wrapper here is also something delete() lacks, which is another argument for consolidating on this version.

{
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The highscores table already deletes these rows automatically when the user goes, so this line does nothing.

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
*
Expand Down
11 changes: 11 additions & 0 deletions app/Services/SettingsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class () extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
// Seed the inactive player deletion threshold. 0 = feature disabled (default).
DB::table('settings')->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();
}
};
12 changes: 12 additions & 0 deletions resources/views/ingame/admin/serversettings.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@
</div>
</div>

<p class="box_highlight textCenter no_buddies">@lang('Player deletion settings.')</p>

<div class="group bborder" style="display: block;">
<div class="fieldwrapper">
<label class="styled textBeefy">@lang('Inactive days before player deletion:')</label>
<div class="thefield">
<input type="text" pattern="[0-9]*" class="textInput w50 textCenter textBeefy" value="{{ $inactive_player_deletion_days }}" size="6" name="inactive_player_deletion_days">
</div>
<div class="smallFont">@lang('Number of days of inactivity after which a player is permanently deleted and their planets are abandoned. Set to 0 to disable.')</div>
</div>
</div>

<p class="box_highlight textCenter no_buddies">@lang('Dark Matter regeneration settings.')</p>

<div class="group bborder" style="display: block;">
Expand Down
4 changes: 4 additions & 0 deletions routes/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Loading
Loading