236 lines
8.4 KiB
PHP
236 lines
8.4 KiB
PHP
<?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\Activity\UserActivityService;
|
|
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);
|
|
|
|
try {
|
|
app(UserActivityService::class)->logAchievement((int) $currentUser->id, (int) $currentAchievement->id, [
|
|
'name' => (string) $currentAchievement->name,
|
|
]);
|
|
} catch (\Throwable) {}
|
|
|
|
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;
|
|
}
|
|
}
|