Skip to content

v2.0#114

Merged
cjmellor merged 68 commits intomainfrom
2.x
Apr 3, 2026
Merged

v2.0#114
cjmellor merged 68 commits intomainfrom
2.x

Conversation

@cjmellor
Copy link
Copy Markdown
Owner

@cjmellor cjmellor commented Mar 21, 2026

Summary

v2.0 major release bringing three new systems and a platform upgrade:

Tier System

  • Automatic promotion/demotion based on points thresholds
  • Tier-gated achievements, tier-specific multipliers, streak freeze durations
  • Leaderboard filtering by tier
  • UserTierUpdated event with promotion/demotion direction

Challenge System

  • Multi-condition goals: points earned, level reached, achievement earned, streak count, tier reached, custom
  • Auto-enrollment, repeatable challenges with baseline tracking
  • Reward dispatch (points, achievements) with re-entrancy guard preventing infinite loops
  • Challenge lifecycle events: enrolled, unenrolled, completed
  • Extensible via ChallengeCondition contract

DB-Backed Multipliers

  • Multiplier and MultiplierScope models replacing class-based system
  • Scoped to specific users or tiers via polymorphic scopeTo()
  • Three stacking strategies: compound, additive, highest
  • Time-windowed with automatic active/expired/scheduled scoping
  • MultiplierApplied event with full audit trail

Platform

  • Minimum PHP 8.3, minimum Laravel 12
  • Modernized to Laravel conventions (Scope attribute, typed config, strict types)
  • Rector and Pint modernisation across the codebase

Bug Fixes

  • resolve() named parameter bug that silently broke challenge evaluation
  • scopeTo() now idempotent — no duplicate scopes on repeated calls
  • Race conditions in tier system hardened
  • Re-entrancy guard scoped per-user, not globally

Test Coverage

Test Coverage Audit: 99.7% — all code paths covered across 9 key source files.
Tests: 18 test files, 216 tests, 417 assertions — all passing.

Pre-Landing Review

No issues found.

Adversarial Review

Claude adversarial subagent: 23 findings reviewed, all false positives (missed existing demotion listener, misunderstood intentional design).
Codex: unavailable (auth failure).

Scope Drift

Scope Check: CLEAN — all changes align with v2.0 stated intent.

Test plan

  • All Pest tests pass (216 tests, 417 assertions)
  • Lint clean (composer lint)
  • Pre-landing review: no issues
  • Adversarial review: clean

🤖 Generated with Claude Code

laravel-shift and others added 3 commits March 5, 2026 16:42
Tighten version constraints for v2.0:
- PHP ^8.3 (minimum for Laravel 13)
- Laravel ^12.0|^13.0 (only actively supported versions)
- Update dev dependencies to drop legacy major versions
- Simplify CI matrix to PHP 8.3/8.4 with Laravel 12/13
- Rewrite rector.php to fluent RectorConfig API with PHP 8.3 sets
- Add pint.json from approval package with strict rules
- Add rector/rector and driftingly/rector-laravel as dev deps
- Disable mb_str_functions (requires PHP 8.4) and protected_to_private (breaks Eloquent scopes)
- Add declare(strict_types=1) to all PHP files
- Apply Rector PHP 8.3 rules (closure return types, arrow functions, etc.)
- Apply Pint Laravel preset (class element ordering, PHPDoc to native types)
- Convert fake() property access to method calls in factories
- Add @extends annotations to factory classes
Breaking changes:
- levelUp() throws InvalidArgumentException when level doesn't exist
- deductPoints() throws when user has no experience record
- Level::add() only accepts array arguments (scalar form removed)
- incrementAchievementProgress() throws when user lacks achievement
- AchievementUser::scopeWithProgress() removed (dead code)
- getStreakLastActivity() returns ?Streak (was Streak)
- grantAchievement($progress) typed as ?int (was untyped)

Bug fixes:
- levelUp() event dispatch: capture previous level before associate()
- StreakBroken event now correctly filters by activity
- nextLevelAt() null guard and division-by-zero guard
- freezeStreak() casts config to int for strict_types
- Level::add() catches UniqueConstraintViolationException (was Throwable)
- PointsIncreasedListener null guard on highestAchievableLevel
- Config key corrected from level-up.streaks.foreign_key to level-up.user.foreign_key

