0, 2 => 100, 3 => 300, 4 => 800, 5 => 2000, 6 => 5000, 7 => 12000, ]; private const RANKS = [ 1 => 'Newbie', 2 => 'Explorer', 3 => 'Contributor', 4 => 'Creator', 5 => 'Pro Creator', 6 => 'Elite', 7 => 'Legend', ]; private const DAILY_CAPS = [ 'artwork_view_received' => 200, 'comment_created' => 100, 'story_published' => 200, 'artwork_published' => 250, 'follower_received' => 400, 'artwork_like_received' => 500, ]; public function addXP( User|int $user, int $amount, string $action, ?int $referenceId = null, bool $dispatchEvent = true, ): bool { if ($amount <= 0) { return false; } $userId = $user instanceof User ? (int) $user->id : $user; if ($userId <= 0) { return false; } $baseAction = $this->baseAction($action); $awardAmount = $this->applyDailyCap($userId, $amount, $baseAction); if ($awardAmount <= 0) { return false; } DB::transaction(function () use ($userId, $awardAmount, $action, $referenceId): void { /** @var User $lockedUser */ $lockedUser = User::query()->lockForUpdate()->findOrFail($userId); $nextXp = max(0, (int) $lockedUser->xp + $awardAmount); $level = $this->calculateLevel($nextXp); $rank = $this->getRank($level); $lockedUser->forceFill([ 'xp' => $nextXp, 'level' => $level, 'rank' => $rank, ])->save(); UserXpLog::query()->create([ 'user_id' => $userId, 'action' => $action, 'xp' => $awardAmount, 'reference_id' => $referenceId, 'created_at' => now(), ]); }); $this->forgetSummaryCache($userId); if ($dispatchEvent) { event(new UserXpUpdated($userId)); } return true; } public function awardArtworkPublished(int $userId, int $artworkId): bool { return $this->awardUnique($userId, 50, 'artwork_published', $artworkId); } public function awardArtworkLikeReceived(int $userId, int $artworkId, int $actorId): bool { return $this->awardUnique($userId, 5, 'artwork_like_received', $artworkId, $actorId); } public function awardFollowerReceived(int $userId, int $followerId): bool { return $this->awardUnique($userId, 20, 'follower_received', $followerId, $followerId); } public function awardStoryPublished(int $userId, int $storyId): bool { return $this->awardUnique($userId, 40, 'story_published', $storyId); } public function awardCommentCreated(int $userId, int $referenceId, string $scope = 'generic'): bool { return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId); } public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool { $viewerKey = $viewerId !== null && $viewerId > 0 ? 'user:' . $viewerId : 'guest:' . sha1((string) ($ipAddress ?: 'guest')); $expiresAt = now()->endOfDay(); $qualifierKey = sprintf('xp:view:qualifier:%d:%d:%s:%s', $userId, $artworkId, $viewerKey, now()->format('Ymd')); if (! Cache::add($qualifierKey, true, $expiresAt)) { return false; } $bucketKey = sprintf('xp:view:bucket:%d:%s', $userId, now()->format('Ymd')); Cache::add($bucketKey, 0, $expiresAt); $bucketCount = Cache::increment($bucketKey); if ($bucketCount % 10 !== 0) { return false; } return $this->addXP($userId, 1, 'artwork_view_received', $artworkId); } public function calculateLevel(int $xp): int { $resolvedLevel = 1; foreach (self::LEVEL_THRESHOLDS as $level => $threshold) { if ($xp >= $threshold) { $resolvedLevel = $level; } } return $resolvedLevel; } public function getRank(int $level): string { return self::RANKS[$level] ?? Arr::last(self::RANKS); } 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()->findOrFail($userId, ['id', 'xp', 'level', 'rank']); $currentLevel = max(1, (int) $currentUser->level); $currentXp = max(0, (int) $currentUser->xp); $currentThreshold = self::LEVEL_THRESHOLDS[$currentLevel] ?? 0; $nextLevel = min($currentLevel + 1, array_key_last(self::LEVEL_THRESHOLDS)); $nextLevelXp = self::LEVEL_THRESHOLDS[$nextLevel] ?? $currentXp; $range = max(1, $nextLevelXp - $currentThreshold); $progressWithinLevel = min($range, max(0, $currentXp - $currentThreshold)); $progressPercent = $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS) ? 100 : (int) round(($progressWithinLevel / $range) * 100); return [ 'xp' => $currentXp, 'level' => $currentLevel, 'rank' => (string) ($currentUser->rank ?: $this->getRank($currentLevel)), 'current_level_xp' => $currentThreshold, 'next_level_xp' => $nextLevelXp, 'progress_xp' => $progressWithinLevel, 'progress_percent' => $progressPercent, 'max_level' => $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS), ]; } ); } public function recalculateStoredProgress(User|int $user, bool $write = true): array { $userId = $user instanceof User ? (int) $user->id : $user; /** @var User $currentUser */ $currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']); $computedXp = (int) UserXpLog::query() ->where('user_id', $userId) ->sum('xp'); $computedLevel = $this->calculateLevel($computedXp); $computedRank = $this->getRank($computedLevel); $changed = (int) $currentUser->xp !== $computedXp || (int) $currentUser->level !== $computedLevel || (string) $currentUser->rank !== $computedRank; if ($write && $changed) { $currentUser->forceFill([ 'xp' => $computedXp, 'level' => $computedLevel, 'rank' => $computedRank, ])->save(); $this->forgetSummaryCache($userId); } return [ 'user_id' => $userId, 'changed' => $changed, 'previous' => [ 'xp' => (int) $currentUser->xp, 'level' => (int) $currentUser->level, 'rank' => (string) $currentUser->rank, ], 'computed' => [ 'xp' => $computedXp, 'level' => $computedLevel, 'rank' => $computedRank, ], ]; } private function awardUnique(int $userId, int $amount, string $action, int $referenceId, ?int $actorId = null): bool { $actionKey = $actorId !== null ? $action . ':' . $actorId : $action; $alreadyAwarded = UserXpLog::query() ->where('user_id', $userId) ->where('action', $actionKey) ->where('reference_id', $referenceId) ->exists(); if ($alreadyAwarded) { return false; } return $this->addXP($userId, $amount, $actionKey, $referenceId); } private function applyDailyCap(int $userId, int $amount, string $baseAction): int { $cap = self::DAILY_CAPS[$baseAction] ?? null; if ($cap === null) { return $amount; } $dayStart = Carbon::now()->startOfDay(); $awardedToday = (int) UserXpLog::query() ->where('user_id', $userId) ->where('action', 'like', $baseAction . '%') ->where('created_at', '>=', $dayStart) ->sum('xp'); return max(0, min($amount, $cap - $awardedToday)); } private function baseAction(string $action): string { return explode(':', $action, 2)[0]; } private function forgetSummaryCache(int $userId): void { Cache::forget($this->summaryCacheKey($userId)); } private function summaryCacheKey(int $userId): string { return 'xp:summary:' . $userId; } }