Achievements overhaul: 37 achievements, page with tabs, leagues, gating
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m21s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped

Achievements (client + server mirror, metric-driven so the list is one source):
- 37 achievements across 6 categories (Victories, Kot, Streaks, Levels, Ranks,
  Veterancy) incl. 7–0 sweeps, kot milestones (1/5/10/25/50/100), win streaks
  (3/5/10/15), level milestones every 5 (5..50), rank floors, games/tricks.
- New AchievementsScreen with category tabs, progress bars, coin + sticker-unlock
  badges, and unlocked/locked states; summary header (unlocked count + coins).
- Some achievements unlock sticker packs: Seven–Zip→Hokm, 25 Kots→Taunts,
  100 Wins→Persian (ownedStickerPackIds now also honors profile.unlocked).
- Prestige titles added: Expert, Professional, Captain, Leader (+ existing).
- Tracks new stat shutoutWins; MatchSummary.shutout (7–0). Profile shows a
  6-item preview + "view all" link.

Leagues: 3 ranked entry tiers — Starter (100, lvl1), Pro (500, lvl10),
Expert (1000, lvl20). Higher league stakes more, so wins/losses swing bigger;
kot bonus now scales to the stake (40%). OnlineLobby shows league cards with
level gating.

Profile photo upload gated to level 25 (client button + server Update guard).

Win animation: PostMatchRewardsModal now shows an animated coins-won count-up
hero on a win.

Verified: dotnet build + tsc + next build clean; sim unlocks 26 achievements
over 500 matches; live server grants first_win/first_kot/shutout_1 and pays
2050 coins on an expert-league shutout+kot win. Images rebuilt on :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 21:47:38 +03:30
parent d66208e39e
commit 7a18bc39e6
16 changed files with 592 additions and 102 deletions
+40 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { Check, Coins, Crown, Music, Pencil, Upload, Volume2 } from "lucide-react";
import { Check, ChevronLeft, Coins, Crown, Lock, Music, Pencil, Upload, Volume2 } from "lucide-react";
import { useRef, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
@@ -8,6 +8,7 @@ import { XpBar } from "@/components/online/XpBar";
import { Avatar } from "@/components/online/Avatar";
import { useSessionStore } from "@/lib/session-store";
import { useSoundStore } from "@/lib/sound-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import {
ACHIEVEMENTS,
@@ -18,6 +19,9 @@ import {
ownedCardBackIds,
ownedCardFrontIds,
} from "@/lib/online/gamification";
/** Level required before a player can upload a custom profile photo. */
const PHOTO_UPLOAD_MIN_LEVEL = 25;
import { AVATARS } from "@/lib/online/types";
import { cn } from "@/lib/cn";
@@ -26,11 +30,13 @@ export function ProfileScreen() {
const profile = useSessionStore((s) => s.profile);
const updateProfile = useSessionStore((s) => s.updateProfile);
const upgradePlan = useSessionStore((s) => s.upgradePlan);
const go = useUIStore((st) => st.go);
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(profile?.displayName ?? "");
if (!profile) return null;
const canUpload = profile.level >= PHOTO_UPLOAD_MIN_LEVEL;
const s = profile.stats;
const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0;
const titleDef = TITLES.find((x) => x.id === profile.title);
@@ -45,7 +51,7 @@ export function ProfileScreen() {
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file || !canUpload) return;
const reader = new FileReader();
reader.onload = () => updateProfile({ avatarImage: String(reader.result) });
reader.readAsDataURL(file);
@@ -62,11 +68,14 @@ export function ProfileScreen() {
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
</div>
<button
onClick={() => fileRef.current?.click()}
className="absolute -bottom-1 ltr:-right-1 rtl:-left-1 btn-gold rounded-full p-1.5"
title={t("profile.upload")}
onClick={() => (canUpload ? fileRef.current?.click() : undefined)}
className={cn(
"absolute -bottom-1 ltr:-right-1 rtl:-left-1 rounded-full p-1.5",
canUpload ? "btn-gold" : "bg-navy-900 gold-border text-cream/50 cursor-not-allowed"
)}
title={canUpload ? t("profile.upload") : t("profile.uploadLocked")}
>
<Upload className="size-3.5" />
{canUpload ? <Upload className="size-3.5" /> : <Lock className="size-3.5" />}
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUpload} />
</div>
@@ -242,10 +251,32 @@ export function ProfileScreen() {
{/* achievements */}
<div className="glass rounded-2xl p-4 mt-4 mb-6">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.achievements")}</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-cream/80">
{t("profile.achievements")}
<span className="text-cream/40 font-normal">
{" "}
({profile.unlocked.length}/{ACHIEVEMENTS.length})
</span>
</h3>
<button
onClick={() => go("achievements")}
className="text-xs font-bold text-gold-300 flex items-center gap-0.5 hover:text-gold-200"
>
{t("achv.viewAll")}
<ChevronLeft className="size-3.5 rtl:rotate-0 ltr:rotate-180" />
</button>
</div>
<div className="space-y-2">
{ACHIEVEMENTS.map((a) => {
const prog = achievementProgress(a.id, s, profile.rating);
{[...ACHIEVEMENTS]
.sort((a, b) => {
const ua = profile.unlocked.includes(a.id) ? 1 : 0;
const ub = profile.unlocked.includes(b.id) ? 1 : 0;
return ub - ua;
})
.slice(0, 6)
.map((a) => {
const prog = achievementProgress(a, s, profile.rating, profile.level);
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
return (