Improvements:
- Cache getStreakLastActivity() result in recordStreak()
- Simplify getCurrentStreakCount() to nullsafe operator
- Nullsafe operators on all getStreakLastActivity() callers
- Standardise on event() helper (remove Event facade usage)
- Remove empty register()/boot() from EventServiceProvider
- Type LeaderboardService::$userModel as string
- Narrow MultiplierService callback return type to int
- nextLevelAt() treats null XP threshold as 0 baseline
New tests:
- levelUp() throws on non-existent level
- levelUp() associates correct level (happy path)
- levelUp() respects level cap
- deductPoints() throws when no experience record
- nextLevelAt() returns 0 when current level missing
- incrementAchievementProgress() throws when user lacks achievement
- hasStreakToday() returns false when no streak exists
- freezeStreak() returns false when no streak exists

Modernisation:
- Apply Rector/Pint code style across all test files
- Fix LevelTest to use array form for Level::add()
- Add Pest IDE helper for Intelephense support
- Use Schema facade in TestCase
- Import Date facade in GiveExperienceTest
- PHP 8.3 and 8.4 only (drop 8.1, 8.2)
- Laravel 12 and 13 only (drop 10, 11)
- Remove exclusion rules (no longer needed)
- Fix YAML block scalar from | to >
@cjmellor cjmellor changed the title v2.0: Drop legacy PHP/Laravel, add Laravel 13 support v2.0: Modernise codebase for PHP 8.3+ and Laravel 12/13 Mar 21, 2026
@cjmellor cjmellor changed the title v2.0: Modernise codebase for PHP 8.3+ and Laravel 12/13 2.x Mar 21, 2026
@cjmellor cjmellor self-assigned this Mar 21, 2026
@cjmellor cjmellor marked this pull request as draft March 21, 2026 16:27
cjmellor and others added 16 commits March 21, 2026 23:38
- README: update Laravel version badge to ^12|^13
- README: add note about deductPoints/setPoints throwing on missing experience
- UPGRADE.md: add comprehensive v1→v2 migration guide covering all breaking changes
Add migrations for tiers table, tier_id foreign keys on experiences
and achievements tables, and alter experience_audits type column from
enum to string for easier future extensibility.

Register TierDirection and AuditType enum extensions (TierUp, TierDown),
tier model in config, migrations in service provider, and UserTierUpdated
event in EventServiceProvider.
Tier model with Tier::add() factory method, HasTiers trait providing
getTier(), getNextTier(), tierProgress(), nextTierAt(), isAtTier(),
and isAtOrAboveTier() methods. Supports configurable demotion with
stored high-water mark when demotion is disabled.

Includes UserTierUpdated event (readonly, typed TierDirection enum),
UserTierUpdatedListener for audit trail, TierExistsException and
TierRequirementNotMet exceptions, and tier relationships on
Experience and Achievement models.
…aderboard

Tier-based multipliers in GiveExperience::applyTierMultiplier(),
tier-gated achievements in HasAchievements::grantAchievement(),
tier-scaled streak freeze in HasStreaks::getFreezeDurationForTier(),
tier-scoped leaderboard via LeaderboardService::forTier(), and
automatic tier promotion/demotion detection in PointsIncreased
and PointsDecreased listeners with ID short-circuit optimization.
TierTest (model creation, metadata, duplicate handling),
HasTiersTest (getTier, getNextTier, tierProgress, nextTierAt,
isAtTier, isAtOrAboveTier, promotion events, demotion events,
high-water mark, tier_id storage), HasTiersIntegrationTest
(tier-gated achievements, tier multipliers, tier-scaled streak
freeze, tier-scoped leaderboard), and UserTierUpdatedListenerTest
(audit trail for promotions and demotions).
Wrap Tier::add() in a DB transaction with UniqueConstraintViolation
catch to prevent partial writes on concurrent inserts. Guard getTier()
calls in GiveExperience, HasAchievements, and HasStreaks with
method_exists() so models without HasTiers don't crash on upgrade.
README: add Tiers section with usage examples for tier definition,
querying, demotion, multipliers, gated achievements, scaled streak
freezes, scoped leaderboards, and events. Update config example
to include tiers config.

