cjmellor / level-up

Level-Up is a Laravel package introducing gamification into your applications. Users earn experience points (XP) and levels through interactions, while also unlocking achievements. It promotes engagement, competition, and fun through its dynamic leaderboard feature. Customisable to fit your specific needs

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Bug]: $user->nextLevelAt(); returning 0

seivad opened this issue · comments

What happened?

Hey mate when I try to utilise $user->nextLevelAt(); it only ever returns 0 and when I add points to the user in question, it increments the level regardless of what the next_level_experience column has stored.

How to reproduce the bug

Seed the levels, add points to a user account and use the getLevel(), nextLevelAt() and getPoints() helper functions.

Package Version

^0.0.6

PHP Version

8.1

Laravel Version

10

Which operating systems does with happen with?

macOS

Notes

I think you just change the nextLevelAt() function in the trait to be:

$nextLevel = Level::firstWhere(column: 'level', operator: '=', value: is_null($checkAgainst) ? $this->getLevel() + 1 : $checkAgainst);

and it will work again. The check using ?? that is originally there passes null to the level where check and is breaking because it's checking for a null "level" column instead of a null "next_level_experience" column as intended.

Hey @seivad

I'll look into it.

@seivad Can you look at #26 and test it and see if your issue resolves?

I wasn't able to replicate the bug though -- so maybe I misunderstood? Here's what I did:

// Levels are set to
// 1 => null
// 2 => 100
// 3 => 250

$user = User::find(1);

$user->addPoints(10);
$user->nextLevelAt(); // 90

$user->addPoints(90);
$user->nextLevelAt(); // 150

$user->addPoints(150);
$user->nextLevelAt(); // 0

As there is only 3 levels, when you've reached it, there is no new level to move to, hence 0

I just copied your trait into a local version to get by and have it working on my end, this is my version that is working well on my platform:

<?php

namespace App\Traits;

use Exception;
use Illuminate\Support\Collection;
use LevelUp\Experience\Models\Level;
use LevelUp\Experience\Enums\AuditType;
use LevelUp\Experience\Models\Experience;
use LevelUp\Experience\Events\UserLevelledUp;
use LevelUp\Experience\Events\PointsDecreased;
use LevelUp\Experience\Events\PointsIncreased;
use LevelUp\Experience\Models\ExperienceAudit;
use Illuminate\Database\Eloquent\Relations\HasOne;
use LevelUp\Experience\Services\MultiplierService;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait GiveExperience
{
    protected ?Collection $multiplierData = null;

    /**
     * @param int $amount
     * @param int $multiplier
     * @param nullstring $type
     * @param nullstring $reason
     * @return mixed
     */
    public function addPoints(
        int $amount,
        int $multiplier = null,
        string $type = null,
        string $reason = null
    ): Experience {
        if ($type === null) {
            $type = AuditType::Add->value;
        }

        /**
         * If the Multiplier Service is enabled, apply the Multipliers.
         */
        if (config(key: 'level-up.multiplier.enabled') && file_exists(filename: config(key: 'level-up.multiplier.path'))) {
            $amount = $this->getMultipliers(amount: $amount);
        }

        if ($multiplier) {
            $amount *= $multiplier;
        }

        /**
         * If the User does not have an Experience record, create one.
         */
        if ($this->experience()->doesntExist()) {
            $experience = $this->experience()->create(attributes: [
                'level_id' => (int) config(key: 'level-up.starting_level'),
                'experience_points' => $amount,
            ]);

            $this->fill([
                'level_id' => $experience->level_id,
            ])->save();

            $this->dispatchEvent($amount, $type, $reason, $experience->experience_points);

            return $experience;
        }

        /**
         * If the User does have an Experience record, update it.
         */
        if (config(key: 'level-up.level_cap.enabled') && $this->getLevel() >= config(key: 'level-up.level_cap.level') && !(config(key: 'level-up.level_cap.points_continue'))) {
            return $this->experience;
        }

        $this->experience->increment(column: 'experience_points', amount: $amount);

        $this->dispatchEvent($amount, $type, $reason);

        return $this->experience;
    }

    /**
     * @param int $amount
     * @return mixed
     */
    protected function getMultipliers(int $amount): int
    {
        $multiplierService = app(MultiplierService::class, [
            'data' => $this->multiplierData ? $this->multiplierData->toArray() : [],
        ]);

        return $multiplierService(points: $amount);
    }

    /**
     * @return mixed
     */
    public function experience(): HasOne
    {
        return $this->hasOne(related: Experience::class);
    }

    /**
     * @param int $amount
     * @param string $type
     * @param string $reason
     */
    protected function dispatchEvent(int $amount, string $type, ?string $reason, $total = null): void
    {
        event(new PointsIncreased(
            pointsAdded: $amount,
            totalPoints: !empty($total) ? $total : $this->experience->experience_points,
            type: $type,
            reason: $reason,
            user: $this,
        ));
    }

    /**
     * @return mixed
     */
    public function getLevel(): int
    {
        return !empty($this->experience->status->level) ? $this->experience->status->level : 0;
    }

    /**
     * @param int $amount
     * @return mixed
     */
    public function deductPoints(int $amount): Experience
    {
        $this->experience->decrement(column: 'experience_points', amount: $amount);

        event(new PointsDecreased(pointsDecreasedBy: $amount, totalPoints: $this->experience->experience_points));

        return $this->experience;
    }

    /**
     * @throws \Exception
     */
    public function setPoints(int $amount): Experience
    {
        if (!$this->experience()->exists()) {
            throw new Exception(message: 'User has no experience record.');
        }

        $this->experience->update(attributes: [
            'experience_points' => $amount,
        ]);

        return $this->experience;
    }

    /**
     * @param array $data
     * @return mixed
     */
    public function withMultiplierData(array $data): static
    {
        $this->multiplierData = collect($data);

        return $this;
    }

    /**
     * @param int $checkAgainst
     * @param nullbool $showAsPercentage
     * @return int
     */
    public function nextLevelAt(int $checkAgainst = null, bool $showAsPercentage = false): int
    {
        $nextLevel = Level::firstWhere(column: 'level', operator: '=', value: is_null($checkAgainst) ? $this->getLevel() + 1 : $checkAgainst);

        if (!$nextLevel || $nextLevel->next_level_experience === null) {
            return 0;
        }

        $currentLevelExperience = Level::firstWhere(column: 'level', operator: '=', value: $this->getLevel())->next_level_experience;

        if ($showAsPercentage) {
            return (int) ((($this->getPoints() - $currentLevelExperience) / ($nextLevel->next_level_experience - $currentLevelExperience)) * 100);
        }

        return max(0, ($nextLevel->next_level_experience - $currentLevelExperience) - ($this->getPoints() - $currentLevelExperience));
    }

    /**
     * @return mixed
     */
    public function getPoints(): int
    {
        return !empty($this->experience->experience_points) ? $this->experience->experience_points : 0;
    }

    /**
     * @return null
     */
    public function levelUp(): void
    {
        if (config(key: 'level-up.level_cap.enabled') && $this->getLevel() >= config(key: 'level-up.level_cap.level')) {
            return;
        }

        $nextLevel = Level::firstWhere(column: 'level', operator: $this->getLevel() + 1);

        // $this->experience->status()->associate(model: $nextLevel);
        // $this->experience->save();

        if (config(key: 'level-up.audit.enabled')) {
            $this->experienceHistory()->create(attributes: [
                'user_id' => $this->id,
                'points' => $this->getPoints(),
                'levelled_up' => true,
                'level_to' => $nextLevel->level,
                'type' => AuditType::LevelUp->value,
            ]);
        }

        $this->update(attributes: [
            'level_id' => $nextLevel->id,
        ]);

        event(new UserLevelledUp(user: $this, level: $this->getLevel()));
    }

    /**
     * @return mixed
     */
    public function experienceHistory(): HasMany
    {
        return $this->hasMany(related: ExperienceAudit::class);
    }

    /**
     * @return mixed
     */
    public function level(): BelongsTo
    {
        return $this->belongsTo(related: Level::class);
    }
}

