Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions assets/styles/components/_badge.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions config/packages/messenger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
144 changes: 144 additions & 0 deletions docs/features/badges.md
Original file line number Diff line number Diff line change
@@ -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<BadgeTier>
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<BadgeConditionInterface>`.

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
```
34 changes: 34 additions & 0 deletions migrations/Version20260416210601.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260416210601 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tier column to badge; enforce uniqueness per (player, type[, tier]) via partial indexes';
}

public function up(Schema $schema): void
{
$this->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');
}
}
65 changes: 65 additions & 0 deletions src/BadgeConditions/AbstractAscendingThresholdCondition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\BadgeConditions;

use SpeedPuzzling\Web\Results\BadgeProgress;
use SpeedPuzzling\Web\Results\PlayerStatsSnapshot;
use SpeedPuzzling\Web\Value\BadgeTier;

/**
* Base class for count-based tiered badges where higher values earn higher tiers.
*
* Subclasses declare ordered thresholds (Bronze → Diamond) and extract the raw
* metric from a snapshot. Tier qualification is `metric >= 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];
}
}
Loading
Loading