diff --git a/assets/styles/components/_badge.scss b/assets/styles/components/_badge.scss index 0b158e18..f573ee79 100644 --- a/assets/styles/components/_badge.scss +++ b/assets/styles/components/_badge.scss @@ -26,3 +26,111 @@ box-shadow: 0 .5rem 1.125rem -.275rem rgba($black, .25); } } + + +// Achievement badge medallions +// -------------------------------------------------- + +$badge-tier-bronze: #cd7f32; +$badge-tier-silver: #c0c0c0; +$badge-tier-gold: #ffd700; +$badge-tier-platinum: #e5e4e2; +$badge-tier-diamond: #b9f2ff; +$badge-tier-supporter: $primary; + +.badge-medallion { + width: 72px; + height: 72px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(40, 40, 40, .95); + font-weight: 700; + font-size: 1.25rem; + box-shadow: 0 4px 14px -2px rgba(0, 0, 0, .25), inset 0 -4px 6px rgba(0, 0, 0, .12); + border: 3px solid rgba(255, 255, 255, .7); + transition: transform .2s ease, box-shadow .2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px -2px rgba(0, 0, 0, .3), inset 0 -4px 6px rgba(0, 0, 0, .12); + } +} + +.badge-medallion-sm { + width: 48px; + height: 48px; + font-size: .95rem; +} + +.badge-medallion-tier { + font-family: Georgia, serif; + letter-spacing: .02em; + text-shadow: 0 1px 0 rgba(255, 255, 255, .45); +} + +.badge-tier-bronze { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-bronze, 18%), $badge-tier-bronze 70%); + color: #3a1d00; +} + +.badge-tier-silver { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-silver, 12%), $badge-tier-silver 70%); + color: #2a2a2a; +} + +.badge-tier-gold { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-gold, 10%), $badge-tier-gold 70%); + color: #3f2d00; +} + +.badge-tier-platinum { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-platinum, 4%), $badge-tier-platinum 70%); + color: #2a2a2a; +} + +.badge-tier-diamond { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-diamond, 4%), $badge-tier-diamond 70%); + color: #004854; +} + +.badge-tier-supporter { + background: radial-gradient(circle at 35% 30%, lighten($badge-tier-supporter, 15%), $badge-tier-supporter 70%); + color: #fff; +} + +.badge-locked { + .badge-medallion { + filter: grayscale(100%); + opacity: .45; + box-shadow: none; + + &:hover { + transform: none; + } + } +} + +.badge-new-pill { + position: absolute; + top: -6px; + right: -8px; + font-size: .65rem; + padding: .2em .45em; + box-shadow: 0 2px 6px rgba(0, 0, 0, .25); + animation: badge-new-pulse 1.6s ease-in-out infinite; +} + +@keyframes badge-new-pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.08); + } +} + +.badge-earned { + min-width: 90px; +} diff --git a/config/packages/messenger.php b/config/packages/messenger.php index 8161bac3..89dfcbc3 100644 --- a/config/packages/messenger.php +++ b/config/packages/messenger.php @@ -5,7 +5,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use SpeedPuzzling\Web\Message\PrepareDigestEmailForPlayer; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Message\RecalculateDerivedMetricsForPuzzle; +use SpeedPuzzling\Web\Message\SendBadgeNotificationEmail; use Symfony\Component\Mailer\Messenger\SendEmailMessage; return App::config([ @@ -35,6 +37,8 @@ SendEmailMessage::class => 'async', PrepareDigestEmailForPlayer::class => 'async', RecalculateDerivedMetricsForPuzzle::class => 'async', + RecalculateBadgesForPlayer::class => 'async', + SendBadgeNotificationEmail::class => 'async', // Events that must run synchronously for immediate UI updates (Turbo Streams) 'SpeedPuzzling\Web\Events\PuzzleBorrowed' => 'sync', 'SpeedPuzzling\Web\Events\PuzzleAddedToCollection' => 'sync', diff --git a/config/services.php b/config/services.php index 08137893..0cee1b44 100644 --- a/config/services.php +++ b/config/services.php @@ -87,6 +87,7 @@ // Services $services->load('SpeedPuzzling\\Web\\Services\\', __DIR__ . '/../src/Services/**/{*.php}'); $services->load('SpeedPuzzling\\Web\\Query\\', __DIR__ . '/../src/Query/**/{*.php}'); + $services->load('SpeedPuzzling\\Web\\BadgeConditions\\', __DIR__ . '/../src/BadgeConditions/**/{*.php}'); $services->load('SpeedPuzzling\\Web\\Security\\', __DIR__ . '/../src/Security/**/{*.php}') ->exclude([ __DIR__ . '/../src/Security/OAuth2User.php', diff --git a/docs/features/badges.md b/docs/features/badges.md new file mode 100644 index 00000000..cbc00d32 --- /dev/null +++ b/docs/features/badges.md @@ -0,0 +1,144 @@ +# Badges / Achievements System + +Fully dynamic, tiered badge system. Once earned, a badge is permanent — the logic can only award new badges, never revoke them. + +## Launch badges + +| Badge | Type value | Tiers (Bronze → Diamond) | Source metric | +|---|---|---|---| +| Puzzle Explorer | `puzzles_solved` | 10 / 100 / 500 / 1000 / 2000 | Distinct puzzles solved (counts team participation) | +| Piece Cruncher | `pieces_solved` | 10k / 100k / 500k / 1M / 2M pieces | Total pieces placed (full credit per team participant) | +| Speed Demon (500pc) | `speed_500_pieces` | < 5h / 2h / 1h / 45m / 30m | Player's fastest SOLO 500-piece solve | +| On Fire | `streak` | 7 / 30 / 90 / 180 / 365 days | All-time longest consecutive-day solving streak | +| Team Spirit | `team_player` | 1 / 5 / 25 / 100 / 500 | Count of duo + team solves the player participated in | +| Supporter (single-tier) | `supporter` | — | Admin-granted, no automation | + +## Visibility rules + +- A player's badges appear on their public profile **only if the profile owner has an active membership**. Non-members have no badges section rendered. +- Badges are evaluated for *all* players regardless of membership — records accumulate silently. They become visible the moment the player's membership activates. +- The `/en/badges` catalog page is public. Logged-in users see per-badge progress bars toward the next tier; logged-out users see the catalog without progress. + +## Architecture + +### Plug-in contract + +``` +SpeedPuzzling\Web\BadgeConditions\BadgeConditionInterface + badgeType(): BadgeType + qualifiedTiers(PlayerStatsSnapshot): list + progressToNextTier(PlayerStatsSnapshot, ?BadgeTier $highestEarned): ?BadgeProgress + requirementForTier(BadgeTier): int +``` + +Implementations are auto-tagged via `#[AutoconfigureTag('badge.condition')]` on the interface. The evaluator and catalog query consume them as `iterable`. + +Most badges share an ascending-threshold structure; they extend `AbstractAscendingThresholdCondition` and only declare `thresholds()` + `currentValue()`. The Speed 500pc badge implements the interface directly because lower seconds = higher tier. + +### Data flow + +#### Live (per-user action) + +``` +User adds/edits/deletes a PuzzleSolvingTime + → Add/Edit/DeletePuzzleSolvingTimeHandler + → dispatch RecalculateBadgesForPlayer($playerId) → ASYNC messenger transport + → RecalculateBadgesForPlayerHandler + → BadgeEvaluator.recalculateForPlayer() + → GetPlayerStatsSnapshot (4–5 small SQLs) + → GetBadges (1 SQL) + → for each condition: compute qualifiedTiers and persist gaps + → if new badges were earned, send ONE TemplatedEmail (highest tier per type) +``` + +#### Backfill / cron + +``` +bin/console myspeedpuzzling:recalculate-badges [--backfill] + → GetAllPlayerIdsWithSolveTimes.execute() + → for each player (index i): + dispatch RecalculateBadgesForPlayer($id) [+ DelayStamp(i * 2000 ms) when --backfill] +``` + +- `--player=UUID` — single player, no stagger. +- `--backfill` — 2-second stagger between players via `DelayStamp` so outbound email volume is spread out smoothly. +- No flag — immediate dispatch for every player; fitting for a 15-minute cron. + +### Data model + +Table `badge` (existing since `Version20240408184034`, extended by `Version20260416210601`): + +| Column | Type | Notes | +|---|---|---| +| id | UUID | PK | +| player_id | UUID | FK → player.id | +| type | VARCHAR | `BadgeType` enum value | +| earned_at | TIMESTAMP | Immutable | +| tier | SMALLINT null | `BadgeTier` enum value (1 Bronze → 5 Diamond). NULL for single-tier badges (Supporter) | + +Two partial unique indexes (created manually in the migration with `custom_` prefix so Doctrine does not manage them): + +- `UNIQUE (player_id, type, tier) WHERE tier IS NOT NULL` — tiered badges +- `UNIQUE (player_id, type) WHERE tier IS NULL` — single-tier badges + +Both indexes are mirrored in `tests/bootstrap.php`. + +### Gap-filling + +When a player first qualifies for, say, tier 3 of a badge without having earned tiers 1 or 2 previously (typical for backfill), the evaluator persists all three rows with the same `earnedAt` timestamp. The email, however, mentions only the highest tier per badge type in that recalc pass. + +### Performance + +- Per-player recalc runs a fixed 4–5 SQLs regardless of history size. +- Backfill fans out via Messenger — natural parallelism at the worker level, crash isolation. +- `DelayStamp(i * 2000ms)` during backfill spreads email dispatches across ~67 minutes for 2000 players. Tune `BACKFILL_DELAY_MS` in `RecalculateBadgesConsoleCommand` if cohort grows. + +## Adding a new badge + +1. Add a case to `src/Value/BadgeType.php`, e.g. `case NightOwl = 'night_owl';`. +2. Create `src/BadgeConditions/NightOwlCondition.php`: + + ```php + readonly final class NightOwlCondition extends AbstractAscendingThresholdCondition + { + public function badgeType(): BadgeType { return BadgeType::NightOwl; } + + protected function currentValue(PlayerStatsSnapshot $snapshot): int + { + return $snapshot->nightOwlSolves; + } + + protected function thresholds(): array + { + return [1 => 10, 2 => 50, 3 => 200, 4 => 500, 5 => 1000]; + } + } + ``` + +3. Add the metric to `PlayerStatsSnapshot` + load it in `GetPlayerStatsSnapshot`. +4. Add translation keys under `badges.badge.night_owl`, `badges.description.night_owl`, and `badges.requirement.night_owl_{1..5}` in `translations/messages.en.yml`. +5. (Optional) Drop `public/img/badges/night_owl_1.png` through `_5.png` when art is ready. Template falls back to a tier-colored medallion otherwise. + +No other code changes needed. + +## Email template + +`templates/emails/badges_earned.html.twig` — Inky-based, uses the shared `_header.html.twig` / `_footer.html.twig`. Subject via `emails.en.yml › badges_earned.subject`. Header `X-Transport: transactional` so it hits the transactional mail transport (not the bulk notifications transport). + +## Opt-out + +Not currently offered. If player frustration over email volume surfaces, add a `badgesOptedOut` boolean on `Player` (following the existing `streakOptedOut` / `rankingOptedOut` pattern) and short-circuit the mailer call at the top of the handler. + +## Cron + +Schedule the same way as the puzzle-intelligence recalc (every 15 minutes): + +``` +*/15 * * * * docker compose exec web php bin/console myspeedpuzzling:recalculate-badges +``` + +On first deploy, seed existing players with: + +``` +docker compose exec web php bin/console myspeedpuzzling:recalculate-badges --backfill +``` diff --git a/migrations/Version20260416210601.php b/migrations/Version20260416210601.php new file mode 100644 index 00000000..2fb22cb5 --- /dev/null +++ b/migrations/Version20260416210601.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE badge ADD tier SMALLINT DEFAULT NULL'); + + // Partial unique indexes — one row per (player, type, tier) for tiered badges, + // one row per (player, type) for single-tier badges like Supporter. Prefixed `custom_` + // so CustomIndexFilteringSchemaManagerFactory skips them on introspection. + $this->addSql('CREATE UNIQUE INDEX custom_badge_unique_tiered ON badge (player_id, type, tier) WHERE tier IS NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX custom_badge_unique_single_tier ON badge (player_id, type) WHERE tier IS NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IF EXISTS custom_badge_unique_tiered'); + $this->addSql('DROP INDEX IF EXISTS custom_badge_unique_single_tier'); + $this->addSql('ALTER TABLE badge DROP tier'); + } +} diff --git a/src/BadgeConditions/AbstractAscendingThresholdCondition.php b/src/BadgeConditions/AbstractAscendingThresholdCondition.php new file mode 100644 index 00000000..7ce5a6b8 --- /dev/null +++ b/src/BadgeConditions/AbstractAscendingThresholdCondition.php @@ -0,0 +1,65 @@ += threshold`. + */ +abstract readonly class AbstractAscendingThresholdCondition implements BadgeConditionInterface +{ + abstract protected function currentValue(PlayerStatsSnapshot $snapshot): int; + + /** + * @return array{1: int, 2: int, 3: int, 4: int, 5: int} + */ + abstract protected function thresholds(): array; + + public function qualifiedTiers(PlayerStatsSnapshot $snapshot): array + { + $current = $this->currentValue($snapshot); + $qualified = []; + + foreach ($this->thresholds() as $tierValue => $threshold) { + if ($current >= $threshold) { + $qualified[] = BadgeTier::from($tierValue); + } + } + + return $qualified; + } + + public function progressToNextTier(PlayerStatsSnapshot $snapshot, null|BadgeTier $highestEarned): null|BadgeProgress + { + $nextTierValue = $highestEarned === null ? 1 : $highestEarned->value + 1; + + if ($nextTierValue > 5) { + return null; + } + + $nextTier = BadgeTier::from($nextTierValue); + $target = $this->thresholds()[$nextTierValue]; + $current = $this->currentValue($snapshot); + $percent = $target > 0 ? (int) floor(min($current, $target) / $target * 100) : 0; + + return new BadgeProgress( + nextTier: $nextTier, + currentValue: $current, + targetValue: $target, + percent: $percent, + ); + } + + public function requirementForTier(BadgeTier $tier): int + { + return $this->thresholds()[$tier->value]; + } +} diff --git a/src/BadgeConditions/BadgeConditionInterface.php b/src/BadgeConditions/BadgeConditionInterface.php new file mode 100644 index 00000000..e85ae9dc --- /dev/null +++ b/src/BadgeConditions/BadgeConditionInterface.php @@ -0,0 +1,36 @@ + + */ + public function qualifiedTiers(PlayerStatsSnapshot $snapshot): array; + + /** + * Progress toward the lowest unearned tier. Returns null when the highest tier is already earned, + * or when the player has no data yet (e.g. never completed a solo 500pc puzzle for the Speed badge). + */ + public function progressToNextTier(PlayerStatsSnapshot $snapshot, null|BadgeTier $highestEarned): null|BadgeProgress; + + /** + * Numeric requirement for a given tier, for display on the catalog page. + * For tiered "speed" badges this is the MAX seconds allowed; for count-based it's the minimum count. + */ + public function requirementForTier(BadgeTier $tier): int; +} diff --git a/src/BadgeConditions/PiecesSolvedCondition.php b/src/BadgeConditions/PiecesSolvedCondition.php new file mode 100644 index 00000000..30f527db --- /dev/null +++ b/src/BadgeConditions/PiecesSolvedCondition.php @@ -0,0 +1,32 @@ +totalPiecesSolved; + } + + protected function thresholds(): array + { + return [ + 1 => 10_000, + 2 => 100_000, + 3 => 500_000, + 4 => 1_000_000, + 5 => 2_000_000, + ]; + } +} diff --git a/src/BadgeConditions/PuzzlesSolvedCondition.php b/src/BadgeConditions/PuzzlesSolvedCondition.php new file mode 100644 index 00000000..cb71fb8e --- /dev/null +++ b/src/BadgeConditions/PuzzlesSolvedCondition.php @@ -0,0 +1,32 @@ +distinctPuzzlesSolved; + } + + protected function thresholds(): array + { + return [ + 1 => 10, + 2 => 100, + 3 => 500, + 4 => 1000, + 5 => 2000, + ]; + } +} diff --git a/src/BadgeConditions/Speed500PiecesCondition.php b/src/BadgeConditions/Speed500PiecesCondition.php new file mode 100644 index 00000000..36f3f98b --- /dev/null +++ b/src/BadgeConditions/Speed500PiecesCondition.php @@ -0,0 +1,85 @@ + 18_000, + 2 => 7_200, + 3 => 3_600, + 4 => 2_700, + 5 => 1_800, + ]; + + public function badgeType(): BadgeType + { + return BadgeType::Speed500Pieces; + } + + public function qualifiedTiers(PlayerStatsSnapshot $snapshot): array + { + $best = $snapshot->best500PieceSoloSeconds; + + if ($best === null) { + return []; + } + + $qualified = []; + + foreach (self::SECONDS_THRESHOLDS as $tierValue => $maxSeconds) { + if ($best <= $maxSeconds) { + $qualified[] = BadgeTier::from($tierValue); + } + } + + return $qualified; + } + + public function progressToNextTier(PlayerStatsSnapshot $snapshot, null|BadgeTier $highestEarned): null|BadgeProgress + { + $nextTierValue = $highestEarned === null ? 1 : $highestEarned->value + 1; + + if ($nextTierValue > 5) { + return null; + } + + $best = $snapshot->best500PieceSoloSeconds; + + if ($best === null) { + return null; + } + + $nextTier = BadgeTier::from($nextTierValue); + $target = self::SECONDS_THRESHOLDS[$nextTierValue]; + + // How close the player is to the next tier's time limit. Bar fills as best time shrinks toward target. + $percent = $best > 0 ? (int) floor(min($target / $best, 1.0) * 100) : 100; + + return new BadgeProgress( + nextTier: $nextTier, + currentValue: $best, + targetValue: $target, + percent: $percent, + ); + } + + public function requirementForTier(BadgeTier $tier): int + { + return self::SECONDS_THRESHOLDS[$tier->value]; + } +} diff --git a/src/BadgeConditions/StreakCondition.php b/src/BadgeConditions/StreakCondition.php new file mode 100644 index 00000000..871c3435 --- /dev/null +++ b/src/BadgeConditions/StreakCondition.php @@ -0,0 +1,32 @@ +allTimeLongestStreakDays; + } + + protected function thresholds(): array + { + return [ + 1 => 7, + 2 => 30, + 3 => 90, + 4 => 180, + 5 => 365, + ]; + } +} diff --git a/src/BadgeConditions/TeamPlayerCondition.php b/src/BadgeConditions/TeamPlayerCondition.php new file mode 100644 index 00000000..2d1d774b --- /dev/null +++ b/src/BadgeConditions/TeamPlayerCondition.php @@ -0,0 +1,32 @@ +teamSolvesCount; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 5, + 3 => 25, + 4 => 100, + 5 => 500, + ]; + } +} diff --git a/src/Component/BadgesProfileSection.php b/src/Component/BadgesProfileSection.php new file mode 100644 index 00000000..1c6073b7 --- /dev/null +++ b/src/Component/BadgesProfileSection.php @@ -0,0 +1,46 @@ + */ + public array $badges = []; + + public function __construct( + readonly private GetBadges $getBadges, + readonly private ClockInterface $clock, + ) { + } + + #[PostMount] + public function loadBadges(): void + { + if ($this->playerId === null) { + return; + } + + $this->badges = $this->getBadges->forPlayer($this->playerId); + } + + /** + * Badge is considered "new" when earned within the last 7 days — highlighted in the UI. + */ + public function isNew(BadgeResult $badge): bool + { + $cutoff = $this->clock->now()->modify('-7 days'); + + return $badge->earnedAt > $cutoff; + } +} diff --git a/src/ConsoleCommands/RecalculateBadgesConsoleCommand.php b/src/ConsoleCommands/RecalculateBadgesConsoleCommand.php new file mode 100644 index 00000000..a8fdabea --- /dev/null +++ b/src/ConsoleCommands/RecalculateBadgesConsoleCommand.php @@ -0,0 +1,95 @@ +addOption( + 'player', + null, + InputOption::VALUE_REQUIRED, + 'Recalculate only for this single player UUID.', + ) + ->addOption( + 'backfill', + null, + InputOption::VALUE_NONE, + 'Stagger dispatches with DelayStamp to spread out email delivery (initial seed runs).', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $specificPlayer = $input->getOption('player'); + $backfill = (bool) $input->getOption('backfill'); + + if ($specificPlayer !== null) { + if (!is_string($specificPlayer)) { + $io->error('Invalid --player value.'); + return self::INVALID; + } + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($specificPlayer)); + $io->success("Dispatched badge recalculation for player {$specificPlayer}."); + + return self::SUCCESS; + } + + $playerIds = $this->getAllPlayerIds->execute(); + + if ($playerIds === []) { + $io->success('No players with solve times found — nothing to dispatch.'); + return self::SUCCESS; + } + + foreach ($playerIds as $index => $playerId) { + $stamps = []; + if ($backfill) { + $stamps[] = new DelayStamp($index * self::BACKFILL_DELAY_MS); + } + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($playerId), $stamps); + } + + $count = count($playerIds); + $mode = $backfill ? 'backfill (staggered)' : 'immediate'; + $io->success("Dispatched {$count} badge recalculation message(s) — {$mode}."); + + return self::SUCCESS; + } +} diff --git a/src/Controller/BadgesOverviewController.php b/src/Controller/BadgesOverviewController.php new file mode 100644 index 00000000..a345e32f --- /dev/null +++ b/src/Controller/BadgesOverviewController.php @@ -0,0 +1,44 @@ + '/odznaky', + 'en' => '/en/badges', + 'es' => '/es/insignias', + 'ja' => '/ja/バッジ', + 'fr' => '/fr/badges', + 'de' => '/de/abzeichen', + ], + name: 'badges_overview', + )] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + $playerId = $profile?->playerId; + + $catalog = $this->getBadgeCatalog->forPlayer($playerId); + + return $this->render('badges_overview.html.twig', [ + 'catalog' => $catalog, + 'logged_in' => $playerId !== null, + ]); + } +} diff --git a/src/Controller/PlayerProfileController.php b/src/Controller/PlayerProfileController.php index 1ef39288..5b69af4b 100644 --- a/src/Controller/PlayerProfileController.php +++ b/src/Controller/PlayerProfileController.php @@ -6,7 +6,6 @@ use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Query\GetAffiliateSupporters; -use SpeedPuzzling\Web\Query\GetBadges; use SpeedPuzzling\Web\Query\GetFavoritePlayers; use SpeedPuzzling\Web\Query\GetPlayerProfile; use SpeedPuzzling\Web\Query\GetRanking; @@ -28,7 +27,6 @@ public function __construct( readonly private GetFavoritePlayers $getFavoritePlayers, readonly private TranslatorInterface $translator, readonly private GetTags $getTags, - readonly private GetBadges $getBadges, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, readonly private HasExistingConversation $hasExistingConversation, readonly private GetAffiliateSupporters $getAffiliateSupporters, @@ -73,7 +71,6 @@ public function __invoke(string $playerId, #[CurrentUser] null|UserInterface $us 'ranking' => $this->getRanking->allForPlayer($player->playerId), 'favorite_players' => $this->getFavoritePlayers->forPlayerId($player->playerId), 'tags' => $this->getTags->allGroupedPerPuzzle(), - 'badges' => $this->getBadges->forPlayer($player->playerId), 'can_message' => $canMessage, 'affiliate_supporters' => $affiliateSupporters, ]); diff --git a/src/Entity/Badge.php b/src/Entity/Badge.php index c3b3edf4..78e90d8a 100644 --- a/src/Entity/Badge.php +++ b/src/Entity/Badge.php @@ -13,7 +13,9 @@ use Doctrine\ORM\Mapping\ManyToOne; use JetBrains\PhpStorm\Immutable; use Ramsey\Uuid\Doctrine\UuidType; +use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use SpeedPuzzling\Web\Value\BadgeTier; use SpeedPuzzling\Web\Value\BadgeType; #[Entity] @@ -33,6 +35,24 @@ public function __construct( public BadgeType $type, #[Column(type: Types::DATETIME_IMMUTABLE)] public DateTimeImmutable $earnedAt, + #[Column(type: Types::SMALLINT, nullable: true)] + #[Immutable] + public null|int $tier = null, ) { } + + public static function earn( + Player $player, + BadgeType $type, + DateTimeImmutable $earnedAt, + null|BadgeTier $tier, + ): self { + return new self( + id: Uuid::uuid7(), + player: $player, + type: $type, + earnedAt: $earnedAt, + tier: $tier?->value, + ); + } } diff --git a/src/Message/RecalculateBadgesForPlayer.php b/src/Message/RecalculateBadgesForPlayer.php new file mode 100644 index 00000000..8dbaaaec --- /dev/null +++ b/src/Message/RecalculateBadgesForPlayer.php @@ -0,0 +1,13 @@ + $badgeSummary + */ + public function __construct( + public string $playerId, + public array $badgeSummary, + ) { + } +} diff --git a/src/MessageHandler/AddPuzzleSolvingTimeHandler.php b/src/MessageHandler/AddPuzzleSolvingTimeHandler.php index 768378cc..3587605a 100644 --- a/src/MessageHandler/AddPuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/AddPuzzleSolvingTimeHandler.php @@ -13,6 +13,7 @@ use SpeedPuzzling\Web\Exceptions\CouldNotGenerateUniqueCode; use SpeedPuzzling\Web\Exceptions\SuspiciousPpm; use SpeedPuzzling\Web\Message\AddPuzzleSolvingTime; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Repository\CompetitionRepository; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\PuzzleRepository; @@ -20,6 +21,7 @@ use SpeedPuzzling\Web\Services\PuzzlersGrouping; use SpeedPuzzling\Web\Value\SolvingTime; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class AddPuzzleSolvingTimeHandler @@ -33,6 +35,7 @@ public function __construct( private ClockInterface $clock, private CompetitionRepository $competitionRepository, private ImageOptimizer $imageOptimizer, + private MessageBusInterface $commandBus, ) { } @@ -105,5 +108,7 @@ public function __invoke(AddPuzzleSolvingTime $message): void ); $this->entityManager->persist($solvingTime); + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($player->id->toString())); } } diff --git a/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php b/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php index 0c7a62c5..40d90c2d 100644 --- a/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php @@ -8,9 +8,11 @@ use SpeedPuzzling\Web\Exceptions\CanNotModifyOtherPlayersTime; use SpeedPuzzling\Web\Exceptions\PuzzleSolvingTimeNotFound; use SpeedPuzzling\Web\Message\DeletePuzzleSolvingTime; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\PuzzleSolvingTimeRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class DeletePuzzleSolvingTimeHandler @@ -19,6 +21,7 @@ public function __construct( private EntityManagerInterface $entityManager, private PlayerRepository $playerRepository, private PuzzleSolvingTimeRepository $puzzleSolvingTimeRepository, + private MessageBusInterface $commandBus, ) { } @@ -35,6 +38,12 @@ public function __invoke(DeletePuzzleSolvingTime $message): void throw new CanNotModifyOtherPlayersTime(); } + $playerId = $currentPlayer->id->toString(); + $this->entityManager->remove($solvingTime); + + // Deletions can't revoke an earned badge (permanent), but re-eval covers any edge cases + // (e.g. an admin deleted a suspicious time that was counted toward a lower tier snapshot). + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($playerId)); } } diff --git a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php index ede6f0bc..ea1a3114 100644 --- a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php @@ -13,6 +13,7 @@ use SpeedPuzzling\Web\Exceptions\PuzzleSolvingTimeNotFound; use SpeedPuzzling\Web\Exceptions\SuspiciousPpm; use SpeedPuzzling\Web\Message\EditPuzzleSolvingTime; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Repository\CompetitionRepository; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\PuzzleSolvingTimeRepository; @@ -20,6 +21,7 @@ use SpeedPuzzling\Web\Services\PuzzlersGrouping; use SpeedPuzzling\Web\Value\SolvingTime; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class EditPuzzleSolvingTimeHandler @@ -32,6 +34,7 @@ public function __construct( private ClockInterface $clock, private CompetitionRepository $competitionRepository, private ImageOptimizer $imageOptimizer, + private MessageBusInterface $commandBus, ) { } @@ -108,5 +111,7 @@ public function __invoke(EditPuzzleSolvingTime $message): void $message->unboxed, competition: $competition, ); + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($currentPlayer->id->toString())); } } diff --git a/src/MessageHandler/RecalculateBadgesForPlayerHandler.php b/src/MessageHandler/RecalculateBadgesForPlayerHandler.php new file mode 100644 index 00000000..7fba561b --- /dev/null +++ b/src/MessageHandler/RecalculateBadgesForPlayerHandler.php @@ -0,0 +1,73 @@ +badgeEvaluator->recalculateForPlayer($message->playerId); + + if ($newBadges === []) { + return; + } + + $this->commandBus->dispatch(new SendBadgeNotificationEmail( + playerId: $message->playerId, + badgeSummary: $this->keepHighestTierPerType($newBadges), + )); + } + + /** + * @param list $badges + * @return list + */ + private function keepHighestTierPerType(array $badges): array + { + $bestByType = []; + + foreach ($badges as $badge) { + $tier = $badge->tier === null ? null : BadgeTier::from($badge->tier); + $typeKey = $badge->type->value; + $existing = $bestByType[$typeKey] ?? null; + + if ($existing === null) { + $bestByType[$typeKey] = [ + 'type' => $badge->type, + 'tier' => $tier, + ]; + continue; + } + + $existingTierValue = $existing['tier'] === null ? 0 : $existing['tier']->value; + $newTierValue = $tier === null ? 0 : $tier->value; + + if ($newTierValue > $existingTierValue) { + $bestByType[$typeKey] = [ + 'type' => $badge->type, + 'tier' => $tier, + ]; + } + } + + return array_values($bestByType); + } +} diff --git a/src/MessageHandler/SendBadgeNotificationEmailHandler.php b/src/MessageHandler/SendBadgeNotificationEmailHandler.php new file mode 100644 index 00000000..ffd5787a --- /dev/null +++ b/src/MessageHandler/SendBadgeNotificationEmailHandler.php @@ -0,0 +1,56 @@ +playerRepository->get($message->playerId); + } catch (PlayerNotFound) { + return; + } + + if ($player->email === null) { + return; + } + + $subject = $this->translator->trans( + 'badges_earned.subject', + domain: 'emails', + locale: $player->locale, + ); + + $email = (new TemplatedEmail()) + ->to($player->email) + ->locale($player->locale) + ->subject($subject) + ->htmlTemplate('emails/badges_earned.html.twig') + ->context([ + 'badges' => $message->badgeSummary, + 'locale' => $player->locale, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); + } +} diff --git a/src/Query/GetAllPlayerIdsWithSolveTimes.php b/src/Query/GetAllPlayerIdsWithSolveTimes.php new file mode 100644 index 00000000..46289a8e --- /dev/null +++ b/src/Query/GetAllPlayerIdsWithSolveTimes.php @@ -0,0 +1,47 @@ + + */ + public function execute(): array + { + // Covers both row-owners AND team-only participants (who appear in the JSON array + // but may never own a row as player_id). + $sql = <<> 'player_id')::uuid AS id + FROM puzzle_solving_time, + jsonb_array_elements(team::jsonb -> 'puzzlers') AS elem + WHERE suspicious = false + AND team IS NOT NULL + AND elem ->> 'player_id' IS NOT NULL +) sub +WHERE id IS NOT NULL +ORDER BY id +SQL; + + /** @var list $ids */ + $ids = $this->database->executeQuery($sql)->fetchFirstColumn(); + + return $ids; + } +} diff --git a/src/Query/GetBadgeCatalog.php b/src/Query/GetBadgeCatalog.php new file mode 100644 index 00000000..21694237 --- /dev/null +++ b/src/Query/GetBadgeCatalog.php @@ -0,0 +1,83 @@ + $conditions + */ + public function __construct( + #[AutowireIterator('badge.condition')] + private iterable $conditions, + private GetPlayerStatsSnapshot $getPlayerStatsSnapshot, + private GetBadges $getBadges, + ) { + } + + /** + * @return list + */ + public function forPlayer(null|string $playerId): array + { + $earnedMap = []; + $snapshot = null; + + if ($playerId !== null) { + $snapshot = $this->getPlayerStatsSnapshot->forPlayer($playerId); + + foreach ($this->getBadges->forPlayer($playerId) as $badge) { + if ($badge->tier === null) { + continue; + } + $earnedMap[$badge->type->value][$badge->tier->value] = $badge->earnedAt; + } + } + + $groups = []; + + foreach ($this->conditions as $condition) { + $type = $condition->badgeType(); + $typeEarned = $earnedMap[$type->value] ?? []; + $tiers = []; + $highestEarned = null; + + foreach (BadgeTier::cases() as $tier) { + $earned = isset($typeEarned[$tier->value]); + if ($earned && ($highestEarned === null || $tier->value > $highestEarned->value)) { + $highestEarned = $tier; + } + + $tiers[] = new BadgeCatalogEntry( + type: $type, + tier: $tier, + requirementTranslationKey: 'badges.requirement.' . $type->value . '_' . $tier->value, + earned: $earned, + earnedAt: $typeEarned[$tier->value] ?? null, + ); + } + + $progress = null; + if ($snapshot instanceof PlayerStatsSnapshot) { + $progress = $condition->progressToNextTier($snapshot, $highestEarned); + } + + $groups[] = new BadgeCatalogGroup( + type: $type, + tiers: $tiers, + progressToNext: $progress, + ); + } + + return $groups; + } +} diff --git a/src/Query/GetBadges.php b/src/Query/GetBadges.php index 17c8f5ec..9a748d89 100644 --- a/src/Query/GetBadges.php +++ b/src/Query/GetBadges.php @@ -4,10 +4,13 @@ namespace SpeedPuzzling\Web\Query; +use DateTimeImmutable; use Doctrine\DBAL\Connection; +use SpeedPuzzling\Web\Results\BadgeResult; +use SpeedPuzzling\Web\Value\BadgeTier; use SpeedPuzzling\Web\Value\BadgeType; -readonly final class GetBadges +readonly class GetBadges { public function __construct( private Connection $database, @@ -15,29 +18,30 @@ public function __construct( } /** - * @return array + * @return list */ public function forPlayer(string $playerId): array { - $query = <<database - ->executeQuery($query, [ - 'playerId' => $playerId, - ]) + /** @var list $rows */ + $rows = $this->database + ->executeQuery($sql, ['playerId' => $playerId]) ->fetchAllAssociative(); - return array_map(static function (array $row): BadgeType { - /** @var array{ - * type: string, - * } $row - */ + return array_map(static function (array $row): BadgeResult { + $tier = $row['tier'] === null ? null : BadgeTier::from((int) $row['tier']); - return BadgeType::from($row['type']); - }, $data); + return new BadgeResult( + type: BadgeType::from($row['type']), + tier: $tier, + earnedAt: new DateTimeImmutable($row['earned_at']), + ); + }, $rows); } } diff --git a/src/Query/GetPlayerStatsSnapshot.php b/src/Query/GetPlayerStatsSnapshot.php new file mode 100644 index 00000000..375ae50e --- /dev/null +++ b/src/Query/GetPlayerStatsSnapshot.php @@ -0,0 +1,127 @@ +distinctPuzzlesSolved($playerId), + totalPiecesSolved: $this->totalPiecesSolved($playerId), + best500PieceSoloSeconds: $this->best500PieceSoloSeconds($playerId), + allTimeLongestStreakDays: $this->allTimeLongestStreakDays($playerId), + teamSolvesCount: $this->teamSolvesCount($playerId), + ); + } + + private function distinctPuzzlesSolved(string $playerId): int + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +SQL; + + $value = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + private function totalPiecesSolved(string $playerId): int + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +SQL; + + $value = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + private function best500PieceSoloSeconds(string $playerId): null|int + { + $sql = <<database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : null; + } + + private function allTimeLongestStreakDays(string $playerId): int + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +ORDER BY solve_day +SQL; + + /** @var list $rows */ + $rows = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchAllAssociative(); + + $activeDays = array_column($rows, 'solve_day'); + + return $this->streakCalculator->calculate($activeDays)->longest; + } + + private function teamSolvesCount(string $playerId): int + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +SQL; + + $value = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Repository/BadgeRepository.php b/src/Repository/BadgeRepository.php new file mode 100644 index 00000000..3d2159c8 --- /dev/null +++ b/src/Repository/BadgeRepository.php @@ -0,0 +1,21 @@ +entityManager->persist($badge); + } +} diff --git a/src/Results/BadgeCatalogEntry.php b/src/Results/BadgeCatalogEntry.php new file mode 100644 index 00000000..697972a6 --- /dev/null +++ b/src/Results/BadgeCatalogEntry.php @@ -0,0 +1,21 @@ + $tiers + */ + public function __construct( + public BadgeType $type, + public array $tiers, + public null|BadgeProgress $progressToNext, + ) { + } + + public function earnedCount(): int + { + return count(array_filter($this->tiers, static fn (BadgeCatalogEntry $entry): bool => $entry->earned)); + } + + public function hasAnyEarned(): bool + { + foreach ($this->tiers as $entry) { + if ($entry->earned) { + return true; + } + } + + return false; + } +} diff --git a/src/Results/BadgeProgress.php b/src/Results/BadgeProgress.php new file mode 100644 index 00000000..39dd33af --- /dev/null +++ b/src/Results/BadgeProgress.php @@ -0,0 +1,18 @@ + $conditions + */ + public function __construct( + #[AutowireIterator('badge.condition')] + private iterable $conditions, + private GetPlayerStatsSnapshot $getPlayerStatsSnapshot, + private GetBadges $getBadges, + private BadgeRepository $badgeRepository, + private PlayerRepository $playerRepository, + private ClockInterface $clock, + ) { + } + + /** + * Returns badges newly persisted for this run. Each entry is a freshly constructed Badge + * (not yet flushed — the Messenger doctrine_transaction middleware commits the transaction). + * + * @return list + */ + public function recalculateForPlayer(string $playerId): array + { + try { + $player = $this->playerRepository->get($playerId); + } catch (PlayerNotFound) { + return []; + } + + $snapshot = $this->getPlayerStatsSnapshot->forPlayer($playerId); + $alreadyEarned = $this->earnedTierMap($this->getBadges->forPlayer($playerId)); + $now = $this->clock->now(); + $newBadges = []; + + foreach ($this->conditions as $condition) { + $type = $condition->badgeType(); + + foreach ($condition->qualifiedTiers($snapshot) as $tier) { + if (isset($alreadyEarned[$type->value][$tier->value])) { + continue; + } + + $badge = Badge::earn($player, $type, $now, $tier); + $this->badgeRepository->save($badge); + $alreadyEarned[$type->value][$tier->value] = true; + $newBadges[] = $badge; + } + } + + return $newBadges; + } + + /** + * @param list $badges + * @return array> + */ + private function earnedTierMap(array $badges): array + { + $map = []; + + foreach ($badges as $badge) { + if ($badge->tier === null) { + continue; + } + + $map[$badge->type->value][$badge->tier->value] = true; + } + + return $map; + } +} diff --git a/src/Value/BadgeTier.php b/src/Value/BadgeTier.php new file mode 100644 index 00000000..872c71b2 --- /dev/null +++ b/src/Value/BadgeTier.php @@ -0,0 +1,35 @@ + 'I', + self::Silver => 'II', + self::Gold => 'III', + self::Platinum => 'IV', + self::Diamond => 'V', + }; + } + + public function cssClass(): string + { + return 'badge-tier-' . strtolower($this->name); + } + + public function translationKey(): string + { + return 'badges.tier.' . strtolower($this->name); + } +} diff --git a/src/Value/BadgeType.php b/src/Value/BadgeType.php index 4f45d16f..05f09a89 100644 --- a/src/Value/BadgeType.php +++ b/src/Value/BadgeType.php @@ -7,4 +7,19 @@ enum BadgeType: string { case Supporter = 'supporter'; + case PuzzlesSolved = 'puzzles_solved'; + case PiecesSolved = 'pieces_solved'; + case Speed500Pieces = 'speed_500_pieces'; + case Streak = 'streak'; + case TeamPlayer = 'team_player'; + + public function isTiered(): bool + { + return $this !== self::Supporter; + } + + public function translationKey(): string + { + return 'badges.badge.' . $this->value; + } } diff --git a/templates/badges_overview.html.twig b/templates/badges_overview.html.twig new file mode 100644 index 00000000..c2baf5e2 --- /dev/null +++ b/templates/badges_overview.html.twig @@ -0,0 +1,87 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'badges.overview_title'|trans }}{% endblock %} + +{% block meta_description %}{{ 'badges.overview_subtitle'|trans }}{% endblock %} + +{% block content %} +
+

