293 lines
9.1 KiB
PHP
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;
|
|
}
|
|
}
|