diff --git a/src/Concerns/GiveExperience.php b/src/Concerns/GiveExperience.php index 7d38e99..994ac74 100644 --- a/src/Concerns/GiveExperience.php +++ b/src/Concerns/GiveExperience.php @@ -38,7 +38,9 @@ public function addPoints( [$amount, $appliedMultipliers] = $this->resolveMultipliers($amount, $multiplier); - if ($this->experience()->doesntExist()) { + $experience = $this->loadedExperience(); + + if (! $experience) { $startingLevel = config(key: 'level-up.starting_level'); $level = $levelClass::query() @@ -54,14 +56,14 @@ public function addPoints( ); } - $this->experience()->create(attributes: [ + $experience = $this->experienceRelation()->create(attributes: [ 'level_id' => $level->id, 'experience_points' => $amount, ]); - $this->load('experience'); + $this->setRelation('experience', $experience); - $this->dispatchEvent($amount, $type, $reason, $appliedMultipliers, $multiplier); + $this->dispatchEvent($experience, $amount, $type, $reason, $appliedMultipliers, $multiplier); if ($level->level > $startingLevel) { for ($lvl = $startingLevel; $lvl <= $level->level; $lvl++) { @@ -69,67 +71,71 @@ public function addPoints( } } - return $this->experience; + return $experience; } if ($this->levelCapExceedsUserLevel()) { - return $this->experience; + return $experience; } - $this->experience->increment(column: 'experience_points', amount: $amount); + $experience->increment(column: 'experience_points', amount: $amount); - $this->dispatchEvent($amount, $type, $reason, $appliedMultipliers, $multiplier); + $this->dispatchEvent($experience, $amount, $type, $reason, $appliedMultipliers, $multiplier); - return $this->experience; + return $experience; } public function experience(): HasOne { - return $this->hasOne(related: config('level-up.models.experience')); + return $this->experienceRelation(); } public function getLevel(): int { - return $this->experience?->status?->level ?? 0; + return $this->loadedExperience()?->status?->level ?? 0; } public function experienceHistory(): HasMany { - return $this->hasMany(related: config('level-up.models.experience_audit')); + return $this->experienceHistoryRelation(); } public function deductPoints(int $amount, ?string $reason = null): Experience { - throw_unless($this->experience()->exists(), Exception::class, 'User has no experience record.'); + $experience = $this->loadedExperience(); - $this->experience->decrement(column: 'experience_points', amount: $amount); + throw_unless($experience, Exception::class, 'User has no experience record.'); + + $experience->decrement(column: 'experience_points', amount: $amount); event(new PointsDecreased( pointsDecreasedBy: $amount, - totalPoints: $this->experience->experience_points, + totalPoints: $experience->experience_points, reason: $reason, user: $this, )); - return $this->experience; + return $experience; } public function setPoints(int $amount): Experience { - throw_unless($this->experience()->exists(), Exception::class, message: 'User has no experience record.'); + $experience = $this->loadedExperience(); + + throw_unless($experience, Exception::class, message: 'User has no experience record.'); - $this->experience->update(attributes: [ + $experience->update(attributes: [ 'experience_points' => $amount, ]); - return $this->experience; + return $experience; } public function nextLevelAt(?int $checkAgainst = null, bool $showAsPercentage = false): int { $levelClass = config(key: 'level-up.models.level'); - $nextLevel = $levelClass::firstWhere(column: 'level', operator: '=', value: is_null($checkAgainst) ? $this->getLevel() + 1 : $checkAgainst); + $nextLevel = $levelClass::firstWhere(column: 'level', operator: '=', value: $checkAgainst ?? $this->getLevel() + 1); if ($this->levelCapExceedsUserLevel()) { return 0; @@ -159,7 +165,7 @@ public function nextLevelAt(?int $checkAgainst = null, bool $showAsPercentage = public function getPoints(): int { - return $this->experience?->experience_points ?? 0; + return $this->loadedExperience()?->experience_points ?? 0; } public function levelUp(int $to): void @@ -173,10 +179,14 @@ public function levelUp(int $to): void throw_unless($level, InvalidArgumentException::class, "Level {$to} does not exist."); + $experience = $this->loadedExperience(); + + throw_unless($experience, Exception::class, message: 'User has no experience record.'); + $previousLevel = $this->getLevel(); - $this->experience->status()->associate(model: $level); - $this->experience->save(); + $experience->status()->associate(model: $level); + $experience->save(); for ($lvl = $previousLevel + 1; $lvl <= $to; $lvl++) { event(new UserLevelledUp(user: $this, level: $lvl)); @@ -237,13 +247,13 @@ protected function applyStackingStrategy(int $amount, Collection $multiplierValu }); } - protected function dispatchEvent(int $amount, string $type, ?string $reason, ?Collection $appliedMultipliers = null, int|float|null $inlineMultiplier = null): void + protected function dispatchEvent(Experience $experience, int $amount, string $type, ?string $reason, ?Collection $appliedMultipliers = null, int|float|null $inlineMultiplier = null): void { $auditData = $this->buildMultiplierAuditData($appliedMultipliers, $inlineMultiplier); event(new PointsIncreased( pointsAdded: $amount, - totalPoints: $this->experience->experience_points, + totalPoints: $experience->experience_points, type: $type, reason: $reason, user: $this, @@ -275,4 +285,29 @@ protected function levelCapExceedsUserLevel(): bool && $this->getLevel() >= config(key: 'level-up.level_cap.level') && ! config(key: 'level-up.level_cap.points_continue'); } + + private function experienceRelation(): HasOne + { + return $this->hasOne(related: config('level-up.models.experience')); + } + + private function experienceHistoryRelation(): HasMany + { + return $this->hasMany(related: config('level-up.models.experience_audit')); + } + + private function loadedExperience(): ?Experience + { + if ($this->relationLoaded('experience')) { + return $this->getRelation('experience'); + } + + $experience = $this->experienceRelation()->first(); + + if ($experience !== null) { + $this->setRelation('experience', $experience); + } + + return $experience; + } } diff --git a/src/Concerns/HasChallenges.php b/src/Concerns/HasChallenges.php index c77b627..82b55a7 100644 --- a/src/Concerns/HasChallenges.php +++ b/src/Concerns/HasChallenges.php @@ -16,10 +16,7 @@ trait HasChallenges { public function challenges(): BelongsToMany { - return $this->belongsToMany(related: config(key: 'level-up.models.challenge'), table: 'challenge_user') - ->using(config(key: 'level-up.models.challenge_user')) - ->withPivot(columns: ['progress', 'completed_at']) - ->withTimestamps(); + return $this->challengesRelation(); } /** @@ -39,10 +36,10 @@ public function enrollInChallenge(Challenge $challenge): void message: 'This challenge has expired.', ); - $existingPivot = $this->challenges()->where('challenge_id', $challenge->id)->first()?->pivot; + $existingPivot = $this->challengesRelation()->where('challenge_id', $challenge->id)->first()?->pivot; if ($existingPivot && $existingPivot->completed_at !== null && $challenge->is_repeatable) { - $this->challenges()->updateExistingPivot($challenge->id, [ + $this->challengesRelation()->updateExistingPivot($challenge->id, [ 'progress' => $this->freshChallengeProgress($challenge), 'completed_at' => null, ]); @@ -61,7 +58,7 @@ public function enrollInChallenge(Challenge $challenge): void ); try { - $this->challenges()->attach($challenge->id, [ + $this->challengesRelation()->attach($challenge->id, [ 'progress' => $this->freshChallengeProgress($challenge), ]); } catch (UniqueConstraintViolationException) { @@ -76,7 +73,7 @@ public function enrollInChallenge(Challenge $challenge): void */ public function unenrollFromChallenge(Challenge $challenge): void { - $pivot = $this->challenges()->where('challenge_id', $challenge->id)->first()?->pivot; + $pivot = $this->challengesRelation()->where('challenge_id', $challenge->id)->first()?->pivot; throw_if( condition: ! $pivot, @@ -90,26 +87,26 @@ public function unenrollFromChallenge(Challenge $challenge): void message: 'Cannot unenroll from a completed challenge.', ); - $this->challenges()->detach($challenge->id); + $this->challengesRelation()->detach($challenge->id); event(new ChallengeUnenrolled(challenge: $challenge, user: $this)); } public function activeChallenges(): BelongsToMany { - return $this->challenges() + return $this->challengesRelation() ->whereNull(columns: 'challenge_user.completed_at'); } public function completedChallenges(): BelongsToMany { - return $this->challenges() + return $this->challengesRelation() ->whereNotNull(columns: 'challenge_user.completed_at'); } public function getChallengeProgress(Challenge $challenge): ?array { - $pivot = $this->challenges()->where('challenge_id', $challenge->id)->first()?->pivot; + $pivot = $this->challengesRelation()->where('challenge_id', $challenge->id)->first()?->pivot; return $pivot?->progress; } @@ -127,6 +124,14 @@ public function getChallengeCompletionPercentage(Challenge $challenge): ?float return round(num: ($completed / count($progress)) * 100, precision: 1); } + private function challengesRelation(): BelongsToMany + { + return $this->belongsToMany(related: config(key: 'level-up.models.challenge'), table: 'challenge_user') + ->using(config(key: 'level-up.models.challenge_user')) + ->withPivot(columns: ['progress', 'completed_at']) + ->withTimestamps(); + } + private function freshChallengeProgress(Challenge $challenge): array { return resolve(ChallengeService::class)->initializeProgress( diff --git a/src/Concerns/HasStreaks.php b/src/Concerns/HasStreaks.php index f9d58d1..1688522 100644 --- a/src/Concerns/HasStreaks.php +++ b/src/Concerns/HasStreaks.php @@ -39,12 +39,12 @@ public function recordStreak(Activity $activity): void if ($diffInDays > 1) { $this->resetStreak($activity); - event(new StreakBroken($this, $activity, $this->streaks()->whereBelongsTo(related: $activity)->first())); + event(new StreakBroken($this, $activity, $this->streaksRelation()->whereBelongsTo(related: $activity)->first())); return; } - $streak = $this->streaks()->whereBelongsTo(related: $activity); + $streak = $this->streaksRelation()->whereBelongsTo(related: $activity); $streak->increment(column: 'count'); $streak->update(values: ['activity_at' => now()]); @@ -53,7 +53,7 @@ public function recordStreak(Activity $activity): void public function streaks(): HasMany { - return $this->hasMany(related: config('level-up.models.streak')); + return $this->streaksRelation(); } public function resetStreak(Activity $activity): void @@ -62,7 +62,7 @@ public function resetStreak(Activity $activity): void $this->archiveStreak($activity); } - $this->streaks() + $this->streaksRelation() ->whereBelongsTo(related: $activity) ->update([ 'count' => 1, @@ -72,7 +72,7 @@ public function resetStreak(Activity $activity): void public function getCurrentStreakCount(Activity $activity): int { - return $this->streaks()->whereBelongsTo(related: $activity)->first()?->count ?? 0; + return $this->streaksRelation()->whereBelongsTo(related: $activity)->first()?->count ?? 0; } public function hasStreakToday(Activity $activity): bool @@ -119,7 +119,7 @@ public function isStreakFrozen(Activity $activity): bool protected function hasStreakForActivity(Activity $activity): bool { - return $this->streaks() + return $this->streaksRelation() ->whereBelongsTo(related: $activity) ->exists(); } @@ -140,7 +140,7 @@ protected function startNewStreak(Activity $activity): Model|Streak protected function getStreakLastActivity(Activity $activity): ?Streak { - return $this->streaks() + return $this->streaksRelation() ->whereBelongsTo(related: $activity) ->latest(column: 'activity_at') ->first(); @@ -183,4 +183,9 @@ protected function archiveStreak(Activity $activity): void 'ended_at' => $latestStreak->activity_at, ]); } + + private function streaksRelation(): HasMany + { + return $this->hasMany(related: config('level-up.models.streak')); + } } diff --git a/tests/Concerns/TraitAliasingTest.php b/tests/Concerns/TraitAliasingTest.php new file mode 100644 index 0000000..66d719f --- /dev/null +++ b/tests/Concerns/TraitAliasingTest.php @@ -0,0 +1,95 @@ +group('aliasing') + ->beforeEach(function (): void { + config()->set(key: 'level-up.multiplier.enabled', value: false); + config()->set(key: 'level-up.tiers.enabled', value: false); + config()->set(key: 'level-up.user.model', value: AliasingUser::class); + + $this->aliasUser = AliasingUser::query()->create([ + 'name' => 'Alias McUser', + 'email' => 'alias@example.test', + 'password' => bcrypt('password'), + 'email_verified_at' => now(), + ]); + }); + +test('host-defined challenges() does not interfere with trait flows', function (): void { + $challenge = Challenge::factory()->create([ + 'conditions' => [['type' => 'points_earned', 'amount' => 50]], + 'rewards' => [['type' => 'points', 'amount' => 10]], + ]); + + $this->aliasUser->enrollInChallenge(challenge: $challenge); + + $this->assertDatabaseHas('challenge_user', [ + 'user_id' => $this->aliasUser->id, + 'challenge_id' => $challenge->id, + ]); + + expect($this->aliasUser->packageChallenges()->count())->toBe(1); + expect($this->aliasUser->activeChallenges()->count())->toBe(1); + expect($this->aliasUser->completedChallenges()->count())->toBe(0); + expect($this->aliasUser->getChallengeProgress($challenge))->toBeArray(); + + $this->aliasUser->unenrollFromChallenge(challenge: $challenge); + + $this->assertDatabaseMissing('challenge_user', [ + 'user_id' => $this->aliasUser->id, + 'challenge_id' => $challenge->id, + ]); +}); + +test('host-defined streaks() does not interfere with trait flows', function (): void { + $activity = Activity::query()->create([ + 'name' => 'aliased-activity', + 'description' => 'streak-aliasing-test', + ]); + + $this->aliasUser->recordStreak(activity: $activity); + + expect($this->aliasUser->getCurrentStreakCount(activity: $activity))->toBe(1); + expect($this->aliasUser->hasStreakToday(activity: $activity))->toBeTrue(); + expect($this->aliasUser->packageStreaks()->count())->toBe(1); + + $this->aliasUser->freezeStreak(activity: $activity, days: 2); + + expect($this->aliasUser->isStreakFrozen(activity: $activity))->toBeTrue(); + + $this->aliasUser->unFreezeStreak(activity: $activity); + + expect($this->aliasUser->isStreakFrozen(activity: $activity))->toBeFalse(); +}); + +test('host-defined experience() does not interfere with trait flows', function (): void { + $experience = $this->aliasUser->addPoints(amount: 30); + + expect($experience->experience_points)->toBe(30); + expect($this->aliasUser->getPoints())->toBe(30); + expect($this->aliasUser->getLevel())->toBe(1); + + $this->aliasUser->addPoints(amount: 80); + + expect($this->aliasUser->getPoints())->toBe(110); + + $this->aliasUser->deductPoints(amount: 10); + + expect($this->aliasUser->getPoints())->toBe(100); + + $this->aliasUser->setPoints(amount: 250); + + expect($this->aliasUser->getPoints())->toBe(250); + + $this->aliasUser->levelUp(to: 2); + + expect($this->aliasUser->getLevel())->toBe(2); + + expect($this->aliasUser->packageExperience()->exists())->toBeTrue(); +}); diff --git a/tests/Fixtures/AliasingUser.php b/tests/Fixtures/AliasingUser.php new file mode 100644 index 0000000..886832f --- /dev/null +++ b/tests/Fixtures/AliasingUser.php @@ -0,0 +1,58 @@ +