I've got 999 levels in the DB which I seeded like this for now:

public function run(): void
    {
        $levels = [];
        for ($i = 1; $i < 1000; ++$i) {
            if ($i == 1) {
                $levels[] = [
                    'level' => $i,
                    'next_level_experience' => null,
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
            } else if ($i <= 3) {
                $levels[] = [
                    'level' => $i,
                    'next_level_experience' => ($i * 100),
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
            } else {
                $levels[] = [
                    'level' => $i,
                    'next_level_experience' => $levels[$i - 2]['next_level_experience'] + ($i * rand(1, 10)),
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
            }
        }

        DB::beginTransaction();
        Level::insert($levels);
        DB::commit();
    }

Did you check #26?

What's different about your version of the Trait? Anything worth adding to the PR?

Hey sorry mate, I was just rushing to get it in and I wrapped up coding around 12:30am and back at it today. The main thing is the 2 commented out lines in Level Up

        // $this->experience->save();```
        
        the trait doesn't seem to have access to $this->experience during the first create. I found basically all these issues occur when the user doesn't have any XP assigned yet, no row, but once the row has been generated once, the dispatched events work well. 

This line also fixes the issue of returning 0 in nextLevelAt:

$nextLevel = Level::firstWhere(column: 'level', operator: '=', value: is_null($checkAgainst) ? $this->getLevel() + 1 : $checkAgainst);


It's a cool package, just needed those few fine tunings to get it sorted / rolled out. If anything, I'd probably also suggest swapping the variables in nextLevelAt() function around, so you pass true first and let it check against the next level by default, right now I have to do nextLevelAt(null, true) but I think it might be better as nextLevelAt(true) and then if you need to check it against something else, nextLevelAt(true, xyz);
        

Well when you're free, check the PR and tell me if solves any of your issues you get and perhaps you could contribute your other suggestions to it? Sounds like you've spotted some things I have overlooked or not even considered.

Except the suggestion of switching the variables around... that's a breaking change so will just have to leave it as is. Seems more of a personal preference.