+ + {{ 'badges.overview_title'|trans }} +

+

{{ 'badges.overview_subtitle'|trans }}

+ + {% if logged_in == false %} +

+ + {{ 'badges.login_to_track'|trans }} +

+ {% endif %} +
+ +
+ {% for group in catalog %} +
+
+
+
+
+

{{ (group.type.translationKey())|trans }}

+

+ {{ ('badges.description.' ~ group.type.value)|trans }} +

+
+ + {% set earned_count = group.earnedCount() %} + + {{ earned_count }} / {{ group.tiers|length }} + +
+ +
+ {% for entry in group.tiers %} +
+
+ {{ entry.tier.romanNumeral() }} +
+
+ {{ (entry.tier.translationKey())|trans }} +
+
+ {% endfor %} +
+ + {% if logged_in and group.progressToNext is not null %} + {% set progress = group.progressToNext %} +
+
+ {{ ('badges.requirement.' ~ group.type.value ~ '_' ~ progress.nextTier.value)|trans }} + {{ progress.percent }}% +
+
+
+
+
+ {% elseif logged_in and group.progressToNext is null and group.hasAnyEarned() %} +

+ + {{ 'badges.highest_earned'|trans }} +

+ {% elseif logged_in %} +

+ + {{ 'badges.not_earned_yet'|trans }} — {{ 'badges.keep_going'|trans }} +