UPGRADE: add v2 tiers migration requirements including the
alter_experience_audits_type_to_string migration and HasTiers
trait instructions.
Add three Boost-compatible files for AI-assisted development:
- guidelines/core.blade.php: package awareness guideline
- skills/level-up-development: comprehensive usage guide
- skills/level-up-upgrade-v2: automated v1→v2 upgrade workflow
feat: add tier system with cross-feature integrations
Add migrations for DB-backed multiplier system:
- multipliers table (name, multiplier, is_active, starts_at, expires_at)
- multiplier_scopes polymorphic table with composite index
- multipliers JSON column on experience_audits for audit trail
Multiplier model with query scopes (active, forUser, scheduled, expired),
polymorphic scoping via MultiplierScope, and validation (multiplier > 0).
Replace path/namespace config with stack_strategy (compound/additive/highest).
Add Multiplier and MultiplierScope to configurable models array.
Remove tiers.multipliers config key (replaced by DB-scoped multipliers).
Rewrite multiplier logic in GiveExperience trait:
- Query active DB multipliers scoped to user/tier
- Apply configurable stacking strategy (compound/additive/highest)
- Inline multiplier param changed from ?int to int|float|null
- Fire MultiplierApplied event when multipliers are applied
- Record applied multipliers in audit trail
- Remove withMultiplierData(), getMultipliers(), applyTierMultiplier()
cjmellor and others added 7 commits April 2, 2026 20:40
- Expired/future challenge enrollment throws
- Unenroll from active/completed/not-enrolled challenges
- Repeatable re-enrollment resets progress
- Completion percentage calculation
- ChallengeEnrolled/ChallengeUnenrolled events
- Auto-enroll baseline regression test (existing points)
- Re-entrancy guard direct test
- Validation rejects invalid condition/reward types and keys
- Custom condition class validation at creation time
- Listener try/catch isolation
- Tier: isAtTier/isAtOrAboveTier with non-existent name
- Tier: demotion below all tiers sets tier_id to null
- Add Challenges section to README with usage, conditions, rewards, events
- Add Challenges section to UPGRADE.md for v2.0 migration guide
- Add challenges config to README config block
- Add .env to .gitignore
- Fix stale search-docs reference in Boost guidelines
Replace app(abstract:) with resolve() (positional arg) — resolve()
names its first parameter $name, not $abstract, causing silent
failures in challenge evaluation via catch(Throwable).

Also: throw_unless/throw_if helpers, type hints, method reordering,
instanceof check in leaderboard filtering.
Match source changes: resolve() positional args, unqualified class
references, explicit query builder calls.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cjmellor cjmellor changed the title 2.x feat: level-up v2.0.0 — tiers, challenges, and Laravel 12+ modernization Apr 2, 2026
@cjmellor cjmellor changed the title feat: level-up v2.0.0 — tiers, challenges, and Laravel 12+ modernization 2.x Apr 2, 2026
@cjmellor cjmellor changed the title 2.x v2.0 Apr 2, 2026
cjmellor and others added 17 commits April 2, 2026 21:09
…factor

# Conflicts:
#	config/level-up.php
#	src/Concerns/GiveExperience.php
#	src/Models/Level.php
#	src/Services/MultiplierService.php
#	tests/Models/LevelTestWithCustomModel.php
…actor

# Conflicts:
#	.gitignore
#	UPGRADE.md
#	config/level-up.php
#	src/Concerns/GiveExperience.php
#	src/LevelUpServiceProvider.php
#	tests/Models/LevelTestWithCustomModel.php
#	tests/TestCase.php
…strategy

Adds tests for scopeTo(), tiers(), users() relationships, starts_at/expires_at
validation, and unknown stacking strategy error path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
scopeTo() now uses firstOrCreate instead of create, so calling it
twice with the same model no longer throws a unique constraint error.

Also adds a test verifying a multiplier scoped to both a user and
their tier is only applied once.
Replace app() with direct instantiation when resolving the Tier
morph class. Avoids container overhead on every addPoints() call.
- Cache single now() call in active() scope instead of creating
  three separate Carbon instances per query
- Remove readonly from MultiplierApplied event properties to match
  the convention used by all other events in the package
Extract resolveMultipliers() and buildMultiplierAuditData() from
addPoints() and dispatchEvent() for clearer separation of concerns.
Rename nested closure params in forUser() scope from $q to descriptive
names ($outer, $scopeQuery, $match, $tierMatch).
refactor: replace class-based multipliers with DB-backed system
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The #[Scope] attribute was added in v12.4.0 and void-returning scopes
were fixed in v12.6.0, causing prefer-lowest CI to fail on v12.1.1.
@cjmellor cjmellor marked this pull request as ready for review April 3, 2026 14:03
@cjmellor cjmellor merged commit 3683170 into main Apr 3, 2026
9 checks passed
@cjmellor cjmellor deleted the 2.x branch April 3, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants