argument('user_id'); $all = (bool) $this->option('all'); $dryRun = (bool) $this->option('dry-run'); $syncAchievements = (bool) $this->option('sync-achievements'); $chunk = max(1, (int) $this->option('chunk')); if ($userId !== null && $all) { $this->error('Provide either a user_id or --all, not both.'); return self::FAILURE; } if ($userId !== null) { return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements); } if ($all) { return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements); } $this->error('Provide a user_id or use --all.'); return self::FAILURE; } private function recalculateSingle( int $userId, XPService $xp, AchievementService $achievements, bool $dryRun, bool $syncAchievements, ): int { $exists = DB::table('users')->where('id', $userId)->exists(); if (! $exists) { $this->error("User {$userId} not found."); return self::FAILURE; } $label = $dryRun ? '[DRY-RUN]' : '[LIVE]'; $this->line("{$label} Recomputing XP for user #{$userId}..."); $result = $xp->recalculateStoredProgress($userId, ! $dryRun); $this->table( ['Field', 'Stored', 'Computed'], [ ['xp', $result['previous']['xp'], $result['computed']['xp']], ['level', $result['previous']['level'], $result['computed']['level']], ['rank', $result['previous']['rank'], $result['computed']['rank']], ] ); if ($dryRun) { if ($syncAchievements) { $pending = $achievements->previewUnlocks($userId); $this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending))); } $this->warn('Dry-run: no changes written.'); return self::SUCCESS; } if ($syncAchievements) { $unlocked = $achievements->checkAchievements($userId); $this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked))); } $this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync."); return self::SUCCESS; } private function recalculateAll( XPService $xp, AchievementService $achievements, int $chunk, bool $dryRun, bool $syncAchievements, ): int { $total = DB::table('users')->whereNull('deleted_at')->count(); $label = $dryRun ? '[DRY-RUN]' : '[LIVE]'; $this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})..."); $processed = 0; $changed = 0; $pendingAchievementUsers = 0; $pendingAchievementUnlocks = 0; $appliedAchievementUnlocks = 0; $bar = $this->output->createProgressBar($total); $bar->start(); DB::table('users') ->whereNull('deleted_at') ->orderBy('id') ->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void { foreach ($users as $user) { $result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun); if ($result['changed']) { $changed++; } if ($syncAchievements) { if ($dryRun) { $pending = $achievements->previewUnlocks((int) $user->id); if (! empty($pending)) { $pendingAchievementUsers++; $pendingAchievementUnlocks += count($pending); } } else { $unlocked = $achievements->checkAchievements((int) $user->id); $appliedAchievementUnlocks += count($unlocked); } } $processed++; $bar->advance(); } }); $bar->finish(); $this->newLine(); $summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.'); if ($syncAchievements) { if ($dryRun) { $summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s)."; } else { $summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied."; } } $this->info($summary); return self::SUCCESS; } }