This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Achievement;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use App\Models\UserAchievement;
use App\Notifications\AchievementUnlockedNotification;
use App\Services\Posts\PostAchievementService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class AchievementService
{
public function __construct(
private readonly XPService $xp,
private readonly PostAchievementService $achievementPosts,
) {}
public function checkAchievements(User|int $user): array
{
$currentUser = $this->resolveUser($user);
$unlocked = [];
foreach ($this->unlockableDefinitions($currentUser) as $achievement) {
if ($this->unlockAchievement($currentUser, $achievement)) {
$unlocked[] = $achievement->slug;
}
}
$this->forgetSummaryCache((int) $currentUser->id);
return $unlocked;
}
public function previewUnlocks(User|int $user): array
{
$currentUser = $this->resolveUser($user);
return $this->unlockableDefinitions($currentUser)
->pluck('slug')
->values()
->all();
}
public function unlockAchievement(User|int $user, Achievement|int $achievement): bool
{
$currentUser = $user instanceof User ? $user : User::query()->findOrFail($user);
$currentAchievement = $achievement instanceof Achievement
? $achievement
: Achievement::query()->findOrFail($achievement);
$inserted = false;
DB::transaction(function () use ($currentUser, $currentAchievement, &$inserted): void {
$result = UserAchievement::query()->insertOrIgnore([
'user_id' => (int) $currentUser->id,
'achievement_id' => (int) $currentAchievement->id,
'unlocked_at' => now(),
]);
if ($result === 0) {
return;
}
$inserted = true;
});
if (! $inserted) {
return false;
}
if ((int) $currentAchievement->xp_reward > 0) {
$this->xp->addXP(
(int) $currentUser->id,
(int) $currentAchievement->xp_reward,
'achievement_unlocked:' . $currentAchievement->slug,
(int) $currentAchievement->id,
false,
);
}
$currentUser->notify(new AchievementUnlockedNotification($currentAchievement));
$this->achievementPosts->achievementUnlocked($currentUser, $currentAchievement);
$this->forgetSummaryCache((int) $currentUser->id);
return true;
}
public function hasAchievement(User|int $user, string $achievementSlug): bool
{
$userId = $user instanceof User ? (int) $user->id : $user;
return UserAchievement::query()
->where('user_id', $userId)
->whereHas('achievement', fn ($query) => $query->where('slug', $achievementSlug))
->exists();
}
public function summary(User|int $user): array
{
$userId = $user instanceof User ? (int) $user->id : $user;
return Cache::remember($this->summaryCacheKey($userId), now()->addMinutes(10), function () use ($userId): array {
$currentUser = User::query()->with('statistics')->findOrFail($userId);
$progress = $this->progressSnapshot($currentUser);
$unlockedMap = UserAchievement::query()
->where('user_id', $userId)
->get()
->keyBy('achievement_id');
$items = $this->definitions()->map(function (Achievement $achievement) use ($progress, $unlockedMap): array {
$progressValue = $this->progressValue($progress, $achievement);
/** @var UserAchievement|null $unlocked */
$unlocked = $unlockedMap->get($achievement->id);
return [
'id' => (int) $achievement->id,
'name' => $achievement->name,
'slug' => $achievement->slug,
'description' => $achievement->description,
'icon' => $achievement->icon,
'xp_reward' => (int) $achievement->xp_reward,
'type' => $achievement->type,
'condition_type' => $achievement->condition_type,
'condition_value' => (int) $achievement->condition_value,
'progress' => min((int) $achievement->condition_value, $progressValue),
'progress_percent' => $achievement->condition_value > 0
? (int) round((min((int) $achievement->condition_value, $progressValue) / (int) $achievement->condition_value) * 100)
: 100,
'unlocked' => $unlocked !== null,
'unlocked_at' => $unlocked?->unlocked_at?->toIso8601String(),
];
});
return [
'unlocked' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->values()->all(),
'locked' => $items->where('unlocked', false)->values()->all(),
'recent' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->take(4)->values()->all(),
'counts' => [
'total' => $items->count(),
'unlocked' => $items->where('unlocked', true)->count(),
'locked' => $items->where('unlocked', false)->count(),
],
];
});
}
public function definitions()
{
return Cache::remember('achievements:definitions', now()->addHour(), function () {
return Achievement::query()->orderBy('type')->orderBy('condition_value')->get();
});
}
public function forgetDefinitionsCache(): void
{
Cache::forget('achievements:definitions');
}
private function progressValue(array $progress, Achievement $achievement): int
{
return (int) ($progress[$achievement->condition_type] ?? 0);
}
private function resolveUser(User|int $user): User
{
return $user instanceof User
? $user->loadMissing('statistics')
: User::query()->with('statistics')->findOrFail($user);
}
private function unlockableDefinitions(User $user): Collection
{
$progress = $this->progressSnapshot($user);
$unlockedSlugs = $this->unlockedSlugs((int) $user->id);
return $this->definitions()->filter(function (Achievement $achievement) use ($progress, $unlockedSlugs): bool {
if ($this->progressValue($progress, $achievement) < (int) $achievement->condition_value) {
return false;
}
return ! isset($unlockedSlugs[$achievement->slug]);
})->values();
}
private function progressSnapshot(User $user): array
{
return [
'upload_count' => Artwork::query()
->published()
->where('user_id', $user->id)
->count(),
'likes_received' => (int) DB::table('artwork_likes as likes')
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
->where('artworks.user_id', $user->id)
->count(),
'followers_count' => (int) ($user->statistics?->followers_count ?? $user->followers()->count()),
'stories_published' => Story::query()->published()->where('creator_id', $user->id)->count(),
'level_reached' => (int) ($user->level ?? 1),
];
}
private function unlockedSlugs(int $userId): array
{
return UserAchievement::query()
->where('user_id', $userId)
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
->pluck('achievements.slug')
->flip()
->all();
}
private function forgetSummaryCache(int $userId): void
{
Cache::forget($this->summaryCacheKey($userId));
}
private function summaryCacheKey(int $userId): string
{
return 'achievements:summary:' . $userId;
}
}