Files
SkinbaseNova/app/Services/XPService.php
2026-03-20 21:17:26 +01:00

293 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\UserXpUpdated;
use App\Models\User;
use App\Models\UserXpLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class XPService
{
private const LEVEL_THRESHOLDS = [
1 => 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;
}
}