+ {% endif %} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/components/BadgesProfileSection.html.twig b/templates/components/BadgesProfileSection.html.twig new file mode 100644 index 00000000..12c3cc8d --- /dev/null +++ b/templates/components/BadgesProfileSection.html.twig @@ -0,0 +1,44 @@ +
+

+ + {{ 'badges.title'|trans }} + {{ badges|length }} +

+ +
+ {% for badge in badges %} + {% set tier = badge.tier %} + {% set tier_class = tier ? tier.cssClass() : 'badge-tier-supporter' %} + {% set label = (badge.type.translationKey())|trans %} + {% set tier_suffix = tier ? ' ' ~ (tier.translationKey())|trans : '' %} + +
+
+
+ {% if tier %} + {{ tier.romanNumeral() }} + {% else %} + + {% endif %} +
+ + {% if this.isNew(badge) %} + {{ 'badges.new'|trans }} + {% endif %} +
+ +
+ {{ label }} +
+ {% if tier %} +
{{ (tier.translationKey())|trans }}
+ {% endif %} +
+ {% endfor %} +
+ + + + {{ 'badges.browse_all'|trans }} + +
diff --git a/templates/emails/badges_earned.html.twig b/templates/emails/badges_earned.html.twig new file mode 100644 index 00000000..97443d5e --- /dev/null +++ b/templates/emails/badges_earned.html.twig @@ -0,0 +1,54 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

