diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/level-up-main.iml b/.idea/level-up-main.iml new file mode 100644 index 0000000..1781a1d --- /dev/null +++ b/.idea/level-up-main.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3985a79 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..7d11e4f --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dc860d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,103 @@ +# Changelog + +All notable changes to `level-up` will be documented in this file. + +## v0.0.10 - 2023-08-23 + +### What's Changed + +- Refactor Tests by @cjmellor in https://github.com/cjmellor/level-up/pull/33 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.9...v0.0.10 + +## v0.0.9 - 2023-08-21 + +### What's Changed + +- Adds a feature to freeze a streak by @cjmellor in https://github.com/cjmellor/level-up/pull/32 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.8...v0.0.9 + +## v0.0.8 - 2023-08-20 + +### What's Changed + +- Possible `nextLevelAt()` Bug by @cjmellor in https://github.com/cjmellor/level-up/pull/26 +- Missing Test by @cjmellor in https://github.com/cjmellor/level-up/pull/27 +- fix: prevent grant of secret achievement twice by @ibrunotome in https://github.com/cjmellor/level-up/pull/31 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.7...v0.0.8 + +## v0.0.7 - 2023-08-18 + +### What's Changed + +- fix: set timestamps when creating achievements by @ibrunotome in https://github.com/cjmellor/level-up/pull/28 +- Streaks by @cjmellor in https://github.com/cjmellor/level-up/pull/29 + +### New Contributors + +- @ibrunotome made their first contribution in https://github.com/cjmellor/level-up/pull/28 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.6...v0.0.7 + +## v0.0.6 - 2023-08-01 + +### What's Changed + +- fix: Return correct value on next level check by @cjmellor in https://github.com/cjmellor/level-up/pull/22 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.5...v0.0.6 + +## v0.0.5 - 2023-07-31 + +### What's Changed + +- fix: Relationship Association by @cjmellor in https://github.com/cjmellor/level-up/pull/19 +- Support PHP 8.1 by @QuintenJustus in https://github.com/cjmellor/level-up/pull/18 + +### New Contributors + +- @QuintenJustus made their first contribution in https://github.com/cjmellor/level-up/pull/18 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.4...v0.0.5 + +## 0.0.4 - 2023-07-24 + +### What's Changed + +- Adding configurable user's table to level relationship migration. by @matthewscalf in https://github.com/cjmellor/level-up/pull/16 + +### New Contributors + +- @matthewscalf made their first contribution in https://github.com/cjmellor/level-up/pull/16 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.3...0.0.4 + +## v0.0.3 - 2023-07-22 + +### What's Changed + +- build(deps-dev): Update rector/rector requirement from ^0.16.0 to ^0.17.6 by @dependabot in https://github.com/cjmellor/level-up/pull/7 +- build(deps-dev): Update driftingly/rector-laravel requirement from ^0.17.0 to ^0.21.0 by @dependabot in https://github.com/cjmellor/level-up/pull/6 +- fix: Add a Default Level by @cjmellor in https://github.com/cjmellor/level-up/pull/14 +- feat: Customise constraints by @cjmellor in https://github.com/cjmellor/level-up/pull/13 +- fix: Bypass Multiplier Folder Check by @cjmellor in https://github.com/cjmellor/level-up/pull/15 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.2...v0.0.3 + +## v0.0.2 - 2023-07-14 + +### What's Changed + +- tests: Update Test Runner by @cjmellor in https://github.com/cjmellor/level-up/pull/5 + +### New Contributors + +- @cjmellor made their first contribution in https://github.com/cjmellor/level-up/pull/5 + +**Full Changelog**: https://github.com/cjmellor/level-up/compare/v0.0.1...v0.0.2 + +## v0.0.1 - 2023-07-13 + +Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d169c6b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) cjmellor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..99e4e11 --- /dev/null +++ b/README.md @@ -0,0 +1,659 @@ +[![Latest Version on Packagist](https://img.shields.io/packagist/v/cjmellor/level-up?color=rgb%2856%20189%20248%29&label=release&style=for-the-badge)](https://packagist.org/packages/cjmellor/level-up) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/cjmellor/level-up/run-tests.yml?branch=main&label=tests&style=for-the-badge&color=rgb%28134%20239%20128%29)](https://github.com/cjmellor/level-up/actions?query=workflow%3Arun-tests+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/cjmellor/level-up.svg?color=rgb%28249%20115%2022%29&style=for-the-badge)](https://packagist.org/packages/cjmellor/level-up) +![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/cjmellor/level-up/php?color=rgb%28165%20180%20252%29&logo=php&logoColor=rgb%28165%20180%20252%29&style=for-the-badge) +![Laravel Version](https://img.shields.io/badge/laravel-^10-rgb(235%2068%2050)?style=for-the-badge&logo=laravel) + +This package allows users to gain experience points (XP) and progress through levels by performing actions on your site. It can provide a simple way to track user progress and implement gamification elements into your application + +![Banner](https://banners.beyondco.de/Level%20Up.png?theme=dark&packageManager=composer+require&packageName=cjmellor%2Flevel-up&pattern=ticTacToe&style=style_1&description=Enable+gamification+via+XP%2C+levels%2C+leaderboards%2C+achievements%2C+and+dynamic+multipliers&md=1&showWatermark=0&fontSize=100px&images=puzzle&widths=auto) + +# Installation + +You can install the package via composer: + +``` +composer require cjmellor/level-up +``` + +You can publish and run the migrations with: + +``` +php artisan vendor:publish --tag="level-up-migrations" +php artisan migrate +``` + +You can publish the config file with: + +``` +php artisan vendor:publish --tag="level-up-config" +``` + +This is the contents of the published config file: + +```php +return [ + /* + |-------------------------------------------------------------------------- + | User Foreign Key + |-------------------------------------------------------------------------- + | + | This value is the foreign key that will be used to relate the Experience model to the User model. + | + */ + 'user' => [ + 'foreign_key' => 'user_id', + 'model' => App\Models\User::class, + ], + + /* + |-------------------------------------------------------------------------- + | Experience Table + |-------------------------------------------------------------------------- + | + | This value is the name of the table that will be used to store experience data. + | + */ + 'table' => 'experiences', + + /* + |----------------------------------------------------------------------- + | Starting Level + |----------------------------------------------------------------------- + | + | The level that a User starts with. + | + */ + 'starting_level' => 1, + + /* + |----------------------------------------------------------------------- + | Multiplier Paths + |----------------------------------------------------------------------- + | + | Set the path and namespace for the Multiplier classes. + | + */ + 'multiplier' => [ + 'enabled' => env(key: 'MULTIPLIER_ENABLED', default: true), + 'path' => env(key: 'MULTIPLIER_PATH', default: app_path(path: 'Multipliers')), + 'namespace' => env(key: 'MULTIPLIER_NAMESPACE', default: 'App\\Multipliers\\'), + ], + + /* + |----------------------------------------------------------------------- + | Level Cap + |----------------------------------------------------------------------- + | + | Set the maximum level a User can reach. + | + */ + 'level_cap' => [ + 'enabled' => env(key: 'LEVEL_CAP_ENABLED', default: true), + 'level' => env(key: 'LEVEL_CAP', default: 100), + 'points_continue' => env(key: 'LEVEL_CAP_POINTS_CONTINUE', default: true), + ], + + /* + | ------------------------------------------------------------------------- + | Audit + | ------------------------------------------------------------------------- + | + | Set the audit configuration. + | + */ + 'audit' => [ + 'enabled' => env(key: 'AUDIT_POINTS', default: false), + ], +]; +``` + +# Usage + +## šŸ’ÆĀ Experience Points (XP) + +> **Note** +> +> XP is enabled by default. You can disable it in the config + +Add the `GiveExperience` trait to your `User` model. + +```php +use LevelUp\Experience\Concerns\GiveExperience; + +class User extends Model +{ + use GiveExperience; + + // ... +} +``` + +**Give XP points to a User** + +```php +$user->addPoints(10); +``` + +A new record will be added to the `experiences` table which stores the Usersā€™ points. If a record already exists, it will be updated instead. All new records will be given a `level_id` of `1`. + +> **Note** +> +> If you didn't set up your Level structure yet, a default Level of `1` will be added to get you started. + +**Deduct XP points from a User** + +```php +$user->deductPoints(10); +``` + +**Set XP points to a User** + +For an event where you just want to directly add a certain number of points to a User. Points can only be ***set*** if the User has an Experience Model. + +```php +$user->setPoints(10); +``` + +**Retrieve a Usersā€™ points** + +```php +$user->getPoints(); +``` + +### Multipliers + +Point multipliers can be used to modify the experience point value of an event by a certain multiplier, such as doubling or tripling the point value. This can be useful for implementing temporary events or promotions that offer bonus points. + +To get started, you can use an Artisan command to crease a new Multiplier. + +```bash +php artisan level-up:multiplier IsMonthDecember +``` + +This will create a file at `app\Multipliers\IsMonthDecember.php`. + +Here is how the class looks: + +```php +month === 12; + } + + public function setMultiplier(): int + { + return 2; + } +} +``` + +Multipliers are enabled by default, but you can change the `$enabled` variable to `false` so that it wonā€™t even run. + +The `qualifies` method is where you put your logic to check against and multiply if the result is true. + +This can be as simple as checking that the month is December. + +```php +public function qualifies(array $data): bool +{ + return now()->month === 12; +} +``` + +Or passing extra data along to check against. This is a bit more complex. + +You can pass extra data along when you're adding points to a User. Any enabled Multiplier can then use that data to check against. + +```php +$user + ->withMultiplierData([ + 'event_id' => 222, + ]) + ->addPoints(10); + +// + +public function qualifies(array $data): bool +{ + return isset($data['event_id']) && $data['event_id'] === 222; +} +``` + +The `setMultiplier` method expects an `int` which is the number it will be multiplied by. + +**Multiply Manually** + +You can skip this altogether and just multiply the points manually if you desire. + +```php +$user->addPoints( + amount: 10, + multiplier: 2 +); +``` + +### Events + +**PointsIncrease** - When points are added. + +```php +public int $pointsAdded, +public int $totalPoints, +public string $type, +public ?string $reason, +public Model $user, +``` + +**PointsDecreased** - When points are decreased. + +```php +public int $pointsDecreasedBy, +public int $totalPoints, +``` + +## ā¬†ļøĀ Levelling + +> **Note** +> +> If you add points before setting up your levelling structure, a default Level of `1` will be added to get you started. + +### Set up your levelling structure + +The package has a handy facade to help you create your levels. + +```php +Level::add( + ['level' => 1, 'next_level_experience' => null], + ['level' => 2, 'next_level_experience' => 100], + ['level' => 3, 'next_level_experience' => 250], +); +``` + +**Level 1** should always be `null` for the `next_level_experience` as it is the default starting point. + +As soon as a User gains the correct number of points listed for the next level, they will level-up. + +> **Example:** a User gains 50 points, theyā€™ll still be on Level 1, but gets another 50 points, so the User will now move onto Level 2 + +**See how many points until the next level** + +```php +$user->nextLevelAt(); +``` + +**Get the Usersā€™ current Level** + +```php +$user->getCurrentLevel(); +``` + +### Level Cap + +A level cap sets the maximum level that a user can reach. Once a user reaches the level cap, they will not be able to gain any more levels, even if they continue to earn experience points. The level cap is enabled by default and capped to level `100`. These +options can be changed in the packages config file at `config/level-up.php` or by adding them to your `.env` file. + +``` +LEVEL_CAP_ENABLED= +LEVEL_CAP= +LEVEL_CAP_POINTS_CONTINUE +``` + +By default, even when a user hits the level cap, they will continue to earn experience points. To freeze this, so points do not increase once the cap is hit, turn on the `points_continue` option in the config file, or set it in the `.env`. + +### Events + +**UserLevelledUp** - When a User levels-up + +```php +public Model $user, +public int $level +``` + +## šŸ†Ā Achievements + +This is a feature that allows you to recognise and reward users for completing specific tasks or reaching certain milestones. +You can define your own achievements and criteria for earning them. +Achievements can be static or have progression. +Static meaning the achievement can be earned instantly. +Achievements with progression can be earned in increments, like an achievement can only be obtained once the progress is 100% complete. + +### Creating Achievements + +There is no built-in methods for creating achievements, there is just an `Achievement` model that you can use as normal: + +```php +Achievement::create([ + 'name' => 'Hit Level 20', + 'is_secret' => false, + 'description' => 'When a User hits Level 20', + 'image' => 'storage/app/achievements/level-20.png', +]); +``` + +### Gain Achievement + +To use Achievements in your User mode, you must first add the Trait. + +```php +// App\Models\User.php + +use LevelUp\Experience\Concerns\HasAchievements; + +class User extends Authenticable +{ + use HasAchievements; + + // ... +} +``` + +Then you can start using its methods, like to grant a User an Achievement: + +```php +$achievement = Achievement::find(1); + +$user->grantAchievement($achievement); +``` + +To retrieve your Achievements: + +```php +$user->achievements; +``` + +### Add progress to Achievement + +```php +$user->grantAchievement( + achievement: $achievement, + progress: 50 // 50% +); +``` + +> **Note** +> +> Achievement progress is capped to 100% + +### Check Achievement Progression + +Check at what progression your Achievements are at. + +```php +$user->achievementsWithProgress()->get(); +``` + +Check Achievements that have a certain amount of progression: + +```php +$user->achievements + ->first() + ->pivot() + ->withProgress(25) + ->get(); +``` + +### Increase Achievement Progression + +You can increment the progression of an Achievement up to 100. + +```php +$user->incrementAchievementProgress( + achievement: $achievement, + amount: 10 +); +``` + +A `AchievementProgressionIncreased` Event runs on method execution. + +### Secret Achievements + +Secret achievements are achievements that are hidden from users until they are unlocked. + +Secret achievements are made secret when created. If you want to make a non-secret Achievement secret, you can just update the Model. + +```php +$achievement->update(['is_secret' => true]); +``` + +You can retrieve the secret Achievements. + +```php +$user->secretAchievements; +``` + +To view *********all********* Achievements, both secret and non-secret: + +```php +$user->allAchievements; +``` + +### Events + +**AchievementAwarded** - When an Achievement is attached to the User + +```php +public Achievement $achievement, +public Model $user, +``` + +> **Note** +> +> This event only runs if the progress of the Achievement is 100% + +**AchievementProgressionIncreased** - When a Usersā€™ progression for an Achievement is increased. + +```php +public Achievement $achievement, +public Model $user, +public int $amount, +``` + +## šŸ“ˆĀ Leaderboard + +The package also includes a leaderboard feature to track and display user rankings based on their experience points. + +The Leaderboard comes as a Service. + +```php +Leaderboard::generate(); +``` + +This generates a User model along with its Experience and Level data and ordered by the Usersā€™ experience points. + +> The Leaderboard is very basic and has room for improvement +> + +## šŸ”Ā Auditing + +You can enable an Auditing feature in the config, which keeps a track each time a User gains points, levels up and what level to. + +The `type` and `reason` fields will be populated automatically based on the action taken, but you can overwrite these when adding points to a User + +```php +$user->addPoints( + amount: 50, + multiplier: 2, + type: AuditType::Add->value, + reason: "Some reason here", +); +``` + +**View a Usersā€™ Audit Experience** + +```php +$user->experienceHistory; +``` + +## šŸ”„Ā Streaks + +With the Streaks feature, you can track and motivate user engagement by monitoring consecutive daily activities. Whether it's logging in, completing tasks, or any other daily activity, maintaining streaks encourages users to stay active and engaged. + +Streaks are controlled in a Trait, so only use the trait if you want to use this feature. Add the Trait to you `User` model + +```php +use LevelUp\Experience\Concerns\HasStreaks; + +class User extends Model +{ + use HasStreaks; + + // ... +} +``` + +### Activities + +Use the `Activies` model to add new activities that you want to track. Hereā€™s some examples: + +- Logs into a website +- Posts an article + +### Record a Streak + +```php +$activity = Activity::find(1); + +$user->recordStreak($activity); +``` + +This will increment the streak count for the User on this activity. An `Event is ran on increment. + +### Break a Streak + +Streaks can be broken, both automatically and manually. This puts the count back to `1` to start again. An Event is ran when a streak is broken. + +For example, if your streak has had a successful run of 5 days, but a day is skipped and you run the activity on day 7, the streak will be broken and reset back to `1`. Currently, this happens automatically. + +### Reset a Streak + +You can reset a streak manually if you desire. If `level-up.archive_streak_history.enabled` is true, the streak history will be recorded. + +```php +$activity = Activity::find(1); + +$user->resetStreak($activity); +``` + +### Archive Streak Histories + +Streaks are recorded, or ā€œarchivedā€ by default. When a streak is broken, a record of the streak is recorded. A Model is supplied to use this data. + +```php +use LevelUp\Experience\Models\StreakHistory; + +StreakHistory::all(); +``` + +### Get Current Streak Count + +See the streak count for an activity for a User + +```php +$user->getCurrentStreakCount($activity); // 2 +``` + +### Check User Streak Activity + +Check if the User has performed a streak for the day + +```php +$user->hasStreakToday($activity); +``` + +### Events + +**StreakIncreased** - If an activity happens on a day after the previous day, the streak is increased. + +```php +public int $pointsAdded, +public int $totalPoints, +public string $type, +public ?string $reason, +public Model $user, +``` + +**StreakBroken** - When a streak is broken and the counter is reset. + +```php +public Model $user, +public Activity $activity, +public Streak $streak, +``` + +## šŸ„¶ Streak Freezing + +Streaks can be frozen, which means they will not be broken if a day is skipped. This is useful for when you want to allow users to take a break from an activity without losing their streak. + +The freeze duration is a configurable option in the config file. + +```php +'freeze_duration' => env(key: 'STREAK_FREEZE_DURATION', default: 1), +``` + +### Freeze a Streak + +Fetch the activity you want to freeze and pass it to the `freezeStreak` method. A second parameter can be passed to set the duration of the freeze. The default is `1` day (as set in the config) + +A `StreakFrozen` Event is ran when a streak is frozen. + +```php +$user->freezeStreak(activity: $activity); + +$user->freezeStreak(activity: $activity, days: 5); // freeze for 5 days +``` + +### Unfreeze a Streak + +The opposite of freezing a streak is unfreezing it. This will allow the streak to be broken again. + +A `StreakUnfrozen` Event is run when a streak is unfrozen. + +```php +$user->unfreezeStreak($activity); +``` + +### Check if a Streak is Frozen + +```php +$user->isStreakFrozen($activity); +``` + +### Events + +**StreakFrozen** - When a streak is frozen. + +```php +public int $frozenStreakLength, +public Carbon $frozenUntil, +``` + +**StreakUnfrozen** - When a streak is unfrozen. + +``` +No data is sent with this event +``` + +# Testing + +``` +composer test +``` + +# Changelog + +Please see [CHANGELOG](notion://www.notion.so/CHANGELOG.md) for more information on what has changed recently. + +# License + +The MIT Licence (MIT). Please see [Licence File](notion://www.notion.so/LICENSE.md) for more information. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..3accdd6 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,11 @@ +# Ugrade Guide + +## v0.0.6 -> v0.0.7 + +v0.0.7 comes with a brand-new feature -- Streaks. + +Some new configuration settings have been introduced. Delete the `config/level-up.php` file. + +Now run `php artisan vendor:publish` and select `LevelUp\Experience\LevelUpServiceProvider` + +This also publishes new migration files. Run `php artisan migrate` to migrate the new tables. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c0d1971 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "cjmellor/level-up", + "description": "This package allows users to gain experience points (XP) and progress through levels by performing actions on your site. It can provide a simple way to track user progress and implement gamification elements into your application", + "keywords": [ + "cjmellor", + "laravel", + "level-up", + "gamification", + "gamify" + ], + "homepage": "https://github.com/cjmellor/level-up", + "license": "MIT", + "authors": [ + { + "name": "Chris Mellor", + "email": "chris@mellor.pizza", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "illuminate/contracts": "^10.0", + "laravel/pint": "^1.10", + "spatie/laravel-package-tools": "^1.15" + }, + "require-dev": { + "driftingly/rector-laravel": "^0.21.0", + "nunomaduro/collision": "^7.5.2", + "orchestra/testbench": "^8.5.3", + "pestphp/pest": "^2.6.1", + "pestphp/pest-plugin-arch": "^v2.0.2", + "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-type-coverage": "^2.0", + "plannr/laravel-fast-refresh-database": "^1.0.2", + "rector/rector": "^0.17.6" + }, + "autoload": { + "psr-4": { + "LevelUp\\Experience\\": "src/", + "LevelUp\\Experience\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "LevelUp\\Experience\\Tests\\": "tests/" + } + }, + "scripts": { + "lint": "vendor/bin/pint", + "format": "vendor/bin/rector process", + "dry-format": "vendor/bin/rector process --dry-run", + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "LevelUp\\Experience\\LevelUpServiceProvider" + ], + "aliases": { + "Experience": "LevelUp\\Experience\\Facades\\Experience" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/level-up.php b/config/level-up.php new file mode 100644 index 0000000..fb251cd --- /dev/null +++ b/config/level-up.php @@ -0,0 +1,99 @@ + [ + 'foreign_key' => 'user_id', + 'model' => App\Models\User::class, + 'users_table' => 'users', + ], + + /* + |-------------------------------------------------------------------------- + | Experience Table + |-------------------------------------------------------------------------- + | + | This value is the name of the table that will be used to store experience data. + | + */ + 'table' => 'experiences', + + /* + |----------------------------------------------------------------------- + | Starting Level + |----------------------------------------------------------------------- + | + | The level that a User starts with. + | + */ + 'starting_level' => 1, + + /* + |----------------------------------------------------------------------- + | Multiplier Paths + |----------------------------------------------------------------------- + | + | Set the path and namespace for the Multiplier classes. + | + */ + 'multiplier' => [ + 'enabled' => env(key: 'MULTIPLIER_ENABLED', default: true), + 'path' => env(key: 'MULTIPLIER_PATH', default: app_path(path: 'Multipliers')), + 'namespace' => env(key: 'MULTIPLIER_NAMESPACE', default: 'App\\Multipliers\\'), + ], + + /* + |----------------------------------------------------------------------- + | Level Cap + |----------------------------------------------------------------------- + | + | Set the maximum level a User can reach. + | + */ + 'level_cap' => [ + 'enabled' => env(key: 'LEVEL_CAP_ENABLED', default: true), + 'level' => env(key: 'LEVEL_CAP', default: 100), + 'points_continue' => env(key: 'LEVEL_CAP_POINTS_CONTINUE', default: true), + ], + + /* + | ------------------------------------------------------------------------- + | Audit + | ------------------------------------------------------------------------- + | + | Set the audit configuration. + | + */ + 'audit' => [ + 'enabled' => env(key: 'AUDIT_POINTS', default: false), + ], + + /* + | ------------------------------------------------------------------------- + | Record streak history + | ------------------------------------------------------------------------- + | + | Set the streak history configuration. + | + */ + 'archive_streak_history' => [ + 'enabled' => env(key: 'ARCHIVE_STREAK_HISTORY_ENABLED', default: true), + ], + + /* + | ------------------------------------------------------------------------- + | Default Streak Freeze Time + | ------------------------------------------------------------------------- + | + | Set the default time in days that a streak will be frozen for. + | + */ + 'freeze_duration' => env(key: 'STREAK_FREEZE_DURATION', default: 1), +]; diff --git a/database/factories/AchievementFactory.php b/database/factories/AchievementFactory.php new file mode 100644 index 0000000..05214ee --- /dev/null +++ b/database/factories/AchievementFactory.php @@ -0,0 +1,30 @@ + fake()->name, + 'description' => fake()->sentence, + 'image' => fake()->imageUrl, + ]; + } + + public function secret(): self + { + return $this->state([ + 'is_secret' => true, + ]); + } +} diff --git a/database/factories/ActivityFactory.php b/database/factories/ActivityFactory.php new file mode 100644 index 0000000..326a50f --- /dev/null +++ b/database/factories/ActivityFactory.php @@ -0,0 +1,19 @@ + fake()->word, + 'description' => fake()->sentence, + ]; + } +} diff --git a/database/factories/StreakFactory.php b/database/factories/StreakFactory.php new file mode 100644 index 0000000..88ea35a --- /dev/null +++ b/database/factories/StreakFactory.php @@ -0,0 +1,23 @@ + User::factory(), + 'activity_id' => Activity::factory(), + 'count' => 1, + 'activity_at' => now(), + ]; + } +} diff --git a/database/migrations/add_level_relationship_to_users_table.php.stub b/database/migrations/add_level_relationship_to_users_table.php.stub new file mode 100644 index 0000000..4424e69 --- /dev/null +++ b/database/migrations/add_level_relationship_to_users_table.php.stub @@ -0,0 +1,24 @@ +foreignId('level_id') + ->after('remember_token') + ->nullable() + ->constrained(); + }); + } + + public function down(): void + { + Schema::table(config('level-up.user.users_table'), function (Blueprint $table) { + $table->dropConstrainedForeignId('level_id'); + }); + } +}; diff --git a/database/migrations/add_streak_freeze_feature_columns_to_streaks_table.php.stub b/database/migrations/add_streak_freeze_feature_columns_to_streaks_table.php.stub new file mode 100644 index 0000000..d4aea0d --- /dev/null +++ b/database/migrations/add_streak_freeze_feature_columns_to_streaks_table.php.stub @@ -0,0 +1,23 @@ +after('activity_at', function (Blueprint $table) { + $table->timestamp('frozen_until')->nullable(); + }); + }); + } + + public function down(): void + { + Schema::table('streaks', function (Blueprint $table) { + $table->dropColumn('frozen_until'); + }); + } +}; diff --git a/database/migrations/create_achievement_user_pivot_table.php.stub b/database/migrations/create_achievement_user_pivot_table.php.stub new file mode 100644 index 0000000..719e5f2 --- /dev/null +++ b/database/migrations/create_achievement_user_pivot_table.php.stub @@ -0,0 +1,23 @@ +id(); + $table->foreignId(column: config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + $table->foreignId(column: 'achievement_id')->constrained(); + $table->integer(column: 'progress')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('achievement_user'); + } +}; diff --git a/database/migrations/create_achievements_table.php.stub b/database/migrations/create_achievements_table.php.stub new file mode 100644 index 0000000..db12db3 --- /dev/null +++ b/database/migrations/create_achievements_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->boolean('is_secret')->default(false); + $table->text('description')->nullable(); + $table->string('image')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('achievements'); + } +}; diff --git a/database/migrations/create_experience_audits_table.php.stub b/database/migrations/create_experience_audits_table.php.stub new file mode 100644 index 0000000..49f5054 --- /dev/null +++ b/database/migrations/create_experience_audits_table.php.stub @@ -0,0 +1,26 @@ +id(); + $table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + $table->integer('points')->index(); + $table->boolean('levelled_up')->default(false); + $table->integer('level_to')->nullable(); + $table->enum('type', ['add', 'remove', 'reset', 'level_up']); + $table->string('reason')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('experience_audits'); + } +}; diff --git a/database/migrations/create_experiences_table.php.stub b/database/migrations/create_experiences_table.php.stub new file mode 100644 index 0000000..a4a8a42 --- /dev/null +++ b/database/migrations/create_experiences_table.php.stub @@ -0,0 +1,19 @@ +id(); + $table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + $table->foreignId('level_id')->constrained(); + $table->integer('experience_points')->default(0)->index(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/create_levels_table.php.stub b/database/migrations/create_levels_table.php.stub new file mode 100644 index 0000000..905f282 --- /dev/null +++ b/database/migrations/create_levels_table.php.stub @@ -0,0 +1,23 @@ +id(); + $table->integer('level')->unique(); + $table->integer('next_level_experience')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('levels'); + } +}; diff --git a/database/migrations/create_streak_activities_table.php.stub b/database/migrations/create_streak_activities_table.php.stub new file mode 100644 index 0000000..04f1360 --- /dev/null +++ b/database/migrations/create_streak_activities_table.php.stub @@ -0,0 +1,22 @@ +id(); + $table->string('name')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('streak_activities'); + } +}; diff --git a/database/migrations/create_streak_histories_table.php.stub b/database/migrations/create_streak_histories_table.php.stub new file mode 100644 index 0000000..d0f02da --- /dev/null +++ b/database/migrations/create_streak_histories_table.php.stub @@ -0,0 +1,27 @@ +id(); + $table->foreignId(column: config(key: 'level-up.user.foreign_key'))->constrained(table: config(key: 'level-up.user.users_table'))->cascadeOnDelete(); + $table->foreignIdFor(model: Activity::class)->constrained(table: 'streak_activities'); + $table->integer(column: 'count')->default(value: 1); + $table->timestamp(column: 'started_at'); + $table->timestamp(column: 'ended_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('streak_histories'); + } +}; diff --git a/database/migrations/create_streaks_table.php.stub b/database/migrations/create_streaks_table.php.stub new file mode 100644 index 0000000..958d495 --- /dev/null +++ b/database/migrations/create_streaks_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->foreignId(column: 'user_id')->constrained()->onDelete('cascade'); + $table->foreignId(column: 'activity_id')->constrained('streak_activities')->onDelete('cascade'); + $table->integer(column: 'count')->default(1); + $table->timestamp(column: 'activity_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('streaks'); + } +}; diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..502e282 --- /dev/null +++ b/rector.php @@ -0,0 +1,41 @@ +paths([ + __DIR__.'/config', + __DIR__.'/database', + __DIR__.'/resources', + __DIR__.'/routes', + __DIR__.'/src', + __DIR__.'/tests', + ]); + + $rectorConfig->sets([ + LevelSetList::UP_TO_PHP_82, + SetList::CODE_QUALITY, + SetList::CODING_STYLE, + SetList::DEAD_CODE, + SetList::EARLY_RETURN, + SetList::TYPE_DECLARATION, + SetList::PRIVATIZATION, + LaravelSetList::LARAVEL_100, + ]); + + $rectorConfig->skip([ + FinalizeClassesWithoutChildrenRector::class, + StaticArrowFunctionRector::class, + StaticClosureRector::class, + UnSpreadOperatorRector::class, + ]); +}; diff --git a/src/Commands/MakeMultiplierCommand.php b/src/Commands/MakeMultiplierCommand.php new file mode 100644 index 0000000..ba60508 --- /dev/null +++ b/src/Commands/MakeMultiplierCommand.php @@ -0,0 +1,45 @@ +laravel->basePath(trim($relativePath, '/'))) + ? $customPath + : __DIR__.$relativePath; + } + + protected function getDefaultNamespace($rootNamespace): string + { + return $rootNamespace.'\Multipliers'; + } + + protected function getArguments(): array + { + return [ + ['name', InputArgument::REQUIRED, 'The name of the multiplier'], + ]; + } + + protected function getOptions(): array + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the multiplier already exists'], + ['multiplier', null, InputOption::VALUE_OPTIONAL, 'The name of the multiplier'], + ]; + } +} diff --git a/src/Concerns/GiveExperience.php b/src/Concerns/GiveExperience.php new file mode 100644 index 0000000..290b01a --- /dev/null +++ b/src/Concerns/GiveExperience.php @@ -0,0 +1,198 @@ +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); + + return $this->experience; + } + + /** + * If the User does have an Experience record, update it. + */ + if (config(key: 'level-up.level_cap.enabled') && $this->getCurrentLevel() >= 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; + } + + protected function getMultipliers(int $amount): int + { + $multiplierService = app(MultiplierService::class, [ + 'data' => $this->multiplierData ? $this->multiplierData->toArray() : [], + ]); + + return $multiplierService(points: $amount); + } + + public function experience(): HasOne + { + return $this->hasOne(related: Experience::class); + } + + protected function dispatchEvent(int $amount, string $type, ?string $reason): void + { + event(new PointsIncreased( + pointsAdded: $amount, + totalPoints: $this->experience->experience_points, + type: $type, + reason: $reason, + user: $this, + )); + } + + public function getCurrentLevel(): int + { + return $this->experience->status->level; + } + + 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; + } + + public function withMultiplierData(array $data): static + { + $this->multiplierData = collect($data); + + return $this; + } + + public function nextLevelAt(int $checkAgainst = null, bool $showAsPercentage = false): int + { + // $nextLevel = Level::firstWhere(column: 'level', operator: '=', value: $checkAgainst ?? $this->getCurrentLevel() + 1); + $nextLevel = Level::firstWhere(column: 'level', operator: '=', value: is_null($checkAgainst) ? $this->getCurrentLevel() + 1 : $checkAgainst); + + if (! $nextLevel || $nextLevel->next_level_experience === null) { + return 0; + } + + $currentLevelExperience = Level::firstWhere(column: 'level', operator: '=', value: $this->getCurrentLevel())->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)); + } + + public function getPoints(): int + { + return $this->experience->experience_points; + } + + public function levelUp(): void + { + if (config(key: 'level-up.level_cap.enabled') && $this->getCurrentLevel() >= config(key: 'level-up.level_cap.level')) { + return; + } + + $nextLevel = Level::firstWhere(column: 'level', operator: $this->getCurrentLevel() + 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->getCurrentLevel())); + } + + public function experienceHistory(): HasMany + { + return $this->hasMany(related: ExperienceAudit::class); + } + + public function level(): BelongsTo + { + return $this->belongsTo(related: Level::class); + } +} diff --git a/src/Concerns/HasAchievements.php b/src/Concerns/HasAchievements.php new file mode 100644 index 0000000..3a5236e --- /dev/null +++ b/src/Concerns/HasAchievements.php @@ -0,0 +1,74 @@ + 100) { + throw new Exception(message: 'Progress cannot be greater than 100'); + } + + if ($this->allAchievements()->find($achievement->id)) { + throw new Exception(message: 'User already has this Achievement'); + } + + $this->achievements()->attach($achievement, [ + 'progress' => $progress ?? null, + ]); + + $this->when(value: ($progress === null) || ($progress === 100), callback: fn (): ?array => event(new AchievementAwarded(achievement: $achievement, user: $this))); + } + + public function achievements(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withTimestamps() + ->withPivot(columns: 'progress') + ->where('is_secret', false) + ->using(AchievementUser::class); + } + + public function incrementAchievementProgress(Achievement $achievement, int $amount = 1) + { + $newProgress = min(100, ($this->achievements()->find($achievement->id)->pivot->progress ?? 0) + $amount); + + $this->achievements()->updateExistingPivot($achievement->id, attributes: ['progress' => $newProgress]); + + event(new AchievementProgressionIncreased(achievement: $achievement, user: $this, amount: $amount)); + + return $newProgress; + } + + public function allAchievements(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withPivot(columns: 'progress'); + } + + public function achievementsWithProgress(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withPivot(columns: 'progress') + ->where('is_secret', false) + ->wherePivotNotNull(column: 'progress'); + } + + public function secretAchievements(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withPivot(columns: 'progress') + ->where('is_secret', true); + } +} diff --git a/src/Concerns/HasStreaks.php b/src/Concerns/HasStreaks.php new file mode 100644 index 0000000..62b6ec6 --- /dev/null +++ b/src/Concerns/HasStreaks.php @@ -0,0 +1,163 @@ +hasStreakForActivity(activity: $activity)) { + $this->startNewStreak($activity); + + return; + } + + $diffInDays = $this->getStreakLastActivity($activity) + ->activity_at + ->startOfDay() + ->diffInDays(now()->startOfDay()); + + // Checking to see if the streak is frozen + if ($this->getStreakLastActivity($activity)->frozen_until && now()->lessThan($this->getStreakLastActivity($activity)->frozen_until)) { + return; + } + + if ($diffInDays === 0) { + return; + } + + // Check to see if the streak was broken + if ($diffInDays > 1) { + $this->resetStreak($activity); + + event(new StreakBroken($this, $activity, $this->streaks()->first())); + + return; + } + + if ($diffInDays === 1) { + $streak = $this->streaks()->whereBelongsTo(related: $activity); + $streak->increment(column: 'count'); + $streak->update(values: ['activity_at' => now()]); + + event(new StreakIncreased($this, $activity, $streak->first())); + } else { + $this->startNewStreak($activity); + } + } + + protected function hasStreakForActivity(Activity $activity): bool + { + return $this->streaks() + ->whereBelongsTo(related: $activity) + ->exists(); + } + + public function streaks(): HasMany + { + return $this->hasMany(related: Streak::class); + } + + protected function startNewStreak(Activity $activity): Model|Streak + { + $streak = $activity->streaks() + ->updateOrCreate([ + 'user_id' => $this->id, + 'activity_id' => $activity->id, + 'activity_at' => now(), + ]); + + event(new StreakStarted($this, $activity, $streak)); + + return $streak; + } + + protected function getStreakLastActivity(Activity $activity): Streak + { + return $this->streaks() + ->whereBelongsTo(related: $activity) + ->latest(column: 'activity_at') + ->first(); + } + + public function resetStreak(Activity $activity): void + { + // Archive the streak + if (config(key: 'level-up.archive_streak_history.enabled')) { + $this->archiveStreak($activity); + } + + $this->streaks() + ->whereBelongsTo(related: $activity) + ->update([ + 'count' => 1, + 'activity_at' => now(), + ]); + } + + protected function archiveStreak(Activity $activity): void + { + $latestStreak = $this->getStreakLastActivity($activity); + + StreakHistory::create([ + 'user_id' => $this->id, + 'activity_id' => $activity->id, + 'count' => $latestStreak->count, + 'started_at' => $latestStreak->activity_at->subDays($latestStreak->count - 1), + 'ended_at' => $latestStreak->activity_at, + ]); + } + + public function getCurrentStreakCount(Activity $activity): int + { + return $this->streaks()->whereBelongsTo(related: $activity)->first() + ? $this->streaks()->whereBelongsTo(related: $activity)->first()->count + : 0; + } + + public function hasStreakToday(Activity $activity): bool + { + return $this->getStreakLastActivity($activity) + ->activity_at + ->isToday(); + } + + public function freezeStreak(Activity $activity, int $days = null): bool + { + $days = $days ?? config(key: 'level-up.freeze_duration'); + + Event::dispatch(new StreakFrozen( + frozenStreakLength: $days, + frozenUntil: now()->addDays(value: $days)->startOfDay() + )); + + return $this->getStreakLastActivity($activity) + ->update(['frozen_until' => now()->addDays(value: $days)->startOfDay()]); + } + + public function unFreezeStreak(Activity $activity): bool + { + Event::dispatch(new StreakUnfroze()); + + return $this->getStreakLastActivity($activity) + ->update(['frozen_until' => null]); + } + + public function isStreakFrozen(Activity $activity): bool + { + return ! is_null($this->getStreakLastActivity($activity)->frozen_until); + } +} diff --git a/src/Contracts/Multiplier.php b/src/Contracts/Multiplier.php new file mode 100644 index 0000000..54aa7ac --- /dev/null +++ b/src/Contracts/Multiplier.php @@ -0,0 +1,10 @@ +name(name: 'level-up') + ->hasCommand(commandClassName: MakeMultiplierCommand::class) + ->hasConfigFile() + ->hasMigrations([ + 'create_levels_table', + 'create_experiences_table', + 'add_level_relationship_to_users_table', + 'create_experience_audits_table', + 'create_achievements_table', + 'create_achievement_user_pivot_table', + 'create_streak_activities_table', + 'create_streaks_table', + 'create_streak_histories_table', + 'add_streak_freeze_feature_columns_to_streaks_table', + ]); + } + + public function register(): void + { + parent::register(); + + $this->app->register(provider: EventServiceProvider::class); + $this->app->singleton(abstract: 'leaderboard', concrete: fn () => new LeaderboardService()); + $this->app->register(provider: MultiplierServiceProvider::class); + } +} diff --git a/src/Listeners/PointsIncreasedListener.php b/src/Listeners/PointsIncreasedListener.php new file mode 100644 index 0000000..e77c0d0 --- /dev/null +++ b/src/Listeners/PointsIncreasedListener.php @@ -0,0 +1,39 @@ +user->experienceHistory()->create([ + 'points' => $event->pointsAdded, + 'type' => $event->type, + 'reason' => $event->reason, + ]); + } + + if (Level::count() === 0) { + Level::add([ + 'level' => config(key: 'level-up.starting_level'), + 'next_level_experience' => null, + ]); + } + + $nextLevel = Level::firstWhere(column: 'level', operator: $event->user->getCurrentLevel() + 1); + + if (! $nextLevel) { + return; + } + + if ($event->user->getPoints() < $nextLevel->next_level_experience) { + return; + } + + $event->user->levelUp(); + } +} diff --git a/src/Models/Achievement.php b/src/Models/Achievement.php new file mode 100644 index 0000000..1948e31 --- /dev/null +++ b/src/Models/Achievement.php @@ -0,0 +1,19 @@ +belongsToMany(related: config(key: 'level-up.user.model')); + } +} diff --git a/src/Models/Activity.php b/src/Models/Activity.php new file mode 100644 index 0000000..e33673c --- /dev/null +++ b/src/Models/Activity.php @@ -0,0 +1,21 @@ +hasMany(related: Streak::class); + } +} diff --git a/src/Models/Experience.php b/src/Models/Experience.php new file mode 100644 index 0000000..6ffeb07 --- /dev/null +++ b/src/Models/Experience.php @@ -0,0 +1,24 @@ +belongsTo(config(key: 'level-up.user.model')); + } + + public function status(): BelongsTo + { + return $this->belongsTo(related: Level::class, foreignKey: 'level_id'); + } +} diff --git a/src/Models/ExperienceAudit.php b/src/Models/ExperienceAudit.php new file mode 100644 index 0000000..eafc03e --- /dev/null +++ b/src/Models/ExperienceAudit.php @@ -0,0 +1,21 @@ + AuditType::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(config(key: 'level-up.user.model')); + } +} diff --git a/src/Models/Level.php b/src/Models/Level.php new file mode 100644 index 0000000..f2386ce --- /dev/null +++ b/src/Models/Level.php @@ -0,0 +1,51 @@ + $levelNumber, + 'next_level_experience' => $pointsToNextLevel, + ]); + } catch (Throwable) { + throw LevelExistsException::handle(levelNumber: $levelNumber); + } + + if (! is_array($level)) { + break; + } + } + + return $newLevels; + } + + public function users(): HasMany + { + return $this->hasMany(related: config(key: 'level-up.user.model')); + } +} diff --git a/src/Models/Pivots/AchievementUser.php b/src/Models/Pivots/AchievementUser.php new file mode 100644 index 0000000..1c00d47 --- /dev/null +++ b/src/Models/Pivots/AchievementUser.php @@ -0,0 +1,15 @@ +where(column: 'progress', operator: $progress)->get(); + } +} diff --git a/src/Models/Streak.php b/src/Models/Streak.php new file mode 100644 index 0000000..17ce0bc --- /dev/null +++ b/src/Models/Streak.php @@ -0,0 +1,29 @@ + 'datetime', + 'frozen_until' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(config(key: 'level-up.user.model')); + } + + public function activity(): BelongsTo + { + return $this->belongsTo(related: Activity::class); + } +} diff --git a/src/Models/StreakHistory.php b/src/Models/StreakHistory.php new file mode 100644 index 0000000..131399f --- /dev/null +++ b/src/Models/StreakHistory.php @@ -0,0 +1,15 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; +} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php new file mode 100644 index 0000000..af732c5 --- /dev/null +++ b/src/Providers/EventServiceProvider.php @@ -0,0 +1,26 @@ + [ + PointsIncreasedListener::class, + ], + ]; + + public function register(): void + { + parent::register(); + } + + public function boot(): void + { + parent::boot(); + } +} diff --git a/src/Providers/MultiplierServiceProvider.php b/src/Providers/MultiplierServiceProvider.php new file mode 100644 index 0000000..7888ca8 --- /dev/null +++ b/src/Providers/MultiplierServiceProvider.php @@ -0,0 +1,30 @@ +app->bind( + abstract: MultiplierService::class, + concrete: fn (Application $app, array $params): MultiplierService => new MultiplierService( + multipliers: collect(value: File::allFiles(config(key: 'level-up.multiplier.path'))) + ->map(callback: fn ($file) => new ReflectionClass(sprintf('%s%s', config(key: 'level-up.multiplier.namespace'), str($file->getFilename())->replace(search: '.php', replace: '')))) + ->filter(callback: fn (ReflectionClass $class): bool => $class->getProperty(name: 'enabled')->getValue($class->newInstance()) === true) + ->map(callback: fn (ReflectionClass $class) => $app->make($class->getName())), + data: $params['data'] ?? [] + ) + ); + } + + public function boot(): void + { + } +} diff --git a/src/Services/LeaderboardService.php b/src/Services/LeaderboardService.php new file mode 100644 index 0000000..5c9e182 --- /dev/null +++ b/src/Services/LeaderboardService.php @@ -0,0 +1,31 @@ +userModel = config(key: 'level-up.user.model'); + } + + public function generate(bool $paginate = false, int $limit = null): array|Collection|LengthAwarePaginator + { + return $this->userModel::query() + ->with(relations: ['experience', 'level']) + ->orderByDesc( + column: Experience::select('experience_points') + ->whereColumn('user_id', 'users.id') + ->latest() + ) + ->take($limit) + ->when($paginate, fn (Builder $query) => $query->paginate(), fn (Builder $query) => $query->get()); + } +} diff --git a/src/Services/MultiplierService.php b/src/Services/MultiplierService.php new file mode 100644 index 0000000..414123f --- /dev/null +++ b/src/Services/MultiplierService.php @@ -0,0 +1,30 @@ +multipliers->reduce( + callback: fn (int $amount, $multiplier) => $multiplier->qualifies($this->getMultiplierData()->toArray()) + ? $amount * $multiplier->setMultiplier() + : $amount, + initial: $points + ); + } + + protected function getMultiplierData(): Collection + { + return collect($this->data); + } +} diff --git a/stubs/Multiplier.stub b/stubs/Multiplier.stub new file mode 100644 index 0000000..5fc8d1c --- /dev/null +++ b/stubs/Multiplier.stub @@ -0,0 +1,20 @@ +