diff --git a/CLAUDE.md b/CLAUDE.md index 0c7a05050..8f341b784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each - **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership - **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players - **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch +- **XP / Levels / Achievements**: `docs/features/xp-levels/` — XP ledger + level curve (1–50), 16 tiered achievements with Achievement Points, weekly content digest, launch runbook. Feature-flagged (`xp-system`, admin-only) until launch; living doc in `docs/features/xp-levels/README.md` - **Referral Program**: `docs/features/referral-program.md` — Members earn 10% of referred subscription revenue. No separate entity — `player.referralProgramJoinedAt` + `player.referralProgramSuspended`. Code = player code. Cookie-based + code-input attribution. Payouts per currency, manual admin payout marking ### Feature Flags diff --git a/assets/controllers/badge_reveal_controller.js b/assets/controllers/badge_reveal_controller.js new file mode 100644 index 000000000..78c3123d3 --- /dev/null +++ b/assets/controllers/badge_reveal_controller.js @@ -0,0 +1,41 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * First-click badge reveal: flips the medallion with a small confetti burst and + * persists the reveal. Fire-and-forget POST — the flip is optimistic. + */ +export default class extends Controller { + static values = { + url: String, + }; + + reveal() { + if (this.element.classList.contains('is-revealed')) { + return; + } + + this.element.classList.add('is-revealed'); + + fetch(this.urlValue, { method: 'POST' }); + + this.burstConfetti(); + } + + burstConfetti() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + + const confetti = document.createElement('span'); + confetti.className = 'xp-confetti xp-confetti-burst'; + confetti.setAttribute('aria-hidden', 'true'); + + for (let i = 0; i < 10; i++) { + confetti.appendChild(document.createElement('i')); + } + + this.element.appendChild(confetti); + + setTimeout(() => confetti.remove(), 3000); + } +} diff --git a/assets/controllers/xp_count_controller.js b/assets/controllers/xp_count_controller.js new file mode 100644 index 000000000..38432619d --- /dev/null +++ b/assets/controllers/xp_count_controller.js @@ -0,0 +1,34 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * One-shot XP counter spin for the launch reveal page: counts from 0 to the + * server-rendered value. Reduced motion → the real number shows instantly + * (it is already in the markup for no-JS visitors anyway). + */ +export default class extends Controller { + static values = { + amount: Number, + duration: { type: Number, default: 1800 }, + }; + + connect() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || this.amountValue <= 0) { + return; + } + + const start = performance.now(); + const formatter = new Intl.NumberFormat('en-US'); + + const tick = (now) => { + const progress = Math.min((now - start) / this.durationValue, 1); + const eased = 1 - Math.pow(1 - progress, 3); + this.element.textContent = formatter.format(Math.round(eased * this.amountValue)); + + if (progress < 1) { + requestAnimationFrame(tick); + } + }; + + requestAnimationFrame(tick); + } +} diff --git a/assets/controllers/xp_reveal_finish_controller.js b/assets/controllers/xp_reveal_finish_controller.js new file mode 100644 index 000000000..bcea5ccea --- /dev/null +++ b/assets/controllers/xp_reveal_finish_controller.js @@ -0,0 +1,24 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Launch-reveal "continue" button: persists the one-time-seen marker via the + * dismiss-hint endpoint, then moves on to the profile. + */ +export default class extends Controller { + static values = { + url: String, + redirect: String, + }; + + async finish() { + try { + await fetch(this.urlValue, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'type=xp_launch_reveal', + }); + } finally { + window.location.assign(this.redirectValue); + } + } +} diff --git a/assets/styles/_components.scss b/assets/styles/_components.scss index 44f1cfe79..a5bc600c5 100644 --- a/assets/styles/_components.scss +++ b/assets/styles/_components.scss @@ -20,6 +20,7 @@ @import 'components/breadcrumb'; @import 'components/pagination'; @import 'components/badge'; +@import 'components/xp'; @import 'components/alert'; @import 'components/list-group'; @import 'components/close'; diff --git a/assets/styles/components/_badge.scss b/assets/styles/components/_badge.scss index 0b158e18a..f573ee792 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/assets/styles/components/_xp.scss b/assets/styles/components/_xp.scss new file mode 100644 index 000000000..cf6686929 --- /dev/null +++ b/assets/styles/components/_xp.scss @@ -0,0 +1,387 @@ +// +// XP / Levels / Achievements +// -------------------------------------------------- +// Everything here is CSS-only by design (locked decision): milestone rings, receipt +// entrance, level-up celebration and confetti ship zero images and zero JS animation. + +$xp-coral: #ec726f; +$xp-sky: #69b3fe; +$xp-indigo: #4e54c8; +$xp-navy: #2b3445; +$xp-gold: #ffd700; +$xp-gold-deep: #f0a500; + + +// Avatar / icon XP ring — progress arc via conic-gradient. +// Usage: + +.xp-ring { + --xp-progress: 0; + --xp-ring-from: #{$xp-sky}; + --xp-ring-to: #{$xp-sky}; + --xp-ring-track: rgba(43, 52, 69, .14); + + position: relative; + display: inline-flex; + border-radius: 50%; + padding: 3px; + background: conic-gradient( + from 0deg, + var(--xp-ring-from), + var(--xp-ring-to) calc(var(--xp-progress) * 360deg), + var(--xp-ring-track) calc(var(--xp-progress) * 360deg) + ); + + > * { + border-radius: 50%; + display: block; + } +} + +// Milestone intensity — the ring's arc gradient grows richer every 10 levels, +// golden at the summit. Zero image assets, brand palette only. +.xp-ring-milestone-10 { + --xp-ring-from: #{$xp-sky}; + --xp-ring-to: #{$xp-indigo}; +} + +.xp-ring-milestone-20 { + --xp-ring-from: #{$xp-indigo}; + --xp-ring-to: #{$xp-coral}; +} + +.xp-ring-milestone-30 { + --xp-ring-from: #{$xp-coral}; + --xp-ring-to: #{$xp-indigo}; + box-shadow: 0 0 0 1px rgba($xp-indigo, .25); +} + +.xp-ring-milestone-40 { + --xp-ring-from: #{$xp-coral}; + --xp-ring-to: #{$xp-sky}; + box-shadow: 0 0 6px rgba($xp-indigo, .45); +} + +.xp-ring-milestone-50 { + --xp-ring-from: #{$xp-gold-deep}; + --xp-ring-to: #{$xp-gold}; + --xp-progress: 1; + box-shadow: 0 0 8px rgba($xp-gold, .65); +} + +// Ambient variant (site header): full quiet ring, no progress, no numbers. +.xp-ring-ambient { + --xp-progress: 1; + padding: 2px; + opacity: .9; +} + + +// Level chip +// Usage: Lv 12 + +.xp-level-chip { + display: inline-flex; + align-items: center; + gap: .3em; + background: $xp-indigo; + color: #fff; + border-radius: 999px; + padding: .2em .65em; + font-size: .8rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.xp-level-chip-max { + background: linear-gradient(135deg, $xp-gold-deep, $xp-gold); + color: #3f2d00; + box-shadow: 0 1px 6px rgba($xp-gold, .5); +} + + +// Progress bar toward next level + +.xp-progressbar { + height: 10px; + border-radius: 999px; + background: rgba($xp-navy, .1); + overflow: hidden; +} + +.xp-progressbar-fill { + --xp-progress: 0; + height: 100%; + width: calc(var(--xp-progress) * 100%); + min-width: 2px; + border-radius: 999px; + background: linear-gradient(90deg, $xp-sky, $xp-indigo); + animation: xp-fill .8s ease-out .55s backwards; +} + +@keyframes xp-fill { + from { + width: 0; + } +} + + +// Post-solve receipt — additive lines with a staggered CSS entrance; +// the progress bar fills after the total lands. + +.xp-receipt-line { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; + min-height: 44px; // thumb-friendly tap target + padding: .3rem .25rem; + opacity: 0; + transform: translateY(6px); + animation: xp-line-in .3s ease-out forwards; + + @for $i from 1 through 8 { + &:nth-child(#{$i}) { + animation-delay: #{$i * 0.07}s; + } + } +} + +@keyframes xp-line-in { + to { + opacity: 1; + transform: none; + } +} + +.xp-receipt-amount { + font-weight: 700; + color: $xp-indigo; + white-space: nowrap; +} + +.xp-receipt-total { + border-top: 2px solid rgba($xp-navy, .15); + font-weight: 700; + + .xp-receipt-amount { + color: $xp-navy; + font-size: 1.1rem; + } +} + +// Pending settlement line ("settles when this puzzle gets rated") — visible but calm. +.xp-receipt-pending { + opacity: .55; + font-style: italic; +} + + +// Free-user teaser & locked achievement strip + +.xp-teaser { + display: flex; + align-items: center; + gap: .5rem; + background: rgba($xp-sky, .12); + border-radius: .5rem; + padding: .6rem .8rem; + font-size: .875rem; +} + +.xp-locked-strip { + .badge-medallion { + filter: grayscale(100%); + opacity: .4; + } +} + + +// Level-up celebration — full-screen interstitial, tap anywhere to dismiss. + +.xp-levelup-overlay { + position: fixed; + inset: 0; + z-index: 1090; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .75rem; + padding: 1.5rem; + text-align: center; + color: #fff; + background: radial-gradient(circle at 50% 35%, rgba($xp-indigo, .92), rgba($xp-navy, .96)); + cursor: pointer; + animation: xp-fade-in .25s ease-out; +} + +.xp-levelup-overlay-max { + background: radial-gradient(circle at 50% 35%, rgba($xp-gold-deep, .94), rgba($xp-navy, .97)); +} + +.xp-levelup-label { + font-size: 1rem; + letter-spacing: .12em; + text-transform: uppercase; + opacity: .85; +} + +.xp-levelup-number { + font-size: 5rem; + font-weight: 800; + line-height: 1; + animation: xp-pop .55s cubic-bezier(.2, 1.6, .4, 1); +} + +.xp-levelup-hint { + font-size: .8rem; + opacity: .7; + margin-top: 1rem; +} + +@keyframes xp-fade-in { + from { + opacity: 0; + } +} + +@keyframes xp-pop { + 0% { + transform: scale(.4); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + + +// First-click badge reveal — 3D medallion flip, front face hidden until revealed. + +.badge-flip { + border: 0; + background: none; + padding: 0; + cursor: pointer; + perspective: 400px; + display: inline-block; + position: relative; +} + +.badge-flip-inner { + display: inline-block; + position: relative; + transition: transform .6s cubic-bezier(.3, 1.4, .4, 1); + transform-style: preserve-3d; + + .is-revealed & { + transform: rotateY(180deg); + } +} + +.badge-flip-back, +.badge-flip-front { + backface-visibility: hidden; +} + +.badge-flip-back { + background: radial-gradient(circle at 35% 30%, lighten($xp-navy, 25%), $xp-navy 75%); + color: rgba(255, 255, 255, .85); + animation: badge-flip-wiggle 2.4s ease-in-out infinite; +} + +.badge-flip-front { + position: absolute; + inset: 0; + transform: rotateY(180deg); +} + +@keyframes badge-flip-wiggle { + 0%, 88%, 100% { + transform: rotate(0); + } + + 92% { + transform: rotate(-4deg); + } + + 96% { + transform: rotate(4deg); + } +} + +// Small localized confetti burst (badge reveal) — same pieces, contained. +.xp-confetti-burst { + position: absolute; + inset: -20px; + overflow: visible; + + i { + animation-duration: 1.6s; + animation-iteration-count: 1; + } +} + + +// CSS-only confetti (brand colors) — used by level-up + badge reveal moments. + +.xp-confetti { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + + i { + position: absolute; + top: -20px; + width: 8px; + height: 14px; + border-radius: 2px; + opacity: .9; + animation: xp-confetti-fall 3s linear infinite; + } + + i:nth-child(1) { left: 6%; background: $xp-coral; animation-delay: 0s; } + i:nth-child(2) { left: 16%; background: $xp-sky; animation-delay: .5s; animation-duration: 2.4s; } + i:nth-child(3) { left: 26%; background: $xp-indigo; animation-delay: .2s; } + i:nth-child(4) { left: 36%; background: $xp-gold; animation-delay: .8s; animation-duration: 2.7s; } + i:nth-child(5) { left: 46%; background: $xp-coral; animation-delay: 1.1s; } + i:nth-child(6) { left: 56%; background: $xp-sky; animation-delay: .4s; animation-duration: 2.2s; } + i:nth-child(7) { left: 66%; background: $xp-indigo; animation-delay: .9s; } + i:nth-child(8) { left: 76%; background: $xp-gold; animation-delay: .1s; animation-duration: 2.9s; } + i:nth-child(9) { left: 86%; background: $xp-coral; animation-delay: .6s; } + i:nth-child(10) { left: 94%; background: $xp-sky; animation-delay: 1.3s; animation-duration: 2.5s; } +} + +@keyframes xp-confetti-fall { + to { + transform: translateY(105vh) rotate(560deg); + } +} + + +// Accessibility: celebrations stay, motion goes. + +@media (prefers-reduced-motion: reduce) { + .xp-receipt-line, + .xp-progressbar-fill, + .xp-levelup-number, + .xp-levelup-overlay, + .badge-flip-back { + animation: none; + opacity: 1; + transform: none; + } + + .badge-flip-inner { + transition: none; + } + + .xp-confetti { + display: none; + } +} diff --git a/config/packages/dev/messenger.php b/config/packages/dev/messenger.php index b3ea8364f..46e6da271 100644 --- a/config/packages/dev/messenger.php +++ b/config/packages/dev/messenger.php @@ -5,6 +5,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use SpeedPuzzling\Web\Message\PrepareDigestEmailForPlayer; +use SpeedPuzzling\Web\Message\SendPlayerContentDigest; use Symfony\Component\Mailer\Messenger\SendEmailMessage; return App::config([ @@ -13,6 +14,7 @@ 'routing' => [ SendEmailMessage::class => 'sync', PrepareDigestEmailForPlayer::class => 'sync', + SendPlayerContentDigest::class => 'sync', 'SpeedPuzzling\\Web\\Events\\*' => 'sync', ], ], diff --git a/config/packages/messenger.php b/config/packages/messenger.php index 8c1ac47f3..8add0b06a 100644 --- a/config/packages/messenger.php +++ b/config/packages/messenger.php @@ -4,8 +4,17 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use SpeedPuzzling\Web\Message\AwardXpForSolvingTime; +use SpeedPuzzling\Web\Message\CompensateXpForDeletedSolve; use SpeedPuzzling\Web\Message\PrepareDigestEmailForPlayer; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Message\RecalculateDerivedMetricsForPuzzle; +use SpeedPuzzling\Web\Message\RecalculateXpChainForSolve; +use SpeedPuzzling\Web\Message\RecalculateXpForPlayer; +use SpeedPuzzling\Web\Message\SendBadgeNotificationEmail; +use SpeedPuzzling\Web\Message\SendPlayerContentDigest; +use SpeedPuzzling\Web\Message\SendXpRevealEmail; +use SpeedPuzzling\Web\Message\SettleXpBonuses; use Symfony\Component\Mailer\Messenger\SendEmailMessage; return App::config([ @@ -38,11 +47,31 @@ 'max_delay' => 1800000, ], ], + // Dedicated queue on the same Doctrine table, drained by the digest + // consumer container — SMTP pacing stays isolated from transactional mail. + 'digest_emails' => [ + 'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%?auto_setup=false&queue_name=digest_emails', + 'retry_strategy' => [ + 'max_retries' => 5, + 'delay' => 60_000, // 1m → 4m → 16m → 64m → 4h (capped) + 'multiplier' => 4, + 'max_delay' => 14_400_000, // 4h + ], + ], ], 'routing' => [ SendEmailMessage::class => 'async', PrepareDigestEmailForPlayer::class => 'async', RecalculateDerivedMetricsForPuzzle::class => 'async', + RecalculateBadgesForPlayer::class => 'async', + RecalculateXpForPlayer::class => 'async', + AwardXpForSolvingTime::class => 'async', + RecalculateXpChainForSolve::class => 'async', + CompensateXpForDeletedSolve::class => 'async', + SettleXpBonuses::class => 'async', + SendPlayerContentDigest::class => 'digest_emails', + SendBadgeNotificationEmail::class => 'async', + SendXpRevealEmail::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/packages/test/messenger.php b/config/packages/test/messenger.php index 31ade680c..4820d6f3c 100644 --- a/config/packages/test/messenger.php +++ b/config/packages/test/messenger.php @@ -5,6 +5,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use SpeedPuzzling\Web\Message\PrepareDigestEmailForPlayer; +use SpeedPuzzling\Web\Message\SendPlayerContentDigest; return App::config([ 'framework' => [ @@ -13,9 +14,13 @@ 'async' => [ 'dsn' => 'in-memory://', ], + 'digest_emails' => [ + 'dsn' => 'in-memory://', + ], ], 'routing' => [ PrepareDigestEmailForPlayer::class => 'sync', + SendPlayerContentDigest::class => 'sync', ], ], ], diff --git a/config/reference.php b/config/reference.php index e9d9af626..ab720ef84 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1810,8 +1810,8 @@ * } * @psalm-type MercureConfig = array{ * hubs?: arrayload('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/design-system/badge-generation-comfyui.md b/docs/design-system/badge-generation-comfyui.md new file mode 100644 index 000000000..fb345b00b --- /dev/null +++ b/docs/design-system/badge-generation-comfyui.md @@ -0,0 +1,103 @@ +# Badge Generation via ComfyUI — Model Research & Bake-off Plan + +> **2026-07-17 UPDATE — bake-off COMPLETE, pipeline validated end-to-end.** +> Winner: FLUX.2 Klein 9B (icons + edit-polish passes) over deterministic frame geometry. +> The validated recipe, experimental findings (flood-fill > neural matting; text-beats-reference +> in edit mode; 11/11 geometry-preserving polish runs), runnable scripts and evidence sheets live in +> **`badge-pipeline/README.md`**. Remaining decisions are listed there (style pick B vs C, scale test). + +_Researched 2026-07-16 (deep-research run: 23 sources fetched, 25 top claims adversarially verified 3-vote, 22 confirmed). Supersedes the ChatGPT pipeline in `prompts/badges.md` for **generation tooling**; the visual spec (socket→tab progression, brand colors, outline rules) in that file remains the source of truth for WHAT to generate._ + +## Context + +- Task A — **5 tier frames**: one grid image, jigsaw-piece frames with exact socket/tab placement per tier, brand fills (#f6f9fc / #EC726F flat / coral→#69b3fe / coral→#4e54c8 gradients), uniform ~2-3px #2b3445 outline. +- Task B — **16+ center icons**: semi-flat line illustration, navy outline, white + coral fills, MSP brand style. +- Task C — transparent backgrounds + composition (icon onto frame). +- Hardware: **Apple M3 Max, 36 GB unified memory** (RAM is the binding constraint; disk is not — 300+ GB free). ComfyUI on MPS. + +## Non-negotiable MPS rules (verified, mid-2026) + +1. **Never use fp8 checkpoints** — PyTorch MPS has no fp8 (e4m3fn) support; weights convert on the fly to fp16/fp32 at 2-4× memory and force swap. **GGUF quants (Q6_K/Q8_0) or fp16 only.** +2. **Avoid bf16 on M3** — bf16 is software-emulated on M1–M3 (~2.1× slower than fp16; hardware bf16 starts with M4). ComfyUI defaulted macOS to bf16 in 2026 builds and caused a 7× slowdown regression; launch with `--fp16-vae --fp16-unet`. +3. **Do NOT use `--force-fp16`** — known black-image bug with MPS fp16 attention on macOS 14.5+. Use the two granular flags above instead. +4. `--use-pytorch-cross-attention` → 30–50 % faster on M-series. +5. `PYTORCH_ENABLE_MPS_FALLBACK=1` — torch.nonzero (mask nodes), some ControlNet scatter ops, and bicubic interpolate silently need CPU fallback. +6. Grid sheets ≤ 2K px on Z-Image (quality degrades ≥3K); plan 5-frame strips at ~2048×512. + +## Candidate stacks (bake-off entrants) + +### Stack 1 — FLUX.2 Klein 9B — "brand-precision" candidate +- **Why**: FLUX.2 is the only family with an *officially documented* exact-hex mechanism (BFL prompting guide: hex codes in prompt + JSON structured prompts with `color_match: "exact"`); a Jan-2026 head-to-head found Klein 9B leading prompt adherence in its class. Unified generation + editing → same model can do "recolor this frame to tier-2 fill" reference passes. +- **Files**: `black-forest-labs/FLUX.2-klein-9B` — fp16/GGUF Q8_0 (18.2 GB full; Q8 ~10 GB) + **Qwen3-8B text encoder** + VAE. 4-step distilled variant (CFG locked 1.0) for iteration, base variant for quality. `unsloth/FLUX.2-klein-4B-GGUF` (8 GB class, Qwen3-4B TE) as the light alternative. +- **Caveats**: as of late Jan 2026 Klein ran in ComfyUI portable (v0.9.2+) but **not ComfyUI Desktop** and wanted extra custom nodes — verify current status on the target install first. Hex prompting is documented for dev/pro, not klein explicitly. Vendor "precise color matching" ≠ bit-exact → keep the color-snap post-pass regardless. + +### Stack 2 — Qwen-Image-Edit-2511 (+ Qwen-Image 2512 base) — "consistency editor" +- **Why**: verified strongest local edit model — region-preserving targeted recolors (demonstrated down to recoloring a single letter), style transfer from reference images. The 20B class has the best instruction-following for layout ("socket on left edge, tabs elsewhere"). +- **Files**: `unsloth/Qwen-Image-Edit-2511-GGUF` Q6_K (~16 GB; Q8 ~21 GB is tight on 36 GB) + Qwen2.5-VL-7B text encoder **Q8 GGUF** (+ mmproj) + `qwen_image_vae.safetensors` + **Lightning 4-step LoRA** (mandatory for usable speed: ~3-4 min/edit with it on Apple Silicon, 6-8+ min without). +- **Caveats**: heaviest and slowest stack; one Mac test found Q8_0 output slightly blurry vs full precision — evaluate quant level in the bake-off. + +### Stack 3 — Z-Image Turbo + Z-Image Base + Icons.Redmond LoRA — "fast iteration + LoRA ecosystem" +- **Why**: 6B, ~1-2 min/1024² on Apple Silicon at 8-9 steps, native ComfyUI support since v0.6.0 with **zero reported MPS workarounds** — the iteration workhorse. Has the only dedicated icon LoRA on a modern base: **Icons.Redmond (artificialguybr), Z-Image-Turbo port** (Civitai model 122827 / version 2705464, triggers `ICREDM, ICONS`). `alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union` enables control-image-guided frame geometry. Z-Image **Base** (released 2026-01-28, non-distilled, 30-50 steps CFG 3-5, Qwen3-4B TE, day-0 ComfyUI) is the designated **LoRA-training base** — the long-term option of training a small MSP-brand style LoRA on our existing illustrations. +- **Files**: `Comfy-Org/z_image_turbo` fp16 repack (or `jayn7`/`unsloth` GGUF Q8), Qwen3-4B TE, VAE; `Tongyi-MAI/Z-Image` base; ControlNet-Union; Icons.Redmond LoRA. +- **Caveats**: Z-Image-**Edit** was still unreleased as of late Jan 2026 (verify July status) — recolor passes would go through Stack 1/2 models or ControlNet re-runs. + +### Stack 4 — SDXL + LoRAs + LayerDiffuse — "verified-everything control group" +- **Why**: every link verified: **only native-RGBA route** in ComfyUI (`huchenlei/ComfyUI-layerdiffuse`, SD1.5/SDXL only — true alpha from the sampler, no matting); **Vector Illustration XL** LoRA (Civitai 60132, SDXL version file, trigger `color icon`, 35.9k downloads 3028↑/0↓) for icons; most mature ControlNet/IP-Adapter ecosystem; fastest (~30 s/img). +- **Caveats**: weakest prompt/color adherence of the four — viable only with control images + LoRAs doing the heavy lifting. "Game Icon XL" (580146) was inspected and is glossy 3D game-UI style — **wrong for MSP brand**, skip it. + +### Ruled out +- **FLUX.2-dev**: 32B + 24B Mistral TE; Q8 GGUF alone is 32 GB; >20 min/image even on a 16 GB CUDA card. Does not fit 36 GB unified memory in any useful form. +- **FLUX.1-dev/Kontext**: superseded by Klein (same family, better adherence, smaller, unified edit); fp16 ~105 s/img at 30 steps on this exact machine if ever needed as control. +- **SD3.5 / HiDream-I1 / Lumina 2**: no task-relevant evidence surfaced (absence of evidence, not proof of inferiority — but nothing argues for them over the four above). +- Flux flat-icon LoRAs: thin traction, monochrome-only, or white-background-baked — none beats Icons.Redmond/Vector-Illustration-XL. + +## Cross-cutting techniques (apply to whichever stack wins) + +1. **Geometry via control image, not prompt** — research verdict: *no* verified model reliably places sockets vs tabs on named sides from text. We render the 5 frame shapes programmatically (exact spec already in `prompts/badges.md` §SVG Template: 200 px body, 50 px knobs, socket/tab table) → rasterize → canny/lineart control input → the model paints brand style over deterministic geometry. Consistency by construction; ComfyUI-official 3×3 brand-grid template validates the grid+crop pattern (its cloud Gemini node is replaced by our local model). +2. **Exact hex via post-pass, not faith** — vendor "precise color matching" is not bit-exact anywhere. Pipeline: generate → measure (`analyze_color`) → snap flat fills to brand hexes (trivial on flat art: color quantize + palette map). Model adherence only minimizes correction. +3. **Transparency A/B** — flat art on a solid contrast background → compare InSPyReNet vs BiRefNet(-HR) matting on the 2-3 px outlines (no published evidence exists for this art style; must test ourselves) vs LayerDiffuse native alpha (SDXL stack only). Icons generated on pure white can also be flattened/keyed programmatically. +4. **Icon-set consistency** — single-grid generation (strip of 4-5 icons per sheet, ≤2K) + verbatim style prefix + reference-image conditioning where the model supports it (Qwen-Edit-2511 style transfer, Klein multi-reference). + +## Bake-off protocol + +Same test matrix per stack, outputs into one comparison sheet; Jan judges style, metrics judge fidelity: + +| Test | Prompt/input | Scored on | +|---|---|---| +| F1 | 5-frame tier strip, prompt-only | geometry adherence (honesty check) | +| F2 | 5-frame tier strip, control-image-guided | outline uniformity, fill quality | +| I1-I3 | icons: stopwatch (easy), flame (medium), interlocking-puzzle-heart (hard) — style prefix from `prompts/badges.md` | brand-style match, 48 px readability | +| E1 | recolor pass: tier-3 frame → tier-4 fill (Stacks 1-2 only) | structure preservation, hex distance | +| T1 | best F/I outputs → InSPyReNet vs BiRefNet vs LayerDiffuse | edge integrity on 2-3 px outlines | +| — | all of the above | wall-clock per image on the M3 Max | + +Hex fidelity measured with `analyze_color` against the 4 brand colors; expected outcome is a **split verdict** (icons ← Z-Image+LoRA or Klein; frames ← control-image route on Klein/Qwen-Edit), which is fine — production pipeline composes anyway. + +## Setup status (2026-07-17) — COMPLETE + +- [x] Install target: the single Comfy Desktop-adopted install — source `~/ComfyUI-Installs/ComfyUI/ComfyUI` + (v0.28.0, native Flux2 nodes), venv `~/Documents/ComfyUI/.venv`, models `~/Documents/ComfyUI/models`. + The old Klein-on-Desktop caveat is moot (0.28.0 ships Flux2 support; "portable" is Windows-only anyway). + Headless launch (identical UI at http://127.0.0.1:8000): + `cd ~/ComfyUI-Installs/ComfyUI/ComfyUI && ~/Documents/ComfyUI/.venv/bin/python3 main.py --port 8000 --enable-manager --base-directory ~/Documents/ComfyUI` +- [x] `CIVITAI_API_TOKEN` set (`~/.zshrc`) +- [x] All 20 files downloaded (~92 GB): Klein 9B/4B Q8 GGUF + Qwen3-8B/4B TEs + flux2 VAE · + Qwen-Image-Edit-2511 Q6_K + Qwen2.5-VL Q8 TE + mmproj + Lightning 4/8-step LoRAs + qwen VAE · + Z-Image Turbo (bf16 + Q8 GGUF) + Z-Image Base bf16 + distill patch LoRA + ae VAE · + SDXL base + ControlNet-Union-promax + Icons.Redmond (Z-Image) + Vector Illustration XL LoRAs +- [x] Custom nodes installed & imports verified: ComfyUI-GGUF (city96), ComfyUI-layerdiffuse, Inspyrenet-Rembg; + `UnetLoaderGGUF`/`CLIPLoaderGGUF` dropdowns list all GGUFs +- [ ] Z-Image-Edit release status still unconfirmed (not blocking — recolor passes go through Klein/Qwen-Edit) +- [ ] Skipped for now: Z-Image ControlNet-Union (needs VideoX-Fun node pack) and Qwen-Image-Layered + (no GGUF; bf16 40 GB / fp8 MPS-hostile) — revisit only if the bake-off demands them + +## Key sources + +- BFL FLUX.2 prompting guide (hex + JSON `color_match`): docs.bfl.ai/guides/prompting_guide_flux2 +- ComfyUI official 3×3 brand-icon grid template: comfy.org/workflows/templates-3x3_grid_brand_icons-aeda3ae212cd +- Qwen-Image-Edit editing capabilities: qwenlm.github.io/blog/qwen-image-edit + docs.comfy.org/tutorials/image/qwen/qwen-image-edit +- LayerDiffuse native alpha (SDXL): github.com/huchenlei/ComfyUI-layerdiffuse +- MPS bf16 slowdown benchmark: lilting.ch/en/articles/comfyui-qwen-mps-bf16-slowdown; fp8-unsupported + GGUF route: soywiz.com/qwen_image_edit +- Z-Image-Base release: comfyui-wiki.com/en/news/2026-01-28-alibaba-z-image-base-release + github.com/Tongyi-MAI/Z-Image +- FLUX.2 Klein variants/sizes/TEs: medium.com/diffusion-doodles/flux-2-klein-shrinking-flux-2-dev +- Icons.Redmond Z-Image port: civitai.com/models/122827 (version 2705464) · Vector Illustration XL: civitai.com/models/60132 diff --git a/docs/design-system/badge-pipeline/README.md b/docs/design-system/badge-pipeline/README.md new file mode 100644 index 000000000..62c920ae2 --- /dev/null +++ b/docs/design-system/badge-pipeline/README.md @@ -0,0 +1,70 @@ +# Badge Generation Pipeline (ComfyUI, local) + +Validated end-to-end 2026-07-17 on the Speed Demon set (5 tiers). Model research and stack +rationale: `../badge-generation-comfyui.md`. Visual spec (socket/tab table, fills, outline): +`../prompts/badges.md`. + +## The validated production recipe + +``` +1. render frames — deterministic PIL renderer, exact spec geometry per tier (badge_pipeline.py: render_frame) +2. generate icon — FLUX.2 Klein 9B Q8 GGUF, brand style + hex prompt (bakeoff_icons.py: g_klein graph) +3. cut out icon — border-connected FLOOD FILL, *not* neural matting (badge_pipeline2.py: floodfill_cutout) +4. compose — icon alpha-pasted onto the 5 frames (badge_pipeline.py: compose_set) +5. polish (optional)— Klein 9B edit pass per badge, PER-TIER fill prompt (badge_pipeline3.py: COMMON + TIER_FILL) +6. cut out result — flood fill again -> final transparent PNG +``` + +Two shippable styles come out of this: +- **Row B ("pure flat")** — stop after step 4: 100 % deterministic fills/gradients, AI only draws the icon. +- **Row C ("hand-illustrated")** — after step 5: organic linework/gradients, geometry still exact. +Evidence: `results-speed-demon-v3.jpg` (top row = B, bottom row = C). + +## Key experimental findings (don't relearn these) + +1. **Prompt-only geometry fails** — the one-shot 5-badge grid (`results-pure-ai-grid.jpg`) gives + beautiful icon/style consistency but ignores the socket/tab table and draws "sockets" as circles + inside the body. Geometry must come from the deterministic renderer (or Jan's art). +2. **Neural matting destroys flat art** — Inspyrenet removed the icon's *interior* white fills + (transparent clock face). Border-connected flood fill is exact on flat art with closed outlines, + and free. Use `floodfill_cutout` for both icon extraction and final badge cutout. +3. **Klein edit passes preserve geometry 1:1** — 11/11 runs kept every tab/socket where the input + had it. Safe to polish deterministic geometry. +4. **Text beats reference in Klein edit mode** — whatever fill the prompt describes overrides the + input image's fill (round-2 regression: one shared prompt repainted all five tiers coral→sky). + Therefore each tier's polish prompt must state that tier's fill exactly (`TIER_FILL` dict). +5. **MPS practicalities** — bf16 z-image beat its Q8 GGUF on speed (98 s vs 152 s); never use fp8 + files; icons ~70–150 s each, polish passes ~4–6.5 min each on the M3 Max. + +## Model verdict (icon round 1, `results-icon-round1.jpg`) + +| Model | Result | +|---|---| +| **FLUX.2 Klein 9B Q8** | Winner — full subject adherence (only one to draw the speed-lines), outline `#252b3f` (Δ12 from brand navy), MSP-like line style | +| FLUX.2 Klein 4B Q8 | Same language, simpler, 2× faster — good for iteration | +| Qwen-Image-Edit-2511 + Lightning | Strong, bolder stroke; reserve for reference-based edits | +| Z-Image Turbo (± Icons.Redmond LoRA) | Different flavor: bold filled app-icon style — alternative direction, not the default | +| SDXL + Vector Illustration XL | Out — subject drift (drew a wall clock), no coral | + +## Running it + +Scripts expect ComfyUI at `127.0.0.1:8000` (headless launch command in +`../badge-generation-comfyui.md` §Setup) and write to `badge-set/` next to the script. + +```bash +python3 badge_pipeline.py frames # render + verify the 5 tier frames only +python3 badge_pipeline.py run # matting/grid/compose/polish experiment (round 1 layout) +python3 badge_pipeline2.py # flood-fill icon, compose, polish all tiers (one prompt — kept for history) +python3 badge_pipeline3.py # per-tier polish prompts — the validated final round +``` + +## Open items before production run + +- [ ] Jan picks the style: Row B (pure flat) vs Row C (hand-illustrated) +- [ ] Scale test: 2–3 more achievement icons (flame, trophy, puzzle-heart) through the full recipe + to verify cross-achievement coherence +- [ ] Color-snap post-pass for Row C (gradients drift slightly warm) — flat-art palette quantize +- [ ] If Row C: decide polish granularity — per composed badge (80 runs ≈ 6–9 h unattended) vs + per part (5 frames + 16 icons = 21 runs ≈ 2 h, then compose) +- [ ] Icon zone/knob shape tuning if Jan supplies his own frame art later (renderer is a placeholder + with exact spec geometry) diff --git a/docs/design-system/badge-pipeline/badge_pipeline.py b/docs/design-system/badge-pipeline/badge_pipeline.py new file mode 100644 index 000000000..67d4a9d34 --- /dev/null +++ b/docs/design-system/badge-pipeline/badge_pipeline.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Speed Demon full-set experiment: A) AI grid B) deterministic frames + AI icon C) AI polish pass. + +Usage: badge_pipeline.py frames|run + frames — render the 5 tier frames only (fast, for visual verification) + run — full end-to-end: matting -> pipeline A -> compose B -> pipeline C -> sheets +""" +import json, os, shutil, sys, time, urllib.request +from PIL import Image, ImageDraw, ImageFilter + +API = "http://127.0.0.1:8000" +ROOT = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(ROOT, "badge-set") +COMFY_IN = os.path.expanduser("~/Documents/ComfyUI/input") +COMFY_OUT = os.path.expanduser("~/Documents/ComfyUI/output") +os.makedirs(OUT, exist_ok=True) + +NAVY = (0x2b, 0x34, 0x45, 255) +CORAL = (0xEC, 0x72, 0x6F) +SKY = (0x69, 0xb3, 0xfe) +INDIGO = (0x4e, 0x54, 0xc8) +GRAY = (0xf6, 0xf9, 0xfc) +SEED = 42 + +# ---------------- deterministic frame renderer ---------------- +# tier -> (tabs on [top, right, bottom, left], fill spec) +TIERS = { + 1: ([0, 0, 0, 0], ("flat", GRAY)), + 2: ([1, 0, 0, 0], ("flat", CORAL)), + 3: ([1, 1, 0, 0], ("grad", CORAL, SKY)), + 4: ([1, 1, 1, 0], ("grad", CORAL, INDIGO)), + 5: ([1, 1, 1, 1], ("grad_sparkle", CORAL, INDIGO)), +} + +def diagonal_gradient(size, c1, c2): + g = Image.new("RGB", (64, 64)) + px = g.load() + for y in range(64): + for x in range(64): + t = (x + y) / 126.0 + px[x, y] = tuple(int(a + (b - a) * t) for a, b in zip(c1, c2)) + return g.resize(size, Image.BILINEAR) + +def render_frame(tier, final=1024): + W = final * 2 # supersample 2x + B = int(W * 0.55) # body size + r_corner = int(B * 0.16) + knob_d = int(B * 0.25) + off = int(B * 0.075) # knob center offset from edge line + x0 = (W - B) // 2 + y0 = (W - B) // 2 + x1, y1 = x0 + B, y0 + B + cx, cy = W // 2, W // 2 + + mask = Image.new("L", (W, W), 0) + d = ImageDraw.Draw(mask) + d.rounded_rectangle([x0, y0, x1, y1], radius=r_corner, fill=255) + tabs = TIERS[tier][0] + centers = { # side -> (knob center for tab, for socket) + 0: ((cx, y0 - off), (cx, y0 + off)), + 1: ((x1 + off, cy), (x1 - off, cy)), + 2: ((cx, y1 + off), (cx, y1 - off)), + 3: ((x0 - off, cy), (x0 + off, cy)), + } + rr = knob_d // 2 + for side in range(4): # tabs first (add), sockets after (cut) + if tabs[side]: + px, py = centers[side][0] + d.ellipse([px - rr, py - rr, px + rr, py + rr], fill=255) + for side in range(4): + if not tabs[side]: + px, py = centers[side][1] + d.ellipse([px - rr, py - rr, px + rr, py + rr], fill=0) + + stroke = 17 # MaxFilter/MinFilter kernel -> ~16px ring at 2x + ring = Image.new("L", (W, W), 0) + grown = mask.filter(ImageFilter.MaxFilter(stroke)) + shrunk = mask.filter(ImageFilter.MinFilter(stroke)) + ring.paste(255, (0, 0), grown) + ring.paste(0, (0, 0), shrunk) + + fill_spec = TIERS[tier][1] + if fill_spec[0] == "flat": + fill_img = Image.new("RGB", (W, W), fill_spec[1]) + else: + fill_img = diagonal_gradient((W, W), fill_spec[1], fill_spec[2]) + + canvas = Image.new("RGBA", (W, W), (0, 0, 0, 0)) + canvas.paste(fill_img, (0, 0), mask) + navy_img = Image.new("RGBA", (W, W), NAVY) + canvas.paste(navy_img, (0, 0), ring) + + if fill_spec[0] == "grad_sparkle": + sp = ImageDraw.Draw(canvas) + def star(cx_, cy_, r): + pts = [(cx_, cy_ - r), (cx_ + r * .22, cy_ - r * .22), (cx_ + r, cy_), + (cx_ + r * .22, cy_ + r * .22), (cx_, cy_ + r), (cx_ - r * .22, cy_ + r * .22), + (cx_ - r, cy_), (cx_ - r * .22, cy_ - r * .22)] + sp.polygon(pts, fill=(255, 255, 255, 235)) + star(x0 + B * .26, y0 + B * .22, B * .052) + star(x0 + B * .78, y0 + B * .16, B * .034) + star(x0 + B * .74, y0 + B * .80, B * .044) + for dx, dy, r in [(.18, .64, .013), (.84, .46, .011)]: + sp.ellipse([x0 + B * dx - B * r, y0 + B * dy - B * r, + x0 + B * dx + B * r, y0 + B * dy + B * r], fill=(255, 255, 255, 220)) + + return canvas.resize((final, final), Image.LANCZOS) + +def render_all_frames(): + paths = [] + for t in range(1, 6): + p = os.path.join(OUT, f"frame-tier{t}.png") + render_frame(t).save(p) + paths.append(p) + print(f"frame tier {t} -> {p}", flush=True) + sheet = Image.new("RGBA", (5 * 512, 512), (245, 246, 250, 255)) + for i, p in enumerate(paths): + sheet.paste(Image.open(p).resize((512, 512)), (i * 512, 0)) + sp = os.path.join(OUT, "frames-sheet.png") + sheet.convert("RGB").save(sp) + print(f"frames sheet -> {sp}", flush=True) + return paths + +# ---------------- comfy helpers ---------------- +def post(path, payload): + req = urllib.request.Request(API + path, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r) if r.length else {} + +def run_graph(name, graph, timeout=1200): + print(f"\n=== {name}", flush=True) + t0 = time.time() + res = post("/prompt", {"prompt": graph}) + pid = res.get("prompt_id") + if not pid: + print(f" REJECTED: {json.dumps(res)[:600]}", flush=True) + return None + while time.time() - t0 < timeout: + time.sleep(2) + try: + with urllib.request.urlopen(f"{API}/history/{pid}", timeout=30) as r: + h = json.load(r).get(pid) + except Exception: + continue + if not h: + continue + st = h.get("status", {}) + if st.get("completed"): + files = [] + for node_out in h.get("outputs", {}).values(): + for img in node_out.get("images", []): + files.append(os.path.join(COMFY_OUT, img.get("subfolder", ""), img["filename"])) + print(f" DONE in {time.time()-t0:.0f}s -> {files}", flush=True) + return files + if st.get("status_str") == "error": + msgs = [m for m in st.get("messages", []) if m[0] == "execution_error"] + print(f" ERROR: {(msgs[0][1].get('exception_message','?') if msgs else '?')[:400]}", flush=True) + return None + print(" TIMEOUT", flush=True) + return None + +def free_memory(): + try: + post("/free", {"unload_models": True, "free_memory": True}) + time.sleep(2) + except Exception: + pass + +# ---------------- pipeline jobs ---------------- +GRID_PROMPT = ( + "A horizontal row of five achievement badges on a pure white background, evenly spaced. " + "Each badge is a jigsaw puzzle piece with a rounded square body and one connector knob on each of its four sides, " + "drawn as a flat vector illustration with uniform dark navy #2b3445 outlines. " + "All five badges contain the same small stopwatch icon at the center, drawn with navy outlines, white fill and a coral accent. " + "Badge 1: all four connectors are concave sockets cut inward; body filled flat very light gray #f6f9fc. " + "Badge 2: the top connector is a convex tab sticking out, the other three are concave sockets; body filled flat coral #EC726F. " + "Badge 3: top and right connectors are convex tabs, bottom and left are concave sockets; body filled with a diagonal gradient from coral #EC726F to sky blue #69b3fe. " + "Badge 4: top, right and bottom connectors are convex tabs, only left is a concave socket; body diagonal gradient from coral #EC726F to indigo #4e54c8. " + "Badge 5: all four connectors are convex tabs; body rich diagonal gradient from coral #EC726F to indigo #4e54c8 with small white sparkles. " + "Flat vector style, no text, no shadows, crisp uniform 2px outlines." +) + +EDIT_PROMPT = ( + "Redraw this badge as a polished flat vector illustration. Keep the exact same jigsaw puzzle piece shape, " + "the same connector knob positions, the same diagonal coral #EC726F to sky blue #69b3fe gradient fill, " + "the same dark navy #2b3445 outline and the same stopwatch icon in the center. " + "Make the linework feel hand-illustrated: smooth rounded outlines with uniform weight, gentle soft shading inside fills. " + "Pure white background. Do not change the geometry, do not add or remove elements, no text." +) + +def klein_loaders(g): + g["1"] = {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "flux-2-klein-9b-Q8_0.gguf"}} + g["2"] = {"class_type": "CLIPLoaderGGUF", "inputs": {"clip_name": "Qwen3-8B-Q8_0.gguf", "type": "flux2"}} + g["3"] = {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}} + return g + +def g_klein_grid(width=2048, height=512, steps=8): + g = klein_loaders({}) + g.update({ + "4": {"class_type": "CLIPTextEncode", "inputs": {"text": GRID_PROMPT, "clip": ["2", 0]}}, + "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}, + "6": {"class_type": "CFGGuider", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "cfg": 1.0}}, + "7": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}, + "8": {"class_type": "Flux2Scheduler", "inputs": {"steps": steps, "width": width, "height": height}}, + "9": {"class_type": "RandomNoise", "inputs": {"noise_seed": SEED}}, + "10": {"class_type": "EmptyFlux2LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}}, + "11": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["9", 0], "guider": ["6", 0], + "sampler": ["7", 0], "sigmas": ["8", 0], "latent_image": ["10", 0]}}, + "12": {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0], "vae": ["3", 0]}}, + "13": {"class_type": "SaveImage", "inputs": {"filename_prefix": "set_A_grid", "images": ["12", 0]}}, + }) + return g + +def g_matting(input_name): + return { + "1": {"class_type": "LoadImage", "inputs": {"image": input_name}}, + "2": {"class_type": "InspyrenetRembg", "inputs": {"image": ["1", 0], "torchscript_jit": "default"}}, + "3": {"class_type": "SaveImage", "inputs": {"filename_prefix": "set_B_icon_rgba", "images": ["2", 0]}}, + } + +def g_klein_edit(input_name, steps=8, prompt=None): + g = klein_loaders({}) + g.update({ + "4": {"class_type": "LoadImage", "inputs": {"image": input_name}}, + "5": {"class_type": "ImageScaleToTotalPixels", "inputs": {"image": ["4", 0], "upscale_method": "lanczos", + "megapixels": 1.0, "resolution_steps": 1}}, + "6": {"class_type": "VAEEncode", "inputs": {"pixels": ["5", 0], "vae": ["3", 0]}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt or EDIT_PROMPT, "clip": ["2", 0]}}, + "8": {"class_type": "ReferenceLatent", "inputs": {"conditioning": ["7", 0], "latent": ["6", 0]}}, + "9": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["8", 0]}}, + "10": {"class_type": "CFGGuider", "inputs": {"model": ["1", 0], "positive": ["8", 0], "negative": ["9", 0], "cfg": 1.0}}, + "11": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}, + "12": {"class_type": "Flux2Scheduler", "inputs": {"steps": steps, "width": 1024, "height": 1024}}, + "13": {"class_type": "RandomNoise", "inputs": {"noise_seed": SEED}}, + "14": {"class_type": "EmptyFlux2LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}}, + "15": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["13", 0], "guider": ["10", 0], + "sampler": ["11", 0], "sigmas": ["12", 0], "latent_image": ["14", 0]}}, + "16": {"class_type": "VAEDecode", "inputs": {"samples": ["15", 0], "vae": ["3", 0]}}, + "17": {"class_type": "SaveImage", "inputs": {"filename_prefix": "set_C_polish", "images": ["16", 0]}}, + }) + return g + +# ---------------- composition ---------------- +def compose_set(frame_paths, icon_rgba_path): + icon = Image.open(icon_rgba_path).convert("RGBA") + bbox = icon.getbbox() + icon = icon.crop(bbox) + badges = [] + for t, fp in zip(range(1, 6), frame_paths): + frame = Image.open(fp).convert("RGBA") + W = frame.width + body = int(W * 0.55) + target = int(body * 0.52) + ic = icon.copy() + scale = min(target / ic.width, target / ic.height) + ic = ic.resize((max(1, int(ic.width * scale)), max(1, int(ic.height * scale))), Image.LANCZOS) + out = frame.copy() + out.alpha_composite(ic, ((W - ic.width) // 2, (W - ic.height) // 2)) + p = os.path.join(OUT, f"badge-tier{t}.png") + out.save(p) + badges.append(p) + print(f"composed tier {t} -> {p}", flush=True) + return badges + +def final_sheet(a_grid, badges, c_before, c_after): + TH = 384 + rows = 3 + sheet = Image.new("RGB", (TH * 5, TH * rows + 40 * rows), (245, 246, 250)) + d = ImageDraw.Draw(sheet) + y = 0 + d.text((10, y + 4), "A — pure AI grid (Klein 9B, one prompt)", fill=(43, 52, 69)) + if a_grid: + g = Image.open(a_grid).convert("RGB") + g = g.resize((TH * 5, int(g.height * (TH * 5 / g.width)))) + sheet.paste(g, (0, y + 40 + max(0, (TH - g.height) // 2))) + y += TH + 40 + d.text((10, y + 4), "B — deterministic frames + AI icon (composed)", fill=(43, 52, 69)) + for i, p in enumerate(badges): + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)) + bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, y + 40)) + y += TH + 40 + d.text((10, y + 4), "C — AI polish pass over composed tier-3 (left: input, right: Klein output)", fill=(43, 52, 69)) + for i, p in enumerate([c_before, c_after]): + if p and os.path.exists(p): + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)) + bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, y + 40)) + p = os.path.join(OUT, "speed-demon-set-sheet.png") + sheet.save(p) + print(f"\nFINAL SHEET -> {p}", flush=True) + return p + +# ---------------- main ---------------- +def main(): + stage = sys.argv[1] if len(sys.argv) > 1 else "run" + frames = render_all_frames() + if stage == "frames": + return + + # 1) matting of the round-1 winning icon (Klein 9B stopwatch) + icon_src = os.path.join(ROOT, "bakeoff", "klein9b.png") + shutil.copy(icon_src, os.path.join(COMFY_IN, "set_icon_src.png")) + matte_files = run_graph("matting (Inspyrenet)", g_matting("set_icon_src.png")) + icon_rgba = None + if matte_files: + icon_rgba = os.path.join(OUT, "icon-rgba.png") + shutil.copy(matte_files[0], icon_rgba) + + # 2) pipeline A: one-shot AI grid + a_files = run_graph("pipeline A: Klein 9B 5-badge grid", g_klein_grid()) + a_grid = None + if a_files: + a_grid = os.path.join(OUT, "A-grid.png") + shutil.copy(a_files[0], a_grid) + + # 3) pipeline B: compose + badges = [] + if icon_rgba: + badges = compose_set(frames, icon_rgba) + + # 4) pipeline C: polish pass on composed tier-3 + c_after = None + c_before = badges[2] if len(badges) >= 3 else None + if c_before: + flat = Image.new("RGBA", (1024, 1024), (255, 255, 255, 255)) + flat.alpha_composite(Image.open(c_before).convert("RGBA")) + flat.convert("RGB").save(os.path.join(COMFY_IN, "set_tier3_input.png")) + c_files = run_graph("pipeline C: Klein 9B polish pass", g_klein_edit("set_tier3_input.png")) + if c_files: + c_after = os.path.join(OUT, "C-polish.png") + shutil.copy(c_files[0], c_after) + free_memory() + + final_sheet(a_grid, badges, c_before, c_after) + print("\nDONE. Results in", OUT, flush=True) + +if __name__ == "__main__": + main() diff --git a/docs/design-system/badge-pipeline/badge_pipeline2.py b/docs/design-system/badge-pipeline/badge_pipeline2.py new file mode 100644 index 000000000..69f628b5d --- /dev/null +++ b/docs/design-system/badge-pipeline/badge_pipeline2.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Round 2: flood-fill cutout (replaces neural matting), re-compose, Klein polish across ALL 5 tiers.""" +import os, shutil, sys +from PIL import Image, ImageDraw +from badge_pipeline import (OUT, COMFY_IN, ROOT, run_graph, g_klein_edit, free_memory, + render_all_frames, compose_set, EDIT_PROMPT) + +def floodfill_cutout(src_path, dst_path, thresh=40): + """Remove only background-connected near-white pixels; keep interior white fills.""" + im = Image.open(src_path).convert("RGB") + pad = Image.new("RGB", (im.width + 2, im.height + 2), (255, 255, 255)) + pad.paste(im, (1, 1)) + MAGIC = (255, 0, 255) + ImageDraw.floodfill(pad, (0, 0), MAGIC, thresh=thresh) + rgba = pad.convert("RGBA") + px = rgba.load() + for y in range(rgba.height): + for x in range(rgba.width): + if px[x, y][:3] == MAGIC: + px[x, y] = (0, 0, 0, 0) + out = rgba.crop((1, 1, rgba.width - 1, rgba.height - 1)) + out.save(dst_path) + print(f"floodfill cutout -> {dst_path}", flush=True) + return dst_path + +def main(): + frames = [os.path.join(OUT, f"frame-tier{t}.png") for t in range(1, 6)] + if not all(os.path.exists(p) for p in frames): + frames = render_all_frames() + + # 1) proper icon cutout via flood fill (keeps white clock face) + icon_src = os.path.join(ROOT, "bakeoff", "klein9b.png") + icon_rgba = floodfill_cutout(icon_src, os.path.join(OUT, "icon-rgba-v2.png")) + + # 2) re-compose all 5 tiers with the fixed icon + badges = compose_set(frames, icon_rgba) + + # 3) Klein polish pass on every tier + polished = [] + for t, b in zip(range(1, 6), badges): + flat = Image.new("RGBA", (1024, 1024), (255, 255, 255, 255)) + flat.alpha_composite(Image.open(b).convert("RGBA")) + in_name = f"set_tier{t}_input.png" + flat.convert("RGB").save(os.path.join(COMFY_IN, in_name)) + files = run_graph(f"polish tier {t}", g_klein_edit(in_name)) + if files: + dst = os.path.join(OUT, f"polished-tier{t}.png") + shutil.copy(files[0], dst) + polished.append(dst) + else: + polished.append(None) + free_memory() + + # 4) background cutout on polished outputs -> final RGBA assets + finals = [] + for t, p in zip(range(1, 6), polished): + if p: + finals.append(floodfill_cutout(p, os.path.join(OUT, f"final-tier{t}.png"), thresh=25)) + else: + finals.append(None) + + # 5) sheet v2: composed row vs polished row + TH = 384 + sheet = Image.new("RGB", (TH * 5, (TH + 40) * 2), (245, 246, 250)) + d = ImageDraw.Draw(sheet) + d.text((10, 4), "B v2 — deterministic frames + flood-fill icon (composed input)", fill=(43, 52, 69)) + for i, p in enumerate(badges): + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)); bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, 40)) + d.text((10, TH + 44), "C v2 — after Klein 9B polish pass (final, bg removed)", fill=(43, 52, 69)) + for i, p in enumerate(finals): + if not p: continue + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)); bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, TH + 80)) + sp = os.path.join(OUT, "speed-demon-v2-sheet.png") + sheet.save(sp) + print(f"\nSHEET V2 -> {sp}", flush=True) + +if __name__ == "__main__": + main() diff --git a/docs/design-system/badge-pipeline/badge_pipeline3.py b/docs/design-system/badge-pipeline/badge_pipeline3.py new file mode 100644 index 000000000..f9e5e2320 --- /dev/null +++ b/docs/design-system/badge-pipeline/badge_pipeline3.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Round 3: per-tier polish prompts (fix the fill-override bug from round 2).""" +import os, shutil +from PIL import Image, ImageDraw +from badge_pipeline import OUT, COMFY_IN, run_graph, g_klein_edit, free_memory +from badge_pipeline2 import floodfill_cutout + +COMMON = ("Redraw this achievement badge as a polished flat vector illustration. " + "Keep the exact same jigsaw puzzle piece shape and connector knob positions, " + "the same uniform dark navy #2b3445 outline, and the same stopwatch icon with " + "white clock face and coral accents in the center. Hand-illustrated feel with smooth " + "rounded linework of uniform weight. Pure white background. " + "Do not change the geometry, do not add or remove elements, no text. ") + +TIER_FILL = { + 1: "The badge body fill is flat very light gray #f6f9fc — keep it flat and light, no gradient, no other body colors.", + 2: "The badge body fill is flat coral #EC726F — keep it flat solid coral, no gradient, no blue.", + 3: "The badge body fill is a smooth soft diagonal gradient: coral #EC726F at the top-left blending gently into sky blue #69b3fe at the bottom-right.", + 4: "The badge body fill is a smooth soft diagonal gradient: coral #EC726F at the top-left blending gently into deep indigo #4e54c8 at the bottom-right.", + 5: ("The badge body fill is a rich smooth diagonal gradient: coral #EC726F at the top-left blending into deep indigo #4e54c8 " + "at the bottom-right, decorated with a few small white four-pointed sparkle stars — keep the sparkles."), +} + +def main(): + badges = [os.path.join(OUT, f"badge-tier{t}.png") for t in range(1, 6)] + polished, finals = [], [] + for t, b in zip(range(1, 6), badges): + flat = Image.new("RGBA", (1024, 1024), (255, 255, 255, 255)) + flat.alpha_composite(Image.open(b).convert("RGBA")) + in_name = f"set_tier{t}_input.png" + flat.convert("RGB").save(os.path.join(COMFY_IN, in_name)) + files = run_graph(f"polish v3 tier {t}", g_klein_edit(in_name, prompt=COMMON + TIER_FILL[t])) + if files: + dst = os.path.join(OUT, f"polished3-tier{t}.png") + shutil.copy(files[0], dst) + polished.append(dst) + finals.append(floodfill_cutout(dst, os.path.join(OUT, f"final3-tier{t}.png"), thresh=25)) + else: + polished.append(None); finals.append(None) + free_memory() + + TH = 384 + sheet = Image.new("RGB", (TH * 5, (TH + 40) * 2), (245, 246, 250)) + d = ImageDraw.Draw(sheet) + d.text((10, 4), "input (composed, deterministic)", fill=(43, 52, 69)) + for i, p in enumerate(badges): + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)); bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, 40)) + d.text((10, TH + 44), "v3 polished (per-tier prompts, bg removed)", fill=(43, 52, 69)) + for i, p in enumerate(finals): + if not p: continue + im = Image.open(p).convert("RGBA") + bg = Image.new("RGBA", im.size, (255, 255, 255, 255)); bg.alpha_composite(im) + sheet.paste(bg.convert("RGB").resize((TH, TH)), (i * TH, TH + 80)) + sp = os.path.join(OUT, "speed-demon-v3-sheet.png") + sheet.save(sp) + print(f"\nSHEET V3 -> {sp}", flush=True) + +if __name__ == "__main__": + main() diff --git a/docs/design-system/badge-pipeline/bakeoff_icons.py b/docs/design-system/badge-pipeline/bakeoff_icons.py new file mode 100644 index 000000000..c46e0c63a --- /dev/null +++ b/docs/design-system/badge-pipeline/bakeoff_icons.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Badge bake-off — icon round (stopwatch subject) across 4 model families.""" +import json, time, urllib.request, shutil, os, sys + +API = "http://127.0.0.1:8000" +OUT_DIR = os.path.expanduser("~/Documents/ComfyUI/output") +RESULTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bakeoff") +os.makedirs(RESULTS, exist_ok=True) + +STYLE = ("semi-flat vector icon, thick dark navy #2b3445 outlines with rounded caps, " + "white fills with soft coral #EC726F accents, minimal detail, rounded friendly geometry, " + "centered composition, plain white background, no text, no shadows") +SUBJECT = ("a stopwatch with a round clock face, two clock hands, a small press button on top, " + "and two tiny horizontal speed-line dashes to the right") +KLEIN_PROMPT = ("Flat vector icon of a stopwatch with a round clock face, two clock hands, " + "a small press button on top, and two tiny speed-line dashes on the right. " + "Uniform 2px outlines in exact color #2b3445 with rounded caps and joins. " + "Fills: white #ffffff base; coral #EC726F accents on the press button and one clock hand. " + "Semi-flat minimal illustration, rounded friendly geometry, centered, " + "plain pure white #ffffff background, no text, no shadows, no gradients.") +NEG = "photo, photorealistic, 3d render, gradients, drop shadow, texture, noise, complex background, text, watermark" +SEED = 42 + +def post(path, payload): + req = urllib.request.Request(API + path, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r) if r.length else {} + +def free_memory(): + try: + post("/free", {"unload_models": True, "free_memory": True}) + time.sleep(2) + except Exception as e: + print(f" (free_memory failed: {e})", flush=True) + +def run(name, graph, timeout=900): + print(f"\n=== {name}", flush=True) + t0 = time.time() + try: + res = post("/prompt", {"prompt": graph}) + except Exception as e: + print(f" ENQUEUE FAILED: {e}", flush=True) + return {"name": name, "status": "enqueue_failed", "error": str(e)} + pid = res.get("prompt_id") + if not pid: + print(f" REJECTED: {json.dumps(res)[:500]}", flush=True) + return {"name": name, "status": "rejected", "error": json.dumps(res)[:500]} + while time.time() - t0 < timeout: + time.sleep(2) + try: + with urllib.request.urlopen(f"{API}/history/{pid}", timeout=30) as r: + h = json.load(r).get(pid) + except Exception: + continue + if not h: + continue + st = h.get("status", {}) + if st.get("completed"): + dt = time.time() - t0 + files = [] + for node_out in h.get("outputs", {}).values(): + for img in node_out.get("images", []): + src = os.path.join(OUT_DIR, img.get("subfolder", ""), img["filename"]) + dst = os.path.join(RESULTS, f"{name}.png") + try: + shutil.copy(src, dst) + files.append(dst) + except Exception as e: + print(f" copy failed {src}: {e}", flush=True) + print(f" DONE in {dt:.0f}s -> {files}", flush=True) + return {"name": name, "status": "ok", "seconds": round(dt), "files": files} + if st.get("status_str") == "error": + msgs = [m for m in st.get("messages", []) if m[0] == "execution_error"] + err = (msgs[0][1].get("exception_message", "?") if msgs else "?")[:400] + print(f" ERROR after {time.time()-t0:.0f}s: {err}", flush=True) + return {"name": name, "status": "error", "error": err} + print(f" TIMEOUT after {timeout}s", flush=True) + return {"name": name, "status": "timeout"} + +def save(prefix, images_ref): + return {"class_type": "SaveImage", "inputs": {"filename_prefix": prefix, "images": images_ref}} + +# ---------- graphs ---------- + +def g_sdxl_vector_lora(): + p = f"color icon, flat vector icon of {SUBJECT}, {STYLE}" + return { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"}}, + "2": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], + "lora_name": "Vector_illustration_XL.safetensors", "strength_model": 0.9, "strength_clip": 0.9}}, + "3": {"class_type": "CLIPTextEncode", "inputs": {"text": p, "clip": ["2", 1]}}, + "4": {"class_type": "CLIPTextEncode", "inputs": {"text": NEG, "clip": ["2", 1]}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}}, + "6": {"class_type": "KSampler", "inputs": {"seed": SEED, "steps": 25, "cfg": 7.0, + "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0, + "model": ["2", 0], "positive": ["3", 0], "negative": ["4", 0], "latent_image": ["5", 0]}}, + "7": {"class_type": "VAEDecode", "inputs": {"samples": ["6", 0], "vae": ["1", 2]}}, + "8": save("bake_sdxl-vectorlora", ["7", 0]), + } + +def g_zimage(unet_gguf=False, icons_lora=False): + p = f"flat vector icon of {SUBJECT}, {STYLE}" + if icons_lora: + p = "ICREDM, ICONS, app icon, " + p + g = {} + if unet_gguf: + g["1"] = {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "z_image_turbo-Q8_0.gguf"}} + else: + g["1"] = {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}} + g["3"] = {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}} + model_ref, clip_ref = ["1", 0], ["3", 0] + if icons_lora: + g["11"] = {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["3", 0], + "lora_name": "Icons_Redmond_ZImageTurbo.safetensors", "strength_model": 1.0, "strength_clip": 1.0}} + model_ref, clip_ref = ["11", 0], ["11", 1] + g["2"] = {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": model_ref, "shift": 3.0}} + g["4"] = {"class_type": "CLIPTextEncode", "inputs": {"text": p, "clip": clip_ref}} + g["5"] = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}} + g["6"] = {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}} + g["7"] = {"class_type": "KSampler", "inputs": {"seed": SEED, "steps": 9, "cfg": 1.0, + "sampler_name": "res_multistep", "scheduler": "simple", "denoise": 1.0, + "model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0]}} + g["8"] = {"class_type": "VAELoader", "inputs": {"vae_name": "z_image_ae.safetensors"}} + g["9"] = {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["8", 0]}} + suffix = "gguf" if unet_gguf else ("iconslora" if icons_lora else "bf16") + g["10"] = save(f"bake_zimage-{suffix}", ["9", 0]) + return g + +def g_klein(size="9b", steps=8): + if size == "9b": + unet = {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "flux-2-klein-9b-Q8_0.gguf"}} + clip = {"class_type": "CLIPLoaderGGUF", "inputs": {"clip_name": "Qwen3-8B-Q8_0.gguf", "type": "flux2"}} + else: + unet = {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "flux-2-klein-4b-Q8_0.gguf"}} + clip = {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "flux2", "device": "default"}} + return { + "1": unet, "2": clip, + "3": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}}, + "4": {"class_type": "CLIPTextEncode", "inputs": {"text": KLEIN_PROMPT, "clip": ["2", 0]}}, + "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}, + "6": {"class_type": "CFGGuider", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "cfg": 1.0}}, + "7": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}, + "8": {"class_type": "Flux2Scheduler", "inputs": {"steps": steps, "width": 1024, "height": 1024}}, + "9": {"class_type": "RandomNoise", "inputs": {"noise_seed": SEED}}, + "10": {"class_type": "EmptyFlux2LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}}, + "11": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["9", 0], "guider": ["6", 0], + "sampler": ["7", 0], "sigmas": ["8", 0], "latent_image": ["10", 0]}}, + "12": {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0], "vae": ["3", 0]}}, + "13": save(f"bake_klein{size}", ["12", 0]), + } + +def g_qwen_edit_t2i(): + return { + "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "qwen-image-edit-2511-Q6_K.gguf"}}, + "2": {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0], + "lora_name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors", "strength_model": 1.0}}, + "3": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["2", 0], "shift": 3.1}}, + "4": {"class_type": "CFGNorm", "inputs": {"model": ["3", 0], "strength": 1.0}}, + "5": {"class_type": "CLIPLoaderGGUF", "inputs": {"clip_name": "Qwen2.5-VL-7B-Instruct-Q8_0.gguf", "type": "qwen_image"}}, + "6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["5", 0], "prompt": KLEIN_PROMPT}}, + "7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["5", 0], "prompt": ""}}, + "8": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}}, + "9": {"class_type": "KSampler", "inputs": {"seed": SEED, "steps": 4, "cfg": 1.0, + "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, + "model": ["4", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["8", 0]}}, + "10": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_vae.safetensors"}}, + "11": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["10", 0]}}, + "12": save("bake_qwen-edit-t2i", ["11", 0]), + } + +# ---------- run ---------- +results = [] +results.append(run("sdxl-vectorlora", g_sdxl_vector_lora())) +free_memory() +results.append(run("zimage-turbo-bf16", g_zimage())) +results.append(run("zimage-turbo-iconslora", g_zimage(icons_lora=True))) +results.append(run("zimage-turbo-gguf", g_zimage(unet_gguf=True))) +free_memory() +results.append(run("klein4b", g_klein("4b"))) +free_memory() +results.append(run("klein9b", g_klein("9b"))) +free_memory() +results.append(run("qwen-edit-t2i", g_qwen_edit_t2i(), timeout=1500)) +free_memory() + +print("\n===== BAKE-OFF SUMMARY =====") +for r in results: + line = f"{r['name']:26s} {r['status']:8s}" + if r.get("seconds") is not None: + line += f" {r['seconds']:4d}s" + if r.get("error"): + line += f" {r['error'][:160]}" + print(line) +print(f"\nResults dir: {RESULTS}") +sys.exit(0 if all(r["status"] == "ok" for r in results) else 1) diff --git a/docs/design-system/badge-pipeline/results-icon-round1.jpg b/docs/design-system/badge-pipeline/results-icon-round1.jpg new file mode 100644 index 000000000..80f3aea1c Binary files /dev/null and b/docs/design-system/badge-pipeline/results-icon-round1.jpg differ diff --git a/docs/design-system/badge-pipeline/results-pure-ai-grid.jpg b/docs/design-system/badge-pipeline/results-pure-ai-grid.jpg new file mode 100644 index 000000000..c326631dd Binary files /dev/null and b/docs/design-system/badge-pipeline/results-pure-ai-grid.jpg differ diff --git a/docs/design-system/badge-pipeline/results-speed-demon-v3.jpg b/docs/design-system/badge-pipeline/results-speed-demon-v3.jpg new file mode 100644 index 000000000..2864014c4 Binary files /dev/null and b/docs/design-system/badge-pipeline/results-speed-demon-v3.jpg differ diff --git a/docs/design-system/prompts/style-prefix.md b/docs/design-system/prompts/style-prefix.md index 1aab7fbfb..59a2c9905 100644 --- a/docs/design-system/prompts/style-prefix.md +++ b/docs/design-system/prompts/style-prefix.md @@ -13,6 +13,8 @@ Color palette: Coral-salmon pink (#EC726F) is the primary accent — used on 30- Key details: Outlines are the defining feature — every single element has them, at a consistent weight. Speed lines are short horizontal dashes, also in dark navy. Small decorative dots or circles may appear as accents. The illustration should have a transparent or pure white background with no frame or border. Shapes can slightly overlap to create a sense of layering. The overall composition should feel balanced and compact, not sprawling. +Puzzle piece geometry (CRITICAL): Every jigsaw piece must look like a real die-cut cardboard puzzle piece — a rounded square body with exactly ONE connector per side: either a smooth, perfectly circular knob (a clean half-circle sitting on a short narrow neck) or one matching circular socket. Knobs and sockets are symmetrical, centered on each side, and identical in size across all pieces. NEVER draw lumpy, melted, wavy, or irregular connectors; never two knobs on one side; never knobs of different sizes on the same piece. Prefer FEWER and LARGER puzzle pieces drawn accurately over many small ones. + Background: Clean white or transparent. ``` diff --git a/docs/design-system/prompts/subjects/level-share-card-background.md b/docs/design-system/prompts/subjects/level-share-card-background.md new file mode 100644 index 000000000..f5728ccfb --- /dev/null +++ b/docs/design-system/prompts/subjects/level-share-card-background.md @@ -0,0 +1,19 @@ +# Subject: Level Share-Card Background + +Background art for the 800×800 level-up / launch share cards ("I'm Level 27 — where do you start?"). The app overlays the level medallion, player name and text in the center at render time, so the composition must keep the middle nearly empty. + +## Full Prompt + +``` +Style: Semi-flat line illustration with soft color fills — the exact style used in modern SaaS and app product illustrations. Every shape has a consistent dark navy blue outline (~2px stroke, color #2b3445) with rounded line caps and joins. Inside the outlines, shapes are filled with soft, pastel-toned colors that have very subtle internal gradients (not fully flat, but far from 3D — like a gentle watercolor wash contained within crisp outlines). All geometry is rounded and friendly — no sharp corners, no aggressive angles. Small horizontal dash marks are used as speed/motion lines to add energy. The illustration should feel light, approachable, and slightly whimsical — like a well-crafted app onboarding illustration. Not childish, not corporate — the sweet spot between playful and professional. + +Color palette: Coral-salmon pink (#EC726F) is the primary accent — used on 30-40% of the filled shapes. Sky blue (#69b3fe) is the secondary fill color. Soft lavender-purple (#4e54c8) adds depth on background elements and shadow-side fills. A touch of teal-aqua for small decorative accents. Dark navy (#2b3445) for ALL outlines, details, and linework. White (#ffffff) with a very subtle blue-gray tint for the lightest areas (clock face, table surface, puzzle surface). Colors should feel soft and slightly muted — never neon, never fully saturated. The overall palette reads as warm and inviting. + +Key details: Outlines are the defining feature — every single element has them, at a consistent weight. Speed lines are short horizontal dashes, also in dark navy. Small decorative dots or circles may appear as accents. Shapes can slightly overlap to create a sense of layering. + +Puzzle piece geometry (CRITICAL): Every jigsaw piece must look like a real die-cut cardboard puzzle piece — a rounded square body with exactly ONE connector per side: either a smooth, perfectly circular knob (a clean half-circle sitting on a short narrow neck) or one matching circular socket. Knobs and sockets are symmetrical, centered on each side, and identical in size across all pieces. NEVER draw lumpy, melted, wavy, or irregular connectors; never two knobs on one side; never knobs of different sizes on the same piece. Prefer FEWER and LARGER puzzle pieces drawn accurately over many small ones. + +Subject: A decorative celebration frame composition designed as a background for a social share card. All illustrated elements hug the edges and corners of the square canvas: overlapping pastel puzzle pieces (coral, sky blue, lavender, teal) tucked into all four corners at varying sizes, thin arcs of confetti dots, tiny sparkle stars, and short navy dash lines sprinkled along the edges to add celebratory energy. One or two puzzle pieces may gently drift inward from the edges, but the central area — roughly the middle 60% of the canvas — must stay completely empty, filled only with a clean very light blue-gray-tinted white, because a medallion and text will be overlaid there later. The overall feel is festive but airy and uncluttered. No text, no numbers, no letters anywhere. + +Aspect ratio: 1:1 +``` diff --git a/docs/design-system/prompts/subjects/xp-launch-reveal-hero.md b/docs/design-system/prompts/subjects/xp-launch-reveal-hero.md new file mode 100644 index 000000000..91097d63c --- /dev/null +++ b/docs/design-system/prompts/subjects/xp-launch-reveal-hero.md @@ -0,0 +1,21 @@ +# Subject: XP Launch Reveal Hero + +Hero illustration for the XP/levels launch email header and the one-time level reveal page — the celebratory "your level is revealed" moment. + +## Full Prompt + +``` +Style: Semi-flat line illustration with soft color fills — the exact style used in modern SaaS and app product illustrations. Every shape has a consistent dark navy blue outline (~2px stroke, color #2b3445) with rounded line caps and joins. Inside the outlines, shapes are filled with soft, pastel-toned colors that have very subtle internal gradients (not fully flat, but far from 3D — like a gentle watercolor wash contained within crisp outlines). All geometry is rounded and friendly — no sharp corners, no aggressive angles. Small horizontal dash marks are used as speed/motion lines to add energy. The illustration should feel light, approachable, and slightly whimsical — like a well-crafted app onboarding illustration. Not childish, not corporate — the sweet spot between playful and professional. + +Color palette: Coral-salmon pink (#EC726F) is the primary accent — used on 30-40% of the filled shapes. Sky blue (#69b3fe) is the secondary fill color. Soft lavender-purple (#4e54c8) adds depth on background elements and shadow-side fills. A touch of teal-aqua for small decorative accents. Dark navy (#2b3445) for ALL outlines, details, and linework. White (#ffffff) with a very subtle blue-gray tint for the lightest areas (clock face, table surface, puzzle surface). Colors should feel soft and slightly muted — never neon, never fully saturated. The overall palette reads as warm and inviting. + +Key details: Outlines are the defining feature — every single element has them, at a consistent weight. Speed lines are short horizontal dashes, also in dark navy. Small decorative dots or circles may appear as accents. The illustration should have a transparent or pure white background with no frame or border. Shapes can slightly overlap to create a sense of layering. The overall composition should feel balanced and compact, not sprawling. + +Background: Clean white or transparent. + +Puzzle piece geometry (CRITICAL): Every jigsaw piece must look like a real die-cut cardboard puzzle piece — a rounded square body with exactly ONE connector per side: either a smooth, perfectly circular knob (a clean half-circle sitting on a short narrow neck) or one matching circular socket. Knobs and sockets are symmetrical, centered on each side, and identical in size across all pieces. NEVER draw lumpy, melted, wavy, or irregular connectors; never two knobs on one side; never knobs of different sizes on the same piece. Prefer FEWER and LARGER puzzle pieces drawn accurately over many small ones. + +Subject: A celebratory reveal moment. In the center, two friendly hands lift a large glowing puzzle-piece medallion upward like a trophy — the medallion is a single large, accurately drawn jigsaw piece in coral with a soft sky-blue ribbon rosette behind it, radiating short navy dash lines and tiny sparkle stars to show excitement. Around it, a rising staircase of THREE large overlapping pastel puzzle pieces (sky blue, lavender, teal) climbs from the lower left toward the medallion, suggesting levels and progression, with small confetti dots and circles floating throughout. In the lower corners, a small round stopwatch and one loose puzzle piece rest on a subtle white table surface, keeping the scene grounded in puzzling. No text, no numbers, no letters anywhere in the illustration. + +Aspect ratio: 16:9 +``` diff --git a/docs/features/badges.md b/docs/features/badges.md new file mode 100644 index 000000000..cbc00d320 --- /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/docs/features/content-digest/README.md b/docs/features/content-digest/README.md new file mode 100644 index 000000000..0a952116b --- /dev/null +++ b/docs/features/content-digest/README.md @@ -0,0 +1,548 @@ +# Content Digest Emails — Daily & Weekly + +Personalized, scheduled digest emails to all opted-in players: a **weekly** "your week in puzzling" +digest (the centerpiece of the XP/levels launch — this document is the "sending mechanics" deliverable +referenced in `docs/features/xp-levels/launch-checklist.md`) and a **daily** "only when something +happened" digest. A monthly same-content newsletter is a future phase (see §15). + +> **Scope update (2026-07-12, Jan):** v1 ships the **weekly digest only** — the daily digest is deferred +> completely (its design below stays as the blueprint for when it's picked up; Phase 3 in §16 moves +> behind a separate go decision). Also decided: weekly digest is **default-on** for existing players, +> and the unread-messages digest **stays separate**. The Sunday-overlap open question (§17.5) is moot for v1. + +This is a separate system from the existing **unread-messages digest** +(`SendUnreadDigestEmailsCommand` / `PrepareDigestEmailForPlayer` / `DigestEmailLog`), which stays +untouched. To avoid naming collisions, everything in this feature is named **content digest** +(`ContentDigestLog`, `SendPlayerContentDigest`, `ContentDigestFrequency`, …). Transactional emails +are also untouched — they keep their immediate path through the `async` transport. + +_Last updated: 2026-07-11. Verified against Symfony 8.0.4/8.0.5 vendor code and the production +deployment at `spare.srv:/deployment/speedpuzzling`._ + +--- + +## 1. Volume & provider limits + +| | Today (10k users) | +1 year (20k users) | +|---|---|---| +| Daily digest (ceiling: all opted-in) | 10,000/day | 20,000/day | +| Weekly digest | 10,000/week (~43k/mo) | 20,000/week (~87k/mo) | +| Monthly newsletter (future) | 10,000/mo | 20,000/mo | +| **Total per month (ceiling)** | **~353,000** | **~707,000** | +| Worst-case single day (all aligned) | 30,000 | 60,000 | + +Actual daily volume will be well below the ceiling: the daily digest only sends when the player has +content (see §9), and the weekly digest skips players after a no-activity send (see §7). + +**Seznam limits** ([inbound bulk limits](https://o-seznam.cz/napoveda/email/pro-odesilatele-hromadnych-zprav/limity-pro-velikost-rozesilky/)): +max 100 messages per SMTP connection (Symfony's `SmtpTransport` restarts the connection every +100 messages by default — no action needed), max 1,500 connections/IP/5 min, max 100,000 messages +or 1 GB per IP per 5 min, and bulk sends should be spread over tens of minutes to hours. At our +target pace of ~2–4 msg/s (single consumer's natural throughput) we sit at ~1% of these caps. + +Two caveats: + +1. **That page governs delivery TO Seznam mailboxes.** We relay outbound through `smtp.seznam.cz` + (Email Profi), whose per-account outbound fair-use ceiling is not publicly documented. + **Open question: confirm with Seznam support before exceeding ~10k/day** through one identity. + Mitigation exists already: two separate sender identities (`notify@notify.myspeedpuzzling.com`, + `news@news.myspeedpuzzling.com`). +2. **Most recipients are not on Seznam.** Gmail's bulk-sender rules (≥5k/day to Gmail) are the + stricter regime: aligned SPF+DKIM+DMARC, RFC 8058 one-click unsubscribe, spam-complaint rate + < 0.3% (target < 0.1%). §10 and §14 cover both. + +Because we relay through Seznam's IPs, **our DKIM domain is our reputation** — there is no IP +warm-up, only domain warm-up (§14). + +## 2. What already exists (reused, not rebuilt) + +- **Mailer**: two named transports in `config/packages/mailer.php` — `transactional` (default) and + `notifications`; per-message selection via the `X-Transport` header. Digests use `notifications` + (`MAILER_NOTIFICATIONS_DSN` = Seznam identity `notify@notify.myspeedpuzzling.com`), same as the + unread-messages digest (`PrepareDigestEmailForPlayerHandler`). +- **Messenger**: single `command_bus` with `ClearEntityManagerMiddleware` + `doctrine_transaction`; + Doctrine transport on Postgres (`messenger_messages`, composite index + `(queue_name, available_at, delivered_at, id)` since `Version20260213165323`); `failed` failure + transport. Consumers poll with `FOR UPDATE SKIP LOCKED`; ack deletes the row, so the table stays + near-empty between runs. A second queue on the same table is the established pattern + (`doctrine://default?queue_name=failed`). +- **Email audit**: `EmailAuditSubscriber` transparently logs every send (works with the direct + transport send in §3/D1 — it skips `queued=true` events and correlates by original message + object). Needs digest-specific adjustments before launch (§12). +- **Templates**: Inky (`twig/inky-extra`) + `inline_css`, `emails` translation domain, per-player + `->locale()`, `_header`/`_footer` partials, `_base_newsletter.html.twig` starter. +- **Preferences UI**: `MessagingSettingsFormType` on the edit-profile page; `Player.newsletterEnabled` + exists (unused by any sender — reserved for the future newsletter). +- **Cron pattern**: host crontab on spare.srv wrapping console commands in + `sentry-cli monitors run` via `docker compose run --rm messenger-consumer`. +- **DNS**: SPF (`include:spf.seznam.cz`) on all three domains; DMARC `p=quarantine` on root, + `p=none` on `notify.`/`news.` subdomains (tightening plan in §14). +- **Absolute URLs from workers**: `config/packages/routing.php` sets `default_uri` from `APP_URL` — + email templates already emit absolute links from CLI/worker context. + +## 3. Architecture overview + +``` +host cron (17:00 daily / Sun 18:00 weekly, Sentry-monitored) + └─ myspeedpuzzling:send-content-digest + • eligibility SQL (preferences + NOT EXISTS content_digest_log for period + no-activity rule) + • dispatches SendPlayerContentDigest(playerId, type, periodKey) per player + with DelayStamp stagger (index × 250 ms) → queue `digest_emails` + └─ dedicated digest-consumer container: messenger:consume digest_emails + SendPlayerContentDigestHandler: + 1. re-check eligibility (player, email, preference, period log) + 2. staleness guard (drop silently if period too old) + 3. gather content blocks (per-user queries) + 4. render TemplatedEmail (locale-aware, Inky) + 5. TransportInterface->send() — synchronous SMTP, X-Transport: notifications + 6. persist ContentDigestLog row (same transaction, unique per player+type+period) + failure: 4xx → rethrow (Messenger retry w/ backoff) · permanent 55x → log + commit, no retry +``` + +### Key decisions (and rejected alternatives) + +**D1 — SMTP happens inside the digest handler via direct `TransportInterface->send()`, not +`MailerInterface->send()`.** `MailerInterface` enqueues a `SendEmailMessage` routed to `async` +(`config/packages/messenger.php`), so the actual SMTP would run in the shared async consumer: +unthrottled, interleaved with transactional mail for hours during a drain, and retried with the +default strategy (3×, seconds) instead of ours. Direct send puts pacing, retry policy, and failure +classification exactly where SMTP happens, and keeps the transactional path completely isolated. +Verified against vendor: `Transports::send()` honors `X-Transport` (and removes/re-adds it); +`AbstractTransport::send()` fires `MessageEvent(queued=false)` → `SentMessageEvent`/`FailedMessageEvent`, +so `EmailAuditSubscriber` records exactly one audit row; `TransportInterface` autowires to the +`Transports` aggregate. Trade-off vs the mailer path: we lose "log row and email enqueue commit +atomically" — accepted, because Messenger's ack semantics are at-least-once anyway (a crash between +SMTP `250 OK` and ack can duplicate one digest; rare and harmless), and re-rendering on retry means +*fresher* content, not stale. + +**D2 — Pacing via `DelayStamp` staggering, not a rate limiter (v1).** `symfony/rate-limiter` is NOT +installed. The dispatch command stamps each message with `new DelayStamp($index * 250)` — the exact +pattern of `RecalculateBadgesConsoleCommand::BACKFILL_DELAY_MS` — so 20k messages become available +over ~83 minutes and the Doctrine transport (which honors `available_at`) releases them gradually. +Note the stagger releases at 4 msg/s while the consumer realistically sustains 2–4 msg/s — so the +actual drain time is bounded by consumer throughput (2h47m at the 2/s planning rate for 20k), not +by the release window; the stagger's job is to cap the *maximum* SMTP rate, and the consumer's +natural ceiling is itself far below any Seznam limit. **Upgrade path** when a second consumer +becomes necessary (when volume exceeds what one consumer clears in the acceptable send window — +~14k per 2h at the 2/s planning rate, see §13): `composer require symfony/rate-limiter`, define a token-bucket limiter +under `framework.rate_limiter`, and set `'rate_limiter' => ''` as a **transport-level key** +(sibling of `dsn`/`retry_strategy` — NOT inside `options`, the Doctrine transport rejects unknown +options). Limiter storage defaults to the `cache.rate_limiter` pool (parent `cache.app` = Redis in +this app), so multiple consumers share one window out of the box. Note: a rate-limited transport +blocks its whole worker while throttled — which is fine only because the digest consumer is dedicated (D3). + +**D3 — Dedicated `digest-consumer` container in production.** Copy of `messenger-consumer` running +`messenger:consume digest_emails --time-limit 3600 --memory-limit 256M` (§13). Rejected: consuming +`async digest_emails` from one worker (priority order) — workable, but a slow digest drain shares +one thread with everything async, memory/restart behavior couples the two, and D2's future rate +limiter would stall async messages. + +**D4 — No suppression table in v1.** With a relay, recipient-stage failures (mailbox unknown/full) +surface asynchronously as bounce emails to the sender mailbox, not at SMTP submission — a +suppression table would stay empty until bounce-mailbox processing exists. v1 gates on preferences +and handles synchronous permanent failures per §6; Phase 4 (§14) adds bounce polling + FBL and +suppresses via the existing `EmailAuditLog` bounce fields (`bounceType`, `bouncedAt`, `recordBounce()`). + +**D5 — Fixed send time.** `Player` has `locale` and `country` but no timezone column. The +production host runs `Etc/UTC` and its cron (Ubuntu 22.04 vixie-cron) does **not** support +`CRON_TZ` (verified on spare.srv), so schedules are expressed in UTC with accepted ±1h DST drift: +daily at 16:00 UTC (= 17:00 Prague winter / 18:00 summer), weekly Sunday 17:00 UTC (= 18:00/19:00 +Prague). Per-user timezone segmentation is a possible later enhancement (needs a new column; +country→timezone is ambiguous). + +## 4. Data model + +### `ContentDigestLog` (new entity, table `content_digest_log`) + +One row per attempted/sent digest — the idempotency anchor and the state for the no-activity rule. + +| column | type | notes | +|---|---|---| +| `id` | uuid | `Uuid::uuid7()` | +| `player_id` | uuid FK | indexed | +| `digest_type` | string enum | `daily` \| `weekly` | +| `period_key` | string | `2026-07-11` (daily) / `2026-W28` (weekly) — computed by the dispatch command, ISO week | +| `sent_at` | datetimetz_immutable | from `ClockInterface` | +| `had_activity` | bool | false = the "we haven't seen a solve" variant was sent — drives the never-two-in-a-row rule | +| `status` | string enum | `sent` \| `failed_permanent` | + +Class-level `#[UniqueConstraint(columns: ['player_id', 'digest_type', 'period_key'])]` (convention: +`DismissedHint`, `PlayerSkillHistory`). Migration is **generated** (`doctrine:migrations:diff` in +the web container), never hand-written. + +### `Player.contentDigestFrequency` (new preference) + +New string-backed enum `src/Value/ContentDigestFrequency.php`: `None = 'none'`, `Daily = 'daily'`, +`Weekly = 'weekly'`. Semantics: `Daily` subscribes to **both** digests (the weekly run targets +`frequency IN ('daily', 'weekly')`, the daily run targets `frequency = 'daily'` — spelled out in §8 +because a naive equality match would silently drop daily subscribers from the weekly send). Column +follows the `Player.php` enum convention: +`#[Column(type: Types::STRING, enumType: ContentDigestFrequency::class, options: ['default' => 'weekly'])]` ++ `changeContentDigestFrequency()` mutator. The existing `EmailNotificationFrequency` is NOT reused — +it throttles the unread-messages digest and has different semantics. + +Default for existing players: **`weekly`** (the XP launch checklist expects the weekly digest to +reach players by default, with opt-out). The ramp-up plan (§14) controls actual exposure, not the +default value. `emailNotificationsEnabled = false` is respected as a global kill-switch for the +player regardless of digest frequency. + +## 5. Messenger configuration (exact edits) + +`config/packages/messenger.php` — add transport + routing (array-style `App::config`, matching the +existing file): + +```php +'transports' => [ + // ... existing sync / failed / async ... + 'digest_emails' => [ + 'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%?auto_setup=false&queue_name=digest_emails', + 'retry_strategy' => [ + 'max_retries' => 5, + 'delay' => 60_000, // 1 min + 'multiplier' => 4, // 1m → 4m → 16m → 64m → 4h (capped; ±10% default jitter) + 'max_delay' => 14_400_000, // 4 h + ], + ], +], +'routing' => [ + // ... existing ... + SendPlayerContentDigest::class => 'digest_emails', +], +``` + +`auto_setup=false` is harmless for a new `queue_name` — it only governs table creation, and +`messenger_messages` exists. Retry delays are honored by the Doctrine transport via `available_at`; +the 4h delay is safe (`redeliver_timeout` only applies to in-flight rows, not pending delayed ones). + +`config/packages/dev/messenger.php` — route `SendPlayerContentDigest::class => 'sync'` +(mirrors `PrepareDigestEmailForPlayer`; dev runs no consumer). + +`config/packages/test/messenger.php` — add `'digest_emails' => ['dsn' => 'in-memory://']` to +transports and `SendPlayerContentDigest::class => 'sync'` to routing. + +## 6. Retry & failure handling + +The automatic-retry requirement is satisfied by the transport `retry_strategy` above; exhausted +retries land on the existing `failed` transport (`messenger:failed:retry` for manual replay, +failures visible in Sentry). + +Inside `SendPlayerContentDigestHandler`, failures are classified — this interacts with the +`doctrine_transaction` middleware, which **rolls back the whole handler transaction on any throw** +(including `UnrecoverableMessageHandlingException`), so the pattern is: + +- **Transient (retry)** — connection errors (`TransportException`, code 0) and SMTP **4xx** + (`UnexpectedResponseException::getCode()` in 400–499, e.g. greylisting/throttling): let the + exception bubble. Rollback discards the log row; Messenger re-dispatches with backoff; the retry + re-renders with fresh data. Note: the rollback also discards the `EmailAuditLog` row that + `EmailAuditSubscriber` created synchronously inside the same transaction — transiently-failed + attempts leave no audit record, only Sentry; the eventual successful retry creates a fresh one. + (Permanent failures commit, so their audit rows survive.) +- **Permanent (no retry)** — SMTP **55x recipient-stage** codes (550/551/552/553/554): **catch, + persist `ContentDigestLog` with `status = failed_permanent`, and return normally** so the + transaction commits and the message is acked. Never throw after persisting — the row would roll + back AND the message would not retry (verified against `DoctrineTransactionMiddleware` + + `SendFailedMessageForRetryListener`). Caution: 5xx also occurs at non-recipient stages (535 auth, + 554 relay-denied at MAIL FROM) — those indicate a sender-side problem, treat as transient (bubble) + so Sentry alerts fire rather than silently marking players failed. +- **Staleness guard** — before doing any work: if `now` is past the period end + TTL (daily: 20h, + weekly: 3 days), log a skip and return normally. Nobody wants yesterday's daily digest delivered + after today's. +- **Eligibility re-check** — re-fetch the player and bail (return normally) on `email === null`, + preference off, or an existing log row for the period. Hours pass between dispatch and consume; + preferences change. (Same pattern as `PrepareDigestEmailForPlayerHandler`.) + +Delivery semantics are **at-least-once**: a consumer crash between SMTP `250 OK` and ack can +duplicate one digest for one player (row redelivered after `redeliver_timeout`, default 1h). The +unique constraint prevents duplicate log rows; the duplicate email itself is accepted as rare and harmless. + +## 7. Idempotency & the no-activity rules (from the XP launch checklist) + +- **One digest per player per period**, enforced three times: eligibility SQL + (`NOT EXISTS content_digest_log …`), handler re-check, DB unique constraint. The dispatch command + is safe to re-run after a crash — it only picks up players without a log row for the period. +- **Weekly digest sends even on a no-activity week** (warm, encouraging tone — never guilt), and the + log row records `had_activity = false`. +- **Never two no-activity digests in a row**: the eligibility SQL excludes players whose most recent + weekly `content_digest_log` row has `had_activity = false` AND who have logged no activity since + that send. Once they log a solve, the next weekly digest resumes. +- The **daily** digest is strictly content-gated: if all daily blocks are empty, the handler writes + no log row and sends nothing (cheap; the "did anything happen" check runs before rendering). + No-activity messaging is a weekly-only concept. Honesty note on re-runs: because empty dailies + leave no log row, a *manual* same-day re-run of the daily dispatch re-dispatches (and re-checks) + most of the daily audience — no duplicate emails result, just queue churn, and players whose + content appeared between the runs get a late digest. Cron runs once per day, so this only matters + for manual replays. + +## 8. Dispatch command + +`src/ConsoleCommands/SendContentDigestConsoleCommand.php` — +`myspeedpuzzling:send-content-digest `, thin per convention (parse input → query → +dispatch; logic lives in the handler): + +- Eligibility from a raw-SQL query class `src/Query/GetPlayersForContentDigest.php` + (heredoc SQL, DBAL `Connection`, `ClockInterface` — copy `GetPlayersWithUnreadMessages` style): + `email IS NOT NULL AND email_notifications_enabled` AND — **weekly run**: + `content_digest_frequency IN ('daily', 'weekly')`; **daily run**: + `content_digest_frequency = 'daily'` — minus `NOT EXISTS` period log, minus the no-activity + rule (§7), dedup by email. +- Computes `period_key` once, dispatches `SendPlayerContentDigest` per player with + `new DelayStamp($index * self::STAGGER_MS)` (250 ms default, constant). +- Dispatching 20k messages is **not** one giant transaction: each `dispatch()` from a console + command is its own small `BEGIN → INSERT → COMMIT` (verified: `DoctrineTransactionMiddleware` + wraps per-envelope; commands are never transaction-wrapped). No EM chunking needed — eligibility + is raw SQL, nothing hydrates into the EM. Precedent: `RecalculateBadgesConsoleCommand --backfill`. +- Tests target the handler and the query class directly, not the command. + +## 9. Content blocks + +The pipeline is content-agnostic; blocks are composed per digest type. Personalization = shared +layout + per-block Twig partials + translated texts (English first, other locales via the +missing-translations workflow before launch). + +### Weekly — "Your week in puzzling" (sends to everyone opted in) + +Ordered: personal wins → social → discovery. Members-only blocks render a teaser for free users. + +| # | Block | Source | Cost | Gating | +|---|---|---|---|---| +| 1 | **XP gained, levels gained, achievements earned** (headline, per XP launch rules: members = full detail, free = "X achievements are waiting" teaser) | XP/levels feature (pending build); badges roundup meanwhile via `GetBadges::forPlayer` filtered to the week | cheap | achievement detail members-only | +| 2 | Your week in numbers (solves, pieces, time vs last week) | windowed variant of `GetPlayerChartData`/`GetPlayerStatistics` | moderate (new windowed query) | free | +| 3 | Streak recap | `ActivityCalendarStreakCalculator` | moderate | honor `streak_opted_out` | +| 4 | Favorites' activity roundup | `GetRecentActivity::ofPlayerFavorites` + week window | cheap (exists) | free; only public solves | +| 5 | Progress to next achievement ("2 solves to Silver") | `Results/BadgeProgress` | moderate | members-only | +| 6 | MSP rating / skill-tier movement this week | **new query** over `player_rating_snapshot` (daily snapshots already written by `PuzzleIntelligenceRecalculator::recordRatingSnapshots`; no read query exists yet) — today's row minus ~7-days-ago row | new-agg | members-only; honor `ranking_opted_out` | +| 7 | Upcoming competitions | `GetCompetitionEvents::allUpcoming` | cheap, global (compute once per run) | free | +| 8 | Most-solved puzzle of the week | week variant of `GetMostSolvedPuzzles::topInMonth` | cheap, global | free | + +The weekly digest ships with whatever subset is ready — block 1 becomes the headline when XP/levels +lands; until then blocks 2–4 carry the narrative. No-activity variant: replaces blocks 1–3 with the +encouraging community-voice message. + +### Daily — strictly "only if something happened" (all blocks empty most days) + +| # | Block | Source | Trigger | +|---|---|---|---| +| 1 | Streak-at-risk nudge | streak calculator: last active day = yesterday, nothing today | streak in jeopardy | +| 2 | Favorites' new solves today | `GetRecentActivity::ofPlayerFavorites`, today window | any favorite solved | +| 3 | Wishlist ↔ new marketplace listings match | **new query** joining wishlist puzzle ids × new `sell_swap_list_item` rows | new match | +| 4 | Pending to-dos (rate transaction, borrowed-unsolved reminder) | `GetTransactionRatings` pending, `GetBorrowedPuzzles::unsolvedByHolderId` | anything pending | + +All four empty → no email, no log row. + +**Do not duplicate existing emails**: the immediate badge email, unread-messages digest, and +transactional notifications keep their channels; the weekly digest only *rolls up* (badges) or +surfaces never-emailed data (favorites' activity — currently in-app notification only). + +## 10. Unsubscribe & headers (required before first bulk send) + +No signed-URL pattern exists in the codebase yet; use Symfony **`UriSigner`** (autowired, signs with +`APP_SECRET`, built-in expiry): + +- Handler embeds + `$uriSigner->sign($urlGenerator->generate('unsubscribe_content_digest', ['playerId' => …], ABSOLUTE_URL), new \DateInterval('P30D'))`. +- `src/Controller/UnsubscribeContentDigestController.php` — single-action, public + (security config already grants `PUBLIC_ACCESS` to `^/`; no firewall changes), single + non-localized path `/unsubscribe/content-digest/{playerId}`. Verifies `checkRequest()`, dispatches + `ChangeContentDigestFrequency($playerId, None)`, renders a localized confirmation page. Accepts + **POST** for RFC 8058 one-click; GET shows a confirm button (protects against link prefetchers + unsubscribing users). +- Every digest email carries: + - `List-Unsubscribe: ` (+ optional `mailto:`) + - `List-Unsubscribe-Post: List-Unsubscribe=One-Click` + - `Precedence: bulk` (explicitly recommended by [Seznam](https://o-seznam.cz/napoveda/email/pro-odesilatele-hromadnych-zprav/doporucene-nastaveni-odesilacich-domen/)) +- Footer: visible unsubscribe link (same signed URL) + link to edit-profile preferences + (Czech law 480/2004 + Gmail/Yahoo requirements). +- Caveat: `UriSigner` validates the full URL incl. scheme/host — generation uses canonical + `APP_URL`, so keep trusted-proxy handling intact in production. + +## 11. Templates & translations + +- `templates/emails/content_digest_weekly.html.twig` + `content_digest_daily.html.twig` — standard + wrapper (`{% trans_default_domain 'emails' %}`, `inky_to_html|inline_css(source('@styles/foundation-emails.css'))`, + `_header`/`_footer` includes); one Twig partial per content block so blocks compose cleanly. +- Subjects translated in PHP (`$translator->trans(key, domain: 'emails', locale: $player->locale)`), + body locale via `->locale($player->locale ?? 'en')`. +- English first (project convention); fill cs/de/es/fr/ja via the missing-translations workflow + before ramp-up (launch-checklist §5 applies to digest copy too). +- From: `notify@notify.myspeedpuzzling.com`, `X-Transport: notifications` (copy the unread digest). +- Consistent From name across all sends (reputation: recipients recognize the sender). + +## 12. Email-audit adjustments (required before ramp-up) + +At 350–700k sends/month the current audit layer becomes the bottleneck — `EmailAuditSubscriber` +stores the **full HTML body** per send (~10–25 GB/month raw at scale) and the cleanup handler runs +one unbatched DELETE in a single transaction (WAL burst; xmin horizon blocks vacuum everywhere, +including `messenger_messages`, for minutes): + +1. **Skip `body_html` (and `smtp_debug_log`) for digest email types** — store template name + params + digest instead. +2. **Shorter retention for digest types** (14–30 days) vs 90 days for the rest — extend + `CleanupEmailAuditLogs` with an optional email-type filter. +3. **Batch the cleanup DELETE** (`… WHERE id IN (SELECT id … LIMIT 10000)` per committed batch; one + message per batch or a non-wrapped DBAL path — the `doctrine_transaction` middleware otherwise + re-wraps everything in one transaction). +4. **Indexes**: single-column indexes on `sent_at`, `status`, `recipient_email`, `message_id` + already exist — add composite `(email_type, sent_at)` (`email_type` is unindexed today) and + consider `(status, sent_at)` for filtered+ordered admin queries; the admin UI's + `distinctEmailTypes()` (unindexed `email_type`) and whole-table counts need caching (~60s) or + removal from the default render path; consider a `custom_` GIN trigram index for the + recipient ILIKE search (existing `custom_*` index pattern, mirror in `tests/bootstrap.php`). + +`messenger_messages` itself is fine: ~20k inserts+deletes/day is two orders of magnitude below +queue-bloat territory; optionally set per-table autovacuum +(`autovacuum_vacuum_scale_factor = 0.01, threshold = 1000`) and watch `n_dead_tup`. + +## 13. Production rollout (spare.srv) + +**Throughput reality**: single-threaded consumer ≈ 150–450 ms/message (render + queries + SMTP) → +sustained 2–4 msg/s; plan capacity at 2/s. 20k drains in ~2h47m at 2/s. A second consumer becomes +necessary around ≥25k/day within a 2-hour window — at that point add the rate limiter (D2) so both +consumers share one Redis-backed window. + +**New compose service** (`/deployment/speedpuzzling/docker-compose.yml`) — copy of +`messenger-consumer` with only the command changed: + +```yaml +digest-consumer: + image: ghcr.io/myspeedpuzzling/website:main + restart: always + command: "bash -c 'wait-for-it postgres:5432 -- sleep 5 && bin/console messenger:consume digest_emails -vv --time-limit 3600 --memory-limit 256M'" + healthcheck: + test: pgrep -f "messenger:consume" > /dev/null || exit 1 + start_period: 15s + timeout: 5s + interval: 30s + retries: 3 + environment: + # identical to messenger-consumer — copy the block verbatim + networks: + - internal +``` + +**`deploy.sh`** — the workers section only recreates `messenger-consumer`; without this change the +digest consumer would run a stale image after every deploy: + +```bash +docker compose stop messenger-consumer digest-consumer +LOGS_START=$(expr $(date +%s)) +docker compose up --detach --force-recreate --remove-orphans messenger-consumer digest-consumer || docker compose logs --since $LOGS_START messenger-consumer digest-consumer +``` + +**Cron** (`/deployment/speedpuzzling` host crontab, existing sentry-cli pattern): + +```cron +0 16 * * * docker compose --file /deployment/speedpuzzling/docker-compose.yml run --rm messenger-consumer sentry-cli monitors run --schedule "0 16 * * *" send-content-digest-daily -- bin/console myspeedpuzzling:send-content-digest daily +0 17 * * 0 docker compose --file /deployment/speedpuzzling/docker-compose.yml run --rm messenger-consumer sentry-cli monitors run --schedule "0 17 * * 0" send-content-digest-weekly -- bin/console myspeedpuzzling:send-content-digest weekly +``` + +Times are UTC — the host runs `Etc/UTC` and its cron does not support `CRON_TZ` (both verified on +spare.srv), so Prague send times drift ±1h with DST (see D5). A wrapper script checking local time +is the escape hatch if exact Prague times ever matter. + +## 14. Reputation plan + +Relaying through Seznam means domain reputation (DKIM `d=notify.myspeedpuzzling.com`) is everything. +Per Seznam, reputation is driven by recipient reactions — opens, deleted-unread, spam markings +([user-signal rules](https://o-seznam.cz/napoveda/email/pro-odesilatele-hromadnych-zprav/zasady-prace-s-databazi-kontaktu-a-vyhodnoceni-uzivatelskeho-signalu/)). + +**Phase order matters — hard requirements before the first bulk send:** + +1. One-click unsubscribe + headers (§10). Non-negotiable for Gmail at ≥5k/day. +2. Register both DKIM domains in [Seznam FBL](https://o-seznam.cz/napoveda/email/pro-odesilatele-hromadnych-zprav/feedback-loop-fbl/) + (fbl.seznam.cz — needs an `abuse@`/`postmaster@` confirmation address on the domain). Reports are + per-spam-marking ARF emails with original headers; a small cron command polls that mailbox and + flips the player's digest preference to `none` (v1) / feeds suppression (Phase 4). +3. Register `myspeedpuzzling.com` in Google Postmaster Tools — the spam-rate dashboard is the + primary early-warning during ramp-up. Abort ramp if spam rate approaches 0.3%. + +**Ramp-up (domain warm-up), ~4–6 weeks:** + +| Week | Weekly digest audience | +|---|---| +| 1 | ~1–2k most-engaged players (recent login/solve) | +| 2 | ~4k | +| 3 | ~8k | +| 4+ | everyone opted-in | + +Start the daily digest ~2 weeks after the weekly is at full volume (its content gating keeps volume +low anyway). Implementation: the eligibility query takes a `--limit` + engagement ordering during +ramp; remove after. + +**Ongoing hygiene:** + +- Engagement gating: daily digest only sends with content (§7/§9); consider downgrading players + inactive >90 days from daily→weekly automatically, and pausing weekly after ~180 days of zero + engagement (the never-two-no-activity rule already approximates this). +- DMARC: after 2–4 weeks of clean `rua` reports at full volume, move `notify.` (and later `news.`) + from `p=none` to `p=quarantine`, then `p=reject`. +- Watch the audit dashboard: sent/failed per type per day; Sentry alerts on cron failure and + `failed`-transport arrivals. + +**Phase 4 — bounce processing (after launch):** Seznam Email Profi forces envelope sender = login, +so VERP stays disabled (`docs/features/emails-tracking.md`). Instead: poll the `notify@` mailbox +(POP3 `pop.seznam.cz:995`) for DSNs with a console command, correlate via the SMTP `message_id` +already stored in `EmailAuditLog`, call `recordBounce()`, and exclude hard-bounced addresses in the +eligibility SQL (`NOT EXISTS (… email_audit_log WHERE bounce_type = 'hard' …)`). For the future +Listmonk newsletter: enable the `[bounce]` section in `listmonk.toml` (scaffold exists, commented out). + +## 15. Future: monthly newsletter (out of scope now) + +Same-content localized campaigns go through **Listmonk** (already deployed at +`listmonk.myspeedpuzzling.com`, `news@news.myspeedpuzzling.com` SMTP configured, zero code +integration today): sync `Player.newsletterEnabled` into per-locale lists via the Listmonk API +(`LISTMONK_API_USER/TOKEN` env vars already provisioned), pull unsubscribes back, enable bounce +processing, set the sliding-window throttle (~10k/hour). Gets campaign editor, archive, and +open/click tracking for free. Decide in ~6 months whether it earns its keep or the in-app pipeline +absorbs the newsletter too. + +## 16. Implementation checklist + +**Phase 1 — pipeline (no emails sent yet)** +- [x] `ContentDigestFrequency` enum + `Player.contentDigestFrequency` column + generated migration +- [x] `ContentDigestLog` entity + repository (persist-only) + generated migration +- [x] `SendPlayerContentDigest` message + `digest_emails` transport + routing (base/dev/test config) +- [x] `SendPlayerContentDigestHandler` (eligibility re-check, staleness guard, direct transport send, + failure classification per §6, log row) — note: 554 is treated as TRANSIENT (bubbles to retry) + because it cannot be distinguished from MAIL FROM relay-denied at submission time +- [x] `GetPlayersForContentDigest` query (preferences + period log + no-activity rule + experienceSystemOptedOut exclusion) +- [x] `SendContentDigestConsoleCommand` with `DelayStamp` stagger (weekly only; refuses while xp-system flag active) +- [x] Preference in `MessagingSettingsFormType`/`FormData`/`EditMessagingSettings`(+Handler)/ + `PlayerProfile`/`GetPlayerProfile`/`EditProfileController`/edit-profile template + translations + (field hidden while the xp-system flag is active) +- [x] Unsubscribe controller + `ChangeContentDigestFrequency` message + signed URLs + + `List-Unsubscribe`/`Precedence` headers +- [x] Tests: handler (KernelTestCase, direct invoke), query, unsubscribe controller, form flow (settings visibility) +- [x] Audit adjustments §12 (skip body+debug for content_digest types, 30-day digest retention, batch-per-message delete, (email_type, sent_at) index) + +**Phase 2 — weekly digest** +- [x] Weekly template + content-block partials + English translations (other locales before full ramp) +- [x] Content queries: windowed week-in-numbers, favorites-week window, week variant of most-solved + (in `WeeklyDigestDataProvider`); the `player_rating_snapshot` delta block (block 6) is NOT + shipped in v1 — "ships with whatever subset is ready" clause applied +- [ ] FBL + Postmaster registration, deliverability smoke test (Gmail/Seznam/Outlook inbox placement) +- [ ] Prod: compose service + deploy.sh + weekly cron (after cron-TZ check) +- [ ] Ramp-up per §14; XP/achievements block joins as headline when XP/levels ships + +**Phase 3 — daily digest** +- [ ] Daily template + streak-at-risk / favorites-today / wishlist-match / to-dos queries +- [ ] Daily cron; monitor volume & spam rate for 2 weeks + +**Phase 4 — reputation hardening** +- [ ] Bounce-mailbox polling command + eligibility exclusion; FBL ingestion automated +- [ ] DMARC tightening; engagement downgrade/sunset rules + +## 17. Open questions + +1. **Email Profi outbound cap** — ask Seznam support for the fair-use ceiling per identity/day + (blocking only for full 20k volume, not for ramp-up). +2. ~~Host cron timezone~~ — resolved: spare.srv runs `Etc/UTC`, installed cron has no `CRON_TZ` + support; schedules are UTC with DST drift (D5, §13). +3. **Unread-messages digest consolidation** — fold into the daily content digest ("max one + MySpeedPuzzling email per day") or keep separate? Recommended: keep separate for v1, revisit + after complaint-rate data exists. +4. Weekly digest **default-on** for existing players (assumed here per the XP launch plan) — confirm, + including whether the launch announcement email should mention it (it should). +5. **Sunday overlap for `daily` subscribers** — a daily subscriber with content on Sunday gets the + daily digest at 16:00 UTC and the weekly an hour later. Content gating makes this rare; + recommended v1 = allow both and monitor, alternative = skip the daily run for weekly-eligible + players on the weekly send day. diff --git a/docs/features/feature_flags.md b/docs/features/feature_flags.md index d439de3b9..0076e78b6 100644 --- a/docs/features/feature_flags.md +++ b/docs/features/feature_flags.md @@ -2,6 +2,18 @@ This file documents all active feature flags in the codebase — where they are, what feature they gate, and when they can be removed. +## XP System (admin-only) — `xp-system` + +- **Feature:** XP / Levels / Achievements gamification bundle (`docs/features/xp-levels/`) +- **Flag:** `SpeedPuzzling\Web\Services\Xp\XpFeatureGate` — `isVisibleFor(?PlayerProfile)` restricts visibility to admins; `isEmailSendingEnabled()` suppresses ALL feature emails (achievement congratulations, weekly digest, reveal emails) for everyone while active +- **Gated files** (authoritative checklist in `docs/features/xp-levels/leak-inventory.md`): + - Components: `BadgesProfileSection`, `XpRing`, `XpSolveReceipt`, `XpRecapCelebration`, `XpPuzzleEstimate`, `XpRevealInvite` + - Controllers: `BadgesOverviewController`, `AchievementDetailController`, `XpLeaderboardController`, `XpHistoryController`, `XpExplainerController`, `FairPlayXpController`, `XpLaunchRevealController`, `XpShareCardController`, `RevealBadgeController`, `BadgeRevealsController`, `EditTimeController` (delete-dialog XP warning), `EditProfileController` (digest + opt-out form fields) + - Email suppression: `RecalculateBadgesForPlayerHandler` (badge congratulations), `SendContentDigestConsoleCommand` + `SendPlayerContentDigestHandler` (weekly digest), `SendXpRevealEmailsConsoleCommand` + `SendXpRevealEmailHandler` (reveal email) +- **NOT gated (intentional):** badge evaluation/persistence and XP ledger accrual keep running for everyone — silent accumulation before launch +- **Exempt by decision (OK to leak):** public API responses + Swagger docs +- **Remove when:** XP launch day (`docs/features/xp-levels/launch-runbook.md`) — flip/remove the gate + call sites, DELETE the flag tests (`tests/Controller/XpLeakTest.php`, `XpPagesTest.php`, `XpSurfacesTest.php`, `DigestSettingsVisibilityTest.php`, flag cases in `BadgesOverviewControllerTest` + `RecalculateBadgesForPlayerHandlerTest`), then run the reveal-email command + ## Competition Table Layout (admin-only) - **Feature:** Table layout management for competition rounds diff --git a/docs/features/xp-levels/README.md b/docs/features/xp-levels/README.md new file mode 100644 index 000000000..20fbcbda8 --- /dev/null +++ b/docs/features/xp-levels/README.md @@ -0,0 +1,123 @@ +# XP / Levels / Achievements + +The gamification bundle: XP + levels for everyone, tiered achievements with Achievement +Points for members, weekly digest as the recurring touchpoint. Business spec authority: +`implementation-plan.md` §1 (locked); this file documents what was actually built. + +## The three currencies + +| Currency | Measures | Audience | +|---|---|---| +| XP + Levels (1–50, numeric only) | Activity | Everyone, free forever | +| Achievement Points (Bronze 5 · Silver 10 · Gold 25 · Platinum 50 · Diamond 100; single-tier 25) | Completion | Members-only display | +| MSP Rating / Points | Skill/speed | Untouched, fully separate | + +XP is never purchasable. Levels gate nothing functional. Level 50 = 3,160 XP +(`src/Value/LevelTable.php`, curve v4 locked). + +## Architecture + +### Ledger + +- `xp_entry` — one row per receipt line (`src/Value/XpReason.php`), amounts are signed + ints. References to solves/badges are **plain uuid columns without FKs** so deleted + solves keep their audit history. `Player.xpTotal`/`level` are denormalized and always + equal `SUM(xp_entry.amount)` / `LevelTable::levelForXp()` — every write goes through + `src/Services/Xp/XpLedger.php`. +- Idempotency anchors: unique partial indexes `custom_xp_entry_solve_reason + (player_id, solving_time_id, reason) WHERE … reason != 'solve_compensation'` and + `custom_xp_entry_badge (badge_id)` (mirrored in `tests/bootstrap.php`). +- `earned_at` carries the SOLVE's timestamp (`COALESCE(finished_at, tracked_at)`) for + solve-derived entries, the badge's `earned_at` for achievements, clock-now only for + settlements (which are excluded from weekly deltas via `in_weekly_delta = false`). +- Leaderboards never aggregate at scale: all-time reads `player.xp_total` (indexed), + the AP ladder reads `player.achievement_points` (indexed), and the weekly tab scans + only the current ISO-week slice via the partial covering index + `custom_xp_entry_weekly_delta (earned_at, player_id, amount) WHERE in_weekly_delta` + (mirrored in `tests/bootstrap.php`). `xp_entry.solving_time_id` is indexed for the + receipt/delete/edit lookups. + +### Formula (locked §1.2) + +`core = base × difficulty × team × unboxed × occurrence`, decomposed into separate +entries (base / difficulty / unboxed), plus full-formula extras for solves tracked at or +after `XpCalculator::FULL_FORMULA_FROM`: speed bonus (5/10/15% vs puzzle percentiles, +solo timed, median from ≥3 distinct solvers, PPM plausibility guard), weekly boost +(+50% core for the first 5 XP-earning solves per ISO week) and daily warm-up (+2 flat). +Occurrence positions count ALL solves of the (player, puzzle) pair in canonical order +`(COALESCE(finished_at, tracked_at), id)`; repeats earn 50%/25%, relax first 50%, +relax repeats zero. Suspicious solves earn nothing. All of it lives in the pure +`src/Services/Xp/XpCalculator.php` — exhaustively unit-tested. + +### Live wiring + +`src/Services/Xp/XpChainRecomputer.php` behind async messages: + +- add → `AwardXpForSolvingTime` (every registered team participant earns) +- edit → `RecalculateXpChainForSolve` (semantic delete+re-add of the (player, puzzle) chain) +- delete → `CompensateXpForDeletedSolve` (per-entry negative mirrors + chain rebuild) +- 15-min cron → `SettleXpBonuses` (ex-post difficulty/speed settlements, frozen forever) + +Full deterministic rebuild: `RecalculateXpForPlayer` / `myspeedpuzzling:recalculate-xp` +(`src/Services/Xp/XpRecomputer.php`) — wipes solve-derived entries, replays history, +preserves achievement entries. Proven idempotent by integration test. Use it to repair +any drift (e.g. after puzzle merges or ownership transfers, which are not live-wired). + +### Achievements + +16 tiered achievement types + admin-granted Early Adopter (DB value `supporter`). +Achievement Points are denormalized to `player.achievement_points` — BadgeEvaluator +re-anchors the absolute total on every evaluation (badge writes happen nowhere else), +so the 15-minute recalc cron self-heals any drift (e.g. manually granted badges); the +AP ladder and every AP display read the column, never aggregate the badge table. +Metrics live in `GetPlayerStatsSnapshot` (owner counters batched in one FILTER-aggregate +query), conditions in `src/BadgeConditions/`. `BadgeEvaluator` persists gap-filled tiers +and grants each new tier its XP once (`BadgeTier::points()`). First-click reveal: +`badge.revealed_at` + `RevealBadge` message (flips lower tiers along). + +Adding an achievement: see `docs/features/badges.md` — plus translation keys and (if a +new metric) a snapshot field. Everything else (catalog, holders directory, AP, XP grant) +picks it up automatically. + +### Surfaces + +Recap receipt + celebration (`XpSolveReceipt` inside the `XpRecapCelebration` +LiveComponent — one poll bridges the async award), profile/header rings (`XpRing`, +CSS-only milestone styling), achievements catalog `/achievements` + holders directory +`/achievements/{type}`, XP leaderboard `/players/xp-leaderboard` (weekly ledger delta / +all-time / AP tabs), audit page `/my/xp-history`, explainer `/how-xp-works`, fair-play +`/fair-play-xp`, one-time launch reveal `/my/xp-reveal` (DismissedHint-backed), share +cards `/xp-card/{playerId}/{launch|level-up}`. + +### Weekly digest + +See `docs/features/content-digest/README.md` (Phases 1–2 built; daily digest + rating +block deferred). Default-on, XP/achievements headline, no-activity variant never twice +in a row, signed one-click unsubscribe, `experienceSystemOptedOut` excluded. + +### Opt-out + +`Player.experienceSystemOptedOut` (settings → features) hides level, receipts, +celebrations, leaderboards, share cards and digests for that player; XP accrues +silently and everything returns on re-enable. Deliberately NOT a generic +"gamification" flag. + +## Cron (production — add to the spare.srv crontab) + +```cron +# XP bonus settlements — every 15 min, AFTER the puzzle-intelligence recalc +*/15 * * * * docker compose --file /deployment/speedpuzzling/docker-compose.yml run --rm messenger-consumer sentry-cli monitors run --schedule "*/15 * * * *" settle-xp-bonuses -- bin/console myspeedpuzzling:settle-xp-bonuses + +# Achievements recalc — every 15 min (existing badges command) +*/15 * * * * docker compose --file /deployment/speedpuzzling/docker-compose.yml run --rm messenger-consumer sentry-cli monitors run --schedule "*/15 * * * *" recalculate-badges -- bin/console myspeedpuzzling:recalculate-badges + +# Weekly content digest — Sundays 17:00 UTC (content-digest README §13; digest-consumer +# compose service must exist before first ramp) +0 17 * * 0 docker compose --file /deployment/speedpuzzling/docker-compose.yml run --rm messenger-consumer sentry-cli monitors run --schedule "0 17 * * 0" send-content-digest-weekly -- bin/console myspeedpuzzling:send-content-digest weekly +``` + +## Feature flag + +`xp-system` — `src/Services/Xp/XpFeatureGate.php`, admin-only visibility + full email +suppression while active. Surface checklist: `leak-inventory.md`. Registry: +`docs/features/feature_flags.md`. Removal = launch day (see `launch-runbook.md`). diff --git a/docs/features/xp-levels/assets/share-card-background-800.png b/docs/features/xp-levels/assets/share-card-background-800.png new file mode 100644 index 000000000..edcc322d6 Binary files /dev/null and b/docs/features/xp-levels/assets/share-card-background-800.png differ diff --git a/docs/features/xp-levels/assets/share-card-background-800.webp b/docs/features/xp-levels/assets/share-card-background-800.webp new file mode 100644 index 000000000..66ca786d1 Binary files /dev/null and b/docs/features/xp-levels/assets/share-card-background-800.webp differ diff --git a/docs/features/xp-levels/assets/share-card-background.png b/docs/features/xp-levels/assets/share-card-background.png new file mode 100644 index 000000000..88086edd3 Binary files /dev/null and b/docs/features/xp-levels/assets/share-card-background.png differ diff --git a/docs/features/xp-levels/assets/xp-hero-1200.png b/docs/features/xp-levels/assets/xp-hero-1200.png new file mode 100644 index 000000000..c79b8b1a3 Binary files /dev/null and b/docs/features/xp-levels/assets/xp-hero-1200.png differ diff --git a/docs/features/xp-levels/assets/xp-hero-1200.webp b/docs/features/xp-levels/assets/xp-hero-1200.webp new file mode 100644 index 000000000..e9e88b97e Binary files /dev/null and b/docs/features/xp-levels/assets/xp-hero-1200.webp differ diff --git a/docs/features/xp-levels/assets/xp-hero.png b/docs/features/xp-levels/assets/xp-hero.png new file mode 100644 index 000000000..f39f5b883 Binary files /dev/null and b/docs/features/xp-levels/assets/xp-hero.png differ diff --git a/docs/features/xp-levels/copy-drafts.md b/docs/features/xp-levels/copy-drafts.md new file mode 100644 index 000000000..b12cd2717 --- /dev/null +++ b/docs/features/xp-levels/copy-drafts.md @@ -0,0 +1,81 @@ +# XP Explainer page — EN copy draft (COPY:pending-jan-approval) + +## Title: How XP & Levels work + +### Intro +Every puzzle you finish moves you forward. XP (experience points) celebrates your activity — +not how fast you are, just that you showed up and puzzled. Log a solve, earn XP, watch your +level grow from 1 all the way to 50. + +### The three numbers on MySpeedPuzzling +| What | Measures | Who sees it | +|---|---|---| +| **XP & Levels** | Your activity — every solve counts | Everyone, free forever | +| **Achievement Points (AP)** | Your collection of earned achievements | Members | +| **MSP Rating** | Your speed and skill | Members (unchanged, separate) | + +XP can never be bought — no boosts, no multipliers, no shortcuts. Ever. +Levels unlock nothing functional; they're your visual puzzling identity. + +### How a solve turns into XP +Your receipt after each solve shows exactly where every point came from: + +- **Base**: roughly 1 XP per 100 pieces (a 500-piece puzzle ≈ 5 XP). Capped at 60. +- **Difficulty bonus**: harder puzzles (community difficulty tiers 3–6) add +15% to +50%. +- **Team solves**: everyone in the group earns 75% of the solo value — puzzling together counts for every pair of hands. +- **Unboxed bonus**: solved without the box picture? +20%. +- **Repeat solves**: the 2nd timed solve of the same puzzle earns 50%, later ones 25%. Relax-mode: first solve 50%, repeats 0%. +- **Speed bonus**: solo timed solves faster than the puzzle's community median earn +5%, top 25% +10%, top 10% +15% (needs times from at least 3 puzzlers). +- **Weekly boost**: your first 5 solves each week earn +50% extra. +- **Daily warm-up**: the first solve of your day brings +2 XP flat. + +**Worked example**: a solo, timed, first-time solve of a 1000-piece tier-4 puzzle, solved +without the box, faster than the median, as your first solve of the week and day: +base 10 + difficulty 3 + unboxed 3 + speed 1 + weekly boost 8 + warm-up 2 = **27 XP**. + +### Unrated puzzles +Brand-new catalog puzzles may not have a difficulty tier yet. You earn the base XP right away, +and the difficulty bonus arrives automatically ("settles") once the community rates the puzzle. +Same for the speed bonus once enough puzzlers have logged times. + +### Achievements give XP too +Every achievement tier grants XP once, forever: Bronze 5 · Silver 10 · Gold 25 · Platinum 50 · +Diamond 100. + +### Levels +[LEVEL TABLE 1–50 rendered here] +Level 50 is the summit — 3,160 XP total. XP keeps accruing past it; at level 50 the +Achievement Points ladder becomes your next mountain. + +### Can I lose XP? +Only by deleting or editing a solve — the XP that solve earned goes with it. Nothing else +ever takes XP away. Achievements are permanent. + +### FAQ +- **Do old solves count?** Yes! Your entire history was converted at launch (base formula, + no time-based bonuses — those only apply going forward). +- **I don't want any of this.** Understood — turn it off in your profile settings + ("experience system"), and your level, XP and celebrations disappear from view. +- **Why did my friend get more XP for the same puzzle?** Repeat discounts, weekly boosts and + daily warm-ups are personal; two receipts rarely match. Tap any receipt line to see its reason. + +# Fair-play page — EN copy draft (COPY:pending-jan-approval) + +## Title: Fair play & trust + +MySpeedPuzzling runs on trust. Nobody reviews your solves before they count — we believe you, +and the community's numbers stay meaningful because of a few quiet, automatic guardrails: + +- XP measures activity, never spending: there is no way to buy XP, boosts or multipliers. +- Repeat solves of the same puzzle earn less each time — logging one puzzle over and over + isn't a shortcut. +- The speed bonus only applies where a reliable community reference exists, and implausibly + fast times simply don't receive it. No accusation, no drama — the bonus just doesn't apply. +- Deleting a solve removes the XP it earned, automatically and transparently (your XP history + page shows every entry). +- Extremely large or unusual entries are capped so no single solve can distort the ladder. + +We deliberately don't publish the exact thresholds — they exist to keep things fair, not to be +gamed. If something looks off on a leaderboard, write to us; a human looks at every report. + +Puzzle honestly, celebrate loudly. 🧩 diff --git a/docs/features/xp-levels/implementation-plan.md b/docs/features/xp-levels/implementation-plan.md new file mode 100644 index 000000000..deacb17cf --- /dev/null +++ b/docs/features/xp-levels/implementation-plan.md @@ -0,0 +1,609 @@ +# XP / Levels / Achievements — Implementation Plan (Executable Backlog) + +> **Audience:** a Claude Code agent implementing this feature end-to-end. +> **Nature:** deterministic, resumable backlog. This file is the single source of progress truth. +> **Business spec authority:** this file + `docs/features/xp-levels/launch-checklist.md` (decisions) + +> `docs/features/badges.md` (shipped achievements architecture) + `docs/features/content-digest/README.md` +> (digest sending mechanics). If this plan and code conventions conflict, `CLAUDE.md` conventions win. + +--- + +## 0. EXECUTION PROTOCOL (read first, every session) + +### State tracking & resume + +- **STATE line** (update after every completed task): + + ``` + STATE: phase=DONE last_completed=P8.T7 branch=feature/dynamic-badges-system + ``` + +- Every task below is a `- [ ]` checkbox with a stable ID (`P2.T3`). Work strictly in order within a + phase; phases strictly in order (P0 → P8) unless a task is marked `[parallel-ok]`. +- **Resume protocol:** read the STATE line, find the first unchecked task, verify the previous task's + acceptance criteria actually hold (spot-check, don't trust blindly), continue. +- After completing a task: tick its checkbox, update the STATE line, commit (see below). Never batch + more than one phase into one commit. + +### Git & environment + +- Branch: **continue on `feature/dynamic-badges-system`** (the badges PR #128 branch — this plan extends + it into one end-to-end branch). Commit per task or small task group; message style follows repo + history (`Area: change`). Do NOT push or open a PR unless asked. +- **The badges system already on this branch shipped UNFLAGGED** — P0 retrofits the feature gate onto + it so the entire branch is silently deployable from the first commit of this plan. +- All PHP commands run inside docker: `docker compose exec web `. JS: `docker compose exec js-watch `. + Port conflicts: see CLAUDE.md overrides (`POSTGRES_PORT=55432 …`). +- **Never run** `doctrine:migrations:migrate` (ask Jan). **Never hand-write migrations** — generate with + `docker compose exec web php bin/console doctrine:migrations:diff` (manual SQL allowed only for + custom indexes / data UPDATEs appended to a generated migration). +- **Quality gate — run after EVERY phase** (all must pass before ticking the phase-complete task): + + ``` + docker compose exec web composer run phpstan + docker compose exec web composer run cs-fix + docker compose exec web vendor/bin/phpunit --exclude-group panther + docker compose exec web php bin/console doctrine:schema:validate + docker compose exec web php bin/console cache:warmup + ``` + +### Subagent orchestration (context management) + +- Keep the main context for: this plan, integration decisions, migrations, wiring, verification. +- Delegate to subagents (general-purpose, one per batch) work that is **self-contained + spec-complete**: + - P3 condition classes + their unit tests (spec table is exhaustive — perfect subagent batch). + - P5 static content pages (explainer, fair-play) once copy exists. + - Translation-file mechanical fills (P8). +- Give each subagent: the exact task IDs, the relevant spec tables from this file (copy them into the + prompt), file paths, the convention pointers (`CLAUDE.md`, an example neighbor file), and require the + quality-gate commands on their output. Verify their diff yourself before ticking tasks. +- Do NOT delegate: entity/migration work, messenger wiring, feature-gate plumbing, anything touching + `AddPuzzleSolvingTimeHandler`/`EditPuzzleSolvingTimeHandler`/`DeletePuzzleSolvingTimeHandler`. + +### Required reading before P0 (skim, don't deep-read) + +`CLAUDE.md` · `docs/features/badges.md` · `docs/features/xp-levels/launch-checklist.md` · +`docs/features/content-digest/README.md` · `docs/features/feature_flags.md` · `.claude/fixtures.md` · +`.claude/symfony-ux-hotwire-architecture-guide.md` · `src/Services/Badges/BadgeEvaluator.php` · +`src/Query/GetPlayerStatsSnapshot.php` · `src/Entity/Badge.php` · `src/Entity/Player.php` · +`config/packages/messenger.php` · `templates/components/BadgesProfileSection.html.twig` + +--- + +## 1. BUSINESS SPECIFICATION (complete, locked — do not re-litigate) + +### 1.1 Three currencies + +| Currency | Measures | Audience | Behavior | +|---|---|---|---| +| **XP + Levels** (new) | Activity | FREE for everyone | Never decreases except solve deletion/edit-down | +| **Achievement Points (AP)** (new) | Completion (sum of earned achievement-tier values) | Members-only display | Never decreases (cascade only on solve deletion affecting tiers — tiers are permanent per badges.md, so AP never decreases) | +| MSP Rating / MSP Points (existing) | Skill / speed | untouched | **No changes, stay fully separate** | + +Locked principles: **XP boosts/multipliers are NEVER purchasable** — no paid XP of any kind, ever. +Levels gate nothing functional (no level-gated features); rewards are visual identity only. + +Terminology (locked): the system is **"Achievements"**; "badge" = only the graphic medallion. +Existing admin-granted **Supporter → display-renamed "Early Adopter"** (keep DB enum value `supporter`). + +### 1.2 XP formula + +**Canonical solve ordering** (for occurrence/repeat logic and recompute determinism): +`ORDER BY COALESCE(finished_at, tracked_at), id` per (player, puzzle). + +**Cutoff constant** `XP_FULL_FORMULA_FROM` (a `\DateTimeImmutable` const, set at deploy/launch): +solves with `tracked_at` **before** it use the *backfill formula*; at/after it, the *full formula*. + +``` +core = base × difficulty × team × unboxed × occurrence + +base = clamp(pieces_count / 100, 1, 60) # pieces snapshot at log time (see 1.8) +difficulty = tier 1–2: 1.00 · tier 3: 1.15 · tier 4: 1.30 · tier 5: 1.40 · tier 6: 1.50 + (puzzle_difficulty.difficulty_tier; unrated: 1.00 now + one-time settlement later, §1.4) +team = 0.75 when puzzling_type IN (duo, team) — every listed participant earns (incl. owner); + 1.00 for solo +unboxed = 1.20 when unboxed flag, else 1.00 +occurrence = timed 1st solve of puzzle: 1.00 · timed 2nd: 0.50 · timed 3rd+: 0.25 + relax (seconds_to_solve IS NULL) 1st: 0.50 · relax repeat: 0.00 + (NO personal-best exception — deliberately removed) + +FULL formula additions (tracked_at ≥ cutoff only): +speed bonus = core × 0.05 / 0.10 / 0.15 for faster-than-median / top-25% / top-10% + conditions: solo + timed + puzzle median exists from ≥3 DISTINCT solvers + + plausibility guard passed (§1.8). Duo/team & relax: never. +weekly boost = core × 0.50 for the first 5 XP-earning solves per ISO week (UTC) +daily warm-up = flat +2 XP for the first XP-earning solve per UTC day + +BACKFILL formula (tracked_at < cutoff): core only. Difficulty = tier at backfill run time, +settled immediately, frozen forever. NO speed/weekly/daily bonuses, NO pending settlements +for historical solves. +``` + +**Ledger decomposition (one entry per receipt line — base and extras are SEPARATE, locked UX +requirement).** Mathematically identical to the formula above, decomposed as: + +``` +base_part = base × team × occurrence → SolveBase entry +difficulty_part = base_part × (difficulty − 1) → SolveDifficultyBonus entry (tier ≥ 3 only) +unboxed_part = (base_part + difficulty_part) × 0.20 → SolveUnboxedBonus entry (unboxed only) +core = base_part + difficulty_part + unboxed_part (not stored — derived) +speed_part = core × {0.05|0.10|0.15} → SolveSpeedBonus entry +weekly_part = core × 0.50 → SolveWeeklyBoost entry (first 5/week) +warmup = flat 2 → SolveDailyWarmup entry (first of day) +``` + +Each part rounded half-up to int independently at persist; zero-valued parts create NO entry; +`base_part` min 1 when core > 0. BACKFILL solves persist only the first three entry kinds. +Receipt lines render 1:1 from ledger entries (repeat/relax discount folds into the base line's +LABEL, never a negative line). + +### 1.3 Level curve v4 (locked — Level 50 = 3,160 XP total) + +| Lv | Σ | Lv | Σ | Lv | Σ | Lv | Σ | Lv | Σ | +|---|---|---|---|---|---|---|---|---|---| +| 2 | 5 | 12 | 87 | 22 | 273 | 32 | 689 | 42 | 1628 | +| 3 | 10 | 13 | 100 | 23 | 301 | 33 | 753 | 43 | 1770 | +| 4 | 16 | 14 | 114 | 24 | 331 | 34 | 822 | 44 | 1924 | +| 5 | 22 | 15 | 129 | 25 | 363 | 35 | 897 | 45 | 2091 | +| 6 | 29 | 16 | 145 | 26 | 399 | 36 | 978 | 46 | 2272 | +| 7 | 37 | 17 | 162 | 27 | 438 | 37 | 1066 | 47 | 2468 | +| 8 | 45 | 18 | 181 | 28 | 480 | 38 | 1161 | 48 | 2680 | +| 9 | 54 | 19 | 201 | 29 | 526 | 39 | 1264 | 49 | 2910 | +| 10 | 64 | 20 | 223 | 30 | 576 | 40 | 1376 | 50 | 3160 | +| 11 | 75 | 21 | 247 | 31 | 630 | 41 | 1497 | | | + +Σ = cumulative XP required to REACH that level. Level 1 = 0 XP. Level 50 is max; XP keeps accruing +in the ledger past 3,160 but no further levels. Levels are **numeric only — no names**. +Calibration invariant (verify in P7): backfill yields **~115 instant-Lv50 players (±10)**. + +### 1.4 Ex-post settlement (go-forward solves only) + +- Solve on unrated puzzle → `solve_base` entry now; when the puzzle FIRST gets a `puzzle_difficulty` + row (cron runs every 15 min), a one-time `difficulty_settlement` entry lands using the tier at that + moment. Same for speed: when the puzzle first reaches a ≥3-distinct-solver median, one-time + `speed_settlement` for qualifying earlier solves. **Settled once, frozen forever** — later tier/median + drift never revises entries. +- Settlement entries are **excluded from weekly-delta** (they are not "this week's activity"). +- UI: pending state shown on receipt ("difficulty bonus pending — settles when this puzzle is rated"). + +### 1.5 Decrease rules (the ONLY ways XP drops) + +- **Delete solve:** compensating negative entries for all that solve's XP (and recompute the remaining + player+puzzle chain — occurrence promotions — deterministically). Delete dialog must warn: + "you will lose N XP earned by this solve". Level may drop. +- **Edit solve:** semantically delete+re-add → recompute that solve's chain, both directions. +- Achievements/AP are never revoked (badges.md invariant), so achievement XP entries are never compensated. + +### 1.6 Achievements — locked launch lineup (16 tiered + Early Adopter) + +XP & AP per tier (locked): **Bronze 5 · Silver 10 · Gold 25 · Platinum 50 · Diamond 100**; single-tier +(Early Adopter) 25. Each earned tier row grants its XP once (ledger `achievement` entry, gap-filled +tiers each grant). AP total = same values summed over earned tiers. + +Existing 5 (shipped, PR #128 — no threshold changes): Puzzle Explorer 10/100/500/1000/2000 · +Piece Cruncher 10k/100k/500k/1M/2M · Speed Demon 500 <5h/2h/1h/45m/30m · On Fire 7/30/90/180/365-day +streak · Team Spirit 1/5/25/100/500. + +New 11 (metric semantics EXACTLY as below; "owner" = `pst.player_id` rows only; "participant" = +owner OR team JSON member, the `GetPlayerStatsSnapshot` pattern; always `suspicious = false`): + +| # | BadgeType case | Metric | Tiers B/S/G/P/D | +|---|---|---|---| +| 6 | `ZenPuzzler = 'zen_puzzler'` | owner solves with `seconds_to_solve IS NULL` | 1/10/50/150/365 | +| 7 | `FirstTry = 'first_try'` | owner solves with `first_attempt = true` | 5/50/200/500/1000 | +| 8 | `Unboxed = 'unboxed'` | owner solves with `unboxed = true` | 1/5/25/50/100 | +| 9 | `BrandExplorer = 'brand_explorer'` | owner distinct `puzzle.manufacturer_id` | 3/10/25/50/100 | +| 10 | `Marathoner = 'marathoner'` | owner solves of puzzles `pieces_count >= 2000` | 1/5/15/40/100 | +| 11 | `Photographer = 'photographer'` | owner solves with `finished_puzzle_photo IS NOT NULL` | 1/25/100/500/1000 | +| 12 | `SteadyHands = 'steady_hands'` | participant longest run of consecutive quarters with ≥1 solve (dates ≥ 2000-01-01) | 2/4/8/12/16 quarters | +| 13 | `Librarian = 'librarian'` | approved `puzzle_change_request` + `puzzle_merge_request` rows by `reporter_id` (`status = 'approved'`) | 1/5/20/50/100 | +| 14 | `SpeedDemon1000 = 'speed_1000_pieces'` | owner fastest SOLO timed 1000pc (like Speed Demon 500) | <8h/4h/2.5h/1h45m/1h15m | +| 15 | `WeekendPuzzler = 'weekend_puzzler'` | owner solves with ISODOW of `COALESCE(finished_at, tracked_at)` IN (6,7) | 10/50/150/300/600 | +| 16 | `Cataloger = 'cataloger'` | `puzzle` rows with `approved = true AND added_by_user_id = player` | 1/10/50/150/300 | + +Deferred (do NOT implement; GitHub issues in P8): Competitor, Night Owl (timezone problem), quest +board, leagues, Puzzle Passport, Year in Puzzling, secret achievements, abuse admin tooling, daily +digest, SVG badge frames. + +### 1.7 Visibility matrix (enforce everywhere; leak inventory in P0.T3) + +| Surface | Free user (self) | Free user (public) | Member (self/public) | +|---|---|---|---| +| Level, XP, progress, receipt | ✔ full | ✔ (unless opted-out/private) | ✔ | +| Achievements detail/medallions | 🔒 locked strip + count teaser "N achievements waiting for you" | ✖ nothing | ✔ (+ first-click confetti reveal, §1.9) | +| AP total / AP ladder | 🔒 teaser + read-only ladder at Lv50 | ✖ | ✔ | +| Per-achievement congratulation email | ✖ never | — | ✔ (existing badges email) | +| Holders directory lists | members only listed; counts include everyone ("+N more puzzlers") | | ✔ listed | + +- Private profiles & XP-opted-out players: excluded from ALL public lists/leaderboards. +- New opt-out: `Player.experienceSystemOptedOut` (follow `streakOptedOut` pattern; deliberately + NOT a generic "gamification" flag — future gamification features like quests get their own + separate opt-outs) — hides level/receipt/ + celebrations/leaderboards/digest for that player; XP accrues silently; reversible. +- At Level 50 the post-solve XP receipt is NOT shown; its slot shows nearest-achievement progress + (member) / waiting-achievements teaser (free). + +### 1.8 Anti-abuse guards (all automatic, all silent) + +1. Per-solve XP cap: base clamp 60 (≈6000pc). +2. Speed bonus requires median from ≥3 distinct solvers. +3. `pieces_count` snapshotted onto `puzzle_solving_time` at log time (new column; backfill from + current puzzle values in the same generated migration). +4. Relax repeats earn 0. +5. Plausibility guard: pieces-per-minute above threshold (constant `MAX_PLAUSIBLE_PPM = 30` for + ≥500pc timed solo — comfortably ABOVE world-record pace ≈17–20 PPM, so no legitimate solve is + ever affected) → no speed bonus + `warning` log (Sentry visible), zero user-facing accusation. +6. No pending window for new catalog puzzles (full trust, decided); junk cleanup = solve deletion + cascade already removes XP. +7. Fair-play policy page ships at launch (copy from Jan-approved draft). + +### 1.9 UX moments (build to this spec; copy drafts arrive separately) + +**Design quality bar (locked, applies to EVERY surface in this plan):** +- **Mobile-first** — most solves are logged from phones. Design every screen at ~360px width first, + then scale up; thumb-reachable primary actions; ≥44px tap targets; no horizontal scroll. +- **Friendly, modern, clean** — soft pastel MSP brand language (coral #EC726F, sky blue #69b3fe, + indigo #4e54c8, navy outlines #2b3445, rounded geometry, puzzle-piece motifs). Bootstrap 5 base, + match existing component styling (`assets/styles/`), generous whitespace, no visual clutter. +- **Clarity over confusion** — every number on screen answers "what is this and why do I have it" + within one tap (tooltip/ⓘ or inline label). Copy voice: warm puzzle-friend at the same table — + one sentence of feeling + one concrete stat; never guilt, never gamer jargon, never corporate. + Zero states always name the one next action and its reward. +- **Performance discipline** — CSS-only animation where possible, no CLS (skeleton heights per + the performance doctrine in `docs/performance-optimizations.md`), `prefers-reduced-motion` + respected everywhere, celebrations always skippable (tap anywhere). + +- **Post-solve receipt** (recap page, mobile-first): additive lines, never negative ("repeat solve" + folds into base line label), staggered CSS entrance, progress bar fills after total. Confetti + scarcity: normal solve none · level-up full-screen (brand colors) · Diamond-tier achievement + interstitial · queue, never two at once. `prefers-reduced-motion` honored. Free users additionally + get one quiet teaser line when the solve progressed a hidden achievement: "This solve counted + toward N waiting achievements 🔒". +- **Lazy achievement/level check**: lazy Live Component on recap page polls once for async-granted + achievements/level-ups (bridges sync render ↔ async messenger evaluation), pops celebration. +- **First-click badge reveal**: earned badge medallions start "unrevealed"; first click flips with + confetti (new nullable `revealed_at` on `badge` + endpoint). Membership-activation reveal page + reuses this (all pending reveals in sequence). +- **Profile**: thin XP ring around avatar + level chip + progress bar; achievements strip per matrix. +- **Header**: same ring/chip component, ambient (no numbers). +- **Leaderboard** `/players/xp-leaderboard`: tabs This week (default) / All time; weekly delta from + ledger (excluding settlements/backfill); country + favorites filters (reuse ladder patterns); + pinned self-row; Lv50 players show "Lv 50 · {AP} AP" (AP only if member). +- **Puzzle detail**: "Solving this earns ~N XP + up to M bonus" + personalized repeat/PB-free note + + unrated pending note. +- **XP audit page** (`/my/xp-history`): every ledger entry with reason, amount, link to solve — + user-facing auditability is a locked requirement. +- **Explainer page** (public) + **fair-play page** (public): static, from approved copy. +- **Launch reveal page** (one-time per player): medallion assembly animation, XP counter spin, + share CTA. Hero assets: `docs/features/xp-levels/assets/xp-hero-1200.{png,webp}`. +- **Share cards**: level-up + launch cards via `ResultImageController` pipeline pattern; background + `docs/features/xp-levels/assets/share-card-background-800.png` (129 KB) — overlay level medallion + + name + text in the empty center. + +### 1.10 Weekly digest (v1 = weekly only; daily deferred) + +Implement per `docs/features/content-digest/README.md` **Phases 1–2 only**, with these locked deltas: +default-on (`ContentDigestFrequency` default `weekly`), XP/achievements block is the headline +(member full detail / free teaser), no-activity variant sends but never twice in a row, footer = +notification-settings link + signed one-click unsubscribe, digest disableable in messaging settings, +suppressed entirely while feature flag active, unread-messages digest untouched, +**`experienceSystemOptedOut` players excluded from digest eligibility** (add to the eligibility SQL). + +### 1.11 Feature flag & rollout + +- Flag: single gate service `src/Services/Xp/XpFeatureGate.php` — `isVisibleFor(?PlayerProfile): bool` + → while flag is ON, true only for admins (`ADMIN_ACCESS` / existing admin check); document in + `docs/features/feature_flags.md` (convention). ALL surfaces in §1.7/§1.9 check it — including the + badge surfaces ALREADY SHIPPED on this branch (retrofitted in P0.T4). Emails/digests/notifications + suppressed while ON; persistence (badges, XP ledger) runs for everyone silently. + **Exempt (OK to leak): public API + Swagger.** +- Launch sequence: merge flagged → deploy → silent backfill on prod → admin verification (+ P7 + distribution check ≈115 max-level) → remove flag (deploy) → run reveal-email command same day. + +--- + +## 2. BACKLOG + +### P0 — Preflight & scaffolding + +- [x] **P0.T1** Check out `feature/dynamic-badges-system`, pull latest. Read the required-reading list (§0). +- [x] **P0.T2** Feature gate: `XpFeatureGate` service + entry in `docs/features/feature_flags.md` + (flag name `xp-system`, gated files list grows as phases land, removal condition = launch day). +- [x] **P0.T3** Copy §1.7 + §1.9 surfaces into `docs/features/xp-levels/leak-inventory.md` as a + checklist — INCLUDING the badge surfaces already shipped on this branch (BadgesProfileSection + component, badges overview/catalog page + route, badge congratulation email path). Every UI task + below must tick its row there when gated. +- [x] **P0.T4** Retrofit the gate onto the already-shipped badge surfaces: `BadgesProfileSection` + renders nothing for non-admins while flagged; badges catalog route/page admin-only while flagged; + `RecalculateBadgesForPlayerHandler` → `SendBadgeNotificationEmail` dispatch short-circuited while + flagged (badge PERSISTENCE keeps running for everyone — silent accumulation is intended; only + visibility + emails are gated). WebTestCase proving non-admin sees nothing + no email dispatched. +- [x] **P0.T5** Phase gate: quality-gate commands pass (baseline green). Update STATE. + NOTE (environment, pre-existing): dev DB has pending branch migration `Version20260416210601` + (badge.tier) — `doctrine:schema:validate` sync check fails until Jan runs dev migrations; mappings + validate clean (`--skip-sync`), drift == exactly the pending migrations. Panther suite has + pre-existing env flakiness (verified identical on baseline); non-panther suite = green + (`--testsuite "Project Test Suite"` — note: `--exclude-group panther` does NOT exclude them, + Panther classes carry no group attribute; exclusion is testsuite-based). + +### P1 — XP domain core (pure logic first, exhaustively unit-tested) + +- [x] **P1.T1** `src/Value/XpReason.php` string enum: `SolveBase`, `SolveDifficultyBonus`, + `SolveUnboxedBonus`, `SolveSpeedBonus`, `SolveWeeklyBoost`, `SolveDailyWarmup`, + `DifficultySettlement`, `SpeedSettlement`, `Achievement`, `SolveCompensation` — 1:1 with the §1.2 + ledger decomposition. +- [x] **P1.T2** `src/Value/LevelTable.php` (pure static): the §1.3 curve; `levelForXp(int): int`, + `xpForLevel(int): int`, `progressToNext(int): ?float`. Unit tests incl. boundaries (0, 4, 5, 3159, 3160, 99999). +- [x] **P1.T3** Entity `src/Entity/XpEntry.php`, table `xp_entry`: id (uuid7), player_id (uuid, indexed), + amount (int, signed), reason (XpReason string), solving_time_id (uuid NULL, **plain column, no FK**), + badge_id (uuid NULL, plain), in_weekly_delta (bool), earned_at (datetime_immutable), created_at (Clock). + **`earned_at` semantics (CRITICAL — weekly delta correctness):** solve-derived entries = + `COALESCE(solve.finished_at, solve.tracked_at)`; achievement entries = `badge.earned_at`; + settlement entries = settlement run time (they're `in_weekly_delta = false` anyway). NEVER + clock-now for solve-derived entries — backfill/recompute would otherwise dump 450k historical + entries into launch week's delta leaderboard. + Unique partial indexes: `custom_xp_entry_solve_reason` ON (solving_time_id, reason) WHERE + solving_time_id IS NOT NULL AND reason != 'solve_compensation'; `custom_xp_entry_badge` ON + (badge_id) WHERE badge_id IS NOT NULL — both idempotency anchors. Index (player_id, earned_at). + Repository persist-only. Generated migration (+ custom indexes appended manually, mirrored in + `tests/bootstrap.php`). +- [x] **P1.T4** `Player`: add `xpTotal` (int, default 0) + `level` (smallint, default 1) + + `experienceSystemOptedOut` (bool, default false, `changeExperienceSystemOptedOut()`), generated migration. +- [x] **P1.T5** `puzzle_solving_time.pieces_count_snapshot` (int NULL) — generated migration + manual + UPDATE backfilling from current `puzzle.pieces_count`. New solves set it at creation + (Add/Edit handlers). XP always reads the snapshot (fallback to puzzle value when NULL). +- [x] **P1.T6** `src/Services/Xp/XpCalculator.php` — PURE service: input = solve context DTO (pieces, + difficulty tier, type, unboxed, timed?, occurrence index, cutoff side, week/day counters, median + percentile), output = list of (XpReason, int amount). Encodes ENTIRE §1.2 incl. rounding. Unit tests: + every multiplier, occurrence ladder, relax, team, backfill-vs-full, caps, PPM guard exclusion, + boundary rounding. Aim for the exhaustive table-driven style of `tests/BadgeConditions/*`. +- [x] **P1.T7** `src/Services/Xp/XpLedger.php` — persistence orchestrator: award entries + update + `Player.xpTotal`/`level` in same transaction (messenger middleware handles flush); returns + level-up info. `src/Query/GetXpProfile.php` (total, level, progress), `GetXpEntriesForSolve.php`, + `GetXpHistory.php` (paginated, for audit page). +- [x] **P1.T8** Recompute: message+handler `RecalculateXpForPlayer` — rebuilds a player's FULL ledger + deterministically from solve history (canonical ordering §1.2): deletes that player's solve-derived + entries, recreates, preserves `Achievement` entries, restores totals. Console command + `myspeedpuzzling:recalculate-xp [--player=UUID] [--all]` (thin, dispatches). Integration test: + seed solves → recompute twice → identical ledger (idempotency proof). +- [x] **P1.T9** Phase gate: quality gates + review §1.2 against `XpCalculator` line by line. STATE. + IMPLEMENTATION NOTES (P1): `custom_xp_entry_solve_reason` is `(player_id, solving_time_id, reason)` — + player_id HAD to join the key because every team participant earns entries for the same solve (§1.2); + the plan's original `(solving_time_id, reason)` collides on team solves (proven by integration test). + Occurrence position counts ALL solves of (player, puzzle) regardless of mode ("relax repeat" = any + prior solve of the puzzle). Suspicious solves earn no XP (consistent with badges/statistics/intelligence). + Recompute reproduces difficulty/speed as regular bonus parts from CURRENT tier/median (settlement + entries exist only between live-award and the next recompute). `pieces_count_snapshot` is set in the + PuzzleSolvingTime constructor (covers Add + Tracking handlers automatically). xp_entry cleanup added + to DeletePlayerHandler (plain-column reference, no FK). + +### P2 — Live wiring (award, edit/delete, settlement) + +- [x] **P2.T1** `AddPuzzleSolvingTimeHandler`: after persist, dispatch (async) `AwardXpForSolvingTime` + (new message+handler → XpCalculator+XpLedger; computes occurrence via canonical ordering; snapshot + pieces). Mirror existing `RecalculateBadgesForPlayer` dispatch pattern. +- [x] **P2.T2** `EditPuzzleSolvingTimeHandler` + `DeletePuzzleSolvingTimeHandler`: dispatch chain + recompute (delete = compensations + chain recompute; edit = same). Delete confirmation dialog + (template where delete is offered): warn with the solve's current XP sum (`GetXpEntriesForSolve`). +- [x] **P2.T3** Achievement XP: in `BadgeEvaluator` (or a listener on its persist path), for each newly + persisted tier create `Achievement` xp_entry (values §1.6) — including gap-filled tiers. NO xp for + re-evaluations (unique badge rows already guarantee once-only). +- [x] **P2.T4** Settlement: console command `myspeedpuzzling:settle-xp-bonuses` — for go-forward solves + lacking `DifficultySettlement` whose puzzle now has difficulty: settle; same for speed. Frozen via + the P1.T3 unique index. Wire into cron docs (same 15-min cadence, AFTER intelligence recalc). + `in_weekly_delta = false` on settlement entries. Tests. +- [x] **P2.T5** Weekly boost / daily warm-up counters: computed inside `AwardXpForSolvingTime` from + ledger (count this ISO-week/day `SolveBase` entries for player, UTC). Deterministic under recompute + (recompute replays canonical order). Tests: 6 solves in a week → 5 boosted; midnight boundaries. +- [x] **P2.T6** Email rules on the existing `SendBadgeNotificationEmail` path: (a) short-circuit while + `XpFeatureGate` flag ON (done in P0.T4 — verify it composes); (b) **post-launch rule: send ONLY to + players with active membership** (free users never receive per-achievement emails — they can't see + the badges; they get the digest teaser instead, §1.7). Tests prove both. + **Email inventory rule (locked): the weekly digest + the members-only achievement email are the + ONLY recurring emails this system sends. NO level-up emails, no per-XP emails — do not invent any.** +- [x] **P2.T7** Phase gate: quality gates; fixture doc `.claude/fixtures.md` updated if fixtures grew. STATE. + IMPLEMENTATION NOTES (P2): live wiring = `XpChainRecomputer` (award/rebuild/compensate/settle) behind + three async messages (`AwardXpForSolvingTime`, `RecalculateXpChainForSolve`, `CompensateXpForDeletedSolve`) + + `SettleXpBonuses`; `AddPuzzleTrackingHandler` (relax logging path) also dispatches award + badge recalc. + Delete = per-entry negative mirrors (audit preserved, idempotent via net-sum guard) + scoped pair rebuild; + edit = pair rebuild incl. removed-team-member cleanup. Weekly/daily counters = canonical-order ledger + counts (entries of deleted solves never hold slots). `RecalculateBadgesForPlayer` gained `isBackfill` + (suppresses email + keeps backfilled achievement XP out of weekly delta) — this IS the suppressed-email + mode P7.T1 refers to. Fixed pre-existing badges bug: evaluator read highest-tier-only (`GetBadges::forPlayer` + DISTINCT ON) so cron re-evaluation crashed on the unique index for multi-tier players — new + `GetBadges::allEarnedTiers`. Fixtures unchanged (tests seed their own rows). + +### P3 — Achievements expansion `[parallel-ok — ideal subagent batch]` + +- [x] **P3.T1** Extend `PlayerStatsSnapshot` + `GetPlayerStatsSnapshot` with the 11 metrics of §1.6 + (owner vs participant semantics EXACT; quarterly streak = island detection like the existing + streak calculator but quarter-granular, dates ≥ 2000-01-01 guard for the year-0024 data bug). + Keep it a fixed number of queries (batch metrics into few SQLs, not 11). +- [x] **P3.T2** 11 new `BadgeType` cases (§1.6 values) + 11 condition classes in `src/BadgeConditions/` + (10 extend `AbstractAscendingThresholdCondition`; `SpeedDemon1000Condition` mirrors + `Speed500PiecesCondition` descending logic). Unit tests per class (copy existing test style). +- [x] **P3.T3** Early Adopter rename: translations only (`badges.badge.supporter` → "Early Adopter" + label text), keep enum value. Grep templates for hardcoded "Supporter". +- [x] **P3.T4** Translation keys (EN only now): `badges.badge.*`, `badges.description.*`, + `badges.requirement.*_{1..5}` for all 11 + rule-clarifying descriptions for non-obvious ones + (Steady Hands "no gaps, quarters", Librarian "accepted only", First Try vs Unboxed distinction). +- [x] **P3.T5** AP: `BadgeTier::points()` (5/10/25/50/100) + single-tier constant 25; + `src/Query/GetAchievementPoints.php` (player AP total); wire achievement XP values (P2.T3) to same source. +- [x] **P3.T6** Phase gate: quality gates + run `myspeedpuzzling:recalculate-badges --player=` + in dev proving new conditions evaluate. STATE. + IMPLEMENTATION NOTES (P3): dev-DB badge writes are blocked until Jan runs pending migrations + (badge.tier missing locally), so the "new conditions evaluate" proof runs in the test env instead: + `XpChainRecomputerTest::testBadgeEvaluatorGrantsAchievementXpOncePerTier` asserts zen_puzzler + + first_try badges are earned by the fixture player through the full evaluator path (all 16 registered + conditions, verified via debug:container --tag=badge.condition). Steady Hands uses CALENDAR year + quarters (EXTRACT(YEAR)), not ISOYEAR — ISOYEAR mislabels Jan 1 in ISO week 52/53. Owner counters + batched into ONE FILTER-aggregate SQL; 4 new queries total. AP query `GetAchievementPoints` guards + SQL CASE values against BadgeTier::points() drift by unit test (full ladder + Early Adopter = 215 AP). + +### P4 — UI phase A: solve loop surfaces (all behind XpFeatureGate) + +- [x] **P4.T1** Post-solve receipt component on recap page (`templates/added_time_recap.html.twig` + area): server-rendered lines from `GetXpEntriesForSolve` + progress bar (LevelTable), §1.9 rules + (Lv50 replacement slot, pending-bonus dim line, opted-out hides). CSS-only animations. +- [x] **P4.T2** Lazy Live Component `XpRecapCelebration` (src/Component/): polls once (or defers via + `loading="lazy"` livecomponent idiom) for async results; level-up interstitial + confetti (small + vendored lib or CSS, ≤3KB, reduced-motion). Queueing rule: level-up before achievement toast. + **Level 50 variant:** golden full celebration for EVERYONE (never paywalled), then a fork screen — + member: enter the AP ladder; free: the same AP ladder READ-ONLY (real names, real totals) + + membership CTA. No free-month grant (explicitly decided against). +- [x] **P4.T3** Profile: avatar XP ring + level chip + progress (in `PlayerHeader` region of + `templates/player_profile.html.twig`), achievements strip states per §1.7 incl. free-user locked + strip + "N waiting" teaser; `revealed_at` column (generated migration) + reveal endpoint + (single-action controller, POST) + first-click confetti flip; membership-activation reveal page + reusing it. Respect private/opted-out branches (mirror existing `rankingOptedOut` template pattern). + **Milestone ring styling is CSS-ONLY** (locked): gradient ring variants intensifying at levels + 10/20/30/40, golden at 50 — brand palette, zero image assets. +- [x] **P4.T4** Header avatar ring (shared Twig component with P4.T3; no numbers). +- [x] **P4.T5** Puzzle detail XP estimate line (+ personalized repeat note, unrated pending note). +- [x] **P4.T6** Phase gate: quality gates + leak-inventory rows ticked for every touched surface + (verify as non-admin in dev: NOTHING visible). STATE. + IMPLEMENTATION NOTES (P4): shared `XpRing` component (ambient header variant + full profile + variant, CSS-only milestone rings via conic-gradient); receipt is `XpSolveReceipt` embedded in + the `XpRecapCelebration` LiveComponent whose single `data-poll` bridges the async award (the + re-render also refreshes the receipt, so the sync-render race resolves itself). Level-up + detection is ledger-derived (player total minus this solve's total), race-free. The Lv50 fork + links to route `xp_leaderboard` which P5.T3 creates — DORMANT until then (renders only for + level-50 players; none exist in fixtures). Reveal = RevealBadge message (sync) flipping the + clicked tier + lower tiers of the same type; membership page shows an XpRevealInvite. Non-admin + invisibility proven by `XpSurfacesTest` (11 assertions incl. header, estimate, reveal endpoints). + +### P5 — UI phase B: pages + +- [x] **P5.T1** Achievements catalog rework: rename UI "Badges"→"Achievements" (route alias/redirect + from `/badges`), AP chips per tier, §1.7 free-user "Earned ✓ — waiting 🔒" states, progress bars stay. +- [x] **P5.T2** Holders directory: catalog cards link to `/achievements/{type}` detail page — per-tier + holder lists (avatar, name, country flag, earned date), country filter, "first to earn" highlight, + newest earners, member-only lists + full counts ("+N more puzzlers"), private/opted-out excluded. + Query with proper indexes (check EXPLAIN on badge table). +- [x] **P5.T3** XP leaderboard page per §1.9 (weekly default from ledger `in_weekly_delta`, all-time + from `player.xp_total`, pinned self-row, filters, Lv50→AP display) + an **Achievement Points tab** + (members ranked by AP total; viewable by all logged-in users — this is the "read-only AP ladder" + free Lv50 players are pointed to). +- [x] **P5.T4** XP audit page `/my/xp-history` (paginated ledger with reasons + solve links). +- [x] **P5.T5** Explainer + fair-play pages (static controllers+templates). DRAFT real EN copy from + §1 of this plan (three-currency table, formula with worked examples, level table, FAQ; fair-play: + trust principles + what's automatically unrewarded — never publish exact guard thresholds), and + mark both templates `` at top for Jan's review pass. +- [x] **P5.T6** Launch reveal page (one-time: `revealed_launch_at` on Player or reuse DismissedHint + pattern — pick DismissedHint-style row, no Player column) + level share-card route in + `ResultImageController` style using the 800×800 background asset + launch/level-up card variants. +- [x] **P5.T7** Phase gate: quality gates + full leak-inventory pass as non-admin. STATE. + IMPLEMENTATION NOTES (P5): catalog moved to /achievements URLs (old /badges = 301, route name + `badges_overview` kept so existing path() calls work); fixed second highest-tier-only bug in + GetBadgeCatalog (earnedMap now allEarnedTiers). Holders listing = members with public profiles, + non-opted-out (ROW_NUMBER per tier, 30 listed, counts include everyone; badge.type index added, + EXPLAIN verified). Leaderboard tabs this-week/all-time/achievement-points with country+favorites + filters and pinned self-rank; the P4 dormant `xp_leaderboard` route is now LIVE. Audit page joins + puzzle+badge context per entry (deleted solves keep entries, show as 'deleted solve'). Launch + reveal one-time state = DismissedHint row with new HintType::XpLaunchReveal; share cards via + GetXpShareCard (Intervention, 800×800 brand background, cached per level). Assets copied to + public/img/xp/. Explainer + fair-play pages delegated to subagent (copy from approved-for-draft + file, marked COPY:pending-jan-approval; no guard thresholds published). + +### P6 — Weekly digest (content-digest Phases 1–2, weekly only) + +Follow `docs/features/content-digest/README.md` §16 Phase 1 + Phase 2 checklists verbatim, with +§1.10 deltas. Key tasks (tick the README's boxes too): + +- [x] **P6.T1** README Phase 1 complete (enum default `weekly`, `ContentDigestLog`, message+transport+ + routing incl. dev/test config, handler with staleness/eligibility/failure classification, query, + console command with stagger, messaging-settings preference UI, unsubscribe controller + signed + URLs + headers, tests, audit adjustments §12). +- [x] **P6.T2** Weekly template + blocks: XP/achievements headline (member/free variants per §1.7), + week-in-numbers, streak recap (honor streakOptedOut), favorites roundup, next-achievement progress + (member), no-activity variant + never-twice-in-a-row eligibility (README §7). Skip daily-only + blocks entirely. Footer: notification settings link + unsubscribe. +- [x] **P6.T3** Digest suppressed while feature flag ON (gate inside dispatch command + handler). +- [x] **P6.T4** Phase gate: quality gates; send test digest to Mailpit in dev (both variants + teaser + variant), verify rendering. STATE. + IMPLEMENTATION NOTES (P6): the Mailpit dev-send is blocked by the pending dev migrations (digest + tables missing locally) — equivalent proof is `WeeklyDigestEmailRenderingTest` rendering all three + variants (member/free-teaser/no-activity) through the full Inky+inline-CSS pipeline. Rating-movement + block (README block 6) deferred with the daily digest (subset-ready clause). 554 responses retry + (indistinguishable from MAIL FROM relay-denied); 550–553 are permanent. Audit cleanup batches by + re-dispatching one message per 10k rows (each batch commits its own transaction under the + doctrine_transaction middleware). The experience-system opt-out checkbox joined the features form + (gate-hidden) — no plan task owned that UI, §1.7 requires reversibility. + +### P7 — Launch tooling & verification + +- [x] **P7.T1** Backfill orchestration command `myspeedpuzzling:xp-backfill`: dispatches + `RecalculateXpForPlayer` for all players with solves (DelayStamp stagger pattern from + `RecalculateBadgesConsoleCommand`), then achievements recalc (`--backfill` suppressed-email mode + already exists — verify email suppression composes with P2.T6 flag check). +- [x] **P7.T2** Verification command `myspeedpuzzling:xp-distribution`: prints level pyramid + instant- + max count + top-20 totals. Acceptance: dev fixtures sane; prod expectations documented inline — + the hard calibration invariants are **≈115 players at Level 50 (±10, = 1.6%)** and **median player + around Level 13–14**; rank-115 total ≈ 3,190+. (Do not invent per-bracket percentage targets — + they were not calibrated for the final curve; the two invariants above are the acceptance test.) +- [x] **P7.T3** One-time reveal email: message+handler+command `myspeedpuzzling:send-xp-reveal-emails` + (staggered, transactional transport, hero asset embedded, per-player level/stats, List-Unsubscribe + headers, one-per-player idempotency log — mirror ContentDigestLog pattern with type `xp_reveal`). +- [x] **P7.T4** Cron documentation block in `docs/features/xp-levels/README.md` (see P8.T1): settle + command cadence, digest weekly cron (from content-digest README §13), recalc integration. +- [x] **P7.T5** Launch-day runbook `docs/features/xp-levels/launch-runbook.md`: exact ordered commands + (backfill → verify → flag removal deploy → reveal emails → digest ramp start), rollback notes + (flag re-add), Jan's manual steps (cron entries, FB posts). +- [x] **P7.T6** Phase gate: quality gates. STATE. + IMPLEMENTATION NOTES (P7): the plan's premise that a "--backfill suppressed-email mode already + exists" was wrong (the old flag only staggered) — P2 added `isBackfill` to the message, which now + suppresses emails AND keeps achievement XP out of the weekly delta; xp-backfill dispatches with it. + Reveal-email idempotency reuses content_digest_log (digest_type 'xp_reveal', period 'launch') per + the plan's "mirror ContentDigestLog pattern" instruction — no new table. Reveal email embeds the + hero PNG inline (cid) and carries List-Unsubscribe headers pointing at the signed digest + unsubscribe (closest global lever). + +### P8 — Hardening, i18n, docs, deferred issues + +- [x] **P8.T1** Write `docs/features/xp-levels/README.md` — the feature's living doc (business rules + §1 condensed, architecture actually built, adding-an-achievement how-to, cron, flag). Update + `CLAUDE.md` Feature Planning section pointer + `docs/features/feature_flags.md` final state. +- [x] **P8.T2** Leak functional test (WebTestCase): as anonymous + as logged non-admin non-member, + request every §1.7/§1.9 surface (profile, leaderboard, catalog, holders, explainer, audit page, + share-card routes, recap) → assert zero XP/level/achievement traces while flag ON. This test is + DELETED on launch day together with the flag (note in feature_flags.md). +- [x] **P8.T3** Anti-abuse verification tests: cap, <3-solver median, PPM guard, relax-repeat-zero. +- [x] **P8.T4** Translations: EN complete → run missing-translations workflow to fill cs/de/es/fr/ja + for ALL new keys (messages + emails). Mark cs achievement names for Jan's native review in the + launch checklist. +- [x] **P8.T5** Create GitHub issues (gh CLI) for every §1.6-deferred item + SVG frames + timezone + handling + daily digest + abuse tooling; link them in launch-checklist §6. +- [x] **P8.T6** Final full gate: all five quality commands + `vendor/bin/phpunit` full suite green + + leak test green + STATE line set to `phase=DONE`. + IMPLEMENTATION NOTES (P8): full suite = 1602 non-Panther tests green (4466 assertions); the + complete run's only failures are the PRE-EXISTING Panther browser-env flakiness verified + identical on the clean baseline in P0 (element-click-intercepted in tests/Panther/PuzzleLibrary, + unrelated to this feature). Leak test = XpLeakTest (28 tests / 184 assertions: every §1.7/§1.9 + surface as anonymous + logged non-admin non-member). Translations: 379 keys × 5 locales filled + via the missing-translations workflow (5 parallel agents), 0 missing everywhere, all 18 yml files + lint-clean; stale pre-existing stub values (badges.title/my_title/badge.supporter) manually + renamed to the Achievements/Early Adopter terminology in all 5 locales. GitHub issues #148–#158 + created for every deferred item and linked in launch-checklist §6. +- [x] **P8.T7** Update `docs/features/xp-levels/launch-checklist.md`: tick implementation-complete, + leave Jan's items (images, copy approvals, ops) clearly outstanding. + +--- + +## 3. OUT OF SCOPE (do not build) + +Jan's deliverables: badge tier-frame + icon images (fallback medallions cover absence), final copy +approvals, cs native review, Seznam inquiry, FB posts, prod cron entries, running prod backfill/ +migrations. Deferred features: everything listed in §1.6-deferred. MSP Points/Rating: untouched. +Listmonk: untouched. Daily digest: untouched (design exists, build later). + +## 4. KNOWN TRAPS (learned during design — respect these) + +- `finished_at` data contains year-0024 rows (pre-2000 guard REQUIRED in any date-window SQL). +- Difficulty tiers run 1–6 (not 1–5) — multipliers in §1.2 are per-tier explicit. +- `doctrine_transaction` middleware wraps handlers — never throw after persisting what must commit + (see content-digest README §6 for the exact pattern). +- FrankenPHP worker mode: any new service caching per-request state implements `ResetInterface`. +- Modal-frame forms need explicit `action:`; stream responses gate on `Turbo-Frame` header (CLAUDE.md). +- Template changes need container restart in dev (`docker compose restart web`) — PHP changes don't. +- Tests test handlers/services directly, never console commands. +- The badge email uses `X-Transport: transactional`; digests use `notifications`. diff --git a/docs/features/xp-levels/launch-checklist.md b/docs/features/xp-levels/launch-checklist.md new file mode 100644 index 000000000..14a1764a8 --- /dev/null +++ b/docs/features/xp-levels/launch-checklist.md @@ -0,0 +1,165 @@ +# XP / Levels / Achievement Points — Launch Checklist + +Owner (Jan) deliverables and decisions for the XP & badges expansion. Keep this file up to date — +tick items as they complete, add new ones as they surface. The full business design will live in +`docs/features/xp-levels/README.md`; badges architecture is documented in `docs/features/badges.md`. + +_Last updated: 2026-07-13 — implementation COMPLETE (all phases P0–P8 of implementation-plan.md); outstanding items below are Jan's._ + +## Design decisions locked (context) + +- Levels are **numeric only (1–50)** — no bracket/level names. +- Level 50 = 3,160 XP total, curve v4 (levels 2–25 identical to v1; levels 26–50 stretched ~8.5%/step to absorb + achievement XP — with achievements included, ~112 players instantly max, 1.6%, same share as solve-only v1; + median-active time-to-max ~2.6–2.9 years). +- XP = activity (free for everyone) · Achievement Points + badges = members-only · MSP Points/Rating stay separate. +- **Weekly digest email — in scope** (un-deferred 2026-07-11). Rules: + - Content: achievements earned last week, XP gained, levels gained. + - Members get full achievement details; free users get the teaser instead ("X achievements are waiting for you" — + marketing style, never specific badge celebrations). + - **No-activity week: digest still sends**, in a warm encouraging tone (community voice: "we haven't seen a solve + from you this week — a puzzle is always a good idea"), never guilt-based. + - **Never two no-activity digests in a row**: after sending one, skip further digests until the player logs + activity again (allowed pattern: activity → no-activity → activity → no-activity; forbidden: no-activity twice consecutively). + - Opted-out players receive no digest. + - Every digest email footer links to **notification settings** (plus signed one-click unsubscribe); the weekly + digest is **disableable in notification settings** via the `contentDigestFrequency` preference (weekly/daily/none). + - Sending mechanics/channel: **defined in `docs/features/content-digest/README.md`** (dedicated + `digest_emails` Messenger queue + rate-paced consumer, `notifications` mailer transport). +- Milestone visuals (levels 10/20/30/40/50): **CSS-only gradient rings** from the brand palette — no art dependency. +- Level-up / launch share cards: **distinct new design** (background art needed from Jan, see §2). +- Freshly added catalog puzzles: **no pending-XP window** — full trust, XP settles immediately; junk cleanup relies on delete-cascading XP removal. +- Launch reveal email: **staggered transactional** (Symfony mailer + Messenger DelayStamp, badges-backfill pattern). +- Rollout: **feature-flagged big bang** (refined 2026-07-12). The whole system merges and deploys behind a + **documented feature flag** (tracked in `docs/features/feature_flags.md` per project convention): + - While flagged: visible to **admins only**; XP/achievement recalculation MAY run silently (backfill + verification + in production), but **no emails, no digests, no notifications** leave the system. + - Launch = remove the flag (deploy) → run the reveal-email command. Public launch and reveal email same day. + - **Leak-surface inventory** — every one of these must be flag-gated and verified as a non-admin before merge: + profile (level ring, chip, achievements strip) · header avatar ring · XP leaderboard · achievements catalog + + holders directory pages · post-solve recap (XP receipt, celebrations, lazy achievement Live Component) · + puzzle-detail XP estimate · explainer + fair-play pages · share-card image routes (direct URL access!) · + achievement congratulation emails from the recalc handler (suppressed while flagged — the existing badge email + path already sends on new earns!) · weekly digest sends (none while flagged) · in-app notifications · + activity feed entries · sitemap/SEO (new pages unindexed/unlinked while flagged) · digest preference + visibility in settings. **Explicitly exempt (OK to leak): public API responses + Swagger docs.** + - Pre-launch verification = admins review everything in production + re-run the calibration distribution queries. +- Launch timing: **as soon as it's built** (implementation + images + translations done). +- **Badge holders directory** (launch scope): global badges page lists every badge + tier; each badge links to a + dedicated detail page showing who holds each tier (avatar, name, country flag, earned date), filterable by country, + with holder counts, "first to earn" highlight and newest earners. Holder *lists* show members only (public badge + display is a membership perk, consistent with profiles); holder *counts* include everyone ("+ N more puzzlers"). + Private profiles / opted-out players excluded from public lists. +- Achievement XP per tier — **locked**: Bronze 5 / Silver 10 / Gold 25 / Platinum 50 / Diamond 100 + (single-tier achievements: 25). Full ladder of one achievement = 190 XP. +- **Terminology — locked**: the system is called **Achievements**; "badge" refers only to the earned graphic + medallion. Catalog page becomes "Achievements"; UI copy follows this rule. +- Existing admin-granted **Supporter** achievement renamed to **"Early Adopter"**. +- Achievements with non-obvious rules get a short rule-clarifying description (already supported by the + per-achievement description + per-tier requirement translation keys — content task, not architecture). + +## 1. Badges — goal: at least 10 badge types at launch + +Currently shipped (PR #128): Puzzle Explorer, Piece Cruncher, Speed Demon 500, On Fire (streak), +Team Spirit — plus admin-granted Supporter. + +- [x] **LINEUP LOCKED (2026-07-12)** — 16 tiered achievements + Early Adopter. Backfill holder counts from + production (base 7,004 players) in parentheses per tier B/S/G/P/D: + | Achievement | Tiers | Holders at backfill | + |---|---|---| + | Puzzle Explorer [shipped] | 10/100/500/1000/2000 distinct puzzles | 3721/931/64/2/0 | + | Piece Cruncher [shipped] | 10k/100k/500k/1M/2M pieces | 3161/737/34/2/1 | + | Speed Demon 500 [shipped] | <5h/2h/1h/45m/30m solo | 5855/5268/2642/1309/291 | + | On Fire [shipped] | 7/30/90/180/365 day streak | 1058/69/10/6/3 | + | Team Spirit [shipped] | 1/5/25/100/500 team solves | 3860/2558/1207/307/5 | + | Zen Puzzler | 1/10/50/150/365 relax solves | 895/218/33/3/1 | + | First Try | 5/50/200/500/1000 first-attempts | 3803/1207/271/41/1 | + | Unboxed | 1/5/25/50/100 no-box solves | 1010/264/30/10/1 | + | Brand Explorer | 3/10/25/50/100 manufacturers | 3779/1576/472/97/7 | + | Marathoner | 1/5/15/40/100 solves of 2000+pc | 373/36/8/1/1 | + | Photographer | 1/25/100/500/1000 photos | 2968/404/123/6/1 | + | Steady Hands | 2/4/8/12/16 unbroken quarters | 4151/2030/689/124/25 | + | Librarian | 1/5/20/50/100 accepted proposals | 214/51/9/2/1 | + | Speed Demon 1000 | <8h/4h/2.5h/1h45/1h15 solo | 2086/1496/674/199/21 | + | Weekend Puzzler | 10/50/150/**300**/**600** weekend solves | 2580/948/251/46/2 | + | Cataloger | 1/10/50/150/**300** approved catalog adds | 3205/769/117/24/5 | + + **Final calibration verified (2026-07-12):** with this full lineup's achievement XP included, curve v4 + (Level 50 = 3,160) yields **exactly 115 instant-max players (1.6%)** — no curve adjustment needed. +- [ ] **Jan: provide badge images** (outstanding — medallion fallback covers absence) — decided: produced all at once **after the lineup locks**. + **Launch approach (updated 2026-07-16): generated locally via ComfyUI on Jan's M3 Max** — replaces the + ChatGPT pipeline; model research + candidate stacks + bake-off protocol in + `docs/design-system/badge-generation-comfyui.md`. Visual spec still `docs/design-system/prompts/badges.md`. + - [x] Run the model bake-off (4 stacks) → winner FLUX.2 Klein 9B; full Speed Demon set produced + end-to-end 2026-07-17 — recipe + findings + scripts in `docs/design-system/badge-pipeline/README.md`. + Jan's pick pending: style Row B (pure flat) vs Row C (hand-illustrated polish) + - [ ] 5 AI-generated puzzle-piece tier frames (socket→tab progression) — ONE image/grid + (single-generation consistency, technique #1 from `docs/design-system/badges-conversation.md`) or + control-image-guided geometry, then crop + - [ ] 1 center icon per badge type (grid batches + verbatim style prefix from `docs/design-system/prompts/badges.md`) — 10+ icons; + never include puzzle-piece shapes inside icon subjects + - Template falls back to tier-colored medallions, so missing art blocks polish, not functionality. + +## 2. Other artwork + +⚠ Known AI limitation (same insight as the V4 badge pipeline): ChatGPT draws unnatural puzzle knobs. +Both prompts now carry a strict knob-geometry block + "fewer, larger pieces" rule. + +- [x] Launch email + reveal page hero illustration — **done** (generated 2026-07-12, approved). Master + + web-optimized variants in `docs/features/xp-levels/assets/` (`xp-hero-1200.png` 186 KB TinyPNG-optimized for + email, `xp-hero-1200.webp` 49 KB for the reveal page). Prompt: `docs/design-system/prompts/subjects/xp-launch-reveal-hero.md`. +- [x] Level share-card background — **done, AI-generated** (SVG route explicitly declined for now). Master + + optimized variants in `docs/features/xp-levels/assets/` (`share-card-background-800.png` 129 KB TinyPNG-optimized + source for the card pipeline, `.webp` 30 KB). Prompt: `docs/design-system/prompts/subjects/level-share-card-background.md`. + +## 3. Copy & policy (Jan reviews, Claude drafts) — drafts DONE, review outstanding + +- [ ] **Jan: review/approve fair-play page** — `templates/xp_fair_play.html.twig` (marked `COPY:pending-jan-approval`; texts in `translations/messages.en.yml` under `xp.fair_play.*`) +- [ ] **Jan: review/approve XP explainer page** — `templates/xp_explainer.html.twig` (`xp.explainer.*` keys) +- [ ] **Jan: review launch email + reveal page copy** — `translations/emails.en.yml` `xp_reveal.*` + `templates/xp_launch_reveal.html.twig` (`xp.reveal.*` keys) + +## 4. Operations (Jan) — step-by-step commands in `launch-runbook.md` + +- [ ] T-7 teaser + launch posts in Facebook groups (timed to implementation readiness) +- [ ] Add production cron entries (`README.md` §Cron: settle-xp-bonuses, recalculate-badges, weekly digest) + `digest-consumer` compose service + deploy.sh workers line (content-digest README §13) +- [ ] Run launch sequence: `myspeedpuzzling:xp-backfill` → `myspeedpuzzling:xp-distribution` (verify ≈115 Lv50 ±10, median L13–14) → remove flag + deploy → `myspeedpuzzling:send-xp-reveal-emails` +- [ ] Ask Seznam support for the Email Profi outbound ceiling (blocks full digest volume, not ramp-up) +- [ ] Dev environment: run `doctrine:migrations:migrate` locally (7 pending migrations from this branch, incl. pre-existing badge.tier) +- [x] Content-digest decisions (2026-07-12): weekly digest **default-on** for existing players · + **daily digest deferred completely — weekly only for v1** (Sunday-overlap question moot) · + unread-messages digest **stays separate** + +## 5. Translations — required before launch + +Everything (UI texts, emails, explainer, fair-play policy, badge names/descriptions) translated to +**all supported locales: cs, de, en, es, fr, ja**. + +- [x] Build in English first (project convention) +- [x] Final pass: fill all locales (missing-translations workflow, 379 keys × 5 locales) +- [ ] **Jan: badge/achievement names reviewed by a native for cs at minimum** — the cs names to + review live in `translations/messages.cs.yml` under `badges.badge.*` (16 names + Early Adopter + = "Průkopník"). Translator flags for the review pass: (a) new gamification texts use informal + "ty" while older settings/emails use "vy" — decide on a global voice; (b) "achievement" = "úspěch" + everywhere new, verify no leftover "odznak" wording reads oddly next to it (emails.badges_earned + still speaks of "odznaky" = the medallions, which matches the locked terminology); + (c) `emails.badges_earned.title` rendered count-neutrally as "%count%× nový odznak!". + +## Ideas noted for technical planning + +- **Lazy achievement check on the result page**: a lazy-loaded Live Component on the post-solve recap page that + checks whether the (async) recalculation granted any achievement/level-up and pops the celebration when it lands — + bridges the gap between the synchronous page render and the async badge evaluation. (Jan, 2026-07-12) + +## 6. Deferred to GitHub issues — CREATED 2026-07-13 + +- **Competitor achievement** — [#148](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/148) +- **Night Owl achievement** — [#149](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/149) +- **Timezone handling for solve timestamps** — [#150](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/150) +- **SVG badge tier frames** — [#151](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/151) +- **Weekly quest board** — [#152](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/152) +- **Leagues / weekly tables** — [#153](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/153) +- **Puzzle Passport** — [#154](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/154) +- **Year in Puzzling** — [#155](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/155) +- **Secret achievements** — [#156](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/156) +- **Abuse admin tooling** — [#157](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/157) +- **Daily content digest (Phase 3)** — [#158](https://github.com/MySpeedPuzzling/myspeedpuzzling.com/issues/158) diff --git a/docs/features/xp-levels/launch-runbook.md b/docs/features/xp-levels/launch-runbook.md new file mode 100644 index 000000000..b4dc0c48f --- /dev/null +++ b/docs/features/xp-levels/launch-runbook.md @@ -0,0 +1,96 @@ +# XP / Levels — Launch-Day Runbook + +Everything below runs on production (`spare.srv:/deployment/speedpuzzling`) unless noted. +The whole branch deploys SILENTLY first — while the `xp-system` flag is active, only +admins see anything and no emails leave the system. + +## 0. Prerequisites (before launch day) + +- [ ] Branch merged + deployed (flag active) — migrations run automatically on container boot +- [ ] Jan: badge tier-frame + icon images dropped into `public/img/badges/` (optional — medallion fallback covers absence) +- [ ] Jan: copy approvals — explainer + fair-play pages (`` markers), reveal email, launch reveal page +- [ ] Jan: cs native review of achievement names (after P8 translation fill) +- [ ] Jan: cron entries from `README.md` §Cron added to the host crontab +- [ ] Deliverability: FBL + Google Postmaster registrations (content-digest README §14) + +## 1. Silent backfill (flag still ACTIVE — safe, invisible, no emails) + +```bash +docker compose run --rm messenger-consumer bin/console myspeedpuzzling:xp-backfill +``` + +Dispatches an XP ledger rebuild for every player with solves, then achievement +evaluation in backfill mode (no congratulation emails, achievement XP excluded from the +weekly delta). Watch the queue drain: + +```bash +docker compose exec postgres psql -U user -d speedpuzzling -c "SELECT queue_name, COUNT(*) FROM messenger_messages GROUP BY 1;" +``` + +Re-running is safe (deterministic recompute + gap-filling evaluator). + +## 2. Verify calibration (flag still ACTIVE) + +```bash +docker compose run --rm messenger-consumer bin/console myspeedpuzzling:xp-distribution +``` + +Hard invariants (production data, ~7,004 players with solves): + +- **≈115 players at Level 50 (±10, ~1.6%)** +- **median player around Level 13–14** +- rank-115 XP total ≈ 3,190+ + +Admins additionally review in production: profile ring/receipt/catalog/leaderboard/ +holders/audit/explainer/reveal page + a test solve end-to-end. + +If numbers are off → investigate BEFORE removing the flag; the public saw nothing yet. +Fixes + `myspeedpuzzling:xp-backfill` re-runs are cheap at this stage. + +## 3. Launch = remove the flag (deploy) + +1. Flip `XpFeatureGate` (`$adminOnly = true` → `false`) — or remove the gate + call + sites entirely per `feature_flags.md` (also DELETE the leak WebTestCases: + `XpPagesTest`, `XpSurfacesTest`, flag-specific tests in + `BadgesOverviewControllerTest` / `RecalculateBadgesForPlayerHandlerTest` / + `DigestSettingsVisibilityTest` — they assert 404s that stop existing). +2. Confirm `XpCalculator::FULL_FORMULA_FROM` is set to the intended cutoff (solves + tracked BEFORE it use the backfill formula; set it to launch-day midnight UTC). +3. Deploy. Smoke-check as a NON-admin: profile ring visible, catalog public, receipt + renders after a solve. + +## 4. Same day: reveal emails + +```bash +docker compose run --rm messenger-consumer bin/console myspeedpuzzling:send-xp-reveal-emails +``` + +Refuses to run while the flag is active. One email per player forever (idempotency log), +2s stagger (~4h for 7k recipients), transactional identity. + +## 5. Digest ramp start + +Follow content-digest README §14 ramp plan (~1–2k most-engaged first). The weekly cron +sends to everyone eligible — for the ramp weeks, either keep the cron off and dispatch +manually, or accept full volume once deliverability prerequisites are green. First send: + +```bash +docker compose run --rm messenger-consumer bin/console myspeedpuzzling:send-content-digest weekly +``` + +(Requires the `digest-consumer` compose service + deploy.sh change from content-digest +README §13.) + +## Rollback + +Re-add the flag (`$adminOnly = true`) + deploy — every surface disappears for +non-admins again, emails stop. Data (ledger, badges, logs) stays intact and keeps +accruing silently; nothing else to undo. Reveal emails already sent cannot be unsent — +that is why verification (step 2) happens before flag removal. + +## Jan's manual list (collected) + +- Run steps 1–5 above on launch day +- Cron entries (README §Cron) + `digest-consumer` compose service + deploy.sh workers line +- FB group posts (T-7 teaser + launch) +- Seznam Email Profi outbound-ceiling inquiry (blocks full digest volume only) diff --git a/docs/features/xp-levels/leak-inventory.md b/docs/features/xp-levels/leak-inventory.md new file mode 100644 index 000000000..bbceaf580 --- /dev/null +++ b/docs/features/xp-levels/leak-inventory.md @@ -0,0 +1,59 @@ +# XP / Achievements — Leak-Surface Inventory + +Every user-facing surface of the gamification bundle must be gated by `XpFeatureGate` +(`src/Services/Xp/XpFeatureGate.php`, flag `xp-system` in `docs/features/feature_flags.md`) while +the flag is active. **Tick a row ONLY after verifying the surface as a non-admin (and as anonymous +where applicable) shows NOTHING** — no XP, level, achievement, or badge traces. + +Rules while flagged: + +- Non-admins see nothing and receive **no emails, digests, or notifications**. +- Badge + XP **persistence keeps running for everyone** (silent accumulation is intended). +- **Exempt by decision (OK to leak): public API responses + Swagger docs.** + +## Already-shipped badge surfaces (retrofit, P0.T4) + +- [x] Profile badges section (`BadgesProfileSection` component) — renders nothing for non-admins (verified: `BadgesOverviewControllerTest::testProfileBadgesSectionHiddenFromNonAdminsWhileFlagged`) +- [x] Badges catalog page + route (`BadgesOverviewController`, `badges_overview`) — 404 for non-admins (verified: anonymous + logged non-admin WebTestCases) +- [x] Badge congratulation email (`RecalculateBadgesForPlayerHandler` → `SendBadgeNotificationEmail`) — dispatch short-circuited while flagged (verified: `RecalculateBadgesForPlayerHandlerTest::testEmailDispatchSuppressedWhileFlagged`) + +## Solve-loop surfaces (P4) + +- [x] Post-solve XP receipt on recap page (`added_time_recap` + `added_tracking_recap`) — verified: `XpSurfacesTest::testRecapShowsNoXpTracesToNonAdminOwner` +- [x] Lazy Live Component `XpRecapCelebration` — gate-checked in every render incl. the live endpoint; renders nothing for non-admins (covered by the recap assertions) +- [x] Profile: avatar XP ring + level chip + progress bar — verified: `XpSurfacesTest::testProfileShowsNoXpTracesToNonAdmins` +- [x] Profile: achievements strip (incl. free-user locked strip + "N waiting" teaser) — matrix inside `BadgesProfileSection`; verified: xp-teaser + ci-medal absence for non-admins +- [x] Badge reveal endpoint (POST, `revealed_at` flip) — 404 while flagged: `XpSurfacesTest::testRevealEndpointIs404ForNonAdmins` +- [x] Membership-activation reveal page (`/my/achievement-reveals` + membership-page invite) — 404/hidden while flagged: `XpSurfacesTest` +- [x] Header avatar XP ring — verified: `XpSurfacesTest::testHeaderShowsNoRingToNonAdmins` +- [x] Puzzle detail XP estimate line — verified: `XpSurfacesTest::testPuzzleDetailShowsNoEstimateToNonAdmins` +- [x] Delete-solve dialog XP warning line — controller passes 0 while flagged (template renders only when > 0); re-verified by P8 leak test + +## Pages (P5) + +- [x] Achievements catalog rework (route + `/badges` 301 redirect) — 404 for non-admins: `XpPagesTest` + `BadgesOverviewControllerTest` +- [x] Achievement holders directory (`/achievements/{type}`) — 404 for non-admins: `XpPagesTest` +- [x] XP leaderboard (`/players/xp-leaderboard`) — all three tabs 404 for non-admins: `XpPagesTest` +- [x] XP audit page (`/my/xp-history`) — 404 for non-admins: `XpPagesTest::testXpHistoryIs404ForNonAdmins` +- [x] Explainer page (`/how-xp-works`) — 404 for non-admins: `XpExplainerControllerTest` + `XpPagesTest` +- [x] Fair-play page (`/fair-play-xp`) — 404 for non-admins: `FairPlayXpControllerTest` + `XpPagesTest` +- [x] Launch reveal page (`/my/xp-reveal`) — 404 for non-admins: `XpPagesTest::testLaunchRevealIs404ForNonAdmins` +- [x] Share-card image routes (`/xp-card/{playerId}/{launch|level-up}`) — 404 for anonymous + non-admins (direct URL access covered): `XpPagesTest` + +## Emails & notifications + +- [ ] Badge congratulation email (see retrofit above; post-launch: members-only, P2.T6) +- [x] Weekly content digest (P6) — dispatch command warns+exits AND handler short-circuits while flagged (SendPlayerContentDigestHandlerTest::testSuppressedWhileFeatureFlagActive) +- [x] Digest preference in messaging settings (P6) — hidden while flagged, incl. the experience-system opt-out checkbox (DigestSettingsVisibilityTest) +- [x] One-time reveal email command (P7) — command AND handler both refuse while flagged (SendXpRevealEmailHandlerTest::testRefusesWhileFlagActive) +- [ ] In-app notifications — none planned; verify none get added +- [ ] Activity feed — verify no XP/achievement entries appear + +## SEO / discovery + +- [x] Sitemap: new pages excluded while flagged — none of the XP routes are referenced by any Sitemap*Controller (verified by grep) +- [x] No links/menu items to gated pages rendered for non-admins — all links live inside gate-checked components/pages (profile strip, catalog, receipt, membership invite); header/menu untouched + +## Final pass + +- [ ] P8.T2 leak WebTestCase green (anonymous + logged non-admin non-member across all surfaces) diff --git a/migrations/Version20260416210601.php b/migrations/Version20260416210601.php new file mode 100644 index 000000000..2fb22cb5a --- /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/migrations/Version20260713131333.php b/migrations/Version20260713131333.php new file mode 100644 index 000000000..82923acd7 --- /dev/null +++ b/migrations/Version20260713131333.php @@ -0,0 +1,47 @@ +addSql('CREATE TABLE xp_entry (id UUID NOT NULL, player_id UUID NOT NULL, amount INT NOT NULL, reason VARCHAR(255) NOT NULL, in_weekly_delta BOOLEAN NOT NULL, earned_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, solving_time_id UUID DEFAULT NULL, badge_id UUID DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_E624552899E6F5DFC4DC16F ON xp_entry (player_id, earned_at)'); + $this->addSql('ALTER TABLE player ADD xp_total INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE player ADD level SMALLINT DEFAULT 1 NOT NULL'); + $this->addSql('ALTER TABLE player ADD experience_system_opted_out BOOLEAN DEFAULT false NOT NULL'); + $this->addSql('ALTER TABLE puzzle_solving_time ADD pieces_count_snapshot INT DEFAULT NULL'); + + // Idempotency anchors — custom_ prefix keeps them out of Doctrine schema introspection + // (CustomIndexFilteringSchemaManagerFactory); both mirrored in tests/bootstrap.php. + // A player gets each receipt-line reason at most once per solve (player_id is part of + // the key because every team participant earns entries for the SAME solve); + // compensations may repeat per solve. + $this->addSql("CREATE UNIQUE INDEX custom_xp_entry_solve_reason ON xp_entry (player_id, solving_time_id, reason) WHERE solving_time_id IS NOT NULL AND reason != 'solve_compensation'"); + // One achievement XP entry per badge row, granted once forever. + $this->addSql('CREATE UNIQUE INDEX custom_xp_entry_badge ON xp_entry (badge_id) WHERE badge_id IS NOT NULL'); + + // Snapshot "pieces at log time" for all existing solves = current puzzle values (backfill decision). + $this->addSql('UPDATE puzzle_solving_time SET pieces_count_snapshot = p.pieces_count FROM puzzle p WHERE p.id = puzzle_solving_time.puzzle_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE xp_entry'); + $this->addSql('ALTER TABLE player DROP xp_total'); + $this->addSql('ALTER TABLE player DROP level'); + $this->addSql('ALTER TABLE player DROP experience_system_opted_out'); + $this->addSql('ALTER TABLE puzzle_solving_time DROP pieces_count_snapshot'); + } +} diff --git a/migrations/Version20260713142704.php b/migrations/Version20260713142704.php new file mode 100644 index 000000000..7071e2228 --- /dev/null +++ b/migrations/Version20260713142704.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE badge ADD revealed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE badge DROP revealed_at'); + } +} diff --git a/migrations/Version20260713145508.php b/migrations/Version20260713145508.php new file mode 100644 index 000000000..22162d4a0 --- /dev/null +++ b/migrations/Version20260713145508.php @@ -0,0 +1,26 @@ +addSql('CREATE INDEX IDX_FEF0481D8CDE5729 ON badge (type)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IDX_FEF0481D8CDE5729'); + } +} diff --git a/migrations/Version20260713151509.php b/migrations/Version20260713151509.php new file mode 100644 index 000000000..2951af6ea --- /dev/null +++ b/migrations/Version20260713151509.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE content_digest_log (id UUID NOT NULL, digest_type VARCHAR(255) NOT NULL, period_key VARCHAR(255) NOT NULL, sent_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, had_activity BOOLEAN NOT NULL, status VARCHAR(255) NOT NULL, player_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_332B10D299E6F5DF ON content_digest_log (player_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_332B10D299E6F5DF5C9B6C1080C3B793 ON content_digest_log (player_id, digest_type, period_key)'); + $this->addSql('ALTER TABLE content_digest_log ADD CONSTRAINT FK_332B10D299E6F5DF FOREIGN KEY (player_id) REFERENCES player (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE player ADD content_digest_frequency VARCHAR(255) DEFAULT \'weekly\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE content_digest_log'); + $this->addSql('ALTER TABLE player DROP content_digest_frequency'); + } +} diff --git a/migrations/Version20260713152816.php b/migrations/Version20260713152816.php new file mode 100644 index 000000000..f5a4c7cd7 --- /dev/null +++ b/migrations/Version20260713152816.php @@ -0,0 +1,26 @@ +addSql('CREATE INDEX IDX_F9EA5FDE13E877AC96E4F388 ON email_audit_log (email_type, sent_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IDX_F9EA5FDE13E877AC96E4F388'); + } +} diff --git a/migrations/Version20260713175512.php b/migrations/Version20260713175512.php new file mode 100644 index 000000000..406577391 --- /dev/null +++ b/migrations/Version20260713175512.php @@ -0,0 +1,58 @@ +addSql('ALTER TABLE player ADD achievement_points INT DEFAULT 0 NOT NULL'); + $this->addSql('CREATE INDEX IDX_98197A65F2A2906 ON player (xp_total)'); + $this->addSql('CREATE INDEX IDX_98197A65C0ADC3B4 ON player (achievement_points)'); + $this->addSql('CREATE INDEX IDX_E6245528D6B05C60 ON xp_entry (solving_time_id)'); + + // Weekly-delta leaderboard: index-only scan of the current ISO-week slice instead of + // a growing whole-table scan (custom_ prefix => ignored by Doctrine introspection, + // mirrored in tests/bootstrap.php). + $this->addSql('CREATE INDEX custom_xp_entry_weekly_delta ON xp_entry (earned_at, player_id, amount) WHERE in_weekly_delta = true'); + + // Backfill AP for players who already hold badges (prod: admin-granted Supporters). + // Values mirror BadgeTier::points() / SINGLE_TIER_POINTS. + $this->addSql(<<<'SQL' +UPDATE player SET achievement_points = totals.points +FROM ( + SELECT player_id, SUM( + CASE tier + WHEN 1 THEN 5 + WHEN 2 THEN 10 + WHEN 3 THEN 25 + WHEN 4 THEN 50 + WHEN 5 THEN 100 + ELSE 25 + END) AS points + FROM badge + GROUP BY player_id +) totals +WHERE totals.player_id = player.id +SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX custom_xp_entry_weekly_delta'); + $this->addSql('DROP INDEX IDX_E6245528D6B05C60'); + $this->addSql('DROP INDEX IDX_98197A65C0ADC3B4'); + $this->addSql('DROP INDEX IDX_98197A65F2A2906'); + $this->addSql('ALTER TABLE player DROP achievement_points'); + } +} diff --git a/public/img/xp/share-card-background-800.png b/public/img/xp/share-card-background-800.png new file mode 100644 index 000000000..edcc322d6 Binary files /dev/null and b/public/img/xp/share-card-background-800.png differ diff --git a/public/img/xp/xp-hero-1200.png b/public/img/xp/xp-hero-1200.png new file mode 100644 index 000000000..c79b8b1a3 Binary files /dev/null and b/public/img/xp/xp-hero-1200.png differ diff --git a/public/img/xp/xp-hero-1200.webp b/public/img/xp/xp-hero-1200.webp new file mode 100644 index 000000000..e9e88b97e Binary files /dev/null and b/public/img/xp/xp-hero-1200.webp differ diff --git a/src/BadgeConditions/AbstractAscendingThresholdCondition.php b/src/BadgeConditions/AbstractAscendingThresholdCondition.php new file mode 100644 index 000000000..7ce5a6b8e --- /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 000000000..e85ae9dc1 --- /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/BrandExplorerCondition.php b/src/BadgeConditions/BrandExplorerCondition.php new file mode 100644 index 000000000..a439d7061 --- /dev/null +++ b/src/BadgeConditions/BrandExplorerCondition.php @@ -0,0 +1,32 @@ +brandExplorerManufacturers; + } + + protected function thresholds(): array + { + return [ + 1 => 3, + 2 => 10, + 3 => 25, + 4 => 50, + 5 => 100, + ]; + } +} diff --git a/src/BadgeConditions/CatalogerCondition.php b/src/BadgeConditions/CatalogerCondition.php new file mode 100644 index 000000000..b0c694046 --- /dev/null +++ b/src/BadgeConditions/CatalogerCondition.php @@ -0,0 +1,32 @@ +catalogerApprovedPuzzles; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 10, + 3 => 50, + 4 => 150, + 5 => 300, + ]; + } +} diff --git a/src/BadgeConditions/FirstTryCondition.php b/src/BadgeConditions/FirstTryCondition.php new file mode 100644 index 000000000..38d5a16b4 --- /dev/null +++ b/src/BadgeConditions/FirstTryCondition.php @@ -0,0 +1,32 @@ +firstTrySolves; + } + + protected function thresholds(): array + { + return [ + 1 => 5, + 2 => 50, + 3 => 200, + 4 => 500, + 5 => 1000, + ]; + } +} diff --git a/src/BadgeConditions/LibrarianCondition.php b/src/BadgeConditions/LibrarianCondition.php new file mode 100644 index 000000000..ba02d4d7a --- /dev/null +++ b/src/BadgeConditions/LibrarianCondition.php @@ -0,0 +1,32 @@ +librarianApprovedRequests; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 5, + 3 => 20, + 4 => 50, + 5 => 100, + ]; + } +} diff --git a/src/BadgeConditions/MarathonerCondition.php b/src/BadgeConditions/MarathonerCondition.php new file mode 100644 index 000000000..743c20d35 --- /dev/null +++ b/src/BadgeConditions/MarathonerCondition.php @@ -0,0 +1,32 @@ +marathonerSolves; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 5, + 3 => 15, + 4 => 40, + 5 => 100, + ]; + } +} diff --git a/src/BadgeConditions/PhotographerCondition.php b/src/BadgeConditions/PhotographerCondition.php new file mode 100644 index 000000000..ff5e59cc4 --- /dev/null +++ b/src/BadgeConditions/PhotographerCondition.php @@ -0,0 +1,32 @@ +photographerSolves; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 25, + 3 => 100, + 4 => 500, + 5 => 1000, + ]; + } +} diff --git a/src/BadgeConditions/PiecesSolvedCondition.php b/src/BadgeConditions/PiecesSolvedCondition.php new file mode 100644 index 000000000..30f527db4 --- /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 000000000..cb71fb8e9 --- /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 000000000..36f3f98b5 --- /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/SpeedDemon1000Condition.php b/src/BadgeConditions/SpeedDemon1000Condition.php new file mode 100644 index 000000000..171998d60 --- /dev/null +++ b/src/BadgeConditions/SpeedDemon1000Condition.php @@ -0,0 +1,85 @@ + 28_800, + 2 => 14_400, + 3 => 9_000, + 4 => 6_300, + 5 => 4_500, + ]; + + public function badgeType(): BadgeType + { + return BadgeType::SpeedDemon1000; + } + + public function qualifiedTiers(PlayerStatsSnapshot $snapshot): array + { + $best = $snapshot->best1000PieceSoloSeconds; + + 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->best1000PieceSoloSeconds; + + 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/SteadyHandsCondition.php b/src/BadgeConditions/SteadyHandsCondition.php new file mode 100644 index 000000000..99fae6fcc --- /dev/null +++ b/src/BadgeConditions/SteadyHandsCondition.php @@ -0,0 +1,32 @@ +steadyHandsQuarters; + } + + protected function thresholds(): array + { + return [ + 1 => 2, + 2 => 4, + 3 => 8, + 4 => 12, + 5 => 16, + ]; + } +} diff --git a/src/BadgeConditions/StreakCondition.php b/src/BadgeConditions/StreakCondition.php new file mode 100644 index 000000000..871c34356 --- /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 000000000..2d1d774b2 --- /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/BadgeConditions/UnboxedCondition.php b/src/BadgeConditions/UnboxedCondition.php new file mode 100644 index 000000000..a72b47202 --- /dev/null +++ b/src/BadgeConditions/UnboxedCondition.php @@ -0,0 +1,32 @@ +unboxedSolves; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 5, + 3 => 25, + 4 => 50, + 5 => 100, + ]; + } +} diff --git a/src/BadgeConditions/WeekendPuzzlerCondition.php b/src/BadgeConditions/WeekendPuzzlerCondition.php new file mode 100644 index 000000000..c12631d43 --- /dev/null +++ b/src/BadgeConditions/WeekendPuzzlerCondition.php @@ -0,0 +1,32 @@ +weekendSolves; + } + + protected function thresholds(): array + { + return [ + 1 => 10, + 2 => 50, + 3 => 150, + 4 => 300, + 5 => 600, + ]; + } +} diff --git a/src/BadgeConditions/ZenPuzzlerCondition.php b/src/BadgeConditions/ZenPuzzlerCondition.php new file mode 100644 index 000000000..6b3c2d3e3 --- /dev/null +++ b/src/BadgeConditions/ZenPuzzlerCondition.php @@ -0,0 +1,32 @@ +zenPuzzlerSolves; + } + + protected function thresholds(): array + { + return [ + 1 => 1, + 2 => 10, + 3 => 50, + 4 => 150, + 5 => 365, + ]; + } +} diff --git a/src/Component/BadgesProfileSection.php b/src/Component/BadgesProfileSection.php new file mode 100644 index 000000000..def181261 --- /dev/null +++ b/src/Component/BadgesProfileSection.php @@ -0,0 +1,118 @@ + */ + public array $badges = []; + + public int $waitingCount = 0; + + public bool $ownProfile = false; + + private bool $viewerAllowed = false; + + public function __construct( + readonly private GetBadges $getBadges, + readonly private ClockInterface $clock, + readonly private XpFeatureGate $xpFeatureGate, + readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + ) { + } + + #[PostMount] + public function load(): void + { + if ($this->playerId === null) { + return; + } + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return; + } + + $this->ownProfile = $viewer !== null && $viewer->playerId === $this->playerId; + + if ($this->subjectIsPrivate && $this->ownProfile === false) { + return; + } + + $this->viewerAllowed = true; + + if ($this->subjectHasMembership) { + $this->badges = $this->getBadges->forPlayer($this->playerId); + + return; + } + + if ($this->ownProfile) { + $this->waitingCount = count($this->getBadges->forPlayer($this->playerId)); + } + } + + /** + * detail = medallions · teaser = locked strip for the free owner · hidden = nothing + */ + public function mode(): string + { + if ($this->viewerAllowed === false) { + return 'hidden'; + } + + if ($this->subjectHasMembership) { + return 'detail'; + } + + return $this->ownProfile ? 'teaser' : 'hidden'; + } + + /** + * 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; + } + + /** + * The first-click reveal moment belongs to the badge owner only — everyone else + * always sees the finished medallion. + */ + public function needsReveal(BadgeResult $badge): bool + { + return $this->ownProfile && $badge->isRevealed() === false && $badge->id !== null; + } +} diff --git a/src/Component/XpPuzzleEstimate.php b/src/Component/XpPuzzleEstimate.php new file mode 100644 index 000000000..12ef21ce1 --- /dev/null +++ b/src/Component/XpPuzzleEstimate.php @@ -0,0 +1,99 @@ +puzzleId === null || $this->piecesCount <= 0) { + return; + } + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return; + } + + if ($viewer !== null && $this->getXpProfile->byPlayerId($viewer->playerId)->optedOut) { + return; + } + + $awards = $this->xpCalculator->calculate(new SolveXpContext( + piecesCount: $this->piecesCount, + difficultyTier: $this->difficultyTier, + isTimed: true, + isTeamOrDuo: false, + unboxed: false, + occurrenceIndex: 1, + isBackfill: false, + speedPercentile: SpeedPercentile::Top10, + xpEarningSolvesThisWeek: 0, + isFirstXpEarningSolveOfDay: true, + )); + + foreach ($awards as $award) { + if ($award->reason === XpReason::SolveBase || $award->reason === XpReason::SolveDifficultyBonus) { + $this->baseXp += $award->amount; + } else { + $this->bonusXp += $award->amount; + } + } + + if ($viewer !== null) { + $this->viewerSolveCount = $this->getSolveXpDisplayInfo->countPlayerSolvesOfPuzzle($viewer->playerId, $this->puzzleId); + } + + $this->visible = true; + } + + public function isUnrated(): bool + { + return $this->difficultyTier === null; + } +} diff --git a/src/Component/XpRecapCelebration.php b/src/Component/XpRecapCelebration.php new file mode 100644 index 000000000..65ee2e1b1 --- /dev/null +++ b/src/Component/XpRecapCelebration.php @@ -0,0 +1,188 @@ +checked = true; + } + + #[LiveAction] + public function dismissCelebration(): void + { + $this->celebrationDismissed = true; + $this->checked = true; + } + + public function isVisible(): bool + { + return $this->profile() !== null; + } + + public function profile(): null|XpProfile + { + if ($this->profileLoaded) { + return $this->loadedProfile; + } + + $this->profileLoaded = true; + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($viewer === null || $this->solvingTimeId === '') { + return null; + } + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return null; + } + + try { + $profile = $this->getXpProfile->byPlayerId($viewer->playerId); + } catch (PlayerNotFound) { + return null; + } + + if ($profile->optedOut) { + return null; + } + + return $this->loadedProfile = $profile; + } + + public function viewerIsMember(): bool + { + return $this->retrieveLoggedUserProfile->getProfile()?->activeMembership === true; + } + + /** + * True when THIS solve's XP pushed the player over a level boundary. + */ + public function showLevelUp(): bool + { + if ($this->celebrationDismissed) { + return false; + } + + $profile = $this->profile(); + + if ($profile === null) { + return false; + } + + $solveTotal = $this->getXpEntriesForSolve->totalForPlayerAndSolvingTime($profile->playerId, $this->solvingTimeId); + + if ($solveTotal <= 0) { + return false; + } + + return LevelTable::levelForXp($profile->xpTotal) > LevelTable::levelForXp($profile->xpTotal - $solveTotal); + } + + public function isMaxLevelCelebration(): bool + { + return ($this->profile()?->isMaxLevel() ?? false) && $this->showLevelUp(); + } + + /** + * Newest achievement earned in the last few minutes — the async badge evaluation + * usually lands between page render and the single poll. Members only (§1.7). + */ + public function recentBadge(): null|BadgeResult + { + if ($this->profile() === null || $this->viewerIsMember() === false) { + return null; + } + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($viewer === null) { + return null; + } + + $cutoff = $this->clock->now()->modify('-3 minutes'); + $newest = null; + + foreach ($this->getBadges->forPlayer($viewer->playerId) as $badge) { + if ($badge->earnedAt < $cutoff) { + continue; + } + + if ($newest === null || $badge->earnedAt > $newest->earnedAt) { + $newest = $badge; + } + } + + return $newest; + } + + public function currentLevel(): int + { + return $this->profile()->level ?? 1; + } + + public function earnedAtIso(DateTimeImmutable $moment): string + { + return $moment->format(DATE_ATOM); + } +} diff --git a/src/Component/XpRevealInvite.php b/src/Component/XpRevealInvite.php new file mode 100644 index 000000000..9ee9b1933 --- /dev/null +++ b/src/Component/XpRevealInvite.php @@ -0,0 +1,50 @@ +playerId === null) { + return; + } + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return; + } + + if ($viewer === null || $viewer->playerId !== $this->playerId) { + return; + } + + $this->unrevealedCount = count($this->getBadges->unrevealedForPlayer($this->playerId)); + } +} diff --git a/src/Component/XpRing.php b/src/Component/XpRing.php new file mode 100644 index 000000000..6e34b43e3 --- /dev/null +++ b/src/Component/XpRing.php @@ -0,0 +1,102 @@ +playerId === null) { + return; + } + + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return; + } + + try { + $profile = $this->getXpProfile->byPlayerId($this->playerId); + } catch (PlayerNotFound) { + return; + } + + if ($profile->optedOut) { + return; + } + + if ($profile->private && $viewer?->playerId !== $this->playerId) { + return; + } + + $this->profile = $profile; + } + + public function isVisible(): bool + { + return $this->profile !== null; + } + + public function milestoneClass(): string + { + $level = $this->profile->level ?? 1; + + return match (true) { + $level >= LevelTable::MAX_LEVEL => 'xp-ring-milestone-50', + $level >= 40 => 'xp-ring-milestone-40', + $level >= 30 => 'xp-ring-milestone-30', + $level >= 20 => 'xp-ring-milestone-20', + $level >= 10 => 'xp-ring-milestone-10', + default => '', + }; + } + + public function progressValue(): float + { + if ($this->profile === null) { + return 0.0; + } + + return $this->profile->progressToNext() ?? 1.0; + } +} diff --git a/src/Component/XpSolveReceipt.php b/src/Component/XpSolveReceipt.php new file mode 100644 index 000000000..4ec1047e4 --- /dev/null +++ b/src/Component/XpSolveReceipt.php @@ -0,0 +1,236 @@ + */ + public array $lines = []; + + public null|XpProfile $profile = null; + + public null|SolveXpDisplayInfo $info = null; + + public bool $viewerIsMember = false; + + public int $waitingCount = 0; + + /** @var list */ + public array $nearestAchievements = []; + + /** + * @param iterable $conditions + */ + public function __construct( + #[AutowireIterator('badge.condition')] + readonly private iterable $conditions, + readonly private GetXpEntriesForSolve $getXpEntriesForSolve, + readonly private GetXpProfile $getXpProfile, + readonly private GetSolveXpDisplayInfo $getSolveXpDisplayInfo, + readonly private GetBadges $getBadges, + readonly private GetPlayerStatsSnapshot $getPlayerStatsSnapshot, + readonly private XpFeatureGate $xpFeatureGate, + readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + ) { + } + + #[PostMount] + public function load(): void + { + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->solvingTimeId === null || $viewer === null) { + return; + } + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + return; + } + + try { + $profile = $this->getXpProfile->byPlayerId($viewer->playerId); + } catch (PlayerNotFound) { + return; + } + + if ($profile->optedOut) { + return; + } + + $info = $this->getSolveXpDisplayInfo->forPlayerAndSolvingTime($viewer->playerId, $this->solvingTimeId); + + if ($info === null) { + // Viewer is not a participant of this solve — nothing to show them. + return; + } + + $this->profile = $profile; + $this->info = $info; + $this->viewerIsMember = $viewer->activeMembership; + $this->lines = $this->getXpEntriesForSolve->forPlayerAndSolvingTime($viewer->playerId, $this->solvingTimeId); + + if ($this->viewerIsMember === false) { + $this->waitingCount = count($this->getBadges->forPlayer($viewer->playerId)); + } + + if ($profile->isMaxLevel() && $this->viewerIsMember) { + $this->nearestAchievements = $this->computeNearestAchievements($viewer->playerId); + } + } + + public function mode(): string + { + if ($this->profile === null || $this->info === null) { + return 'hidden'; + } + + if ($this->profile->isMaxLevel()) { + return 'max_level'; + } + + if ($this->lines !== []) { + return 'receipt'; + } + + if ($this->info->isTimed === false && $this->info->occurrenceIndex > 1) { + return 'relax_repeat'; + } + + // Entries are written asynchronously — the celebration component's poll refreshes this slot. + return 'pending'; + } + + public function total(): int + { + $total = 0; + + foreach ($this->lines as $line) { + $total += $line->amount; + } + + return $total; + } + + public function lineLabelKey(XpEntryLine $line): string + { + if ($line->reason === XpReason::SolveBase) { + $info = $this->info; + + if ($info !== null && $info->isTimed === false) { + return 'xp.line.base_relax'; + } + + return match (true) { + $info === null || $info->occurrenceIndex <= 1 => 'xp.line.base', + $info->occurrenceIndex === 2 => 'xp.line.base_repeat_second', + default => 'xp.line.base_repeat_later', + }; + } + + return 'xp.line.' . $line->reason->value; + } + + public function showDifficultyPending(): bool + { + return $this->info !== null + && $this->lines !== [] + && $this->info->isBackfill === false + && $this->info->puzzleHasDifficultyTier === false + && $this->hasLine(XpReason::SolveDifficultyBonus) === false + && $this->hasLine(XpReason::DifficultySettlement) === false; + } + + public function showSpeedPending(): bool + { + return $this->info !== null + && $this->lines !== [] + && $this->info->isBackfill === false + && $this->info->isSolo + && $this->info->isTimed + && $this->info->speedMedianReliable === false + && $this->hasLine(XpReason::SolveSpeedBonus) === false + && $this->hasLine(XpReason::SpeedSettlement) === false; + } + + private function hasLine(XpReason $reason): bool + { + foreach ($this->lines as $line) { + if ($line->reason === $reason) { + return true; + } + } + + return false; + } + + /** + * Top progress toward not-yet-earned achievement tiers — the level-50 replacement slot. + * + * @return list + */ + private function computeNearestAchievements(string $playerId): array + { + $snapshot = $this->getPlayerStatsSnapshot->forPlayer($playerId); + + $highestEarned = []; + foreach ($this->getBadges->forPlayer($playerId) as $badge) { + if ($badge->tier !== null) { + $highestEarned[$badge->type->value] = $badge->tier; + } + } + + $candidates = []; + + foreach ($this->conditions as $condition) { + $type = $condition->badgeType(); + $earned = $highestEarned[$type->value] ?? null; + + if ($earned === BadgeTier::Diamond) { + continue; + } + + $progress = $condition->progressToNextTier($snapshot, $earned); + + if ($progress === null || $progress->percent <= 0) { + continue; + } + + $candidates[] = ['type' => $type, 'progress' => $progress]; + } + + usort($candidates, static fn (array $a, array $b): int => $b['progress']->percent <=> $a['progress']->percent); + + return array_slice($candidates, 0, 2); + } +} diff --git a/src/ConsoleCommands/CleanupEmailAuditLogsCommand.php b/src/ConsoleCommands/CleanupEmailAuditLogsCommand.php index 6a803bac7..2704fa9a6 100644 --- a/src/ConsoleCommands/CleanupEmailAuditLogsCommand.php +++ b/src/ConsoleCommands/CleanupEmailAuditLogsCommand.php @@ -52,7 +52,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var int $deleted */ $deleted = $handledStamp->getResult(); - $io->success("Deleted {$deleted} email audit log entries older than {$days} days."); + // Bulk digest emails get a much shorter retention (content-digest README §12). + $digestDays = min($days, 30); + $digestEnvelope = $this->messageBus->dispatch(new CleanupEmailAuditLogs($digestDays, emailTypePrefix: 'content_digest')); + + /** @var HandledStamp $digestStamp */ + $digestStamp = $digestEnvelope->last(HandledStamp::class); + /** @var int $digestDeleted */ + $digestDeleted = $digestStamp->getResult(); + + $io->success("Deleted {$deleted} email audit log entries older than {$days} days (+{$digestDeleted} digest entries older than {$digestDays} days)."); return Command::SUCCESS; } diff --git a/src/ConsoleCommands/RecalculateBadgesConsoleCommand.php b/src/ConsoleCommands/RecalculateBadgesConsoleCommand.php new file mode 100644 index 000000000..7998240b4 --- /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, isBackfill: $backfill), $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/ConsoleCommands/RecalculateXpConsoleCommand.php b/src/ConsoleCommands/RecalculateXpConsoleCommand.php new file mode 100644 index 000000000..3005e2209 --- /dev/null +++ b/src/ConsoleCommands/RecalculateXpConsoleCommand.php @@ -0,0 +1,88 @@ +addOption( + 'player', + null, + InputOption::VALUE_REQUIRED, + 'Recalculate only for this single player UUID.', + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'Recalculate for every player with solve times.', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $specificPlayer = $input->getOption('player'); + $all = (bool) $input->getOption('all'); + + if ($specificPlayer !== null) { + if (!is_string($specificPlayer)) { + $io->error('Invalid --player value.'); + return self::INVALID; + } + + $this->commandBus->dispatch(new RecalculateXpForPlayer($specificPlayer)); + $io->success("Dispatched XP recalculation for player {$specificPlayer}."); + + return self::SUCCESS; + } + + if ($all === false) { + $io->error('Pass either --player=UUID or --all.'); + + return self::INVALID; + } + + $playerIds = $this->getAllPlayerIds->execute(); + + if ($playerIds === []) { + $io->success('No players with solve times found — nothing to dispatch.'); + return self::SUCCESS; + } + + foreach ($playerIds as $playerId) { + $this->commandBus->dispatch(new RecalculateXpForPlayer($playerId)); + } + + $count = count($playerIds); + $io->success("Dispatched {$count} XP recalculation message(s)."); + + return self::SUCCESS; + } +} diff --git a/src/ConsoleCommands/SendContentDigestConsoleCommand.php b/src/ConsoleCommands/SendContentDigestConsoleCommand.php new file mode 100644 index 000000000..0875bdadd --- /dev/null +++ b/src/ConsoleCommands/SendContentDigestConsoleCommand.php @@ -0,0 +1,80 @@ +addArgument('type', InputArgument::REQUIRED, 'Digest type: weekly (daily is deferred)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $type = $input->getArgument('type'); + + if ($type !== 'weekly') { + $io->error('Only the weekly digest exists in v1 — the daily digest is deferred.'); + + return self::INVALID; + } + + // No digest leaves the system while the xp-system flag is active (§1.10). + if ($this->xpFeatureGate->isEmailSendingEnabled() === false) { + $io->warning('xp-system feature flag is active — digest sending is suppressed, nothing dispatched.'); + + return self::SUCCESS; + } + + $period = DigestPeriod::weeklyFor($this->clock->now()); + $playerIds = $this->getPlayersForContentDigest->weekly($period->key); + + foreach ($playerIds as $index => $playerId) { + $this->commandBus->dispatch( + new SendPlayerContentDigest($playerId, 'weekly', $period->key), + [new DelayStamp($index * self::STAGGER_MS)], + ); + } + + $count = count($playerIds); + $io->success("Dispatched {$count} weekly digest message(s) for period {$period->key}."); + + return self::SUCCESS; + } +} diff --git a/src/ConsoleCommands/SendXpRevealEmailsConsoleCommand.php b/src/ConsoleCommands/SendXpRevealEmailsConsoleCommand.php new file mode 100644 index 000000000..3c75325f0 --- /dev/null +++ b/src/ConsoleCommands/SendXpRevealEmailsConsoleCommand.php @@ -0,0 +1,70 @@ +xpFeatureGate->isEmailSendingEnabled() === false) { + $io->error('The xp-system feature flag is still active — remove the flag and deploy BEFORE sending reveal emails.'); + + return self::FAILURE; + } + + $playerIds = $this->getPlayersForXpRevealEmail->execute(); + + if ($playerIds === []) { + $io->success('Nobody left to notify — every eligible player already got the reveal email.'); + + return self::SUCCESS; + } + + foreach ($playerIds as $index => $playerId) { + $this->commandBus->dispatch( + new SendXpRevealEmail($playerId), + [new DelayStamp($index * self::STAGGER_MS)], + ); + } + + $count = count($playerIds); + $minutes = (int) ceil($count * self::STAGGER_MS / 60_000); + $io->success("Dispatched {$count} reveal email(s), spread over ~{$minutes} minutes."); + + return self::SUCCESS; + } +} diff --git a/src/ConsoleCommands/SettleXpBonusesConsoleCommand.php b/src/ConsoleCommands/SettleXpBonusesConsoleCommand.php new file mode 100644 index 000000000..6c651066b --- /dev/null +++ b/src/ConsoleCommands/SettleXpBonusesConsoleCommand.php @@ -0,0 +1,35 @@ +commandBus->dispatch(new SettleXpBonuses()); + + (new SymfonyStyle($input, $output))->success('Dispatched XP bonus settlement.'); + + return self::SUCCESS; + } +} diff --git a/src/ConsoleCommands/XpBackfillConsoleCommand.php b/src/ConsoleCommands/XpBackfillConsoleCommand.php new file mode 100644 index 000000000..bcb787e14 --- /dev/null +++ b/src/ConsoleCommands/XpBackfillConsoleCommand.php @@ -0,0 +1,88 @@ +getAllPlayerIds->execute(); + + if ($playerIds === []) { + $io->success('No players with solve times found — nothing to backfill.'); + + return self::SUCCESS; + } + + foreach ($playerIds as $index => $playerId) { + $this->commandBus->dispatch( + new RecalculateXpForPlayer($playerId), + [new DelayStamp($index * self::XP_DELAY_MS)], + ); + } + + // Achievements start after the last XP recompute is released, so every badge + // evaluation sees a complete ledger and Achievement XP entries anchor cleanly. + $badgesOffset = count($playerIds) * self::XP_DELAY_MS; + + foreach ($playerIds as $index => $playerId) { + $this->commandBus->dispatch( + new RecalculateBadgesForPlayer($playerId, isBackfill: true), + [new DelayStamp($badgesOffset + $index * self::BADGES_DELAY_MS)], + ); + } + + $count = count($playerIds); + $io->success("Dispatched {$count} XP recompute + {$count} achievement backfill message(s)."); + $io->note('Re-run myspeedpuzzling:xp-distribution once the queue drains to verify the calibration invariants.'); + + return self::SUCCESS; + } +} diff --git a/src/ConsoleCommands/XpDistributionConsoleCommand.php b/src/ConsoleCommands/XpDistributionConsoleCommand.php new file mode 100644 index 000000000..de0fbf2ca --- /dev/null +++ b/src/ConsoleCommands/XpDistributionConsoleCommand.php @@ -0,0 +1,110 @@ + $pyramid */ + $pyramid = $this->database->fetchAllAssociative( + 'SELECT level, COUNT(*) AS players FROM player WHERE xp_total > 0 GROUP BY level ORDER BY level DESC', + ); + + if ($pyramid === []) { + $io->warning('No players with XP found — run myspeedpuzzling:xp-backfill first.'); + + return self::SUCCESS; + } + + $total = 0; + foreach ($pyramid as $row) { + $total += (int) $row['players']; + } + + $io->title('Level pyramid'); + $rows = []; + foreach ($pyramid as $row) { + $players = (int) $row['players']; + $rows[] = [ + 'Lv ' . $row['level'], + $players, + sprintf('%.1f%%', $players / $total * 100), + str_repeat('█', max(1, (int) round($players / $total * 60))), + ]; + } + $io->table(['Level', 'Players', 'Share', ''], $rows); + + $maxLevelCount = 0; + foreach ($pyramid as $row) { + if ($row['level'] >= LevelTable::MAX_LEVEL) { + $maxLevelCount += (int) $row['players']; + } + } + + $median = $this->database->fetchOne( + 'SELECT PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY level) FROM player WHERE xp_total > 0', + ); + + $io->section('Calibration invariants (production expectations)'); + $io->listing([ + sprintf('Players at Level 50: %d — expected ≈115 (±10) on production data', $maxLevelCount), + sprintf('Median level: %s — expected around 13–14 on production data', is_numeric($median) ? (string) (int) $median : 'n/a'), + sprintf('Players with XP: %d', $total), + ]); + + /** @var list $top */ + $top = $this->database->fetchAllAssociative( + 'SELECT name, code, xp_total, level FROM player WHERE xp_total > 0 ORDER BY xp_total DESC LIMIT 20', + ); + + $io->section('Top 20 XP totals (rank-115 total should be ≈3,190+ on production)'); + $io->table( + ['#', 'Player', 'XP', 'Level'], + array_map( + static fn (array $row, int $index): array => [ + $index + 1, + $row['name'] ?? ('#' . strtoupper($row['code'])), + $row['xp_total'], + $row['level'], + ], + $top, + array_keys($top), + ), + ); + + return self::SUCCESS; + } +} diff --git a/src/Controller/AchievementDetailController.php b/src/Controller/AchievementDetailController.php new file mode 100644 index 000000000..5eb2d8860 --- /dev/null +++ b/src/Controller/AchievementDetailController.php @@ -0,0 +1,65 @@ + '/uspechy/{type}', + 'en' => '/en/achievements/{type}', + 'es' => '/es/logros/{type}', + 'ja' => '/ja/実績/{type}', + 'fr' => '/fr/succes/{type}', + 'de' => '/de/erfolge/{type}', + ], + name: 'achievement_detail', + )] + public function __invoke(Request $request, string $type): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $badgeType = BadgeType::tryFrom($type); + + if ($badgeType === null || $badgeType->isTiered() === false) { + throw $this->createNotFoundException(); + } + + $country = $request->query->getString('country'); + $country = $country !== '' ? strtolower($country) : null; + + return $this->render('achievement_detail.html.twig', [ + 'type' => $badgeType, + 'tier_sections' => $this->getAchievementHolders->forType($badgeType, $country), + 'newest_earners' => $this->getAchievementHolders->newestEarners($badgeType), + 'countries' => $this->getAchievementHolders->countries($badgeType), + 'selected_country' => $country, + ]); + } +} diff --git a/src/Controller/BadgeRevealsController.php b/src/Controller/BadgeRevealsController.php new file mode 100644 index 000000000..5e61c897c --- /dev/null +++ b/src/Controller/BadgeRevealsController.php @@ -0,0 +1,58 @@ + '/moje/odhaleni-uspechu', + 'en' => '/en/my/achievement-reveals', + 'es' => '/es/mis/logros-revelar', + 'ja' => '/ja/実績公開', + 'fr' => '/fr/mes/succes-reveler', + 'de' => '/de/meine/erfolge-aufdecken', + ], + name: 'badge_reveals', + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + // Achievement detail is a members-only surface — free players get the teaser instead. + if ($profile->activeMembership === false) { + return $this->redirectToRoute('membership'); + } + + return $this->render('badge_reveals.html.twig', [ + 'badges' => $this->getBadges->unrevealedForPlayer($profile->playerId), + ]); + } +} diff --git a/src/Controller/BadgesOverviewController.php b/src/Controller/BadgesOverviewController.php new file mode 100644 index 000000000..1dbcb2870 --- /dev/null +++ b/src/Controller/BadgesOverviewController.php @@ -0,0 +1,57 @@ + '/uspechy', + 'en' => '/en/achievements', + 'es' => '/es/logros', + 'ja' => '/ja/実績', + 'fr' => '/fr/succes', + 'de' => '/de/erfolge', + ], + name: 'badges_overview', + )] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $playerId = $profile?->playerId; + + $catalog = $this->getBadgeCatalog->forPlayer($playerId); + + return $this->render('badges_overview.html.twig', [ + 'catalog' => $catalog, + 'logged_in' => $playerId !== null, + 'is_member' => $profile?->activeMembership === true, + 'ap_total' => $profile !== null && $profile->activeMembership + ? $this->getAchievementPoints->forPlayer($profile->playerId) + : null, + ]); + } +} diff --git a/src/Controller/BadgesOverviewRedirectController.php b/src/Controller/BadgesOverviewRedirectController.php new file mode 100644 index 000000000..54289d996 --- /dev/null +++ b/src/Controller/BadgesOverviewRedirectController.php @@ -0,0 +1,33 @@ + '/odznaky', + 'en' => '/en/badges', + 'es' => '/es/insignias', + 'ja' => '/ja/バッジ', + 'fr' => '/fr/badges', + 'de' => '/de/abzeichen', + ], + name: 'badges_overview_legacy', + )] + public function __invoke(): Response + { + return $this->redirectToRoute('badges_overview', [], Response::HTTP_MOVED_PERMANENTLY); + } +} diff --git a/src/Controller/EditProfileController.php b/src/Controller/EditProfileController.php index ab8a93387..15d15ba24 100644 --- a/src/Controller/EditProfileController.php +++ b/src/Controller/EditProfileController.php @@ -26,6 +26,7 @@ use SpeedPuzzling\Web\Query\GetPlayerOAuth2Consents; use SpeedPuzzling\Web\Query\GetPlayerPersonalAccessTokens; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; +use SpeedPuzzling\Web\Services\Xp\XpFeatureGate; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -47,6 +48,7 @@ public function __construct( readonly private GetPlayerOAuth2Consents $getPlayerOAuth2Consents, readonly private GetPlayerPersonalAccessTokens $getPlayerPersonalAccessTokens, readonly private GetOAuth2ClientRequests $getOAuth2ClientRequests, + readonly private XpFeatureGate $xpFeatureGate, ) { } @@ -146,7 +148,11 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response $messagingSettingsFormData = MessagingSettingsFormData::fromPlayerProfile($player); - $messagingSettingsForm = $this->createForm(MessagingSettingsFormType::class, $messagingSettingsFormData); + $xpSurfacesVisible = $this->xpFeatureGate->isVisibleFor($player); + + $messagingSettingsForm = $this->createForm(MessagingSettingsFormType::class, $messagingSettingsFormData, [ + 'show_content_digest' => $xpSurfacesVisible, + ]); $messagingSettingsForm->handleRequest($request); if ($messagingSettingsForm->isSubmitted() && $messagingSettingsForm->isValid()) { @@ -157,6 +163,7 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response $messagingSettingsFormData->emailNotificationsEnabled, $messagingSettingsFormData->emailNotificationFrequency, $messagingSettingsFormData->newsletterEnabled, + $xpSurfacesVisible ? $messagingSettingsFormData->contentDigestFrequency : null, ) ); @@ -167,7 +174,9 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response $featuresOptionsFormData = FeaturesOptionsFormData::fromPlayerProfile($player); - $featuresOptionsForm = $this->createForm(FeaturesOptionsFormType::class, $featuresOptionsFormData); + $featuresOptionsForm = $this->createForm(FeaturesOptionsFormType::class, $featuresOptionsFormData, [ + 'show_experience_system' => $xpSurfacesVisible, + ]); $featuresOptionsForm->handleRequest($request); if ($featuresOptionsForm->isSubmitted() && $featuresOptionsForm->isValid()) { @@ -177,6 +186,7 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response $featuresOptionsFormData->streakOptedOut, $featuresOptionsFormData->rankingOptedOut, $featuresOptionsFormData->timePredictionsOptedOut, + $xpSurfacesVisible ? $featuresOptionsFormData->experienceSystemOptedOut : null, ) ); diff --git a/src/Controller/EditTimeController.php b/src/Controller/EditTimeController.php index a9ba88bee..408f64f77 100644 --- a/src/Controller/EditTimeController.php +++ b/src/Controller/EditTimeController.php @@ -14,9 +14,11 @@ use SpeedPuzzling\Web\Query\GetPlayerSolvedPuzzles; use SpeedPuzzling\Web\Query\GetPuzzleOverview; use SpeedPuzzling\Web\Query\GetPuzzlesOverview; +use SpeedPuzzling\Web\Query\GetXpEntriesForSolve; use SpeedPuzzling\Web\Results\PuzzleOverview; use SpeedPuzzling\Web\Results\SolvedPuzzleDetail; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; +use SpeedPuzzling\Web\Services\Xp\XpFeatureGate; use SpeedPuzzling\Web\Value\EditTimeReturnContext; use SpeedPuzzling\Web\Value\PuzzleAddMode; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -42,6 +44,8 @@ public function __construct( readonly private GetPuzzleOverview $getPuzzleOverview, readonly private TranslatorInterface $translator, readonly private GetFavoritePlayers $getFavoritePlayers, + readonly private GetXpEntriesForSolve $getXpEntriesForSolve, + readonly private XpFeatureGate $xpFeatureGate, ) { } @@ -166,6 +170,10 @@ public function __invoke(Request $request, #[CurrentUser] User $user, string $ti 'return_context' => $context->value, 'return_url' => $this->resolveReturnUrl($context, $solvedPuzzle), 'return_title' => $this->resolveReturnTitle($context, $solvedPuzzle), + // Delete dialog warning: how much XP disappears with this solve (0 = hide line). + 'xp_delete_warning' => $this->xpFeatureGate->isVisibleFor($player) + ? max($this->getXpEntriesForSolve->totalForPlayerAndSolvingTime($player->playerId, $timeId), 0) + : 0, ]; if ($isModalRequest) { diff --git a/src/Controller/FairPlayXpController.php b/src/Controller/FairPlayXpController.php new file mode 100644 index 000000000..65d7816ab --- /dev/null +++ b/src/Controller/FairPlayXpController.php @@ -0,0 +1,42 @@ + '/fair-play-xp', + 'en' => '/en/fair-play-xp', + 'es' => '/es/fair-play-xp', + 'ja' => '/ja/フェアプレイxp', + 'fr' => '/fr/fair-play-xp', + 'de' => '/de/fair-play-xp', + ], + name: 'xp_fair_play', + )] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + return $this->render('xp_fair_play.html.twig'); + } +} diff --git a/src/Controller/PlayerProfileController.php b/src/Controller/PlayerProfileController.php index 262840b7a..56a3f8404 100644 --- a/src/Controller/PlayerProfileController.php +++ b/src/Controller/PlayerProfileController.php @@ -5,7 +5,6 @@ namespace SpeedPuzzling\Web\Controller; 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; @@ -25,7 +24,6 @@ public function __construct( readonly private GetRanking $getRanking, readonly private GetFavoritePlayers $getFavoritePlayers, readonly private GetTags $getTags, - readonly private GetBadges $getBadges, readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, readonly private HasExistingConversation $hasExistingConversation, readonly private GetAffiliateSupporters $getAffiliateSupporters, @@ -65,7 +63,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/Controller/RevealBadgeController.php b/src/Controller/RevealBadgeController.php new file mode 100644 index 000000000..96c28420b --- /dev/null +++ b/src/Controller/RevealBadgeController.php @@ -0,0 +1,54 @@ + '/odznaky/{badgeId}/odhalit', + 'en' => '/en/badges/{badgeId}/reveal', + 'es' => '/es/insignias/{badgeId}/reveal', + 'ja' => '/ja/バッジ/{badgeId}/reveal', + 'fr' => '/fr/badges/{badgeId}/reveal', + 'de' => '/de/abzeichen/{badgeId}/reveal', + ], + name: 'reveal_badge', + methods: ['POST'], + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke(string $badgeId): Response + { + $loggedPlayer = $this->retrieveLoggedUserProfile->getProfile(); + assert($loggedPlayer !== null); + + if ($this->xpFeatureGate->isVisibleFor($loggedPlayer) === false) { + throw $this->createNotFoundException(); + } + + $this->messageBus->dispatch(new RevealBadge( + playerId: $loggedPlayer->playerId, + badgeId: $badgeId, + )); + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/UnsubscribeContentDigestController.php b/src/Controller/UnsubscribeContentDigestController.php new file mode 100644 index 000000000..90f749d6a --- /dev/null +++ b/src/Controller/UnsubscribeContentDigestController.php @@ -0,0 +1,57 @@ +uriSigner->checkRequest($request) === false) { + throw $this->createNotFoundException(); + } + + if ($request->isMethod('POST')) { + $this->messageBus->dispatch(new ChangeContentDigestFrequency( + playerId: $playerId, + frequency: ContentDigestFrequency::None, + )); + + return $this->render('unsubscribe_content_digest.html.twig', [ + 'state' => 'done', + 'confirm_url' => null, + ]); + } + + return $this->render('unsubscribe_content_digest.html.twig', [ + 'state' => 'confirm', + 'confirm_url' => $request->getUri(), + ]); + } +} diff --git a/src/Controller/XpExplainerController.php b/src/Controller/XpExplainerController.php new file mode 100644 index 000000000..2ab9a8fc3 --- /dev/null +++ b/src/Controller/XpExplainerController.php @@ -0,0 +1,51 @@ + '/jak-funguji-xp', + 'en' => '/en/how-xp-works', + 'es' => '/es/como-funciona-xp', + 'ja' => '/ja/xpの仕組み', + 'fr' => '/fr/comment-fonctionne-xp', + 'de' => '/de/wie-xp-funktioniert', + ], + name: 'xp_explainer', + )] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $curve = []; + + for ($level = 1; $level <= LevelTable::MAX_LEVEL; $level++) { + $curve[$level] = LevelTable::xpForLevel($level); + } + + return $this->render('xp_explainer.html.twig', [ + 'levels' => $curve, + ]); + } +} diff --git a/src/Controller/XpHistoryController.php b/src/Controller/XpHistoryController.php new file mode 100644 index 000000000..fb65eb55d --- /dev/null +++ b/src/Controller/XpHistoryController.php @@ -0,0 +1,67 @@ + '/moje/xp-historie', + 'en' => '/en/my/xp-history', + 'es' => '/es/mi/xp-historial', + 'ja' => '/ja/マイ/xp履歴', + 'fr' => '/fr/mon/xp-historique', + 'de' => '/de/meine/xp-verlauf', + ], + name: 'xp_history', + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke(Request $request): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $page = max(1, $request->query->getInt('page', 1)); + $total = $this->getXpHistory->countForPlayer($profile->playerId); + $lastPage = max(1, (int) ceil($total / self::PER_PAGE)); + $page = min($page, $lastPage); + + return $this->render('xp_history.html.twig', [ + 'entries' => $this->getXpHistory->forPlayer($profile->playerId, self::PER_PAGE, ($page - 1) * self::PER_PAGE), + 'xp_profile' => $this->getXpProfile->byPlayerId($profile->playerId), + 'total' => $total, + 'page' => $page, + 'last_page' => $lastPage, + ]); + } +} diff --git a/src/Controller/XpLaunchRevealController.php b/src/Controller/XpLaunchRevealController.php new file mode 100644 index 000000000..4fd6a7356 --- /dev/null +++ b/src/Controller/XpLaunchRevealController.php @@ -0,0 +1,68 @@ + '/moje/xp-odhaleni', + 'en' => '/en/my/xp-reveal', + 'es' => '/es/mi/xp-revelacion', + 'ja' => '/ja/マイ/xp公開', + 'fr' => '/fr/mon/xp-revelation', + 'de' => '/de/meine/xp-enthuellung', + ], + name: 'xp_launch_reveal', + )] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function __invoke(): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + assert($profile !== null); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $xpProfile = $this->getXpProfile->byPlayerId($profile->playerId); + + if ($xpProfile->optedOut || ($this->isHintDismissed)($profile->playerId, HintType::XpLaunchReveal)) { + return $this->redirectToRoute('my_profile'); + } + + return $this->render('xp_launch_reveal.html.twig', [ + 'xp_profile' => $xpProfile, + 'badges_count' => count($this->getBadges->forPlayer($profile->playerId)), + 'is_member' => $profile->activeMembership, + 'player_id' => $profile->playerId, + ]); + } +} diff --git a/src/Controller/XpLeaderboardController.php b/src/Controller/XpLeaderboardController.php new file mode 100644 index 000000000..55dc7a41e --- /dev/null +++ b/src/Controller/XpLeaderboardController.php @@ -0,0 +1,95 @@ + '/hraci/xp-zebricek', + 'en' => '/en/players/xp-leaderboard', + 'es' => '/es/jugadores/xp-clasificacion', + 'ja' => '/ja/プレイヤー/xpランキング', + 'fr' => '/fr/joueurs/xp-classement', + 'de' => '/de/spieler/xp-rangliste', + ], + name: 'xp_leaderboard', + )] + public function __invoke(Request $request): Response + { + $profile = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($profile) === false) { + throw $this->createNotFoundException(); + } + + $tab = $request->query->getString('tab', 'this-week'); + + if (in_array($tab, self::TABS, true) === false) { + $tab = 'this-week'; + } + + $country = $request->query->getString('country'); + $country = $country !== '' ? strtolower($country) : null; + + $favoritesOnly = $request->query->getBoolean('favorites') && $profile !== null; + $favoriteIds = $favoritesOnly ? array_values($profile->favoritePlayers) : null; + + // The AP ladder is for logged-in eyes only (free users may look, §1.7). + if ($tab === 'achievement-points' && $profile === null) { + $rows = []; + } else { + $rows = match ($tab) { + 'all-time' => $this->getXpLeaderboard->allTime($country, $favoriteIds), + 'achievement-points' => $this->getXpLeaderboard->achievementPoints($country, $favoriteIds), + default => $this->getXpLeaderboard->thisWeek($country, $favoriteIds), + }; + } + + $selfRank = null; + $selfOnBoard = false; + + if ($profile !== null) { + foreach ($rows as $row) { + if ($row->playerId === $profile->playerId) { + $selfOnBoard = true; + break; + } + } + + if ($selfOnBoard === false) { + $selfRank = $this->getXpLeaderboard->selfRank($profile->playerId, $tab); + } + } + + return $this->render('xp_leaderboard.html.twig', [ + 'tab' => $tab, + 'rows' => $rows, + 'countries' => $this->getXpLeaderboard->countries(), + 'selected_country' => $country, + 'favorites_only' => $favoritesOnly, + 'self_rank' => $selfRank, + 'self_on_board' => $selfOnBoard, + 'viewer' => $profile, + ]); + } +} diff --git a/src/Controller/XpShareCardController.php b/src/Controller/XpShareCardController.php new file mode 100644 index 000000000..e334241e5 --- /dev/null +++ b/src/Controller/XpShareCardController.php @@ -0,0 +1,53 @@ + 'launch|level-up'], + )] + public function __invoke(string $playerId, string $variant): Response + { + $viewer = $this->retrieveLoggedUserProfile->getProfile(); + + if ($this->xpFeatureGate->isVisibleFor($viewer) === false) { + throw $this->createNotFoundException(); + } + + if ($this->getXpProfile->byPlayerId($playerId)->optedOut) { + throw $this->createNotFoundException(); + } + + $fileContent = $this->getXpShareCard->forPlayer($playerId, $variant); + + return new Response($fileContent, 200, [ + 'Content-Type' => 'image/png', + 'Content-Disposition' => 'inline', + ]); + } +} diff --git a/src/Entity/Badge.php b/src/Entity/Badge.php index c3b3edf44..6396d8a45 100644 --- a/src/Entity/Badge.php +++ b/src/Entity/Badge.php @@ -9,14 +9,18 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; +use Doctrine\ORM\Mapping\Index; use Doctrine\ORM\Mapping\JoinColumn; 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] +#[Index(columns: ['type'])] class Badge { public function __construct( @@ -33,6 +37,38 @@ 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, + /** + * First-click reveal moment: medallions start "unrevealed" for their owner and + * flip with confetti on first click (or on the membership-activation reveal page). + */ + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $revealedAt = null, ) { } + + public function reveal(DateTimeImmutable $now): void + { + if ($this->revealedAt === null) { + $this->revealedAt = $now; + } + } + + 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/Entity/ContentDigestLog.php b/src/Entity/ContentDigestLog.php new file mode 100644 index 000000000..fd4731308 --- /dev/null +++ b/src/Entity/ContentDigestLog.php @@ -0,0 +1,59 @@ + true])] public bool $newsletterEnabled = true; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::STRING, enumType: ContentDigestFrequency::class, options: ['default' => 'weekly'])] + public ContentDigestFrequency $contentDigestFrequency = ContentDigestFrequency::Weekly; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(type: Types::BOOLEAN, options: ['default' => false])] public bool $streakOptedOut = false; @@ -168,6 +176,27 @@ class Player #[Column(type: Types::BOOLEAN, options: ['default' => false])] public bool $referralProgramSuspended = false; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::INTEGER, options: ['default' => 0])] + public int $xpTotal = 0; + + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::SMALLINT, options: ['default' => 1])] + public int $level = 1; + + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::BOOLEAN, options: ['default' => false])] + public bool $experienceSystemOptedOut = false; + + /** + * Denormalized Achievement Points — SUM of BadgeTier::points() over every earned + * badge row. Maintained (and self-healed on each evaluation) by BadgeEvaluator, + * the only badge write path; AP never decreases by design. + */ + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::INTEGER, options: ['default' => 0])] + public int $achievementPoints = 0; + public function __construct( #[Id] #[Immutable] @@ -246,6 +275,14 @@ public function removeFavoritePlayer(Player $favoritePlayer): void $this->favoritePlayers = array_values($this->favoritePlayers); } + /** + * @return array + */ + public function favoritePlayerIds(): array + { + return $this->favoritePlayers; + } + public function discardFavoritePlayerId(string $playerId): bool { $key = array_search($playerId, $this->favoritePlayers, true); @@ -383,6 +420,11 @@ public function changeNewsletterEnabled(bool $enabled): void $this->newsletterEnabled = $enabled; } + public function changeContentDigestFrequency(ContentDigestFrequency $frequency): void + { + $this->contentDigestFrequency = $frequency; + } + public function changeStreakOptedOut(bool $optedOut): void { $this->streakOptedOut = $optedOut; @@ -398,6 +440,30 @@ public function changeTimePredictionsOptedOut(bool $optedOut): void $this->timePredictionsOptedOut = $optedOut; } + public function changeExperienceSystemOptedOut(bool $optedOut): void + { + $this->experienceSystemOptedOut = $optedOut; + } + + /** + * Called exclusively by the XP ledger — xpTotal must always equal the sum of the + * player's xp_entry amounts, and level must match LevelTable::levelForXp(xpTotal). + */ + public function updateExperience(int $xpTotal, int $level): void + { + $this->xpTotal = $xpTotal; + $this->level = $level; + } + + /** + * Called exclusively by BadgeEvaluator with the absolute recomputed total — + * setting (not incrementing) makes every evaluation run self-healing. + */ + public function updateAchievementPoints(int $achievementPoints): void + { + $this->achievementPoints = $achievementPoints; + } + public function joinReferralProgram(DateTimeImmutable $now): void { $this->referralProgramJoinedAt = $now; diff --git a/src/Entity/PuzzleSolvingTime.php b/src/Entity/PuzzleSolvingTime.php index a4da4b717..cdde2b94f 100644 --- a/src/Entity/PuzzleSolvingTime.php +++ b/src/Entity/PuzzleSolvingTime.php @@ -38,6 +38,14 @@ class PuzzleSolvingTime implements EntityWithEvents #[Column(options: ['default' => PuzzlingType::Solo->value])] public PuzzlingType $puzzlingType; + /** + * Pieces count captured at log time — XP anti-abuse guard: later edits to the puzzle's + * pieces count never change XP already earned. NULL only for rows that predate the column + * (backfilled from the puzzle in the migration; fallback to the puzzle value when reading). + */ + #[Column(type: Types::INTEGER, nullable: true)] + public null|int $piecesCountSnapshot; + public function __construct( #[Id] #[Immutable] @@ -80,6 +88,7 @@ public function __construct( ) { $this->puzzlersCount = $this->calculatePuzzlersCount(); $this->puzzlingType = PuzzlingType::fromPuzzlersCount($this->puzzlersCount); + $this->piecesCountSnapshot = $puzzle->piecesCount; $this->recordThat( new PuzzleSolved($this->id, $this->puzzle->id), diff --git a/src/Entity/XpEntry.php b/src/Entity/XpEntry.php new file mode 100644 index 000000000..44d6a6f62 --- /dev/null +++ b/src/Entity/XpEntry.php @@ -0,0 +1,66 @@ + spl_object_id => audit log UUID */ private array $pendingAuditIds = []; /** @var array spl_object_id => email type captured before rendering */ private array $pendingEmailTypes = []; + /** @var array spl_object_id => skip smtp debug log for this message */ + private array $pendingSkipDebug = []; + public function __construct( private readonly MessageBusInterface $messageBus, private readonly LoggerInterface $logger, @@ -100,13 +109,20 @@ public function onMessage(MessageEvent $event): void $headers->addIdHeader('Message-ID', $message->generateMessageId()); } + $emailType = $this->pendingEmailTypes[$objectId] ?? $this->extractEmailTypeFromMessage($message); + $skipBody = $emailType !== null && str_starts_with($emailType, self::SKIP_BODY_EMAIL_TYPE_PREFIX); + + if ($skipBody) { + $this->pendingSkipDebug[$objectId] = true; + } + $envelope = $this->messageBus->dispatch(new CreateEmailAuditLog( recipientEmail: $message->getTo()[0]->getAddress(), subject: $message->getSubject() ?? '', transportName: $event->getTransport(), - emailType: $this->pendingEmailTypes[$objectId] ?? $this->extractEmailTypeFromMessage($message), - bodyHtml: $this->readBody($message->getHtmlBody()), - bodyText: $this->readBody($message->getTextBody()), + emailType: $emailType, + bodyHtml: $skipBody ? null : $this->readBody($message->getHtmlBody()), + bodyText: $skipBody ? null : $this->readBody($message->getTextBody()), )); /** @var HandledStamp $handledStamp */ @@ -151,10 +167,10 @@ public function onSentMessage(SentMessageEvent $event): void auditLogId: $this->pendingAuditIds[$objectId], messageId: $messageIdHeader ?? $mtaOrHeader, mtaQueueId: $mtaQueueId, - smtpDebugLog: $sentMessage->getDebug(), + smtpDebugLog: isset($this->pendingSkipDebug[$objectId]) ? '' : $sentMessage->getDebug(), )); - unset($this->pendingAuditIds[$objectId]); + unset($this->pendingAuditIds[$objectId], $this->pendingSkipDebug[$objectId]); } catch (\Throwable $e) { $this->logger->error('Failed to update email audit log on sent', [ 'exception' => $e, @@ -177,10 +193,10 @@ public function onFailedMessage(FailedMessageEvent $event): void $this->messageBus->dispatch(new RecordEmailSendFailure( auditLogId: $this->pendingAuditIds[$objectId], errorMessage: $error->getMessage(), - smtpDebugLog: $debugLog, + smtpDebugLog: isset($this->pendingSkipDebug[$objectId]) ? null : $debugLog, )); - unset($this->pendingAuditIds[$objectId]); + unset($this->pendingAuditIds[$objectId], $this->pendingSkipDebug[$objectId]); } catch (\Throwable $e) { $this->logger->error('Failed to update email audit log on failure', [ 'exception' => $e, @@ -192,6 +208,7 @@ public function reset(): void { $this->pendingAuditIds = []; $this->pendingEmailTypes = []; + $this->pendingSkipDebug = []; } private function extractEmailTypeFromMessage(Email $message): null|string diff --git a/src/Exceptions/BadgeNotFound.php b/src/Exceptions/BadgeNotFound.php new file mode 100644 index 000000000..144b4e896 --- /dev/null +++ b/src/Exceptions/BadgeNotFound.php @@ -0,0 +1,15 @@ +streakOptedOut = $playerProfile->streakOptedOut; $data->rankingOptedOut = $playerProfile->rankingOptedOut; $data->timePredictionsOptedOut = $playerProfile->timePredictionsOptedOut; + $data->experienceSystemOptedOut = $playerProfile->experienceSystemOptedOut; return $data; } diff --git a/src/FormData/MessagingSettingsFormData.php b/src/FormData/MessagingSettingsFormData.php index 77352a374..a2169525f 100644 --- a/src/FormData/MessagingSettingsFormData.php +++ b/src/FormData/MessagingSettingsFormData.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\FormData; use SpeedPuzzling\Web\Results\PlayerProfile; +use SpeedPuzzling\Web\Value\ContentDigestFrequency; use SpeedPuzzling\Web\Value\EmailNotificationFrequency; final class MessagingSettingsFormData @@ -13,6 +14,7 @@ final class MessagingSettingsFormData public bool $emailNotificationsEnabled = true; public EmailNotificationFrequency $emailNotificationFrequency = EmailNotificationFrequency::TwentyFourHours; public bool $newsletterEnabled = true; + public ContentDigestFrequency $contentDigestFrequency = ContentDigestFrequency::Weekly; public static function fromPlayerProfile(PlayerProfile $playerProfile): self { @@ -21,6 +23,7 @@ public static function fromPlayerProfile(PlayerProfile $playerProfile): self $data->emailNotificationsEnabled = $playerProfile->emailNotificationsEnabled; $data->emailNotificationFrequency = $playerProfile->emailNotificationFrequency; $data->newsletterEnabled = $playerProfile->newsletterEnabled; + $data->contentDigestFrequency = $playerProfile->contentDigestFrequency; return $data; } diff --git a/src/FormType/FeaturesOptionsFormType.php b/src/FormType/FeaturesOptionsFormType.php index a654510b3..9b56b8220 100644 --- a/src/FormType/FeaturesOptionsFormType.php +++ b/src/FormType/FeaturesOptionsFormType.php @@ -37,12 +37,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'edit_profile.hide_time_predictions_help', 'required' => false, ]); + + // Hidden while the xp-system flag is active. + if ($options['show_experience_system'] === true) { + $builder->add('experienceSystemOptedOut', CheckboxType::class, [ + 'label' => 'edit_profile.hide_experience_system', + 'help' => 'edit_profile.hide_experience_system_help', + 'required' => false, + ]); + } } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => FeaturesOptionsFormData::class, + 'show_experience_system' => false, ]); + + $resolver->setAllowedTypes('show_experience_system', 'bool'); } } diff --git a/src/FormType/MessagingSettingsFormType.php b/src/FormType/MessagingSettingsFormType.php index 9d62245c6..1cb2d4a2b 100644 --- a/src/FormType/MessagingSettingsFormType.php +++ b/src/FormType/MessagingSettingsFormType.php @@ -5,6 +5,7 @@ namespace SpeedPuzzling\Web\FormType; use SpeedPuzzling\Web\FormData\MessagingSettingsFormData; +use SpeedPuzzling\Web\Value\ContentDigestFrequency; use SpeedPuzzling\Web\Value\EmailNotificationFrequency; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -50,12 +51,29 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'edit_profile.newsletter_help', 'required' => false, ]); + + // Hidden while the xp-system flag is active (the digest itself is suppressed too). + if ($options['show_content_digest'] === true) { + $builder->add('contentDigestFrequency', EnumType::class, [ + 'class' => ContentDigestFrequency::class, + 'label' => 'edit_profile.content_digest_frequency', + 'help' => 'edit_profile.content_digest_help', + 'choice_label' => static fn (ContentDigestFrequency $frequency): string => match ($frequency) { + ContentDigestFrequency::None => 'edit_profile.content_digest_none', + ContentDigestFrequency::Daily => 'edit_profile.content_digest_daily', + ContentDigestFrequency::Weekly => 'edit_profile.content_digest_weekly', + }, + ]); + } } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => MessagingSettingsFormData::class, + 'show_content_digest' => false, ]); + + $resolver->setAllowedTypes('show_content_digest', 'bool'); } } diff --git a/src/Message/AwardXpForSolvingTime.php b/src/Message/AwardXpForSolvingTime.php new file mode 100644 index 000000000..44b3713a7 --- /dev/null +++ b/src/Message/AwardXpForSolvingTime.php @@ -0,0 +1,13 @@ + $badgeSummary + */ + public function __construct( + public string $playerId, + public array $badgeSummary, + ) { + } +} diff --git a/src/Message/SendPlayerContentDigest.php b/src/Message/SendPlayerContentDigest.php new file mode 100644 index 000000000..9c69923f7 --- /dev/null +++ b/src/Message/SendPlayerContentDigest.php @@ -0,0 +1,20 @@ +entityManager->persist($solvingTime); + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($player->id->toString())); + $this->commandBus->dispatch(new AwardXpForSolvingTime($solvingTimeId->toString())); } } diff --git a/src/MessageHandler/AddPuzzleTrackingHandler.php b/src/MessageHandler/AddPuzzleTrackingHandler.php index 272fbf2f9..2ea694c43 100644 --- a/src/MessageHandler/AddPuzzleTrackingHandler.php +++ b/src/MessageHandler/AddPuzzleTrackingHandler.php @@ -11,11 +11,14 @@ use SpeedPuzzling\Web\Exceptions\CanNotAssembleEmptyGroup; use SpeedPuzzling\Web\Exceptions\CouldNotGenerateUniqueCode; use SpeedPuzzling\Web\Message\AddPuzzleTracking; +use SpeedPuzzling\Web\Message\AwardXpForSolvingTime; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\PuzzleRepository; use SpeedPuzzling\Web\Services\ImageOptimizer; use SpeedPuzzling\Web\Services\PuzzlersGrouping; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class AddPuzzleTrackingHandler @@ -28,6 +31,7 @@ public function __construct( private PuzzlersGrouping $puzzlersGrouping, private ClockInterface $clock, private ImageOptimizer $imageOptimizer, + private MessageBusInterface $commandBus, ) { } @@ -77,5 +81,9 @@ public function __invoke(AddPuzzleTracking $message): void ); $this->entityManager->persist($solvingTime); + + // Relax tracking counts toward achievements (Zen Puzzler) and earns XP too. + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($player->id->toString())); + $this->commandBus->dispatch(new AwardXpForSolvingTime($trackingId->toString())); } } diff --git a/src/MessageHandler/AwardXpForSolvingTimeHandler.php b/src/MessageHandler/AwardXpForSolvingTimeHandler.php new file mode 100644 index 000000000..c63a81e75 --- /dev/null +++ b/src/MessageHandler/AwardXpForSolvingTimeHandler.php @@ -0,0 +1,23 @@ +xpChainRecomputer->awardForNewSolve($message->solvingTimeId); + } +} diff --git a/src/MessageHandler/ChangeContentDigestFrequencyHandler.php b/src/MessageHandler/ChangeContentDigestFrequencyHandler.php new file mode 100644 index 000000000..af0a56906 --- /dev/null +++ b/src/MessageHandler/ChangeContentDigestFrequencyHandler.php @@ -0,0 +1,29 @@ +playerRepository->get($message->playerId); + + $player->changeContentDigestFrequency($message->frequency); + } +} diff --git a/src/MessageHandler/CleanupEmailAuditLogsHandler.php b/src/MessageHandler/CleanupEmailAuditLogsHandler.php index 30cc2342e..ed7e4a986 100644 --- a/src/MessageHandler/CleanupEmailAuditLogsHandler.php +++ b/src/MessageHandler/CleanupEmailAuditLogsHandler.php @@ -8,6 +8,7 @@ use SpeedPuzzling\Web\Message\CleanupEmailAuditLogs; use SpeedPuzzling\Web\Repository\EmailAuditLogRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class CleanupEmailAuditLogsHandler @@ -15,6 +16,7 @@ public function __construct( private EmailAuditLogRepository $emailAuditLogRepository, private ClockInterface $clock, + private MessageBusInterface $commandBus, ) { } @@ -22,6 +24,17 @@ public function __invoke(CleanupEmailAuditLogs $message): int { $before = $this->clock->now()->modify("-{$message->retentionDays} days"); - return $this->emailAuditLogRepository->deleteOlderThan($before); + $deleted = $this->emailAuditLogRepository->deleteOlderThan($before, $message->emailTypePrefix, $message->batchSize); + + // A full batch means more rows are waiting — continue in a fresh transaction. + if ($deleted >= $message->batchSize) { + $this->commandBus->dispatch(new CleanupEmailAuditLogs( + retentionDays: $message->retentionDays, + emailTypePrefix: $message->emailTypePrefix, + batchSize: $message->batchSize, + )); + } + + return $deleted; } } diff --git a/src/MessageHandler/CompensateXpForDeletedSolveHandler.php b/src/MessageHandler/CompensateXpForDeletedSolveHandler.php new file mode 100644 index 000000000..2f70bbb6b --- /dev/null +++ b/src/MessageHandler/CompensateXpForDeletedSolveHandler.php @@ -0,0 +1,23 @@ +xpChainRecomputer->compensateAndRebuildAfterDeletion($message->solvingTimeId, $message->puzzleId); + } +} diff --git a/src/MessageHandler/DeletePlayerHandler.php b/src/MessageHandler/DeletePlayerHandler.php index bfece4372..a20eb6472 100644 --- a/src/MessageHandler/DeletePlayerHandler.php +++ b/src/MessageHandler/DeletePlayerHandler.php @@ -32,6 +32,7 @@ use SpeedPuzzling\Web\Entity\TransactionRating; use SpeedPuzzling\Web\Entity\UserBlock; use SpeedPuzzling\Web\Entity\WishListItem; +use SpeedPuzzling\Web\Entity\XpEntry; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Message\DeletePlayer; use SpeedPuzzling\Web\Repository\PlayerRepository; @@ -238,6 +239,8 @@ private function deletePlayerOwnedRows(string $playerId): void $em->createQuery('DELETE FROM ' . CollectionItem::class . ' ci WHERE ci.player = :p')->setParameter('p', $playerId)->execute(); $em->createQuery('DELETE FROM ' . Collection::class . ' c WHERE c.player = :p')->setParameter('p', $playerId)->execute(); $em->createQuery('DELETE FROM ' . Badge::class . ' b WHERE b.player = :p')->setParameter('p', $playerId)->execute(); + // xp_entry references players via a plain uuid column (no FK) — clean up explicitly. + $em->createQuery('DELETE FROM ' . XpEntry::class . ' x WHERE x.playerId = :p')->setParameter('p', $playerId)->execute(); } private function deleteUserBlocks(string $playerId): void diff --git a/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php b/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php index 0c7a62c59..849e94f08 100644 --- a/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/DeletePuzzleSolvingTimeHandler.php @@ -7,10 +7,13 @@ use Doctrine\ORM\EntityManagerInterface; use SpeedPuzzling\Web\Exceptions\CanNotModifyOtherPlayersTime; use SpeedPuzzling\Web\Exceptions\PuzzleSolvingTimeNotFound; +use SpeedPuzzling\Web\Message\CompensateXpForDeletedSolve; 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 +22,7 @@ public function __construct( private EntityManagerInterface $entityManager, private PlayerRepository $playerRepository, private PuzzleSolvingTimeRepository $puzzleSolvingTimeRepository, + private MessageBusInterface $commandBus, ) { } @@ -35,6 +39,16 @@ public function __invoke(DeletePuzzleSolvingTime $message): void throw new CanNotModifyOtherPlayersTime(); } + $playerId = $currentPlayer->id->toString(); + $puzzleId = $solvingTime->puzzle->id->toString(); + $solvingTimeId = $solvingTime->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)); + // The puzzle id travels in the message — it is unrecoverable once the row is gone. + $this->commandBus->dispatch(new CompensateXpForDeletedSolve($solvingTimeId, $puzzleId)); } } diff --git a/src/MessageHandler/EditFeaturesOptionsHandler.php b/src/MessageHandler/EditFeaturesOptionsHandler.php index d6b4fb1cc..c399eefe9 100644 --- a/src/MessageHandler/EditFeaturesOptionsHandler.php +++ b/src/MessageHandler/EditFeaturesOptionsHandler.php @@ -27,5 +27,9 @@ public function __invoke(EditFeaturesOptions $message): void $player->changeStreakOptedOut($message->streakOptedOut); $player->changeRankingOptedOut($message->rankingOptedOut); $player->changeTimePredictionsOptedOut($message->timePredictionsOptedOut); + + if ($message->experienceSystemOptedOut !== null) { + $player->changeExperienceSystemOptedOut($message->experienceSystemOptedOut); + } } } diff --git a/src/MessageHandler/EditMessagingSettingsHandler.php b/src/MessageHandler/EditMessagingSettingsHandler.php index aa324f639..6dedb78ff 100644 --- a/src/MessageHandler/EditMessagingSettingsHandler.php +++ b/src/MessageHandler/EditMessagingSettingsHandler.php @@ -28,5 +28,9 @@ public function __invoke(EditMessagingSettings $message): void $player->changeEmailNotificationsEnabled($message->emailNotificationsEnabled); $player->changeEmailNotificationFrequency($message->emailNotificationFrequency); $player->changeNewsletterEnabled($message->newsletterEnabled); + + if ($message->contentDigestFrequency !== null) { + $player->changeContentDigestFrequency($message->contentDigestFrequency); + } } } diff --git a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php index 663c75841..2cb869926 100644 --- a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php @@ -13,6 +13,8 @@ use SpeedPuzzling\Web\Exceptions\PuzzleSolvingTimeNotFound; use SpeedPuzzling\Web\Exceptions\SuspiciousPpm; use SpeedPuzzling\Web\Message\EditPuzzleSolvingTime; +use SpeedPuzzling\Web\Message\RecalculateBadgesForPlayer; +use SpeedPuzzling\Web\Message\RecalculateXpChainForSolve; use SpeedPuzzling\Web\Repository\CompetitionRepository; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\PuzzleSolvingTimeRepository; @@ -21,6 +23,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 @@ -34,6 +37,7 @@ public function __construct( private CompetitionRepository $competitionRepository, private ImageOptimizer $imageOptimizer, private MistypedYearNormalizer $mistypedYearNormalizer, + private MessageBusInterface $commandBus, ) { } @@ -110,5 +114,9 @@ public function __invoke(EditPuzzleSolvingTime $message): void $message->unboxed, competition: $competition, ); + + $this->commandBus->dispatch(new RecalculateBadgesForPlayer($currentPlayer->id->toString())); + // Edit is semantically delete+re-add for XP — rebuild the affected chains. + $this->commandBus->dispatch(new RecalculateXpChainForSolve($message->puzzleSolvingTimeId)); } } diff --git a/src/MessageHandler/RecalculateBadgesForPlayerHandler.php b/src/MessageHandler/RecalculateBadgesForPlayerHandler.php new file mode 100644 index 000000000..557170666 --- /dev/null +++ b/src/MessageHandler/RecalculateBadgesForPlayerHandler.php @@ -0,0 +1,86 @@ +badgeEvaluator->recalculateForPlayer($message->playerId, $message->isBackfill); + + if ($newBadges === []) { + return; + } + + // Backfill runs seed thousands of historical earns — never email those. + if ($message->isBackfill) { + return; + } + + // Badge persistence above runs for everyone; only the congratulation email + // is suppressed while the xp-system feature flag is active. + if ($this->xpFeatureGate->isEmailSendingEnabled() === false) { + 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/RecalculateXpChainForSolveHandler.php b/src/MessageHandler/RecalculateXpChainForSolveHandler.php new file mode 100644 index 000000000..aecc8660d --- /dev/null +++ b/src/MessageHandler/RecalculateXpChainForSolveHandler.php @@ -0,0 +1,23 @@ +xpChainRecomputer->rebuildChainForEditedSolve($message->solvingTimeId); + } +} diff --git a/src/MessageHandler/RecalculateXpForPlayerHandler.php b/src/MessageHandler/RecalculateXpForPlayerHandler.php new file mode 100644 index 000000000..8a6b96d30 --- /dev/null +++ b/src/MessageHandler/RecalculateXpForPlayerHandler.php @@ -0,0 +1,23 @@ +xpRecomputer->recomputeForPlayer($message->playerId); + } +} diff --git a/src/MessageHandler/RevealBadgeHandler.php b/src/MessageHandler/RevealBadgeHandler.php new file mode 100644 index 000000000..1bddfe715 --- /dev/null +++ b/src/MessageHandler/RevealBadgeHandler.php @@ -0,0 +1,49 @@ +badgeRepository->get($message->badgeId); + + if ($badge->player->id->toString() !== $message->playerId) { + // Only the owner experiences the reveal moment — ignore anyone else. + return; + } + + $now = $this->clock->now(); + + foreach ($this->badgeRepository->findByPlayerAndType($badge->player->id->toString(), $badge->type) as $sibling) { + $isSameOrLowerTier = $badge->tier === null + || ($sibling->tier !== null && $sibling->tier <= $badge->tier); + + if ($isSameOrLowerTier) { + $sibling->reveal($now); + } + } + } +} diff --git a/src/MessageHandler/SendBadgeNotificationEmailHandler.php b/src/MessageHandler/SendBadgeNotificationEmailHandler.php new file mode 100644 index 000000000..fd4556439 --- /dev/null +++ b/src/MessageHandler/SendBadgeNotificationEmailHandler.php @@ -0,0 +1,67 @@ +playerRepository->get($message->playerId); + $profile = $this->getPlayerProfile->byId($message->playerId); + } catch (PlayerNotFound) { + return; + } + + if ($player->email === null) { + return; + } + + // Achievement detail is a members-only surface (§1.7) — free players can't see + // their badges, so they never receive per-achievement emails; the weekly digest + // teaser covers them instead. The weekly digest + this email are the ONLY + // recurring emails of the achievements system. + if ($profile->activeMembership === false) { + 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/MessageHandler/SendPlayerContentDigestHandler.php b/src/MessageHandler/SendPlayerContentDigestHandler.php new file mode 100644 index 000000000..1eef3236b --- /dev/null +++ b/src/MessageHandler/SendPlayerContentDigestHandler.php @@ -0,0 +1,193 @@ +send() (README D1) so pacing, retry policy and failure + * classification live exactly where the sending does — the shared async mailer + * path stays untouched for transactional email. + * + * Failure model (README §6, doctrine_transaction middleware rolls back on ANY throw): + * transient (connection, 4xx, sender-stage 5xx) → rethrow, Messenger retries with + * backoff and the retry re-renders fresh content; permanent recipient-stage 55x → + * catch, persist a failed_permanent log row, return normally so the message is acked. + * Never throw after persisting. + */ +#[AsMessageHandler] +readonly final class SendPlayerContentDigestHandler +{ + public function __construct( + private PlayerRepository $playerRepository, + private GetPlayerProfile $getPlayerProfile, + private ContentDigestLogRepository $contentDigestLogRepository, + private WeeklyDigestDataProvider $weeklyDigestDataProvider, + private TransportInterface $transport, + private TranslatorInterface $translator, + private UriSigner $uriSigner, + private UrlGeneratorInterface $urlGenerator, + private Connection $database, + private ClockInterface $clock, + private XpFeatureGate $xpFeatureGate, + private LoggerInterface $logger, + ) { + } + + public function __invoke(SendPlayerContentDigest $message): void + { + if ($message->digestType !== 'weekly') { + return; + } + + // No digest leaves the system while the xp-system flag is active (§1.10). + if ($this->xpFeatureGate->isEmailSendingEnabled() === false) { + $this->logger->notice('Digest suppressed by xp-system feature flag', ['playerId' => $message->playerId]); + + return; + } + + $period = DigestPeriod::fromKey($message->periodKey); + $now = $this->clock->now(); + + // Staleness guard: nobody wants last week's digest delivered days late. + if ($period->isStaleAt($now)) { + $this->logger->notice('Skipping stale digest', ['playerId' => $message->playerId, 'period' => $period->key]); + + return; + } + + // Eligibility re-check — hours pass between dispatch and consume, preferences change. + try { + $player = $this->playerRepository->get($message->playerId); + } catch (PlayerNotFound) { + return; + } + + if ( + $player->email === null + || $player->emailNotificationsEnabled === false + || $player->experienceSystemOptedOut + || in_array($player->contentDigestFrequency, [ContentDigestFrequency::Daily, ContentDigestFrequency::Weekly], true) === false + || $this->alreadyLogged($message->playerId, $period->key) + ) { + return; + } + + $profile = $this->getPlayerProfile->byId($message->playerId); + $data = $this->weeklyDigestDataProvider->forPlayer($player, $period); + $email = $player->email; + $locale = $player->locale ?? 'en'; + + $unsubscribeUrl = $this->uriSigner->sign( + $this->urlGenerator->generate( + 'unsubscribe_content_digest', + ['playerId' => $message->playerId], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + new DateInterval('P30D'), + ); + + $subject = $this->translator->trans( + $data->hadActivity() ? 'content_digest.weekly.subject' : 'content_digest.weekly.subject_quiet', + domain: 'emails', + locale: $locale, + ); + + $templatedEmail = (new TemplatedEmail()) + ->from(new Address('notify@notify.myspeedpuzzling.com', 'MySpeedPuzzling')) + ->to($email) + ->locale($locale) + ->subject($subject) + ->htmlTemplate('emails/content_digest_weekly.html.twig') + ->context([ + 'playerName' => $player->name, + 'data' => $data, + 'isMember' => $profile->activeMembership, + 'hadActivity' => $data->hadActivity(), + 'unsubscribeUrl' => $unsubscribeUrl, + 'locale' => $locale, + ]); + + $headers = $templatedEmail->getHeaders(); + $headers->addTextHeader('X-Transport', 'notifications'); + $headers->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); + $headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + $headers->addTextHeader('Precedence', 'bulk'); + + try { + $this->transport->send($templatedEmail); + } catch (UnexpectedResponseException $exception) { + // 550–553 are unambiguously recipient-stage (mailbox unknown/full/denied): + // log the permanent failure and ack. 554 also fires at MAIL FROM (relay + // denied) and 535 at auth — those are sender-side, so they bubble and retry + // where Sentry can see them. + if (in_array($exception->getCode(), [550, 551, 552, 553], true)) { + $this->logger->warning('Digest permanently rejected for recipient', [ + 'playerId' => $message->playerId, + 'code' => $exception->getCode(), + 'exception' => $exception, + ]); + + $this->contentDigestLogRepository->save(new ContentDigestLog( + id: Uuid::uuid7(), + player: $player, + digestType: 'weekly', + periodKey: $period->key, + sentAt: $now, + hadActivity: $data->hadActivity(), + status: ContentDigestLog::STATUS_FAILED_PERMANENT, + )); + + return; + } + + throw $exception; + } + + $this->contentDigestLogRepository->save(new ContentDigestLog( + id: Uuid::uuid7(), + player: $player, + digestType: 'weekly', + periodKey: $period->key, + sentAt: $now, + hadActivity: $data->hadActivity(), + status: ContentDigestLog::STATUS_SENT, + )); + } + + private function alreadyLogged(string $playerId, string $periodKey): bool + { + $value = $this->database->fetchOne( + "SELECT 1 FROM content_digest_log WHERE player_id = :playerId AND digest_type = 'weekly' AND period_key = :periodKey LIMIT 1", + ['playerId' => $playerId, 'periodKey' => $periodKey], + ); + + return $value !== false; + } +} diff --git a/src/MessageHandler/SendXpRevealEmailHandler.php b/src/MessageHandler/SendXpRevealEmailHandler.php new file mode 100644 index 000000000..ab3b56d6b --- /dev/null +++ b/src/MessageHandler/SendXpRevealEmailHandler.php @@ -0,0 +1,146 @@ +xpFeatureGate->isEmailSendingEnabled() === false) { + $this->logger->notice('XP reveal email suppressed — xp-system flag still active', [ + 'playerId' => $message->playerId, + ]); + + return; + } + + try { + $player = $this->playerRepository->get($message->playerId); + } catch (PlayerNotFound) { + return; + } + + if ( + $player->email === null + || $player->emailNotificationsEnabled === false + || $player->experienceSystemOptedOut + || $this->alreadySent($message->playerId) + ) { + return; + } + + $profile = $this->getPlayerProfile->byId($message->playerId); + $xpProfile = $this->getXpProfile->byPlayerId($message->playerId); + $badgesCount = count($this->getBadges->forPlayer($message->playerId)); + $locale = $player->locale ?? 'en'; + + $unsubscribeUrl = $this->uriSigner->sign( + $this->urlGenerator->generate( + 'unsubscribe_content_digest', + ['playerId' => $message->playerId], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + new DateInterval('P30D'), + ); + + $subject = $this->translator->trans('xp_reveal.subject', domain: 'emails', locale: $locale); + + $email = (new TemplatedEmail()) + ->from(new Address('notify@notify.myspeedpuzzling.com', 'MySpeedPuzzling')) + ->to($player->email) + ->locale($locale) + ->subject($subject) + ->htmlTemplate('emails/xp_reveal.html.twig') + ->context([ + 'playerName' => $player->name, + 'level' => $xpProfile->level, + 'xpTotal' => $xpProfile->xpTotal, + 'badgesCount' => $badgesCount, + 'isMember' => $profile->activeMembership, + 'locale' => $locale, + ]) + ->addPart((new DataPart(new File(__DIR__ . '/../../public/img/xp/xp-hero-1200.png'), 'xp-hero', 'image/png'))->asInline()); + + $headers = $email->getHeaders(); + $headers->addTextHeader('X-Transport', 'transactional'); + $headers->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); + $headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + + $this->mailer->send($email); + + $this->contentDigestLogRepository->save(new ContentDigestLog( + id: Uuid::uuid7(), + player: $player, + digestType: self::LOG_TYPE, + periodKey: self::LOG_PERIOD, + sentAt: $this->clock->now(), + hadActivity: true, + status: ContentDigestLog::STATUS_SENT, + )); + } + + private function alreadySent(string $playerId): bool + { + $value = $this->database->fetchOne( + "SELECT 1 FROM content_digest_log WHERE player_id = :playerId AND digest_type = 'xp_reveal' LIMIT 1", + ['playerId' => $playerId], + ); + + return $value !== false; + } +} diff --git a/src/MessageHandler/SettleXpBonusesHandler.php b/src/MessageHandler/SettleXpBonusesHandler.php new file mode 100644 index 000000000..51e2f80ae --- /dev/null +++ b/src/MessageHandler/SettleXpBonusesHandler.php @@ -0,0 +1,29 @@ +xpChainRecomputer->settlePendingBonuses(); + + if ($settled > 0) { + $this->logger->info('Settled pending XP bonuses', ['entries' => $settled]); + } + } +} diff --git a/src/Query/GetAchievementHolders.php b/src/Query/GetAchievementHolders.php new file mode 100644 index 000000000..bb1752447 --- /dev/null +++ b/src/Query/GetAchievementHolders.php @@ -0,0 +1,208 @@ + NOW() + ) +SQL; + + public function __construct( + private Connection $database, + ) { + } + + /** + * Tier sections Diamond → Bronze; holders inside a tier ordered by earn date + * (the first row is the "first to earn" highlight). + * + * @return list + */ + public function forType(BadgeType $type, null|string $country): array + { + $listedSql = <<listedEligibility()} +) ranked +WHERE position <= :limit +ORDER BY tier DESC, position ASC +SQL; + + /** @var list $listedRows */ + $listedRows = $this->database->executeQuery($listedSql, [ + 'type' => $type->value, + 'country' => $country, + 'limit' => self::LISTED_PER_TIER, + ])->fetchAllAssociative(); + + $countsSql = << $countRows */ + $countRows = $this->database->executeQuery($countsSql, [ + 'type' => $type->value, + 'country' => $country, + ])->fetchAllAssociative(); + + $countsByTier = []; + foreach ($countRows as $row) { + $countsByTier[$row['tier']] = is_numeric($row['holders_count']) ? (int) $row['holders_count'] : 0; + } + + $holdersByTier = []; + foreach ($listedRows as $row) { + $holdersByTier[$row['tier']][] = new AchievementHolder( + playerId: $row['player_id'], + playerName: $row['player_name'], + playerCode: $row['code'], + countryCode: CountryCode::fromCode($row['country']), + avatar: $row['avatar'], + earnedAt: new DateTimeImmutable($row['earned_at']), + ); + } + + $sections = []; + + foreach (array_reverse(BadgeTier::cases()) as $tier) { + $sections[] = new AchievementTierHolders( + tier: $tier, + holders: $holdersByTier[$tier->value] ?? [], + totalCount: $countsByTier[$tier->value] ?? 0, + ); + } + + return $sections; + } + + /** + * Freshest earns across all tiers, one entry per player. + * + * @return list + */ + public function newestEarners(BadgeType $type, int $limit = 8): array + { + $sql = <<listedEligibility()} + ORDER BY p.id, b.earned_at DESC +) newest +ORDER BY earned_at DESC +LIMIT :limit +SQL; + + /** @var list $rows */ + $rows = $this->database->executeQuery($sql, [ + 'type' => $type->value, + 'limit' => $limit, + ])->fetchAllAssociative(); + + $earners = []; + + foreach ($rows as $row) { + $earners[] = new AchievementHolder( + playerId: $row['player_id'], + playerName: $row['player_name'], + playerCode: $row['code'], + countryCode: CountryCode::fromCode($row['country']), + avatar: $row['avatar'], + earnedAt: new DateTimeImmutable($row['earned_at']), + tier: BadgeTier::from($row['tier']), + ); + } + + return $earners; + } + + /** + * Countries available for the filter — from listed holders only. + * + * @return list + */ + public function countries(BadgeType $type): array + { + $sql = <<listedEligibility()} +ORDER BY p.country +SQL; + + /** @var list $countries */ + $countries = $this->database->executeQuery($sql, ['type' => $type->value])->fetchFirstColumn(); + + return $countries; + } + + private function listedEligibility(): string + { + return self::LISTED_ELIGIBILITY; + } +} diff --git a/src/Query/GetAchievementPoints.php b/src/Query/GetAchievementPoints.php new file mode 100644 index 000000000..859081e2d --- /dev/null +++ b/src/Query/GetAchievementPoints.php @@ -0,0 +1,29 @@ +database + ->executeQuery('SELECT achievement_points FROM player WHERE id = :playerId', ['playerId' => $playerId]) + ->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Query/GetAllPlayerIdsWithSolveTimes.php b/src/Query/GetAllPlayerIdsWithSolveTimes.php new file mode 100644 index 000000000..46289a8e4 --- /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 000000000..100302931 --- /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->allEarnedTiers($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 018b4c48c..6e44175b2 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,37 +18,99 @@ public function __construct( } /** - * @return list + * Every earned badge row, all tiers included — the badge evaluator needs the complete + * set to decide what is still missing (the display query below collapses to the highest + * tier per type, which would make re-evaluations re-insert lower tiers). + * + * @return list + */ + public function allEarnedTiers(string $playerId): array + { + $sql = << $rows */ + $rows = $this->database + ->executeQuery($sql, ['playerId' => $playerId]) + ->fetchAllAssociative(); + + return $this->hydrate($rows); + } + + /** + * @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(); - $badges = []; + return $this->hydrate($rows); + } - foreach ($data as $row) { - /** @var array{ - * type: string, - * } $row - */ + /** + * Highest earned tier per type that the owner has not flipped yet — the + * membership-activation reveal page shows these in sequence. + * + * @return list + */ + public function unrevealedForPlayer(string $playerId): array + { + $sql = << $rows */ + $rows = $this->database + ->executeQuery($sql, ['playerId' => $playerId]) + ->fetchAllAssociative(); + + return array_values(array_filter( + $this->hydrate($rows), + static fn (BadgeResult $badge): bool => $badge->isRevealed() === false, + )); + } + /** + * @param list $rows + * @return list + */ + private function hydrate(array $rows): array + { + $badges = []; + + foreach ($rows as $row) { // Unknown values in the database (e.g. badge types that were // removed or not implemented yet) must not break player profiles - $badge = BadgeType::tryFrom($row['type']); + $type = BadgeType::tryFrom($row['type']); - if ($badge !== null) { - $badges[] = $badge; + if ($type === null) { + continue; } + + $badges[] = new BadgeResult( + type: $type, + tier: $row['tier'] === null ? null : BadgeTier::from((int) $row['tier']), + earnedAt: new DateTimeImmutable($row['earned_at']), + id: $row['id'], + revealedAt: $row['revealed_at'] === null ? null : new DateTimeImmutable($row['revealed_at']), + ); } return $badges; diff --git a/src/Query/GetPlayerProfile.php b/src/Query/GetPlayerProfile.php index 435eaa43d..9e4b87c0d 100644 --- a/src/Query/GetPlayerProfile.php +++ b/src/Query/GetPlayerProfile.php @@ -63,6 +63,8 @@ public function byId(string $playerId): PlayerProfile rating_count, average_rating, streak_opted_out, + content_digest_frequency, + experience_system_opted_out, ranking_opted_out, time_predictions_opted_out, fair_use_policy_accepted_at, @@ -132,6 +134,8 @@ public function byUserId(string $userId): PlayerProfile rating_count, average_rating, streak_opted_out, + content_digest_frequency, + experience_system_opted_out, ranking_opted_out, time_predictions_opted_out, fair_use_policy_accepted_at, diff --git a/src/Query/GetPlayerStatsSnapshot.php b/src/Query/GetPlayerStatsSnapshot.php new file mode 100644 index 000000000..8e4fe4a28 --- /dev/null +++ b/src/Query/GetPlayerStatsSnapshot.php @@ -0,0 +1,295 @@ +ownerSolveAggregates($playerId); + + return new PlayerStatsSnapshot( + playerId: $playerId, + distinctPuzzlesSolved: $this->distinctPuzzlesSolved($playerId), + totalPiecesSolved: $this->totalPiecesSolved($playerId), + best500PieceSoloSeconds: $this->best500PieceSoloSeconds($playerId), + allTimeLongestStreakDays: $this->allTimeLongestStreakDays($playerId), + teamSolvesCount: $this->teamSolvesCount($playerId), + zenPuzzlerSolves: $ownerAggregates['zenPuzzlerSolves'], + firstTrySolves: $ownerAggregates['firstTrySolves'], + unboxedSolves: $ownerAggregates['unboxedSolves'], + brandExplorerManufacturers: $ownerAggregates['brandExplorerManufacturers'], + marathonerSolves: $ownerAggregates['marathonerSolves'], + photographerSolves: $ownerAggregates['photographerSolves'], + steadyHandsQuarters: $this->steadyHandsQuarters($playerId), + librarianApprovedRequests: $this->librarianApprovedRequests($playerId), + best1000PieceSoloSeconds: $ownerAggregates['best1000PieceSoloSeconds'], + weekendSolves: $ownerAggregates['weekendSolves'], + catalogerApprovedPuzzles: $this->catalogerApprovedPuzzles($playerId), + ); + } + + /** + * All owner-scope counters in ONE query — every metric here filters on the same + * (`player_id = :playerId AND suspicious = false`) base set, so they share a single + * scan with `FILTER` aggregates instead of one query per badge. + * + * The `>= 2000-01-01` guard on the weekend counter is mandatory: the database + * contains garbage rows dated year 0024 which would otherwise be counted. + * + * @return array{ + * zenPuzzlerSolves: int, + * firstTrySolves: int, + * unboxedSolves: int, + * brandExplorerManufacturers: int, + * marathonerSolves: int, + * photographerSolves: int, + * weekendSolves: int, + * best1000PieceSoloSeconds: null|int, + * } + */ + private function ownerSolveAggregates(string $playerId): array + { + $sql = <<= 2000) AS marathoner_solves, + COUNT(*) FILTER (WHERE pst.finished_puzzle_photo IS NOT NULL) AS photographer_solves, + COUNT(*) FILTER ( + WHERE EXTRACT(ISODOW FROM COALESCE(pst.finished_at, pst.tracked_at)) IN (6, 7) + AND COALESCE(pst.finished_at, pst.tracked_at) >= '2000-01-01' + ) AS weekend_solves, + MIN(pst.seconds_to_solve) FILTER ( + WHERE pst.puzzling_type = 'solo' + AND pst.seconds_to_solve IS NOT NULL + AND p.pieces_count = 1000 + ) AS best_1000_solo_seconds +FROM puzzle_solving_time pst +JOIN puzzle p ON p.id = pst.puzzle_id +WHERE pst.player_id = :playerId + AND pst.suspicious = false +SQL; + + /** @var array{ + * zen_solves: int|string, + * first_try_solves: int|string, + * unboxed_solves: int|string, + * brand_manufacturers: int|string, + * marathoner_solves: int|string, + * photographer_solves: int|string, + * weekend_solves: int|string, + * best_1000_solo_seconds: null|int|string, + * }|false $row + */ + $row = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchAssociative(); + + if ($row === false) { + return [ + 'zenPuzzlerSolves' => 0, + 'firstTrySolves' => 0, + 'unboxedSolves' => 0, + 'brandExplorerManufacturers' => 0, + 'marathonerSolves' => 0, + 'photographerSolves' => 0, + 'weekendSolves' => 0, + 'best1000PieceSoloSeconds' => null, + ]; + } + + return [ + 'zenPuzzlerSolves' => is_numeric($row['zen_solves']) ? (int) $row['zen_solves'] : 0, + 'firstTrySolves' => is_numeric($row['first_try_solves']) ? (int) $row['first_try_solves'] : 0, + 'unboxedSolves' => is_numeric($row['unboxed_solves']) ? (int) $row['unboxed_solves'] : 0, + 'brandExplorerManufacturers' => is_numeric($row['brand_manufacturers']) ? (int) $row['brand_manufacturers'] : 0, + 'marathonerSolves' => is_numeric($row['marathoner_solves']) ? (int) $row['marathoner_solves'] : 0, + 'photographerSolves' => is_numeric($row['photographer_solves']) ? (int) $row['photographer_solves'] : 0, + 'weekendSolves' => is_numeric($row['weekend_solves']) ? (int) $row['weekend_solves'] : 0, + 'best1000PieceSoloSeconds' => is_numeric($row['best_1000_solo_seconds']) ? (int) $row['best_1000_solo_seconds'] : null, + ]; + } + + /** + * Longest run of consecutive calendar quarters that each contain at least one solve + * (owner or team participation). One SQL returns the distinct (year, quarter) pairs, + * the island detection runs in PHP — consecutive means `year * 4 + quarter` increments + * by exactly one. Dates before 2000-01-01 are excluded (the database contains garbage + * rows dated year 0024). + */ + private function steadyHandsQuarters(string $playerId): int + { + $sql = <<= '2000-01-01' + AND ( + player_id = :playerId + OR (team::jsonb -> 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +ORDER BY solve_year, solve_quarter +SQL; + + /** @var list $rows */ + $rows = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchAllAssociative(); + + $longest = 0; + $currentRun = 0; + $previousIndex = null; + + foreach ($rows as $row) { + $index = (int) $row['solve_year'] * 4 + (int) $row['solve_quarter']; + + $currentRun = $previousIndex !== null && $index === $previousIndex + 1 + ? $currentRun + 1 + : 1; + + $longest = max($longest, $currentRun); + $previousIndex = $index; + } + + return $longest; + } + + private function librarianApprovedRequests(string $playerId): int + { + $sql = <<database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + private function catalogerApprovedPuzzles(string $playerId): int + { + $sql = <<database->executeQuery($sql, ['playerId' => $playerId])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + 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/Query/GetPlayersForContentDigest.php b/src/Query/GetPlayersForContentDigest.php new file mode 100644 index 000000000..85d023933 --- /dev/null +++ b/src/Query/GetPlayersForContentDigest.php @@ -0,0 +1,80 @@ + + */ + public function weekly(string $periodKey): array + { + $sql = <<= last_log.sent_at + ) + ) + ORDER BY LOWER(p.email), p.registered_at ASC +) eligible +ORDER BY id +SQL; + + /** @var list $ids */ + $ids = $this->database + ->executeQuery($sql, [ + 'periodKey' => $periodKey, + 'sentStatus' => ContentDigestLog::STATUS_SENT, + ]) + ->fetchFirstColumn(); + + return $ids; + } +} diff --git a/src/Query/GetPlayersForXpRevealEmail.php b/src/Query/GetPlayersForXpRevealEmail.php new file mode 100644 index 000000000..197b58673 --- /dev/null +++ b/src/Query/GetPlayersForXpRevealEmail.php @@ -0,0 +1,47 @@ + + */ + public function execute(): array + { + $sql = << $ids */ + $ids = $this->database->executeQuery($sql)->fetchFirstColumn(); + + return $ids; + } +} diff --git a/src/Query/GetPuzzleSpeedPercentiles.php b/src/Query/GetPuzzleSpeedPercentiles.php new file mode 100644 index 000000000..c0b326d13 --- /dev/null +++ b/src/Query/GetPuzzleSpeedPercentiles.php @@ -0,0 +1,69 @@ +forPuzzles([$puzzleId])[$puzzleId] ?? PuzzleSpeedPercentiles::empty(); + } + + /** + * @param list $puzzleIds + * @return array keyed by puzzle id + */ + public function forPuzzles(array $puzzleIds): array + { + if ($puzzleIds === []) { + return []; + } + + $sql = << $rows */ + $rows = $this->database + ->executeQuery($sql, ['puzzleIds' => $puzzleIds], ['puzzleIds' => ArrayParameterType::STRING]) + ->fetchAllAssociative(); + + $result = []; + + foreach ($rows as $row) { + $result[$row['puzzle_id']] = new PuzzleSpeedPercentiles( + distinctSolvers: (int) $row['distinct_solvers'], + medianSeconds: $row['median_seconds'], + p25Seconds: $row['p25_seconds'], + p10Seconds: $row['p10_seconds'], + ); + } + + return $result; + } +} diff --git a/src/Query/GetSolveXpDisplayInfo.php b/src/Query/GetSolveXpDisplayInfo.php new file mode 100644 index 000000000..516fb6aa9 --- /dev/null +++ b/src/Query/GetSolveXpDisplayInfo.php @@ -0,0 +1,108 @@ + 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +SQL; + + $value = $this->database + ->executeQuery($sql, ['playerId' => $playerId, 'puzzleId' => $puzzleId]) + ->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + /** + * Null when the player is not a participant of the solve (or the solve is gone) — + * the receipt renders nothing in that case. + */ + public function forPlayerAndSolvingTime(string $playerId, string $solvingTimeId): null|SolveXpDisplayInfo + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +SQL; + + /** @var array{puzzle_id: string, seconds_to_solve: null|int, puzzling_type: string, tracked_at: string, earned_at: string, difficulty_tier: null|int}|false $row */ + $row = $this->database + ->executeQuery($sql, ['playerId' => $playerId, 'solvingTimeId' => $solvingTimeId]) + ->fetchAssociative(); + + if ($row === false) { + return null; + } + + $occurrenceSql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) + AND (COALESCE(pst.finished_at, pst.tracked_at), pst.id) < (CAST(:earnedAt AS TIMESTAMP), CAST(:solvingTimeId AS UUID)) +SQL; + + $before = $this->database->executeQuery($occurrenceSql, [ + 'playerId' => $playerId, + 'puzzleId' => $row['puzzle_id'], + 'earnedAt' => $row['earned_at'], + 'solvingTimeId' => $solvingTimeId, + ])->fetchOne(); + + $isSolo = $row['puzzling_type'] === 'solo'; + $isTimed = $row['seconds_to_solve'] !== null; + + return new SolveXpDisplayInfo( + occurrenceIndex: (is_numeric($before) ? (int) $before : 0) + 1, + isTimed: $isTimed, + isSolo: $isSolo, + isBackfill: new DateTimeImmutable($row['tracked_at']) < XpCalculator::fullFormulaFrom(), + puzzleHasDifficultyTier: $row['difficulty_tier'] !== null, + speedMedianReliable: $isSolo && $isTimed + && $this->getPuzzleSpeedPercentiles->forPuzzle($row['puzzle_id'])->hasReliableMedian(), + ); + } +} diff --git a/src/Query/GetXpEntriesForSolve.php b/src/Query/GetXpEntriesForSolve.php new file mode 100644 index 000000000..db49dca69 --- /dev/null +++ b/src/Query/GetXpEntriesForSolve.php @@ -0,0 +1,73 @@ + + */ + public function forPlayerAndSolvingTime(string $playerId, string $solvingTimeId): array + { + $sql = << $rows */ + $rows = $this->database + ->executeQuery($sql, ['solvingTimeId' => $solvingTimeId, 'playerId' => $playerId]) + ->fetchAllAssociative(); + + $lines = []; + + foreach ($rows as $row) { + $lines[] = new XpEntryLine( + reason: XpReason::from($row['reason']), + amount: $row['amount'], + earnedAt: new DateTimeImmutable($row['earned_at']), + solvingTimeId: $row['solving_time_id'], + badgeId: $row['badge_id'], + ); + } + + return $lines; + } + + /** + * Net XP the solve is currently worth to ONE player — shown in the + * delete-confirmation warning ("you will lose N XP earned by this solve"). + */ + public function totalForPlayerAndSolvingTime(string $playerId, string $solvingTimeId): int + { + $sql = <<database + ->executeQuery($sql, ['solvingTimeId' => $solvingTimeId, 'playerId' => $playerId]) + ->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Query/GetXpHistory.php b/src/Query/GetXpHistory.php new file mode 100644 index 000000000..28f61fb0e --- /dev/null +++ b/src/Query/GetXpHistory.php @@ -0,0 +1,90 @@ + + */ + public function forPlayer(string $playerId, int $limit, int $offset): array + { + $sql = << $rows */ + $rows = $this->database + ->executeQuery($sql, [ + 'playerId' => $playerId, + 'limit' => $limit, + 'offset' => $offset, + ]) + ->fetchAllAssociative(); + + $lines = []; + + foreach ($rows as $row) { + $lines[] = new XpHistoryEntry( + reason: XpReason::from($row['reason']), + amount: $row['amount'], + earnedAt: new DateTimeImmutable($row['earned_at']), + solvingTimeId: $row['solving_time_id'], + puzzleId: $row['puzzle_id'], + puzzleName: $row['puzzle_name'], + badgeType: $row['badge_type'] === null ? null : BadgeType::tryFrom($row['badge_type']), + badgeTier: $row['badge_tier'] === null ? null : BadgeTier::from($row['badge_tier']), + ); + } + + return $lines; + } + + public function countForPlayer(string $playerId): int + { + $sql = <<database + ->executeQuery($sql, ['playerId' => $playerId]) + ->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Query/GetXpLeaderboard.php b/src/Query/GetXpLeaderboard.php new file mode 100644 index 000000000..2a6ead805 --- /dev/null +++ b/src/Query/GetXpLeaderboard.php @@ -0,0 +1,322 @@ + NOW() + ) +SQL; + + public function __construct( + private Connection $database, + private ClockInterface $clock, + ) { + } + + /** + * @param list $favoritePlayerIds + * @return list + */ + public function allTime(null|string $country, null|array $favoritePlayerIds, int $limit = 100): array + { + [$favoritesCondition, $params, $types] = $this->favoritesFilter($favoritePlayerIds); + + $sql = <<= 50 AND EXISTS ( + SELECT 1 FROM membership m WHERE m.player_id = p.id AND {$this->activeMembership()} + ) THEN p.achievement_points END AS achievement_points +FROM player p +WHERE p.xp_total > 0 + AND {$this->publicEligibility()} + AND (CAST(:country AS TEXT) IS NULL OR p.country = :country) + {$favoritesCondition} +ORDER BY p.xp_total DESC, p.id ASC +LIMIT :limit +SQL; + + return $this->hydrate($sql, ['country' => $country, 'limit' => $limit] + $params, $types); + } + + /** + * @param list $favoritePlayerIds + * @return list + */ + public function thisWeek(null|string $country, null|array $favoritePlayerIds, int $limit = 100): array + { + [$favoritesCondition, $params, $types] = $this->favoritesFilter($favoritePlayerIds); + [$weekStart, $weekEnd] = $this->currentWeekWindow(); + + $sql = <<= 50 AND EXISTS ( + SELECT 1 FROM membership m WHERE m.player_id = p.id AND {$this->activeMembership()} + ) THEN p.achievement_points END AS achievement_points +FROM ( + SELECT e.player_id, SUM(e.amount) AS value + FROM xp_entry e + WHERE e.in_weekly_delta = true + AND e.earned_at >= CAST(:weekStart AS TIMESTAMP) + AND e.earned_at < CAST(:weekEnd AS TIMESTAMP) + GROUP BY e.player_id + HAVING SUM(e.amount) > 0 +) delta +JOIN player p ON p.id = delta.player_id +WHERE {$this->publicEligibility()} + AND (CAST(:country AS TEXT) IS NULL OR p.country = :country) + {$favoritesCondition} +ORDER BY delta.value DESC, p.id ASC +LIMIT :limit +SQL; + + return $this->hydrate($sql, [ + 'country' => $country, + 'limit' => $limit, + 'weekStart' => $weekStart->format('Y-m-d H:i:s'), + 'weekEnd' => $weekEnd->format('Y-m-d H:i:s'), + ] + $params, $types); + } + + /** + * Achievement Points ladder — members ranked by AP; viewable by all logged-in + * users (this is the read-only ladder free level-50 players are pointed to). + * + * @param list $favoritePlayerIds + * @return list + */ + public function achievementPoints(null|string $country, null|array $favoritePlayerIds, int $limit = 100): array + { + [$favoritesCondition, $params, $types] = $this->favoritesFilter($favoritePlayerIds); + + $sql = <<activeMembership()} +WHERE p.achievement_points > 0 + AND {$this->publicEligibility()} + AND (CAST(:country AS TEXT) IS NULL OR p.country = :country) + {$favoritesCondition} +ORDER BY p.achievement_points DESC, p.id ASC +LIMIT :limit +SQL; + + return $this->hydrate($sql, ['country' => $country, 'limit' => $limit] + $params, $types); + } + + /** + * The viewer's own standing for the pinned self-row: [rank, value] within the + * unfiltered public set, or null when they have nothing on that board. + * + * @return array{rank: int, value: int}|null + */ + public function selfRank(string $playerId, string $tab): null|array + { + [$weekStart, $weekEnd] = $this->currentWeekWindow(); + + $sql = match ($tab) { + 'this-week' => <<= CAST(:weekStart AS TIMESTAMP) + AND e.earned_at < CAST(:weekEnd AS TIMESTAMP) + GROUP BY e.player_id + HAVING SUM(e.amount) > 0 +) +SELECT mine.value, + 1 + ( + SELECT COUNT(*) FROM deltas d + JOIN player p ON p.id = d.player_id + WHERE d.value > mine.value AND {$this->publicEligibility()} + ) AS rank +FROM deltas mine +WHERE mine.player_id = :playerId +SQL, + 'achievement-points' => <<activeMembership()} + WHERE p.achievement_points > mine.achievement_points AND {$this->publicEligibility()} + ) AS rank +FROM player mine +WHERE mine.id = :playerId AND mine.achievement_points > 0 +SQL, + default => << mine.xp_total AND {$this->publicEligibility()} + ) AS rank +FROM player mine +WHERE mine.id = :playerId AND mine.xp_total > 0 +SQL, + }; + + $params = ['playerId' => $playerId]; + + if ($tab === 'this-week') { + $params['weekStart'] = $weekStart->format('Y-m-d H:i:s'); + $params['weekEnd'] = $weekEnd->format('Y-m-d H:i:s'); + } + + /** @var array{value: int|string, rank: int|string}|false $row */ + $row = $this->database->executeQuery($sql, $params)->fetchAssociative(); + + if ($row === false) { + return null; + } + + return [ + 'rank' => is_numeric($row['rank']) ? (int) $row['rank'] : 0, + 'value' => is_numeric($row['value']) ? (int) $row['value'] : 0, + ]; + } + + /** + * @return list + */ + public function countries(): array + { + $sql = << 0 + AND p.country IS NOT NULL + AND {$this->publicEligibility()} +ORDER BY p.country +SQL; + + /** @var list $countries */ + $countries = $this->database->executeQuery($sql)->fetchFirstColumn(); + + return $countries; + } + + /** + * @return array{DateTimeImmutable, DateTimeImmutable} + */ + private function currentWeekWindow(): array + { + $now = $this->clock->now(); + $weekStart = $now + ->setISODate((int) $now->format('o'), (int) $now->format('W')) + ->setTime(0, 0); + + return [$weekStart, $weekStart->modify('+7 days')]; + } + + /** + * @param null|list $favoritePlayerIds + * @return array{string, array, array} + */ + private function favoritesFilter(null|array $favoritePlayerIds): array + { + if ($favoritePlayerIds === null) { + return ['', [], []]; + } + + if ($favoritePlayerIds === []) { + // Favorites filter active with no favorites — force an empty board. + return ['AND FALSE', [], []]; + } + + return [ + 'AND p.id IN (:favoriteIds)', + ['favoriteIds' => $favoritePlayerIds], + ['favoriteIds' => ArrayParameterType::STRING], + ]; + } + + /** + * @param array $params + * @param array $types + * @return list + */ + private function hydrate(string $sql, array $params, array $types): array + { + /** @var list $rows */ + $rows = $this->database->executeQuery($sql, $params, $types)->fetchAllAssociative(); + + $result = []; + + foreach ($rows as $row) { + $result[] = new XpLeaderboardRow( + rank: is_numeric($row['rank']) ? (int) $row['rank'] : 0, + playerId: $row['player_id'], + playerName: $row['player_name'], + playerCode: $row['code'], + countryCode: CountryCode::fromCode($row['country']), + avatar: $row['avatar'], + value: is_numeric($row['value']) ? (int) $row['value'] : 0, + level: $row['level'], + achievementPoints: is_numeric($row['achievement_points']) ? (int) $row['achievement_points'] : null, + ); + } + + return $result; + } + + private function publicEligibility(): string + { + return self::PUBLIC_ELIGIBILITY; + } + + private function activeMembership(): string + { + return self::ACTIVE_MEMBERSHIP; + } +} diff --git a/src/Query/GetXpProfile.php b/src/Query/GetXpProfile.php new file mode 100644 index 000000000..195d86adc --- /dev/null +++ b/src/Query/GetXpProfile.php @@ -0,0 +1,46 @@ +database + ->executeQuery($sql, ['playerId' => $playerId]) + ->fetchAssociative(); + + if ($row === false) { + throw new PlayerNotFound(); + } + + return new XpProfile( + playerId: $row['id'], + xpTotal: $row['xp_total'], + level: $row['level'], + optedOut: $row['experience_system_opted_out'], + private: $row['is_private'], + ); + } +} diff --git a/src/Repository/BadgeRepository.php b/src/Repository/BadgeRepository.php new file mode 100644 index 000000000..32ea9eaf5 --- /dev/null +++ b/src/Repository/BadgeRepository.php @@ -0,0 +1,51 @@ +entityManager->persist($badge); + } + + /** + * @throws BadgeNotFound + */ + public function get(string $badgeId): Badge + { + $badge = $this->entityManager->find(Badge::class, $badgeId); + + if ($badge === null) { + throw new BadgeNotFound(); + } + + return $badge; + } + + /** + * @return list + */ + public function findByPlayerAndType(string $playerId, BadgeType $type): array + { + /** @var list $badges */ + $badges = $this->entityManager->getRepository(Badge::class)->findBy([ + 'player' => $playerId, + 'type' => $type, + ]); + + return $badges; + } +} diff --git a/src/Repository/ContentDigestLogRepository.php b/src/Repository/ContentDigestLogRepository.php new file mode 100644 index 000000000..4af9190ae --- /dev/null +++ b/src/Repository/ContentDigestLogRepository.php @@ -0,0 +1,21 @@ +entityManager->persist($log); + } +} diff --git a/src/Repository/EmailAuditLogRepository.php b/src/Repository/EmailAuditLogRepository.php index 334aaa189..77a24ca36 100644 --- a/src/Repository/EmailAuditLogRepository.php +++ b/src/Repository/EmailAuditLogRepository.php @@ -32,13 +32,33 @@ public function get(UuidInterface $id): EmailAuditLog return $log; } - public function deleteOlderThan(\DateTimeImmutable $before): int + /** + * Deletes at most one batch per call (content-digest README §12) — an unbounded DELETE + * at bulk-email volume produces a WAL burst and blocks vacuum for minutes. The caller + * loops via message re-dispatch, so every batch commits in its own transaction. + */ + public function deleteOlderThan(\DateTimeImmutable $before, null|string $emailTypePrefix = null, int $batchSize = 10_000): int { - return $this->entityManager->createQueryBuilder() - ->delete(EmailAuditLog::class, 'e') - ->where('e.sentAt < :before') - ->setParameter('before', $before) - ->getQuery() - ->execute(); + $typeCondition = $emailTypePrefix !== null ? 'AND email_type LIKE :emailTypePrefix' : ''; + + $sql = << $before->format('Y-m-d H:i:s'), + 'batchSize' => $batchSize, + ]; + + if ($emailTypePrefix !== null) { + $params['emailTypePrefix'] = $emailTypePrefix . '%'; + } + + return (int) $this->entityManager->getConnection()->executeStatement($sql, $params); } } diff --git a/src/Repository/XpEntryRepository.php b/src/Repository/XpEntryRepository.php new file mode 100644 index 000000000..6c0fb892b --- /dev/null +++ b/src/Repository/XpEntryRepository.php @@ -0,0 +1,21 @@ +entityManager->persist($entry); + } +} diff --git a/src/Results/AchievementHolder.php b/src/Results/AchievementHolder.php new file mode 100644 index 000000000..592b42ed0 --- /dev/null +++ b/src/Results/AchievementHolder.php @@ -0,0 +1,23 @@ + */ + public array $holders, + public int $totalCount, + ) { + } + + /** + * Everyone counts, but only members with public profiles are listed — + * this is the "+N more puzzlers" number. + */ + public function hiddenCount(): int + { + return max(0, $this->totalCount - count($this->holders)); + } +} diff --git a/src/Results/BadgeCatalogEntry.php b/src/Results/BadgeCatalogEntry.php new file mode 100644 index 000000000..697972a6a --- /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 000000000..39dd33afb --- /dev/null +++ b/src/Results/BadgeProgress.php @@ -0,0 +1,18 @@ +revealedAt !== null; + } +} diff --git a/src/Results/PlayerProfile.php b/src/Results/PlayerProfile.php index 9be8bdc9b..9a421e433 100644 --- a/src/Results/PlayerProfile.php +++ b/src/Results/PlayerProfile.php @@ -8,6 +8,7 @@ use Nette\Utils\Json; use Nette\Utils\JsonException; use SpeedPuzzling\Web\Value\CollectionVisibility; +use SpeedPuzzling\Web\Value\ContentDigestFrequency; use SpeedPuzzling\Web\Value\CountryCode; use SpeedPuzzling\Web\Value\EmailNotificationFrequency; use SpeedPuzzling\Web\Value\SellSwapListSettings; @@ -47,6 +48,8 @@ * rating_count: int|string, * average_rating: null|string, * streak_opted_out: bool, + * content_digest_frequency: string, + * experience_system_opted_out: bool, * ranking_opted_out: bool, * time_predictions_opted_out: bool, * fair_use_policy_accepted_at: null|string, @@ -89,6 +92,8 @@ public function __construct( public bool $emailNotificationsEnabled = true, public EmailNotificationFrequency $emailNotificationFrequency = EmailNotificationFrequency::TwentyFourHours, public bool $newsletterEnabled = true, + public ContentDigestFrequency $contentDigestFrequency = ContentDigestFrequency::Weekly, + public bool $experienceSystemOptedOut = false, public int $ratingCount = 0, public null|float $averageRating = null, public bool $streakOptedOut = false, @@ -183,6 +188,8 @@ public static function fromDatabaseRow(array $row, DateTimeImmutable $now): self ratingCount: (int) $row['rating_count'], averageRating: $row['average_rating'] !== null ? (float) $row['average_rating'] : null, streakOptedOut: (bool) $row['streak_opted_out'], + contentDigestFrequency: ContentDigestFrequency::from($row['content_digest_frequency']), + experienceSystemOptedOut: (bool) $row['experience_system_opted_out'], rankingOptedOut: (bool) $row['ranking_opted_out'], timePredictionsOptedOut: (bool) $row['time_predictions_opted_out'], fairUsePolicyAccepted: $row['fair_use_policy_accepted_at'] !== null, diff --git a/src/Results/PlayerStatsSnapshot.php b/src/Results/PlayerStatsSnapshot.php new file mode 100644 index 000000000..006d667f3 --- /dev/null +++ b/src/Results/PlayerStatsSnapshot.php @@ -0,0 +1,40 @@ +medianSeconds !== null + && $this->distinctSolvers >= XpCalculator::SPEED_BONUS_MIN_DISTINCT_SOLVERS; + } + + /** + * Lower seconds = faster: top-10% means being at or below the 10th percentile time. + */ + public function percentileFor(int $secondsToSolve): SpeedPercentile + { + if ($this->hasReliableMedian() === false || $this->medianSeconds === null) { + return SpeedPercentile::None; + } + + if ($this->p10Seconds !== null && $secondsToSolve <= $this->p10Seconds) { + return SpeedPercentile::Top10; + } + + if ($this->p25Seconds !== null && $secondsToSolve <= $this->p25Seconds) { + return SpeedPercentile::Top25; + } + + if ($secondsToSolve < $this->medianSeconds) { + return SpeedPercentile::AboveMedian; + } + + return SpeedPercentile::None; + } +} diff --git a/src/Results/SolveXpDisplayInfo.php b/src/Results/SolveXpDisplayInfo.php new file mode 100644 index 000000000..a886b2fe1 --- /dev/null +++ b/src/Results/SolveXpDisplayInfo.php @@ -0,0 +1,23 @@ + */ + public array $achievementsEarned, + public int $solvesCount, + public int $piecesCount, + public int $minutesSpent, + public int $previousSolvesCount, + public int $previousPiecesCount, + public int $currentStreakDays, + /** @var list */ + public array $favoritesActivity, + /** @var list */ + public array $nextAchievements, + public null|string $mostSolvedPuzzleName, + public int $mostSolvedPuzzleSolvers, + ) { + } + + public function hadActivity(): bool + { + return $this->solvesCount > 0 || $this->xpGained > 0 || $this->achievementsEarned !== []; + } +} diff --git a/src/Results/XpEntryLine.php b/src/Results/XpEntryLine.php new file mode 100644 index 000000000..b9c25a973 --- /dev/null +++ b/src/Results/XpEntryLine.php @@ -0,0 +1,23 @@ +level >= LevelTable::MAX_LEVEL; + } +} diff --git a/src/Results/XpProfile.php b/src/Results/XpProfile.php new file mode 100644 index 000000000..c2e3e1800 --- /dev/null +++ b/src/Results/XpProfile.php @@ -0,0 +1,49 @@ +level >= LevelTable::MAX_LEVEL; + } + + /** + * Fraction (0.0–1.0) toward the next level; null at max level. + */ + public function progressToNext(): null|float + { + return LevelTable::progressToNext($this->xpTotal); + } + + public function xpIntoCurrentLevel(): int + { + return $this->xpTotal - LevelTable::xpForLevel($this->level); + } + + /** + * XP still missing to reach the next level; null at max level. + */ + public function xpToNextLevel(): null|int + { + if ($this->isMaxLevel()) { + return null; + } + + return LevelTable::xpForLevel($this->level + 1) - $this->xpTotal; + } +} diff --git a/src/Services/Badges/BadgeEvaluator.php b/src/Services/Badges/BadgeEvaluator.php new file mode 100644 index 000000000..8d0d3d6b6 --- /dev/null +++ b/src/Services/Badges/BadgeEvaluator.php @@ -0,0 +1,140 @@ + $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, + private XpLedger $xpLedger, + ) { + } + + /** + * 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, bool $isBackfill = false): array + { + try { + $player = $this->playerRepository->get($playerId); + } catch (PlayerNotFound) { + return []; + } + + $snapshot = $this->getPlayerStatsSnapshot->forPlayer($playerId); + $existingBadges = $this->getBadges->allEarnedTiers($playerId); + $alreadyEarned = $this->earnedTierMap($existingBadges); + $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; + } + } + + if ($newBadges !== []) { + $drafts = []; + + foreach ($newBadges as $badge) { + $tier = $badge->tier === null ? null : BadgeTier::from($badge->tier); + + $drafts[] = new XpEntryDraft( + reason: XpReason::Achievement, + amount: $tier === null ? BadgeTier::SINGLE_TIER_POINTS : $tier->points(), + earnedAt: $badge->earnedAt, + // Backfilled achievements carry the backfill run time as earned_at and + // must not flood that week's delta leaderboard. + inWeeklyDelta: $isBackfill === false, + badgeId: $badge->id, + ); + } + + $this->xpLedger->append($player, $drafts); + } + + // Re-anchor the denormalized AP total on every evaluation (absolute set, not an + // increment) — the 15-minute recalc cron thereby self-heals any drift, e.g. from + // manually granted badges. Doctrine only issues an UPDATE when the value changed. + $achievementPoints = 0; + + foreach ($existingBadges as $existingBadge) { + $achievementPoints += $existingBadge->tier?->points() ?? BadgeTier::SINGLE_TIER_POINTS; + } + + foreach ($newBadges as $newBadge) { + $newTier = $newBadge->tier === null ? null : BadgeTier::from($newBadge->tier); + $achievementPoints += $newTier?->points() ?? BadgeTier::SINGLE_TIER_POINTS; + } + + $player->updateAchievementPoints($achievementPoints); + + 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/Services/Digest/WeeklyDigestDataProvider.php b/src/Services/Digest/WeeklyDigestDataProvider.php new file mode 100644 index 000000000..22ea22fe5 --- /dev/null +++ b/src/Services/Digest/WeeklyDigestDataProvider.php @@ -0,0 +1,282 @@ + $conditions + */ + public function __construct( + private Connection $database, + #[AutowireIterator('badge.condition')] + private iterable $conditions, + private GetBadges $getBadges, + private GetPlayerStatsSnapshot $getPlayerStatsSnapshot, + private ActivityCalendarStreakCalculator $streakCalculator, + ) { + } + + public function forPlayer(Player $player, DigestPeriod $period): WeeklyDigestData + { + $playerId = $player->id->toString(); + + [$xpGained, $levelsGained] = $this->xpBlock($player, $period); + $achievements = $this->achievementsEarned($playerId, $period); + [$solves, $pieces, $minutes] = $this->weekInNumbers($playerId, $period->weekStart->format('Y-m-d H:i:s'), $period->weekEnd->format('Y-m-d H:i:s')); + [$previousSolves, $previousPieces] = $this->weekInNumbers( + $playerId, + $period->weekStart->modify('-7 days')->format('Y-m-d H:i:s'), + $period->weekStart->format('Y-m-d H:i:s'), + ); + [$mostSolvedName, $mostSolvedSolvers] = $this->mostSolvedOfWeek($period); + + return new WeeklyDigestData( + xpGained: $xpGained, + levelsGained: $levelsGained, + currentLevel: $player->level, + achievementsEarned: $achievements, + solvesCount: $solves, + piecesCount: $pieces, + minutesSpent: $minutes, + previousSolvesCount: $previousSolves, + previousPiecesCount: $previousPieces, + currentStreakDays: $player->streakOptedOut ? 0 : $this->currentStreak($playerId), + favoritesActivity: $this->favoritesActivity($player, $period), + nextAchievements: $this->nextAchievements($playerId), + mostSolvedPuzzleName: $mostSolvedName, + mostSolvedPuzzleSolvers: $mostSolvedSolvers, + ); + } + + /** + * @return array{int, int} + */ + private function xpBlock(Player $player, DigestPeriod $period): array + { + $sql = <<= CAST(:weekStart AS TIMESTAMP) + AND earned_at < CAST(:weekEnd AS TIMESTAMP) +SQL; + + $value = $this->database->executeQuery($sql, [ + 'playerId' => $player->id->toString(), + 'weekStart' => $period->weekStart->format('Y-m-d H:i:s'), + 'weekEnd' => $period->weekEnd->format('Y-m-d H:i:s'), + ])->fetchOne(); + + $xpGained = is_numeric($value) ? (int) $value : 0; + $levelsGained = max(0, LevelTable::levelForXp($player->xpTotal) - LevelTable::levelForXp($player->xpTotal - $xpGained)); + + return [$xpGained, $levelsGained]; + } + + /** + * @return list + */ + private function achievementsEarned(string $playerId, DigestPeriod $period): array + { + $earned = []; + + foreach ($this->getBadges->allEarnedTiers($playerId) as $badge) { + if ($badge->earnedAt >= $period->weekStart && $badge->earnedAt < $period->weekEnd) { + $earned[] = ['type' => $badge->type, 'tier' => $badge->tier]; + } + } + + return $earned; + } + + /** + * @return array{int, int, int} + */ + private function weekInNumbers(string $playerId, string $windowStart, string $windowEnd): array + { + $sql = <<= CAST(:windowStart AS TIMESTAMP) + AND COALESCE(pst.finished_at, pst.tracked_at) < CAST(:windowEnd AS TIMESTAMP) +SQL; + + /** @var array{solves: int|string, pieces: int|string, minutes: int|string}|false $row */ + $row = $this->database->executeQuery($sql, [ + 'playerId' => $playerId, + 'windowStart' => $windowStart, + 'windowEnd' => $windowEnd, + ])->fetchAssociative(); + + if ($row === false) { + return [0, 0, 0]; + } + + return [ + is_numeric($row['solves']) ? (int) $row['solves'] : 0, + is_numeric($row['pieces']) ? (int) $row['pieces'] : 0, + is_numeric($row['minutes']) ? (int) $row['minutes'] : 0, + ]; + } + + private function currentStreak(string $playerId): int + { + $sql = <<= '2000-01-01' + AND ( + player_id = :playerId + OR (team::jsonb -> '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(); + + return $this->streakCalculator->calculate(array_column($rows, 'solve_day'))->current; + } + + /** + * @return list + */ + private function favoritesActivity(Player $player, DigestPeriod $period): array + { + $favoriteIds = array_values($player->favoritePlayerIds()); + + if ($favoriteIds === []) { + return []; + } + + $sql = <<= CAST(:weekStart AS TIMESTAMP) + AND COALESCE(pst.finished_at, pst.tracked_at) < CAST(:weekEnd AS TIMESTAMP) +GROUP BY p.id, p.name, p.code +ORDER BY solves DESC +LIMIT 5 +SQL; + + /** @var list $rows */ + $rows = $this->database->executeQuery( + $sql, + [ + 'favoriteIds' => $favoriteIds, + 'weekStart' => $period->weekStart->format('Y-m-d H:i:s'), + 'weekEnd' => $period->weekEnd->format('Y-m-d H:i:s'), + ], + ['favoriteIds' => ArrayParameterType::STRING], + )->fetchAllAssociative(); + + return array_map( + static fn (array $row): array => ['name' => $row['name'], 'solves' => is_numeric($row['solves']) ? (int) $row['solves'] : 0], + $rows, + ); + } + + /** + * Top progress toward the next achievement tiers — members-only block. + * + * @return list + */ + private function nextAchievements(string $playerId): array + { + $snapshot = $this->getPlayerStatsSnapshot->forPlayer($playerId); + + $highestEarned = []; + foreach ($this->getBadges->forPlayer($playerId) as $badge) { + if ($badge->tier !== null) { + $highestEarned[$badge->type->value] = $badge->tier; + } + } + + $candidates = []; + + foreach ($this->conditions as $condition) { + $type = $condition->badgeType(); + $earned = $highestEarned[$type->value] ?? null; + + if ($earned === BadgeTier::Diamond) { + continue; + } + + $progress = $condition->progressToNextTier($snapshot, $earned); + + if ($progress === null || $progress->percent <= 0) { + continue; + } + + $candidates[] = ['type' => $type, 'progress' => $progress]; + } + + usort($candidates, static fn (array $a, array $b): int => $b['progress']->percent <=> $a['progress']->percent); + + return array_slice($candidates, 0, 2); + } + + /** + * @return array{null|string, int} + */ + private function mostSolvedOfWeek(DigestPeriod $period): array + { + $sql = <<= CAST(:weekStart AS TIMESTAMP) + AND COALESCE(pst.finished_at, pst.tracked_at) < CAST(:weekEnd AS TIMESTAMP) +GROUP BY p.id, p.name +HAVING COUNT(DISTINCT pst.player_id) >= 3 +ORDER BY solvers DESC, p.name ASC +LIMIT 1 +SQL; + + /** @var array{name: string, solvers: int|string}|false $row */ + $row = $this->database->executeQuery($sql, [ + 'weekStart' => $period->weekStart->format('Y-m-d H:i:s'), + 'weekEnd' => $period->weekEnd->format('Y-m-d H:i:s'), + ])->fetchAssociative(); + + if ($row === false) { + return [null, 0]; + } + + return [$row['name'], is_numeric($row['solvers']) ? (int) $row['solvers'] : 0]; + } +} diff --git a/src/Services/GetXpShareCard.php b/src/Services/GetXpShareCard.php new file mode 100644 index 000000000..f33a1ac59 --- /dev/null +++ b/src/Services/GetXpShareCard.php @@ -0,0 +1,98 @@ +getPlayerProfile->byId($playerId); + $xpProfile = $this->getXpProfile->byPlayerId($playerId); + + $noOlderThan = $this->clock->now()->modify('-1 month'); + $path = "players/{$playerId}/xp-card-{$variant}-lv{$xpProfile->level}.png"; + + if ( + $this->filesystem->fileExists($path) + && $this->filesystem->lastModified($path) >= $noOlderThan->getTimestamp() + ) { + return $this->filesystem->read($path); + } + + $size = self::SIZE; + $centerX = (int) ($size / 2); + + $playerName = $player->playerName ?? sprintf('#%s', strtoupper($player->code)); + $headline = $variant === 'level-up' + ? sprintf('reached Level %d!', $xpProfile->level) + : sprintf('is Level %d on the puzzle journey!', $xpProfile->level); + $signature = sprintf('#%s on MySpeedPuzzling.com', $player->code); + + $image = $this->imageManager->decode((string) file_get_contents(__DIR__ . '/../../public/img/xp/share-card-background-800.png')) + ->cover($size, $size) + ->text((string) $xpProfile->level, $centerX, 330, function (FontFactory $font) use ($size) { + $font->filename(__DIR__ . '/../../assets/fonts/Rubik/Rubik-ExtraBold.ttf'); + $font->color('#2b3445'); + $font->size((int) ($size / 4)); + $font->align('center', 'center'); + }) + ->text('LEVEL', $centerX, 215, function (FontFactory $font) use ($size) { + $font->filename(__DIR__ . '/../../assets/fonts/Rubik/Rubik-Bold.ttf'); + $font->color('#4e54c8'); + $font->size((int) ($size / 22)); + $font->align('center', 'center'); + }) + ->text($playerName, $centerX, 460, function (FontFactory $font) use ($size) { + $font->wrap((int) ($size * 0.8)); + $font->filename(__DIR__ . '/../../assets/fonts/Rubik/Rubik-Bold.ttf'); + $font->color('#2b3445'); + $font->size((int) ($size / 16)); + $font->align('center', 'center'); + }) + ->text($headline, $centerX, 525, function (FontFactory $font) use ($size) { + $font->wrap((int) ($size * 0.85)); + $font->filename(__DIR__ . '/../../assets/fonts/Rubik/Rubik-Regular.ttf'); + $font->color('#2b3445'); + $font->size((int) ($size / 22)); + $font->align('center', 'center'); + }) + ->text($signature, $centerX, 600, function (FontFactory $font) use ($size) { + $font->filename(__DIR__ . '/../../assets/fonts/Rubik/Rubik-Regular.ttf'); + $font->color('#4e54c8'); + $font->size((int) ($size / 28)); + $font->align('center', 'center'); + }); + + $encoded = (string) $image->encodeUsingMediaType(MediaType::IMAGE_PNG, quality: 100); + + $this->filesystem->write($path, $encoded); + + return $encoded; + } +} diff --git a/src/Services/Xp/XpCalculator.php b/src/Services/Xp/XpCalculator.php new file mode 100644 index 000000000..c6b28c180 --- /dev/null +++ b/src/Services/Xp/XpCalculator.php @@ -0,0 +1,194 @@ + 0. Backfill solves (tracked before the + * cutoff) earn only the first three award kinds. Relax repeats earn nothing at all. + */ +readonly final class XpCalculator +{ + /** + * Solves tracked BEFORE this moment use the backfill formula (core only); at/after it, + * the full formula. Set at deploy/launch by Jan — class constants cannot hold objects, + * hence the string; consume via fullFormulaFrom(). + */ + public const string FULL_FORMULA_FROM = '2026-08-01 00:00:00'; + + /** + * Anti-abuse plausibility guard (§1.8): pieces-per-minute above this on a ≥500pc timed + * solo solve disqualifies the speed bonus (silently — warning log only). World-record + * pace is ≈17–20 PPM, so no legitimate solve is ever affected. + */ + public const float MAX_PLAUSIBLE_PPM = 30.0; + public const int PPM_GUARD_MIN_PIECES = 500; + + /** Speed bonus requires the puzzle median to come from at least this many distinct solvers. */ + public const int SPEED_BONUS_MIN_DISTINCT_SOLVERS = 3; + + public const int WEEKLY_BOOST_SOLVE_LIMIT = 5; + + private const float BASE_MIN = 1.0; + private const float BASE_MAX = 60.0; + private const float TEAM_MULTIPLIER = 0.75; + private const float UNBOXED_BONUS_RATE = 0.20; + private const float WEEKLY_BOOST_RATE = 0.50; + private const int DAILY_WARMUP_XP = 2; + + public static function fullFormulaFrom(): DateTimeImmutable + { + return new DateTimeImmutable(self::FULL_FORMULA_FROM); + } + + /** + * §1.8 plausibility guard — callers must resolve SpeedPercentile::None (plus a warning + * log, never any user-facing accusation) when this returns true. + */ + public static function isImplausiblyFast(int $piecesCount, int $secondsToSolve): bool + { + if ($piecesCount < self::PPM_GUARD_MIN_PIECES) { + return false; + } + + if ($secondsToSolve <= 0) { + return true; + } + + return ($piecesCount / ($secondsToSolve / 60)) > self::MAX_PLAUSIBLE_PPM; + } + + /** + * @return list + */ + public function calculate(SolveXpContext $context): array + { + $occurrence = $this->occurrenceMultiplier($context->isTimed, $context->occurrenceIndex); + + if ($occurrence <= 0.0) { + // Relax repeat — earns nothing, produces no ledger entries. + return []; + } + + $base = min(max($context->piecesCount / 100, self::BASE_MIN), self::BASE_MAX); + $team = $context->isTeamOrDuo ? self::TEAM_MULTIPLIER : 1.0; + + $basePart = $base * $team * $occurrence; + $difficultyPart = $basePart * ($this->difficultyMultiplier($context->difficultyTier) - 1.0); + $unboxedPart = $context->unboxed ? ($basePart + $difficultyPart) * self::UNBOXED_BONUS_RATE : 0.0; + $core = $basePart + $difficultyPart + $unboxedPart; + + $awards = [ + // core > 0 is guaranteed here (base ≥ 1, occurrence > 0) — floor the base line at 1. + new XpAward(XpReason::SolveBase, max($this->roundHalfUp($basePart), 1)), + ]; + + $difficultyAmount = $this->roundHalfUp($difficultyPart); + if ($difficultyAmount > 0) { + $awards[] = new XpAward(XpReason::SolveDifficultyBonus, $difficultyAmount); + } + + $unboxedAmount = $this->roundHalfUp($unboxedPart); + if ($unboxedAmount > 0) { + $awards[] = new XpAward(XpReason::SolveUnboxedBonus, $unboxedAmount); + } + + if ($context->isBackfill) { + return $awards; + } + + // Speed bonus: solo + timed only — defensive re-check even though the wiring + // already passes SpeedPercentile::None for ineligible solves. + if ($context->isTimed && $context->isTeamOrDuo === false) { + $speedAmount = $this->roundHalfUp($core * $context->speedPercentile->bonusRate()); + + if ($speedAmount > 0) { + $awards[] = new XpAward(XpReason::SolveSpeedBonus, $speedAmount); + } + } + + if ($context->xpEarningSolvesThisWeek < self::WEEKLY_BOOST_SOLVE_LIMIT) { + $weeklyAmount = $this->roundHalfUp($core * self::WEEKLY_BOOST_RATE); + + if ($weeklyAmount > 0) { + $awards[] = new XpAward(XpReason::SolveWeeklyBoost, $weeklyAmount); + } + } + + if ($context->isFirstXpEarningSolveOfDay) { + $awards[] = new XpAward(XpReason::SolveDailyWarmup, self::DAILY_WARMUP_XP); + } + + return $awards; + } + + /** + * Difficulty multiplier per puzzle_difficulty.difficulty_tier — tiers run 1–6 (not 1–5!). + * Unrated puzzles (null) count as 1.00 now and settle once when first rated. + */ + private function difficultyMultiplier(null|int $tier): float + { + return match ($tier) { + null, 1, 2 => 1.0, + 3 => 1.15, + 4 => 1.30, + 5 => 1.40, + 6 => 1.50, + default => throw new InvalidArgumentException(sprintf('Difficulty tier must be between 1 and 6, got %d.', $tier)), + }; + } + + /** + * Occurrence position counts ALL solves of the (player, puzzle) pair in canonical order, + * regardless of mode; the multiplier then depends on this solve's mode: + * timed 1st 1.00 · timed 2nd 0.50 · timed 3rd+ 0.25 · relax 1st 0.50 · relax repeat 0.00. + * There is deliberately NO personal-best exception. + */ + private function occurrenceMultiplier(bool $isTimed, int $occurrenceIndex): float + { + if ($occurrenceIndex < 1) { + throw new InvalidArgumentException(sprintf('Occurrence index is 1-based, got %d.', $occurrenceIndex)); + } + + if ($isTimed) { + return match ($occurrenceIndex) { + 1 => 1.0, + 2 => 0.5, + default => 0.25, + }; + } + + return $occurrenceIndex === 1 ? 0.5 : 0.0; + } + + private function roundHalfUp(float $value): int + { + // Two-step rounding absorbs binary float representation error (10 × 0.15 gives + // 1.4999999999999998) — the formula's decimal inputs are exact within 9 decimals, + // so the intermediate round restores the intended half boundary before the final + // half-up rounding to int. + return (int) round(round($value, 9)); + } +} diff --git a/src/Services/Xp/XpChainRecomputer.php b/src/Services/Xp/XpChainRecomputer.php new file mode 100644 index 000000000..b7ac9dfc7 --- /dev/null +++ b/src/Services/Xp/XpChainRecomputer.php @@ -0,0 +1,735 @@ +loadSolve($solvingTimeId); + + if ($solve === null || $solve['suspicious']) { + return; + } + + $earnedAt = new DateTimeImmutable($solve['earned_at']); + + foreach ($this->participantsOf($solve) as $participantId) { + // Idempotency under at-least-once delivery: entries already exist → done. + if ($this->hasAnyEntry($participantId, $solvingTimeId)) { + continue; + } + + try { + $player = $this->playerRepository->get($participantId); + } catch (PlayerNotFound) { + continue; + } + + $occurrence = $this->occurrenceIndex($participantId, $solve['puzzle_id'], $earnedAt, $solvingTimeId); + + $awards = $this->xpCalculator->calculate($this->contextFor( + solve: $solve, + participantId: $participantId, + occurrence: $occurrence, + earnedAt: $earnedAt, + )); + + $drafts = []; + foreach ($awards as $award) { + $drafts[] = new XpEntryDraft( + reason: $award->reason, + amount: $award->amount, + earnedAt: $earnedAt, + inWeeklyDelta: true, + solvingTimeId: Uuid::fromString($solvingTimeId), + ); + } + + $this->xpLedger->append($player, $drafts); + } + } + + public function rebuildChainForEditedSolve(string $solvingTimeId): void + { + $solve = $this->loadSolve($solvingTimeId); + + if ($solve === null) { + // Solve got deleted before this message ran — the deletion flow owns cleanup. + return; + } + + $participants = array_values(array_unique([ + ...$this->participantsOf($solve), + ...$this->playersWithEntriesFor($solvingTimeId), + ])); + + foreach ($participants as $participantId) { + $this->rebuildPair($participantId, $solve['puzzle_id'], [$solvingTimeId]); + } + } + + public function compensateAndRebuildAfterDeletion(string $solvingTimeId, string $puzzleId): void + { + $affectedPlayers = $this->playersWithEntriesFor($solvingTimeId); + + foreach ($affectedPlayers as $participantId) { + $this->compensateEntries($participantId, $solvingTimeId); + } + + foreach ($affectedPlayers as $participantId) { + $this->rebuildPair($participantId, $puzzleId, []); + } + } + + /** + * Ex-post settlements (§1.4): a solve logged on an unrated puzzle earns its difficulty + * bonus once the puzzle first gets a difficulty tier; a solve on a puzzle without a + * reliable median earns its speed bonus once ≥3 distinct solvers exist. Settled once, + * frozen forever (anchored by the unique (player, solve, reason) index) — later tier or + * median drift never revises anything. Go-forward solves only; settlements never count + * toward the weekly delta and carry the settlement run time as earned_at. + * + * Returns the number of settlement entries created. + */ + public function settlePendingBonuses(): int + { + $now = $this->clock->now(); + $created = 0; + + /** @var array> $draftsByPlayer */ + $draftsByPlayer = []; + + foreach ($this->loadPendingDifficultySettlements() as $row) { + $amount = $this->settlementAmount($row, XpReason::SolveDifficultyBonus); + + if ($amount <= 0) { + continue; + } + + $draftsByPlayer[$row['player_id']][] = new XpEntryDraft( + reason: XpReason::DifficultySettlement, + amount: $amount, + earnedAt: $now, + inWeeklyDelta: false, + solvingTimeId: Uuid::fromString($row['id']), + ); + $created++; + } + + foreach ($this->loadPendingSpeedSettlements() as $row) { + $amount = $this->settlementAmount($row, XpReason::SolveSpeedBonus); + + if ($amount <= 0) { + continue; + } + + $draftsByPlayer[$row['player_id']][] = new XpEntryDraft( + reason: XpReason::SpeedSettlement, + amount: $amount, + earnedAt: $now, + inWeeklyDelta: false, + solvingTimeId: Uuid::fromString($row['id']), + ); + $created++; + } + + foreach ($draftsByPlayer as $playerId => $drafts) { + try { + $player = $this->playerRepository->get($playerId); + } catch (PlayerNotFound) { + continue; + } + + $this->xpLedger->append($player, $drafts); + } + + return $created; + } + + /** + * Runs the full calculator for the solve as it stands today and extracts the single + * receipt line being settled — guarantees settlement amounts use the exact same math + * and rounding as live awards. + * + * @phpstan-param SolveRow $row + */ + private function settlementAmount(array $row, XpReason $wantedReason): int + { + $earnedAt = new DateTimeImmutable($row['earned_at']); + $occurrence = $this->occurrenceIndex($row['player_id'], $row['puzzle_id'], $earnedAt, $row['id']); + + $isTeamOrDuo = $row['puzzling_type'] !== 'solo'; + $speedPercentile = SpeedPercentile::None; + + if ($wantedReason === XpReason::SolveSpeedBonus && $isTeamOrDuo === false && $row['seconds_to_solve'] !== null) { + $speedPercentile = $this->resolveSpeedPercentile($row, $row['player_id']); + } + + $awards = $this->xpCalculator->calculate(new SolveXpContext( + piecesCount: $row['pieces'], + difficultyTier: $row['difficulty_tier'], + isTimed: $row['seconds_to_solve'] !== null, + isTeamOrDuo: $isTeamOrDuo, + unboxed: $row['unboxed'], + occurrenceIndex: $occurrence, + isBackfill: false, + speedPercentile: $speedPercentile, + // Suppress weekly boost and daily warm-up — only the settled line matters. + xpEarningSolvesThisWeek: XpCalculator::WEEKLY_BOOST_SOLVE_LIMIT, + isFirstXpEarningSolveOfDay: false, + )); + + foreach ($awards as $award) { + if ($award->reason === $wantedReason) { + return $award->amount; + } + } + + return 0; + } + + /** + * Awarded (player, solve) pairs on go-forward solves whose puzzle now has a bonus-worthy + * difficulty tier but which have neither a live difficulty bonus nor a settlement yet. + * + * @phpstan-return list + */ + private function loadPendingDifficultySettlements(): array + { + $sql = self::SOLVE_SELECT_FROM_ENTRIES . <<= CAST(:cutoff AS TIMESTAMP) + AND pd.difficulty_tier >= 3 + AND NOT EXISTS ( + SELECT 1 FROM xp_entry d + WHERE d.player_id = e.player_id + AND d.solving_time_id = e.solving_time_id + AND d.reason IN (:difficultyReasons) + ) +SQL; + + /** @phpstan-var list $rows */ + $rows = $this->database->executeQuery( + $sql, + [ + 'solveBase' => XpReason::SolveBase->value, + 'cutoff' => XpCalculator::FULL_FORMULA_FROM, + 'difficultyReasons' => [XpReason::SolveDifficultyBonus->value, XpReason::DifficultySettlement->value], + ], + ['difficultyReasons' => ArrayParameterType::STRING], + )->fetchAllAssociative(); + + return $rows; + } + + /** + * @phpstan-return list + */ + private function loadPendingSpeedSettlements(): array + { + $sql = self::SOLVE_SELECT_FROM_ENTRIES . <<= CAST(:cutoff AS TIMESTAMP) + AND pst.puzzling_type = 'solo' + AND pst.seconds_to_solve IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM xp_entry d + WHERE d.player_id = e.player_id + AND d.solving_time_id = e.solving_time_id + AND d.reason IN (:speedReasons) + ) +SQL; + + /** @phpstan-var list $rows */ + $rows = $this->database->executeQuery( + $sql, + [ + 'solveBase' => XpReason::SolveBase->value, + 'cutoff' => XpCalculator::FULL_FORMULA_FROM, + 'speedReasons' => [XpReason::SolveSpeedBonus->value, XpReason::SpeedSettlement->value], + ], + ['speedReasons' => ArrayParameterType::STRING], + )->fetchAllAssociative(); + + return $rows; + } + + private function compensateEntries(string $playerId, string $solvingTimeId): void + { + /** @var list $entries */ + $entries = $this->database->fetchAllAssociative( + 'SELECT amount, reason, earned_at, in_weekly_delta FROM xp_entry + WHERE player_id = :playerId AND solving_time_id = :solvingTimeId', + ['playerId' => $playerId, 'solvingTimeId' => $solvingTimeId], + ); + + $net = 0; + foreach ($entries as $entry) { + $net += $entry['amount']; + } + + // Net zero = already compensated (message redelivery) or never earned anything. + if ($net === 0) { + return; + } + + try { + $player = $this->playerRepository->get($playerId); + } catch (PlayerNotFound) { + return; + } + + $drafts = []; + foreach ($entries as $entry) { + if ($entry['reason'] === XpReason::SolveCompensation->value) { + continue; + } + + $drafts[] = new XpEntryDraft( + reason: XpReason::SolveCompensation, + amount: -$entry['amount'], + earnedAt: new DateTimeImmutable($entry['earned_at']), + inWeeklyDelta: $entry['in_weekly_delta'], + solvingTimeId: Uuid::fromString($solvingTimeId), + ); + } + + $this->xpLedger->append($player, $drafts); + } + + /** + * Wipe and deterministically replay every entry of the (participant, puzzle) pair. + * $extraSolveIds covers orphaned entries of solves the participant is no longer part + * of (e.g. removed from the team on edit). Compensation lines and entries of deleted + * solves are never touched — they are the audit history. + * + * @param list $extraSolveIds + */ + private function rebuildPair(string $playerId, string $puzzleId, array $extraSolveIds): void + { + try { + $player = $this->playerRepository->get($playerId); + } catch (PlayerNotFound) { + return; + } + + $solves = $this->loadPairSolves($playerId, $puzzleId); + + $solveIds = array_values(array_unique([...array_column($solves, 'id'), ...$extraSolveIds])); + + $wipedSum = 0; + + if ($solveIds !== []) { + $wiped = $this->database->fetchOne( + 'SELECT COALESCE(SUM(amount), 0) FROM xp_entry + WHERE player_id = :playerId AND solving_time_id IN (:solvingTimeIds) AND reason != :compensation', + [ + 'playerId' => $playerId, + 'solvingTimeIds' => $solveIds, + 'compensation' => XpReason::SolveCompensation->value, + ], + ['solvingTimeIds' => ArrayParameterType::STRING], + ); + $wipedSum = is_numeric($wiped) ? (int) $wiped : 0; + + $this->database->executeStatement( + 'DELETE FROM xp_entry + WHERE player_id = :playerId AND solving_time_id IN (:solvingTimeIds) AND reason != :compensation', + [ + 'playerId' => $playerId, + 'solvingTimeIds' => $solveIds, + 'compensation' => XpReason::SolveCompensation->value, + ], + ['solvingTimeIds' => ArrayParameterType::STRING], + ); + } + + $drafts = []; + $occurrence = 0; + + /** @var array $weeklyReplayed */ + $weeklyReplayed = []; + /** @var array $dailyReplayed */ + $dailyReplayed = []; + + foreach ($solves as $solve) { + $occurrence++; + $earnedAt = new DateTimeImmutable($solve['earned_at']); + $weekKey = $earnedAt->format('o-\WW'); + $dayKey = $earnedAt->format('Y-m-d'); + + $awards = $this->xpCalculator->calculate($this->contextFor( + solve: $solve, + participantId: $playerId, + occurrence: $occurrence, + earnedAt: $earnedAt, + weeklyExtra: $weeklyReplayed[$weekKey] ?? 0, + dayAlreadyReplayed: isset($dailyReplayed[$dayKey]), + )); + + if ($awards === []) { + continue; + } + + $weeklyReplayed[$weekKey] = ($weeklyReplayed[$weekKey] ?? 0) + 1; + $dailyReplayed[$dayKey] = true; + + foreach ($awards as $award) { + $drafts[] = new XpEntryDraft( + reason: $award->reason, + amount: $award->amount, + earnedAt: $earnedAt, + inWeeklyDelta: true, + solvingTimeId: Uuid::fromString($solve['id']), + ); + } + } + + // The wipe above bypassed the ledger, so subtract the wiped sum from the + // denormalized totals incrementally — a fresh DB SUM would miss unflushed entries + // (e.g. compensations appended moments ago in the deletion flow). + $adjustedTotal = $player->xpTotal - $wipedSum; + $player->updateExperience($adjustedTotal, LevelTable::levelForXp($adjustedTotal)); + + $this->xpLedger->append($player, $drafts); + } + + /** + * @phpstan-param SolveRow $solve + */ + private function contextFor( + array $solve, + string $participantId, + int $occurrence, + DateTimeImmutable $earnedAt, + int $weeklyExtra = 0, + bool $dayAlreadyReplayed = false, + ): SolveXpContext { + $isBackfill = new DateTimeImmutable($solve['tracked_at']) < XpCalculator::fullFormulaFrom(); + $isTimed = $solve['seconds_to_solve'] !== null; + $isTeamOrDuo = $solve['puzzling_type'] !== 'solo'; + + $speedPercentile = SpeedPercentile::None; + + if ($isBackfill === false && $isTeamOrDuo === false && $solve['seconds_to_solve'] !== null) { + $speedPercentile = $this->resolveSpeedPercentile($solve, $participantId); + } + + return new SolveXpContext( + piecesCount: $solve['pieces'], + difficultyTier: $solve['difficulty_tier'], + isTimed: $isTimed, + isTeamOrDuo: $isTeamOrDuo, + unboxed: $solve['unboxed'], + occurrenceIndex: $occurrence, + isBackfill: $isBackfill, + speedPercentile: $speedPercentile, + xpEarningSolvesThisWeek: $weeklyExtra + $this->countWeekSolvesBefore($participantId, $earnedAt, $solve['id']), + isFirstXpEarningSolveOfDay: $dayAlreadyReplayed === false + && $this->hasDaySolveBefore($participantId, $earnedAt, $solve['id']) === false, + ); + } + + /** + * @phpstan-param SolveRow $solve + */ + private function resolveSpeedPercentile(array $solve, string $participantId): SpeedPercentile + { + $seconds = $solve['seconds_to_solve']; + + if ($seconds === null) { + return SpeedPercentile::None; + } + + if (XpCalculator::isImplausiblyFast($solve['pieces'], $seconds)) { + // Silent guard: no speed bonus, no user-facing accusation — ops visibility only. + $this->logger->warning('XP speed bonus denied by plausibility guard', [ + 'solvingTimeId' => $solve['id'], + 'playerId' => $participantId, + 'piecesCount' => $solve['pieces'], + 'secondsToSolve' => $seconds, + ]); + + return SpeedPercentile::None; + } + + return $this->getPuzzleSpeedPercentiles + ->forPuzzle($solve['puzzle_id']) + ->percentileFor($seconds); + } + + /** + * @phpstan-return SolveRow|null + */ + private function loadSolve(string $solvingTimeId): null|array + { + /** @phpstan-var SolveRow|false $row */ + $row = $this->database + ->executeQuery(self::SOLVE_SELECT . ' WHERE pst.id = :id', ['id' => $solvingTimeId]) + ->fetchAssociative(); + + return $row === false ? null : $row; + } + + /** + * @phpstan-return list + */ + private function loadPairSolves(string $playerId, string $puzzleId): array + { + $sql = self::SOLVE_SELECT . << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +ORDER BY COALESCE(pst.finished_at, pst.tracked_at), pst.id +SQL; + + /** @phpstan-var list $rows */ + $rows = $this->database + ->executeQuery($sql, ['playerId' => $playerId, 'puzzleId' => $puzzleId]) + ->fetchAllAssociative(); + + return $rows; + } + + /** + * Registered participants: the owner plus every team member with a player id. + * + * @phpstan-param SolveRow $solve + * @return list + */ + private function participantsOf(array $solve): array + { + $participants = [$solve['player_id']]; + + if ($solve['team'] !== null) { + try { + $team = Json::decode($solve['team'], forceArrays: true); + } catch (JsonException) { + $team = null; + } + + if (is_array($team) && isset($team['puzzlers']) && is_array($team['puzzlers'])) { + foreach ($team['puzzlers'] as $puzzler) { + if (is_array($puzzler) && isset($puzzler['player_id']) && is_string($puzzler['player_id'])) { + $participants[] = $puzzler['player_id']; + } + } + } + } + + return array_values(array_unique($participants)); + } + + /** + * @return list + */ + private function playersWithEntriesFor(string $solvingTimeId): array + { + /** @var list $ids */ + $ids = $this->database + ->executeQuery( + 'SELECT DISTINCT player_id FROM xp_entry WHERE solving_time_id = :solvingTimeId', + ['solvingTimeId' => $solvingTimeId], + ) + ->fetchFirstColumn(); + + return $ids; + } + + private function hasAnyEntry(string $playerId, string $solvingTimeId): bool + { + $value = $this->database->fetchOne( + 'SELECT 1 FROM xp_entry WHERE player_id = :playerId AND solving_time_id = :solvingTimeId LIMIT 1', + ['playerId' => $playerId, 'solvingTimeId' => $solvingTimeId], + ); + + return $value !== false; + } + + /** + * 1-based canonical position of this solve among ALL of the participant's solves + * of the puzzle (both modes). + */ + private function occurrenceIndex(string $playerId, string $puzzleId, DateTimeImmutable $earnedAt, string $solvingTimeId): int + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) + AND (COALESCE(pst.finished_at, pst.tracked_at), pst.id) < (CAST(:earnedAt AS TIMESTAMP), CAST(:solvingTimeId AS UUID)) +SQL; + + $value = $this->database->executeQuery($sql, [ + 'playerId' => $playerId, + 'puzzleId' => $puzzleId, + 'earnedAt' => $earnedAt->format('Y-m-d H:i:s'), + 'solvingTimeId' => $solvingTimeId, + ])->fetchOne(); + + return (is_numeric($value) ? (int) $value : 0) + 1; + } + + /** + * XP-earning solves (= solve_base entries of still-existing solves) canonically before + * this solve within its ISO week. + */ + private function countWeekSolvesBefore(string $playerId, DateTimeImmutable $earnedAt, string $solvingTimeId): int + { + $weekStart = $earnedAt + ->setISODate((int) $earnedAt->format('o'), (int) $earnedAt->format('W')) + ->setTime(0, 0); + + $sql = <<= CAST(:windowStart AS TIMESTAMP) + AND e.earned_at < CAST(:windowEnd AS TIMESTAMP) + AND (e.earned_at, e.solving_time_id) < (CAST(:earnedAt AS TIMESTAMP), CAST(:solvingTimeId AS UUID)) + AND EXISTS (SELECT 1 FROM puzzle_solving_time pst WHERE pst.id = e.solving_time_id) +SQL; + + $value = $this->database->executeQuery($sql, [ + 'playerId' => $playerId, + 'reason' => XpReason::SolveBase->value, + 'windowStart' => $weekStart->format('Y-m-d H:i:s'), + 'windowEnd' => $weekStart->modify('+7 days')->format('Y-m-d H:i:s'), + 'earnedAt' => $earnedAt->format('Y-m-d H:i:s'), + 'solvingTimeId' => $solvingTimeId, + ])->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } + + private function hasDaySolveBefore(string $playerId, DateTimeImmutable $earnedAt, string $solvingTimeId): bool + { + $dayStart = $earnedAt->setTime(0, 0); + + $sql = <<= CAST(:windowStart AS TIMESTAMP) + AND e.earned_at < CAST(:windowEnd AS TIMESTAMP) + AND (e.earned_at, e.solving_time_id) < (CAST(:earnedAt AS TIMESTAMP), CAST(:solvingTimeId AS UUID)) + AND EXISTS (SELECT 1 FROM puzzle_solving_time pst WHERE pst.id = e.solving_time_id) +LIMIT 1 +SQL; + + $value = $this->database->executeQuery($sql, [ + 'playerId' => $playerId, + 'reason' => XpReason::SolveBase->value, + 'windowStart' => $dayStart->format('Y-m-d H:i:s'), + 'windowEnd' => $dayStart->modify('+1 day')->format('Y-m-d H:i:s'), + 'earnedAt' => $earnedAt->format('Y-m-d H:i:s'), + 'solvingTimeId' => $solvingTimeId, + ])->fetchOne(); + + return $value !== false; + } +} diff --git a/src/Services/Xp/XpFeatureGate.php b/src/Services/Xp/XpFeatureGate.php new file mode 100644 index 000000000..87539d5ab --- /dev/null +++ b/src/Services/Xp/XpFeatureGate.php @@ -0,0 +1,46 @@ +adminOnly === false) { + return true; + } + + return $viewer?->isAdmin === true; + } + + /** + * Outbound email traffic belonging to this feature (achievement congratulations, weekly + * content digest, reveal emails) is suppressed entirely while the flag is active — + * for everyone, admins included. + */ + public function isEmailSendingEnabled(): bool + { + return $this->adminOnly === false; + } +} diff --git a/src/Services/Xp/XpLedger.php b/src/Services/Xp/XpLedger.php new file mode 100644 index 000000000..ea2d78848 --- /dev/null +++ b/src/Services/Xp/XpLedger.php @@ -0,0 +1,72 @@ + $drafts + */ + public function append(Player $player, array $drafts): XpLevelChange + { + $now = $this->clock->now(); + $delta = 0; + + foreach ($drafts as $draft) { + $this->xpEntryRepository->save(new XpEntry( + id: Uuid::uuid7(), + playerId: $player->id, + amount: $draft->amount, + reason: $draft->reason, + inWeeklyDelta: $draft->inWeeklyDelta, + earnedAt: $draft->earnedAt, + createdAt: $now, + solvingTimeId: $draft->solvingTimeId, + badgeId: $draft->badgeId, + )); + + $delta += $draft->amount; + } + + $previousXpTotal = $player->xpTotal; + $previousLevel = $player->level; + + $newXpTotal = $previousXpTotal + $delta; + $newLevel = LevelTable::levelForXp($newXpTotal); + + if ($delta !== 0) { + $player->updateExperience($newXpTotal, $newLevel); + } + + return new XpLevelChange( + previousXpTotal: $previousXpTotal, + newXpTotal: $newXpTotal, + previousLevel: $previousLevel, + newLevel: $newLevel, + ); + } +} diff --git a/src/Services/Xp/XpRecomputer.php b/src/Services/Xp/XpRecomputer.php new file mode 100644 index 000000000..aa44c1f00 --- /dev/null +++ b/src/Services/Xp/XpRecomputer.php @@ -0,0 +1,201 @@ +playerRepository->get($playerId); + } catch (PlayerNotFound) { + return; + } + + // Wipe everything solve-derived (incl. settlements and compensations); the + // doctrine_transaction middleware makes wipe + rebuild atomic. + $this->database->executeStatement( + 'DELETE FROM xp_entry WHERE player_id = :playerId AND reason != :achievement', + ['playerId' => $playerId, 'achievement' => XpReason::Achievement->value], + ); + + $solves = $this->loadSolveHistory($playerId); + + $puzzleIds = array_values(array_unique(array_column($solves, 'puzzle_id'))); + $percentilesByPuzzle = $this->getPuzzleSpeedPercentiles->forPuzzles($puzzleIds); + + $cutoff = XpCalculator::fullFormulaFrom(); + $drafts = []; + + /** @var array $occurrenceByPuzzle */ + $occurrenceByPuzzle = []; + /** @var array $weeklyCounts */ + $weeklyCounts = []; + /** @var array $daysWithXp */ + $daysWithXp = []; + + foreach ($solves as $solve) { + $occurrence = ($occurrenceByPuzzle[$solve['puzzle_id']] ?? 0) + 1; + $occurrenceByPuzzle[$solve['puzzle_id']] = $occurrence; + + $earnedAt = new DateTimeImmutable($solve['earned_at']); + $isBackfill = new DateTimeImmutable($solve['tracked_at']) < $cutoff; + $isTimed = $solve['seconds_to_solve'] !== null; + $isTeamOrDuo = $solve['puzzling_type'] !== 'solo'; + $weekKey = $earnedAt->format('o-\WW'); + $dayKey = $earnedAt->format('Y-m-d'); + + $speedPercentile = SpeedPercentile::None; + + if ($isBackfill === false && $isTeamOrDuo === false && $solve['seconds_to_solve'] !== null) { + $speedPercentile = $this->resolveSpeedPercentile( + percentiles: $percentilesByPuzzle[$solve['puzzle_id']] ?? PuzzleSpeedPercentiles::empty(), + piecesCount: $solve['pieces'], + secondsToSolve: $solve['seconds_to_solve'], + solvingTimeId: $solve['id'], + playerId: $playerId, + ); + } + + $awards = $this->xpCalculator->calculate(new SolveXpContext( + piecesCount: $solve['pieces'], + difficultyTier: $solve['difficulty_tier'], + isTimed: $isTimed, + isTeamOrDuo: $isTeamOrDuo, + unboxed: $solve['unboxed'], + occurrenceIndex: $occurrence, + isBackfill: $isBackfill, + speedPercentile: $speedPercentile, + xpEarningSolvesThisWeek: $weeklyCounts[$weekKey] ?? 0, + isFirstXpEarningSolveOfDay: isset($daysWithXp[$dayKey]) === false, + )); + + if ($awards === []) { + continue; + } + + $weeklyCounts[$weekKey] = ($weeklyCounts[$weekKey] ?? 0) + 1; + $daysWithXp[$dayKey] = true; + + foreach ($awards as $award) { + $drafts[] = new XpEntryDraft( + reason: $award->reason, + amount: $award->amount, + earnedAt: $earnedAt, + inWeeklyDelta: true, + solvingTimeId: Uuid::fromString($solve['id']), + ); + } + } + + // Reset totals to the preserved achievement entries, then let the ledger append + // the rebuilt solve entries on top — xpTotal ends as SUM(all entries) again. + $achievementTotal = $this->preservedAchievementTotal($playerId); + $player->updateExperience($achievementTotal, LevelTable::levelForXp($achievementTotal)); + + $this->xpLedger->append($player, $drafts); + } + + /** + * @return list + */ + private function loadSolveHistory(string $playerId): array + { + $sql = << 'puzzlers') @> jsonb_build_array(jsonb_build_object('player_id', CAST(:playerId AS UUID))) + ) +ORDER BY COALESCE(pst.finished_at, pst.tracked_at), pst.id +SQL; + + /** @var list $rows */ + $rows = $this->database->executeQuery($sql, ['playerId' => $playerId])->fetchAllAssociative(); + + return $rows; + } + + private function resolveSpeedPercentile( + PuzzleSpeedPercentiles $percentiles, + int $piecesCount, + int $secondsToSolve, + string $solvingTimeId, + string $playerId, + ): SpeedPercentile { + if (XpCalculator::isImplausiblyFast($piecesCount, $secondsToSolve)) { + // Silent guard: no speed bonus, no user-facing accusation — just ops visibility. + $this->logger->warning('XP speed bonus denied by plausibility guard', [ + 'solvingTimeId' => $solvingTimeId, + 'playerId' => $playerId, + 'piecesCount' => $piecesCount, + 'secondsToSolve' => $secondsToSolve, + ]); + + return SpeedPercentile::None; + } + + return $percentiles->percentileFor($secondsToSolve); + } + + private function preservedAchievementTotal(string $playerId): int + { + $value = $this->database + ->executeQuery( + 'SELECT COALESCE(SUM(amount), 0) FROM xp_entry WHERE player_id = :playerId', + ['playerId' => $playerId], + ) + ->fetchOne(); + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Value/BadgeTier.php b/src/Value/BadgeTier.php new file mode 100644 index 000000000..7aa0992bf --- /dev/null +++ b/src/Value/BadgeTier.php @@ -0,0 +1,56 @@ + 5, + self::Silver => 10, + self::Gold => 25, + self::Platinum => 50, + self::Diamond => 100, + }; + } + + public function romanNumeral(): string + { + return match ($this) { + self::Bronze => '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 4f45d16fd..847ff8554 100644 --- a/src/Value/BadgeType.php +++ b/src/Value/BadgeType.php @@ -7,4 +7,30 @@ 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'; + case ZenPuzzler = 'zen_puzzler'; + case FirstTry = 'first_try'; + case Unboxed = 'unboxed'; + case BrandExplorer = 'brand_explorer'; + case Marathoner = 'marathoner'; + case Photographer = 'photographer'; + case SteadyHands = 'steady_hands'; + case Librarian = 'librarian'; + case SpeedDemon1000 = 'speed_1000_pieces'; + case WeekendPuzzler = 'weekend_puzzler'; + case Cataloger = 'cataloger'; + + public function isTiered(): bool + { + return $this !== self::Supporter; + } + + public function translationKey(): string + { + return 'badges.badge.' . $this->value; + } } diff --git a/src/Value/ContentDigestFrequency.php b/src/Value/ContentDigestFrequency.php new file mode 100644 index 000000000..e9f6f6c67 --- /dev/null +++ b/src/Value/ContentDigestFrequency.php @@ -0,0 +1,17 @@ +setISODate((int) $moment->format('o'), (int) $moment->format('W')) + ->setTime(0, 0); + + return new self( + key: $moment->format('o-\WW'), + weekStart: $weekStart, + weekEnd: $weekStart->modify('+7 days'), + ); + } + + public static function fromKey(string $periodKey): self + { + if (preg_match('/^(\d{4})-W(\d{2})$/', $periodKey, $matches) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid weekly period key "%s".', $periodKey)); + } + + $weekStart = (new DateTimeImmutable('now')) + ->setISODate((int) $matches[1], (int) $matches[2]) + ->setTime(0, 0); + + return new self( + key: $periodKey, + weekStart: $weekStart, + weekEnd: $weekStart->modify('+7 days'), + ); + } + + /** + * The digest for a PAST week goes out right after the week ends; once the TTL passes, + * a delayed message is silently dropped instead of delivering ancient news. + */ + public function isStaleAt(DateTimeImmutable $now): bool + { + return $now > $this->weekEnd->modify(self::STALE_AFTER_PERIOD_END); + } +} diff --git a/src/Value/HintType.php b/src/Value/HintType.php index ef54d9fd0..16579db45 100644 --- a/src/Value/HintType.php +++ b/src/Value/HintType.php @@ -9,4 +9,5 @@ enum HintType: string case MarketplaceDisclaimer = 'marketplace_disclaimer'; case MarketplaceSettingsChecklist = 'marketplace_settings_checklist'; case FeatureRequestsIntro = 'feature_requests_intro'; + case XpLaunchReveal = 'xp_launch_reveal'; } diff --git a/src/Value/LevelTable.php b/src/Value/LevelTable.php new file mode 100644 index 000000000..5338efd21 --- /dev/null +++ b/src/Value/LevelTable.php @@ -0,0 +1,119 @@ + + */ + private const array CUMULATIVE_XP = [ + 1 => 0, + 2 => 5, + 3 => 10, + 4 => 16, + 5 => 22, + 6 => 29, + 7 => 37, + 8 => 45, + 9 => 54, + 10 => 64, + 11 => 75, + 12 => 87, + 13 => 100, + 14 => 114, + 15 => 129, + 16 => 145, + 17 => 162, + 18 => 181, + 19 => 201, + 20 => 223, + 21 => 247, + 22 => 273, + 23 => 301, + 24 => 331, + 25 => 363, + 26 => 399, + 27 => 438, + 28 => 480, + 29 => 526, + 30 => 576, + 31 => 630, + 32 => 689, + 33 => 753, + 34 => 822, + 35 => 897, + 36 => 978, + 37 => 1066, + 38 => 1161, + 39 => 1264, + 40 => 1376, + 41 => 1497, + 42 => 1628, + 43 => 1770, + 44 => 1924, + 45 => 2091, + 46 => 2272, + 47 => 2468, + 48 => 2680, + 49 => 2910, + 50 => 3160, + ]; + + public static function levelForXp(int $xp): int + { + $level = 1; + + foreach (self::CUMULATIVE_XP as $candidate => $threshold) { + if ($xp < $threshold) { + break; + } + + $level = $candidate; + } + + return $level; + } + + /** + * Cumulative XP required to reach the given level. + */ + public static function xpForLevel(int $level): int + { + if (isset(self::CUMULATIVE_XP[$level]) === false) { + throw new InvalidArgumentException(sprintf('Level must be between 1 and %d, got %d.', self::MAX_LEVEL, $level)); + } + + return self::CUMULATIVE_XP[$level]; + } + + /** + * Fraction (0.0–1.0) of the way from the current level to the next one. + * Null at max level — there is nothing to progress toward. + */ + public static function progressToNext(int $xp): null|float + { + $level = self::levelForXp($xp); + + if ($level >= self::MAX_LEVEL) { + return null; + } + + $currentThreshold = self::CUMULATIVE_XP[$level]; + $nextThreshold = self::CUMULATIVE_XP[$level + 1]; + + return ($xp - $currentThreshold) / ($nextThreshold - $currentThreshold); + } +} diff --git a/src/Value/SolveXpContext.php b/src/Value/SolveXpContext.php new file mode 100644 index 000000000..7584d7bba --- /dev/null +++ b/src/Value/SolveXpContext.php @@ -0,0 +1,39 @@ + 0.0, + self::AboveMedian => 0.05, + self::Top25 => 0.10, + self::Top10 => 0.15, + }; + } +} diff --git a/src/Value/XpAward.php b/src/Value/XpAward.php new file mode 100644 index 000000000..b99c64985 --- /dev/null +++ b/src/Value/XpAward.php @@ -0,0 +1,17 @@ +newLevel > $this->previousLevel; + } + + public function reachedMaxLevel(): bool + { + return $this->leveledUp() && $this->newLevel === LevelTable::MAX_LEVEL; + } +} diff --git a/src/Value/XpReason.php b/src/Value/XpReason.php new file mode 100644 index 000000000..70abbfbe1 --- /dev/null +++ b/src/Value/XpReason.php @@ -0,0 +1,38 @@ +value; + } + + /** + * Entries derived from a solve — wiped and recreated by the deterministic recompute. + * Achievement entries are the only kind preserved across recomputes (never revoked). + */ + public function isSolveDerived(): bool + { + return $this !== self::Achievement; + } +} diff --git a/templates/achievement_detail.html.twig b/templates/achievement_detail.html.twig new file mode 100644 index 000000000..62f211923 --- /dev/null +++ b/templates/achievement_detail.html.twig @@ -0,0 +1,116 @@ +{% extends 'base.html.twig' %} + +{% set type_label = (type.translationKey())|trans %} + +{% block title %}{{ 'badges.detail.meta_title'|trans({'%name%': type_label}) }}{% endblock %} + +{% block content %} + + +
+

+ + {{ type_label }} +

+

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

+
+ + {% if countries|length > 0 %} +
+
+ + +
+
+ {% endif %} + + {% if newest_earners|length > 0 and selected_country is null %} +
+

{{ 'badges.detail.newest_earners'|trans }}

+ +
+ {% endif %} + + {% for section in tier_sections %} +
+
+
+
+ {{ section.tier.romanNumeral() }} +
+
+

{{ (section.tier.translationKey())|trans }} · +{{ section.tier.points }} AP

+ {{ ('badges.requirement.' ~ type.value ~ '_' ~ section.tier.value)|trans }} +
+ + {{ 'badges.detail.holders_count'|trans({'%count%': section.totalCount}) }} + +
+ + {% if section.holders|length > 0 %} +
+ {% for holder in section.holders %} + + {% if loop.first %} + 🏆 + {% endif %} + {% if holder.avatar is not null %} + + {% endif %} + {{ holder.playerName ?? ('#' ~ holder.playerCode|upper) }} + {% if holder.countryCode is not null %} + + {% endif %} + {{ holder.earnedAt|format_date('short') }} + + {% endfor %} + + {% if section.hiddenCount > 0 %} + + {{ 'badges.detail.more_puzzlers'|trans({'%count%': section.hiddenCount}) }} + + {% endif %} +
+ {% elseif section.totalCount > 0 %} +

+ {{ 'badges.detail.only_hidden_holders'|trans({'%count%': section.totalCount}) }} +

+ {% else %} +

+ {{ 'badges.detail.nobody_yet'|trans }} +

+ {% endif %} +
+
+ {% endfor %} + +

+ + {{ 'badges.detail.listing_note'|trans }} +

+{% endblock %} diff --git a/templates/added_time_recap.html.twig b/templates/added_time_recap.html.twig index 6b2a09895..844b6504f 100644 --- a/templates/added_time_recap.html.twig +++ b/templates/added_time_recap.html.twig @@ -21,6 +21,8 @@
+ + {% if is_solo and solved_puzzle.time is not null %} {% if not player.rankingOptedOut %} {{ include('added_time_recap/_ranking.html.twig') }} diff --git a/templates/added_tracking_recap.html.twig b/templates/added_tracking_recap.html.twig index 2f9868dfe..cf70003fb 100644 --- a/templates/added_tracking_recap.html.twig +++ b/templates/added_tracking_recap.html.twig @@ -14,6 +14,8 @@
+ +
{{ 'added_tracking_recap.add_another'|trans }} diff --git a/templates/badge_reveals.html.twig b/templates/badge_reveals.html.twig new file mode 100644 index 000000000..6613cf3d5 --- /dev/null +++ b/templates/badge_reveals.html.twig @@ -0,0 +1,59 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'badges.reveals.meta_title'|trans }}{% endblock %} + +{% block content %} +

{{ 'badges.reveals.title'|trans }}

+ + {% if badges|length > 0 %} +

{{ 'badges.reveals.intro'|trans({'%count%': 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 %} + +
+ + +
+ {{ label }} +
+ {% if tier %} +
{{ (tier.translationKey())|trans }}
+ {% endif %} +
+ {% endfor %} +
+ +

{{ 'badges.reveals.hint'|trans }}

+ {% else %} +
+ + {{ 'badges.reveals.all_done'|trans }} +
+ {% endif %} + + + {{ 'badges.reveals.back_to_profile'|trans }} + +{% endblock %} diff --git a/templates/badges_overview.html.twig b/templates/badges_overview.html.twig new file mode 100644 index 000000000..8d5559b83 --- /dev/null +++ b/templates/badges_overview.html.twig @@ -0,0 +1,123 @@ +{% 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 ap_total is not null %} +

+ + + {{ 'badges.ap_total'|trans({'%points%': ap_total}) }} + +

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

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

+ {% elseif not is_member %} +

+ + {{ 'badges.free_user_hint'|trans }} + {{ 'badges.unlock_with_membership'|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 %} + {% set earned_but_locked = entry.earned and logged_in and not is_member %} +
+
+
+ {{ entry.tier.romanNumeral() }} +
+ {% if earned_but_locked %} + + {% endif %} +
+
+ {{ (entry.tier.translationKey())|trans }} +
+
+{{ entry.tier.points }} AP
+
+ {% 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 %} + + + {{ 'badges.view_holders'|trans }} + +
+
+
+ {% endfor %} +
+ +

+ + {{ 'badges.how_it_works_hint'|trans }} + {{ 'badges.how_it_works_link'|trans }} +

+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 3c28f7668..2b3f7979e 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -353,9 +353,11 @@ {% else %} diff --git a/templates/components/XpRevealInvite.html.twig b/templates/components/XpRevealInvite.html.twig new file mode 100644 index 000000000..bb5d31170 --- /dev/null +++ b/templates/components/XpRevealInvite.html.twig @@ -0,0 +1,11 @@ +{% if unrevealedCount > 0 %} +
+ +
+ {{ 'badges.reveals.invite'|trans({'%count%': unrevealedCount}) }} +
+ + {{ 'badges.reveals.invite_button'|trans }} + +
+{% endif %} diff --git a/templates/components/XpRing.html.twig b/templates/components/XpRing.html.twig new file mode 100644 index 000000000..55df586aa --- /dev/null +++ b/templates/components/XpRing.html.twig @@ -0,0 +1,33 @@ +{% set inner %}{% block content %}{% endblock %}{% endset %} + +{% if this.visible %} + {% if ambient %} + {{ inner }} + {% else %} +
+ {{ inner }} + +
+
+ + {{ 'xp.level_chip'|trans({'%level%': this.profile.level}) }} + + {{ 'xp.total'|trans({'%xp%': this.profile.xpTotal}) }} +
+ + {% if showProgress %} + {% if this.profile.isMaxLevel %} + {{ 'xp.max_level_reached'|trans }} + {% else %} +
+
+
+ {{ 'xp.progress_label'|trans({'%xp%': this.profile.xpToNextLevel, '%level%': this.profile.level + 1}) }} + {% endif %} + {% endif %} +
+
+ {% endif %} +{% else %} + {{ inner }} +{% endif %} diff --git a/templates/components/XpSolveReceipt.html.twig b/templates/components/XpSolveReceipt.html.twig new file mode 100644 index 000000000..d82e8dab9 --- /dev/null +++ b/templates/components/XpSolveReceipt.html.twig @@ -0,0 +1,106 @@ +{% set mode = this.mode %} + +{% if mode != 'hidden' %} +
+
+ {% if mode == 'receipt' %} +

+ + {{ 'xp.receipt.title'|trans }} +

+ +
+ {% for line in lines %} +
+ {{ this.lineLabelKey(line)|trans }} + +{{ line.amount }} XP +
+ {% endfor %} + + {% if this.showDifficultyPending %} +
+ {{ 'xp.line.difficulty_pending'|trans }} + +
+ {% endif %} + + {% if this.showSpeedPending %} +
+ {{ 'xp.line.speed_pending'|trans }} + +
+ {% endif %} + +
+ {{ 'xp.receipt.total'|trans }} + +{{ this.total }} XP +
+
+ +
+
+ {{ 'xp.level_chip'|trans({'%level%': profile.level}) }} + {{ 'xp.progress_label'|trans({'%xp%': profile.xpToNextLevel, '%level%': profile.level + 1}) }} +
+
+
+
+
+ + {% if not viewerIsMember and waitingCount > 0 %} +
+ + {{ 'xp.receipt.waiting_teaser'|trans({'%count%': waitingCount}) }} +
+ {% endif %} + {% elseif mode == 'max_level' %} +

+ {{ 'xp.level_chip'|trans({'%level%': profile.level}) }} + {{ 'xp.receipt.max_level_title'|trans }} +

+ + {% if viewerIsMember %} + {% if this.nearestAchievements|length > 0 %} +

{{ 'xp.receipt.nearest_achievements'|trans }}

+ {% for candidate in this.nearestAchievements %} +
+
+ {{ candidate.type.translationKey()|trans }} — {{ candidate.progress.nextTier.translationKey()|trans }} + {{ candidate.progress.percent }}% +
+ +
+ {% endfor %} + + + {{ 'badges.browse_all'|trans }} + + {% else %} +

{{ 'xp.receipt.max_level_all_done'|trans }}

+ {% endif %} + {% else %} +
+ + {{ 'xp.receipt.waiting_teaser'|trans({'%count%': waitingCount}) }} +
+ + + {{ 'badges.unlock_with_membership'|trans }} + + {% endif %} + {% elseif mode == 'relax_repeat' %} +

+ + {{ 'xp.receipt.relax_repeat'|trans }} +

+ {% else %} +

+
+ {{ 'xp.receipt.counting'|trans }} +

+ {% endif %} +
+
+{% endif %} diff --git a/templates/edit-profile.html.twig b/templates/edit-profile.html.twig index 31742cdcd..3c0d30cf7 100644 --- a/templates/edit-profile.html.twig +++ b/templates/edit-profile.html.twig @@ -29,6 +29,9 @@ {{ form_row(features_options_form.streakOptedOut) }} {{ form_row(features_options_form.rankingOptedOut) }} {{ form_row(features_options_form.timePredictionsOptedOut) }} + {% if features_options_form.experienceSystemOptedOut is defined %} + {{ form_row(features_options_form.experienceSystemOptedOut) }} + {% endif %}

@@ -50,6 +53,9 @@

{{ form_row(messaging_settings_form.newsletterEnabled) }} + {% if messaging_settings_form.contentDigestFrequency is defined %} + {{ form_row(messaging_settings_form.contentDigestFrequency) }} + {% endif %}

diff --git a/templates/edit-time.html.twig b/templates/edit-time.html.twig index a45ff36f6..c55e2d76c 100644 --- a/templates/edit-time.html.twig +++ b/templates/edit-time.html.twig @@ -59,6 +59,12 @@

+{% endblock %} diff --git a/templates/xp_explainer.html.twig b/templates/xp_explainer.html.twig new file mode 100644 index 000000000..977fbcaf1 --- /dev/null +++ b/templates/xp_explainer.html.twig @@ -0,0 +1,140 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'xp.explainer.title'|trans }}{% endblock %} + +{% block content %} + +
+

{{ 'xp.explainer.title'|trans }}

+

{{ 'xp.explainer.intro'|trans }}

+ +
+
+

{{ 'xp.explainer.currencies.heading'|trans }}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'xp.explainer.currencies.header_what'|trans }}{{ 'xp.explainer.currencies.header_measures'|trans }}{{ 'xp.explainer.currencies.header_who'|trans }}
{{ 'xp.explainer.currencies.xp_name'|trans }}{{ 'xp.explainer.currencies.xp_measures'|trans }}{{ 'xp.explainer.currencies.xp_who'|trans }}
{{ 'xp.explainer.currencies.ap_name'|trans }}{{ 'xp.explainer.currencies.ap_measures'|trans }}{{ 'xp.explainer.currencies.ap_who'|trans }}
{{ 'xp.explainer.currencies.rating_name'|trans }}{{ 'xp.explainer.currencies.rating_measures'|trans }}{{ 'xp.explainer.currencies.rating_who'|trans }}
+
+

{{ 'xp.explainer.currencies.never_bought'|trans }}

+

{{ 'xp.explainer.currencies.unlock_nothing'|trans }}

+
+
+ +
+
+

{{ 'xp.explainer.how.heading'|trans }}

+

{{ 'xp.explainer.how.intro'|trans }}

+
    +
  • {{ 'xp.explainer.how.base'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.difficulty'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.team'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.unboxed'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.repeat'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.speed'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.weekly'|trans|raw }}
  • +
  • {{ 'xp.explainer.how.daily'|trans|raw }}
  • +
+
+
+ +
+
+

{{ 'xp.explainer.example.heading'|trans }}

+

{{ 'xp.explainer.example.body'|trans }}

+

{{ 'xp.explainer.example.math'|trans|raw }}

+
+
+ +
+
+

{{ 'xp.explainer.unrated.heading'|trans }}

+

{{ 'xp.explainer.unrated.body'|trans }}

+
+
+ +
+
+

{{ 'xp.explainer.achievements.heading'|trans }}

+

{{ 'xp.explainer.achievements.body'|trans }}

+
+
+ +
+
+

{{ 'xp.explainer.levels.heading'|trans }}

+
+ {% for column in levels|batch(10) %} +
+ + + + + + + + + {% for level, xp in column %} + + + + + {% endfor %} + +
{{ 'xp.explainer.levels.header_level'|trans }}{{ 'xp.explainer.levels.header_xp'|trans }}
{{ level }}{{ xp }}
+
+ {% endfor %} +
+

{{ 'xp.explainer.levels.summit'|trans }}

+
+
+ +
+
+

{{ 'xp.explainer.lose.heading'|trans }}

+

{{ 'xp.explainer.lose.body'|trans }}

+
+
+ +
+
+

{{ 'xp.explainer.faq.heading'|trans }}

+ +

{{ 'xp.explainer.faq.old_solves_question'|trans }}

+

{{ 'xp.explainer.faq.old_solves_answer'|trans }}

+ +

{{ 'xp.explainer.faq.opt_out_question'|trans }}

+

{{ 'xp.explainer.faq.opt_out_answer'|trans }}

+ +

{{ 'xp.explainer.faq.friend_question'|trans }}

+

{{ 'xp.explainer.faq.friend_answer'|trans }}

+ +

{{ 'xp.explainer.faq.fair_play_question'|trans }}

+

{{ 'xp.explainer.faq.fair_play_answer'|trans({'%fair_play_link%': path('xp_fair_play')})|raw }}

+
+
+
+{% endblock %} diff --git a/templates/xp_fair_play.html.twig b/templates/xp_fair_play.html.twig new file mode 100644 index 000000000..9980fc1b9 --- /dev/null +++ b/templates/xp_fair_play.html.twig @@ -0,0 +1,27 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'xp.fair_play.title'|trans }}{% endblock %} + +{% block content %} + +
+

{{ 'xp.fair_play.title'|trans }}

+

{{ 'xp.fair_play.intro'|trans }}

+ +
+
+
    +
  • {{ 'xp.fair_play.principle_no_buying'|trans }}
  • +
  • {{ 'xp.fair_play.principle_repeats'|trans }}
  • +
  • {{ 'xp.fair_play.principle_speed'|trans }}
  • +
  • {{ 'xp.fair_play.principle_deleting'|trans }}
  • +
  • {{ 'xp.fair_play.principle_caps'|trans }}
  • +
+
+
+ +

{{ 'xp.fair_play.closing_thresholds'|trans }}

+

{{ 'xp.fair_play.closing_celebrate'|trans }}

+

{{ 'xp.fair_play.explainer_link'|trans({'%explainer_link%': path('xp_explainer')})|raw }}

+
+{% endblock %} diff --git a/templates/xp_history.html.twig b/templates/xp_history.html.twig new file mode 100644 index 000000000..8bc49fdf2 --- /dev/null +++ b/templates/xp_history.html.twig @@ -0,0 +1,79 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'xp.history.meta_title'|trans }}{% endblock %} + +{% block content %} +
+

+ + {{ 'xp.history.title'|trans }} +

+

{{ 'xp.history.subtitle'|trans }}

+ + {% if not xp_profile.optedOut %} +
+ {{ 'xp.level_chip'|trans({'%level%': xp_profile.level}) }} + {{ 'xp.total'|trans({'%xp%': xp_profile.xpTotal}) }} +
+ {% endif %} +
+ + {% if entries|length == 0 %} +
+ + {{ 'xp.history.empty'|trans }} +
+ {% else %} +
+ + + + + + + + + + {% for entry in entries %} + + + + + + {% endfor %} + +
{{ 'xp.history.when'|trans }}{{ 'xp.history.reason'|trans }}XP
{{ entry.earnedAt|format_date('short') }} + {{ ('xp.line.' ~ entry.reason.value)|trans }} + {% if entry.puzzleId is not null %} + — {{ entry.puzzleName }} + {% elseif entry.badgeType is not null %} + — {{ entry.badgeType.translationKey()|trans }}{% if entry.badgeTier is not null %} ({{ entry.badgeTier.translationKey()|trans }}){% endif %} + {% elseif entry.solvingTimeId is not null and entry.reason.value != 'solve_compensation' %} + — {{ 'xp.history.deleted_solve'|trans }} + {% endif %} + + {{ entry.amount > 0 ? '+' }}{{ entry.amount }} +
+
+ + {% if last_page > 1 %} + + {% endif %} + {% endif %} + +

+ + {{ 'xp.history.note'|trans }} + {{ 'badges.how_it_works_link'|trans }} +

+{% endblock %} diff --git a/templates/xp_launch_reveal.html.twig b/templates/xp_launch_reveal.html.twig new file mode 100644 index 000000000..3feff6af8 --- /dev/null +++ b/templates/xp_launch_reveal.html.twig @@ -0,0 +1,77 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'xp.reveal.meta_title'|trans }}{% endblock %} + +{% block content %} +
+ + +

{{ 'xp.reveal.title'|trans }}

+

{{ 'xp.reveal.intro'|trans }}

+ +
+ + + {{ 'xp.reveal.level_label'|trans }} + {{ xp_profile.level }} + + + +
+ {{ xp_profile.xpTotal|number_format(0, '.', ' ') }} + XP +
+

{{ 'xp.reveal.xp_note'|trans }}

+
+ + {% if badges_count > 0 %} +
+ + + {% if is_member %} + {{ 'xp.reveal.achievements_member'|trans({'%count%': badges_count}) }} + {% else %} + {{ 'badges.waiting_teaser'|trans({'%count%': badges_count}) }} + {% endif %} + +
+ {% endif %} + +
+ + + +
+ + {% if is_member %} +

+ {{ 'xp.reveal.to_badge_reveals'|trans }} +

+ {% endif %} +
+{% endblock %} diff --git a/templates/xp_leaderboard.html.twig b/templates/xp_leaderboard.html.twig new file mode 100644 index 000000000..d1e35f7a1 --- /dev/null +++ b/templates/xp_leaderboard.html.twig @@ -0,0 +1,126 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'xp.leaderboard.meta_title'|trans }}{% endblock %} + +{% block content %} +
+

+ + {{ 'xp.leaderboard.title'|trans }} +

+

+ {{ 'xp.leaderboard.subtitle'|trans }} + {{ 'badges.how_it_works_link'|trans }} +

+
+ + + +
+ + +
+ + +
+ + {% if viewer is not null %} +
+ + +
+ {% endif %} +
+ + {% if tab == 'achievement-points' and viewer is null %} +
+ + {{ 'xp.leaderboard.ap_login_required'|trans }} +
+ {% elseif rows|length == 0 %} +
+ + {{ 'xp.leaderboard.empty'|trans }} +
+ {% else %} + {% if self_rank is not null %} +
+
+ #{{ self_rank.rank }} + {{ 'xp.leaderboard.your_position'|trans }} + + {{ tab == 'achievement-points' + ? 'xp.leaderboard.ap_value'|trans({'%points%': self_rank.value}) + : 'xp.leaderboard.xp_value'|trans({'%xp%': self_rank.value}) }} + +
+
+ {% endif %} + +
+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
#{{ 'xp.leaderboard.player'|trans }} + {{ ('xp.leaderboard.value.' ~ tab)|trans }} +
+ {% if row.rank == 1 %}🥇{% elseif row.rank == 2 %}🥈{% elseif row.rank == 3 %}🥉{% else %}{{ row.rank }}{% endif %} + + + {% if row.avatar is not null %} + + {% endif %} + {{ row.playerName ?? ('#' ~ row.playerCode|upper) }} + {% if row.countryCode is not null %} + + {% endif %} + {% if row.isMaxLevel and row.achievementPoints is not null %} + {{ 'xp.leaderboard.lv50_ap'|trans({'%points%': row.achievementPoints}) }} + {% else %} + {{ 'xp.level_chip'|trans({'%level%': row.level}) }} + {% endif %} + + + {{ tab == 'achievement-points' + ? 'xp.leaderboard.ap_value'|trans({'%points%': row.value}) + : 'xp.leaderboard.xp_value'|trans({'%xp%': row.value}) }} +
+
+ {% endif %} + + {% if tab == 'this-week' %} +

+ + {{ 'xp.leaderboard.weekly_note'|trans }} +

+ {% endif %} +{% endblock %} diff --git a/tests/BadgeConditions/BrandExplorerConditionTest.php b/tests/BadgeConditions/BrandExplorerConditionTest.php new file mode 100644 index 000000000..4bd95cd77 --- /dev/null +++ b/tests/BadgeConditions/BrandExplorerConditionTest.php @@ -0,0 +1,92 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new BrandExplorerCondition())->qualifiedTiers($this->snapshot(2))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new BrandExplorerCondition())->qualifiedTiers($this->snapshot(3))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new BrandExplorerCondition())->qualifiedTiers($this->snapshot(30)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove100(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new BrandExplorerCondition())->qualifiedTiers($this->snapshot(100)), + ); + } + + public function testProgressTowardBronzeWithNoBadges(): void + { + $progress = (new BrandExplorerCondition())->progressToNextTier($this->snapshot(2), null); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Bronze, $progress->nextTier); + self::assertSame(2, $progress->currentValue); + self::assertSame(3, $progress->targetValue); + self::assertSame(66, $progress->percent); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new BrandExplorerCondition())->progressToNextTier($this->snapshot(9), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(90, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new BrandExplorerCondition())->progressToNextTier($this->snapshot(120), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new BrandExplorerCondition(); + + self::assertSame(3, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(10, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(100, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $brandExplorerManufacturers): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + brandExplorerManufacturers: $brandExplorerManufacturers, + ); + } +} diff --git a/tests/BadgeConditions/CatalogerConditionTest.php b/tests/BadgeConditions/CatalogerConditionTest.php new file mode 100644 index 000000000..c5cd4f8bd --- /dev/null +++ b/tests/BadgeConditions/CatalogerConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new CatalogerCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new CatalogerCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new CatalogerCondition())->qualifiedTiers($this->snapshot(60)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove300(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new CatalogerCondition())->qualifiedTiers($this->snapshot(300)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new CatalogerCondition())->progressToNextTier($this->snapshot(8), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(8, $progress->currentValue); + self::assertSame(10, $progress->targetValue); + self::assertSame(80, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new CatalogerCondition())->progressToNextTier($this->snapshot(400), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new CatalogerCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(10, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(300, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $catalogerApprovedPuzzles): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + catalogerApprovedPuzzles: $catalogerApprovedPuzzles, + ); + } +} diff --git a/tests/BadgeConditions/FirstTryConditionTest.php b/tests/BadgeConditions/FirstTryConditionTest.php new file mode 100644 index 000000000..fc97d3d0f --- /dev/null +++ b/tests/BadgeConditions/FirstTryConditionTest.php @@ -0,0 +1,92 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new FirstTryCondition())->qualifiedTiers($this->snapshot(4))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new FirstTryCondition())->qualifiedTiers($this->snapshot(5))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new FirstTryCondition())->qualifiedTiers($this->snapshot(250)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove1000(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new FirstTryCondition())->qualifiedTiers($this->snapshot(1000)), + ); + } + + public function testProgressTowardBronzeWithNoBadges(): void + { + $progress = (new FirstTryCondition())->progressToNextTier($this->snapshot(3), null); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Bronze, $progress->nextTier); + self::assertSame(3, $progress->currentValue); + self::assertSame(5, $progress->targetValue); + self::assertSame(60, $progress->percent); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new FirstTryCondition())->progressToNextTier($this->snapshot(45), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(90, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new FirstTryCondition())->progressToNextTier($this->snapshot(1500), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new FirstTryCondition(); + + self::assertSame(5, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(50, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(1000, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $firstTrySolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + firstTrySolves: $firstTrySolves, + ); + } +} diff --git a/tests/BadgeConditions/LibrarianConditionTest.php b/tests/BadgeConditions/LibrarianConditionTest.php new file mode 100644 index 000000000..ee5791a1b --- /dev/null +++ b/tests/BadgeConditions/LibrarianConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new LibrarianCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new LibrarianCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new LibrarianCondition())->qualifiedTiers($this->snapshot(25)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove100(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new LibrarianCondition())->qualifiedTiers($this->snapshot(100)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new LibrarianCondition())->progressToNextTier($this->snapshot(4), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(4, $progress->currentValue); + self::assertSame(5, $progress->targetValue); + self::assertSame(80, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new LibrarianCondition())->progressToNextTier($this->snapshot(120), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new LibrarianCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(5, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(100, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $librarianApprovedRequests): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + librarianApprovedRequests: $librarianApprovedRequests, + ); + } +} diff --git a/tests/BadgeConditions/MarathonerConditionTest.php b/tests/BadgeConditions/MarathonerConditionTest.php new file mode 100644 index 000000000..73c31f166 --- /dev/null +++ b/tests/BadgeConditions/MarathonerConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new MarathonerCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new MarathonerCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new MarathonerCondition())->qualifiedTiers($this->snapshot(20)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove100(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new MarathonerCondition())->qualifiedTiers($this->snapshot(100)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new MarathonerCondition())->progressToNextTier($this->snapshot(4), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(4, $progress->currentValue); + self::assertSame(5, $progress->targetValue); + self::assertSame(80, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new MarathonerCondition())->progressToNextTier($this->snapshot(150), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new MarathonerCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(5, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(100, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $marathonerSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + marathonerSolves: $marathonerSolves, + ); + } +} diff --git a/tests/BadgeConditions/PhotographerConditionTest.php b/tests/BadgeConditions/PhotographerConditionTest.php new file mode 100644 index 000000000..2f010d03b --- /dev/null +++ b/tests/BadgeConditions/PhotographerConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new PhotographerCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new PhotographerCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new PhotographerCondition())->qualifiedTiers($this->snapshot(120)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove1000(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new PhotographerCondition())->qualifiedTiers($this->snapshot(1000)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new PhotographerCondition())->progressToNextTier($this->snapshot(20), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(20, $progress->currentValue); + self::assertSame(25, $progress->targetValue); + self::assertSame(80, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new PhotographerCondition())->progressToNextTier($this->snapshot(1200), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new PhotographerCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(25, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(1000, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $photographerSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + photographerSolves: $photographerSolves, + ); + } +} diff --git a/tests/BadgeConditions/PiecesSolvedConditionTest.php b/tests/BadgeConditions/PiecesSolvedConditionTest.php new file mode 100644 index 000000000..d14ed3bed --- /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 000000000..2227b28e1 --- /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 000000000..658c77c9e --- /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/SpeedDemon1000ConditionTest.php b/tests/BadgeConditions/SpeedDemon1000ConditionTest.php new file mode 100644 index 000000000..198911317 --- /dev/null +++ b/tests/BadgeConditions/SpeedDemon1000ConditionTest.php @@ -0,0 +1,96 @@ +badgeType()); + } + + public function testNoSolveMeansNoTiers(): void + { + self::assertSame([], (new SpeedDemon1000Condition())->qualifiedTiers($this->snapshot(null))); + } + + public function testSlowerThan8HoursYieldsNothing(): void + { + self::assertSame([], (new SpeedDemon1000Condition())->qualifiedTiers($this->snapshot(28_801))); + } + + public function testExactlyThreshold8HoursEarnsBronze(): void + { + self::assertSame([BadgeTier::Bronze], (new SpeedDemon1000Condition())->qualifiedTiers($this->snapshot(28_800))); + } + + public function testFasterTimeQualifiesAllLowerTiers(): void + { + // 2 hours: under 8h, 4h and 2.5h limits, but slower than 1h45m. + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new SpeedDemon1000Condition())->qualifiedTiers($this->snapshot(7_200)), + ); + } + + public function testSub75MinutesEarnsAllTiers(): void + { + self::assertCount(5, (new SpeedDemon1000Condition())->qualifiedTiers($this->snapshot(4_000))); + } + + public function testProgressTowardDiamondShowsShrinkingRatio(): void + { + // Best time 6000s (1h40m), target for Diamond is 4500s (1h15m) + $progress = (new SpeedDemon1000Condition())->progressToNextTier($this->snapshot(6_000), BadgeTier::Platinum); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Diamond, $progress->nextTier); + self::assertSame(6_000, $progress->currentValue); + self::assertSame(4_500, $progress->targetValue); + self::assertSame(75, $progress->percent); + } + + public function testProgressIsNullWithoutAnySolve(): void + { + self::assertNull((new SpeedDemon1000Condition())->progressToNextTier($this->snapshot(null), null)); + } + + public function testProgressIsNullWhenDiamondEarned(): void + { + self::assertNull( + (new SpeedDemon1000Condition())->progressToNextTier($this->snapshot(4_000), BadgeTier::Diamond), + ); + } + + public function testRequirementForTier(): void + { + $condition = new SpeedDemon1000Condition(); + + self::assertSame(28_800, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(14_400, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(9_000, $condition->requirementForTier(BadgeTier::Gold)); + self::assertSame(6_300, $condition->requirementForTier(BadgeTier::Platinum)); + self::assertSame(4_500, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(null|int $best1000SoloSeconds): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + best1000PieceSoloSeconds: $best1000SoloSeconds, + ); + } +} diff --git a/tests/BadgeConditions/SteadyHandsConditionTest.php b/tests/BadgeConditions/SteadyHandsConditionTest.php new file mode 100644 index 000000000..3e96bd085 --- /dev/null +++ b/tests/BadgeConditions/SteadyHandsConditionTest.php @@ -0,0 +1,92 @@ +badgeType()); + } + + public function testSingleQuarterIsBelowFirstThreshold(): void + { + self::assertSame([], (new SteadyHandsCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new SteadyHandsCondition())->qualifiedTiers($this->snapshot(2))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new SteadyHandsCondition())->qualifiedTiers($this->snapshot(9)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove16(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new SteadyHandsCondition())->qualifiedTiers($this->snapshot(16)), + ); + } + + public function testProgressTowardBronzeWithNoBadges(): void + { + $progress = (new SteadyHandsCondition())->progressToNextTier($this->snapshot(1), null); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Bronze, $progress->nextTier); + self::assertSame(1, $progress->currentValue); + self::assertSame(2, $progress->targetValue); + self::assertSame(50, $progress->percent); + } + + public function testProgressTowardGoldWithSilverEarned(): void + { + $progress = (new SteadyHandsCondition())->progressToNextTier($this->snapshot(6), BadgeTier::Silver); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Gold, $progress->nextTier); + self::assertSame(75, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new SteadyHandsCondition())->progressToNextTier($this->snapshot(20), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new SteadyHandsCondition(); + + self::assertSame(2, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(4, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(16, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $steadyHandsQuarters): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + steadyHandsQuarters: $steadyHandsQuarters, + ); + } +} diff --git a/tests/BadgeConditions/StreakConditionTest.php b/tests/BadgeConditions/StreakConditionTest.php new file mode 100644 index 000000000..9106bc660 --- /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 000000000..e4db04542 --- /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/BadgeConditions/UnboxedConditionTest.php b/tests/BadgeConditions/UnboxedConditionTest.php new file mode 100644 index 000000000..d421c0d9b --- /dev/null +++ b/tests/BadgeConditions/UnboxedConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new UnboxedCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new UnboxedCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new UnboxedCondition())->qualifiedTiers($this->snapshot(30)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove100(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new UnboxedCondition())->qualifiedTiers($this->snapshot(100)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new UnboxedCondition())->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); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new UnboxedCondition())->progressToNextTier($this->snapshot(150), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new UnboxedCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(5, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(100, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $unboxedSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + unboxedSolves: $unboxedSolves, + ); + } +} diff --git a/tests/BadgeConditions/WeekendPuzzlerConditionTest.php b/tests/BadgeConditions/WeekendPuzzlerConditionTest.php new file mode 100644 index 000000000..a693af556 --- /dev/null +++ b/tests/BadgeConditions/WeekendPuzzlerConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new WeekendPuzzlerCondition())->qualifiedTiers($this->snapshot(9))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new WeekendPuzzlerCondition())->qualifiedTiers($this->snapshot(10))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new WeekendPuzzlerCondition())->qualifiedTiers($this->snapshot(200)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove600(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new WeekendPuzzlerCondition())->qualifiedTiers($this->snapshot(600)), + ); + } + + public function testProgressTowardBronzeWithNoBadges(): void + { + $progress = (new WeekendPuzzlerCondition())->progressToNextTier($this->snapshot(5), null); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Bronze, $progress->nextTier); + self::assertSame(5, $progress->currentValue); + self::assertSame(10, $progress->targetValue); + self::assertSame(50, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new WeekendPuzzlerCondition())->progressToNextTier($this->snapshot(700), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new WeekendPuzzlerCondition(); + + self::assertSame(10, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(50, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(600, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $weekendSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + weekendSolves: $weekendSolves, + ); + } +} diff --git a/tests/BadgeConditions/ZenPuzzlerConditionTest.php b/tests/BadgeConditions/ZenPuzzlerConditionTest.php new file mode 100644 index 000000000..95bdf67a3 --- /dev/null +++ b/tests/BadgeConditions/ZenPuzzlerConditionTest.php @@ -0,0 +1,83 @@ +badgeType()); + } + + public function testNoQualifyingTiersWhenBelowFirstThreshold(): void + { + self::assertSame([], (new ZenPuzzlerCondition())->qualifiedTiers($this->snapshot(0))); + } + + public function testQualifiesForFirstTierAtExactThreshold(): void + { + self::assertSame([BadgeTier::Bronze], (new ZenPuzzlerCondition())->qualifiedTiers($this->snapshot(1))); + } + + public function testQualifiesForAllLowerTiersWhenSkippingAhead(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold], + (new ZenPuzzlerCondition())->qualifiedTiers($this->snapshot(60)), + ); + } + + public function testQualifiesForAllTiersAtOrAbove365(): void + { + self::assertSame( + [BadgeTier::Bronze, BadgeTier::Silver, BadgeTier::Gold, BadgeTier::Platinum, BadgeTier::Diamond], + (new ZenPuzzlerCondition())->qualifiedTiers($this->snapshot(365)), + ); + } + + public function testProgressTowardSilverWithBronzeEarned(): void + { + $progress = (new ZenPuzzlerCondition())->progressToNextTier($this->snapshot(5), BadgeTier::Bronze); + + self::assertNotNull($progress); + self::assertSame(BadgeTier::Silver, $progress->nextTier); + self::assertSame(5, $progress->currentValue); + self::assertSame(10, $progress->targetValue); + self::assertSame(50, $progress->percent); + } + + public function testProgressIsNullWhenAllTiersEarned(): void + { + self::assertNull((new ZenPuzzlerCondition())->progressToNextTier($this->snapshot(400), BadgeTier::Diamond)); + } + + public function testRequirementForTier(): void + { + $condition = new ZenPuzzlerCondition(); + + self::assertSame(1, $condition->requirementForTier(BadgeTier::Bronze)); + self::assertSame(10, $condition->requirementForTier(BadgeTier::Silver)); + self::assertSame(365, $condition->requirementForTier(BadgeTier::Diamond)); + } + + private function snapshot(int $zenPuzzlerSolves): PlayerStatsSnapshot + { + return new PlayerStatsSnapshot( + playerId: '018d0000-0000-0000-0000-000000000000', + distinctPuzzlesSolved: 0, + totalPiecesSolved: 0, + best500PieceSoloSeconds: null, + allTimeLongestStreakDays: 0, + teamSolvesCount: 0, + zenPuzzlerSolves: $zenPuzzlerSolves, + ); + } +} diff --git a/tests/Controller/BadgesOverviewControllerTest.php b/tests/Controller/BadgesOverviewControllerTest.php new file mode 100644 index 000000000..a4a2250a2 --- /dev/null +++ b/tests/Controller/BadgesOverviewControllerTest.php @@ -0,0 +1,71 @@ +request('GET', '/en/achievements'); + + self::assertResponseStatusCodeSame(404); + } + + public function testNonAdminMemberGets404WhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', '/en/achievements'); + + self::assertResponseStatusCodeSame(404); + } + + public function testAdminSeesCatalogWhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/achievements'); + + self::assertResponseIsSuccessful(); + } + + public function testProfileBadgesSectionHiddenFromNonAdminsWhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/player-profile/' . PlayerFixture::PLAYER_WITH_STRIPE); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('ci-medal', $content); + self::assertStringNotContainsString('/en/achievements', $content); + } + + public function testProfileBadgesSectionVisibleToAdminWhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/player-profile/' . PlayerFixture::PLAYER_WITH_STRIPE); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('ci-medal', $content); + self::assertStringContainsString('/en/achievements', $content); + } +} diff --git a/tests/Controller/DigestSettingsVisibilityTest.php b/tests/Controller/DigestSettingsVisibilityTest.php new file mode 100644 index 000000000..7fcd2043f --- /dev/null +++ b/tests/Controller/DigestSettingsVisibilityTest.php @@ -0,0 +1,42 @@ +request('GET', '/en/edit-profile'); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('contentDigestFrequency', $content); + self::assertStringNotContainsString('experienceSystemOptedOut', $content); + } + + public function testAdminSeesDigestAndXpPreferences(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/edit-profile'); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('contentDigestFrequency', $content); + self::assertStringContainsString('experienceSystemOptedOut', $content); + } +} diff --git a/tests/Controller/FairPlayXpControllerTest.php b/tests/Controller/FairPlayXpControllerTest.php new file mode 100644 index 000000000..bccaba790 --- /dev/null +++ b/tests/Controller/FairPlayXpControllerTest.php @@ -0,0 +1,47 @@ +request('GET', '/en/fair-play-xp'); + + self::assertResponseStatusCodeSame(404); + } + + public function testNonAdminGets404WhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/fair-play-xp'); + + self::assertResponseStatusCodeSame(404); + } + + public function testAdminSeesFairPlayPageWhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/fair-play-xp'); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('Fair play & trust', $content); + } +} diff --git a/tests/Controller/UnsubscribeContentDigestControllerTest.php b/tests/Controller/UnsubscribeContentDigestControllerTest.php new file mode 100644 index 000000000..d83cccbfd --- /dev/null +++ b/tests/Controller/UnsubscribeContentDigestControllerTest.php @@ -0,0 +1,65 @@ +get(UrlGeneratorInterface::class)->generate( + 'unsubscribe_content_digest', + ['playerId' => PlayerFixture::PLAYER_REGULAR], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + $signedUrl = $container->get(UriSigner::class)->sign($url, new \DateInterval('P30D')); + + // GET never unsubscribes (link prefetchers!) — it shows the confirm button. + $browser->request('GET', $signedUrl); + self::assertResponseIsSuccessful(); + + $frequency = $container->get(Connection::class)->fetchOne( + 'SELECT content_digest_frequency FROM player WHERE id = :id', + ['id' => PlayerFixture::PLAYER_REGULAR], + ); + self::assertSame('weekly', $frequency); + + // POST (one-click / confirm button) flips the preference. + $browser->request('POST', $signedUrl); + self::assertResponseIsSuccessful(); + + $frequency = $container->get(Connection::class)->fetchOne( + 'SELECT content_digest_frequency FROM player WHERE id = :id', + ['id' => PlayerFixture::PLAYER_REGULAR], + ); + self::assertSame('none', $frequency); + } + + public function testTamperedSignatureIs404(): void + { + $browser = self::createClient(); + + $browser->request('POST', '/unsubscribe/content-digest/' . PlayerFixture::PLAYER_REGULAR . '?_hash=forged'); + + self::assertResponseStatusCodeSame(404); + } + + public function testUnsignedRequestIs404(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/unsubscribe/content-digest/' . PlayerFixture::PLAYER_REGULAR); + + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Controller/XpExplainerControllerTest.php b/tests/Controller/XpExplainerControllerTest.php new file mode 100644 index 000000000..debb079f0 --- /dev/null +++ b/tests/Controller/XpExplainerControllerTest.php @@ -0,0 +1,47 @@ +request('GET', '/en/how-xp-works'); + + self::assertResponseStatusCodeSame(404); + } + + public function testNonAdminGets404WhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/how-xp-works'); + + self::assertResponseStatusCodeSame(404); + } + + public function testAdminSeesExplainerWhileFlagged(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/how-xp-works'); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('How XP & Levels work', $content); + } +} diff --git a/tests/Controller/XpLeakTest.php b/tests/Controller/XpLeakTest.php new file mode 100644 index 000000000..effa826d0 --- /dev/null +++ b/tests/Controller/XpLeakTest.php @@ -0,0 +1,132 @@ + + */ + public static function provideGatedRoutes(): array + { + return [ + 'achievements catalog' => ['/en/achievements'], + 'achievement detail' => ['/en/achievements/puzzles_solved'], + 'xp leaderboard' => ['/en/players/xp-leaderboard'], + 'xp leaderboard all-time' => ['/en/players/xp-leaderboard?tab=all-time'], + 'xp leaderboard AP' => ['/en/players/xp-leaderboard?tab=achievement-points'], + 'explainer' => ['/en/how-xp-works'], + 'fair play' => ['/en/fair-play-xp'], + 'share card launch' => ['/xp-card/' . PlayerFixture::PLAYER_REGULAR . '/launch'], + 'share card level-up' => ['/xp-card/' . PlayerFixture::PLAYER_REGULAR . '/level-up'], + ]; + } + + /** + * @return array + */ + public static function provideContentPages(): array + { + return [ + 'own profile' => ['/en/player-profile/' . PlayerFixture::PLAYER_REGULAR], + 'member profile' => ['/en/player-profile/' . PlayerFixture::PLAYER_WITH_STRIPE], + 'own solve recap' => ['/en/time-added/' . PuzzleSolvingTimeFixture::TIME_06], + 'own tracking recap' => ['/en/tracking-added/' . PuzzleSolvingTimeFixture::TIME_46_RELAX_NO_FINISHED_AT], + 'puzzle detail' => ['/en/puzzle/' . PuzzleFixture::PUZZLE_500_01], + 'edit profile settings' => ['/en/edit-profile'], + 'membership page' => ['/en/membership'], + 'faq (header check)' => ['/en/faq'], + 'edit time (delete dialog)' => ['/en/edit-time/' . PuzzleSolvingTimeFixture::TIME_06], + ]; + } + + #[DataProvider('provideGatedRoutes')] + public function testGatedRouteIs404ForLoggedNonAdminNonMember(string $url): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', $url); + + self::assertResponseStatusCodeSame(404); + } + + #[DataProvider('provideGatedRoutes')] + public function testGatedRouteIs404ForAnonymous(string $url): void + { + $browser = self::createClient(); + + $browser->request('GET', $url); + + self::assertResponseStatusCodeSame(404); + } + + #[DataProvider('provideContentPages')] + public function testPageCarriesNoXpTracesForLoggedNonAdminNonMember(string $url): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', $url); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + + foreach (self::TRACE_MARKERS as $marker) { + self::assertStringNotContainsString($marker, $content, "Marker \"{$marker}\" leaked on {$url}"); + } + } + + public function testGatedActionEndpointsRejectNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('POST', '/en/badges/018d0000-0000-0000-0000-00000000dead/reveal'); + self::assertResponseStatusCodeSame(404); + + $browser->request('GET', '/en/my/xp-history'); + self::assertResponseStatusCodeSame(404); + + $browser->request('GET', '/en/my/xp-reveal'); + self::assertResponseStatusCodeSame(404); + + $browser->request('GET', '/en/my/achievement-reveals'); + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Controller/XpPagesTest.php b/tests/Controller/XpPagesTest.php new file mode 100644 index 000000000..8d264b944 --- /dev/null +++ b/tests/Controller/XpPagesTest.php @@ -0,0 +1,138 @@ + + */ + public static function provideGatedPages(): array + { + return [ + 'leaderboard' => ['/en/players/xp-leaderboard'], + 'leaderboard all-time tab' => ['/en/players/xp-leaderboard?tab=all-time'], + 'leaderboard AP tab' => ['/en/players/xp-leaderboard?tab=achievement-points'], + 'achievement detail' => ['/en/achievements/puzzles_solved'], + 'achievements catalog' => ['/en/achievements'], + 'explainer' => ['/en/how-xp-works'], + 'fair play' => ['/en/fair-play-xp'], + 'share card' => ['/xp-card/' . PlayerFixture::PLAYER_ADMIN . '/launch'], + ]; + } + + #[DataProvider('provideGatedPages')] + public function testPageIs404ForNonAdmins(string $url): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $url); + + self::assertResponseStatusCodeSame(404); + } + + #[DataProvider('provideGatedPages')] + public function testPageRendersForAdmin(string $url): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', $url); + + self::assertResponseIsSuccessful(); + } + + public function testXpHistoryIs404ForNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', '/en/my/xp-history'); + + self::assertResponseStatusCodeSame(404); + } + + public function testXpHistoryRendersForAdmin(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/my/xp-history'); + + self::assertResponseIsSuccessful(); + } + + public function testLaunchRevealIs404ForNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', '/en/my/xp-reveal'); + + self::assertResponseStatusCodeSame(404); + } + + public function testLaunchRevealRendersForAdmin(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/my/xp-reveal'); + + self::assertResponseIsSuccessful(); + } + + public function testShareCardReturnsPngForAdmin(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/xp-card/' . PlayerFixture::PLAYER_ADMIN . '/level-up'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'image/png'); + } + + public function testInvalidAchievementTypeIs404(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/achievements/not_a_type'); + + self::assertResponseStatusCodeSame(404); + } + + public function testLegacyBadgesUrlRedirectsPermanently(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/en/badges'); + + self::assertResponseStatusCodeSame(301); + self::assertResponseRedirects('/en/achievements'); + } + + public function testAnonymousGets404OnGatedPages(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/en/players/xp-leaderboard'); + self::assertResponseStatusCodeSame(404); + + $browser->request('GET', '/xp-card/' . PlayerFixture::PLAYER_ADMIN . '/launch'); + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Controller/XpSurfacesTest.php b/tests/Controller/XpSurfacesTest.php new file mode 100644 index 000000000..7d5196969 --- /dev/null +++ b/tests/Controller/XpSurfacesTest.php @@ -0,0 +1,149 @@ +request('GET', '/en/player-profile/' . PlayerFixture::PLAYER_WITH_STRIPE); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('xp-ring', $content); + self::assertStringNotContainsString('xp-level-chip', $content); + self::assertStringNotContainsString('xp-teaser', $content); + } + + public function testProfileShowsRingAndChipToAdmin(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/player-profile/' . PlayerFixture::PLAYER_WITH_STRIPE); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('xp-ring', $content); + self::assertStringContainsString('xp-level-chip', $content); + } + + public function testRecapShowsNoXpTracesToNonAdminOwner(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/time-added/' . PuzzleSolvingTimeFixture::TIME_06); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('xp-solve-receipt', $content); + self::assertStringNotContainsString('xp-receipt-line', $content); + } + + public function testRecapShowsReceiptRegionToAdminOwner(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + // TIME_03 belongs to the admin fixture player. + $browser->request('GET', '/en/time-added/' . PuzzleSolvingTimeFixture::TIME_03); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('xp-solve-receipt', $content); + } + + public function testPuzzleDetailShowsNoEstimateToNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/puzzle/' . PuzzleFixture::PUZZLE_500_01); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('xp-puzzle-estimate', $content); + } + + public function testPuzzleDetailShowsEstimateToAdmin(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/puzzle/' . PuzzleFixture::PUZZLE_500_01); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringContainsString('xp-puzzle-estimate', $content); + } + + public function testRevealEndpointIs404ForNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('POST', '/en/badges/018d0000-0000-0000-0000-00000000dead/reveal'); + + self::assertResponseStatusCodeSame(404); + } + + public function testBadgeRevealsPageIs404ForNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', '/en/my/achievement-reveals'); + + self::assertResponseStatusCodeSame(404); + } + + public function testBadgeRevealsPageRendersForAdminMember(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_ADMIN); + + $browser->request('GET', '/en/my/achievement-reveals'); + + self::assertResponseIsSuccessful(); + } + + public function testMembershipPageShowsNoRevealInviteToNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', '/en/membership'); + + self::assertResponseIsSuccessful(); + self::assertStringNotContainsString('xp-reveal-invite', (string) $browser->getResponse()->getContent()); + } + + public function testHeaderShowsNoRingToNonAdmins(): void + { + $browser = self::createClient(); + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', '/en/faq'); + + self::assertResponseIsSuccessful(); + $content = (string) $browser->getResponse()->getContent(); + self::assertStringNotContainsString('xp-ring', $content); + } +} diff --git a/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php b/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php new file mode 100644 index 000000000..2f47e41b8 --- /dev/null +++ b/tests/MessageHandler/RecalculateBadgesForPlayerHandlerTest.php @@ -0,0 +1,118 @@ +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, + xpFeatureGate: new XpFeatureGate(adminOnly: false), + ); + + $handler(new RecalculateBadgesForPlayer(PlayerFixture::PLAYER_REGULAR)); + + self::assertCount(0, $busSpy->dispatched); + } + + /** + * While the xp-system feature flag is active, badge evaluation keeps running + * but the congratulation email must never be dispatched. + * Delete this test on launch day together with the flag. + */ + public function testEmailDispatchSuppressedWhileFlagged(): 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), + ]; + + $busSpy = new MessageBusSpy(); + $handler = new RecalculateBadgesForPlayerHandler( + badgeEvaluator: new FakeBadgeEvaluator($badges), + commandBus: $busSpy, + xpFeatureGate: new XpFeatureGate(), + ); + + $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, + xpFeatureGate: new XpFeatureGate(adminOnly: false), + ); + + $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::assertArrayHasKey('puzzles_solved', $byType); + self::assertArrayHasKey('streak', $byType); + self::assertSame(3, $byType['puzzles_solved']); + self::assertSame(1, $byType['streak']); + } +} diff --git a/tests/MessageHandler/RevealBadgeHandlerTest.php b/tests/MessageHandler/RevealBadgeHandlerTest.php new file mode 100644 index 000000000..b61bfca61 --- /dev/null +++ b/tests/MessageHandler/RevealBadgeHandlerTest.php @@ -0,0 +1,75 @@ +handler = $container->get(RevealBadgeHandler::class); + $this->database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testRevealFlipsBadgeAndItsLowerTiers(): void + { + $bronze = $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'puzzles_solved', 1); + $silver = $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'puzzles_solved', 2); + $gold = $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'puzzles_solved', 3); + $unrelated = $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'streak', 1); + + ($this->handler)(new RevealBadge(PlayerFixture::PLAYER_REGULAR, $silver)); + $this->entityManager->flush(); + + self::assertNotNull($this->revealedAt($bronze), 'Lower tier flips together with the clicked one'); + self::assertNotNull($this->revealedAt($silver)); + self::assertNull($this->revealedAt($gold), 'Higher tier stays unrevealed'); + self::assertNull($this->revealedAt($unrelated), 'Other badge types stay untouched'); + } + + public function testRevealIgnoresNonOwners(): void + { + $badge = $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'puzzles_solved', 1); + + ($this->handler)(new RevealBadge(PlayerFixture::PLAYER_PRIVATE, $badge)); + $this->entityManager->flush(); + + self::assertNull($this->revealedAt($badge)); + } + + private function insertBadge(string $playerId, string $type, int $tier): string + { + $id = Uuid::uuid7()->toString(); + + $this->database->executeStatement( + 'INSERT INTO badge (id, player_id, type, earned_at, tier) VALUES (:id, :playerId, :type, NOW(), :tier)', + ['id' => $id, 'playerId' => $playerId, 'type' => $type, 'tier' => $tier], + ); + + return $id; + } + + private function revealedAt(string $badgeId): null|string + { + $value = $this->database->fetchOne('SELECT revealed_at FROM badge WHERE id = :id', ['id' => $badgeId]); + + return is_string($value) ? $value : null; + } +} diff --git a/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php b/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php new file mode 100644 index 000000000..4af0caee4 --- /dev/null +++ b/tests/MessageHandler/SendBadgeNotificationEmailHandlerTest.php @@ -0,0 +1,119 @@ +mailer = new TestMailerSpy(); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->getPlayerProfile = $container->get(GetPlayerProfile::class); + $this->translator = $container->get(TranslatorInterface::class); + } + + public function testSendsEmailToMemberWithCorrectTemplate(): void + { + $handler = $this->handler(); + + $handler(new SendBadgeNotificationEmail( + playerId: PlayerFixture::PLAYER_WITH_STRIPE, + 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()); + } + + /** + * Achievement detail is members-only (§1.7) — free players never receive the + * per-achievement congratulation email; the weekly digest teaser covers them. + */ + public function testSkipsEmailForPlayerWithoutMembership(): void + { + $handler = $this->handler(); + + $handler(new SendBadgeNotificationEmail( + playerId: PlayerFixture::PLAYER_REGULAR, + badgeSummary: [['type' => BadgeType::PuzzlesSolved, 'tier' => BadgeTier::Gold]], + )); + + self::assertCount(0, $this->mailer->sent); + } + + 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), + getPlayerProfile: $this->getPlayerProfile, + 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 = $this->handler(); + + $handler(new SendBadgeNotificationEmail( + playerId: '00000000-0000-0000-0000-000000000099', + badgeSummary: [['type' => BadgeType::Streak, 'tier' => BadgeTier::Bronze]], + )); + + self::assertCount(0, $this->mailer->sent); + } + + private function handler(): SendBadgeNotificationEmailHandler + { + return new SendBadgeNotificationEmailHandler( + playerRepository: $this->playerRepository, + getPlayerProfile: $this->getPlayerProfile, + mailer: $this->mailer, + translator: $this->translator, + ); + } +} diff --git a/tests/MessageHandler/SendPlayerContentDigestHandlerTest.php b/tests/MessageHandler/SendPlayerContentDigestHandlerTest.php new file mode 100644 index 000000000..6b0378c9b --- /dev/null +++ b/tests/MessageHandler/SendPlayerContentDigestHandlerTest.php @@ -0,0 +1,266 @@ +database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + $this->clock = $container->get(ClockInterface::class); + } + + public function testSendsDigestWithActivityAndLogsIt(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + $this->insertSolve(PlayerFixture::PLAYER_WITH_STRIPE, $this->clock->now()); + + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + $this->entityManager->flush(); + + self::assertCount(1, $transport->sent); + $email = $transport->sent[0]; + self::assertInstanceOf(TemplatedEmail::class, $email); + self::assertSame('emails/content_digest_weekly.html.twig', $email->getHtmlTemplate()); + self::assertNotNull($email->getHeaders()->get('List-Unsubscribe')); + self::assertSame('List-Unsubscribe=One-Click', $email->getHeaders()->get('List-Unsubscribe-Post')?->getBodyAsString()); + self::assertSame('notifications', $email->getHeaders()->get('X-Transport')?->getBodyAsString()); + self::assertSame('bulk', $email->getHeaders()->get('Precedence')?->getBodyAsString()); + + $log = $this->logRow(PlayerFixture::PLAYER_WITH_STRIPE, $period->key); + self::assertNotNull($log); + self::assertSame('sent', $log['status']); + self::assertTrue((bool) $log['had_activity']); + } + + public function testNoActivityVariantStillSendsAndIsMarked(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + // PLAYER_PRIVATE has no solves in the current week (fixtures are day-offset based, + // but the week window only catches recent ones — delete to be deterministic). + $this->database->executeStatement( + 'DELETE FROM puzzle_solving_time WHERE player_id = :playerId', + ['playerId' => PlayerFixture::PLAYER_PRIVATE], + ); + $this->database->executeStatement( + 'DELETE FROM xp_entry WHERE player_id = :playerId', + ['playerId' => PlayerFixture::PLAYER_PRIVATE], + ); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_PRIVATE, 'weekly', $period->key)); + $this->entityManager->flush(); + + self::assertCount(1, $transport->sent); + + $log = $this->logRow(PlayerFixture::PLAYER_PRIVATE, $period->key); + self::assertNotNull($log); + self::assertFalse((bool) $log['had_activity']); + } + + public function testSuppressedWhileFeatureFlagActive(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + + $transport = new TransportSpy(); + $handler = $this->handler($transport, flagActive: true); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + $this->entityManager->flush(); + + self::assertCount(0, $transport->sent); + self::assertNull($this->logRow(PlayerFixture::PLAYER_WITH_STRIPE, $period->key)); + } + + public function testStalePeriodIsSkippedSilently(): void + { + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', '2020-W01')); + $this->entityManager->flush(); + + self::assertCount(0, $transport->sent); + self::assertNull($this->logRow(PlayerFixture::PLAYER_WITH_STRIPE, '2020-W01')); + } + + public function testExperienceOptOutIsExcluded(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + $this->database->executeStatement( + 'UPDATE player SET experience_system_opted_out = true WHERE id = :playerId', + ['playerId' => PlayerFixture::PLAYER_WITH_STRIPE], + ); + + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + + self::assertCount(0, $transport->sent); + } + + public function testFrequencyNoneIsExcluded(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + $this->database->executeStatement( + "UPDATE player SET content_digest_frequency = 'none' WHERE id = :playerId", + ['playerId' => PlayerFixture::PLAYER_WITH_STRIPE], + ); + + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + + self::assertCount(0, $transport->sent); + } + + public function testAlreadyLoggedPeriodIsNotSentTwice(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + $this->insertLog(PlayerFixture::PLAYER_WITH_STRIPE, $period->key); + + $transport = new TransportSpy(); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + + self::assertCount(0, $transport->sent); + } + + public function testPermanentRecipientFailureLogsAndAcks(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + + $transport = new TransportSpy(new UnexpectedResponseException('550 mailbox unavailable', 550)); + $handler = $this->handler($transport); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + $this->entityManager->flush(); + + $log = $this->logRow(PlayerFixture::PLAYER_WITH_STRIPE, $period->key); + self::assertNotNull($log); + self::assertSame('failed_permanent', $log['status']); + } + + public function testTransientFailureBubblesForRetry(): void + { + $period = DigestPeriod::weeklyFor($this->clock->now()); + + $transport = new TransportSpy(new UnexpectedResponseException('421 try again later', 421)); + $handler = $this->handler($transport); + + $this->expectException(UnexpectedResponseException::class); + + $handler(new SendPlayerContentDigest(PlayerFixture::PLAYER_WITH_STRIPE, 'weekly', $period->key)); + } + + private function handler(TransportInterface $transport, bool $flagActive = false): SendPlayerContentDigestHandler + { + $container = self::getContainer(); + + return new SendPlayerContentDigestHandler( + playerRepository: $container->get(PlayerRepository::class), + getPlayerProfile: $container->get(GetPlayerProfile::class), + contentDigestLogRepository: $container->get(ContentDigestLogRepository::class), + weeklyDigestDataProvider: $container->get(WeeklyDigestDataProvider::class), + transport: $transport, + translator: $container->get(TranslatorInterface::class), + uriSigner: $container->get(UriSigner::class), + urlGenerator: $container->get(UrlGeneratorInterface::class), + database: $this->database, + clock: $this->clock, + xpFeatureGate: new XpFeatureGate(adminOnly: $flagActive), + logger: new NullLogger(), + ); + } + + /** + * @return array{status: string, had_activity: bool}|null + */ + private function logRow(string $playerId, string $periodKey): null|array + { + /** @var array{status: string, had_activity: bool}|false $row */ + $row = $this->database->fetchAssociative( + "SELECT status, had_activity FROM content_digest_log WHERE player_id = :playerId AND digest_type = 'weekly' AND period_key = :periodKey", + ['playerId' => $playerId, 'periodKey' => $periodKey], + ); + + return $row === false ? null : $row; + } + + private function insertLog(string $playerId, string $periodKey, bool $hadActivity = true, string $status = 'sent'): void + { + $this->database->executeStatement( + "INSERT INTO content_digest_log (id, player_id, digest_type, period_key, sent_at, had_activity, status) + VALUES (:id, :playerId, 'weekly', :periodKey, NOW(), :hadActivity, :status)", + [ + 'id' => Uuid::uuid7()->toString(), + 'playerId' => $playerId, + 'periodKey' => $periodKey, + 'hadActivity' => $hadActivity ? 'true' : 'false', + 'status' => $status, + ], + ); + } + + private function insertSolve(string $playerId, \DateTimeImmutable $at): void + { + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, 3600, :playerId, :puzzleId, :at, true, NULL, :at, + NULL, NULL, false, false, 1, \'solo\', false, 500)', + [ + 'id' => Uuid::uuid7()->toString(), + 'playerId' => $playerId, + 'puzzleId' => PuzzleFixture::PUZZLE_500_04, + 'at' => $at->format('Y-m-d H:i:s'), + ], + ); + } +} diff --git a/tests/MessageHandler/SendXpRevealEmailHandlerTest.php b/tests/MessageHandler/SendXpRevealEmailHandlerTest.php new file mode 100644 index 000000000..bb1fca532 --- /dev/null +++ b/tests/MessageHandler/SendXpRevealEmailHandlerTest.php @@ -0,0 +1,112 @@ +database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testSendsOncePerPlayerForever(): void + { + $mailer = new TestMailerSpy(); + $handler = $this->handler($mailer, flagActive: false); + + $handler(new SendXpRevealEmail(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->entityManager->flush(); + + self::assertCount(1, $mailer->sent); + $email = $mailer->sent[0]; + self::assertInstanceOf(TemplatedEmail::class, $email); + self::assertSame('emails/xp_reveal.html.twig', $email->getHtmlTemplate()); + self::assertSame('transactional', $email->getHeaders()->get('X-Transport')?->getBodyAsString()); + self::assertNotNull($email->getHeaders()->get('List-Unsubscribe')); + + // Second run: the idempotency log blocks a duplicate. + $handler(new SendXpRevealEmail(PlayerFixture::PLAYER_WITH_STRIPE)); + + self::assertSame(1, $mailer->sentCount()); + + $logged = $this->database->fetchOne( + "SELECT COUNT(*) FROM content_digest_log WHERE player_id = :playerId AND digest_type = 'xp_reveal'", + ['playerId' => PlayerFixture::PLAYER_WITH_STRIPE], + ); + self::assertSame(1, (int) (is_numeric($logged) ? $logged : 0)); + } + + public function testRefusesWhileFlagActive(): void + { + $mailer = new TestMailerSpy(); + $handler = $this->handler($mailer, flagActive: true); + + $handler(new SendXpRevealEmail(PlayerFixture::PLAYER_WITH_STRIPE)); + + self::assertCount(0, $mailer->sent); + } + + public function testOptedOutPlayersAreSkipped(): void + { + $this->database->executeStatement( + 'UPDATE player SET experience_system_opted_out = true WHERE id = :playerId', + ['playerId' => PlayerFixture::PLAYER_WITH_STRIPE], + ); + + $mailer = new TestMailerSpy(); + $handler = $this->handler($mailer, flagActive: false); + + $handler(new SendXpRevealEmail(PlayerFixture::PLAYER_WITH_STRIPE)); + + self::assertCount(0, $mailer->sent); + } + + private function handler(TestMailerSpy $mailer, bool $flagActive): SendXpRevealEmailHandler + { + $container = self::getContainer(); + + return new SendXpRevealEmailHandler( + playerRepository: $container->get(PlayerRepository::class), + getPlayerProfile: $container->get(GetPlayerProfile::class), + getXpProfile: $container->get(GetXpProfile::class), + getBadges: $container->get(GetBadges::class), + contentDigestLogRepository: $container->get(ContentDigestLogRepository::class), + mailer: $mailer, + translator: $container->get(TranslatorInterface::class), + uriSigner: $container->get(UriSigner::class), + urlGenerator: $container->get(UrlGeneratorInterface::class), + database: $this->database, + clock: $container->get(ClockInterface::class), + xpFeatureGate: new XpFeatureGate(adminOnly: $flagActive), + logger: new NullLogger(), + ); + } +} diff --git a/tests/MessageHandler/TestMailerSpy.php b/tests/MessageHandler/TestMailerSpy.php index e2b68907c..c535b240d 100644 --- a/tests/MessageHandler/TestMailerSpy.php +++ b/tests/MessageHandler/TestMailerSpy.php @@ -17,4 +17,9 @@ public function send(RawMessage $message, null|Envelope $envelope = null): void { $this->sent[] = $message; } + + public function sentCount(): int + { + return count($this->sent); + } } diff --git a/tests/Query/GetAchievementPointsTest.php b/tests/Query/GetAchievementPointsTest.php new file mode 100644 index 000000000..8d9891286 --- /dev/null +++ b/tests/Query/GetAchievementPointsTest.php @@ -0,0 +1,135 @@ +database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testEvaluatorAnchorsColumnAndQueriesReadIt(): void + { + // A fresh player with no solves: the evaluator earns nothing new and purely + // re-anchors the denormalized total from the existing badge rows. + $playerId = $this->insertFreshPlayer(member: true); + + self::assertSame(0, $this->columnValue($playerId)); + + $expected = 0; + foreach (BadgeTier::cases() as $tier) { + $this->insertBadge($playerId, 'puzzles_solved', $tier->value); + $expected += $tier->points(); + } + $this->insertBadge($playerId, 'supporter', null); + $expected += BadgeTier::SINGLE_TIER_POINTS; + + // Full ladder (5+10+25+50+100) + Early Adopter (25) = 215 — the §1.6 locked values. + self::assertSame(215, $expected); + + $this->evaluate($playerId); + + self::assertSame($expected, self::getContainer()->get(GetAchievementPoints::class)->forPlayer($playerId)); + self::assertSame($expected, $this->columnValue($playerId)); + + // The AP ladder ranks straight off the column (member + public profile required). + $mine = null; + foreach (self::getContainer()->get(GetXpLeaderboard::class)->achievementPoints(null, null) as $row) { + if ($row->playerId === $playerId) { + $mine = $row; + } + } + + self::assertNotNull($mine); + self::assertSame($expected, $mine->value); + self::assertSame($expected, $mine->achievementPoints); + } + + public function testEvaluationSelfHealsManualDrift(): void + { + $playerId = $this->insertFreshPlayer(member: false); + $this->insertBadge($playerId, 'streak', BadgeTier::Gold->value); + + // Simulate drift (e.g. a badge granted via raw SQL without the evaluator). + $this->database->executeStatement( + 'UPDATE player SET achievement_points = 999 WHERE id = :id', + ['id' => $playerId], + ); + + $this->evaluate($playerId); + + self::assertSame( + BadgeTier::Gold->points(), + self::getContainer()->get(GetAchievementPoints::class)->forPlayer($playerId), + ); + } + + private function columnValue(string $playerId): int + { + $value = $this->database->fetchOne( + 'SELECT achievement_points FROM player WHERE id = :id', + ['id' => $playerId], + ); + + return is_numeric($value) ? (int) $value : -1; + } + + private function evaluate(string $playerId): void + { + self::getContainer()->get(BadgeEvaluator::class)->recalculateForPlayer($playerId); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function insertFreshPlayer(bool $member): string + { + $playerId = Uuid::uuid7()->toString(); + + $this->database->executeStatement( + "INSERT INTO player (id, code, name, email, registered_at) VALUES (:id, :code, 'AP Tester', :email, NOW())", + ['id' => $playerId, 'code' => 'ap-' . substr($playerId, -8), 'email' => 'ap-' . substr($playerId, -8) . '@test.local'], + ); + + if ($member) { + $this->database->executeStatement( + "INSERT INTO membership (id, player_id, created_at, granted_until) VALUES (:id, :playerId, NOW(), NOW() + INTERVAL '30 days')", + ['id' => Uuid::uuid7()->toString(), 'playerId' => $playerId], + ); + } + + return $playerId; + } + + private function insertBadge(string $playerId, string $type, null|int $tier): void + { + $this->database->executeStatement( + 'INSERT INTO badge (id, player_id, type, earned_at, tier) VALUES (:id, :playerId, :type, NOW(), :tier)', + ['id' => Uuid::uuid7()->toString(), 'playerId' => $playerId, 'type' => $type, 'tier' => $tier], + ); + } +} diff --git a/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php b/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php new file mode 100644 index 000000000..32754ae3d --- /dev/null +++ b/tests/Query/GetAllPlayerIdsWithSolveTimesTest.php @@ -0,0 +1,56 @@ +query = new GetAllPlayerIdsWithSolveTimes( + self::getContainer()->get(Connection::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 index d9b6aefc5..a37a1010f 100644 --- a/tests/Query/GetBadgesTest.php +++ b/tests/Query/GetBadgesTest.php @@ -13,14 +13,14 @@ final class GetBadgesTest extends KernelTestCase { - private GetBadges $getBadges; + private GetBadges $query; private Connection $connection; protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); - $this->getBadges = $container->get(GetBadges::class); + $this->query = $container->get(GetBadges::class); $this->connection = $container->get(Connection::class); } @@ -28,24 +28,37 @@ public function testReturnsKnownBadges(): void { $this->insertBadge(PlayerFixture::PLAYER_REGULAR, BadgeType::Supporter->value); - $badges = $this->getBadges->forPlayer(PlayerFixture::PLAYER_REGULAR); + $badges = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); - self::assertSame([BadgeType::Supporter], $badges); + self::assertCount(1, $badges); + self::assertSame(BadgeType::Supporter, $badges[0]->type); + self::assertNull($badges[0]->tier); + self::assertSame('2026-01-01 12:00:00', $badges[0]->earnedAt->format('Y-m-d H:i:s')); } public function testUnknownBadgeTypesInDatabaseAreSkipped(): void { $this->insertBadge(PlayerFixture::PLAYER_REGULAR, BadgeType::Supporter->value); - $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'pieces_solved'); + $this->insertBadge(PlayerFixture::PLAYER_REGULAR, 'removed_experimental_badge'); - $badges = $this->getBadges->forPlayer(PlayerFixture::PLAYER_REGULAR); + $badges = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); - self::assertSame([BadgeType::Supporter], $badges); + self::assertCount(1, $badges); + self::assertSame(BadgeType::Supporter, $badges[0]->type); } - public function testReturnsEmptyListForPlayerWithoutBadges(): void + public function testReturnsEmptyArrayForPlayerWithNoBadges(): void { - self::assertSame([], $this->getBadges->forPlayer(PlayerFixture::PLAYER_ADMIN)); + // No badge fixtures exist — empty is correct + self::assertSame([], $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR)); + self::assertSame([], $this->query->forPlayer(PlayerFixture::PLAYER_ADMIN)); + } + + public function testReturnsEmptyArrayForNonExistentPlayer(): void + { + $badges = $this->query->forPlayer('00000000-0000-0000-0000-000000000099'); + + self::assertSame([], $badges); } private function insertBadge(string $playerId, string $type): void diff --git a/tests/Query/GetPlayerStatsSnapshotTest.php b/tests/Query/GetPlayerStatsSnapshotTest.php new file mode 100644 index 000000000..40428ed29 --- /dev/null +++ b/tests/Query/GetPlayerStatsSnapshotTest.php @@ -0,0 +1,334 @@ +database = $container->get(Connection::class); + $this->query = new GetPlayerStatsSnapshot( + $this->database, + new ActivityCalendarStreakCalculator($container->get(ClockInterface::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); + self::assertSame(0, $snapshot->zenPuzzlerSolves); + self::assertSame(0, $snapshot->firstTrySolves); + self::assertSame(0, $snapshot->unboxedSolves); + self::assertSame(0, $snapshot->brandExplorerManufacturers); + self::assertSame(0, $snapshot->marathonerSolves); + self::assertSame(0, $snapshot->photographerSolves); + self::assertSame(0, $snapshot->steadyHandsQuarters); + self::assertSame(0, $snapshot->librarianApprovedRequests); + self::assertNull($snapshot->best1000PieceSoloSeconds); + self::assertSame(0, $snapshot->weekendSolves); + self::assertSame(0, $snapshot->catalogerApprovedPuzzles); + } + + 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); + } + + public function testOwnerScopedCountersForRegularPlayer(): void + { + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + // TIME_46_RELAX_NO_FINISHED_AT is the only owner solve without tracked seconds + self::assertSame(1, $snapshot->zenPuzzlerSolves); + + // first_attempt=true owner solves: TIME_01, 06, 09, 12, 13, 19, 21, 25, 28, 29, 36, 41 + // (TIME_07/08 are repeats, TIME_46 is not a first attempt) + // + INTEL_TIME_13, 14, 15 from PuzzleIntelligenceFixture (INTEL_TIME_17 is a repeat) + self::assertSame(15, $snapshot->firstTrySolves); + + // TIME_45_UNBOXED belongs to PLAYER_WITH_STRIPE, not PLAYER_REGULAR + self::assertSame(0, $snapshot->unboxedSolves); + + // Owner-solved puzzles come from Ravensburger and Trefl only + self::assertSame(2, $snapshot->brandExplorerManufacturers); + + // TIME_25 on PUZZLE_2000 (2000 pieces) is the only 2000+ piece owner solve + self::assertSame(1, $snapshot->marathonerSolves); + + // No fixture solve has a finished puzzle photo + self::assertSame(0, $snapshot->photographerSolves); + + // 1000pc solo timed solves: TIME_19 (4500s) and TIME_29 (3950s); + // TIME_12/TIME_41 are duo solves and TIME_46 has no time + self::assertSame(3950, $snapshot->best1000PieceSoloSeconds); + } + + public function testUnboxedSolvesCountedForPlayerWithStripe(): void + { + // TIME_45_UNBOXED is the only unboxed solve in fixtures + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_STRIPE); + + self::assertSame(1, $snapshot->unboxedSolves); + } + + public function testLibrarianCountsOnlyApprovedRequests(): void + { + // PuzzleReportFixture: PLAYER_REGULAR reported 4 change requests (1 approved, + // 1 rejected, 2 pending) and 1 pending merge request + $regular = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + self::assertSame(1, $regular->librarianApprovedRequests); + + // PLAYER_ADMIN approved/rejected them but never reported any + $admin = $this->query->forPlayer(PlayerFixture::PLAYER_ADMIN); + self::assertSame(0, $admin->librarianApprovedRequests); + } + + public function testCatalogerCountsOnlyApprovedPuzzles(): void + { + // PLAYER_REGULAR only added PUZZLE_UNAPPROVED (approved = false) + $regular = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + self::assertSame(0, $regular->catalogerApprovedPuzzles); + + // PLAYER_ADMIN added 20 approved puzzles in PuzzleFixture + 6 in CompetitionApiFixture + $admin = $this->query->forPlayer(PlayerFixture::PLAYER_ADMIN); + self::assertSame(26, $admin->catalogerApprovedPuzzles); + } + + public function testWeekendAndQuarterMetricsStayWithinSaneBoundsOnRelativeFixtures(): void + { + // Fixture solve dates are relative to "now", so the weekday/quarter distribution is + // not deterministic — deterministic coverage lives in the fixed-date tests below. + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_REGULAR); + + // PLAYER_REGULAR owns 19 fixture solves in total + self::assertGreaterThanOrEqual(0, $snapshot->weekendSolves); + self::assertLessThanOrEqual(19, $snapshot->weekendSolves); + + // All fixture activity happens within the last ~45 days: it spans 1 quarter, + // or 2 consecutive ones when it crosses a quarter boundary + self::assertGreaterThanOrEqual(1, $snapshot->steadyHandsQuarters); + self::assertLessThanOrEqual(2, $snapshot->steadyHandsQuarters); + } + + public function testWeekendSolvesCountSaturdaysAndSundaysWithGarbageDateGuard(): void + { + $baseline = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES)->weekendSolves; + + // Counted: finished on Saturday (finished_at wins over the Monday tracked_at) + $this->insertSolve( + PlayerFixture::PLAYER_WITH_FAVORITES, + PuzzleFixture::PUZZLE_500_04, + trackedAt: new DateTimeImmutable('2020-06-01 12:00:00'), + finishedAt: new DateTimeImmutable('2020-06-06 12:00:00'), + ); + + // Counted: no finished_at, tracked on Sunday (falls back to tracked_at) + $this->insertSolve( + PlayerFixture::PLAYER_WITH_FAVORITES, + PuzzleFixture::PUZZLE_500_04, + trackedAt: new DateTimeImmutable('2020-06-07 12:00:00'), + finishedAt: null, + ); + + // NOT counted: finished on Monday (even though tracked on Saturday) + $this->insertSolve( + PlayerFixture::PLAYER_WITH_FAVORITES, + PuzzleFixture::PUZZLE_500_04, + trackedAt: new DateTimeImmutable('2020-06-06 12:00:00'), + finishedAt: new DateTimeImmutable('2020-06-08 12:00:00'), + ); + + // NOT counted: garbage year-0024 row (proleptic Saturday) is excluded by the + // >= 2000-01-01 guard — without the guard this would count as a weekend solve + $this->insertSolve( + PlayerFixture::PLAYER_WITH_FAVORITES, + PuzzleFixture::PUZZLE_500_04, + trackedAt: new DateTimeImmutable('0024-06-08 12:00:00'), + finishedAt: new DateTimeImmutable('0024-06-08 12:00:00'), + ); + + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES); + + self::assertSame($baseline + 2, $snapshot->weekendSolves); + } + + public function testSteadyHandsQuartersFindLongestConsecutiveRun(): void + { + // Isolated island: a single quarter far in the past + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2015-06-15 12:00:00')); + + // Consecutive run: 2019Q4 → 2020Q4 = 5 quarters (with a duplicate inside 2020Q1) + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2019-11-15 12:00:00')); + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2020-02-10 12:00:00')); + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2020-03-15 12:00:00')); + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2020-05-10 12:00:00')); + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2020-08-10 12:00:00')); + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('2020-11-10 12:00:00')); + + // The "now"-relative fixture solves form an island of at most 2 quarters — the + // 5-quarter run above is the longest + self::assertSame(5, $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES)->steadyHandsQuarters); + + // Team participation counts too: PLAYER_WITH_FAVORITES is only a team member on + // this 2021Q1 solve owned by PLAYER_REGULAR — it extends the run to 6 quarters + $this->insertSolve( + PlayerFixture::PLAYER_REGULAR, + PuzzleFixture::PUZZLE_500_04, + trackedAt: new DateTimeImmutable('2021-02-10 12:00:00'), + team: [PlayerFixture::PLAYER_REGULAR, PlayerFixture::PLAYER_WITH_FAVORITES], + ); + + self::assertSame(6, $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES)->steadyHandsQuarters); + + // Garbage year-0024 rows are excluded by the >= 2000-01-01 guard + $this->insertSolve(PlayerFixture::PLAYER_WITH_FAVORITES, PuzzleFixture::PUZZLE_500_04, trackedAt: new DateTimeImmutable('0024-05-05 12:00:00')); + + self::assertSame(6, $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES)->steadyHandsQuarters); + } + + public function testSuspiciousSolvesAreExcludedFromNewMetrics(): void + { + $baseline = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES); + + // A suspicious weekend marathon solve must not move any counter + $this->insertSolve( + PlayerFixture::PLAYER_WITH_FAVORITES, + PuzzleFixture::PUZZLE_2000, + trackedAt: new DateTimeImmutable('2020-06-06 12:00:00'), + suspicious: true, + ); + + $snapshot = $this->query->forPlayer(PlayerFixture::PLAYER_WITH_FAVORITES); + + self::assertSame($baseline->weekendSolves, $snapshot->weekendSolves); + self::assertSame($baseline->marathonerSolves, $snapshot->marathonerSolves); + self::assertSame($baseline->firstTrySolves, $snapshot->firstTrySolves); + self::assertSame($baseline->steadyHandsQuarters, $snapshot->steadyHandsQuarters); + } + + /** + * Mirrors the puzzle_solving_time INSERT used by XpChainRecomputerTest — DAMA rolls the + * row back after each test. + * + * @param list|null $team + */ + private function insertSolve( + string $playerId, + string $puzzleId, + DateTimeImmutable $trackedAt, + null|DateTimeImmutable|false $finishedAt = false, + null|array $team = null, + bool $suspicious = false, + ): void { + $teamJson = null; + $puzzlingType = 'solo'; + $puzzlersCount = 1; + + if ($team !== null) { + $puzzlers = []; + foreach ($team as $teamPlayerId) { + $puzzlers[] = [ + 'player_id' => $teamPlayerId, + 'player_name' => null, + 'player_code' => null, + 'player_country' => null, + 'is_private' => false, + ]; + } + $teamJson = json_encode(['team_id' => null, 'puzzlers' => $puzzlers], JSON_THROW_ON_ERROR); + $puzzlersCount = count($team); + $puzzlingType = $puzzlersCount === 2 ? 'duo' : 'team'; + } + + // false = default to trackedAt, null = explicitly NULL + $resolvedFinishedAt = $finishedAt === false ? $trackedAt : $finishedAt; + + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, 3600, :playerId, :puzzleId, :trackedAt, true, :team, :finishedAt, + NULL, NULL, true, false, :puzzlersCount, :puzzlingType, :suspicious, 500)', + [ + 'id' => Uuid::uuid7()->toString(), + 'playerId' => $playerId, + 'puzzleId' => $puzzleId, + 'trackedAt' => $trackedAt->format('Y-m-d H:i:s'), + 'finishedAt' => $resolvedFinishedAt?->format('Y-m-d H:i:s'), + 'team' => $teamJson, + 'puzzlersCount' => $puzzlersCount, + 'puzzlingType' => $puzzlingType, + 'suspicious' => (int) $suspicious, + ], + ); + } +} diff --git a/tests/Query/GetPlayersForContentDigestTest.php b/tests/Query/GetPlayersForContentDigestTest.php new file mode 100644 index 000000000..33972c398 --- /dev/null +++ b/tests/Query/GetPlayersForContentDigestTest.php @@ -0,0 +1,123 @@ +query = $container->get(GetPlayersForContentDigest::class); + $this->database = $container->get(Connection::class); + $this->periodKey = DigestPeriod::weeklyFor($container->get(ClockInterface::class)->now())->key; + } + + public function testDefaultOnPlayersAreEligible(): void + { + $eligible = $this->query->weekly($this->periodKey); + + // All five fixture players have emails, notifications on and the weekly default. + self::assertContains(PlayerFixture::PLAYER_REGULAR, $eligible); + self::assertContains(PlayerFixture::PLAYER_WITH_STRIPE, $eligible); + } + + public function testPeriodLogExcludes(): void + { + $this->insertLog(PlayerFixture::PLAYER_REGULAR, $this->periodKey, hadActivity: true); + + self::assertNotContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + } + + public function testFrequencyNoneExcludes(): void + { + $this->database->executeStatement( + "UPDATE player SET content_digest_frequency = 'none' WHERE id = :playerId", + ['playerId' => PlayerFixture::PLAYER_REGULAR], + ); + + self::assertNotContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + } + + public function testExperienceOptOutExcludes(): void + { + $this->database->executeStatement( + 'UPDATE player SET experience_system_opted_out = true WHERE id = :playerId', + ['playerId' => PlayerFixture::PLAYER_REGULAR], + ); + + self::assertNotContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + } + + public function testNeverTwoNoActivityDigestsInARow(): void + { + // Last week's digest was the no-activity variant and nothing was solved since → + // skip this week entirely. + $this->database->executeStatement('DELETE FROM puzzle_solving_time WHERE player_id = :playerId', [ + 'playerId' => PlayerFixture::PLAYER_REGULAR, + ]); + $this->insertLog(PlayerFixture::PLAYER_REGULAR, '2026-W01', hadActivity: false); + + self::assertNotContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + + // One solve after that send → digests resume. + $this->insertSolve(PlayerFixture::PLAYER_REGULAR); + + self::assertContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + } + + public function testActivityDigestDoesNotBlockNextWeek(): void + { + $this->insertLog(PlayerFixture::PLAYER_REGULAR, '2026-W01', hadActivity: true); + + self::assertContains(PlayerFixture::PLAYER_REGULAR, $this->query->weekly($this->periodKey)); + } + + private function insertLog(string $playerId, string $periodKey, bool $hadActivity): void + { + $this->database->executeStatement( + "INSERT INTO content_digest_log (id, player_id, digest_type, period_key, sent_at, had_activity, status) + VALUES (:id, :playerId, 'weekly', :periodKey, NOW() - INTERVAL '7 days', :hadActivity, 'sent')", + [ + 'id' => Uuid::uuid7()->toString(), + 'playerId' => $playerId, + 'periodKey' => $periodKey, + 'hadActivity' => $hadActivity ? 'true' : 'false', + ], + ); + } + + private function insertSolve(string $playerId): void + { + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, 3600, :playerId, :puzzleId, NOW(), true, NULL, NOW(), + NULL, NULL, false, false, 1, \'solo\', false, 500)', + [ + 'id' => Uuid::uuid7()->toString(), + 'playerId' => $playerId, + 'puzzleId' => PuzzleFixture::PUZZLE_500_04, + ], + ); + } +} diff --git a/tests/Query/XpQueriesTest.php b/tests/Query/XpQueriesTest.php new file mode 100644 index 000000000..13c4e9f19 --- /dev/null +++ b/tests/Query/XpQueriesTest.php @@ -0,0 +1,77 @@ +get(XpRecomputer::class)->recomputeForPlayer(PlayerFixture::PLAYER_REGULAR); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + } + + public function testXpProfileReflectsLedger(): void + { + $query = self::getContainer()->get(GetXpProfile::class); + + $profile = $query->byPlayerId(PlayerFixture::PLAYER_REGULAR); + + self::assertGreaterThan(0, $profile->xpTotal); + self::assertGreaterThan(1, $profile->level); + self::assertFalse($profile->optedOut); + self::assertFalse($profile->isMaxLevel()); + self::assertNotNull($profile->progressToNext()); + self::assertNotNull($profile->xpToNextLevel()); + } + + public function testXpProfileThrowsForUnknownPlayer(): void + { + $query = self::getContainer()->get(GetXpProfile::class); + + $this->expectException(PlayerNotFound::class); + + $query->byPlayerId('00000000-0000-0000-0000-000000000000'); + } + + public function testEntriesForSolveAndTotal(): void + { + $query = self::getContainer()->get(GetXpEntriesForSolve::class); + + $lines = $query->forPlayerAndSolvingTime(PlayerFixture::PLAYER_REGULAR, PuzzleSolvingTimeFixture::TIME_06); + + self::assertCount(1, $lines); + self::assertSame(XpReason::SolveBase, $lines[0]->reason); + self::assertSame(5, $lines[0]->amount); + self::assertSame(5, $query->totalForPlayerAndSolvingTime(PlayerFixture::PLAYER_REGULAR, PuzzleSolvingTimeFixture::TIME_06)); + self::assertSame(0, $query->totalForPlayerAndSolvingTime(PlayerFixture::PLAYER_REGULAR, '00000000-0000-0000-0000-000000000000')); + } + + public function testHistoryPaginates(): void + { + $query = self::getContainer()->get(GetXpHistory::class); + + $total = $query->countForPlayer(PlayerFixture::PLAYER_REGULAR); + self::assertGreaterThan(2, $total); + + $firstPage = $query->forPlayer(PlayerFixture::PLAYER_REGULAR, limit: 2, offset: 0); + $secondPage = $query->forPlayer(PlayerFixture::PLAYER_REGULAR, limit: 2, offset: 2); + + self::assertCount(2, $firstPage); + self::assertNotEquals($firstPage, $secondPage); + } +} diff --git a/tests/Results/PlayerProfileMembershipTest.php b/tests/Results/PlayerProfileMembershipTest.php index 3d613b09c..62949b912 100644 --- a/tests/Results/PlayerProfileMembershipTest.php +++ b/tests/Results/PlayerProfileMembershipTest.php @@ -138,6 +138,8 @@ public function testGrantedMembershipStillActive(): void * rating_count: int|string, * average_rating: null|string, * streak_opted_out: bool, + * content_digest_frequency: string, + * experience_system_opted_out: bool, * ranking_opted_out: bool, * time_predictions_opted_out: bool, * fair_use_policy_accepted_at: null|string, @@ -183,6 +185,8 @@ private function createRow( 'rating_count' => 0, 'average_rating' => null, 'streak_opted_out' => false, + 'content_digest_frequency' => 'weekly', + 'experience_system_opted_out' => false, 'ranking_opted_out' => false, 'time_predictions_opted_out' => false, 'fair_use_policy_accepted_at' => null, diff --git a/tests/Services/Badges/BadgeEvaluatorTest.php b/tests/Services/Badges/BadgeEvaluatorTest.php new file mode 100644 index 000000000..d77920c46 --- /dev/null +++ b/tests/Services/Badges/BadgeEvaluatorTest.php @@ -0,0 +1,224 @@ +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::assertArrayHasKey('puzzles_solved', $byType); + self::assertArrayHasKey('pieces_solved', $byType); + 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('allEarnedTiers')->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'), + xpLedger: new XpLedger($this->createStub(XpEntryRepository::class), 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/Services/Digest/WeeklyDigestEmailRenderingTest.php b/tests/Services/Digest/WeeklyDigestEmailRenderingTest.php new file mode 100644 index 000000000..d5a3cc180 --- /dev/null +++ b/tests/Services/Digest/WeeklyDigestEmailRenderingTest.php @@ -0,0 +1,99 @@ +render(memberVariant: true, data: $this->activityData()); + + self::assertStringContainsString('+42 XP', $html); + self::assertStringContainsString('Puzzle Explorer', $html); + self::assertStringContainsString('unsubscribe', $html); + self::assertStringContainsString('https://example.com/unsubscribe', $html); + } + + public function testFreeTeaserVariantRenders(): void + { + $html = $this->render(memberVariant: false, data: $this->activityData()); + + self::assertStringContainsString('waiting for you', $html); + self::assertStringNotContainsString('Within your reach', $html); + } + + public function testNoActivityVariantRenders(): void + { + $html = $this->render(memberVariant: true, data: $this->quietData()); + + self::assertStringContainsString('a puzzle is always a good idea', $html); + self::assertStringNotContainsString('Your week in numbers', $html); + } + + private function render(bool $memberVariant, WeeklyDigestData $data): string + { + self::bootKernel(); + $twig = self::getContainer()->get(Environment::class); + + return $twig->render('emails/content_digest_weekly.html.twig', [ + 'playerName' => 'Testy', + 'data' => $data, + 'isMember' => $memberVariant, + 'hadActivity' => $data->hadActivity(), + 'unsubscribeUrl' => 'https://example.com/unsubscribe?signed=1', + 'locale' => 'en', + ]); + } + + private function activityData(): WeeklyDigestData + { + return new WeeklyDigestData( + xpGained: 42, + levelsGained: 1, + currentLevel: 12, + achievementsEarned: [ + ['type' => \SpeedPuzzling\Web\Value\BadgeType::PuzzlesSolved, 'tier' => \SpeedPuzzling\Web\Value\BadgeTier::Silver], + ], + solvesCount: 4, + piecesCount: 2500, + minutesSpent: 300, + previousSolvesCount: 2, + previousPiecesCount: 1000, + currentStreakDays: 5, + favoritesActivity: [['name' => 'Jane', 'solves' => 3]], + nextAchievements: [], + mostSolvedPuzzleName: 'Colorful Cats', + mostSolvedPuzzleSolvers: 12, + ); + } + + private function quietData(): WeeklyDigestData + { + return new WeeklyDigestData( + xpGained: 0, + levelsGained: 0, + currentLevel: 12, + achievementsEarned: [], + solvesCount: 0, + piecesCount: 0, + minutesSpent: 0, + previousSolvesCount: 0, + previousPiecesCount: 0, + currentStreakDays: 0, + favoritesActivity: [], + nextAchievements: [], + mostSolvedPuzzleName: null, + mostSolvedPuzzleSolvers: 0, + ); + } +} diff --git a/tests/Services/Xp/XpAntiAbuseTest.php b/tests/Services/Xp/XpAntiAbuseTest.php new file mode 100644 index 000000000..8f3a0e3bd --- /dev/null +++ b/tests/Services/Xp/XpAntiAbuseTest.php @@ -0,0 +1,140 @@ +chainRecomputer = $container->get(XpChainRecomputer::class); + $this->database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testPerSolveXpIsCappedAtBase60(): void + { + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_9000, 50000, 9000); + + $this->award($solveId); + + self::assertSame(60, $this->amount($solveId, 'solve_base')); + } + + public function testSpeedBonusRequiresMedianFromThreeDistinctSolvers(): void + { + // Two distinct solvers only — even a blazing (plausible) time earns no speed bonus. + $this->insertSolve(PlayerFixture::PLAYER_PRIVATE, PuzzleFixture::PUZZLE_500_04, 5000, 500); + $mine = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 2000, 500); + + $this->award($mine); + + self::assertGreaterThan(0, $this->amount($mine, 'solve_base')); + self::assertSame(0, $this->amount($mine, 'solve_speed_bonus')); + } + + public function testSpeedBonusGrantedWithReliableMedianAndPlausiblePace(): void + { + // Positive control: 3 distinct solvers + fast-but-human time → the bonus exists. + $this->insertSolve(PlayerFixture::PLAYER_PRIVATE, PuzzleFixture::PUZZLE_500_04, 5000, 500); + $this->insertSolve(PlayerFixture::PLAYER_ADMIN, PuzzleFixture::PUZZLE_500_04, 6000, 500); + $mine = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 2000, 500); + + $this->award($mine); + + self::assertGreaterThan(0, $this->amount($mine, 'solve_speed_bonus')); + } + + public function testImplausiblePpmSilentlyDeniesSpeedBonusButKeepsBase(): void + { + // 500 pieces in 600 seconds = 50 PPM — far above the plausibility ceiling + // (and above any world record), with an otherwise reliable median. + $this->insertSolve(PlayerFixture::PLAYER_PRIVATE, PuzzleFixture::PUZZLE_500_04, 5000, 500); + $this->insertSolve(PlayerFixture::PLAYER_ADMIN, PuzzleFixture::PUZZLE_500_04, 6000, 500); + $mine = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 600, 500); + + $this->award($mine); + + self::assertGreaterThan(0, $this->amount($mine, 'solve_base'), 'Base XP is never withheld — no user-facing accusation'); + self::assertSame(0, $this->amount($mine, 'solve_speed_bonus')); + } + + public function testRelaxRepeatEarnsExactlyNothing(): void + { + $first = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, 500); + $this->award($first); + + $relaxRepeat = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, null, 500); + $this->award($relaxRepeat); + + $entries = $this->database->fetchOne( + 'SELECT COUNT(*) FROM xp_entry WHERE solving_time_id = :id', + ['id' => $relaxRepeat], + ); + self::assertSame(0, (int) (is_numeric($entries) ? $entries : -1)); + } + + private function award(string $solvingTimeId): void + { + $this->chainRecomputer->awardForNewSolve($solvingTimeId); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function amount(string $solvingTimeId, string $reason): int + { + $value = $this->database->fetchOne( + 'SELECT COALESCE(SUM(amount), 0) FROM xp_entry WHERE solving_time_id = :id AND reason = :reason', + ['id' => $solvingTimeId, 'reason' => $reason], + ); + + return is_numeric($value) ? (int) $value : 0; + } + + private function insertSolve(string $playerId, string $puzzleId, null|int $seconds, int $pieces): string + { + $solveId = Uuid::uuid7()->toString(); + $at = XpCalculator::fullFormulaFrom()->modify('+5 days')->format('Y-m-d H:i:s'); + + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, :seconds, :playerId, :puzzleId, :at, true, NULL, :at, + NULL, NULL, false, false, 1, \'solo\', false, :pieces)', + [ + 'id' => $solveId, + 'seconds' => $seconds, + 'playerId' => $playerId, + 'puzzleId' => $puzzleId, + 'at' => $at, + 'pieces' => $pieces, + ], + ); + + return $solveId; + } +} diff --git a/tests/Services/Xp/XpCalculatorTest.php b/tests/Services/Xp/XpCalculatorTest.php new file mode 100644 index 000000000..0c247e228 --- /dev/null +++ b/tests/Services/Xp/XpCalculatorTest.php @@ -0,0 +1,216 @@ +}> + */ + public static function provideSolves(): array + { + return [ + // --- base scaling & clamps --- + 'plain 500pc solve' => [self::context(), ['solve_base' => 5]], + 'base minimum clamps at 1 (54pc)' => [self::context(pieces: 54), ['solve_base' => 1]], + 'base maximum clamps at 60 (9000pc)' => [self::context(pieces: 9000), ['solve_base' => 60]], + 'base rounds half-up (150pc → 1.5 → 2)' => [self::context(pieces: 150), ['solve_base' => 2]], + 'base rounds half-up (240pc → 2.4 → 2)' => [self::context(pieces: 240), ['solve_base' => 2]], + 'base rounds half-up (250pc → 2.5 → 3)' => [self::context(pieces: 250), ['solve_base' => 3]], + + // --- difficulty multipliers, tiers 1–6 explicit --- + 'tier 1 has no difficulty bonus' => [self::context(pieces: 1000, tier: 1), ['solve_base' => 10]], + 'tier 2 has no difficulty bonus' => [self::context(pieces: 1000, tier: 2), ['solve_base' => 10]], + 'tier 3 = 15% bonus' => [self::context(pieces: 1000, tier: 3), ['solve_base' => 10, 'solve_difficulty_bonus' => 2]], + 'tier 4 = 30% bonus' => [self::context(pieces: 1000, tier: 4), ['solve_base' => 10, 'solve_difficulty_bonus' => 3]], + 'tier 5 = 40% bonus' => [self::context(pieces: 1000, tier: 5), ['solve_base' => 10, 'solve_difficulty_bonus' => 4]], + 'tier 6 = 50% bonus' => [self::context(pieces: 1000, tier: 6), ['solve_base' => 10, 'solve_difficulty_bonus' => 5]], + 'unrated puzzle counts as multiplier 1.00' => [self::context(pieces: 1000, tier: null), ['solve_base' => 10]], + 'difficulty bonus rounding to zero creates no entry' => [self::context(pieces: 100, tier: 3), ['solve_base' => 1]], + + // --- team multiplier --- + 'duo/team solve earns 0.75×' => [self::context(pieces: 1000, team: true), ['solve_base' => 8]], + 'team with difficulty bonus' => [self::context(pieces: 1000, tier: 4, team: true), ['solve_base' => 8, 'solve_difficulty_bonus' => 2]], + + // --- unboxed --- + 'unboxed adds 20% of base+difficulty' => [ + self::context(pieces: 1000, tier: 4, unboxed: true), + ['solve_base' => 10, 'solve_difficulty_bonus' => 3, 'solve_unboxed_bonus' => 3], + ], + 'unboxed alone' => [self::context(unboxed: true), ['solve_base' => 5, 'solve_unboxed_bonus' => 1]], + + // --- occurrence ladder (positions count ALL solves of the puzzle, any mode) --- + 'timed 2nd occurrence earns 50%' => [self::context(occurrence: 2), ['solve_base' => 3]], + 'timed 3rd occurrence earns 25%' => [self::context(occurrence: 3), ['solve_base' => 1]], + 'timed 10th occurrence still earns 25%' => [self::context(pieces: 1000, occurrence: 10), ['solve_base' => 3]], + 'relax 1st occurrence earns 50%' => [self::context(timed: false), ['solve_base' => 3]], + 'timed solve after an earlier relax solve is occurrence 2' => [self::context(occurrence: 2), ['solve_base' => 3]], + + // --- base floor --- + 'base part floors at 1 when core > 0' => [ + self::context(pieces: 100, team: true, occurrence: 3), + ['solve_base' => 1], + ], + + // --- backfill: core only, bonuses ignored even when eligible --- + 'backfill drops speed, weekly and warm-up' => [ + self::context(pieces: 2000, tier: 5, unboxed: true, backfill: true, percentile: SpeedPercentile::Top10, weekCount: 0, firstOfDay: true), + ['solve_base' => 20, 'solve_difficulty_bonus' => 8, 'solve_unboxed_bonus' => 6], + ], + + // --- speed bonus --- + 'speed bonus above median = 5% of core' => [ + self::context(pieces: 1000, percentile: SpeedPercentile::AboveMedian), + ['solve_base' => 10, 'solve_speed_bonus' => 1], + ], + 'speed bonus top 25% = 10% of core' => [ + self::context(pieces: 1000, percentile: SpeedPercentile::Top25), + ['solve_base' => 10, 'solve_speed_bonus' => 1], + ], + 'speed bonus top 10% = 15% of core' => [ + self::context(pieces: 1000, percentile: SpeedPercentile::Top10), + ['solve_base' => 10, 'solve_speed_bonus' => 2], + ], + 'speed bonus rounding to zero creates no entry' => [ + self::context(pieces: 500, percentile: SpeedPercentile::AboveMedian), + ['solve_base' => 5], + ], + 'duo never gets a speed bonus' => [ + self::context(pieces: 1000, team: true, percentile: SpeedPercentile::Top10), + ['solve_base' => 8], + ], + 'relax never gets a speed bonus' => [ + self::context(pieces: 1000, timed: false, percentile: SpeedPercentile::Top10), + ['solve_base' => 5], + ], + + // --- weekly boost: first 5 XP-earning solves per ISO week --- + 'weekly boost = 50% of core for the first solve of the week' => [ + self::context(weekCount: 0), + ['solve_base' => 5, 'solve_weekly_boost' => 3], + ], + 'weekly boost still applies to the 5th solve of the week' => [ + self::context(weekCount: 4), + ['solve_base' => 5, 'solve_weekly_boost' => 3], + ], + 'no weekly boost from the 6th solve of the week' => [ + self::context(weekCount: 5), + ['solve_base' => 5], + ], + 'weekly boost computed on full core incl. bonuses' => [ + self::context(pieces: 1000, tier: 4, unboxed: true, weekCount: 0), + ['solve_base' => 10, 'solve_difficulty_bonus' => 3, 'solve_unboxed_bonus' => 3, 'solve_weekly_boost' => 8], + ], + 'relax first solve gets the weekly boost too' => [ + self::context(timed: false, weekCount: 0), + ['solve_base' => 3, 'solve_weekly_boost' => 1], + ], + + // --- daily warm-up --- + 'first XP-earning solve of the day gets flat +2' => [ + self::context(firstOfDay: true), + ['solve_base' => 5, 'solve_daily_warmup' => 2], + ], + + // --- everything at once --- + 'full receipt: base, difficulty, unboxed, speed, weekly, warm-up' => [ + self::context(pieces: 1000, tier: 4, unboxed: true, percentile: SpeedPercentile::AboveMedian, weekCount: 0, firstOfDay: true), + [ + 'solve_base' => 10, + 'solve_difficulty_bonus' => 3, + 'solve_unboxed_bonus' => 3, + 'solve_speed_bonus' => 1, + 'solve_weekly_boost' => 8, + 'solve_daily_warmup' => 2, + ], + ], + ]; + } + + /** + * @param array $expected reason value => amount + */ + #[DataProvider('provideSolves')] + public function testCalculate(SolveXpContext $context, array $expected): void + { + $awards = (new XpCalculator())->calculate($context); + + $actual = []; + foreach ($awards as $award) { + self::assertArrayNotHasKey($award->reason->value, $actual, 'Duplicate reason in one receipt'); + $actual[$award->reason->value] = $award->amount; + } + + self::assertSame($expected, $actual); + } + + public function testRelaxRepeatEarnsNothingAtAll(): void + { + $context = self::context(timed: false, occurrence: 2, weekCount: 0, firstOfDay: true); + + self::assertSame([], (new XpCalculator())->calculate($context)); + } + + public function testRelaxThirdOccurrenceEarnsNothing(): void + { + self::assertSame([], (new XpCalculator())->calculate(self::context(timed: false, occurrence: 3))); + } + + public function testInvalidDifficultyTierIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new XpCalculator())->calculate(self::context(tier: 7)); + } + + public function testOccurrenceIndexMustBePositive(): void + { + $this->expectException(InvalidArgumentException::class); + + (new XpCalculator())->calculate(self::context(occurrence: 0)); + } + + public function testFullFormulaCutoffParses(): void + { + self::assertSame(XpCalculator::FULL_FORMULA_FROM, XpCalculator::fullFormulaFrom()->format('Y-m-d H:i:s')); + } +} diff --git a/tests/Services/Xp/XpChainRecomputerTest.php b/tests/Services/Xp/XpChainRecomputerTest.php new file mode 100644 index 000000000..6c1955401 --- /dev/null +++ b/tests/Services/Xp/XpChainRecomputerTest.php @@ -0,0 +1,497 @@ +chainRecomputer = $container->get(XpChainRecomputer::class); + $this->database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testAwardCreatesFullReceiptForNewSolve(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at, 500); + + $this->award($solveId); + + self::assertSame( + ['solve_base' => 5, 'solve_daily_warmup' => 2, 'solve_weekly_boost' => 3], + $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR), + ); + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + } + + public function testAwardIsIdempotentUnderRedelivery(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at, 500); + + $this->award($solveId); + $firstTotals = $this->playerTotals(PlayerFixture::PLAYER_REGULAR); + + $this->award($solveId); + + self::assertSame($firstTotals, $this->playerTotals(PlayerFixture::PLAYER_REGULAR)); + self::assertSame(3, $this->entryCountFor($solveId)); + } + + public function testAwardTeamSolveGrantsEveryRegisteredParticipant(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveId = $this->insertSolve( + PlayerFixture::PLAYER_REGULAR, + PuzzleFixture::PUZZLE_500_04, + 3600, + $at, + 500, + team: [PlayerFixture::PLAYER_REGULAR, PlayerFixture::PLAYER_PRIVATE], + ); + + $this->award($solveId); + + // 500pc duo: base 5 × 0.75 = 3.75 → 4 for BOTH participants, own weekly/daily each. + $owner = $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR); + $teammate = $this->entriesFor($solveId, PlayerFixture::PLAYER_PRIVATE); + + self::assertSame(['solve_base' => 4, 'solve_daily_warmup' => 2, 'solve_weekly_boost' => 2], $owner); + self::assertSame($owner, $teammate); + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_PRIVATE); + } + + public function testWeeklyBoostStopsAfterFiveSolvesAndResetsNextWeek(): void + { + $base = XpCalculator::fullFormulaFrom()->modify('+28 days'); + $monday = $base->setISODate((int) $base->format('o'), (int) $base->format('W'))->setTime(12, 0); + + $puzzles = [ + PuzzleFixture::PUZZLE_500_04, + PuzzleFixture::PUZZLE_1000_04, + PuzzleFixture::PUZZLE_1000_05, + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_4000, + PuzzleFixture::PUZZLE_5000, + ]; + + $solveIds = []; + foreach ($puzzles as $day => $puzzleId) { + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, $puzzleId, 7200, $monday->modify("+{$day} days"), 500); + $this->award($solveId); + $solveIds[] = $solveId; + } + + // First five solves of the ISO week are boosted, the sixth is not. + for ($i = 0; $i < 5; $i++) { + self::assertArrayHasKey('solve_weekly_boost', $this->entriesFor($solveIds[$i], PlayerFixture::PLAYER_REGULAR), "Solve {$i} must be boosted"); + } + self::assertArrayNotHasKey('solve_weekly_boost', $this->entriesFor($solveIds[5], PlayerFixture::PLAYER_REGULAR)); + + // Every solve was on its own day — each gets the daily warm-up. + foreach ($solveIds as $solveId) { + self::assertArrayHasKey('solve_daily_warmup', $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR)); + } + + // A solve the following Monday starts a fresh week and is boosted again. + $nextWeek = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_6000, 7200, $monday->modify('+7 days'), 6000); + $this->award($nextWeek); + + self::assertArrayHasKey('solve_weekly_boost', $this->entriesFor($nextWeek, PlayerFixture::PLAYER_REGULAR)); + } + + public function testEditRebuildsOccurrenceChainInBothDirections(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveA = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at->setTime(9, 0), 500); + $solveB = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3500, $at->modify('+1 day')->setTime(9, 0), 500); + $this->award($solveA); + $this->award($solveB); + + self::assertSame(5, $this->entriesFor($solveA, PlayerFixture::PLAYER_REGULAR)['solve_base']); + self::assertSame(3, $this->entriesFor($solveB, PlayerFixture::PLAYER_REGULAR)['solve_base']); + + // "Edit" solve B to a finished date BEFORE solve A → canonical order flips. + $this->database->executeStatement( + 'UPDATE puzzle_solving_time SET finished_at = :at WHERE id = :id', + ['at' => $at->modify('-1 day')->setTime(9, 0)->format('Y-m-d H:i:s'), 'id' => $solveB], + ); + + $this->chainRecomputer->rebuildChainForEditedSolve($solveB); + $this->entityManager->flush(); + $this->entityManager->clear(); + + self::assertSame(3, $this->entriesFor($solveA, PlayerFixture::PLAYER_REGULAR)['solve_base']); + self::assertSame(5, $this->entriesFor($solveB, PlayerFixture::PLAYER_REGULAR)['solve_base']); + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + } + + public function testDeleteCompensatesAndPromotesRemainingChain(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveA = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at->setTime(9, 0), 500); + $solveB = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3500, $at->modify('+1 day')->setTime(9, 0), 500); + $this->award($solveA); + $this->award($solveB); + + // Simulate DeletePuzzleSolvingTimeHandler: row removed, then the async message. + $this->database->executeStatement('DELETE FROM puzzle_solving_time WHERE id = :id', ['id' => $solveA]); + + $this->chainRecomputer->compensateAndRebuildAfterDeletion($solveA, PuzzleFixture::PUZZLE_500_04); + $this->entityManager->flush(); + $this->entityManager->clear(); + + // Solve A: original receipt (5+3+2) preserved plus exact negative mirrors. + $aEntries = $this->allEntriesFor($solveA, PlayerFixture::PLAYER_REGULAR); + self::assertSame(0, array_sum(array_column($aEntries, 'amount'))); + self::assertCount(6, $aEntries); + self::assertSame(3, $this->countByReason($aEntries, 'solve_compensation')); + + // Solve B got promoted to first occurrence and now owns the week/day bonuses. + self::assertSame( + ['solve_base' => 5, 'solve_daily_warmup' => 2, 'solve_weekly_boost' => 3], + $this->entriesFor($solveB, PlayerFixture::PLAYER_REGULAR), + ); + + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + self::assertSame(10, $this->playerTotals(PlayerFixture::PLAYER_REGULAR)['xp_total']); + } + + public function testDeleteIsIdempotentUnderRedelivery(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveA = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at, 500); + $this->award($solveA); + + $this->database->executeStatement('DELETE FROM puzzle_solving_time WHERE id = :id', ['id' => $solveA]); + + $this->chainRecomputer->compensateAndRebuildAfterDeletion($solveA, PuzzleFixture::PUZZLE_500_04); + $this->entityManager->flush(); + $this->entityManager->clear(); + $firstTotals = $this->playerTotals(PlayerFixture::PLAYER_REGULAR); + $firstCount = $this->entryCountFor($solveA); + + $this->chainRecomputer->compensateAndRebuildAfterDeletion($solveA, PuzzleFixture::PUZZLE_500_04); + $this->entityManager->flush(); + $this->entityManager->clear(); + + self::assertSame($firstTotals, $this->playerTotals(PlayerFixture::PLAYER_REGULAR)); + self::assertSame($firstCount, $this->entryCountFor($solveA)); + } + + public function testDifficultySettlementLandsOnceWhenPuzzleGetsRated(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 3600, $at, 500); + $this->award($solveId); + + self::assertArrayNotHasKey('solve_difficulty_bonus', $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR)); + + $this->ratePuzzle(PuzzleFixture::PUZZLE_500_04, tier: 5); + + $settled = $this->settle(); + self::assertGreaterThanOrEqual(1, $settled); + + $entries = $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR); + // base_part 5.0 × (1.40 − 1.00) = 2 — settled with the tier at settlement time. + self::assertSame(2, $entries['difficulty_settlement']); + + $weeklyDelta = $this->database->fetchOne( + "SELECT in_weekly_delta FROM xp_entry WHERE solving_time_id = :id AND reason = 'difficulty_settlement'", + ['id' => $solveId], + ); + self::assertFalse((bool) $weeklyDelta, 'Settlements never count toward the weekly delta.'); + + // Frozen forever: the second run settles nothing more for this solve. + $this->settle(); + self::assertSame(1, $this->countEntries($solveId, 'difficulty_settlement')); + + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + } + + public function testSpeedSettlementLandsWhenMedianBecomesReliable(): void + { + $at = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveId = $this->insertSolve(PlayerFixture::PLAYER_REGULAR, PuzzleFixture::PUZZLE_500_04, 1000, $at, 500); + $this->award($solveId); + + // Only one distinct solver — no speed bonus at award time. + self::assertArrayNotHasKey('solve_speed_bonus', $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR)); + + // Two more solvers appear → median from 3 distinct solvers exists now. + $this->insertSolve(PlayerFixture::PLAYER_PRIVATE, PuzzleFixture::PUZZLE_500_04, 5000, $at->modify('+1 day'), 500); + $this->insertSolve(PlayerFixture::PLAYER_ADMIN, PuzzleFixture::PUZZLE_500_04, 6000, $at->modify('+2 days'), 500); + + $this->settle(); + + $entries = $this->entriesFor($solveId, PlayerFixture::PLAYER_REGULAR); + // Fastest of [1000, 5000, 6000] → top-10%: core 5.0 × 0.15 = 0.75 → 1. + self::assertSame(1, $entries['speed_settlement']); + + // Frozen forever. + $this->settle(); + self::assertSame(1, $this->countEntries($solveId, 'speed_settlement')); + } + + public function testBadgeEvaluatorGrantsAchievementXpOncePerTier(): void + { + $container = self::getContainer(); + $evaluator = $container->get(BadgeEvaluator::class); + + $newBadges = $evaluator->recalculateForPlayer(PlayerFixture::PLAYER_REGULAR); + $this->entityManager->flush(); + + self::assertNotEmpty($newBadges); + + // P3 expansion proof: the new conditions evaluate and award — the fixture player + // has 1 relax solve (Zen Puzzler Bronze) and 15 first-attempt solves (First Try Bronze). + $earnedTypes = array_map(static fn ($badge) => $badge->type->value, $newBadges); + self::assertContains('zen_puzzler', $earnedTypes); + self::assertContains('first_try', $earnedTypes); + + foreach ($newBadges as $badge) { + $amount = $this->database->fetchOne( + "SELECT amount FROM xp_entry WHERE badge_id = :badgeId AND reason = 'achievement'", + ['badgeId' => $badge->id->toString()], + ); + $expected = match ($badge->tier) { + 1 => 5, + 2 => 10, + 3 => 25, + 4 => 50, + 5 => 100, + null => 25, + default => self::fail('Unexpected tier'), + }; + self::assertSame($expected, is_numeric($amount) ? (int) $amount : null, "Badge {$badge->type->value} tier {$badge->tier}"); + } + + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + + // Re-evaluation grants nothing new. + $countBefore = $this->achievementEntryCount(PlayerFixture::PLAYER_REGULAR); + $evaluator->recalculateForPlayer(PlayerFixture::PLAYER_REGULAR); + $this->entityManager->flush(); + self::assertSame($countBefore, $this->achievementEntryCount(PlayerFixture::PLAYER_REGULAR)); + } + + public function testBackfilledAchievementXpStaysOutOfWeeklyDelta(): void + { + $evaluator = self::getContainer()->get(BadgeEvaluator::class); + + $newBadges = $evaluator->recalculateForPlayer(PlayerFixture::PLAYER_PRIVATE, isBackfill: true); + $this->entityManager->flush(); + + self::assertNotEmpty($newBadges); + + $inDelta = $this->database->fetchOne( + "SELECT COUNT(*) FROM xp_entry WHERE player_id = :playerId AND reason = 'achievement' AND in_weekly_delta = true", + ['playerId' => PlayerFixture::PLAYER_PRIVATE], + ); + + self::assertSame(0, is_numeric($inDelta) ? (int) $inDelta : -1); + } + + private function award(string $solvingTimeId): void + { + $this->chainRecomputer->awardForNewSolve($solvingTimeId); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function settle(): int + { + $settled = $this->chainRecomputer->settlePendingBonuses(); + $this->entityManager->flush(); + $this->entityManager->clear(); + + return $settled; + } + + /** + * @return array reason => amount + */ + private function entriesFor(string $solvingTimeId, string $playerId): array + { + /** @var list $rows */ + $rows = $this->database->fetchAllAssociative( + 'SELECT reason, amount FROM xp_entry WHERE solving_time_id = :id AND player_id = :playerId ORDER BY reason', + ['id' => $solvingTimeId, 'playerId' => $playerId], + ); + + $entries = []; + foreach ($rows as $row) { + $entries[$row['reason']] = $row['amount']; + } + + return $entries; + } + + /** + * @return list + */ + private function allEntriesFor(string $solvingTimeId, string $playerId): array + { + /** @var list $rows */ + $rows = $this->database->fetchAllAssociative( + 'SELECT reason, amount FROM xp_entry WHERE solving_time_id = :id AND player_id = :playerId ORDER BY created_at, reason', + ['id' => $solvingTimeId, 'playerId' => $playerId], + ); + + return $rows; + } + + /** + * @param list $entries + */ + private function countByReason(array $entries, string $reason): int + { + return count(array_filter($entries, static fn (array $entry): bool => $entry['reason'] === $reason)); + } + + private function entryCountFor(string $solvingTimeId): int + { + $value = $this->database->fetchOne( + 'SELECT COUNT(*) FROM xp_entry WHERE solving_time_id = :id', + ['id' => $solvingTimeId], + ); + + return is_numeric($value) ? (int) $value : -1; + } + + private function countEntries(string $solvingTimeId, string $reason): int + { + $value = $this->database->fetchOne( + 'SELECT COUNT(*) FROM xp_entry WHERE solving_time_id = :id AND reason = :reason', + ['id' => $solvingTimeId, 'reason' => $reason], + ); + + return is_numeric($value) ? (int) $value : -1; + } + + private function achievementEntryCount(string $playerId): int + { + $value = $this->database->fetchOne( + "SELECT COUNT(*) FROM xp_entry WHERE player_id = :playerId AND reason = 'achievement'", + ['playerId' => $playerId], + ); + + return is_numeric($value) ? (int) $value : -1; + } + + /** + * @return array{xp_total: int, level: int} + */ + private function playerTotals(string $playerId): array + { + /** @var array{xp_total: int, level: int}|false $row */ + $row = $this->database->fetchAssociative( + 'SELECT xp_total, level FROM player WHERE id = :playerId', + ['playerId' => $playerId], + ); + + self::assertNotFalse($row); + + return $row; + } + + private function assertTotalsMatchLedger(string $playerId): void + { + $ledgerSum = $this->database->fetchOne( + 'SELECT COALESCE(SUM(amount), 0) FROM xp_entry WHERE player_id = :playerId', + ['playerId' => $playerId], + ); + $totals = $this->playerTotals($playerId); + + self::assertSame((int) (is_numeric($ledgerSum) ? $ledgerSum : -1), $totals['xp_total']); + self::assertSame(LevelTable::levelForXp($totals['xp_total']), $totals['level']); + } + + private function ratePuzzle(string $puzzleId, int $tier): void + { + $this->database->executeStatement( + "INSERT INTO puzzle_difficulty (puzzle_id, difficulty_tier, difficulty_score, confidence, sample_size, computed_at) + VALUES (:puzzleId, :tier, 50, 'high', 10, NOW())", + ['puzzleId' => $puzzleId, 'tier' => $tier], + ); + } + + /** + * @param list|null $team + */ + private function insertSolve( + string $playerId, + string $puzzleId, + null|int $seconds, + \DateTimeImmutable $at, + int $piecesSnapshot, + null|array $team = null, + ): string { + $solveId = Uuid::uuid7()->toString(); + + $teamJson = null; + $puzzlingType = 'solo'; + $puzzlersCount = 1; + + if ($team !== null) { + $puzzlers = []; + foreach ($team as $teamPlayerId) { + $puzzlers[] = [ + 'player_id' => $teamPlayerId, + 'player_name' => null, + 'player_code' => null, + 'player_country' => null, + 'is_private' => false, + ]; + } + $teamJson = json_encode(['team_id' => null, 'puzzlers' => $puzzlers], JSON_THROW_ON_ERROR); + $puzzlersCount = count($team); + $puzzlingType = $puzzlersCount === 2 ? 'duo' : 'team'; + } + + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, :seconds, :playerId, :puzzleId, :at, true, :team, :at, + NULL, NULL, false, false, :puzzlersCount, :puzzlingType, false, :pieces)', + [ + 'id' => $solveId, + 'seconds' => $seconds, + 'playerId' => $playerId, + 'puzzleId' => $puzzleId, + 'at' => $at->format('Y-m-d H:i:s'), + 'team' => $teamJson, + 'puzzlersCount' => $puzzlersCount, + 'puzzlingType' => $puzzlingType, + 'pieces' => $piecesSnapshot, + ], + ); + + return $solveId; + } +} diff --git a/tests/Services/Xp/XpRecomputerTest.php b/tests/Services/Xp/XpRecomputerTest.php new file mode 100644 index 000000000..73c96e823 --- /dev/null +++ b/tests/Services/Xp/XpRecomputerTest.php @@ -0,0 +1,265 @@ +recomputer = $container->get(XpRecomputer::class); + $this->database = $container->get(Connection::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testRecomputeRebuildsLedgerDeterministically(): void + { + $playerId = PlayerFixture::PLAYER_REGULAR; + + // A pre-existing achievement entry must survive the rebuild untouched. + $badgeId = $this->insertAchievementEntry($playerId, xp: 25); + + // Two solves after the full-formula cutoff exercise weekly boost + daily warm-up. + $afterCutoff = XpCalculator::fullFormulaFrom()->modify('+10 days'); + $solveA = $this->insertSolve($playerId, PuzzleFixture::PUZZLE_500_04, 3600, $afterCutoff->setTime(9, 0), 500); + $solveB = $this->insertSolve($playerId, PuzzleFixture::PUZZLE_1500_02, 7200, $afterCutoff->setTime(15, 0), 1500); + + $this->recompute($playerId); + + // Occurrence ladder on PUZZLE_500_02 (fixture solves 20/15/10 days ago): 5 → 3 → 1. + self::assertSame(5, $this->entryAmount(PuzzleSolvingTimeFixture::TIME_06, 'solve_base')); + self::assertSame(3, $this->entryAmount(PuzzleSolvingTimeFixture::TIME_07, 'solve_base')); + self::assertSame(1, $this->entryAmount(PuzzleSolvingTimeFixture::TIME_08, 'solve_base')); + + // Relax solve of a puzzle already solved twice = relax repeat: zero entries. + self::assertSame([], $this->entriesFor(PuzzleSolvingTimeFixture::TIME_46_RELAX_NO_FINISHED_AT)); + + // Backfill solves never receive weekly boost or daily warm-up. + self::assertSame( + ['solve_base' => 5], + $this->entriesFor(PuzzleSolvingTimeFixture::TIME_06), + ); + + // Post-cutoff solve A: base 5, weekly boost 3 (first of week), warm-up 2 (first of day). + self::assertSame( + ['solve_base' => 5, 'solve_daily_warmup' => 2, 'solve_weekly_boost' => 3], + $this->entriesFor($solveA), + ); + + // Post-cutoff solve B same day: base 15, weekly boost 8 — no second warm-up. + self::assertSame( + ['solve_base' => 15, 'solve_weekly_boost' => 8], + $this->entriesFor($solveB), + ); + + // Achievement entry preserved. + $achievementCount = $this->database->fetchOne( + 'SELECT COUNT(*) FROM xp_entry WHERE badge_id = :badgeId', + ['badgeId' => $badgeId], + ); + self::assertSame(1, (int) (is_numeric($achievementCount) ? $achievementCount : 0)); + + // Denormalized totals match the ledger and the level curve. + $this->assertTotalsMatchLedger($playerId); + + // Idempotency: a second run yields an identical ledger and identical totals. + $firstRun = $this->ledgerSnapshot($playerId); + $firstTotals = $this->playerTotals($playerId); + + $this->recompute($playerId); + + self::assertSame($firstRun, $this->ledgerSnapshot($playerId)); + self::assertSame($firstTotals, $this->playerTotals($playerId)); + $this->assertTotalsMatchLedger($playerId); + } + + public function testTeamParticipantEarnsThroughTheirOwnOccurrenceChain(): void + { + // TIME_12 is a duo solve of PUZZLE_1000_01 owned by PLAYER_REGULAR with + // PLAYER_PRIVATE as team member. For the owner it is their first solve of that + // puzzle (10 × 0.75 = 7.5 → 8); for the participant it comes after their own + // earlier solo solve (TIME_16), so it is occurrence 2 (10 × 0.75 × 0.5 = 3.75 → 4). + $this->recompute(PlayerFixture::PLAYER_REGULAR); + $this->recompute(PlayerFixture::PLAYER_PRIVATE); + + $owner = $this->database->fetchOne( + 'SELECT amount FROM xp_entry WHERE solving_time_id = :id AND player_id = :playerId AND reason = :reason', + ['id' => PuzzleSolvingTimeFixture::TIME_12, 'playerId' => PlayerFixture::PLAYER_REGULAR, 'reason' => 'solve_base'], + ); + $participant = $this->database->fetchOne( + 'SELECT amount FROM xp_entry WHERE solving_time_id = :id AND player_id = :playerId AND reason = :reason', + ['id' => PuzzleSolvingTimeFixture::TIME_12, 'playerId' => PlayerFixture::PLAYER_PRIVATE, 'reason' => 'solve_base'], + ); + + self::assertSame(8, (int) (is_numeric($owner) ? $owner : 0)); + self::assertSame(4, (int) (is_numeric($participant) ? $participant : 0)); + + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_REGULAR); + $this->assertTotalsMatchLedger(PlayerFixture::PLAYER_PRIVATE); + } + + public function testSuspiciousSolvesEarnNothing(): void + { + $playerId = PlayerFixture::PLAYER_REGULAR; + $suspicious = $this->insertSolve( + $playerId, + PuzzleFixture::PUZZLE_500_04, + 600, + XpCalculator::fullFormulaFrom()->modify('+20 days'), + 500, + suspicious: true, + ); + + $this->recompute($playerId); + + self::assertSame([], $this->entriesFor($suspicious)); + } + + private function recompute(string $playerId): void + { + $this->recomputer->recomputeForPlayer($playerId); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + /** + * @return array reason => amount + */ + private function entriesFor(string $solvingTimeId): array + { + /** @var list $rows */ + $rows = $this->database->fetchAllAssociative( + 'SELECT reason, amount FROM xp_entry WHERE solving_time_id = :id ORDER BY reason', + ['id' => $solvingTimeId], + ); + + $entries = []; + foreach ($rows as $row) { + $entries[$row['reason']] = $row['amount']; + } + + return $entries; + } + + private function entryAmount(string $solvingTimeId, string $reason): int + { + $value = $this->database->fetchOne( + 'SELECT amount FROM xp_entry WHERE solving_time_id = :id AND reason = :reason', + ['id' => $solvingTimeId, 'reason' => $reason], + ); + + return is_numeric($value) ? (int) $value : 0; + } + + /** + * @return list> + */ + private function ledgerSnapshot(string $playerId): array + { + return $this->database->fetchAllAssociative( + 'SELECT reason, amount, solving_time_id, badge_id, in_weekly_delta, earned_at + FROM xp_entry WHERE player_id = :playerId + ORDER BY earned_at, solving_time_id, reason', + ['playerId' => $playerId], + ); + } + + /** + * @return array{xp_total: int, level: int} + */ + private function playerTotals(string $playerId): array + { + /** @var array{xp_total: int, level: int}|false $row */ + $row = $this->database->fetchAssociative( + 'SELECT xp_total, level FROM player WHERE id = :playerId', + ['playerId' => $playerId], + ); + + self::assertNotFalse($row); + + return $row; + } + + private function assertTotalsMatchLedger(string $playerId): void + { + $ledgerSum = $this->database->fetchOne( + 'SELECT COALESCE(SUM(amount), 0) FROM xp_entry WHERE player_id = :playerId', + ['playerId' => $playerId], + ); + $totals = $this->playerTotals($playerId); + + self::assertSame((int) (is_numeric($ledgerSum) ? $ledgerSum : -1), $totals['xp_total']); + self::assertSame(LevelTable::levelForXp($totals['xp_total']), $totals['level']); + } + + private function insertAchievementEntry(string $playerId, int $xp): string + { + $badgeId = Uuid::uuid7()->toString(); + + $this->database->executeStatement( + "INSERT INTO badge (id, player_id, type, earned_at, tier) + VALUES (:id, :playerId, 'puzzles_solved', NOW(), 1)", + ['id' => $badgeId, 'playerId' => $playerId], + ); + + $this->database->executeStatement( + "INSERT INTO xp_entry (id, player_id, amount, reason, in_weekly_delta, earned_at, created_at, badge_id) + VALUES (:id, :playerId, :amount, 'achievement', true, NOW(), NOW(), :badgeId)", + ['id' => Uuid::uuid7()->toString(), 'playerId' => $playerId, 'amount' => $xp, 'badgeId' => $badgeId], + ); + + return $badgeId; + } + + private function insertSolve( + string $playerId, + string $puzzleId, + int $seconds, + \DateTimeImmutable $at, + int $piecesSnapshot, + bool $suspicious = false, + ): string { + $solveId = Uuid::uuid7()->toString(); + + $this->database->executeStatement( + 'INSERT INTO puzzle_solving_time + (id, seconds_to_solve, player_id, puzzle_id, tracked_at, verified, team, finished_at, + comment, finished_puzzle_photo, first_attempt, unboxed, puzzlers_count, puzzling_type, + suspicious, pieces_count_snapshot) + VALUES + (:id, :seconds, :playerId, :puzzleId, :at, true, NULL, :at, + NULL, NULL, false, false, 1, :puzzlingType, :suspicious, :pieces)', + [ + 'id' => $solveId, + 'seconds' => $seconds, + 'playerId' => $playerId, + 'puzzleId' => $puzzleId, + 'at' => $at->format('Y-m-d H:i:s'), + 'puzzlingType' => 'solo', + 'suspicious' => $suspicious ? 'true' : 'false', + 'pieces' => $piecesSnapshot, + ], + ); + + return $solveId; + } +} diff --git a/tests/TestDouble/FakeBadgeCondition.php b/tests/TestDouble/FakeBadgeCondition.php new file mode 100644 index 000000000..d79018d87 --- /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 000000000..c6d0587ea --- /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, bool $isBackfill = false): array + { + return $this->returnValue; + } +} diff --git a/tests/TestDouble/FakePlayerRepository.php b/tests/TestDouble/FakePlayerRepository.php new file mode 100644 index 000000000..2649f079d --- /dev/null +++ b/tests/TestDouble/FakePlayerRepository.php @@ -0,0 +1,21 @@ +player; + } +} diff --git a/tests/TestDouble/MessageBusSpy.php b/tests/TestDouble/MessageBusSpy.php new file mode 100644 index 000000000..7ab1c5cda --- /dev/null +++ b/tests/TestDouble/MessageBusSpy.php @@ -0,0 +1,21 @@ + */ + public array $dispatched = []; + + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->dispatched[] = $message; + + return new Envelope($message); + } +} diff --git a/tests/TestDouble/SavedBadgeRecorder.php b/tests/TestDouble/SavedBadgeRecorder.php new file mode 100644 index 000000000..84e36a121 --- /dev/null +++ b/tests/TestDouble/SavedBadgeRecorder.php @@ -0,0 +1,13 @@ + */ + public array $saved = []; +} diff --git a/tests/TestDouble/TransportSpy.php b/tests/TestDouble/TransportSpy.php new file mode 100644 index 000000000..043bf937f --- /dev/null +++ b/tests/TestDouble/TransportSpy.php @@ -0,0 +1,37 @@ + */ + public array $sent = []; + + public function __construct( + private readonly null|\Throwable $throwOnSend = null, + ) { + } + + public function send(RawMessage $message, null|Envelope $envelope = null): null|SentMessage + { + if ($this->throwOnSend !== null) { + throw $this->throwOnSend; + } + + $this->sent[] = $message; + + return null; + } + + public function __toString(): string + { + return 'spy://'; + } +} diff --git a/tests/Value/LevelTableTest.php b/tests/Value/LevelTableTest.php new file mode 100644 index 000000000..083e76094 --- /dev/null +++ b/tests/Value/LevelTableTest.php @@ -0,0 +1,101 @@ + + */ + public static function provideXpToLevel(): array + { + return [ + 'zero xp is level 1' => [0, 1], + 'just below level 2' => [4, 1], + 'exactly level 2' => [5, 2], + 'between levels' => [9, 2], + 'exactly level 3' => [10, 3], + 'exactly level 10' => [64, 10], + 'mid curve' => [300, 22], + 'exactly level 25' => [363, 25], + 'just below max level' => [3159, 49], + 'exactly max level' => [3160, 50], + 'far beyond max level' => [99999, 50], + 'negative xp clamps to level 1' => [-10, 1], + ]; + } + + #[DataProvider('provideXpToLevel')] + public function testLevelForXp(int $xp, int $expectedLevel): void + { + self::assertSame($expectedLevel, LevelTable::levelForXp($xp)); + } + + public function testXpForLevelBoundaries(): void + { + self::assertSame(0, LevelTable::xpForLevel(1)); + self::assertSame(5, LevelTable::xpForLevel(2)); + self::assertSame(223, LevelTable::xpForLevel(20)); + self::assertSame(1376, LevelTable::xpForLevel(40)); + self::assertSame(3160, LevelTable::xpForLevel(50)); + } + + public function testXpForLevelRejectsLevelZero(): void + { + $this->expectException(InvalidArgumentException::class); + + LevelTable::xpForLevel(0); + } + + public function testXpForLevelRejectsLevelAboveMax(): void + { + $this->expectException(InvalidArgumentException::class); + + LevelTable::xpForLevel(51); + } + + public function testProgressToNextAtLevelStart(): void + { + self::assertSame(0.0, LevelTable::progressToNext(0)); + self::assertSame(0.0, LevelTable::progressToNext(5)); + } + + public function testProgressToNextMidLevel(): void + { + // Level 2 spans 5..10 — 8 XP is 3/5 of the way to level 3. + self::assertSame(0.6, LevelTable::progressToNext(8)); + } + + public function testProgressToNextJustBeforeLevelUp(): void + { + $progress = LevelTable::progressToNext(3159); + + self::assertNotNull($progress); + self::assertGreaterThan(0.99, $progress); + self::assertLessThan(1.0, $progress); + } + + public function testProgressToNextIsNullAtMaxLevel(): void + { + self::assertNull(LevelTable::progressToNext(3160)); + self::assertNull(LevelTable::progressToNext(99999)); + } + + public function testCurveIsStrictlyIncreasing(): void + { + $previous = LevelTable::xpForLevel(1); + + for ($level = 2; $level <= LevelTable::MAX_LEVEL; $level++) { + $current = LevelTable::xpForLevel($level); + self::assertGreaterThan($previous, $current, "Level {$level} threshold must exceed level " . ($level - 1)); + $previous = $current; + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e5e334be5..d3a2ddc00 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -179,6 +179,17 @@ 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'); + + // Weekly-delta leaderboard window (Version20260713175512) + $pdo->exec('CREATE INDEX IF NOT EXISTS custom_xp_entry_weekly_delta ON xp_entry (earned_at, player_id, amount) WHERE in_weekly_delta = true'); + + // XP ledger idempotency anchors (Version20260713131333) — player_id is part of the key + // because every team participant earns entries for the same solve. + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS custom_xp_entry_solve_reason ON xp_entry (player_id, solving_time_id, reason) WHERE solving_time_id IS NOT NULL AND reason != 'solve_compensation'"); + $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS custom_xp_entry_badge ON xp_entry (badge_id) WHERE badge_id IS NOT NULL'); } /** diff --git a/translations/emails.cs.yml b/translations/emails.cs.yml index 98b06e1c2..475d4c3e6 100644 --- a/translations/emails.cs.yml +++ b/translations/emails.cs.yml @@ -148,3 +148,51 @@ feature_request_declined:

Přesto děkujeme za podporu nápadu! Vaše vstupy nám pomáhají pochopit, co komunitu zajímá.

admin_comment: |

Komentář od týmu: %adminComment%

+ +badges_earned: + subject: "Máš nové odznaky na MySpeedPuzzling!" + title: "%count%× nový odznak!" + intro: "Gratulujeme! Máš za sebou nové milníky na MySpeedPuzzling." + cta: | +

Podívej se na všechny své odznaky i na postup k těm dalším.

+ outro: "Skládej dál — další úspěchy čekají na odemčení!" + +content_digest: + weekly: + subject: "Tvůj týden ve skládání 🧩" + subject_quiet: "Klidný puzzle týden — držíme ti místo u stolu" + greeting: "Ahoj %name%, tady je tvůj týden ve skládání!" + greeting_anonymous: "Ahoj puzzlere, tady je tvůj týden ve skládání!" + xp_title: "Tvoje XP tento týden" + xp_gained: "+%xp% XP získáno" + levels_gained: "{1}o 1 úroveň výš — teď jsi na úrovni %level%!|{2,3,4}o %count% úrovně výš — teď jsi na úrovni %level%!|[5,Inf[o %count% úrovní výš — teď jsi na úrovni %level%!" + achievements_intro: "{1}Máš 1 nový úspěch:|{2,3,4}Máš %count% nové úspěchy:|[5,Inf[Máš %count% nových úspěchů:" + achievements_teaser: "{1}Čeká na tebe 1 nový úspěch — staň se členem a odhal ho!|{2,3,4}Čekají na tebe %count% nové úspěchy — staň se členem a odhal je!|[5,Inf[Čeká na tebe %count% nových úspěchů — staň se členem a odhal je!" + numbers_title: "Tvůj týden v číslech" + numbers_line: "{1}1 složení|]1,Inf[%solves% složení — %pieces% dílků na svém místě." + numbers_minutes: "Zhruba %minutes% minut čistého skládání." + numbers_previous: "Minulý týden: %solves% složení, %pieces% dílků." + streak_line: "Jedeš %days%denní sérii — krásná vytrvalost!" + no_activity_line_1: "Tento týden jsme od tebe žádné složení neviděli — a to je úplně v pořádku, život se občas přihlásí o slovo." + no_activity_line_2: "Až budeš mít chuť, puzzle je vždycky dobrý nápad. Tvůj další dílek čeká. 🧩" + favorites_title: "Tvoji oblíbení puzzleři nezaháleli" + favorites_line: "{1}%name% má 1 nové složení|{2,3,4}%name% má %count% nová složení|[5,Inf[%name% má %count% nových složení" + next_achievements_title: "Na dosah" + community_title: "Dění v komunitě" + most_solved_line: "Nejskládanější puzzle týdne: %puzzle% (%solvers% puzzlerů)." + footer_settings: "Tento souhrn dostáváš každý týden. Kdykoli si ho můžeš upravit v nastavení notifikací." + footer_unsubscribe: "Jedno kliknutí a je klid: odhlásit odběr souhrnu." + +xp_reveal: + subject: "Celá tvoje puzzle cesta se právě proměnila v XP ✨" + greeting: "Ahoj %name%, stalo se něco krásného!" + greeting_anonymous: "Ahoj puzzlere, stalo se něco krásného!" + intro: "MySpeedPuzzling nově oslavuje tvoji aktivitu pomocí XP a úrovní — a počítají se všechna puzzle, která máš zaznamenaná. Žádné nastavování, žádný háček: tady je, jakou hodnotu má tvoje historie." + level_line: "Úroveň %level%" + xp_line: "%xp% XP za celou tvoji puzzle historii" + achievements_member: "{1}A k tomu už máš na profilu 1 úspěch!|{2,3,4}A k tomu už máš na profilu %count% úspěchy!|[5,Inf[A k tomu už máš na profilu %count% úspěchů!" + achievements_teaser: "{1}A 1 úspěch na tebe tiše čeká.|{2,3,4}A %count% úspěchy na tebe tiše čekají.|[5,Inf[A %count% úspěchů na tebe tiše čeká." + cta: | +

Podívej se na svůj velký moment

+ outro: "Úrovně nikdy nic nepodmiňují a XP se nedají koupit — je to prostě tvůj puzzle příběh, který si zaslouží oslavu. Uvidíme se u stolu! 🧩" + explainer: 'Zajímají tě detaily? Jak fungují XP a úrovně.' diff --git a/translations/emails.de.yml b/translations/emails.de.yml index a9c61daaa..7be1f54a6 100644 --- a/translations/emails.de.yml +++ b/translations/emails.de.yml @@ -148,3 +148,51 @@ feature_request_declined:

Danke, dass du die Idee trotzdem unterstützt hast! Deine Beiträge helfen uns zu verstehen, was der Community wichtig ist.

admin_comment: |

Kommentar vom Team: %adminComment%

+ +badges_earned: + subject: "Du hast neue Abzeichen auf MySpeedPuzzling verdient!" + title: "%count% neue(s) Abzeichen verdient!" + intro: "Herzlichen Glückwunsch! Du hast neue Meilensteine auf MySpeedPuzzling erreicht." + cta: | +

Sieh dir alle deine Abzeichen und deinen Fortschritt zu den nächsten an.

+ outro: "Puzzle weiter — weitere Erfolge warten darauf, freigeschaltet zu werden!" + +content_digest: + weekly: + subject: "Deine Puzzle-Woche 🧩" + subject_quiet: "Eine ruhige Puzzle-Woche — wir haben dir einen Platz freigehalten" + greeting: "Hallo %name%, hier ist deine Puzzle-Woche!" + greeting_anonymous: "Hallo Puzzler, hier ist deine Puzzle-Woche!" + xp_title: "Deine XP diese Woche" + xp_gained: "+%xp% XP verdient" + levels_gained: "{1}du bist 1 Level aufgestiegen, jetzt Level %level%!|]1,Inf[du bist %count% Level aufgestiegen, jetzt Level %level%!" + achievements_intro: "{1}Du hast 1 neuen Erfolg verdient:|]1,Inf[Du hast %count% neue Erfolge verdient:" + achievements_teaser: "{1}1 neuer Erfolg wartet auf dich — werde Mitglied, um ihn aufzudecken!|]1,Inf[%count% neue Erfolge warten auf dich — werde Mitglied, um sie aufzudecken!" + numbers_title: "Deine Woche in Zahlen" + numbers_line: "{1}1 Ergebnis|]1,Inf[%solves% Ergebnisse — %pieces% Teile gelegt." + numbers_minutes: "Rund %minutes% Minuten pures Puzzeln." + numbers_previous: "Letzte Woche: %solves% Ergebnisse, %pieces% Teile." + streak_line: "Deine Serie läuft seit %days% Tagen — wunderbar beständig!" + no_activity_line_1: "Diese Woche haben wir kein Ergebnis von dir gesehen — und das ist völlig in Ordnung, das Leben kommt manchmal dazwischen." + no_activity_line_2: "Wann immer du bereit bist: Ein Puzzle ist immer eine gute Idee. Dein nächstes Teil wartet schon. 🧩" + favorites_title: "Deine Lieblings-Puzzler waren fleißig" + favorites_line: "{1}%name% hat 1 Ergebnis erfasst|]1,Inf[%name% hat %count% Ergebnisse erfasst" + next_achievements_title: "Zum Greifen nah" + community_title: "Aus der Community" + most_solved_line: "Meistgelegtes Puzzle der Woche: %puzzle% (%solvers% Puzzler)." + footer_settings: "Du bekommst diesen Rückblick wöchentlich. Passe ihn jederzeit in deinen Benachrichtigungseinstellungen an." + footer_unsubscribe: "Ein Klick und Schluss: Rückblick abbestellen." + +xp_reveal: + subject: "Deine ganze Puzzle-Reise ist gerade zu XP geworden ✨" + greeting: "Hallo %name%, etwas Schönes ist passiert!" + greeting_anonymous: "Hallo Puzzler, etwas Schönes ist passiert!" + intro: "MySpeedPuzzling feiert deine Aktivität jetzt mit XP und Leveln — und jedes Puzzle, das du je erfasst hast, zählt bereits. Nichts einzurichten, kein Haken: Hier ist, was deine Geschichte wert ist." + level_line: "Level %level%" + xp_line: "%xp% XP verdient in deiner gesamten Puzzle-Geschichte" + achievements_member: "{1}Dazu ist 1 Erfolg schon auf deinem Profil!|]1,Inf[Dazu sind %count% Erfolge schon auf deinem Profil!" + achievements_teaser: "{1}Und 1 Erfolg wartet still auf dich.|]1,Inf[Und %count% Erfolge warten still auf dich." + cta: | +

Erlebe deinen großen Moment

+ outro: "Level schalten nichts frei und XP lassen sich niemals kaufen — es ist einfach deine Puzzle-Geschichte, gefeiert. Wir sehen uns am Tisch! 🧩" + explainer: 'Neugierig auf die Details? So funktionieren XP & Level.' diff --git a/translations/emails.en.yml b/translations/emails.en.yml index 89080c49b..b51385daa 100644 --- a/translations/emails.en.yml +++ b/translations/emails.en.yml @@ -148,3 +148,51 @@ 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!" + +content_digest: + weekly: + subject: "Your week in puzzling 🧩" + subject_quiet: "A quiet puzzle week — we saved you a seat" + greeting: "Hi %name%, here is your week in puzzling!" + greeting_anonymous: "Hi puzzler, here is your week in puzzling!" + xp_title: "Your XP this week" + xp_gained: "+%xp% XP earned" + levels_gained: "{1}you climbed 1 level, now Level %level%!|]1,Inf[you climbed %count% levels, now Level %level%!" + achievements_intro: "{1}You earned 1 new achievement:|]1,Inf[You earned %count% new achievements:" + achievements_teaser: "{1}1 new achievement is waiting for you — become a member to reveal it!|]1,Inf[%count% new achievements are waiting for you — become a member to reveal them!" + numbers_title: "Your week in numbers" + numbers_line: "{1}1 solve|]1,Inf[%solves% solves — %pieces% pieces placed." + numbers_minutes: "About %minutes% minutes of pure puzzling." + numbers_previous: "Last week: %solves% solves, %pieces% pieces." + streak_line: "You are on a %days%-day streak — beautifully steady!" + no_activity_line_1: "We haven't seen a solve from you this week — and that's perfectly fine, life happens." + no_activity_line_2: "Whenever you're ready, a puzzle is always a good idea. Your next piece is waiting. 🧩" + favorites_title: "Your favorite puzzlers were busy" + favorites_line: "{1}%name% logged 1 solve|]1,Inf[%name% logged %count% solves" + next_achievements_title: "Within your reach" + community_title: "Around the community" + most_solved_line: "Most solved puzzle of the week: %puzzle% (%solvers% puzzlers)." + footer_settings: "You get this digest weekly. Adjust it anytime in your notification settings." + footer_unsubscribe: "One click and it stops: unsubscribe from the digest." + +xp_reveal: + subject: "Your whole puzzling journey just turned into XP ✨" + greeting: "Hi %name%, something lovely happened!" + greeting_anonymous: "Hi puzzler, something lovely happened!" + intro: "MySpeedPuzzling now celebrates your activity with XP and levels — and every puzzle you ever logged already counts. No setup, no catch: here is what your history is worth." + level_line: "Level %level%" + xp_line: "%xp% XP earned across your whole puzzling history" + achievements_member: "{1}Plus 1 achievement is already on your profile!|]1,Inf[Plus %count% achievements are already on your profile!" + achievements_teaser: "{1}And 1 achievement is quietly waiting for you.|]1,Inf[And %count% achievements are quietly waiting for you." + cta: | +

See your reveal moment

+ outro: "Levels never gate anything and XP can never be bought — it is simply your puzzling story, celebrated. See you at the table! 🧩" + explainer: 'Curious about the details? How XP & Levels work.' diff --git a/translations/emails.es.yml b/translations/emails.es.yml index 3231bf8bf..3308967bd 100644 --- a/translations/emails.es.yml +++ b/translations/emails.es.yml @@ -148,3 +148,51 @@ feature_request_declined:

¡Gracias por apoyar la idea de todos modos! Tus aportes nos ayudan a entender lo que le importa a la comunidad.

admin_comment: |

Comentario del equipo: %adminComment%

+ +badges_earned: + subject: "¡Has conseguido nuevas insignias en MySpeedPuzzling!" + title: "¡%count% nueva(s) insignia(s) conseguida(s)!" + intro: "¡Felicidades! Has alcanzado nuevos hitos en MySpeedPuzzling." + cta: | +

Mira todas tus insignias y tu progreso hacia las siguientes.

+ outro: "Sigue armando — ¡más logros esperan a ser desbloqueados!" + +content_digest: + weekly: + subject: "Tu semana en puzzles 🧩" + subject_quiet: "Una semana puzzlera tranquila — te guardamos un sitio" + greeting: "Hola %name%, ¡aquí tienes tu semana en puzzles!" + greeting_anonymous: "Hola puzzlero, ¡aquí tienes tu semana en puzzles!" + xp_title: "Tu XP de esta semana" + xp_gained: "+%xp% XP ganados" + levels_gained: "{1}subiste 1 nivel, ¡ahora eres Nivel %level%!|]1,Inf[subiste %count% niveles, ¡ahora eres Nivel %level%!" + achievements_intro: "{1}Conseguiste 1 nuevo logro:|]1,Inf[Conseguiste %count% nuevos logros:" + achievements_teaser: "{1}1 nuevo logro te está esperando — ¡hazte miembro para revelarlo!|]1,Inf[%count% nuevos logros te están esperando — ¡hazte miembro para revelarlos!" + numbers_title: "Tu semana en números" + numbers_line: "{1}1 resolución|]1,Inf[%solves% resoluciones — %pieces% piezas colocadas." + numbers_minutes: "Unos %minutes% minutos de puzzle en estado puro." + numbers_previous: "La semana pasada: %solves% resoluciones, %pieces% piezas." + streak_line: "Llevas una racha de %days% días — ¡qué constancia tan bonita!" + no_activity_line_1: "Esta semana no hemos visto ninguna resolución tuya — y no pasa nada, la vida a veces manda." + no_activity_line_2: "Cuando quieras, un puzzle siempre es buena idea. Tu próxima pieza te está esperando. 🧩" + favorites_title: "Tus puzzleros favoritos no pararon" + favorites_line: "{1}%name% registró 1 resolución|]1,Inf[%name% registró %count% resoluciones" + next_achievements_title: "A tu alcance" + community_title: "Por la comunidad" + most_solved_line: "El puzzle más armado de la semana: %puzzle% (%solvers% puzzleros)." + footer_settings: "Recibes este resumen cada semana. Ajústalo cuando quieras en tu configuración de notificaciones." + footer_unsubscribe: "Un clic y se acaba: darse de baja del resumen." + +xp_reveal: + subject: "Todo tu recorrido puzzlero se acaba de convertir en XP ✨" + greeting: "Hola %name%, ¡ha pasado algo bonito!" + greeting_anonymous: "Hola puzzlero, ¡ha pasado algo bonito!" + intro: "MySpeedPuzzling ahora celebra tu actividad con XP y niveles — y cada puzzle que has registrado ya cuenta. Sin configurar nada y sin trampa: esto es lo que vale tu historia." + level_line: "Nivel %level%" + xp_line: "%xp% XP ganados a lo largo de toda tu historia puzzlera" + achievements_member: "{1}¡Además, 1 logro ya está en tu perfil!|]1,Inf[¡Además, %count% logros ya están en tu perfil!" + achievements_teaser: "{1}Y 1 logro te espera en silencio.|]1,Inf[Y %count% logros te esperan en silencio." + cta: | +

Descubre tu momento de revelación

+ outro: "Los niveles nunca bloquean nada y el XP no se puede comprar — es simplemente tu historia puzzlera, celebrada. ¡Nos vemos en la mesa! 🧩" + explainer: '¿Quieres conocer los detalles? Cómo funcionan los XP y niveles.' diff --git a/translations/emails.fr.yml b/translations/emails.fr.yml index e07f1ebb3..62e478ee3 100644 --- a/translations/emails.fr.yml +++ b/translations/emails.fr.yml @@ -148,3 +148,51 @@ feature_request_declined:

Merci tout de même d'avoir soutenu cette idée ! Votre contribution nous aide à comprendre ce qui compte pour la communauté.

admin_comment: |

Commentaire de l'équipe : %adminComment%

+ +badges_earned: + subject: "Vous avez obtenu de nouveaux badges sur MySpeedPuzzling !" + title: "%count% nouveau(x) badge(s) obtenu(s) !" + intro: "Félicitations ! Vous venez de franchir de nouvelles étapes sur MySpeedPuzzling." + cta: | +

Voir tous vos badges et votre progression vers les prochains.

+ outro: "Continuez à puzzler — d'autres succès n'attendent que d'être débloqués !" + +content_digest: + weekly: + subject: "Votre semaine puzzle 🧩" + subject_quiet: "Une semaine puzzle bien calme — nous vous avons gardé une place" + greeting: "Bonjour %name%, voici votre semaine puzzle !" + greeting_anonymous: "Bonjour puzzleur, voici votre semaine puzzle !" + xp_title: "Vos XP cette semaine" + xp_gained: "+%xp% XP gagnés" + levels_gained: "{1}vous avez gagné 1 niveau, vous voilà niveau %level% !|]1,Inf[vous avez gagné %count% niveaux, vous voilà niveau %level% !" + achievements_intro: "{1}Vous avez gagné 1 nouveau succès :|]1,Inf[Vous avez gagné %count% nouveaux succès :" + achievements_teaser: "{1}1 nouveau succès vous attend — devenez membre pour le révéler !|]1,Inf[%count% nouveaux succès vous attendent — devenez membre pour les révéler !" + numbers_title: "Votre semaine en chiffres" + numbers_line: "{1}1 résolution|]1,Inf[%solves% résolutions — %pieces% pièces posées." + numbers_minutes: "Environ %minutes% minutes de puzzle à l'état pur." + numbers_previous: "La semaine dernière : %solves% résolutions, %pieces% pièces." + streak_line: "Vous êtes sur une série de %days% jours — belle régularité !" + no_activity_line_1: "Nous n'avons pas vu de résolution de votre part cette semaine — et ce n'est pas grave du tout, ça arrive." + no_activity_line_2: "Quand vous voudrez, un puzzle est toujours une bonne idée. Votre prochaine pièce vous attend. 🧩" + favorites_title: "Vos puzzleurs favoris n'ont pas chômé" + favorites_line: "{1}%name% a enregistré 1 résolution|]1,Inf[%name% a enregistré %count% résolutions" + next_achievements_title: "À votre portée" + community_title: "Du côté de la communauté" + most_solved_line: "Puzzle le plus résolu de la semaine : %puzzle% (%solvers% puzzleurs)." + footer_settings: "Vous recevez ce résumé chaque semaine. Ajustez-le à tout moment dans vos paramètres de notification." + footer_unsubscribe: "Un clic et c'est terminé : se désabonner du résumé." + +xp_reveal: + subject: "Tout votre parcours puzzle vient de se transformer en XP ✨" + greeting: "Bonjour %name%, il vient de se passer quelque chose de charmant !" + greeting_anonymous: "Bonjour puzzleur, il vient de se passer quelque chose de charmant !" + intro: "MySpeedPuzzling célèbre désormais votre activité avec des XP et des niveaux — et chaque puzzle que vous avez enregistré compte déjà. Rien à configurer, aucun piège : voici ce que vaut votre historique." + level_line: "Niveau %level%" + xp_line: "%xp% XP gagnés sur l'ensemble de votre historique puzzle" + achievements_member: "{1}En prime, 1 succès est déjà sur votre profil !|]1,Inf[En prime, %count% succès sont déjà sur votre profil !" + achievements_teaser: "{1}Et 1 succès vous attend discrètement.|]1,Inf[Et %count% succès vous attendent discrètement." + cta: | +

Découvrir votre moment de révélation

+ outro: "Les niveaux ne bloquent jamais rien et les XP ne s'achètent pas — c'est simplement votre histoire de puzzleur, célébrée. À bientôt autour de la table ! 🧩" + explainer: "Envie de connaître les détails ? Comment fonctionnent les XP et les niveaux." diff --git a/translations/emails.ja.yml b/translations/emails.ja.yml index 69f3b0039..e5127e99a 100644 --- a/translations/emails.ja.yml +++ b/translations/emails.ja.yml @@ -148,3 +148,51 @@ feature_request_declined:

それでも、このアイデアを応援してくださりありがとうございました!皆様のご意見は、コミュニティが何を大切にしているかを理解する助けになっています。

admin_comment: |

チームからのコメント: %adminComment%

+ +badges_earned: + subject: "MySpeedPuzzlingで新しいバッジを獲得しました!" + title: "新しいバッジを%count%個獲得しました!" + intro: "おめでとうございます!MySpeedPuzzlingで新たなマイルストーンに到達しました。" + cta: | +

獲得したバッジと、次のバッジへの進捗をすべて見る

+ outro: "パズルを続けましょう — 解除を待っている実績がまだまだあります!" + +content_digest: + weekly: + subject: "あなたのパズルな一週間 🧩" + subject_quiet: "静かなパズル週間でした — お席はちゃんと取ってあります" + greeting: "%name%さん、こんにちは。あなたの一週間のパズル活動をお届けします!" + greeting_anonymous: "パズラーさん、こんにちは。あなたの一週間のパズル活動をお届けします!" + xp_title: "今週のXP" + xp_gained: "+%xp% XP獲得" + levels_gained: "{1}1レベルアップして、現在レベル%level%です!|]1,Inf[%count%レベルアップして、現在レベル%level%です!" + achievements_intro: "{1}新しい実績を1個獲得しました:|]1,Inf[新しい実績を%count%個獲得しました:" + achievements_teaser: "{1}新しい実績1個があなたを待っています — メンバーになってお披露目しましょう!|]1,Inf[新しい実績%count%個があなたを待っています — メンバーになってお披露目しましょう!" + numbers_title: "数字で見る今週" + numbers_line: "{1}完成1回|]1,Inf[完成%solves%回 — %pieces%ピースをはめました。" + numbers_minutes: "純粋なパズル時間はおよそ%minutes%分。" + numbers_previous: "先週:完成%solves%回、%pieces%ピース。" + streak_line: "現在%days%日連続でパズル中 — 見事な安定感です!" + no_activity_line_1: "今週は完成記録が見当たりませんでした — もちろん、それでまったく問題ありません。そういう週もあります。" + no_activity_line_2: "気が向いたときが、パズルどきです。次のピースが待っています。🧩" + favorites_title: "お気に入りパズラーの活躍" + favorites_line: "{1}%name%さんが完成1回を記録|]1,Inf[%name%さんが完成%count%回を記録" + next_achievements_title: "あと少しで手が届きます" + community_title: "コミュニティの話題" + most_solved_line: "今週最も解かれたパズル:%puzzle%(%solvers%人のパズラー)。" + footer_settings: "このダイジェストは毎週お届けしています。通知設定でいつでも調整できます。" + footer_unsubscribe: "ワンクリックで止まります:ダイジェストの配信を停止する。" + +xp_reveal: + subject: "あなたのパズルの歩みが、まるごとXPになりました ✨" + greeting: "%name%さん、こんにちは。素敵なことが起こりました!" + greeting_anonymous: "パズラーさん、こんにちは。素敵なことが起こりました!" + intro: "MySpeedPuzzlingでは、XPとレベルであなたの活動をたたえるようになりました — これまでに記録したすべてのパズルが、もう反映されています。設定は不要、裏もありません。あなたのこれまでの歩みの価値はこちらです。" + level_line: "レベル%level%" + xp_line: "これまでのパズル履歴すべてで%xp% XPを獲得" + achievements_member: "{1}さらに、実績1個がすでにプロフィールに飾られています!|]1,Inf[さらに、実績%count%個がすでにプロフィールに飾られています!" + achievements_teaser: "{1}そして、実績1個がひっそりとあなたを待っています。|]1,Inf[そして、実績%count%個がひっそりとあなたを待っています。" + cta: | +

お披露目の瞬間を見る

+ outro: "レベルで何かが制限されることはなく、XPをお金で買うこともできません — これはただ、あなたのパズルの物語をたたえるものです。それでは、パズルテーブルでお会いしましょう!🧩" + explainer: "詳しく知りたいですか?XPとレベルの仕組みをご覧ください。" diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index 872abc3a0..7e0e91fd8 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -412,6 +412,7 @@ "delete_button": "Smazat — nikdy se to nestalo!" "delete_modal_message": "Jen pro jistotu..." "delete_modal_confirm": "Opravdu smazat?" + "delete_xp_warning": "Přijdeš o %xp% XP získaných za toto složení." "delete_modal_discard_button": "Ne, ponechat" "delete_modal_confirm_button": "Ano, smazat" "error": @@ -714,6 +715,13 @@ "frequency_48_hours": "Každých 48 hodin" "frequency_1_week": "Jednou týdně" "newsletter_enabled": "Dostávat newsletter" + "content_digest_frequency": "Týdenní souhrn e-mailem" + "content_digest_help": "Tvůj týden ve skládání: XP, úspěchy, statistiky a aktivita přátel. Jednou týdně je tak akorát." + "content_digest_none": "Souhrn mi neposílat" + "content_digest_daily": "Denně + týdně" + "content_digest_weekly": "Týdně" + "hide_experience_system": "Skrýt systém zkušeností (XP a úrovně)" + "hide_experience_system_help": "Tvoje úroveň, XP účtenky, oslavy i pozice v žebříčcích všude zmizí. XP se ale potichu načítají dál, takže když systém zase zapneš, o nic nepřijdeš." "newsletter_help": "Občas vám pošleme newsletter o novinkách na platformě, většinou o nových funkcích. Očekávaná frekvence: maximálně 1x měsíčně." "messaging_notifications": "Zprávy a upozornění" "features_options": "Nastavení funkcí" @@ -886,11 +894,350 @@ "conversation_request": "general": "%player% vám poslal žádost o konverzaci" "marketplace": "%player% vám poslal zprávu o %puzzle%" -"badges": - "title": "Odznaky" - "my_title": "Mé odznaky" - "badge": - "supporter": "Podporovatel" +xp: + level_chip: "Lv %level%" + total: "%xp% XP" + total_tooltip: "Celkem získáno %xp% XP" + max_level_reached: "Vrchol hory — úroveň 50!" + progress_label: "%xp% XP do úrovně %level%" + receipt: + title: "Tvoje XP za toto složení" + total: "Celkem" + waiting_teaser: "{1}Toto složení se započítalo do 1 čekajícího úspěchu 🔒|]1,Inf[Toto složení se započítalo do %count% čekajících úspěchů 🔒" + max_level_title: "Úroveň 50 — XP se stále počítají, sláva je navždy" + nearest_achievements: "Tvoje další úspěchy jsou na dosah:" + max_level_all_done: "Máš dobyto vše, co se dobýt dalo. Upřímně smekáme." + relax_repeat: "Známé puzzle, čistě pro radost — tentokrát bez XP, a přesně tak to má být." + counting: "Počítáme tvoje XP…" + line: + base: "Složení" + base_repeat_second: "Opakované složení (podruhé, ×50 %)" + base_repeat_later: "Opakované složení (potřetí a dál, ×25 %)" + base_relax: "Relax složení (×50 %)" + solve_difficulty_bonus: "Bonus za obtížnost" + solve_unboxed_bonus: "Bonus za skládání naslepo" + solve_speed_bonus: "Bonus za rychlost" + solve_weekly_boost: "Týdenní boost" + solve_daily_warmup: "Denní rozcvička" + difficulty_settlement: "Bonus za obtížnost (doúčtováno)" + speed_settlement: "Bonus za rychlost (doúčtováno)" + achievement: "Získaný úspěch" + solve_compensation: "Smazané složení" + difficulty_pending: "Bonus za obtížnost čeká — doúčtuje se, jakmile puzzle získá hodnocení" + speed_pending: "Bonus za rychlost čeká — potřebuje časy od 3 puzzlerů" + levelup: + aria: "Nová úroveň! Jsi na úrovni %level%" + label: "Nová úroveň!" + max_label: "Vrchol!" + message: "Tvoje puzzle cesta stoupá výš a výš. Krásná práce!" + max_message: "Úroveň 50. Výš to nejde. Odteď je tvojí horou žebříček bodů za úspěchy." + enter_ap_ladder: "Vstoupit na žebříček AP" + view_ap_ladder: "Zobrazit žebříček AP" + skip_hint: "Pokračuj klepnutím kamkoli" + achievement_toast: + title: "Nový úspěch!" + estimate: + line: "Za složení získáš ~%base% XP + bonus až %bonus%" + repeat_note: "Tohle puzzle už máš složené — tentokrát se počítá ×%multiplier%." + unrated_note: "Bonus za obtížnost se doúčtuje automaticky, jakmile puzzle získá hodnocení." + leaderboard: + meta_title: "XP žebříček" + title: "XP žebříček" + subtitle: "Aktivita, která se slaví." + tab: + this-week: "Tento týden" + all-time: "Celkově" + achievement-points: "Body za úspěchy" + favorites_only: "Pouze oblíbení" + player: "Puzzler" + value: + this-week: "XP tento týden" + all-time: "XP celkem" + achievement-points: "AP" + xp_value: "%xp% XP" + ap_value: "%points% AP" + lv50_ap: "Lv 50 · %points% AP" + your_position: "Tvoje pozice" + ap_login_required: "Pro prohlížení žebříčku bodů za úspěchy se přihlas." + empty: "Zatím tu nic není — zaznamenej složení a otevři žebříček!" + weekly_note: "XP tohoto týdne ze složení a úspěchů; doúčtování a zpětný přepočet při spuštění se sem nepočítají." + history: + meta_title: "Moje XP historie" + title: "Moje XP historie" + subtitle: "Každý bod má své místo — kompletní přehled tvých XP." + when: "Kdy" + reason: "Důvod" + empty: "Zatím žádné XP — tvoje první složení to změní." + deleted_solve: "smazané složení" + pagination: "Stránky XP historie" + note: "Vrtá ti hlavou některý řádek?" + reveal: + meta_title: "Tvoje puzzle cesta odhalena" + title: "Celá tvoje cesta se právě proměnila v XP ✨" + intro: "Počítá se každé puzzle, které máš zaznamenané. Tady je, jakou hodnotu má tvoje historie:" + level_label: "Úroveň" + xp_note: "získáno za celou tvoji puzzle historii" + achievements_member: "{1}A 1 úspěch už je tvůj — najdeš ho na svém profilu!|{2,3,4}A %count% úspěchy už jsou tvoje — najdeš je na svém profilu!|[5,Inf[A %count% úspěchů už je tvých — najdeš je na svém profilu!" + share_title: "Na MySpeedPuzzling mám úroveň %level%!" + share_button: "Sdílet moji úroveň" + continue: "Přejít na můj profil" + to_badge_reveals: "Máš úspěchy k odhalení? Otoč si je tady." + explainer: + title: "Jak fungují XP a úrovně" + intro: "Každé dokončené puzzle tě posune dál. XP (zkušenostní body) oslavují tvoji aktivitu — nejde o rychlost, ale o to, že skládáš. Zaznamenej složení, získej XP a sleduj, jak tvoje úroveň roste od 1 až do 50." + currencies: + heading: "Tři čísla na MySpeedPuzzling" + header_what: "Co" + header_measures: "Měří" + header_who: "Kdo je vidí" + xp_name: "XP a úrovně" + xp_measures: "Tvoji aktivitu — počítá se každé složení" + xp_who: "Všichni, navždy zdarma" + ap_name: "Body za úspěchy (AP)" + ap_measures: "Tvoji sbírku získaných úspěchů" + ap_who: "Členové" + rating_name: "MSP Rating" + rating_measures: "Tvoji rychlost a dovednosti" + rating_who: "Členové (beze změny, zvlášť)" + never_bought: "XP se nedají koupit — žádné boosty, žádné násobiče, žádné zkratky. Nikdy." + unlock_nothing: "Úrovně neodemykají žádné funkce; jsou tvojí vizuální puzzle identitou." + how: + heading: "Jak se složení promění v XP" + intro: "Účtenka po každém složení přesně ukazuje, odkud se každý bod vzal:" + base: "Základ: zhruba 1 XP za 100 dílků (puzzle s 500 dílky ≈ 5 XP). Strop je 60." + difficulty: "Bonus za obtížnost: těžší puzzle (komunitní stupně obtížnosti 3–6) přidají +15 % až +50 %." + team: "Týmová složení: každý ve skupině získá 75 % sólo hodnoty — společné skládání se počítá každému páru rukou." + unboxed: "Bonus naslepo: složeno bez obrázku na krabici? +20 %." + repeat: "Opakovaná složení: 2. měřené složení stejného puzzle vynese 50 %, další 25 %. Relax mód: první složení 50 %, opakování 0 %." + speed: "Bonus za rychlost: sólo měřená složení rychlejší než komunitní medián puzzle získají +5 %, top 25 % +10 %, top 10 % +15 % (potřebuje časy alespoň od 3 puzzlerů)." + weekly: "Týdenní boost: prvních 5 složení každý týden vynese +50 % navíc." + daily: "Denní rozcvička: první složení dne přinese +2 XP." + example: + heading: "Příklad výpočtu" + body: "Sólo měřené první složení puzzle s 1000 dílky a obtížností 4, složené naslepo, rychleji než medián, jako tvoje první složení v týdnu i ve dni:" + math: "základ 10 + obtížnost 3 + naslepo 3 + rychlost 1 + týdenní boost 8 + rozcvička 2 = 27 XP" + unrated: + heading: "Puzzle bez hodnocení" + body: "Úplně nová puzzle v katalogu ještě nemusí mít stupeň obtížnosti. Základní XP získáš hned a bonus za obtížnost dorazí automaticky („doúčtuje se“), jakmile komunita puzzle ohodnotí. Stejně tak bonus za rychlost, jakmile časy zaznamená dost puzzlerů." + achievements: + heading: "Úspěchy dávají XP taky" + body: "Každý stupeň úspěchu udělí XP jednou, navždy: bronz 5 · stříbro 10 · zlato 25 · platina 50 · diamant 100." + levels: + heading: "Úrovně" + header_level: "Úroveň" + header_xp: "XP" + summit: "Úroveň 50 je vrchol — celkem 3 160 XP. XP se načítají i dál; na úrovni 50 se tvojí další horou stává žebříček bodů za úspěchy." + lose: + heading: "Můžu o XP přijít?" + body: "Jen smazáním nebo úpravou složení — XP, které složení vyneslo, odejdou s ním. Nic jiného ti XP nikdy nevezme. Úspěchy jsou trvalé." + faq: + heading: "Časté dotazy" + old_solves_question: "Počítají se stará složení?" + old_solves_answer: "Ano! Celá tvoje historie se převedla při spuštění (základní vzorec, bez časových bonusů — ty platí až od teď)." + opt_out_question: "Nechci nic z toho." + opt_out_answer: "Rozumíme — vypni si to v nastavení profilu („systém zkušeností“) a tvoje úroveň, XP i oslavy zmizí z dohledu." + friend_question: "Proč kamarád dostal za stejné puzzle víc XP?" + friend_answer: "Slevy za opakování, týdenní boosty a denní rozcvičky jsou osobní; dvě účtenky se shodují jen málokdy. Klepni na kterýkoli řádek účtenky a uvidíš jeho důvod." + fair_play_question: "Jak je zajištěná férovost?" + fair_play_answer: "Čísla drží poctivá několik tichých automatických pojistek — vše o nich najdeš na stránce Fair play a důvěra." + fair_play: + title: "Fair play a důvěra" + intro: "MySpeedPuzzling stojí na důvěře. Nikdo tvoje složení před započítáním nekontroluje — věříme ti a čísla komunity zůstávají smysluplná díky několika tichým automatickým pojistkám:" + principle_no_buying: "XP měří aktivitu, nikdy útratu: XP, boosty ani násobiče se nedají koupit." + principle_repeats: "Opakovaná složení stejného puzzle vynášejí pokaždé méně — dokola zaznamenávat jedno puzzle není zkratka." + principle_speed: "Bonus za rychlost platí jen tam, kde existuje spolehlivé komunitní srovnání, a nevěrohodně rychlé časy ho prostě nedostanou. Žádné obviňování, žádné drama — bonus se jen nepřičte." + principle_deleting: "Smazání složení odebere i XP, které vyneslo, automaticky a transparentně (na stránce XP historie vidíš každý záznam)." + principle_caps: "Extrémně velké nebo neobvyklé záznamy mají strop, takže žádné jediné složení nemůže pokřivit žebříček." + closing_thresholds: "Přesné limity záměrně nezveřejňujeme — existují proto, aby udržely férovost, ne aby se daly obcházet. Pokud ti na žebříčku něco nesedí, napiš nám; na každé hlášení se dívá člověk." + closing_celebrate: "Skládej poctivě, slav nahlas. 🧩" + explainer_link: "Zajímá tě, jak se každý bod získává? Podívej se na Jak fungují XP a úrovně." + +content_digest: + unsubscribe: + meta_title: "Odhlásit odběr souhrnu" + confirm_title: "Odhlásit odběr týdenního souhrnu?" + confirm_text: "Nic se neděje — jedno kliknutí níže a týdenní souhrn ti přestane chodit. Kdykoli si ho můžeš znovu zapnout v nastavení notifikací." + confirm_button: "Ano, odhlásit mě" + done_title: "Máš odhlášeno" + done_text: "Týdenní souhrn ti už e-mailem chodit nebude. Kdyby ti chyběl, čeká na tebe v nastavení notifikací." + back_home: "Zpět na MySpeedPuzzling" + +badges: + title: "Úspěchy" + my_title: "Moje úspěchy" + new: "NOVÉ" + browse_all: "Procházet všechny úspěchy" + reveal: "Odhal svůj nový úspěch" + waiting_teaser: "{1}Čeká na tebe 1 úspěch — staň se členem a odhal ho!|{2,3,4}Čekají na tebe %count% úspěchy — staň se členem a odhal je!|[5,Inf[Čeká na tebe %count% úspěchů — staň se členem a odhal je!" + waiting_teaser_zero: "Tvůj první úspěch je blíž, než si myslíš — zaznamenej složení a uvidíš, co se stane." + unlock_with_membership: "Odemkni členstvím" + ap_total: "%points% AP" + ap_total_tooltip: "Tvoje body za úspěchy — každý získaný stupeň se počítá, navždy" + free_user_hint: "Úspěchy získáváš i bez členství — v bezpečí na tebe počkají." + earned_waiting: "Získáno ✓ — čeká 🔒" + view_holders: "Podívej se, kdo ho získal" + how_it_works_hint: "Úspěchy udělují XP a body za úspěchy." + how_it_works_link: "Jak fungují XP a úrovně" + detail: + meta_title: "%name% — držitelé úspěchu" + country_filter: "Země" + all_countries: "Všechny země" + newest_earners: "Nejnovější držitelé" + holders_count: "{0}Zatím nikdo|{1}1 puzzler|{2,3,4}%count% puzzleři|[5,Inf[%count% puzzlerů" + holders_count_tooltip: "Tady se počítá každý — včetně puzzlerů, jejichž profily nejsou v seznamu níže" + first_to_earn: "První držitel" + more_puzzlers: "+%count% dalších puzzlerů" + only_hidden_holders: "{1}Tento stupeň získal 1 puzzler — jeho profil není veřejně viditelný.|{2,3,4}Tento stupeň získali %count% puzzleři — jejich profily nejsou veřejně viditelné.|[5,Inf[Tento stupeň získalo %count% puzzlerů — jejich profily nejsou veřejně viditelné." + nobody_yet: "Tento stupeň zatím nikdo nezískal. Kdo bude první, zapíše se do historie!" + listing_note: "V seznamech držitelů jsou členové s veřejným profilem; počty zahrnují všechny puzzlery." + reveals: + meta_title: "Odhal své úspěchy" + title: "Tvoje úspěchy jsou tady 🎉" + intro: "{1}1 úspěch tu na tebe tiše čekal. Klepni na něj a otoč ho!|{2,3,4}%count% úspěchy tu na tebe tiše čekaly. Klepni na každý a otoč ho!|[5,Inf[%count% úspěchů tu na tebe tiše čekalo. Klepni na každý a otoč ho!" + hint: "Nespěchej — každé otočení si zaslouží svou malou chvilku." + all_done: "Vše je odhaleno — tvůj profil je teď hrdě nosí." + invite: "{1}1 úspěch čeká na své odhalení!|{2,3,4}%count% úspěchy čekají na své odhalení!|[5,Inf[%count% úspěchů čeká na své odhalení!" + invite_button: "Odhalit je" + back_to_profile: "Zpět na můj profil" + overview_title: "Úspěchy" + overview_subtitle: "Získávej úspěchy za dosažené milníky na své puzzle cestě" + progress_label: "%current% / %target%" + progress_time_label: "%current_time% → cíl %target_time%" + locked: "Zamčeno" + earned_on: "Získáno %date%" + highest_earned: "Nejvyšší získaný" + not_earned_yet: "Zatím nezískáno" + keep_going: "Jen tak dál!" + login_to_track: "Přihlas se a uvidíš svůj postup ke každému úspěchu." + tier: + bronze: "Bronz" + silver: "Stříbro" + gold: "Zlato" + platinum: "Platina" + diamond: "Diamant" + badge: + supporter: "Průkopník" + puzzles_solved: "Puzzle průzkumník" + pieces_solved: "Požírač dílků" + speed_500_pieces: "Démon rychlosti (500 dílků)" + streak: "V jednom ohni" + team_player: "Týmový duch" + zen_puzzler: "Zen skládač" + first_try: "Na první pokus" + unboxed: "Naslepo" + brand_explorer: "Objevitel značek" + marathoner: "Maratonec" + photographer: "Fotograf" + steady_hands: "Pevná ruka" + librarian: "Knihovník" + speed_1000_pieces: "Démon rychlosti (1000 dílků)" + weekend_puzzler: "Víkendový skládač" + cataloger: "Katalogizátor" + description: + supporter: "Pro puzzlery, kteří MySpeedPuzzling věřili od začátku a pomohli mu růst." + puzzles_solved: "Skládej různá puzzle a šplhej po tomto žebříčku." + pieces_solved: "Čím víc dílků, tím líp — počítá se každý dílek, který kdy zapadl na své místo." + speed_500_pieces: "Tvůj stupeň určuje tvoje nejrychlejší sólo složení puzzle s 500 dílky." + streak: "Skládej puzzle den za dnem bez vynechání." + team_player: "Spoj se s dalšími puzzlery a skládejte společně." + zen_puzzler: "Skládej čistě pro radost — počítají se relax složení zaznamenaná bez času." + first_try: "Dej to napoprvé — počítá se každé složení zaznamenané jako tvůj první pokus u daného puzzle." + unboxed: "Skládej puzzle bez koukání na obrázek na krabici — ta nejtajemnější výzva." + brand_explorer: "Ochutnej puzzle od různých výrobců a objev své oblíbené značky." + marathoner: "Pokoř ty velké — počítá se každé složení puzzle s 2 000 a více dílky." + photographer: "Přidej k zaznamenanému složení fotku hotového puzzle — počítá se každý snímek." + steady_hands: "Skládej v po sobě jdoucích čtvrtletích roku, v každém alespoň jedno složení — bez mezer." + librarian: "Pomáhej udržovat katalog puzzlí přesný — počítají se jen tvoje přijaté návrhy na vylepšení katalogu." + speed_1000_pieces: "Tvůj stupeň určuje tvoje nejrychlejší sólo složení puzzle s 1 000 dílky." + weekend_puzzler: "Soboty a neděle patří puzzlím — počítá se každé víkendové složení." + cataloger: "Rozšiřuj katalog puzzlí — počítá se každé tvoje schválené přidané puzzle." + requirement: + puzzles_solved_1: "Slož 10 různých puzzlí" + puzzles_solved_2: "Slož 100 různých puzzlí" + puzzles_solved_3: "Slož 500 různých puzzlí" + puzzles_solved_4: "Slož 1 000 různých puzzlí" + puzzles_solved_5: "Slož 2 000 různých puzzlí" + pieces_solved_1: "Nasbírej 10 000 dílků" + pieces_solved_2: "Nasbírej 100 000 dílků" + pieces_solved_3: "Nasbírej 500 000 dílků" + pieces_solved_4: "Nasbírej 1 000 000 dílků" + pieces_solved_5: "Nasbírej 2 000 000 dílků" + speed_500_pieces_1: "Slož puzzle s 500 dílky pod 5 hodin (sólo)" + speed_500_pieces_2: "Slož puzzle s 500 dílky pod 2 hodiny (sólo)" + speed_500_pieces_3: "Slož puzzle s 500 dílky pod 1 hodinu (sólo)" + speed_500_pieces_4: "Slož puzzle s 500 dílky pod 45 minut (sólo)" + speed_500_pieces_5: "Slož puzzle s 500 dílky pod 30 minut (sólo)" + streak_1: "Skládej puzzle 7 dní v řadě" + streak_2: "Skládej puzzle 30 dní v řadě" + streak_3: "Skládej puzzle 90 dní v řadě" + streak_4: "Skládej puzzle 180 dní v řadě" + streak_5: "Skládej puzzle 365 dní v řadě" + team_player_1: "Slož své první puzzle v týmu nebo ve dvojici" + team_player_2: "Slož 5 puzzlí v týmu nebo ve dvojici" + team_player_3: "Slož 25 puzzlí v týmu nebo ve dvojici" + team_player_4: "Slož 100 puzzlí v týmu nebo ve dvojici" + team_player_5: "Slož 500 puzzlí v týmu nebo ve dvojici" + zen_puzzler_1: "Zaznamenej své první relax složení bez času" + zen_puzzler_2: "Zaznamenej 10 relax složení bez času" + zen_puzzler_3: "Zaznamenej 50 relax složení bez času" + zen_puzzler_4: "Zaznamenej 150 relax složení bez času" + zen_puzzler_5: "Zaznamenej 365 relax složení bez času" + first_try_1: "Zaznamenej 5 složení na první pokus" + first_try_2: "Zaznamenej 50 složení na první pokus" + first_try_3: "Zaznamenej 200 složení na první pokus" + first_try_4: "Zaznamenej 500 složení na první pokus" + first_try_5: "Zaznamenej 1 000 složení na první pokus" + unboxed_1: "Slož své první puzzle bez koukání na krabici" + unboxed_2: "Slož 5 puzzlí bez koukání na krabici" + unboxed_3: "Slož 25 puzzlí bez koukání na krabici" + unboxed_4: "Slož 50 puzzlí bez koukání na krabici" + unboxed_5: "Slož 100 puzzlí bez koukání na krabici" + brand_explorer_1: "Slož puzzle od 3 různých výrobců" + brand_explorer_2: "Slož puzzle od 10 různých výrobců" + brand_explorer_3: "Slož puzzle od 25 různých výrobců" + brand_explorer_4: "Slož puzzle od 50 různých výrobců" + brand_explorer_5: "Slož puzzle od 100 různých výrobců" + marathoner_1: "Slož své první puzzle s 2 000+ dílky" + marathoner_2: "Slož 5 puzzlí s 2 000+ dílky" + marathoner_3: "Slož 15 puzzlí s 2 000+ dílky" + marathoner_4: "Slož 40 puzzlí s 2 000+ dílky" + marathoner_5: "Slož 100 puzzlí s 2 000+ dílky" + photographer_1: "Přidej fotku ke svému prvnímu složenému puzzle" + photographer_2: "Přidej fotky k 25 složeným puzzlím" + photographer_3: "Přidej fotky ke 100 složeným puzzlím" + photographer_4: "Přidej fotky k 500 složeným puzzlím" + photographer_5: "Přidej fotky k 1 000 složeným puzzlím" + steady_hands_1: "Slož alespoň jedno puzzle ve 2 čtvrtletích roku po sobě" + steady_hands_2: "Slož alespoň jedno puzzle ve 4 čtvrtletích roku po sobě" + steady_hands_3: "Slož alespoň jedno puzzle v 8 čtvrtletích roku po sobě" + steady_hands_4: "Slož alespoň jedno puzzle ve 12 čtvrtletích roku po sobě" + steady_hands_5: "Slož alespoň jedno puzzle v 16 čtvrtletích roku po sobě" + librarian_1: "Prosaď svůj první návrh na vylepšení katalogu" + librarian_2: "Prosaď 5 návrhů na vylepšení katalogu" + librarian_3: "Prosaď 20 návrhů na vylepšení katalogu" + librarian_4: "Prosaď 50 návrhů na vylepšení katalogu" + librarian_5: "Prosaď 100 návrhů na vylepšení katalogu" + speed_1000_pieces_1: "Slož puzzle s 1 000 dílky pod 8 hodin (sólo)" + speed_1000_pieces_2: "Slož puzzle s 1 000 dílky pod 4 hodiny (sólo)" + speed_1000_pieces_3: "Slož puzzle s 1 000 dílky pod 2,5 hodiny (sólo)" + speed_1000_pieces_4: "Slož puzzle s 1 000 dílky pod 1 hodinu 45 minut (sólo)" + speed_1000_pieces_5: "Slož puzzle s 1 000 dílky pod 1 hodinu 15 minut (sólo)" + weekend_puzzler_1: "Zvládni 10 víkendových složení" + weekend_puzzler_2: "Zvládni 50 víkendových složení" + weekend_puzzler_3: "Zvládni 150 víkendových složení" + weekend_puzzler_4: "Zvládni 300 víkendových složení" + weekend_puzzler_5: "Zvládni 600 víkendových složení" + cataloger_1: "Přidej do katalogu své první schválené puzzle" + cataloger_2: "Přidej do katalogu 10 schválených puzzlí" + cataloger_3: "Přidej do katalogu 50 schválených puzzlí" + cataloger_4: "Přidej do katalogu 150 schválených puzzlí" + cataloger_5: "Přidej do katalogu 300 schválených puzzlí" + unit: + puzzles: "puzzlí" + pieces: "dílků" + days: "dní" + team_solves: "týmových složení" + "scan": "meta": "title": "Čtečka čárových kódů" @@ -2735,6 +3082,39 @@ qr_code: "most_solved": "Nejskládanější puzzle %pieces% dílků" "view_all": "Zobrazit všechny puzzle %pieces% dílků" +guides: + breadcrumb: + home: "Domů" + guides: "Průvodce" + ui: + eyebrow: "Průvodce" + updated: "Aktualizováno %date%" + data_note: "Živá data z komunity" + read_next: "Další čtení" + cta_title: "Chceš si změřit čas u dalšího puzzle?" + cta_stopwatch: "Spustit stopky" + cta_puzzles: "Procházet databázi puzzlí" + cta_events: "Najít soutěž" + index: + title: "Průvodce speed puzzlingem" + meta_description: "Průvodce speed puzzlingem podložené skutečnými daty o časech skládání: jak dlouho puzzle opravdu trvají, jak fungují soutěže a jak se zrychlit." + intro: "Napsáno speed puzzling komunitou, podloženo skutečnými naměřenými časy skládání — žádné odhady." + read_guide: "Číst průvodce" + footnote: "Všechny statistiky v těchto průvodcích se počítají ze složení zaznamenaných na MySpeedPuzzling a automaticky se obnovují, jak komunita zaznamenává nové časy." + what_is: + title: "Co je speed puzzling?" + meta_description: "Speed puzzling je skládání puzzle na čas. Zjisti, jak fungují soutěže jako WJPC, jaké časy jsou běžné a jak začít." + card_description: "Kompletní úvod: formáty, jak fungují soutěže, typické časy a jak se přidat ke komunitě." + how_long: + title: "Jak dlouho trvá složit puzzle s 1000 dílky?" + meta_description: "Změřeno na %count% zaznamenaných sólo složeních: medián u puzzle s 1000 dílky je %median%. Skutečné percentily a nejrychlejší časy podle počtu dílků." + meta_description_fallback: "Jediná odpověď na internetu podložená daty: mediány časů, percentily a nejrychlejší zaznamenaná složení podle počtu dílků, ze skutečně naměřených časů." + card_description: "Skutečná data, žádné odhady: mediány časů, percentily a nejrychlejší zaznamenané výsledky podle počtu dílků." + tips: + title: "Tipy na speed puzzling: jak se zrychlit" + meta_description: "Deset praktických tipů na speed puzzling od soutěžních skládačů — třídění, okraje, skládání podle barev, příprava a jak měřit skutečný pokrok." + card_description: "Deset technik, které opravdu zkrátí tvůj čas skládání, plus komunitní měřítka, se kterými se můžeš porovnat." + "wjpc_hub": "meta": "title": "Mistrovství světa ve skládání puzzle" diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 0b60fa897..363130d0c 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -392,6 +392,7 @@ "delete_button": "Löschen — nie passiert!" "delete_modal_message": "Nur um sicher zu gehen..." "delete_modal_confirm": "Wirklich löschen?" + "delete_xp_warning": "Du verlierst %xp% XP, die dieses Ergebnis gebracht hat." "delete_modal_discard_button": "Nein, behalten" "delete_modal_confirm_button": "Ja, löschen" "error": @@ -694,6 +695,13 @@ "frequency_48_hours": "Alle 48 Stunden" "frequency_1_week": "Einmal pro Woche" "newsletter_enabled": "Newsletter erhalten" + "content_digest_frequency": "Wochenrückblick per E-Mail" + "content_digest_help": "Deine Puzzle-Woche: XP, Erfolge, Statistiken und die Aktivität deiner Freunde. Wöchentlich ist genau richtig." + "content_digest_none": "Keinen Rückblick senden" + "content_digest_daily": "Täglich + wöchentlich" + "content_digest_weekly": "Wöchentlich" + "hide_experience_system": "Erfahrungssystem (XP & Level) ausblenden" + "hide_experience_system_help": "Dein Level, deine XP-Quittungen, Jubelmomente und Bestenlisten-Einträge verschwinden überall. XP sammeln sich im Stillen weiter — es geht also nichts verloren, wenn du es wieder einschaltest." "newsletter_help": "Wir senden gelegentlich einen Newsletter über Neuigkeiten auf der Plattform, meist über neue Funktionen. Erwartete Häufigkeit: maximal 1x pro Monat." "messaging_notifications": "Nachrichten & Benachrichtigungen" "personal_access_tokens": "Persönliche Zugriffstoken" @@ -866,11 +874,350 @@ "conversation_request": "general": "%player% hat dir eine Konversationsanfrage gesendet" "marketplace": "%player% hat dir eine Nachricht über %puzzle% gesendet" -"badges": - "title": "Abzeichen" - "my_title": "Meine Abzeichen" - "badge": - "supporter": "Unterstützer" +xp: + level_chip: "Lv %level%" + total: "%xp% XP" + total_tooltip: "%xp% XP insgesamt verdient" + max_level_reached: "Gipfel erreicht — Level 50!" + progress_label: "%xp% XP bis Level %level%" + receipt: + title: "Deine XP für dieses Puzzle" + total: "Gesamt" + waiting_teaser: "{1}Dieses Ergebnis zählt für 1 wartenden Erfolg 🔒|]1,Inf[Dieses Ergebnis zählt für %count% wartende Erfolge 🔒" + max_level_title: "Level 50 — XP zählen weiter, der Ruhm bleibt für immer" + nearest_achievements: "Deine nächsten Erfolge sind zum Greifen nah:" + max_level_all_done: "Du hast alles erobert, was es zu erobern gibt. Wir sind ehrlich beeindruckt." + relax_repeat: "Ein vertrautes Puzzle, einfach nur zum Genießen — diesmal keine XP, und genau darum geht's." + counting: "Wir zählen deine XP…" + line: + base: "Puzzle gelegt" + base_repeat_second: "Wiederholung (2. Mal, ×50%)" + base_repeat_later: "Wiederholung (ab 3. Mal, ×25%)" + base_relax: "Relax-Puzzle (×50%)" + solve_difficulty_bonus: "Schwierigkeitsbonus" + solve_unboxed_bonus: "Blind-gelegt-Bonus" + solve_speed_bonus: "Tempobonus" + solve_weekly_boost: "Wochen-Boost" + solve_daily_warmup: "Tägliches Warm-up" + difficulty_settlement: "Schwierigkeitsbonus (nachgereicht)" + speed_settlement: "Tempobonus (nachgereicht)" + achievement: "Erfolg erhalten" + solve_compensation: "Ergebnis gelöscht" + difficulty_pending: "Schwierigkeitsbonus ausstehend — wird nachgereicht, sobald dieses Puzzle bewertet ist" + speed_pending: "Tempobonus ausstehend — braucht Zeiten von 3 Puzzlern" + levelup: + aria: "Level-up! Du hast Level %level% erreicht" + label: "Level-up!" + max_label: "Der Gipfel!" + message: "Deine Puzzle-Reise klettert weiter nach oben. Wunderbar gemacht!" + max_message: "Level 50. Höher geht es nicht. Ab jetzt ist die Erfolgspunkte-Rangliste dein Berg." + enter_ap_ladder: "Zur AP-Rangliste" + view_ap_ladder: "AP-Rangliste ansehen" + skip_hint: "Tippe irgendwo, um fortzufahren" + achievement_toast: + title: "Neuer Erfolg!" + estimate: + line: "Dieses Puzzle bringt ~%base% XP + bis zu %bonus% Bonus" + repeat_note: "Du hast dieses Puzzle schon einmal gelegt — diesmal zählt es ×%multiplier%." + unrated_note: "Der Schwierigkeitsbonus wird automatisch nachgereicht, sobald dieses Puzzle bewertet ist." + leaderboard: + meta_title: "XP-Bestenliste" + title: "XP-Bestenliste" + subtitle: "Aktivität, gefeiert." + tab: + this-week: "Diese Woche" + all-time: "Allzeit" + achievement-points: "Erfolgspunkte" + favorites_only: "Nur Favoriten" + player: "Puzzler" + value: + this-week: "XP diese Woche" + all-time: "XP gesamt" + achievement-points: "AP" + xp_value: "%xp% XP" + ap_value: "%points% AP" + lv50_ap: "Lv 50 · %points% AP" + your_position: "Deine Platzierung" + ap_login_required: "Melde dich an, um die Erfolgspunkte-Rangliste zu sehen." + empty: "Noch nichts zu sehen — erfasse ein Ergebnis und eröffne die Bestenliste!" + weekly_note: "XP dieser Woche aus Puzzles und Erfolgen; Nachreichungen und die Start-Umwandlung zählen hier nicht." + history: + meta_title: "Mein XP-Verlauf" + title: "Mein XP-Verlauf" + subtitle: "Jeder Punkt, sauber verbucht — dein vollständiges XP-Konto." + when: "Wann" + reason: "Grund" + empty: "Noch keine XP — dein erstes Puzzle ändert das." + deleted_solve: "gelöschtes Ergebnis" + pagination: "Seiten des XP-Verlaufs" + note: "Fragst du dich, was hinter einer Zeile steckt?" + reveal: + meta_title: "Deine Puzzle-Reise, enthüllt" + title: "Deine ganze Reise ist gerade zu XP geworden ✨" + intro: "Jedes Puzzle, das du je erfasst hast, zählt. Hier ist, was deine Geschichte wert ist:" + level_label: "Level" + xp_note: "verdient in deiner gesamten Puzzle-Geschichte" + achievements_member: "{1}Und 1 Erfolg gehört schon dir — du findest ihn auf deinem Profil!|]1,Inf[Und %count% Erfolge gehören schon dir — du findest sie auf deinem Profil!" + share_title: "Ich bin Level %level% auf MySpeedPuzzling!" + share_button: "Mein Level teilen" + continue: "Zu meinem Profil" + to_badge_reveals: "Hast du Erfolge zum Aufdecken? Dreh sie hier um." + explainer: + title: "So funktionieren XP & Level" + intro: "Jedes Puzzle, das du fertigstellst, bringt dich weiter. XP (Erfahrungspunkte) feiern deine Aktivität — nicht, wie schnell du bist, sondern dass du da warst und gepuzzelt hast. Erfasse ein Ergebnis, verdiene XP und sieh zu, wie dein Level von 1 bis ganz hinauf zu 50 wächst." + currencies: + heading: "Die drei Zahlen auf MySpeedPuzzling" + header_what: "Was" + header_measures: "Misst" + header_who: "Wer sieht es" + xp_name: "XP & Level" + xp_measures: "Deine Aktivität — jedes Puzzle zählt" + xp_who: "Alle, für immer kostenlos" + ap_name: "Erfolgspunkte (AP)" + ap_measures: "Deine Sammlung verdienter Erfolge" + ap_who: "Mitglieder" + rating_name: "MSP Rating" + rating_measures: "Dein Tempo und Können" + rating_who: "Mitglieder (unverändert, separat)" + never_bought: "XP lassen sich nicht kaufen — keine Boosts, keine Multiplikatoren, keine Abkürzungen. Niemals." + unlock_nothing: "Level schalten nichts Funktionales frei; sie sind deine sichtbare Puzzle-Identität." + how: + heading: "Wie aus einem Puzzle XP werden" + intro: "Deine Quittung nach jedem Puzzle zeigt genau, woher jeder Punkt kommt:" + base: "Basis: etwa 1 XP pro 100 Teile (ein 500-Teile-Puzzle ≈ 5 XP). Gedeckelt bei 60." + difficulty: "Schwierigkeitsbonus: schwerere Puzzles (Community-Schwierigkeitsstufen 3–6) bringen +15% bis +50%." + team: "Team-Puzzles: alle in der Gruppe erhalten 75% des Solo-Werts — gemeinsames Puzzeln zählt für jedes Paar Hände." + unboxed: "Blind-gelegt-Bonus: ohne Schachtelbild gelegt? +20%." + repeat: "Wiederholungen: das 2. Ergebnis auf Zeit beim selben Puzzle bringt 50%, spätere 25%. Relax-Modus: erstes Ergebnis 50%, Wiederholungen 0%." + speed: "Tempobonus: Solo-Ergebnisse auf Zeit, die schneller als der Community-Median des Puzzles sind, bringen +5%, Top 25% +10%, Top 10% +15% (braucht Zeiten von mindestens 3 Puzzlern)." + weekly: "Wochen-Boost: deine ersten 5 Puzzles jeder Woche bringen +50% extra." + daily: "Tägliches Warm-up: das erste Puzzle deines Tages bringt pauschal +2 XP." + example: + heading: "Rechenbeispiel" + body: "Ein Solo-Ergebnis auf Zeit, zum ersten Mal gelegt: ein 1000-Teile-Puzzle der Schwierigkeitsstufe 4, blind gelegt, schneller als der Median, als erstes Puzzle der Woche und des Tages:" + math: "Basis 10 + Schwierigkeit 3 + blind gelegt 3 + Tempo 1 + Wochen-Boost 8 + Warm-up 2 = 27 XP" + unrated: + heading: "Unbewertete Puzzles" + body: "Brandneue Katalog-Puzzles haben manchmal noch keine Schwierigkeitsstufe. Die Basis-XP bekommst du sofort, und der Schwierigkeitsbonus kommt automatisch (wird \"nachgereicht\"), sobald die Community das Puzzle bewertet hat. Dasselbe gilt für den Tempobonus, sobald genug Puzzler Zeiten erfasst haben." + achievements: + heading: "Auch Erfolge bringen XP" + body: "Jede Erfolgsstufe bringt einmalig XP, für immer: Bronze 5 · Silber 10 · Gold 25 · Platin 50 · Diamant 100." + levels: + heading: "Level" + header_level: "Level" + header_xp: "XP" + summit: "Level 50 ist der Gipfel — insgesamt 3.160 XP. XP sammeln sich auch danach weiter; ab Level 50 wird die Erfolgspunkte-Rangliste dein nächster Berg." + lose: + heading: "Kann ich XP verlieren?" + body: "Nur durch Löschen oder Bearbeiten eines Ergebnisses — die XP, die es gebracht hat, gehen mit. Sonst nimmt dir nichts jemals XP weg. Erfolge sind dauerhaft." + faq: + heading: "FAQ" + old_solves_question: "Zählen alte Ergebnisse?" + old_solves_answer: "Ja! Deine gesamte Historie wurde zum Start umgewandelt (Basisformel, ohne zeitbasierte Boni — die gelten nur ab jetzt)." + opt_out_question: "Ich will das alles nicht." + opt_out_answer: "Verstanden — schalte es in deinen Profileinstellungen aus (\"Erfahrungssystem\"), und dein Level, deine XP und die Jubelmomente verschwinden aus der Ansicht." + friend_question: "Warum hat jemand anderes mehr XP für dasselbe Puzzle bekommen?" + friend_answer: "Wiederholungsabzüge, Wochen-Boosts und tägliche Warm-ups sind persönlich; zwei Quittungen sind selten gleich. Tippe auf eine Zeile der Quittung, um ihren Grund zu sehen." + fair_play_question: "Wie bleibt das fair?" + fair_play_answer: "Ein paar stille, automatische Leitplanken halten die Zahlen ehrlich — alles darüber liest du auf der Seite Fair Play & Vertrauen." + fair_play: + title: "Fair Play & Vertrauen" + intro: "MySpeedPuzzling basiert auf Vertrauen. Niemand prüft deine Ergebnisse, bevor sie zählen — wir glauben dir, und die Zahlen der Community bleiben dank ein paar stiller, automatischer Leitplanken aussagekräftig:" + principle_no_buying: "XP messen Aktivität, niemals Geld: Es gibt keine Möglichkeit, XP, Boosts oder Multiplikatoren zu kaufen." + principle_repeats: "Wiederholte Ergebnisse beim selben Puzzle bringen jedes Mal weniger — ein Puzzle immer wieder zu erfassen ist keine Abkürzung." + principle_speed: "Der Tempobonus gilt nur dort, wo eine verlässliche Community-Referenz existiert, und unglaubwürdig schnelle Zeiten bekommen ihn schlicht nicht. Keine Anschuldigung, kein Drama — der Bonus gilt einfach nicht." + principle_deleting: "Das Löschen eines Ergebnisses entfernt die damit verdienten XP, automatisch und transparent (dein XP-Verlauf zeigt jeden Eintrag)." + principle_caps: "Extrem große oder ungewöhnliche Einträge werden gedeckelt, damit kein einzelnes Ergebnis die Rangliste verzerren kann." + closing_thresholds: "Die genauen Schwellenwerte veröffentlichen wir bewusst nicht — sie sorgen für Fairness und sollen nicht ausgetrickst werden. Wenn dir auf einer Bestenliste etwas seltsam vorkommt, schreib uns; ein Mensch schaut sich jede Meldung an." + closing_celebrate: "Puzzle ehrlich, feiere laut. 🧩" + explainer_link: "Neugierig, wie jeder Punkt entsteht? Siehe So funktionieren XP & Level." + +content_digest: + unsubscribe: + meta_title: "Wochenrückblick abbestellen" + confirm_title: "Den wöchentlichen Rückblick abbestellen?" + confirm_text: "Kein Problem — ein Klick unten und der Wochenrückblick stoppt. In deinen Benachrichtigungseinstellungen kannst du ihn jederzeit wieder aktivieren." + confirm_button: "Ja, abbestellen" + done_title: "Du bist abgemeldet" + done_text: "Der Wochenrückblick landet nicht mehr in deinem Postfach. Falls du ihn vermisst: Er wartet in deinen Benachrichtigungseinstellungen." + back_home: "Zurück zu MySpeedPuzzling" + +badges: + title: "Erfolge" + my_title: "Meine Erfolge" + new: "NEU" + browse_all: "Alle Erfolge durchstöbern" + reveal: "Deck deinen neuen Erfolg auf" + waiting_teaser: "{1}1 Erfolg wartet auf dich — werde Mitglied, um ihn aufzudecken!|]1,Inf[%count% Erfolge warten auf dich — werde Mitglied, um sie aufzudecken!" + waiting_teaser_zero: "Dein erster Erfolg ist näher, als du denkst — erfasse ein Ergebnis und schau, was passiert." + unlock_with_membership: "Mit Mitgliedschaft freischalten" + ap_total: "%points% AP" + ap_total_tooltip: "Deine Erfolgspunkte — jede verdiente Stufe zählt, für immer" + free_user_hint: "Du verdienst auch ohne Mitgliedschaft weiter Erfolge — sie warten sicher und wohlbehalten auf dich." + earned_waiting: "Verdient ✓ — wartet 🔒" + view_holders: "Sieh, wer ihn verdient hat" + how_it_works_hint: "Erfolge bringen XP und Erfolgspunkte." + how_it_works_link: "So funktionieren XP & Level" + detail: + meta_title: "%name% — Puzzler mit diesem Erfolg" + country_filter: "Land" + all_countries: "Alle Länder" + newest_earners: "Zuletzt verdient" + holders_count: "{0}Noch niemand|{1}1 Puzzler|]1,Inf[%count% Puzzler" + holders_count_tooltip: "Hier zählen alle — auch Puzzler, deren Profile unten nicht aufgeführt sind" + first_to_earn: "Als Erster verdient" + more_puzzlers: "+%count% weitere Puzzler" + only_hidden_holders: "{1}1 Puzzler hat diese Stufe verdient — das Profil ist nicht öffentlich gelistet.|]1,Inf[%count% Puzzler haben diese Stufe verdient — ihre Profile sind nicht öffentlich gelistet." + nobody_yet: "Diese Stufe hat noch niemand verdient. Wer zuerst kommt, schreibt Geschichte!" + listing_note: "Die Listen zeigen Mitglieder mit öffentlichem Profil; die Zahlen umfassen alle Puzzler." + reveals: + meta_title: "Deck deine Erfolge auf" + title: "Deine Erfolge sind da 🎉" + intro: "{1}1 Erfolg hat still auf dich gewartet. Tippe darauf, um ihn umzudrehen!|]1,Inf[%count% Erfolge haben still auf dich gewartet. Tippe auf jeden, um ihn umzudrehen!" + hint: "Lass dir Zeit — jedes Umdrehen verdient seinen eigenen kleinen Moment." + all_done: "Alles aufgedeckt — dein Profil trägt sie jetzt mit Stolz." + invite: "{1}1 Erfolg wartet auf seinen großen Moment!|]1,Inf[%count% Erfolge warten auf ihren großen Moment!" + invite_button: "Jetzt aufdecken" + back_to_profile: "Zurück zu meinem Profil" + overview_title: "Erfolge" + overview_subtitle: "Verdiene Erfolge, indem du Meilensteine auf deiner Puzzle-Reise erreichst" + progress_label: "%current% / %target%" + progress_time_label: "%current_time% → Ziel %target_time%" + locked: "Gesperrt" + earned_on: "Verdient am %date%" + highest_earned: "Höchste verdiente Stufe" + not_earned_yet: "Noch nicht verdient" + keep_going: "Weiter so!" + login_to_track: "Melde dich an, um deinen Fortschritt bei jedem Erfolg zu sehen." + tier: + bronze: "Bronze" + silver: "Silber" + gold: "Gold" + platinum: "Platin" + diamond: "Diamant" + badge: + supporter: "Pionier" + puzzles_solved: "Puzzle-Entdecker" + pieces_solved: "Teilefresser" + speed_500_pieces: "Tempoteufel (500 Teile)" + streak: "Feuer & Flamme" + team_player: "Teamgeist" + zen_puzzler: "Zen-Puzzler" + first_try: "Auf Anhieb" + unboxed: "Blind gelegt" + brand_explorer: "Markenentdecker" + marathoner: "Marathon-Puzzler" + photographer: "Fotograf" + steady_hands: "Ruhige Hand" + librarian: "Bibliothekar" + speed_1000_pieces: "Tempoteufel (1.000 Teile)" + weekend_puzzler: "Wochenend-Puzzler" + cataloger: "Archivar" + description: + supporter: "Verliehen an die Puzzler, die früh an MySpeedPuzzling geglaubt und beim Wachsen geholfen haben." + puzzles_solved: "Löse verschiedene Puzzles, um diese Leiter zu erklimmen." + pieces_solved: "Je mehr Teile, desto besser — jedes Teil, das du je gelegt hast, zählt." + speed_500_pieces: "Dein schnellstes 500-Teile-Solo-Ergebnis bestimmt deine Stufe." + streak: "Lege Tag für Tag Puzzles, ohne auszusetzen." + team_player: "Tu dich mit anderen Puzzlern zusammen und knackt Puzzles gemeinsam." + zen_puzzler: "Puzzle einfach nur zum Vergnügen — hier zählen entspannte Ergebnisse ohne Zeit." + first_try: "Gleich beim ersten Mal — jedes Ergebnis, das du als ersten Versuch eines Puzzles erfasst, zählt." + unboxed: "Lege Puzzles, ohne aufs Schachtelbild zu schauen — die ultimative Mystery-Herausforderung." + brand_explorer: "Probiere Puzzles verschiedener Marken und entdecke deine Favoriten." + marathoner: "Bezwinge die Großen — jedes gelegte Puzzle mit 2.000 oder mehr Teilen zählt." + photographer: "Mach ein Foto deines fertigen Puzzles, wenn du ein Ergebnis erfasst — jedes Bild zählt." + steady_hands: "Puzzle über aufeinanderfolgende Quartale hinweg, mit mindestens einem Ergebnis in jedem — ohne Lücke." + librarian: "Hilf mit, den Puzzle-Katalog korrekt zu halten — nur deine akzeptierten Verbesserungsvorschläge zählen." + speed_1000_pieces: "Dein schnellstes 1.000-Teile-Solo-Ergebnis bestimmt deine Stufe." + weekend_puzzler: "Samstage und Sonntage sind zum Puzzeln da — jedes Wochenend-Ergebnis zählt." + cataloger: "Lass den Puzzle-Katalog wachsen — jedes genehmigte Puzzle, das du hinzufügst, zählt." + requirement: + puzzles_solved_1: "Löse 10 verschiedene Puzzles" + puzzles_solved_2: "Löse 100 verschiedene Puzzles" + puzzles_solved_3: "Löse 500 verschiedene Puzzles" + puzzles_solved_4: "Löse 1.000 verschiedene Puzzles" + puzzles_solved_5: "Löse 2.000 verschiedene Puzzles" + pieces_solved_1: "Sammle 10.000 Teile" + pieces_solved_2: "Sammle 100.000 Teile" + pieces_solved_3: "Sammle 500.000 Teile" + pieces_solved_4: "Sammle 1.000.000 Teile" + pieces_solved_5: "Sammle 2.000.000 Teile" + speed_500_pieces_1: "Lege ein 500-Teile-Puzzle in unter 5 Stunden (solo)" + speed_500_pieces_2: "Lege ein 500-Teile-Puzzle in unter 2 Stunden (solo)" + speed_500_pieces_3: "Lege ein 500-Teile-Puzzle in unter 1 Stunde (solo)" + speed_500_pieces_4: "Lege ein 500-Teile-Puzzle in unter 45 Minuten (solo)" + speed_500_pieces_5: "Lege ein 500-Teile-Puzzle in unter 30 Minuten (solo)" + streak_1: "Lege an 7 Tagen in Folge Puzzles" + streak_2: "Lege an 30 Tagen in Folge Puzzles" + streak_3: "Lege an 90 Tagen in Folge Puzzles" + streak_4: "Lege an 180 Tagen in Folge Puzzles" + streak_5: "Lege an 365 Tagen in Folge Puzzles" + team_player_1: "Schließe dein erstes Team- oder Duo-Puzzle ab" + team_player_2: "Schließe 5 Team- oder Duo-Puzzles ab" + team_player_3: "Schließe 25 Team- oder Duo-Puzzles ab" + team_player_4: "Schließe 100 Team- oder Duo-Puzzles ab" + team_player_5: "Schließe 500 Team- oder Duo-Puzzles ab" + zen_puzzler_1: "Erfasse dein erstes entspanntes Ergebnis ohne Zeit" + zen_puzzler_2: "Erfasse 10 entspannte Ergebnisse ohne Zeit" + zen_puzzler_3: "Erfasse 50 entspannte Ergebnisse ohne Zeit" + zen_puzzler_4: "Erfasse 150 entspannte Ergebnisse ohne Zeit" + zen_puzzler_5: "Erfasse 365 entspannte Ergebnisse ohne Zeit" + first_try_1: "Erfasse 5 Erstversuche" + first_try_2: "Erfasse 50 Erstversuche" + first_try_3: "Erfasse 200 Erstversuche" + first_try_4: "Erfasse 500 Erstversuche" + first_try_5: "Erfasse 1.000 Erstversuche" + unboxed_1: "Lege dein erstes Puzzle, ohne auf die Schachtel zu schauen" + unboxed_2: "Lege 5 Puzzles, ohne auf die Schachtel zu schauen" + unboxed_3: "Lege 25 Puzzles, ohne auf die Schachtel zu schauen" + unboxed_4: "Lege 50 Puzzles, ohne auf die Schachtel zu schauen" + unboxed_5: "Lege 100 Puzzles, ohne auf die Schachtel zu schauen" + brand_explorer_1: "Löse Puzzles von 3 verschiedenen Marken" + brand_explorer_2: "Löse Puzzles von 10 verschiedenen Marken" + brand_explorer_3: "Löse Puzzles von 25 verschiedenen Marken" + brand_explorer_4: "Löse Puzzles von 50 verschiedenen Marken" + brand_explorer_5: "Löse Puzzles von 100 verschiedenen Marken" + marathoner_1: "Schließe dein erstes Puzzle mit 2.000+ Teilen ab" + marathoner_2: "Schließe 5 Puzzles mit 2.000+ Teilen ab" + marathoner_3: "Schließe 15 Puzzles mit 2.000+ Teilen ab" + marathoner_4: "Schließe 40 Puzzles mit 2.000+ Teilen ab" + marathoner_5: "Schließe 100 Puzzles mit 2.000+ Teilen ab" + photographer_1: "Füge deinem ersten fertigen Puzzle ein Foto hinzu" + photographer_2: "Füge 25 fertigen Puzzles Fotos hinzu" + photographer_3: "Füge 100 fertigen Puzzles Fotos hinzu" + photographer_4: "Füge 500 fertigen Puzzles Fotos hinzu" + photographer_5: "Füge 1.000 fertigen Puzzles Fotos hinzu" + steady_hands_1: "Löse in 2 Quartalen in Folge mindestens ein Puzzle" + steady_hands_2: "Löse in 4 Quartalen in Folge mindestens ein Puzzle" + steady_hands_3: "Löse in 8 Quartalen in Folge mindestens ein Puzzle" + steady_hands_4: "Löse in 12 Quartalen in Folge mindestens ein Puzzle" + steady_hands_5: "Löse in 16 Quartalen in Folge mindestens ein Puzzle" + librarian_1: "Bring deinen ersten Katalog-Verbesserungsvorschlag durch" + librarian_2: "Bring 5 Katalog-Verbesserungsvorschläge durch" + librarian_3: "Bring 20 Katalog-Verbesserungsvorschläge durch" + librarian_4: "Bring 50 Katalog-Verbesserungsvorschläge durch" + librarian_5: "Bring 100 Katalog-Verbesserungsvorschläge durch" + speed_1000_pieces_1: "Lege ein 1.000-Teile-Puzzle in unter 8 Stunden (solo)" + speed_1000_pieces_2: "Lege ein 1.000-Teile-Puzzle in unter 4 Stunden (solo)" + speed_1000_pieces_3: "Lege ein 1.000-Teile-Puzzle in unter 2,5 Stunden (solo)" + speed_1000_pieces_4: "Lege ein 1.000-Teile-Puzzle in unter 1 Stunde 45 Minuten (solo)" + speed_1000_pieces_5: "Lege ein 1.000-Teile-Puzzle in unter 1 Stunde 15 Minuten (solo)" + weekend_puzzler_1: "Lege 10 Puzzles an Wochenenden" + weekend_puzzler_2: "Lege 50 Puzzles an Wochenenden" + weekend_puzzler_3: "Lege 150 Puzzles an Wochenenden" + weekend_puzzler_4: "Lege 300 Puzzles an Wochenenden" + weekend_puzzler_5: "Lege 600 Puzzles an Wochenenden" + cataloger_1: "Füge dein erstes genehmigtes Puzzle zum Katalog hinzu" + cataloger_2: "Füge 10 genehmigte Puzzles zum Katalog hinzu" + cataloger_3: "Füge 50 genehmigte Puzzles zum Katalog hinzu" + cataloger_4: "Füge 150 genehmigte Puzzles zum Katalog hinzu" + cataloger_5: "Füge 300 genehmigte Puzzles zum Katalog hinzu" + unit: + puzzles: "Puzzles" + pieces: "Teile" + days: "Tage" + team_solves: "Team-Ergebnisse" + "scan": "meta": "title": "Barcode-Leser" @@ -2737,6 +3084,39 @@ qr_code: "most_solved": "Meistgelegte %pieces%-teilige Puzzles" "view_all": "Alle %pieces%-teiligen Puzzles anzeigen" +guides: + breadcrumb: + home: "Startseite" + guides: "Guides" + ui: + eyebrow: "Guide" + updated: "Aktualisiert am %date%" + data_note: "Live-Daten der Community" + read_next: "Als Nächstes lesen" + cta_title: "Bereit, bei deinem nächsten Puzzle die Zeit zu stoppen?" + cta_stopwatch: "Stoppuhr starten" + cta_puzzles: "Puzzle-Datenbank durchstöbern" + cta_events: "Einen Wettbewerb finden" + index: + title: "Speed Puzzling Guides" + meta_description: "Guides zum Speed Puzzling, gestützt auf echte Legezeiten: wie lange Puzzles wirklich dauern, wie Wettbewerbe funktionieren und wie du schneller wirst." + intro: "Geschrieben von der Speed-Puzzling-Community, gestützt auf echte erfasste Legezeiten — keine Schätzungen." + read_guide: "Guide lesen" + footnote: "Alle Statistiken in diesen Guides werden aus den auf MySpeedPuzzling erfassten Ergebnissen berechnet und aktualisieren sich automatisch, sobald die Community neue Zeiten erfasst." + what_is: + title: "Was ist Speed Puzzling?" + meta_description: "Speed Puzzling ist Puzzeln gegen die Uhr. Erfahre, wie Wettbewerbe wie die WJPC funktionieren, welche Zeiten typisch sind und wie du einsteigst." + card_description: "Die komplette Einführung: Formate, wie Wettbewerbe ablaufen, typische Zeiten und wie du Teil der Community wirst." + how_long: + title: "Wie lange dauert ein 1000-Teile-Puzzle?" + meta_description: "Gemessen an %count% erfassten Solo-Ergebnissen: Das mediane 1000-Teile-Puzzle dauert %median%. Echte Perzentile und Bestzeiten nach Teileanzahl." + meta_description_fallback: "Die einzige datenbasierte Antwort im Netz: mediane Zeiten, Perzentile und schnellste erfasste Ergebnisse nach Teileanzahl — aus echten Legezeiten der Community." + card_description: "Echte Daten statt Schätzungen: mediane Zeiten, Perzentile und schnellste erfasste Ergebnisse nach Teileanzahl." + tips: + title: "Speed-Puzzling-Tipps: So wirst du schneller" + meta_description: "Zehn praktische Speed-Puzzling-Tipps von Wettkampf-Puzzlern — Sortieren, Randteile, Farbblöcke, Setup und wie du echten Fortschritt misst." + card_description: "Zehn Techniken, die deine Legezeit wirklich verkürzen, plus Community-Benchmarks zum Vergleichen." + "wjpc_hub": "meta": "title": "Puzzle Weltmeisterschaft (WJPC)" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index b26604844..59b49c542 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -444,6 +444,7 @@ edit_time: delete_button: "Delete — never happened!" delete_modal_message: "Just to be sure..." delete_modal_confirm: "Really delete?" + delete_xp_warning: "You will lose %xp% XP earned by this solve." delete_modal_discard_button: "No, keep it" delete_modal_confirm_button: "Yes, delete" @@ -869,6 +870,13 @@ edit_profile: frequency_48_hours: "Every 48 hours" frequency_1_week: "Once a week" newsletter_enabled: "Receive newsletters" + content_digest_frequency: "Weekly digest email" + content_digest_help: "Your week in puzzling: XP, achievements, stats and friends' activity. Weekly is the sweet spot." + content_digest_none: "Don't send me the digest" + content_digest_daily: "Daily + weekly" + content_digest_weekly: "Weekly" + hide_experience_system: "Hide the experience system (XP & levels)" + hide_experience_system_help: "Your level, XP receipts, celebrations and leaderboard entries disappear everywhere. XP keeps accruing silently, so nothing is lost if you turn it back on." newsletter_help: "We will occasionally send a newsletter about what's new on the platform, usually explaining new features. Expected frequency: no more than 1 per month." personal_access_tokens: "Personal Access Tokens" personal_access_tokens_description: "Tokens for accessing your own data through the API." @@ -1054,11 +1062,349 @@ notifications: general: "%player% sent you a conversation request" marketplace: "%player% sent you a message about %puzzle%" +xp: + level_chip: "Lv %level%" + total: "%xp% XP" + total_tooltip: "%xp% XP earned in total" + max_level_reached: "Top of the mountain — level 50!" + progress_label: "%xp% XP to level %level%" + receipt: + title: "Your XP for this solve" + total: "Total" + waiting_teaser: "{1}This solve counted toward 1 waiting achievement 🔒|]1,Inf[This solve counted toward %count% waiting achievements 🔒" + max_level_title: "Level 50 — XP still counts, glory is forever" + nearest_achievements: "Your next achievements are within reach:" + max_level_all_done: "You have conquered everything there is to conquer. We are honestly impressed." + relax_repeat: "A familiar puzzle, purely for the joy of it — no XP this time, and that's the point." + counting: "Counting your XP…" + line: + base: "Solve" + base_repeat_second: "Repeat solve (2nd time, ×50%)" + base_repeat_later: "Repeat solve (3rd+ time, ×25%)" + base_relax: "Relax solve (×50%)" + solve_difficulty_bonus: "Difficulty bonus" + solve_unboxed_bonus: "Unboxed bonus" + solve_speed_bonus: "Speed bonus" + solve_weekly_boost: "Weekly boost" + solve_daily_warmup: "Daily warm-up" + difficulty_settlement: "Difficulty bonus (settled)" + speed_settlement: "Speed bonus (settled)" + achievement: "Achievement earned" + solve_compensation: "Solve deleted" + difficulty_pending: "Difficulty bonus pending — settles when this puzzle gets rated" + speed_pending: "Speed bonus pending — needs times from 3 puzzlers" + levelup: + aria: "Level up! You reached level %level%" + label: "Level up!" + max_label: "The summit!" + message: "Your puzzling journey keeps climbing. Beautifully done!" + max_message: "Level 50. The highest there is. From here on, the Achievement Points ladder is your mountain." + enter_ap_ladder: "Enter the AP ladder" + view_ap_ladder: "See the AP ladder" + skip_hint: "Tap anywhere to continue" + achievement_toast: + title: "New achievement!" + estimate: + line: "Solving this earns ~%base% XP + up to %bonus% bonus" + repeat_note: "You've solved this one before — this time it counts ×%multiplier%." + unrated_note: "Difficulty bonus settles automatically once this puzzle gets rated." + leaderboard: + meta_title: "XP Leaderboard" + title: "XP Leaderboard" + subtitle: "Activity, celebrated." + tab: + this-week: "This week" + all-time: "All time" + achievement-points: "Achievement Points" + favorites_only: "Favorites only" + player: "Puzzler" + value: + this-week: "XP this week" + all-time: "Total XP" + achievement-points: "AP" + xp_value: "%xp% XP" + ap_value: "%points% AP" + lv50_ap: "Lv 50 · %points% AP" + your_position: "Your position" + ap_login_required: "Log in to browse the Achievement Points ladder." + empty: "Nothing here yet — log a solve and open the board!" + weekly_note: "This week's XP from solves and achievements; settlements and launch backfill don't count here." + history: + meta_title: "My XP history" + title: "My XP history" + subtitle: "Every point, accounted for — your complete XP ledger." + when: "When" + reason: "Reason" + empty: "No XP yet — your first solve changes that." + deleted_solve: "deleted solve" + pagination: "XP history pages" + note: "Wondering about a line?" + reveal: + meta_title: "Your puzzling journey, revealed" + title: "Your whole journey just turned into XP ✨" + intro: "Every puzzle you ever logged counts. Here is what your history is worth:" + level_label: "Level" + xp_note: "earned across your whole puzzling history" + achievements_member: "{1}And 1 achievement is already yours — find it on your profile!|]1,Inf[And %count% achievements are already yours — find them on your profile!" + share_title: "I'm Level %level% on MySpeedPuzzling!" + share_button: "Share my level" + continue: "Take me to my profile" + to_badge_reveals: "Got achievements to reveal? Flip them here." + explainer: + title: "How XP & Levels work" + intro: "Every puzzle you finish moves you forward. XP (experience points) celebrates your activity — not how fast you are, just that you showed up and puzzled. Log a solve, earn XP, watch your level grow from 1 all the way to 50." + currencies: + heading: "The three numbers on MySpeedPuzzling" + header_what: "What" + header_measures: "Measures" + header_who: "Who sees it" + xp_name: "XP & Levels" + xp_measures: "Your activity — every solve counts" + xp_who: "Everyone, free forever" + ap_name: "Achievement Points (AP)" + ap_measures: "Your collection of earned achievements" + ap_who: "Members" + rating_name: "MSP Rating" + rating_measures: "Your speed and skill" + rating_who: "Members (unchanged, separate)" + never_bought: "XP can never be bought — no boosts, no multipliers, no shortcuts. Ever." + unlock_nothing: "Levels unlock nothing functional; they're your visual puzzling identity." + how: + heading: "How a solve turns into XP" + intro: "Your receipt after each solve shows exactly where every point came from:" + base: "Base: roughly 1 XP per 100 pieces (a 500-piece puzzle ≈ 5 XP). Capped at 60." + difficulty: "Difficulty bonus: harder puzzles (community difficulty tiers 3–6) add +15% to +50%." + team: "Team solves: everyone in the group earns 75% of the solo value — puzzling together counts for every pair of hands." + unboxed: "Unboxed bonus: solved without the box picture? +20%." + repeat: "Repeat solves: the 2nd timed solve of the same puzzle earns 50%, later ones 25%. Relax-mode: first solve 50%, repeats 0%." + speed: "Speed bonus: solo timed solves faster than the puzzle's community median earn +5%, top 25% +10%, top 10% +15% (needs times from at least 3 puzzlers)." + weekly: "Weekly boost: your first 5 solves each week earn +50% extra." + daily: "Daily warm-up: the first solve of your day brings +2 XP flat." + example: + heading: "Worked example" + body: "A solo, timed, first-time solve of a 1000-piece tier-4 puzzle, solved without the box, faster than the median, as your first solve of the week and day:" + math: "base 10 + difficulty 3 + unboxed 3 + speed 1 + weekly boost 8 + warm-up 2 = 27 XP" + unrated: + heading: "Unrated puzzles" + body: "Brand-new catalog puzzles may not have a difficulty tier yet. You earn the base XP right away, and the difficulty bonus arrives automatically (\"settles\") once the community rates the puzzle. Same for the speed bonus once enough puzzlers have logged times." + achievements: + heading: "Achievements give XP too" + body: "Every achievement tier grants XP once, forever: Bronze 5 · Silver 10 · Gold 25 · Platinum 50 · Diamond 100." + levels: + heading: "Levels" + header_level: "Level" + header_xp: "XP" + summit: "Level 50 is the summit — 3,160 XP total. XP keeps accruing past it; at level 50 the Achievement Points ladder becomes your next mountain." + lose: + heading: "Can I lose XP?" + body: "Only by deleting or editing a solve — the XP that solve earned goes with it. Nothing else ever takes XP away. Achievements are permanent." + faq: + heading: "FAQ" + old_solves_question: "Do old solves count?" + old_solves_answer: "Yes! Your entire history was converted at launch (base formula, no time-based bonuses — those only apply going forward)." + opt_out_question: "I don't want any of this." + opt_out_answer: "Understood — turn it off in your profile settings (\"experience system\"), and your level, XP and celebrations disappear from view." + friend_question: "Why did my friend get more XP for the same puzzle?" + friend_answer: "Repeat discounts, weekly boosts and daily warm-ups are personal; two receipts rarely match. Tap any receipt line to see its reason." + fair_play_question: "How is this kept fair?" + fair_play_answer: "A few quiet, automatic guardrails keep the numbers honest — read all about them on the Fair play & trust page." + fair_play: + title: "Fair play & trust" + intro: "MySpeedPuzzling runs on trust. Nobody reviews your solves before they count — we believe you, and the community's numbers stay meaningful because of a few quiet, automatic guardrails:" + principle_no_buying: "XP measures activity, never spending: there is no way to buy XP, boosts or multipliers." + principle_repeats: "Repeat solves of the same puzzle earn less each time — logging one puzzle over and over isn't a shortcut." + principle_speed: "The speed bonus only applies where a reliable community reference exists, and implausibly fast times simply don't receive it. No accusation, no drama — the bonus just doesn't apply." + principle_deleting: "Deleting a solve removes the XP it earned, automatically and transparently (your XP history page shows every entry)." + principle_caps: "Extremely large or unusual entries are capped so no single solve can distort the ladder." + closing_thresholds: "We deliberately don't publish the exact thresholds — they exist to keep things fair, not to be gamed. If something looks off on a leaderboard, write to us; a human looks at every report." + closing_celebrate: "Puzzle honestly, celebrate loudly. 🧩" + explainer_link: "Curious how every point is earned? See How XP & Levels work." + +content_digest: + unsubscribe: + meta_title: "Unsubscribe from the digest" + confirm_title: "Unsubscribe from the weekly digest?" + confirm_text: "No hard feelings — one click below and the weekly digest stops. You can re-enable it in your notification settings anytime." + confirm_button: "Yes, unsubscribe me" + done_title: "You're unsubscribed" + done_text: "The weekly digest won't email you anymore. If you miss it, it's waiting in your notification settings." + back_home: "Back to MySpeedPuzzling" + badges: - title: "Badges" - my_title: "My badges" + title: "Achievements" + my_title: "My achievements" + new: "NEW" + browse_all: "Browse all achievements" + reveal: "Reveal your new achievement" + waiting_teaser: "{1}1 achievement is waiting for you — become a member to reveal it!|]1,Inf[%count% achievements are waiting for you — become a member to reveal them!" + waiting_teaser_zero: "Your first achievement is closer than you think — log a solve and see what happens." + unlock_with_membership: "Unlock with membership" + ap_total: "%points% AP" + ap_total_tooltip: "Your Achievement Points — every earned tier counts, forever" + free_user_hint: "You keep earning achievements even without membership — they wait for you, safe and sound." + earned_waiting: "Earned ✓ — waiting 🔒" + view_holders: "See who earned it" + how_it_works_hint: "Achievements grant XP and Achievement Points." + how_it_works_link: "How XP & Levels work" + detail: + meta_title: "%name% — achievement holders" + country_filter: "Country" + all_countries: "All countries" + newest_earners: "Newest earners" + holders_count: "{0}Nobody yet|{1}1 puzzler|]1,Inf[%count% puzzlers" + holders_count_tooltip: "Everyone counts here — including puzzlers whose profiles are not listed below" + first_to_earn: "First to earn" + more_puzzlers: "+%count% more puzzlers" + only_hidden_holders: "{1}1 puzzler earned this tier — their profile is not publicly listed.|]1,Inf[%count% puzzlers earned this tier — their profiles are not publicly listed." + nobody_yet: "Nobody has earned this tier yet. First one writes history!" + listing_note: "Holder lists show members with public profiles; the counts include every puzzler." + reveals: + meta_title: "Reveal your achievements" + title: "Your achievements are here 🎉" + intro: "{1}1 achievement has been quietly waiting for you. Tap it to flip it over!|]1,Inf[%count% achievements have been quietly waiting for you. Tap each one to flip it over!" + hint: "Take your time — every flip deserves its own little moment." + all_done: "Everything is revealed — your profile wears them proudly now." + invite: "{1}1 achievement is waiting for its reveal moment!|]1,Inf[%count% achievements are waiting for their reveal moment!" + invite_button: "Reveal them" + back_to_profile: "Back to my profile" + overview_title: "Achievements" + overview_subtitle: "Earn achievements 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 achievement." + tier: + bronze: "Bronze" + silver: "Silver" + gold: "Gold" + platinum: "Platinum" + diamond: "Diamond" badge: - supporter: "Supporter" + supporter: "Early Adopter" + puzzles_solved: "Puzzle Explorer" + pieces_solved: "Piece Cruncher" + speed_500_pieces: "Speed Demon (500pc)" + streak: "On Fire" + team_player: "Team Spirit" + zen_puzzler: "Zen Puzzler" + first_try: "First Try" + unboxed: "Unboxed" + brand_explorer: "Brand Explorer" + marathoner: "Marathoner" + photographer: "Photographer" + steady_hands: "Steady Hands" + librarian: "Librarian" + speed_1000_pieces: "Speed Demon (1000pc)" + weekend_puzzler: "Weekend Puzzler" + cataloger: "Cataloger" + description: + supporter: "Awarded to the puzzlers who believed in MySpeedPuzzling early and helped it grow." + 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." + zen_puzzler: "Puzzle purely for the joy of it — relaxed solves logged without a time count here." + first_try: "Nail it on the first go — every solve logged as your first attempt at a puzzle counts." + unboxed: "Solve puzzles without peeking at the picture on the box — the ultimate mystery challenge." + brand_explorer: "Sample puzzles from different manufacturers and discover your favorites." + marathoner: "Conquer the big ones — every solve of a puzzle with 2,000 or more pieces counts." + photographer: "Snap a photo of your finished puzzle when logging a solve — every picture counts." + steady_hands: "Keep puzzling through consecutive quarters of the year with at least one solve in each — no gaps." + librarian: "Help keep the puzzle catalog accurate — only your accepted catalog improvement proposals count." + speed_1000_pieces: "Your fastest 1,000-piece solo solve sets your tier." + weekend_puzzler: "Saturdays and Sundays are for puzzling — every weekend solve counts." + cataloger: "Grow the puzzle catalog — every approved puzzle you add counts." + 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" + zen_puzzler_1: "Log your first relaxed solve without a time" + zen_puzzler_2: "Log 10 relaxed solves without a time" + zen_puzzler_3: "Log 50 relaxed solves without a time" + zen_puzzler_4: "Log 150 relaxed solves without a time" + zen_puzzler_5: "Log 365 relaxed solves without a time" + first_try_1: "Log 5 first-attempt solves" + first_try_2: "Log 50 first-attempt solves" + first_try_3: "Log 200 first-attempt solves" + first_try_4: "Log 500 first-attempt solves" + first_try_5: "Log 1,000 first-attempt solves" + unboxed_1: "Solve your first puzzle without looking at the box" + unboxed_2: "Solve 5 puzzles without looking at the box" + unboxed_3: "Solve 25 puzzles without looking at the box" + unboxed_4: "Solve 50 puzzles without looking at the box" + unboxed_5: "Solve 100 puzzles without looking at the box" + brand_explorer_1: "Solve puzzles from 3 different manufacturers" + brand_explorer_2: "Solve puzzles from 10 different manufacturers" + brand_explorer_3: "Solve puzzles from 25 different manufacturers" + brand_explorer_4: "Solve puzzles from 50 different manufacturers" + brand_explorer_5: "Solve puzzles from 100 different manufacturers" + marathoner_1: "Complete your first puzzle with 2,000+ pieces" + marathoner_2: "Complete 5 puzzles with 2,000+ pieces" + marathoner_3: "Complete 15 puzzles with 2,000+ pieces" + marathoner_4: "Complete 40 puzzles with 2,000+ pieces" + marathoner_5: "Complete 100 puzzles with 2,000+ pieces" + photographer_1: "Add a photo to your first finished puzzle" + photographer_2: "Add photos to 25 finished puzzles" + photographer_3: "Add photos to 100 finished puzzles" + photographer_4: "Add photos to 500 finished puzzles" + photographer_5: "Add photos to 1,000 finished puzzles" + steady_hands_1: "Solve at least one puzzle in 2 quarters of the year in a row" + steady_hands_2: "Solve at least one puzzle in 4 quarters of the year in a row" + steady_hands_3: "Solve at least one puzzle in 8 quarters of the year in a row" + steady_hands_4: "Solve at least one puzzle in 12 quarters of the year in a row" + steady_hands_5: "Solve at least one puzzle in 16 quarters of the year in a row" + librarian_1: "Get your first catalog improvement proposal accepted" + librarian_2: "Get 5 catalog improvement proposals accepted" + librarian_3: "Get 20 catalog improvement proposals accepted" + librarian_4: "Get 50 catalog improvement proposals accepted" + librarian_5: "Get 100 catalog improvement proposals accepted" + speed_1000_pieces_1: "Solve a 1,000-piece puzzle in under 8 hours (solo)" + speed_1000_pieces_2: "Solve a 1,000-piece puzzle in under 4 hours (solo)" + speed_1000_pieces_3: "Solve a 1,000-piece puzzle in under 2.5 hours (solo)" + speed_1000_pieces_4: "Solve a 1,000-piece puzzle in under 1 hour 45 minutes (solo)" + speed_1000_pieces_5: "Solve a 1,000-piece puzzle in under 1 hour 15 minutes (solo)" + weekend_puzzler_1: "Complete 10 solves on weekends" + weekend_puzzler_2: "Complete 50 solves on weekends" + weekend_puzzler_3: "Complete 150 solves on weekends" + weekend_puzzler_4: "Complete 300 solves on weekends" + weekend_puzzler_5: "Complete 600 solves on weekends" + cataloger_1: "Add your first approved puzzle to the catalog" + cataloger_2: "Add 10 approved puzzles to the catalog" + cataloger_3: "Add 50 approved puzzles to the catalog" + cataloger_4: "Add 150 approved puzzles to the catalog" + cataloger_5: "Add 300 approved puzzles to the catalog" + unit: + puzzles: "puzzles" + pieces: "pieces" + days: "days" + team_solves: "team solves" scan: diff --git a/translations/messages.es.yml b/translations/messages.es.yml index 95a95bafa..75ce39f06 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -393,6 +393,7 @@ "delete_button": "Eliminar — ¡nunca pasó!" "delete_modal_message": "Solo para estar seguro..." "delete_modal_confirm": "¿Realmente eliminar?" + "delete_xp_warning": "Perderás los %xp% XP ganados con esta resolución." "delete_modal_discard_button": "No, mantenerlo" "delete_modal_confirm_button": "Sí, eliminar" "back_to": "Volver a %title%" @@ -459,6 +460,13 @@ "frequency_48_hours": "Cada 48 horas" "frequency_1_week": "Una vez a la semana" "newsletter_enabled": "Recibir boletines" + "content_digest_frequency": "Correo de resumen semanal" + "content_digest_help": "Tu semana en puzzles: XP, logros, estadísticas y actividad de tus amigos. Semanal es el punto justo." + "content_digest_none": "No enviarme el resumen" + "content_digest_daily": "Diario + semanal" + "content_digest_weekly": "Semanal" + "hide_experience_system": "Ocultar el sistema de experiencia (XP y niveles)" + "hide_experience_system_help": "Tu nivel, recibos de XP, celebraciones y entradas en clasificaciones desaparecen de todas partes. El XP se sigue acumulando en silencio, así que no pierdes nada si lo vuelves a activar." "newsletter_help": "Ocasionalmente enviaremos un boletín sobre novedades en la plataforma, generalmente explicando nuevas funciones. Frecuencia esperada: no más de 1 al mes." "features_options": "Opciones de funciones" "hide_ranking": "Ocultar mi clasificación y habilidad" @@ -887,11 +895,350 @@ "conversation_request": "general": "%player% te envió una solicitud de conversación" "marketplace": "%player% te envió un mensaje sobre %puzzle%" -"badges": - "title": "Insignias" - "my_title": "Mis insignias" - "badge": - "supporter": "Partidario" +xp: + level_chip: "Nv %level%" + total: "%xp% XP" + total_tooltip: "%xp% XP ganados en total" + max_level_reached: "La cima de la montaña — ¡nivel 50!" + progress_label: "%xp% XP para el nivel %level%" + receipt: + title: "Tu XP por esta resolución" + total: "Total" + waiting_teaser: "{1}Esta resolución contó para 1 logro en espera 🔒|]1,Inf[Esta resolución contó para %count% logros en espera 🔒" + max_level_title: "Nivel 50 — el XP sigue contando, la gloria es para siempre" + nearest_achievements: "Tus próximos logros están al alcance de la mano:" + max_level_all_done: "Has conquistado todo lo que había por conquistar. Estamos sinceramente impresionados." + relax_repeat: "Un puzzle conocido, por puro placer — esta vez sin XP, y de eso se trata." + counting: "Contando tu XP…" + line: + base: "Resolución" + base_repeat_second: "Resolución repetida (2.ª vez, ×50%)" + base_repeat_later: "Resolución repetida (3.ª vez o más, ×25%)" + base_relax: "Resolución relax (×50%)" + solve_difficulty_bonus: "Bonus por dificultad" + solve_unboxed_bonus: "Bonus sin caja" + solve_speed_bonus: "Bonus por velocidad" + solve_weekly_boost: "Impulso semanal" + solve_daily_warmup: "Calentamiento diario" + difficulty_settlement: "Bonus por dificultad (liquidado)" + speed_settlement: "Bonus por velocidad (liquidado)" + achievement: "Logro conseguido" + solve_compensation: "Resolución eliminada" + difficulty_pending: "Bonus por dificultad pendiente — se liquida cuando este puzzle reciba valoración" + speed_pending: "Bonus por velocidad pendiente — necesita tiempos de 3 puzzleros" + levelup: + aria: "¡Subiste de nivel! Alcanzaste el nivel %level%" + label: "¡Subiste de nivel!" + max_label: "¡La cima!" + message: "Tu recorrido puzzlero sigue subiendo. ¡Bien hecho!" + max_message: "Nivel 50. El más alto que existe. A partir de aquí, tu montaña es la escalera de Puntos de Logro." + enter_ap_ladder: "Entrar a la escalera de AP" + view_ap_ladder: "Ver la escalera de AP" + skip_hint: "Toca en cualquier parte para continuar" + achievement_toast: + title: "¡Nuevo logro!" + estimate: + line: "Resolverlo da ~%base% XP + hasta %bonus% de bonus" + repeat_note: "Ya has resuelto este puzzle antes — esta vez cuenta ×%multiplier%." + unrated_note: "El bonus por dificultad se liquida automáticamente cuando este puzzle recibe valoración." + leaderboard: + meta_title: "Clasificación de XP" + title: "Clasificación de XP" + subtitle: "La actividad, celebrada." + tab: + this-week: "Esta semana" + all-time: "Todo el tiempo" + achievement-points: "Puntos de Logro" + favorites_only: "Solo favoritos" + player: "Puzzlero" + value: + this-week: "XP esta semana" + all-time: "XP total" + achievement-points: "AP" + xp_value: "%xp% XP" + ap_value: "%points% AP" + lv50_ap: "Nv 50 · %points% AP" + your_position: "Tu posición" + ap_login_required: "Inicia sesión para explorar la escalera de Puntos de Logro." + empty: "Aún no hay nada por aquí — ¡registra una resolución e inaugura el tablero!" + weekly_note: "El XP de esta semana proviene de resoluciones y logros; las liquidaciones y la conversión inicial del lanzamiento no cuentan aquí." + history: + meta_title: "Mi historial de XP" + title: "Mi historial de XP" + subtitle: "Cada punto, contabilizado — tu registro completo de XP." + when: "Cuándo" + reason: "Motivo" + empty: "Aún no hay XP — tu primera resolución lo cambiará." + deleted_solve: "resolución eliminada" + pagination: "Páginas del historial de XP" + note: "¿Te intriga alguna línea?" + reveal: + meta_title: "Tu recorrido puzzlero, revelado" + title: "Todo tu recorrido se acaba de convertir en XP ✨" + intro: "Cada puzzle que has registrado cuenta. Esto es lo que vale tu historia:" + level_label: "Nivel" + xp_note: "ganados a lo largo de toda tu historia puzzlera" + achievements_member: "{1}¡Y 1 logro ya es tuyo — encuéntralo en tu perfil!|]1,Inf[¡Y %count% logros ya son tuyos — encuéntralos en tu perfil!" + share_title: "¡Soy Nivel %level% en MySpeedPuzzling!" + share_button: "Compartir mi nivel" + continue: "Llévame a mi perfil" + to_badge_reveals: "¿Tienes logros por revelar? Voltéalos aquí." + explainer: + title: "Cómo funcionan los XP y niveles" + intro: "Cada puzzle que terminas te hace avanzar. Los XP (puntos de experiencia) celebran tu actividad — no lo rápido que eres, sino que te sentaste a armar. Registra una resolución, gana XP y mira crecer tu nivel desde el 1 hasta el 50." + currencies: + heading: "Los tres números de MySpeedPuzzling" + header_what: "Qué" + header_measures: "Mide" + header_who: "Quién lo ve" + xp_name: "XP y niveles" + xp_measures: "Tu actividad — cada resolución cuenta" + xp_who: "Todos, gratis para siempre" + ap_name: "Puntos de Logro (AP)" + ap_measures: "Tu colección de logros conseguidos" + ap_who: "Miembros" + rating_name: "MSP Rating" + rating_measures: "Tu velocidad y habilidad" + rating_who: "Miembros (sin cambios, independiente)" + never_bought: "El XP no se puede comprar jamás — sin potenciadores, sin multiplicadores, sin atajos. Nunca." + unlock_nothing: "Los niveles no desbloquean nada funcional; son tu identidad puzzlera visual." + how: + heading: "Cómo una resolución se convierte en XP" + intro: "Tu recibo después de cada resolución muestra exactamente de dónde salió cada punto:" + base: "Base: aproximadamente 1 XP por cada 100 piezas (un puzzle de 500 piezas ≈ 5 XP). Con tope de 60." + difficulty: "Bonus por dificultad: los puzzles más difíciles (niveles de dificultad 3–6 de la comunidad) suman de +15% a +50%." + team: "Resoluciones en equipo: cada integrante del grupo gana el 75% del valor en solitario — armar juntos cuenta para cada par de manos." + unboxed: "Bonus sin caja: ¿resuelto sin la imagen de la caja? +20%." + repeat: "Resoluciones repetidas: la 2.ª resolución cronometrada del mismo puzzle gana el 50%, las siguientes el 25%. En modo relax: primera resolución 50%, repeticiones 0%." + speed: "Bonus por velocidad: las resoluciones en solitario cronometradas más rápidas que la mediana comunitaria del puzzle ganan +5%, top 25% +10%, top 10% +15% (necesita tiempos de al menos 3 puzzleros)." + weekly: "Impulso semanal: tus primeras 5 resoluciones de cada semana ganan un +50% extra." + daily: "Calentamiento diario: la primera resolución de tu día trae +2 XP fijos." + example: + heading: "Ejemplo práctico" + body: "Una resolución en solitario, cronometrada y al primer intento de un puzzle de 1000 piezas de dificultad 4, resuelto sin caja, más rápido que la mediana, como tu primera resolución de la semana y del día:" + math: "base 10 + dificultad 3 + sin caja 3 + velocidad 1 + impulso semanal 8 + calentamiento 2 = 27 XP" + unrated: + heading: "Puzzles sin valorar" + body: "Los puzzles recién llegados al catálogo pueden no tener todavía nivel de dificultad. La base de XP la ganas al momento, y el bonus por dificultad llega automáticamente (se \"liquida\") cuando la comunidad valora el puzzle. Lo mismo con el bonus por velocidad, cuando suficientes puzzleros hayan registrado tiempos." + achievements: + heading: "Los logros también dan XP" + body: "Cada rango de logro otorga XP una sola vez, para siempre: Bronce 5 · Plata 10 · Oro 25 · Platino 50 · Diamante 100." + levels: + heading: "Niveles" + header_level: "Nivel" + header_xp: "XP" + summit: "El nivel 50 es la cima — 3.160 XP en total. El XP sigue acumulándose después; en el nivel 50, la escalera de Puntos de Logro se convierte en tu próxima montaña." + lose: + heading: "¿Puedo perder XP?" + body: "Solo al eliminar o editar una resolución — el XP que ganó esa resolución se va con ella. Nada más te quita XP jamás. Los logros son permanentes." + faq: + heading: "Preguntas frecuentes" + old_solves_question: "¿Cuentan las resoluciones antiguas?" + old_solves_answer: "¡Sí! Todo tu historial se convirtió en el lanzamiento (fórmula base, sin bonus por tiempo — esos solo aplican de aquí en adelante)." + opt_out_question: "No quiero nada de esto." + opt_out_answer: "Entendido — desactívalo en la configuración de tu perfil (\"sistema de experiencia\") y tu nivel, XP y celebraciones desaparecerán de la vista." + friend_question: "¿Por qué mi amigo ganó más XP por el mismo puzzle?" + friend_answer: "Los descuentos por repetición, los impulsos semanales y los calentamientos diarios son personales; dos recibos rara vez coinciden. Toca cualquier línea del recibo para ver su motivo." + fair_play_question: "¿Cómo se mantiene esto justo?" + fair_play_answer: "Unas cuantas salvaguardas silenciosas y automáticas mantienen los números honestos — lee todo sobre ellas en la página de Juego limpio y confianza." + fair_play: + title: "Juego limpio y confianza" + intro: "MySpeedPuzzling funciona con confianza. Nadie revisa tus resoluciones antes de que cuenten — te creemos, y los números de la comunidad siguen siendo significativos gracias a unas cuantas salvaguardas silenciosas y automáticas:" + principle_no_buying: "El XP mide actividad, nunca gasto: no hay forma de comprar XP, potenciadores ni multiplicadores." + principle_repeats: "Las resoluciones repetidas del mismo puzzle ganan menos cada vez — registrar un puzzle una y otra vez no es un atajo." + principle_speed: "El bonus por velocidad solo se aplica donde existe una referencia comunitaria fiable, y los tiempos inverosímilmente rápidos simplemente no lo reciben. Sin acusaciones, sin drama — el bonus simplemente no se aplica." + principle_deleting: "Eliminar una resolución retira el XP que ganó, de forma automática y transparente (tu página de historial de XP muestra cada entrada)." + principle_caps: "Las entradas extremadamente grandes o inusuales tienen tope para que ninguna resolución pueda distorsionar la escalera." + closing_thresholds: "Deliberadamente no publicamos los umbrales exactos — existen para mantener la justicia, no para que se les haga trampa. Si algo te parece raro en una clasificación, escríbenos; una persona revisa cada reporte." + closing_celebrate: "Arma con honestidad, celebra a lo grande. 🧩" + explainer_link: "¿Quieres saber cómo se gana cada punto? Mira Cómo funcionan los XP y niveles." + +content_digest: + unsubscribe: + meta_title: "Darse de baja del resumen" + confirm_title: "¿Darte de baja del resumen semanal?" + confirm_text: "Sin rencores — un clic abajo y el resumen semanal se detiene. Puedes volver a activarlo cuando quieras en tu configuración de notificaciones." + confirm_button: "Sí, dame de baja" + done_title: "Baja completada" + done_text: "El resumen semanal ya no te enviará correos. Si lo echas de menos, te espera en tu configuración de notificaciones." + back_home: "Volver a MySpeedPuzzling" + +badges: + title: "Logros" + my_title: "Mis logros" + new: "NUEVO" + browse_all: "Explorar todos los logros" + reveal: "Revela tu nuevo logro" + waiting_teaser: "{1}1 logro te está esperando — ¡hazte miembro para revelarlo!|]1,Inf[%count% logros te están esperando — ¡hazte miembro para revelarlos!" + waiting_teaser_zero: "Tu primer logro está más cerca de lo que crees — registra una resolución y mira qué pasa." + unlock_with_membership: "Desbloquear con membresía" + ap_total: "%points% AP" + ap_total_tooltip: "Tus Puntos de Logro — cada rango conseguido cuenta, para siempre" + free_user_hint: "Sigues ganando logros incluso sin membresía — te esperan, sanos y salvos." + earned_waiting: "Conseguido ✓ — en espera 🔒" + view_holders: "Ver quién lo consiguió" + how_it_works_hint: "Los logros otorgan XP y Puntos de Logro." + how_it_works_link: "Cómo funcionan los XP y niveles" + detail: + meta_title: "%name% — poseedores del logro" + country_filter: "País" + all_countries: "Todos los países" + newest_earners: "Los más recientes" + holders_count: "{0}Nadie todavía|{1}1 puzzlero|]1,Inf[%count% puzzleros" + holders_count_tooltip: "Aquí cuentan todos — incluidos los puzzleros cuyos perfiles no aparecen abajo" + first_to_earn: "Primeros en conseguirlo" + more_puzzlers: "+%count% puzzleros más" + only_hidden_holders: "{1}1 puzzlero consiguió este rango — su perfil no aparece públicamente.|]1,Inf[%count% puzzleros consiguieron este rango — sus perfiles no aparecen públicamente." + nobody_yet: "Nadie ha conseguido este rango todavía. ¡El primero escribirá la historia!" + listing_note: "Las listas de poseedores muestran a los miembros con perfil público; los contadores incluyen a todos los puzzleros." + reveals: + meta_title: "Revela tus logros" + title: "Tus logros están aquí 🎉" + intro: "{1}1 logro te ha estado esperando en silencio. ¡Tócalo para voltearlo!|]1,Inf[%count% logros te han estado esperando en silencio. ¡Toca cada uno para voltearlo!" + hint: "Tómate tu tiempo — cada volteo merece su pequeño momento." + all_done: "Todo revelado — tu perfil ya los luce con orgullo." + invite: "{1}¡1 logro espera su momento de revelación!|]1,Inf[¡%count% logros esperan su momento de revelación!" + invite_button: "Revelarlos" + back_to_profile: "Volver a mi perfil" + overview_title: "Logros" + overview_subtitle: "Consigue logros alcanzando hitos en tu recorrido puzzlero" + progress_label: "%current% / %target%" + progress_time_label: "%current_time% → objetivo %target_time%" + locked: "Bloqueado" + earned_on: "Conseguido el %date%" + highest_earned: "Máximo conseguido" + not_earned_yet: "Aún no conseguido" + keep_going: "¡Sigue así!" + login_to_track: "Inicia sesión para ver tu progreso hacia cada logro." + tier: + bronze: "Bronce" + silver: "Plata" + gold: "Oro" + platinum: "Platino" + diamond: "Diamante" + badge: + supporter: "Pionero" + puzzles_solved: "Explorador de Puzzles" + pieces_solved: "Devorapiezas" + speed_500_pieces: "Demonio de la Velocidad (500p)" + streak: "En Racha" + team_player: "Espíritu de Equipo" + zen_puzzler: "Puzzlero Zen" + first_try: "A la Primera" + unboxed: "Sin Caja" + brand_explorer: "Explorador de Marcas" + marathoner: "Maratonista" + photographer: "Fotógrafo" + steady_hands: "Manos Firmes" + librarian: "Bibliotecario" + speed_1000_pieces: "Demonio de la Velocidad (1000p)" + weekend_puzzler: "Puzzlero de Finde" + cataloger: "Catalogador" + description: + supporter: "Otorgado a los puzzleros que creyeron pronto en MySpeedPuzzling y lo ayudaron a crecer." + puzzles_solved: "Resuelve puzzles distintos para subir esta escalera." + pieces_solved: "Cuantas más piezas, mejor — cuenta cada una que hayas colocado en tu vida." + speed_500_pieces: "Tu resolución más rápida de 500 piezas en solitario define tu rango." + streak: "Resuelve puzzles día tras día, sin saltarte ninguno." + team_player: "Haz equipo con otros puzzleros para liquidar puzzles juntos." + zen_puzzler: "Arma por puro placer — aquí cuentan las resoluciones relax registradas sin tiempo." + first_try: "Clávalo a la primera — cuenta cada resolución registrada como tu primer intento en un puzzle." + unboxed: "Resuelve puzzles sin espiar la imagen de la caja — el desafío misterioso definitivo." + brand_explorer: "Prueba puzzles de distintos fabricantes y descubre tus favoritos." + marathoner: "Conquista los gigantes — cuenta cada resolución de un puzzle de 2.000 piezas o más." + photographer: "Toma una foto de tu puzzle terminado al registrar una resolución — cada foto cuenta." + steady_hands: "Sigue armando durante trimestres consecutivos del año, con al menos una resolución en cada uno — sin huecos." + librarian: "Ayuda a mantener el catálogo de puzzles preciso — solo cuentan tus propuestas de mejora del catálogo aceptadas." + speed_1000_pieces: "Tu resolución más rápida de 1.000 piezas en solitario define tu rango." + weekend_puzzler: "Los sábados y domingos son para armar puzzles — cuenta cada resolución de fin de semana." + cataloger: "Haz crecer el catálogo de puzzles — cuenta cada puzzle aprobado que añades." + requirement: + puzzles_solved_1: "Resuelve 10 puzzles distintos" + puzzles_solved_2: "Resuelve 100 puzzles distintos" + puzzles_solved_3: "Resuelve 500 puzzles distintos" + puzzles_solved_4: "Resuelve 1.000 puzzles distintos" + puzzles_solved_5: "Resuelve 2.000 puzzles distintos" + pieces_solved_1: "Acumula 10.000 piezas" + pieces_solved_2: "Acumula 100.000 piezas" + pieces_solved_3: "Acumula 500.000 piezas" + pieces_solved_4: "Acumula 1.000.000 de piezas" + pieces_solved_5: "Acumula 2.000.000 de piezas" + speed_500_pieces_1: "Resuelve un puzzle de 500 piezas en menos de 5 horas (en solitario)" + speed_500_pieces_2: "Resuelve un puzzle de 500 piezas en menos de 2 horas (en solitario)" + speed_500_pieces_3: "Resuelve un puzzle de 500 piezas en menos de 1 hora (en solitario)" + speed_500_pieces_4: "Resuelve un puzzle de 500 piezas en menos de 45 minutos (en solitario)" + speed_500_pieces_5: "Resuelve un puzzle de 500 piezas en menos de 30 minutos (en solitario)" + streak_1: "Resuelve puzzles 7 días seguidos" + streak_2: "Resuelve puzzles 30 días seguidos" + streak_3: "Resuelve puzzles 90 días seguidos" + streak_4: "Resuelve puzzles 180 días seguidos" + streak_5: "Resuelve puzzles 365 días seguidos" + team_player_1: "Completa tu primer puzzle en equipo o en dúo" + team_player_2: "Completa 5 puzzles en equipo o en dúo" + team_player_3: "Completa 25 puzzles en equipo o en dúo" + team_player_4: "Completa 100 puzzles en equipo o en dúo" + team_player_5: "Completa 500 puzzles en equipo o en dúo" + zen_puzzler_1: "Registra tu primera resolución relax sin tiempo" + zen_puzzler_2: "Registra 10 resoluciones relax sin tiempo" + zen_puzzler_3: "Registra 50 resoluciones relax sin tiempo" + zen_puzzler_4: "Registra 150 resoluciones relax sin tiempo" + zen_puzzler_5: "Registra 365 resoluciones relax sin tiempo" + first_try_1: "Registra 5 resoluciones al primer intento" + first_try_2: "Registra 50 resoluciones al primer intento" + first_try_3: "Registra 200 resoluciones al primer intento" + first_try_4: "Registra 500 resoluciones al primer intento" + first_try_5: "Registra 1.000 resoluciones al primer intento" + unboxed_1: "Resuelve tu primer puzzle sin mirar la caja" + unboxed_2: "Resuelve 5 puzzles sin mirar la caja" + unboxed_3: "Resuelve 25 puzzles sin mirar la caja" + unboxed_4: "Resuelve 50 puzzles sin mirar la caja" + unboxed_5: "Resuelve 100 puzzles sin mirar la caja" + brand_explorer_1: "Resuelve puzzles de 3 fabricantes distintos" + brand_explorer_2: "Resuelve puzzles de 10 fabricantes distintos" + brand_explorer_3: "Resuelve puzzles de 25 fabricantes distintos" + brand_explorer_4: "Resuelve puzzles de 50 fabricantes distintos" + brand_explorer_5: "Resuelve puzzles de 100 fabricantes distintos" + marathoner_1: "Completa tu primer puzzle de 2.000+ piezas" + marathoner_2: "Completa 5 puzzles de 2.000+ piezas" + marathoner_3: "Completa 15 puzzles de 2.000+ piezas" + marathoner_4: "Completa 40 puzzles de 2.000+ piezas" + marathoner_5: "Completa 100 puzzles de 2.000+ piezas" + photographer_1: "Añade una foto a tu primer puzzle terminado" + photographer_2: "Añade fotos a 25 puzzles terminados" + photographer_3: "Añade fotos a 100 puzzles terminados" + photographer_4: "Añade fotos a 500 puzzles terminados" + photographer_5: "Añade fotos a 1.000 puzzles terminados" + steady_hands_1: "Resuelve al menos un puzzle en 2 trimestres del año seguidos" + steady_hands_2: "Resuelve al menos un puzzle en 4 trimestres del año seguidos" + steady_hands_3: "Resuelve al menos un puzzle en 8 trimestres del año seguidos" + steady_hands_4: "Resuelve al menos un puzzle en 12 trimestres del año seguidos" + steady_hands_5: "Resuelve al menos un puzzle en 16 trimestres del año seguidos" + librarian_1: "Consigue que acepten tu primera propuesta de mejora del catálogo" + librarian_2: "Consigue que acepten 5 propuestas de mejora del catálogo" + librarian_3: "Consigue que acepten 20 propuestas de mejora del catálogo" + librarian_4: "Consigue que acepten 50 propuestas de mejora del catálogo" + librarian_5: "Consigue que acepten 100 propuestas de mejora del catálogo" + speed_1000_pieces_1: "Resuelve un puzzle de 1.000 piezas en menos de 8 horas (en solitario)" + speed_1000_pieces_2: "Resuelve un puzzle de 1.000 piezas en menos de 4 horas (en solitario)" + speed_1000_pieces_3: "Resuelve un puzzle de 1.000 piezas en menos de 2,5 horas (en solitario)" + speed_1000_pieces_4: "Resuelve un puzzle de 1.000 piezas en menos de 1 hora y 45 minutos (en solitario)" + speed_1000_pieces_5: "Resuelve un puzzle de 1.000 piezas en menos de 1 hora y 15 minutos (en solitario)" + weekend_puzzler_1: "Completa 10 resoluciones en fin de semana" + weekend_puzzler_2: "Completa 50 resoluciones en fin de semana" + weekend_puzzler_3: "Completa 150 resoluciones en fin de semana" + weekend_puzzler_4: "Completa 300 resoluciones en fin de semana" + weekend_puzzler_5: "Completa 600 resoluciones en fin de semana" + cataloger_1: "Añade tu primer puzzle aprobado al catálogo" + cataloger_2: "Añade 10 puzzles aprobados al catálogo" + cataloger_3: "Añade 50 puzzles aprobados al catálogo" + cataloger_4: "Añade 150 puzzles aprobados al catálogo" + cataloger_5: "Añade 300 puzzles aprobados al catálogo" + unit: + puzzles: "puzzles" + pieces: "piezas" + days: "días" + team_solves: "resoluciones en equipo" + "scan": "meta": "title": "Lector de código de barras" @@ -2737,6 +3084,39 @@ fair_use_policy: "most_solved": "Puzzles de %pieces% piezas más armados" "view_all": "Ver todos los puzzles de %pieces% piezas" +guides: + breadcrumb: + home: "Inicio" + guides: "Guías" + ui: + eyebrow: "Guía" + updated: "Actualizado el %date%" + data_note: "Datos de la comunidad en vivo" + read_next: "Sigue leyendo" + cta_title: "¿Listo para cronometrar tu próximo puzzle?" + cta_stopwatch: "Iniciar el cronómetro" + cta_puzzles: "Explorar la base de datos de puzzles" + cta_events: "Encontrar una competición" + index: + title: "Guías de Speed Puzzling" + meta_description: "Guías de speed puzzling respaldadas por datos reales de tiempos: cuánto tardan realmente los puzzles, cómo funcionan las competiciones y cómo ser más rápido." + intro: "Escritas por la comunidad de speed puzzling, respaldadas por tiempos reales registrados — no por suposiciones." + read_guide: "Leer la guía" + footnote: "Todas las estadísticas de estas guías se calculan a partir de resoluciones registradas en MySpeedPuzzling y se actualizan automáticamente a medida que la comunidad registra nuevos tiempos." + what_is: + title: "¿Qué es el Speed Puzzling?" + meta_description: "El speed puzzling es armar puzzles contrarreloj. Descubre cómo funcionan competiciones como el WJPC, qué tiempos son típicos y cómo empezar." + card_description: "La introducción completa: formatos, cómo funcionan las competiciones, tiempos típicos y cómo unirte a la comunidad." + how_long: + title: "¿Cuánto se tarda en armar un puzzle de 1000 piezas?" + meta_description: "Medido en %count% resoluciones en solitario registradas: la mediana para un puzzle de 1000 piezas es %median%. Percentiles reales y tiempos más rápidos, por número de piezas." + meta_description_fallback: "La única respuesta en línea basada en datos: tiempos medianos, percentiles y resoluciones más rápidas registradas por número de piezas, a partir de resoluciones reales." + card_description: "Datos reales, no suposiciones: tiempos medianos, percentiles y resultados más rápidos registrados por número de piezas." + tips: + title: "Consejos de Speed Puzzling: cómo ser más rápido" + meta_description: "Diez consejos prácticos de speed puzzling usados por puzzleros competitivos — clasificar piezas, bordes, bloques de color, preparación y cómo medir el progreso real." + card_description: "Diez técnicas que de verdad reducen tu tiempo, más referencias de la comunidad para compararte." + "wjpc_hub": "meta": "title": "Campeonato Mundial de Puzzles (WJPC)" diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 710c8990f..3b66fb998 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -395,6 +395,7 @@ "delete_button": "Supprimer — jamais arrivé !" "delete_modal_message": "Juste pour être sûr..." "delete_modal_confirm": "Vraiment supprimer ?" + "delete_xp_warning": "Vous perdrez les %xp% XP gagnés avec cette résolution." "delete_modal_discard_button": "Non, garder" "delete_modal_confirm_button": "Oui, supprimer" "error": @@ -697,6 +698,13 @@ "frequency_48_hours": "Toutes les 48 heures" "frequency_1_week": "Une fois par semaine" "newsletter_enabled": "Recevoir les newsletters" + "content_digest_frequency": "Résumé hebdomadaire par e-mail" + "content_digest_help": "Votre semaine puzzle : XP, succès, statistiques et activité de vos amis. Une fois par semaine, c'est le rythme idéal." + "content_digest_none": "Ne pas m'envoyer le résumé" + "content_digest_daily": "Quotidien + hebdomadaire" + "content_digest_weekly": "Hebdomadaire" + "hide_experience_system": "Masquer le système d'expérience (XP et niveaux)" + "hide_experience_system_help": "Votre niveau, vos reçus d'XP, les célébrations et vos entrées dans les classements disparaissent partout. Les XP continuent de s'accumuler en silence : vous ne perdez rien si vous changez d'avis." "newsletter_help": "Nous enverrons occasionnellement une newsletter sur les nouveautés de la plateforme, généralement pour expliquer les nouvelles fonctionnalités. Fréquence prévue : pas plus d'une fois par mois." "messaging_notifications": "Messagerie et notifications" "personal_access_tokens": "Jetons d'accès personnels" @@ -888,11 +896,347 @@ "conversation_request": "general": "%player% vous a envoyé une demande de conversation" "marketplace": "%player% vous a envoyé un message à propos de %puzzle%" +"xp": + "level_chip": "Niv. %level%" + "total": "%xp% XP" + "total_tooltip": "%xp% XP gagnés au total" + "max_level_reached": "Le sommet de la montagne — niveau 50 !" + "progress_label": "%xp% XP avant le niveau %level%" + "receipt": + "title": "Vos XP pour cette résolution" + "total": "Total" + "waiting_teaser": "{1}Cette résolution a compté pour 1 succès en attente 🔒|]1,Inf[Cette résolution a compté pour %count% succès en attente 🔒" + "max_level_title": "Niveau 50 — les XP comptent toujours, la gloire est éternelle" + "nearest_achievements": "Vos prochains succès sont à portée de main :" + "max_level_all_done": "Vous avez conquis tout ce qu'il y avait à conquérir. Nous sommes sincèrement impressionnés." + "relax_repeat": "Un puzzle familier, juste pour le plaisir — pas d'XP cette fois, et c'est justement l'idée." + "counting": "Calcul de vos XP…" + "line": + "base": "Résolution" + "base_repeat_second": "Résolution répétée (2e fois, ×50%)" + "base_repeat_later": "Résolution répétée (3e fois et plus, ×25%)" + "base_relax": "Résolution relax (×50%)" + "solve_difficulty_bonus": "Bonus de difficulté" + "solve_unboxed_bonus": "Bonus sans boîte" + "solve_speed_bonus": "Bonus de vitesse" + "solve_weekly_boost": "Boost hebdomadaire" + "solve_daily_warmup": "Échauffement du jour" + "difficulty_settlement": "Bonus de difficulté (régularisé)" + "speed_settlement": "Bonus de vitesse (régularisé)" + "achievement": "Succès obtenu" + "solve_compensation": "Résolution supprimée" + "difficulty_pending": "Bonus de difficulté en attente — versé dès que ce puzzle sera noté" + "speed_pending": "Bonus de vitesse en attente — il faut les temps de 3 puzzleurs" + "levelup": + "aria": "Niveau supérieur ! Vous avez atteint le niveau %level%" + "label": "Niveau supérieur !" + "max_label": "Le sommet !" + "message": "Votre parcours puzzle continue de grimper. Magnifique !" + "max_message": "Niveau 50. Le plus haut qui existe. Désormais, le classement des points de succès est votre montagne." + "enter_ap_ladder": "Entrer dans le classement AP" + "view_ap_ladder": "Voir le classement AP" + "skip_hint": "Touchez n'importe où pour continuer" + "achievement_toast": + "title": "Nouveau succès !" + "estimate": + "line": "Résoudre ce puzzle rapporte ~%base% XP + jusqu'à %bonus% de bonus" + "repeat_note": "Vous avez déjà résolu celui-ci — cette fois, il compte ×%multiplier%." + "unrated_note": "Le bonus de difficulté sera versé automatiquement dès que ce puzzle sera noté." + "leaderboard": + "meta_title": "Classement XP" + "title": "Classement XP" + "subtitle": "L'activité, célébrée." + "tab": + "this-week": "Cette semaine" + "all-time": "Depuis toujours" + "achievement-points": "Points de succès" + "favorites_only": "Favoris uniquement" + "player": "Puzzleur" + "value": + "this-week": "XP cette semaine" + "all-time": "XP au total" + "achievement-points": "AP" + "xp_value": "%xp% XP" + "ap_value": "%points% AP" + "lv50_ap": "Niv. 50 · %points% AP" + "your_position": "Votre position" + "ap_login_required": "Connectez-vous pour parcourir le classement des points de succès." + "empty": "Rien ici pour l'instant — enregistrez une résolution et inaugurez le tableau !" + "weekly_note": "Les XP de la semaine proviennent des résolutions et des succès ; les régularisations et la reprise d'historique du lancement ne comptent pas ici." + "history": + "meta_title": "Mon historique d'XP" + "title": "Mon historique d'XP" + "subtitle": "Chaque point est comptabilisé — votre registre d'XP complet." + "when": "Quand" + "reason": "Raison" + "empty": "Pas encore d'XP — votre première résolution va changer ça." + "deleted_solve": "résolution supprimée" + "pagination": "Pages de l'historique d'XP" + "note": "Une ligne vous intrigue ?" + "reveal": + "meta_title": "Votre parcours puzzle, révélé" + "title": "Tout votre parcours vient de se transformer en XP ✨" + "intro": "Chaque puzzle que vous avez enregistré compte. Voici ce que vaut votre historique :" + "level_label": "Niveau" + "xp_note": "gagnés sur l'ensemble de votre parcours puzzle" + "achievements_member": "{1}Et 1 succès est déjà à vous — retrouvez-le sur votre profil !|]1,Inf[Et %count% succès sont déjà à vous — retrouvez-les sur votre profil !" + "share_title": "Je suis niveau %level% sur MySpeedPuzzling !" + "share_button": "Partager mon niveau" + "continue": "Voir mon profil" + "to_badge_reveals": "Des succès à révéler ? Retournez-les ici." + "explainer": + "title": "Comment fonctionnent les XP et les niveaux" + "intro": "Chaque puzzle terminé vous fait avancer. Les XP (points d'expérience) célèbrent votre activité — pas votre vitesse, simplement le fait d'être là et de puzzler. Enregistrez une résolution, gagnez des XP et regardez votre niveau grimper de 1 jusqu'à 50." + "currencies": + "heading": "Les trois chiffres de MySpeedPuzzling" + "header_what": "Quoi" + "header_measures": "Mesure" + "header_who": "Qui le voit" + "xp_name": "XP et niveaux" + "xp_measures": "Votre activité — chaque résolution compte" + "xp_who": "Tout le monde, gratuit pour toujours" + "ap_name": "Points de succès (AP)" + "ap_measures": "Votre collection de succès obtenus" + "ap_who": "Membres" + "rating_name": "MSP Rating" + "rating_measures": "Votre vitesse et vos compétences" + "rating_who": "Membres (inchangé, séparé)" + "never_bought": "Les XP ne s'achètent pas — pas de boosts, pas de multiplicateurs, pas de raccourcis. Jamais." + "unlock_nothing": "Les niveaux ne débloquent rien de fonctionnel ; ils sont votre identité visuelle de puzzleur." + "how": + "heading": "Comment une résolution se transforme en XP" + "intro": "Votre reçu après chaque résolution montre exactement d'où vient chaque point :" + "base": "Base : environ 1 XP pour 100 pièces (un puzzle de 500 pièces ≈ 5 XP). Plafonné à 60." + "difficulty": "Bonus de difficulté : les puzzles plus difficiles (niveaux de difficulté communautaires 3–6) ajoutent +15% à +50%." + "team": "Résolutions en équipe : chaque membre du groupe gagne 75% de la valeur solo — puzzler ensemble compte pour chaque paire de mains." + "unboxed": "Bonus sans boîte : résolu sans l'image de la boîte ? +20%." + "repeat": "Résolutions répétées : la 2e résolution chronométrée du même puzzle rapporte 50%, les suivantes 25%. Mode relax : première résolution 50%, répétitions 0%." + "speed": "Bonus de vitesse : les résolutions solo chronométrées plus rapides que la médiane communautaire du puzzle rapportent +5%, top 25% +10%, top 10% +15% (il faut les temps d'au moins 3 puzzleurs)." + "weekly": "Boost hebdomadaire : vos 5 premières résolutions de la semaine rapportent +50% de plus." + "daily": "Échauffement du jour : la première résolution de votre journée apporte +2 XP tout rond." + "example": + "heading": "Exemple concret" + "body": "Une résolution solo, chronométrée et en première tentative d'un puzzle de 1 000 pièces de niveau 4, résolu sans la boîte, plus vite que la médiane, comme première résolution de la semaine et du jour :" + "math": "base 10 + difficulté 3 + sans boîte 3 + vitesse 1 + boost hebdomadaire 8 + échauffement 2 = 27 XP" + "unrated": + "heading": "Puzzles non notés" + "body": "Les puzzles tout juste ajoutés au catalogue n'ont parfois pas encore de niveau de difficulté. Vous gagnez les XP de base immédiatement, et le bonus de difficulté arrive automatiquement (il est « régularisé ») dès que la communauté note le puzzle. Même chose pour le bonus de vitesse dès qu'assez de puzzleurs ont enregistré des temps." + "achievements": + "heading": "Les succès rapportent aussi des XP" + "body": "Chaque palier de succès rapporte des XP une seule fois, pour toujours : Bronze 5 · Argent 10 · Or 25 · Platine 50 · Diamant 100." + "levels": + "heading": "Niveaux" + "header_level": "Niveau" + "header_xp": "XP" + "summit": "Le niveau 50 est le sommet — 3 160 XP au total. Les XP continuent de s'accumuler ensuite ; au niveau 50, le classement des points de succès devient votre prochaine montagne." + "lose": + "heading": "Puis-je perdre des XP ?" + "body": "Uniquement en supprimant ou en modifiant une résolution — les XP qu'elle avait rapportés partent avec elle. Rien d'autre ne retire jamais d'XP. Les succès sont permanents." + "faq": + "heading": "FAQ" + "old_solves_question": "Les anciennes résolutions comptent-elles ?" + "old_solves_answer": "Oui ! Tout votre historique a été converti au lancement (formule de base, sans les bonus liés au temps — ceux-ci ne s'appliquent qu'à partir de maintenant)." + "opt_out_question": "Je ne veux rien de tout ça." + "opt_out_answer": "Compris — désactivez-le dans les paramètres de votre profil (« système d'expérience »), et votre niveau, vos XP et les célébrations disparaissent de l'affichage." + "friend_question": "Pourquoi mon ami a-t-il reçu plus d'XP pour le même puzzle ?" + "friend_answer": "Les réductions pour répétition, les boosts hebdomadaires et les échauffements du jour sont personnels ; deux reçus se ressemblent rarement. Touchez une ligne du reçu pour voir sa raison." + "fair_play_question": "Comment cela reste-t-il équitable ?" + "fair_play_answer": "Quelques garde-fous discrets et automatiques gardent les chiffres honnêtes — découvrez-les sur la page Fair-play et confiance." + "fair_play": + "title": "Fair-play et confiance" + "intro": "MySpeedPuzzling fonctionne sur la confiance. Personne ne vérifie vos résolutions avant qu'elles comptent — nous vous croyons, et les chiffres de la communauté restent fiables grâce à quelques garde-fous discrets et automatiques :" + "principle_no_buying": "Les XP mesurent l'activité, jamais les dépenses : il est impossible d'acheter des XP, des boosts ou des multiplicateurs." + "principle_repeats": "Les résolutions répétées du même puzzle rapportent moins à chaque fois — enregistrer le même puzzle encore et encore n'est pas un raccourci." + "principle_speed": "Le bonus de vitesse ne s'applique que lorsqu'une référence communautaire fiable existe, et les temps invraisemblablement rapides ne le reçoivent tout simplement pas. Pas d'accusation, pas de drame — le bonus ne s'applique pas, c'est tout." + "principle_deleting": "Supprimer une résolution retire les XP qu'elle avait rapportés, automatiquement et en toute transparence (votre page d'historique d'XP montre chaque entrée)." + "principle_caps": "Les entrées extrêmement grandes ou inhabituelles sont plafonnées pour qu'aucune résolution ne puisse fausser le classement." + "closing_thresholds": "Nous ne publions volontairement pas les seuils exacts — ils existent pour que tout reste équitable, pas pour être contournés. Si quelque chose vous semble étrange dans un classement, écrivez-nous ; un humain examine chaque signalement." + "closing_celebrate": "Puzzlez honnêtement, célébrez bruyamment. 🧩" + "explainer_link": "Envie de savoir comment chaque point se gagne ? Consultez Comment fonctionnent les XP et les niveaux." +"content_digest": + "unsubscribe": + "meta_title": "Se désabonner du résumé" + "confirm_title": "Se désabonner du résumé hebdomadaire ?" + "confirm_text": "Sans rancune — un clic ci-dessous et le résumé hebdomadaire s'arrête. Vous pourrez le réactiver à tout moment dans vos paramètres de notification." + "confirm_button": "Oui, désabonnez-moi" + "done_title": "Désabonnement effectué" + "done_text": "Le résumé hebdomadaire ne vous enverra plus d'e-mails. S'il vous manque, il vous attend dans vos paramètres de notification." + "back_home": "Retour à MySpeedPuzzling" "badges": - "title": "Badges" - "my_title": "Mes badges" + "title": "Succès" + "my_title": "Mes succès" + "new": "NOUVEAU" + "browse_all": "Parcourir tous les succès" + "reveal": "Révélez votre nouveau succès" + "waiting_teaser": "{1}1 succès vous attend — devenez membre pour le révéler !|]1,Inf[%count% succès vous attendent — devenez membre pour les révéler !" + "waiting_teaser_zero": "Votre premier succès est plus proche que vous ne le pensez — enregistrez une résolution et voyez ce qui se passe." + "unlock_with_membership": "Débloquer avec l'adhésion" + "ap_total": "%points% AP" + "ap_total_tooltip": "Vos points de succès — chaque palier obtenu compte, pour toujours" + "free_user_hint": "Vous continuez à gagner des succès même sans adhésion — ils vous attendent, bien au chaud." + "earned_waiting": "Obtenu ✓ — en attente 🔒" + "view_holders": "Voir qui l'a obtenu" + "how_it_works_hint": "Les succès rapportent des XP et des points de succès." + "how_it_works_link": "Comment fonctionnent les XP et les niveaux" + "detail": + "meta_title": "%name% — détenteurs du succès" + "country_filter": "Pays" + "all_countries": "Tous les pays" + "newest_earners": "Derniers à l'avoir obtenu" + "holders_count": "{0}Personne pour l'instant|{1}1 puzzleur|]1,Inf[%count% puzzleurs" + "holders_count_tooltip": "Tout le monde compte ici — y compris les puzzleurs dont le profil n'est pas listé ci-dessous" + "first_to_earn": "Premier à l'avoir obtenu" + "more_puzzlers": "+%count% autres puzzleurs" + "only_hidden_holders": "{1}1 puzzleur a obtenu ce palier — son profil n'est pas listé publiquement.|]1,Inf[%count% puzzleurs ont obtenu ce palier — leurs profils ne sont pas listés publiquement." + "nobody_yet": "Personne n'a encore obtenu ce palier. Le premier écrira l'histoire !" + "listing_note": "Les listes de détenteurs montrent les membres au profil public ; les compteurs incluent tous les puzzleurs." + "reveals": + "meta_title": "Révélez vos succès" + "title": "Vos succès sont là 🎉" + "intro": "{1}1 succès vous attendait discrètement. Touchez-le pour le retourner !|]1,Inf[%count% succès vous attendaient discrètement. Touchez-les un par un pour les retourner !" + "hint": "Prenez votre temps — chaque carte retournée mérite son petit moment." + "all_done": "Tout est révélé — votre profil les porte fièrement désormais." + "invite": "{1}1 succès attend son moment de révélation !|]1,Inf[%count% succès attendent leur moment de révélation !" + "invite_button": "Les révéler" + "back_to_profile": "Retour à mon profil" + "overview_title": "Succès" + "overview_subtitle": "Gagnez des succès en franchissant des étapes dans votre parcours puzzle" + "progress_label": "%current% / %target%" + "progress_time_label": "%current_time% → objectif %target_time%" + "locked": "Verrouillé" + "earned_on": "Obtenu le %date%" + "highest_earned": "Plus haut palier obtenu" + "not_earned_yet": "Pas encore obtenu" + "keep_going": "Continuez comme ça !" + "login_to_track": "Connectez-vous pour voir votre progression vers chaque succès." + "tier": + "bronze": "Bronze" + "silver": "Argent" + "gold": "Or" + "platinum": "Platine" + "diamond": "Diamant" "badge": - "supporter": "Supporteur" + "supporter": "Pionnier" + "puzzles_solved": "Explorateur de puzzles" + "pieces_solved": "Croqueur de pièces" + "speed_500_pieces": "Démon de vitesse (500 pcs)" + "streak": "En feu" + "team_player": "Esprit d'équipe" + "zen_puzzler": "Puzzleur zen" + "first_try": "Du premier coup" + "unboxed": "Sans boîte" + "brand_explorer": "Explorateur de marques" + "marathoner": "Marathonien" + "photographer": "Photographe" + "steady_hands": "Mains sûres" + "librarian": "Bibliothécaire" + "speed_1000_pieces": "Démon de vitesse (1000 pcs)" + "weekend_puzzler": "Puzzleur du week-end" + "cataloger": "Catalogueur" + "description": + "supporter": "Décerné aux puzzleurs qui ont cru en MySpeedPuzzling dès le début et l'ont aidé à grandir." + "puzzles_solved": "Résolvez des puzzles différents pour gravir cette échelle." + "pieces_solved": "Plus il y a de pièces, mieux c'est — chaque pièce jamais posée compte." + "speed_500_pieces": "Votre résolution solo la plus rapide d'un 500 pièces détermine votre palier." + "streak": "Résolvez des puzzles jour après jour, sans en sauter un." + "team_player": "Faites équipe avec d'autres puzzleurs et venez à bout des puzzles ensemble." + "zen_puzzler": "Puzzlez pour le pur plaisir — les résolutions relax enregistrées sans temps comptent ici." + "first_try": "Réussissez du premier coup — chaque résolution enregistrée comme première tentative d'un puzzle compte." + "unboxed": "Résolvez des puzzles sans regarder l'image de la boîte — le défi mystère ultime." + "brand_explorer": "Goûtez aux puzzles de différents fabricants et découvrez vos favoris." + "marathoner": "Venez à bout des géants — chaque résolution d'un puzzle de 2 000 pièces ou plus compte." + "photographer": "Prenez une photo de votre puzzle terminé en enregistrant une résolution — chaque image compte." + "steady_hands": "Continuez à puzzler pendant des trimestres consécutifs avec au moins une résolution dans chacun — sans interruption." + "librarian": "Aidez à garder le catalogue de puzzles exact — seules vos propositions d'amélioration acceptées comptent." + "speed_1000_pieces": "Votre résolution solo la plus rapide d'un 1 000 pièces détermine votre palier." + "weekend_puzzler": "Les samedis et dimanches sont faits pour puzzler — chaque résolution du week-end compte." + "cataloger": "Faites grandir le catalogue de puzzles — chaque puzzle approuvé que vous ajoutez compte." + "requirement": + "puzzles_solved_1": "Résolvez 10 puzzles différents" + "puzzles_solved_2": "Résolvez 100 puzzles différents" + "puzzles_solved_3": "Résolvez 500 puzzles différents" + "puzzles_solved_4": "Résolvez 1 000 puzzles différents" + "puzzles_solved_5": "Résolvez 2 000 puzzles différents" + "pieces_solved_1": "Accumulez 10 000 pièces" + "pieces_solved_2": "Accumulez 100 000 pièces" + "pieces_solved_3": "Accumulez 500 000 pièces" + "pieces_solved_4": "Accumulez 1 000 000 de pièces" + "pieces_solved_5": "Accumulez 2 000 000 de pièces" + "speed_500_pieces_1": "Résolvez un puzzle de 500 pièces en moins de 5 heures (solo)" + "speed_500_pieces_2": "Résolvez un puzzle de 500 pièces en moins de 2 heures (solo)" + "speed_500_pieces_3": "Résolvez un puzzle de 500 pièces en moins d'1 heure (solo)" + "speed_500_pieces_4": "Résolvez un puzzle de 500 pièces en moins de 45 minutes (solo)" + "speed_500_pieces_5": "Résolvez un puzzle de 500 pièces en moins de 30 minutes (solo)" + "streak_1": "Résolvez des puzzles 7 jours d'affilée" + "streak_2": "Résolvez des puzzles 30 jours d'affilée" + "streak_3": "Résolvez des puzzles 90 jours d'affilée" + "streak_4": "Résolvez des puzzles 180 jours d'affilée" + "streak_5": "Résolvez des puzzles 365 jours d'affilée" + "team_player_1": "Terminez votre premier puzzle en équipe ou en duo" + "team_player_2": "Terminez 5 puzzles en équipe ou en duo" + "team_player_3": "Terminez 25 puzzles en équipe ou en duo" + "team_player_4": "Terminez 100 puzzles en équipe ou en duo" + "team_player_5": "Terminez 500 puzzles en équipe ou en duo" + "zen_puzzler_1": "Enregistrez votre première résolution relax sans temps" + "zen_puzzler_2": "Enregistrez 10 résolutions relax sans temps" + "zen_puzzler_3": "Enregistrez 50 résolutions relax sans temps" + "zen_puzzler_4": "Enregistrez 150 résolutions relax sans temps" + "zen_puzzler_5": "Enregistrez 365 résolutions relax sans temps" + "first_try_1": "Enregistrez 5 résolutions en première tentative" + "first_try_2": "Enregistrez 50 résolutions en première tentative" + "first_try_3": "Enregistrez 200 résolutions en première tentative" + "first_try_4": "Enregistrez 500 résolutions en première tentative" + "first_try_5": "Enregistrez 1 000 résolutions en première tentative" + "unboxed_1": "Résolvez votre premier puzzle sans regarder la boîte" + "unboxed_2": "Résolvez 5 puzzles sans regarder la boîte" + "unboxed_3": "Résolvez 25 puzzles sans regarder la boîte" + "unboxed_4": "Résolvez 50 puzzles sans regarder la boîte" + "unboxed_5": "Résolvez 100 puzzles sans regarder la boîte" + "brand_explorer_1": "Résolvez des puzzles de 3 fabricants différents" + "brand_explorer_2": "Résolvez des puzzles de 10 fabricants différents" + "brand_explorer_3": "Résolvez des puzzles de 25 fabricants différents" + "brand_explorer_4": "Résolvez des puzzles de 50 fabricants différents" + "brand_explorer_5": "Résolvez des puzzles de 100 fabricants différents" + "marathoner_1": "Terminez votre premier puzzle de 2 000 pièces ou plus" + "marathoner_2": "Terminez 5 puzzles de 2 000 pièces ou plus" + "marathoner_3": "Terminez 15 puzzles de 2 000 pièces ou plus" + "marathoner_4": "Terminez 40 puzzles de 2 000 pièces ou plus" + "marathoner_5": "Terminez 100 puzzles de 2 000 pièces ou plus" + "photographer_1": "Ajoutez une photo à votre premier puzzle terminé" + "photographer_2": "Ajoutez des photos à 25 puzzles terminés" + "photographer_3": "Ajoutez des photos à 100 puzzles terminés" + "photographer_4": "Ajoutez des photos à 500 puzzles terminés" + "photographer_5": "Ajoutez des photos à 1 000 puzzles terminés" + "steady_hands_1": "Résolvez au moins un puzzle sur 2 trimestres de l'année d'affilée" + "steady_hands_2": "Résolvez au moins un puzzle sur 4 trimestres de l'année d'affilée" + "steady_hands_3": "Résolvez au moins un puzzle sur 8 trimestres de l'année d'affilée" + "steady_hands_4": "Résolvez au moins un puzzle sur 12 trimestres de l'année d'affilée" + "steady_hands_5": "Résolvez au moins un puzzle sur 16 trimestres de l'année d'affilée" + "librarian_1": "Faites accepter votre première proposition d'amélioration du catalogue" + "librarian_2": "Faites accepter 5 propositions d'amélioration du catalogue" + "librarian_3": "Faites accepter 20 propositions d'amélioration du catalogue" + "librarian_4": "Faites accepter 50 propositions d'amélioration du catalogue" + "librarian_5": "Faites accepter 100 propositions d'amélioration du catalogue" + "speed_1000_pieces_1": "Résolvez un puzzle de 1 000 pièces en moins de 8 heures (solo)" + "speed_1000_pieces_2": "Résolvez un puzzle de 1 000 pièces en moins de 4 heures (solo)" + "speed_1000_pieces_3": "Résolvez un puzzle de 1 000 pièces en moins de 2 h 30 (solo)" + "speed_1000_pieces_4": "Résolvez un puzzle de 1 000 pièces en moins d'1 h 45 (solo)" + "speed_1000_pieces_5": "Résolvez un puzzle de 1 000 pièces en moins d'1 h 15 (solo)" + "weekend_puzzler_1": "Terminez 10 résolutions le week-end" + "weekend_puzzler_2": "Terminez 50 résolutions le week-end" + "weekend_puzzler_3": "Terminez 150 résolutions le week-end" + "weekend_puzzler_4": "Terminez 300 résolutions le week-end" + "weekend_puzzler_5": "Terminez 600 résolutions le week-end" + "cataloger_1": "Ajoutez votre premier puzzle approuvé au catalogue" + "cataloger_2": "Ajoutez 10 puzzles approuvés au catalogue" + "cataloger_3": "Ajoutez 50 puzzles approuvés au catalogue" + "cataloger_4": "Ajoutez 150 puzzles approuvés au catalogue" + "cataloger_5": "Ajoutez 300 puzzles approuvés au catalogue" + "unit": + "puzzles": "puzzles" + "pieces": "pièces" + "days": "jours" + "team_solves": "résolutions en équipe" "scan": "meta": "title": "Lecteur de code-barres" @@ -2739,6 +3083,39 @@ edition: "most_solved": "Puzzles %pieces% pièces les plus assemblés" "view_all": "Voir tous les puzzles %pieces% pièces" +"guides": + "breadcrumb": + "home": "Accueil" + "guides": "Guides" + "ui": + "eyebrow": "Guide" + "updated": "Mis à jour le %date%" + "data_note": "Données communautaires en direct" + "read_next": "À lire ensuite" + "cta_title": "Prêt à chronométrer votre prochain puzzle ?" + "cta_stopwatch": "Lancer le chronomètre" + "cta_puzzles": "Parcourir la base de puzzles" + "cta_events": "Trouver une compétition" + "index": + "title": "Guides du speed puzzling" + "meta_description": "Guides du speed puzzling appuyés sur de vrais temps d'assemblage : combien de temps prend vraiment un puzzle, comment fonctionnent les compétitions et comment devenir plus rapide." + "intro": "Écrits par la communauté du speed puzzling, appuyés sur de vrais temps d'assemblage enregistrés — pas des suppositions." + "read_guide": "Lire le guide" + "footnote": "Toutes les statistiques de ces guides sont calculées à partir des résolutions enregistrées sur MySpeedPuzzling et se mettent à jour automatiquement à mesure que la communauté enregistre de nouveaux temps." + "what_is": + "title": "Qu'est-ce que le speed puzzling ?" + "meta_description": "Le speed puzzling, c'est l'assemblage de puzzles contre la montre. Découvrez comment fonctionnent les compétitions comme le WJPC, les temps typiques et comment vous lancer." + "card_description": "L'introduction complète : formats, fonctionnement des compétitions, temps typiques et comment rejoindre la communauté." + "how_long": + "title": "Combien de temps pour un puzzle de 1 000 pièces ?" + "meta_description": "Mesuré sur %count% résolutions solo enregistrées : un puzzle de 1 000 pièces médian se termine en %median%. Percentiles réels et meilleurs temps, par nombre de pièces." + "meta_description_fallback": "La seule réponse en ligne appuyée sur des données : temps médians, percentiles et records enregistrés par nombre de pièces, issus de vraies résolutions suivies." + "card_description": "De vraies données, pas des suppositions : temps médians, percentiles et records enregistrés par nombre de pièces." + "tips": + "title": "Conseils de speed puzzling : comment devenir plus rapide" + "meta_description": "Dix conseils pratiques de speed puzzling utilisés par les puzzleurs compétitifs — tri, bords, blocs de couleurs, installation et comment mesurer de vrais progrès." + "card_description": "Dix techniques qui réduisent vraiment votre temps d'assemblage, plus des repères communautaires pour vous situer." + "wjpc_hub": "meta": "title": "Championnat du monde de puzzle (WJPC)" diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index e38d45c63..b7dd32e58 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -751,6 +751,7 @@ "delete_button": "削除 — なかったことに!" "delete_modal_message": "確認のため..." "delete_modal_confirm": "本当に削除しますか?" + "delete_xp_warning": "この記録で獲得した%xp% XPが失われます。" "delete_modal_discard_button": "いいえ、残します" "delete_modal_confirm_button": "はい、削除します" "error": @@ -802,6 +803,13 @@ "frequency_48_hours": "48時間ごと" "frequency_1_week": "週に1回" "newsletter_enabled": "ニュースレターを受信する" + "content_digest_frequency": "週間ダイジェストメール" + "content_digest_help": "あなたの一週間のパズル活動:XP、実績、統計、仲間のアクティビティ。週1回がちょうどいい頻度です。" + "content_digest_none": "ダイジェストを受け取らない" + "content_digest_daily": "毎日+毎週" + "content_digest_weekly": "毎週" + "hide_experience_system": "経験値システム(XPとレベル)を非表示" + "hide_experience_system_help": "あなたのレベル、XPの明細、お祝い演出、リーダーボードへの表示がすべて非表示になります。XPは裏側で静かに貯まり続けるので、再びオンにしても何も失われません。" "newsletter_help": "プラットフォームの新機能などについてのニュースレターを不定期にお送りします。想定頻度:月1回以下。" "messaging_notifications": "メッセージと通知" "personal_access_tokens": "個人アクセストークン" @@ -931,11 +939,349 @@ 10: "10月" 11: "11月" 12: "12月" -"badges": - "title": "バッジ" - "my_title": "マイバッジ" - "badge": - "supporter": "サポーター" +xp: + level_chip: "Lv %level%" + total: "%xp% XP" + total_tooltip: "合計%xp% XPを獲得" + max_level_reached: "山の頂上に到達 — レベル50!" + progress_label: "レベル%level%まであと%xp% XP" + receipt: + title: "今回の完成で獲得したXP" + total: "合計" + waiting_teaser: "{1}この完成は、お披露目待ちの実績1個にカウントされました 🔒|]1,Inf[この完成は、お披露目待ちの実績%count%個にカウントされました 🔒" + max_level_title: "レベル50 — XPはこれからも貯まり、栄光は永遠に" + nearest_achievements: "次の実績はもう目前です:" + max_level_all_done: "制覇できるものはすべて制覇しました。正直、脱帽です。" + relax_repeat: "おなじみのパズルを、純粋に楽しむために — 今回はXPなし。それがいいんです。" + counting: "XPを集計中…" + line: + base: "完成" + base_repeat_second: "リピート完成(2回目、×50%)" + base_repeat_later: "リピート完成(3回目以降、×25%)" + base_relax: "リラックス完成(×50%)" + solve_difficulty_bonus: "難易度ボーナス" + solve_unboxed_bonus: "箱なしボーナス" + solve_speed_bonus: "スピードボーナス" + solve_weekly_boost: "ウィークリーブースト" + solve_daily_warmup: "デイリーウォームアップ" + difficulty_settlement: "難易度ボーナス(確定)" + speed_settlement: "スピードボーナス(確定)" + achievement: "実績獲得" + solve_compensation: "記録の削除" + difficulty_pending: "難易度ボーナスは保留中 — このパズルが評価されると確定します" + speed_pending: "スピードボーナスは保留中 — 3人のパズラーのタイムが必要です" + levelup: + aria: "レベルアップ!レベル%level%に到達しました" + label: "レベルアップ!" + max_label: "頂上です!" + message: "あなたのパズルの旅は登り続けています。お見事です!" + max_message: "レベル50。ここが最高峰です。ここから先は、実績ポイントのラダーがあなたの山になります。" + enter_ap_ladder: "APラダーへ進む" + view_ap_ladder: "APラダーを見る" + skip_hint: "どこでもタップして続行" + achievement_toast: + title: "新しい実績!" + estimate: + line: "このパズルを完成させると約%base% XP+最大%bonus%のボーナスを獲得" + repeat_note: "このパズルは以前にも完成しています — 今回は×%multiplier%でカウントされます。" + unrated_note: "難易度ボーナスは、このパズルが評価されると自動的に確定します。" + leaderboard: + meta_title: "XPリーダーボード" + title: "XPリーダーボード" + subtitle: "活動に、拍手を。" + tab: + this-week: "今週" + all-time: "全期間" + achievement-points: "実績ポイント" + favorites_only: "お気に入りのみ" + player: "パズラー" + value: + this-week: "今週のXP" + all-time: "合計XP" + achievement-points: "AP" + xp_value: "%xp% XP" + ap_value: "%points% AP" + lv50_ap: "Lv 50 · %points% AP" + your_position: "あなたの順位" + ap_login_required: "実績ポイントのラダーを見るにはログインしてください。" + empty: "まだ何もありません — 記録を追加して、ボードの幕を開けましょう!" + weekly_note: "今週の完成と実績によるXPです。確定分やリリース時の一括付与はここには含まれません。" + history: + meta_title: "マイXP履歴" + title: "マイXP履歴" + subtitle: "1ポイントまで、すべて記帳済み — あなたの完全なXP台帳です。" + when: "日時" + reason: "理由" + empty: "まだXPがありません — 最初の完成がそれを変えます。" + deleted_solve: "削除された記録" + pagination: "XP履歴のページ" + note: "気になる行がありますか?" + reveal: + meta_title: "あなたのパズルの歩み、お披露目" + title: "あなたの歩みが、まるごとXPになりました ✨" + intro: "これまでに記録したすべてのパズルがカウントされています。あなたの歩みの価値はこちら:" + level_label: "レベル" + xp_note: "これまでのパズル履歴すべてで獲得" + achievements_member: "{1}さらに、実績1個はもうあなたのもの — プロフィールで見つけてください!|]1,Inf[さらに、実績%count%個はもうあなたのもの — プロフィールで見つけてください!" + share_title: "MySpeedPuzzlingでレベル%level%になりました!" + share_button: "レベルをシェア" + continue: "マイプロフィールへ" + to_badge_reveals: "お披露目待ちの実績がありますか?ここでめくりましょう。" + explainer: + title: "XPとレベルの仕組み" + intro: "パズルをひとつ完成させるたびに、あなたは前へ進みます。XP(経験値)は、速さではなく、あなたが取り組んだことそのものをたたえるもの。記録を追加してXPを獲得し、レベルが1から50まで育っていくのを見守りましょう。" + currencies: + heading: "MySpeedPuzzlingの3つの数字" + header_what: "項目" + header_measures: "測るもの" + header_who: "表示対象" + xp_name: "XPとレベル" + xp_measures: "あなたの活動 — すべての完成がカウント" + xp_who: "全員、ずっと無料" + ap_name: "実績ポイント(AP)" + ap_measures: "獲得した実績のコレクション" + ap_who: "メンバー" + rating_name: "MSP Rating" + rating_measures: "あなたの速さとスキル" + rating_who: "メンバー(従来どおり、別枠)" + never_bought: "XPは決してお金では買えません — ブーストも、倍率も、近道もなし。ずっとです。" + unlock_nothing: "レベルで機能が解放されることはありません。レベルは、あなたのパズラーとしての個性を映すものです。" + how: + heading: "完成がXPになる仕組み" + intro: "完成のたびに表示される明細で、1ポイントごとの内訳を確認できます:" + base: "基本:およそ100ピースにつき1 XP(500ピースのパズル ≈ 5 XP)。上限は60です。" + difficulty: "難易度ボーナス:難しいパズル(コミュニティ難易度ティア3〜6)には+15%〜+50%が加算されます。" + team: "チーム完成:グループ全員がソロの75%を獲得 — 一緒に組み立てた頑張りが、全員分カウントされます。" + unboxed: "箱なしボーナス:箱の絵を見ずに完成したら? +20%です。" + repeat: "リピート完成:同じパズルの2回目のタイム付き完成は50%、それ以降は25%。リラックスモードは初回50%、リピートは0%です。" + speed: "スピードボーナス:コミュニティ中央値より速いソロのタイム付き完成には+5%、上位25%なら+10%、上位10%なら+15%(3人以上のパズラーのタイムが必要です)。" + weekly: "ウィークリーブースト:毎週最初の5回の完成には+50%が上乗せされます。" + daily: "デイリーウォームアップ:その日最初の完成には一律+2 XP。" + example: + heading: "計算例" + body: "1000ピース・ティア4のパズルを、ソロ・タイム計測あり・初挑戦で、箱を見ずに、中央値より速く、その週とその日の最初の完成として仕上げた場合:" + math: "基本10 + 難易度3 + 箱なし3 + スピード1 + ウィークリーブースト8 + ウォームアップ2 = 27 XP" + unrated: + heading: "未評価のパズル" + body: "カタログに登場したばかりのパズルには、まだ難易度ティアがないことがあります。基本XPはすぐに獲得でき、コミュニティがパズルを評価すると難易度ボーナスが自動的に加算(「確定」)されます。スピードボーナスも、十分な人数のタイムが集まれば同じように確定します。" + achievements: + heading: "実績でもXPがもらえます" + body: "実績の各ティアは一度きり、永久に有効なXPを付与します:ブロンズ5 · シルバー10 · ゴールド25 · プラチナ50 · ダイヤモンド100。" + levels: + heading: "レベル" + header_level: "レベル" + header_xp: "XP" + summit: "レベル50が頂上です — 合計3,160 XP。その後もXPは貯まり続け、レベル50からは実績ポイントのラダーが次の山になります。" + lose: + heading: "XPが減ることはありますか?" + body: "記録を削除または編集したときだけです — その記録で獲得したXPも一緒になくなります。それ以外でXPが減ることはありません。実績は永久にあなたのものです。" + faq: + heading: "よくある質問" + old_solves_question: "過去の記録もカウントされますか?" + old_solves_answer: "はい!リリース時に、これまでの履歴すべてが変換されました(基本の計算式のみで、時間系のボーナスは対象外 — これらは今後の記録にのみ適用されます)。" + opt_out_question: "こういうのは要りません。" + opt_out_answer: "わかりました — プロフィール設定(「経験値システム」)でオフにすると、レベル、XP、お祝い演出が表示されなくなります。" + friend_question: "同じパズルなのに、友達の方が多くXPをもらえたのはなぜですか?" + friend_answer: "リピートの減額、ウィークリーブースト、デイリーウォームアップは人それぞれ。2枚の明細がぴったり一致することはめったにありません。明細の行をタップすると、その理由が表示されます。" + fair_play_question: "公平性はどう保たれていますか?" + fair_play_answer: "静かに自動で働くいくつかのガードレールが、数字の正直さを守っています — 詳しくはフェアプレイと信頼のページをご覧ください。" + fair_play: + title: "フェアプレイと信頼" + intro: "MySpeedPuzzlingは信頼の上に成り立っています。記録が反映される前に誰かが審査することはありません — 私たちはあなたを信じています。それでもコミュニティの数字が意味を持ち続けるのは、静かに自動で働くいくつかのガードレールのおかげです:" + principle_no_buying: "XPが測るのは活動であって、支出ではありません。XPやブースト、倍率をお金で買う方法は存在しません。" + principle_repeats: "同じパズルのリピート完成は、回数を重ねるごとに獲得XPが減ります — 同じパズルを何度も記録しても近道にはなりません。" + principle_speed: "スピードボーナスは、信頼できるコミュニティの基準がある場合にのみ適用され、現実的とは思えないほど速いタイムには単純に付きません。非難もドラマもなし — ボーナスが適用されないだけです。" + principle_deleting: "記録を削除すると、その記録で獲得したXPも自動的かつ透明に取り除かれます(XP履歴ページですべてのエントリを確認できます)。" + principle_caps: "極端に大きい、または通常と異なるエントリには上限が設けられ、1件の記録がラダーを歪めることはありません。" + closing_thresholds: "正確なしきい値はあえて公開していません — 公平さを守るためのものであって、攻略されるためのものではないからです。リーダーボードで何かおかしいと感じたら、ぜひご連絡ください。すべての報告に人間が目を通します。" + closing_celebrate: "正直にパズルを、盛大にお祝いを。🧩" + explainer_link: "1ポイントごとの獲得方法が気になりますか?XPとレベルの仕組みをご覧ください。" + +content_digest: + unsubscribe: + meta_title: "ダイジェストの配信停止" + confirm_title: "週間ダイジェストの配信を停止しますか?" + confirm_text: "お気になさらず — 下をワンクリックするだけで週間ダイジェストは止まります。通知設定からいつでも再開できます。" + confirm_button: "はい、配信を停止します" + done_title: "配信を停止しました" + done_text: "今後、週間ダイジェストのメールは届きません。恋しくなったら、通知設定でいつでもお待ちしています。" + back_home: "MySpeedPuzzlingに戻る" + +badges: + title: "実績" + my_title: "マイ実績" + new: "NEW" + browse_all: "すべての実績を見る" + reveal: "新しい実績をお披露目する" + waiting_teaser: "{1}実績1個があなたを待っています — メンバーになってお披露目しましょう!|]1,Inf[実績%count%個があなたを待っています — メンバーになってお披露目しましょう!" + waiting_teaser_zero: "最初の実績は思っているより近くにあります — 記録を追加して、何が起こるか見てみましょう。" + unlock_with_membership: "メンバーシップで解除" + ap_total: "%points% AP" + ap_total_tooltip: "あなたの実績ポイント — 獲得したティアはすべて、ずっとカウントされます" + free_user_hint: "メンバーシップがなくても実績は貯まり続けます — 大切に保管されて、あなたを待っています。" + earned_waiting: "獲得済み ✓ — お披露目待ち 🔒" + view_holders: "獲得したパズラーを見る" + how_it_works_hint: "実績を獲得するとXPと実績ポイントがもらえます。" + how_it_works_link: "XPとレベルの仕組み" + detail: + meta_title: "%name% — 実績保持者" + country_filter: "国" + all_countries: "すべての国" + newest_earners: "最近の獲得者" + holders_count: "{0}まだいません|{1}1人のパズラー|]1,Inf[%count%人のパズラー" + holders_count_tooltip: "この数には全員が含まれます — 下に掲載されていないパズラーも含みます" + first_to_earn: "最初の獲得者" + more_puzzlers: "+%count%人のパズラー" + only_hidden_holders: "{1}1人のパズラーがこのティアを獲得しています — プロフィールは公開されていません。|]1,Inf[%count%人のパズラーがこのティアを獲得しています — プロフィールは公開されていません。" + nobody_yet: "このティアの獲得者はまだいません。最初の一人が歴史を作ります!" + listing_note: "保持者リストにはプロフィールを公開しているメンバーが表示されます。人数にはすべてのパズラーが含まれます。" + reveals: + meta_title: "実績をお披露目" + title: "あなたの実績が届きました 🎉" + intro: "{1}実績1個がひっそりとあなたを待っていました。タップしてめくってみましょう!|]1,Inf[実績%count%個がひっそりとあなたを待っていました。ひとつずつタップしてめくってみましょう!" + hint: "ゆっくりどうぞ — 一枚一枚のめくりが、それぞれの小さな見せ場です。" + all_done: "すべてお披露目が終わりました — あなたのプロフィールが誇らしげに飾っています。" + invite: "{1}実績1個がお披露目の瞬間を待っています!|]1,Inf[実績%count%個がお披露目の瞬間を待っています!" + invite_button: "お披露目する" + back_to_profile: "マイプロフィールに戻る" + overview_title: "実績" + overview_subtitle: "パズルの歩みの中でマイルストーンを達成して、実績を獲得しましょう" + progress_label: "%current% / %target%" + progress_time_label: "%current_time% → 目標 %target_time%" + locked: "ロック中" + earned_on: "%date%に獲得" + highest_earned: "最高獲得ティア" + not_earned_yet: "未獲得" + keep_going: "その調子!" + login_to_track: "ログインすると、各実績への進捗を確認できます。" + tier: + bronze: "ブロンズ" + silver: "シルバー" + gold: "ゴールド" + platinum: "プラチナ" + diamond: "ダイヤモンド" + badge: + supporter: "アーリーアダプター" + puzzles_solved: "パズルエクスプローラー" + pieces_solved: "ピースクランチャー" + speed_500_pieces: "スピードデーモン(500ピース)" + streak: "絶好調" + team_player: "チームスピリット" + zen_puzzler: "禅パズラー" + first_try: "ファーストトライ" + unboxed: "箱いらず" + brand_explorer: "ブランドエクスプローラー" + marathoner: "マラソンランナー" + photographer: "フォトグラファー" + steady_hands: "ゆるぎない手" + librarian: "司書" + speed_1000_pieces: "スピードデーモン(1000ピース)" + weekend_puzzler: "週末パズラー" + cataloger: "カタログ職人" + description: + supporter: "早くからMySpeedPuzzlingを信じ、成長を支えてくれたパズラーに贈られます。" + puzzles_solved: "いろいろなパズルを完成させて、このラダーを登りましょう。" + pieces_solved: "ピースは多いほど良し — これまでにはめたすべてのピースがカウントされます。" + speed_500_pieces: "500ピースのソロ最速タイムでティアが決まります。" + streak: "一日も欠かさず、毎日パズルを完成させましょう。" + team_player: "他のパズラーとチームを組んで、一緒にパズルを仕上げましょう。" + zen_puzzler: "純粋に楽しむためのパズル — タイムなしで記録したリラックスモードの完成がカウントされます。" + first_try: "一度目で決めましょう — そのパズルへの初挑戦として記録された完成がすべてカウントされます。" + unboxed: "箱の絵をのぞかずにパズルを完成させる — 究極のミステリーチャレンジです。" + brand_explorer: "さまざまなメーカーのパズルを試して、お気に入りを見つけましょう。" + marathoner: "大物に挑みましょう — 2,000ピース以上のパズルの完成がすべてカウントされます。" + photographer: "記録を追加するときに完成したパズルの写真を撮りましょう — 一枚一枚がカウントされます。" + steady_hands: "四半期ごとに少なくとも1回の完成を、途切れさせずに続けましょう。" + librarian: "パズルカタログを正確に保つお手伝い — 承認されたカタログ改善提案のみがカウントされます。" + speed_1000_pieces: "1,000ピースのソロ最速タイムでティアが決まります。" + weekend_puzzler: "土曜日と日曜日はパズルの日 — 週末の完成がすべてカウントされます。" + cataloger: "パズルカタログを育てましょう — あなたが追加して承認されたパズルがすべてカウントされます。" + requirement: + puzzles_solved_1: "10種類のパズルを完成させる" + puzzles_solved_2: "100種類のパズルを完成させる" + puzzles_solved_3: "500種類のパズルを完成させる" + puzzles_solved_4: "1,000種類のパズルを完成させる" + puzzles_solved_5: "2,000種類のパズルを完成させる" + pieces_solved_1: "累計10,000ピースに到達する" + pieces_solved_2: "累計100,000ピースに到達する" + pieces_solved_3: "累計500,000ピースに到達する" + pieces_solved_4: "累計1,000,000ピースに到達する" + pieces_solved_5: "累計2,000,000ピースに到達する" + speed_500_pieces_1: "500ピースのパズルを5時間切りで完成させる(ソロ)" + speed_500_pieces_2: "500ピースのパズルを2時間切りで完成させる(ソロ)" + speed_500_pieces_3: "500ピースのパズルを1時間切りで完成させる(ソロ)" + speed_500_pieces_4: "500ピースのパズルを45分切りで完成させる(ソロ)" + speed_500_pieces_5: "500ピースのパズルを30分切りで完成させる(ソロ)" + streak_1: "7日連続でパズルを完成させる" + streak_2: "30日連続でパズルを完成させる" + streak_3: "90日連続でパズルを完成させる" + streak_4: "180日連続でパズルを完成させる" + streak_5: "365日連続でパズルを完成させる" + team_player_1: "チームまたはペアで初めてパズルを完成させる" + team_player_2: "チームまたはペアでパズルを5回完成させる" + team_player_3: "チームまたはペアでパズルを25回完成させる" + team_player_4: "チームまたはペアでパズルを100回完成させる" + team_player_5: "チームまたはペアでパズルを500回完成させる" + zen_puzzler_1: "タイムなしのリラックス完成を初めて記録する" + zen_puzzler_2: "タイムなしのリラックス完成を10回記録する" + zen_puzzler_3: "タイムなしのリラックス完成を50回記録する" + zen_puzzler_4: "タイムなしのリラックス完成を150回記録する" + zen_puzzler_5: "タイムなしのリラックス完成を365回記録する" + first_try_1: "初挑戦での完成を5回記録する" + first_try_2: "初挑戦での完成を50回記録する" + first_try_3: "初挑戦での完成を200回記録する" + first_try_4: "初挑戦での完成を500回記録する" + first_try_5: "初挑戦での完成を1,000回記録する" + unboxed_1: "箱を見ずに初めてパズルを完成させる" + unboxed_2: "箱を見ずにパズルを5回完成させる" + unboxed_3: "箱を見ずにパズルを25回完成させる" + unboxed_4: "箱を見ずにパズルを50回完成させる" + unboxed_5: "箱を見ずにパズルを100回完成させる" + brand_explorer_1: "3社の異なるメーカーのパズルを完成させる" + brand_explorer_2: "10社の異なるメーカーのパズルを完成させる" + brand_explorer_3: "25社の異なるメーカーのパズルを完成させる" + brand_explorer_4: "50社の異なるメーカーのパズルを完成させる" + brand_explorer_5: "100社の異なるメーカーのパズルを完成させる" + marathoner_1: "2,000ピース以上のパズルを初めて完成させる" + marathoner_2: "2,000ピース以上のパズルを5回完成させる" + marathoner_3: "2,000ピース以上のパズルを15回完成させる" + marathoner_4: "2,000ピース以上のパズルを40回完成させる" + marathoner_5: "2,000ピース以上のパズルを100回完成させる" + photographer_1: "完成したパズルに初めて写真を追加する" + photographer_2: "完成したパズル25件に写真を追加する" + photographer_3: "完成したパズル100件に写真を追加する" + photographer_4: "完成したパズル500件に写真を追加する" + photographer_5: "完成したパズル1,000件に写真を追加する" + steady_hands_1: "2四半期連続で、各四半期に少なくとも1つのパズルを完成させる" + steady_hands_2: "4四半期連続で、各四半期に少なくとも1つのパズルを完成させる" + steady_hands_3: "8四半期連続で、各四半期に少なくとも1つのパズルを完成させる" + steady_hands_4: "12四半期連続で、各四半期に少なくとも1つのパズルを完成させる" + steady_hands_5: "16四半期連続で、各四半期に少なくとも1つのパズルを完成させる" + librarian_1: "カタログ改善提案が初めて承認される" + librarian_2: "カタログ改善提案が5件承認される" + librarian_3: "カタログ改善提案が20件承認される" + librarian_4: "カタログ改善提案が50件承認される" + librarian_5: "カタログ改善提案が100件承認される" + speed_1000_pieces_1: "1,000ピースのパズルを8時間切りで完成させる(ソロ)" + speed_1000_pieces_2: "1,000ピースのパズルを4時間切りで完成させる(ソロ)" + speed_1000_pieces_3: "1,000ピースのパズルを2時間30分切りで完成させる(ソロ)" + speed_1000_pieces_4: "1,000ピースのパズルを1時間45分切りで完成させる(ソロ)" + speed_1000_pieces_5: "1,000ピースのパズルを1時間15分切りで完成させる(ソロ)" + weekend_puzzler_1: "週末に10回完成させる" + weekend_puzzler_2: "週末に50回完成させる" + weekend_puzzler_3: "週末に150回完成させる" + weekend_puzzler_4: "週末に300回完成させる" + weekend_puzzler_5: "週末に600回完成させる" + cataloger_1: "カタログに追加したパズルが初めて承認される" + cataloger_2: "カタログに追加したパズルが10件承認される" + cataloger_3: "カタログに追加したパズルが50件承認される" + cataloger_4: "カタログに追加したパズルが150件承認される" + cataloger_5: "カタログに追加したパズルが300件承認される" + unit: + puzzles: "パズル" + pieces: "ピース" + days: "日" + team_solves: "チーム完成" "scan": "meta": "title": "バーコードリーダー" @@ -2726,6 +3072,39 @@ qr_code: "most_solved": "最も組み立てられた%pieces%ピースパズル" "view_all": "%pieces%ピースパズルをすべて見る" +guides: + breadcrumb: + home: "ホーム" + guides: "ガイド" + ui: + eyebrow: "ガイド" + updated: "%date%更新" + data_note: "コミュニティのライブデータ" + read_next: "次に読む" + cta_title: "次のパズル、タイムを計ってみませんか?" + cta_stopwatch: "ストップウォッチを開始" + cta_puzzles: "パズルデータベースを見る" + cta_events: "大会を探す" + index: + title: "スピードパズリングガイド" + meta_description: "実際の完成タイムデータに裏付けられたスピードパズリングのガイド:パズルにかかる本当の時間、大会の仕組み、速くなるコツ。" + intro: "スピードパズリングコミュニティが執筆し、実際に記録された完成タイムに裏付けられています — 当て推量ではありません。" + read_guide: "ガイドを読む" + footnote: "これらのガイドの統計はすべてMySpeedPuzzlingに記録された完成データから算出され、コミュニティが新しいタイムを記録するたびに自動的に更新されます。" + what_is: + title: "スピードパズリングとは?" + meta_description: "スピードパズリングは、時間と競うジグソーパズルです。WJPCなどの大会の仕組み、一般的なタイム、始め方をご紹介します。" + card_description: "完全な入門ガイド:競技形式、大会の仕組み、一般的なタイム、コミュニティへの参加方法。" + how_long: + title: "1000ピースのパズルにかかる時間は?" + meta_description: "%count%件のソロ完成記録から算出:1000ピースパズルの中央値は%median%です。ピース数ごとの実際のパーセンタイルと最速タイムを掲載。" + meta_description_fallback: "ネット上で唯一のデータに基づく答え:実際に記録された完成データによる、ピース数ごとのタイム中央値・パーセンタイル・最速記録。" + card_description: "当て推量ではなく実データ:ピース数ごとのタイム中央値、パーセンタイル、最速記録。" + tips: + title: "スピードパズリング上達のコツ:速くなるには" + meta_description: "競技パズラーが実践する10の実用的なコツ — 仕分け、エッジ、色のブロック分け、セットアップ、そして本当の上達の測り方。" + card_description: "完成タイムを実際に縮める10のテクニックと、自分の実力を測るコミュニティベンチマーク。" + "wjpc_hub": "meta": "title": "世界ジグソーパズル選手権(WJPC)"