{{ 'badges_earned.title'|trans({'%count%': badges|length}) }}

+ +
+
+ + + +

{{ 'badges_earned.intro'|trans }}

+
+
+ + + +
    + {% for entry in badges %} + {% set type_label = (entry.type.translationKey())|trans({}, 'messages', locale) %} + {% if entry.tier %} + {% set tier_label = (entry.tier.translationKey())|trans({}, 'messages', locale) %} +
  • {{ type_label }} — {{ tier_label }} ({{ entry.tier.romanNumeral() }})
  • + {% else %} +
  • {{ type_label }}
  • + {% endif %} + {% endfor %} +
+
+
+ + + + + {{ 'badges_earned.cta'|trans({'%badgesUrl%': url('badges_overview')})|raw }} + + + + + + +

{{ 'badges_earned.outro'|trans }}

+
+
+ + {{ include('emails/_footer.html.twig') }} +
+
+{% endapply %} diff --git a/templates/player_profile.html.twig b/templates/player_profile.html.twig index c98eb6db..9003feec 100644 --- a/templates/player_profile.html.twig +++ b/templates/player_profile.html.twig @@ -246,17 +246,8 @@ - {% if badges is not empty %} -
-

{{ 'badges.title'|trans }}

- - {% for badge in badges %} - - {{ ('badges.badge.' ~ badge.value)|trans }}
- {{ ('badges.badge.' ~ badge.value)|trans }} -
- {% endfor %} -
+ {% if player.activeMembership %} + {% endif %} {% if affiliate_supporters is not null %} diff --git a/tests/BadgeConditions/PiecesSolvedConditionTest.php b/tests/BadgeConditions/PiecesSolvedConditionTest.php new file mode 100644 index 00000000..d14ed3be --- /dev/null +++ b/tests/BadgeConditions/PiecesSolvedConditionTest.php @@ -0,0 +1,57 @@ +badgeType()); + } + + public function testNoTiersBelowTenThousand(): void + { + self::assertSame([], (new PiecesSolvedCondition())->qualifiedTiers($this->snapshot(9_999))); + } + + public function testQualifiesAtThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new PiecesSolvedCondition())->qualifiedTiers($this->snapshot(10_000))); + } + + public function testAllTiersAtTwoMillion(): void + { + self::assertCount(5, (new PiecesSolvedCondition())->qualifiedTiers($this->snapshot(2_000_000))); + } + + public function testProgressFromGoldTowardPlatinum(): void + { + $progress = (new PiecesSolvedCondition())->progressToNextTier($this->snapshot(750_000), BadgeTier::Gold); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Platinum, $progress->nextTier); + self::assertSame(750_000, $progress->currentValue); + self::assertSame(1_000_000, $progress->targetValue); + self::assertSame(75, $progress->percent); + } + + private function snapshot(int $pieces): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: $pieces, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + ); + } +} diff --git a/tests/BadgeConditions/PuzzlesSolvedConditionTest.php b/tests/BadgeConditions/PuzzlesSolvedConditionTest.php new file mode 100644 index 00000000..2227b28e --- /dev/null +++ b/tests/BadgeConditions/PuzzlesSolvedConditionTest.php @@ -0,0 +1,108 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 9); + + self::assertSame([], (new PuzzlesSolvedCondition())->qualifiedTiers($snapshot)); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 10); + + self::assertSame( + [BadgeTier::Bronze], + (new PuzzlesSolvedCondition())->qualifiedTiers($snapshot), + ); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 600); + + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new PuzzlesSolvedCondition())->qualifiedTiers($snapshot), + ); + } + + public function testQualifiesForAllTiersAtOrAbove2000(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 2500); + + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new PuzzlesSolvedCondition())->qualifiedTiers($snapshot), + ); + } + + public function testProgressTowardBronzeWithNoBadges(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 3); + + $progress = (new PuzzlesSolvedCondition())->progressToNextTier($snapshot, null); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Bronze, $progress->nextTier); + self::assertSame(3, $progress->currentValue); + self::assertSame(10, $progress->targetValue); + self::assertSame(30, $progress->percent); + } + + public function testProgressCapsAt100Percent(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 90); + + $progress = (new PuzzlesSolvedCondition())->progressToNextTier($snapshot, BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(90, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + $snapshot = $this->snapshot(distinctPuzzles: 3000); + + self::assertNull((new PuzzlesSolvedCondition())->progressToNextTier($snapshot, BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new PuzzlesSolvedCondition(); + + self::assertSame(10, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(100, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(2000, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $distinctPuzzles): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: $distinctPuzzles, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + ); + } +} diff --git a/tests/BadgeConditions/Speed500PiecesConditionTest.php b/tests/BadgeConditions/Speed500PiecesConditionTest.php new file mode 100644 index 00000000..658c77c9 --- /dev/null +++ b/tests/BadgeConditions/Speed500PiecesConditionTest.php @@ -0,0 +1,75 @@ +badgeType()); + } + + public function testNoSolveMeansNoTiers(): void + { + self::assertSame([], (new Speed500PiecesCondition())->qualifiedTiers($this->snapshot(null))); + } + + public function testSlowerThan5HoursYieldsNothing(): void + { + self::assertSame([], (new Speed500PiecesCondition())->qualifiedTiers($this->snapshot(18_001))); + } + + public function testExactlyThreshold5HoursEarnsBronze(): void + { + self::assertSame([BadgeTier::Bronze], (new Speed500PiecesCondition())->qualifiedTiers($this->snapshot(18_000))); + } + + public function testSub30MinEarnsAllTiers(): void + { + self::assertCount(5, (new Speed500PiecesCondition())->qualifiedTiers($this->snapshot(1_500))); + } + + public function testProgressTowardDiamondShowsShrinkingRatio(): void + { + // Best time 2500s (~42min), target for Diamond is 1800s (30min) + $progress = (new Speed500PiecesCondition())->progressToNextTier($this->snapshot(2_500), BadgeTier::Platinum); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Diamond, $progress->nextTier); + self::assertSame(2_500, $progress->currentValue); + self::assertSame(1_800, $progress->targetValue); + self::assertSame(72, $progress->percent); + } + + public function testProgressIsNullWithoutAnySolve(): void + { + self::assertNull((new Speed500PiecesCondition())->progressToNextTier($this->snapshot(null), null)); + } + + public function testProgressIsNullWhenDiamondEarned(): void + { + self::assertNull( + (new Speed500PiecesCondition())->progressToNextTier($this->snapshot(1_500), BadgeTier::Diamond), + ); + } + + private function snapshot(null|int $best500SoloSeconds): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: $best500SoloSeconds, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + ); + } +} diff --git a/tests/BadgeConditions/StreakConditionTest.php b/tests/BadgeConditions/StreakConditionTest.php new file mode 100644 index 00000000..9106bc66 --- /dev/null +++ b/tests/BadgeConditions/StreakConditionTest.php @@ -0,0 +1,62 @@ +badgeType()); + } + + public function testZeroStreakHasNoTier(): void + { + self::assertSame([], (new StreakCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testSixDaysShortOfFirstTier(): void + { + self::assertSame([], (new StreakCondition())->qualifiedTiers($this->snapshot(6))); + } + + public function testSevenDaysEarnsBronze(): void + { + self::assertSame([BadgeTier::Bronze], (new StreakCondition())->qualifiedTiers($this->snapshot(7))); + } + + public function testFullYearEarnsAllTiers(): void + { + self::assertCount(5, (new StreakCondition())->qualifiedTiers($this->snapshot(365))); + } + + public function testProgressTowardGold(): void + { + $progress = (new StreakCondition())->progressToNextTier($this->snapshot(60), BadgeTier::Silver); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Gold, $progress->nextTier); + self::assertSame(60, $progress->currentValue); + self::assertSame(90, $progress->targetValue); + self::assertSame(66, $progress->percent); + } + + private function snapshot(int $streakDays): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: $streakDays, + teamSolvesCount: 0, + ); + } +} diff --git a/tests/BadgeConditions/TeamPlayerConditionTest.php b/tests/BadgeConditions/TeamPlayerConditionTest.php new file mode 100644 index 00000000..e4db0454 --- /dev/null +++ b/tests/BadgeConditions/TeamPlayerConditionTest.php @@ -0,0 +1,65 @@ +badgeType()); + } + + public function testFirstTeamSolveEarnsBronze(): void + { + self::assertSame([BadgeTier::Bronze], (new TeamPlayerCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testFourSolvesStillOnlyBronze(): void + { + self::assertSame([BadgeTier::Bronze], (new TeamPlayerCondition())->qualifiedTiers($this->snapshot(4))); + } + + public function testFiveSolvesEarnBronzeAndSilver(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver], + (new TeamPlayerCondition())->qualifiedTiers($this->snapshot(5)), + ); + } + + public function testFiveHundredSolvesEarnAllTiers(): void + { + self::assertCount(5, (new TeamPlayerCondition())->qualifiedTiers($this->snapshot(500))); + } + + public function testProgressTowardSilverFromBronze(): void + { + $progress = (new TeamPlayerCondition())->progressToNextTier($this->snapshot(3), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(3, $progress->currentValue); + self::assertSame(5, $progress->targetValue); + self::assertSame(60, $progress->percent); + } + + private function snapshot(int $teamSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: $teamSolves, + ); + } +} diff --git a/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php b/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php new file mode 100644 index 00000000..3b9ac3c2 --- /dev/null +++ b/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php @@ -0,0 +1,101 @@ +connection = $container->get(Connection::class); + $this->playerRepository = $container->get(PlayerRepository::class); + + $this->connection->executeStatement('DELETE FROM badge WHERE player_id IN (:players)', [ + 'players' => [PlayerFixture::PLAYER_REGULAR, PlayerFixture::PLAYER_WITH_STRIPE], + ], ['players' => ArrayParameterType::STRING]); + } + + public function testDoesNothingWhenEvaluatorReturnsNoNewBadges(): void + { + $busSpy = new MessageBusSpy(); + $handler = new RecalculateBadgesForPlayerHandler( + badgeEvaluator: new FakeBadgeEvaluator([]), + commandBus: $busSpy, + ); + + $handler(new RecalculateBadgesForPlayer(PlayerFixture::PLAYER_REGULAR)); + + self::assertCount(0, $busSpy->dispatched); + } + + public function testDispatchesNotificationEmailWithHighestTierPerType(): void + { + $player = $this->playerRepository->get(PlayerFixture::PLAYER_REGULAR); + $now = new DateTimeImmutable('2026-04-16 12:00:00'); + + $badges = [ + Badge::earn($player, BadgeType::PuzzlesSolved, $now, BadgeTier::Bronze), + Badge::earn($player, BadgeType::PuzzlesSolved, $now, BadgeTier::Silver), + Badge::earn($player, BadgeType::PuzzlesSolved, $now, BadgeTier::Gold), + Badge::earn($player, BadgeType::Streak, $now, BadgeTier::Bronze), + ]; + + $busSpy = new MessageBusSpy(); + $handler = new RecalculateBadgesForPlayerHandler( + badgeEvaluator: new FakeBadgeEvaluator($badges), + commandBus: $busSpy, + ); + + $handler(new RecalculateBadgesForPlayer(PlayerFixture::PLAYER_REGULAR)); + + self::assertCount(1, $busSpy->dispatched); + + $message = $busSpy->dispatched[0]; + self::assertInstanceOf(SendBadgeNotificationEmail::class, $message); + self::assertSame(PlayerFixture::PLAYER_REGULAR, $message->playerId); + self::assertCount(2, $message->badgeSummary); + + $byType = []; + foreach ($message->badgeSummary as $entry) { + $byType[$entry['type']->value] = $entry['tier']?->value; + } + self::assertSame(3, $byType['puzzles_solved']); + self::assertSame(1, $byType['streak']); + } +} + +final class MessageBusSpy implements MessageBusInterface +{ + /** @var list */ + public array $dispatched = []; + + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->dispatched[] = $message; + + return new Envelope($message); + } +} diff --git a/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php b/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php new file mode 100644 index 00000000..d08b0eb0 --- /dev/null +++ b/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php @@ -0,0 +1,97 @@ +mailer = new TestMailerSpy(); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->translator = $container->get(TranslatorInterface::class); + } + + public function testSendsEmailWithCorrectTemplate(): void + { + $handler = new SendBadgeNotificationEmailHandler( + playerRepository: $this->playerRepository, + mailer: $this->mailer, + translator: $this->translator, + ); + + $handler(new SendBadgeNotificationEmail( + playerId: PlayerFixture::PLAYER_REGULAR, + badgeSummary: [ + ['type' => BadgeType::PuzzlesSolved, 'tier' => BadgeTier::Gold], + ['type' => BadgeType::Streak, 'tier' => BadgeTier::Bronze], + ], + )); + + self::assertCount(1, $this->mailer->sent); + $message = $this->mailer->sent[0]; + self::assertInstanceOf(TemplatedEmail::class, $message); + self::assertSame('emails/badges_earned.html.twig', $message->getHtmlTemplate()); + self::assertSame('transactional', $message->getHeaders()->get('X-Transport')?->getBodyAsString()); + } + + public function testSkipsEmailWhenPlayerHasNoEmail(): void + { + $player = new \SpeedPuzzling\Web\Entity\Player( + id: \Ramsey\Uuid\Uuid::uuid7(), + code: 'noemail-test', + userId: null, + email: null, + name: 'No Email', + registeredAt: new \DateTimeImmutable(), + ); + + $handler = new SendBadgeNotificationEmailHandler( + playerRepository: new FakePlayerRepository($player), + mailer: $this->mailer, + translator: $this->translator, + ); + + $handler(new SendBadgeNotificationEmail( + playerId: $player->id->toString(), + badgeSummary: [['type' => BadgeType::Streak, 'tier' => BadgeTier::Bronze]], + )); + + self::assertCount(0, $this->mailer->sent); + } + + public function testSkipsEmailWhenPlayerNotFound(): void + { + $handler = new SendBadgeNotificationEmailHandler( + playerRepository: $this->playerRepository, + mailer: $this->mailer, + translator: $this->translator, + ); + + $handler(new SendBadgeNotificationEmail( + playerId: '00000000-0000-0000-0000-000000000099', + badgeSummary: [['type' => BadgeType::Streak, 'tier' => BadgeTier::Bronze]], + )); + + self::assertCount(0, $this->mailer->sent); + } +} diff --git a/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php b/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php new file mode 100644 index 00000000..0c2d8597 --- /dev/null +++ b/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php @@ -0,0 +1,53 @@ +query = self::getContainer()->get(GetAllPlayerIdsWithSolveTimes::class); + } + + public function testReturnsNonEmptyList(): void + { + $ids = $this->query->execute(); + + self::assertNotEmpty($ids); + } + + public function testAllIdsAreValidUuids(): void + { + $ids = $this->query->execute(); + + foreach ($ids as $id) { + self::assertTrue(Uuid::isValid($id), "Expected a valid UUID, got: $id"); + } + } + + public function testContainsKnownFixturePlayersWithSolveTimes(): void + { + $ids = $this->query->execute(); + + self::assertContains(PlayerFixture::PLAYER_REGULAR, $ids); + self::assertContains(PlayerFixture::PLAYER_ADMIN, $ids); + self::assertContains(PlayerFixture::PLAYER_PRIVATE, $ids); + } + + public function testContainsNoDuplicates(): void + { + $ids = $this->query->execute(); + + self::assertCount(count(array_unique($ids)), $ids); + } +} diff --git a/tests/Query/GetBadgesTest.php b/tests/Query/GetBadgesTest.php new file mode 100644 index 00000000..27e72ebe --- /dev/null +++ b/tests/Query/GetBadgesTest.php @@ -0,0 +1,42 @@ +query = self::getContainer()->get(GetBadges::class); + } + + public function testReturnsEmptyArrayForPlayerWithNoBadges(): void + { + $badges = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + // No badge fixtures exist — empty is correct + self::assertSame([], $badges); + } + + public function testReturnsEmptyArrayForNonExistentPlayer(): void + { + $badges = $this->query->forPlayer('00000000-0000-0000-0000-000000000099'); + + self::assertSame([], $badges); + } + + public function testQueryDoesNotErrorOnPlayerWithoutBadges(): void + { + $badges = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + self::assertCount(0, $badges); + } +} diff --git a/tests/Query/GetPlayerStatsSnapshotTest.php b/tests/Query/GetPlayerStatsSnapshotTest.php new file mode 100644 index 00000000..d8f90081 --- /dev/null +++ b/tests/Query/GetPlayerStatsSnapshotTest.php @@ -0,0 +1,79 @@ +query = self::getContainer()->get(GetPlayerStatsSnapshot::class); + } + + public function testReturnsSnapshotForPlayerWithSolveTimes(): void + { + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + self::assertSame(PlayerFixture::PLAYER_REGULAR, $snapshot->playerId); + self::assertGreaterThan(0, $snapshot->distinctPuzzlesSolved); + self::assertGreaterThan(0, $snapshot->totalPiecesSolved); + self::assertNotNull($snapshot->best500PieceSoloSeconds); + self::assertGreaterThan(0, $snapshot->best500PieceSoloSeconds); + self::assertGreaterThanOrEqual(0, $snapshot->allTimeLongestStreakDays); + self::assertGreaterThanOrEqual(0, $snapshot->teamSolvesCount); + } + + public function testReturnsZerosForNonExistentPlayer(): void + { + $snapshot = $this->query->forPlayer('00000000-0000-0000-0000-000000000099'); + + self::assertSame(0, $snapshot->distinctPuzzlesSolved); + self::assertSame(0, $snapshot->totalPiecesSolved); + self::assertNull($snapshot->best500PieceSoloSeconds); + self::assertSame(0, $snapshot->allTimeLongestStreakDays); + self::assertSame(0, $snapshot->teamSolvesCount); + } + + public function testBest500PieceSoloSecondsIsSmallestValue(): void + { + // PLAYER_REGULAR has multiple 500pc solo solves; verify we get the fastest + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + self::assertNotNull($snapshot->best500PieceSoloSeconds); + // The fastest 500pc time in fixtures is 1700s (TIME_08) for PLAYER_REGULAR + self::assertLessThanOrEqual(1800, $snapshot->best500PieceSoloSeconds); + } + + public function testPiecesSolvedCountsAllParticipation(): void + { + // PLAYER_REGULAR has solo + team solves across multiple piece counts + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + // Multiple puzzles of 500, 1000, 1500, 2000 pieces = well over 10,000 total + self::assertGreaterThan(5000, $snapshot->totalPiecesSolved); + } + + public function testPlayerWithOnlySoloSolvesHasZeroTeamCount(): void + { + // PLAYER_WITH_FAVORITES has only solo solves in fixtures + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES); + + self::assertSame(0, $snapshot->teamSolvesCount); + } + + public function testPlayerWithTeamSolvesCountsThem(): void + { + // PLAYER_REGULAR has at least TIME_12 and TIME_41 as team solves + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + self::assertGreaterThanOrEqual(2, $snapshot->teamSolvesCount); + } +} diff --git a/tests/Services/Badges/BadgeEvaluatorTest.php b/tests/Services/Badges/BadgeEvaluatorTest.php new file mode 100644 index 00000000..874608b6 --- /dev/null +++ b/tests/Services/Badges/BadgeEvaluatorTest.php @@ -0,0 +1,219 @@ +evaluator( + conditions: [], + existingBadges: [], + playerRepository: $this->playerRepositoryThrowing(), + recorder: $recorder, + ); + + self::assertSame([], $evaluator->recalculateForPlayer(self::PLAYER_ID)); + self::assertSame([], $recorder->saved); + } + + public function testPersistsAllQualifiedTiersWhenNoneEarnedYet(): void + { + $recorder = new SavedBadgeRecorder(); + + $evaluator = $this->evaluator( + conditions: [ + new FakeBadgeCondition(BadgeType::PuzzlesSolved, [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold]), + ], + existingBadges: [], + playerRepository: $this->playerRepositoryReturning($this->fakePlayer()), + recorder: $recorder, + ); + + $result = $evaluator->recalculateForPlayer(self::PLAYER_ID); + + self::assertCount(3, $result); + self::assertSame([1, 2, 3], array_map(static fn (Badge $b): null|int => $b->tier, $recorder->saved)); + self::assertSame(BadgeType::PuzzlesSolved, $recorder->saved[0]->type); + } + + public function testSkipsTiersAlreadyInDatabase(): void + { + $recorder = new SavedBadgeRecorder(); + + $evaluator = $this->evaluator( + conditions: [ + new FakeBadgeCondition(BadgeType::Streak, [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold]), + ], + existingBadges: [ + new BadgeResult(BadgeType::Streak, BadgeTier::Bronze, new DateTimeImmutable('2026-01-01')), + new BadgeResult(BadgeType::Streak, BadgeTier::Silver, new DateTimeImmutable('2026-02-01')), + ], + playerRepository: $this->playerRepositoryReturning($this->fakePlayer()), + recorder: $recorder, + ); + + $result = $evaluator->recalculateForPlayer(self::PLAYER_ID); + + self::assertCount(1, $result); + self::assertSame(3, $result[0]->tier); + } + + public function testReturnsEmptyWhenAllQualifiedTiersAlreadyEarned(): void + { + $recorder = new SavedBadgeRecorder(); + + $evaluator = $this->evaluator( + conditions: [ + new FakeBadgeCondition(BadgeType::TeamPlayer, [BadgeTier::Bronze]), + ], + existingBadges: [ + new BadgeResult(BadgeType::TeamPlayer, BadgeTier::Bronze, new DateTimeImmutable('2026-01-01')), + ], + playerRepository: $this->playerRepositoryReturning($this->fakePlayer()), + recorder: $recorder, + ); + + self::assertSame([], $evaluator->recalculateForPlayer(self::PLAYER_ID)); + self::assertSame([], $recorder->saved); + } + + public function testIgnoresSingleTierBadgesWhenCheckingForGaps(): void + { + // Supporter badges live with tier = null. They must not collide with tier-1 of any type. + $recorder = new SavedBadgeRecorder(); + + $evaluator = $this->evaluator( + conditions: [ + new FakeBadgeCondition(BadgeType::PuzzlesSolved, [BadgeTier::Bronze]), + ], + existingBadges: [ + new BadgeResult(BadgeType::Supporter, null, new DateTimeImmutable('2026-01-01')), + ], + playerRepository: $this->playerRepositoryReturning($this->fakePlayer()), + recorder: $recorder, + ); + + self::assertCount(1, $evaluator->recalculateForPlayer(self::PLAYER_ID)); + } + + public function testEarnsBadgesAcrossMultipleConditions(): void + { + $recorder = new SavedBadgeRecorder(); + + $evaluator = $this->evaluator( + conditions: [ + new FakeBadgeCondition(BadgeType::PuzzlesSolved, [BadgeTier::Bronze]), + new FakeBadgeCondition(BadgeType::PiecesSolved, [BadgeTier::Bronze, BadgeTier::Silver]), + new FakeBadgeCondition(BadgeType::Streak, []), + ], + existingBadges: [], + playerRepository: $this->playerRepositoryReturning($this->fakePlayer()), + recorder: $recorder, + ); + + $result = $evaluator->recalculateForPlayer(self::PLAYER_ID); + + self::assertCount(3, $result); + + $byType = []; + foreach ($recorder->saved as $badge) { + $byType[$badge->type->value][] = $badge->tier; + } + + self::assertSame([1], $byType['puzzles_solved']); + self::assertSame([1, 2], $byType['pieces_solved']); + } + + /** + * @param iterable $conditions + * @param list $existingBadges + */ + private function evaluator( + iterable $conditions, + array $existingBadges, + PlayerRepository $playerRepository, + SavedBadgeRecorder $recorder, + ): BadgeEvaluator { + $getSnapshot = $this->createStub(GetPlayerStatsSnapshot::class); + $getSnapshot->method('forPlayer')->willReturn($this->emptySnapshot()); + + $getBadges = $this->createStub(GetBadges::class); + $getBadges->method('forPlayer')->willReturn($existingBadges); + + $badgeRepository = $this->createStub(BadgeRepository::class); + $badgeRepository->method('save')->willReturnCallback(function (Badge $badge) use ($recorder): void { + $recorder->saved[] = $badge; + }); + + return new BadgeEvaluator( + conditions: $conditions, + getPlayerStatsSnapshot: $getSnapshot, + getBadges: $getBadges, + badgeRepository: $badgeRepository, + playerRepository: $playerRepository, + clock: new MockClock('2026-04-16 12:00:00'), + ); + } + + private function emptySnapshot(): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: self::PLAYER_ID, + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + ); + } + + private function fakePlayer(): Player + { + $player = (new \ReflectionClass(Player::class))->newInstanceWithoutConstructor(); + (new \ReflectionClass(Player::class))->getProperty('id')->setValue($player, Uuid::fromString(self::PLAYER_ID)); + + return $player; + } + + private function playerRepositoryReturning(Player $player): PlayerRepository + { + $repository = $this->createStub(PlayerRepository::class); + $repository->method('get')->willReturn($player); + + return $repository; + } + + private function playerRepositoryThrowing(): PlayerRepository + { + $repository = $this->createStub(PlayerRepository::class); + $repository->method('get')->willThrowException(new PlayerNotFound()); + + return $repository; + } +} diff --git a/tests/TestDouble/FakeBadgeCondition.php b/tests/TestDouble/FakeBadgeCondition.php new file mode 100644 index 00000000..d79018d8 --- /dev/null +++ b/tests/TestDouble/FakeBadgeCondition.php @@ -0,0 +1,43 @@ + $qualifiedTiers + */ + public function __construct( + private readonly BadgeType $type, + private readonly array $qualifiedTiers, + ) { + } + + public function badgeType(): BadgeType + { + return $this->type; + } + + public function qualifiedTiers(PlayerStatsSnapshot $snapshot): array + { + return $this->qualifiedTiers; + } + + public function progressToNextTier(PlayerStatsSnapshot $snapshot, null|BadgeTier $highestEarned): null|BadgeProgress + { + return null; + } + + public function requirementForTier(BadgeTier $tier): int + { + return 0; + } +} diff --git a/tests/TestDouble/FakeBadgeEvaluator.php b/tests/TestDouble/FakeBadgeEvaluator.php new file mode 100644 index 00000000..107c6299 --- /dev/null +++ b/tests/TestDouble/FakeBadgeEvaluator.php @@ -0,0 +1,24 @@ + $returnValue + */ + public function __construct(private array $returnValue) + { + // Skip parent constructor — only recalculateForPlayer is exercised in tests. + } + + public function recalculateForPlayer(string $playerId): array + { + return $this->returnValue; + } +} diff --git a/tests/TestDouble/FakePlayerRepository.php b/tests/TestDouble/FakePlayerRepository.php new file mode 100644 index 00000000..2649f079 --- /dev/null +++ b/tests/TestDouble/FakePlayerRepository.php @@ -0,0 +1,21 @@ +player; + } +} diff --git a/tests/TestDouble/SavedBadgeRecorder.php b/tests/TestDouble/SavedBadgeRecorder.php new file mode 100644 index 00000000..84e36a12 --- /dev/null +++ b/tests/TestDouble/SavedBadgeRecorder.php @@ -0,0 +1,13 @@ + */ + public array $saved = []; +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e5e334be..83a7f295 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -179,6 +179,10 @@ function createCustomIndexes(): void // Chat message unread optimization (Version20260212002500) $pdo->exec('CREATE INDEX IF NOT EXISTS custom_chat_message_unread ON chat_message (conversation_id, sender_id) WHERE read_at IS NULL'); + // Badge uniqueness — partial indexes for tiered vs single-tier badges (Version20260416210601) + $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS custom_badge_unique_tiered ON badge (player_id, type, tier) WHERE tier IS NOT NULL'); + $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS custom_badge_unique_single_tier ON badge (player_id, type) WHERE tier IS NULL'); + } /** diff --git a/translations/emails.en.yml b/translations/emails.en.yml index 89080c49..afeb20f2 100644 --- a/translations/emails.en.yml +++ b/translations/emails.en.yml @@ -148,3 +148,11 @@ feature_request_declined:

Thanks for supporting the idea anyway! Your input helps us understand what the community cares about.

admin_comment: |

Comment from the team: %adminComment%

+ +badges_earned: + subject: "You earned new badges on MySpeedPuzzling!" + title: "%count% new badge(s) earned!" + intro: "Congratulations! You've reached new milestones on MySpeedPuzzling." + cta: | +

See all your badges and your progress toward the next ones.

+ outro: "Keep puzzling — more achievements are waiting to be unlocked!" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 5d65f2ff..c6c4de21 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -971,8 +971,69 @@ notifications: badges: title: "Badges" my_title: "My badges" + new: "NEW" + browse_all: "Browse all badges" + overview_title: "All Badges" + overview_subtitle: "Earn badges by reaching milestones in your puzzling journey" + progress_label: "%current% / %target%" + progress_time_label: "%current_time% → target %target_time%" + locked: "Locked" + earned_on: "Earned %date%" + highest_earned: "Highest earned" + not_earned_yet: "Not earned yet" + keep_going: "Keep going!" + login_to_track: "Log in to see your progress toward each badge." + tier: + bronze: "Bronze" + silver: "Silver" + gold: "Gold" + platinum: "Platinum" + diamond: "Diamond" badge: supporter: "Supporter" + puzzles_solved: "Puzzle Explorer" + pieces_solved: "Piece Cruncher" + speed_500_pieces: "Speed Demon (500pc)" + streak: "On Fire" + team_player: "Team Spirit" + description: + supporter: "Awarded for supporting MySpeedPuzzling." + puzzles_solved: "Solve distinct puzzles to climb this ladder." + pieces_solved: "The more pieces, the better — count every one you've ever placed." + speed_500_pieces: "Your fastest 500-piece solo solve sets your tier." + streak: "Solve puzzles day after day without skipping." + team_player: "Team up with other puzzlers and knock out puzzles together." + requirement: + puzzles_solved_1: "Solve 10 distinct puzzles" + puzzles_solved_2: "Solve 100 distinct puzzles" + puzzles_solved_3: "Solve 500 distinct puzzles" + puzzles_solved_4: "Solve 1,000 distinct puzzles" + puzzles_solved_5: "Solve 2,000 distinct puzzles" + pieces_solved_1: "Accumulate 10,000 pieces" + pieces_solved_2: "Accumulate 100,000 pieces" + pieces_solved_3: "Accumulate 500,000 pieces" + pieces_solved_4: "Accumulate 1,000,000 pieces" + pieces_solved_5: "Accumulate 2,000,000 pieces" + speed_500_pieces_1: "Solve a 500-piece puzzle in under 5 hours (solo)" + speed_500_pieces_2: "Solve a 500-piece puzzle in under 2 hours (solo)" + speed_500_pieces_3: "Solve a 500-piece puzzle in under 1 hour (solo)" + speed_500_pieces_4: "Solve a 500-piece puzzle in under 45 minutes (solo)" + speed_500_pieces_5: "Solve a 500-piece puzzle in under 30 minutes (solo)" + streak_1: "Solve puzzles 7 days in a row" + streak_2: "Solve puzzles 30 days in a row" + streak_3: "Solve puzzles 90 days in a row" + streak_4: "Solve puzzles 180 days in a row" + streak_5: "Solve puzzles 365 days in a row" + team_player_1: "Complete your first team or duo puzzle" + team_player_2: "Complete 5 team or duo puzzles" + team_player_3: "Complete 25 team or duo puzzles" + team_player_4: "Complete 100 team or duo puzzles" + team_player_5: "Complete 500 team or duo puzzles" + unit: + puzzles: "puzzles" + pieces: "pieces" + days: "days" + team_solves: "team solves" scan: