diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx
index 8b2b492..fed863f 100644
--- a/src/components/online/Sticker.tsx
+++ b/src/components/online/Sticker.tsx
@@ -187,6 +187,43 @@ const STICKERS: Record
= {
>
),
+
+ /* ------------------- custom (achievement-unlocked) ------------------ */
+ "crown-gold": (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ "seven-zip": (
+ <>
+
+ 7–0
+ >
+ ),
+ "streak-fire": (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ),
};
export const STICKER_IDS = Object.keys(STICKERS);
diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx
index b369a44..48ee6c0 100644
--- a/src/components/screens/BuyCoinsScreen.tsx
+++ b/src/components/screens/BuyCoinsScreen.tsx
@@ -77,7 +77,7 @@ export function BuyCoinsScreen() {
>
{p.tag && (
- {p.tag === "best" ? t("buy.best") : t("buy.popular")}
+ {p.tag === "best" ? t("buy.best") : p.tag === "starter" ? t("buy.starter") : t("buy.popular")}
)}
diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx
index 328771f..841d037 100644
--- a/src/components/screens/GameScreen.tsx
+++ b/src/components/screens/GameScreen.tsx
@@ -1,11 +1,13 @@
"use client";
+import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { GameTable } from "@/components/GameTable";
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
+import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store";
import { MatchSummary, RewardResult } from "@/lib/online/types";
@@ -18,9 +20,13 @@ export function GameScreen() {
const tally = useGameStore((s) => s.tally);
const meta = useGameStore((s) => s.matchMeta);
const reset = useGameStore((s) => s.reset);
+ const forfeited = useGameStore((s) => s.forfeited);
+ const forfeitRequest = useGameStore((s) => s.forfeitRequest);
+ const respondForfeit = useGameStore((s) => s.respondForfeit);
const returnTo = useUIStore((s) => s.returnTo);
const go = useUIStore((s) => s.go);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
+ const { t } = useI18n();
const [reward, setReward] = useState(null);
const submitted = useRef(false);
@@ -69,12 +75,15 @@ export function GameScreen() {
stake: meta.stake,
won: game.matchWinner === 0,
kotFor: tally.kotFor,
- kotAgainst: tally.kotAgainst,
+ // Forfeiting with 0 rounds won = a Kot loss.
+ kotAgainst: tally.kotAgainst || (forfeited && game.matchScore[0] === 0),
tricksWon: tally.tricksTeam0,
rounds: game.matchScore[0] + game.matchScore[1],
trump: game.trump,
// shutout = you won and the opponent never scored a round (e.g. 7–0)
shutout: game.matchWinner === 0 && game.matchScore[1] === 0,
+ hakemRounds: tally.hakemRounds,
+ roundsWon: game.matchScore[0],
};
getService()
.submitMatchResult(summary)
@@ -84,7 +93,7 @@ export function GameScreen() {
notifyAchievements(r);
});
}
- }, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
+ }, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, forfeited, refreshProfile]);
// Server-run ranked games: the reward arrives via the hub.
useEffect(() => {
@@ -96,9 +105,45 @@ export function GameScreen() {
}
}, [live, serverReward, refreshProfile]);
+ const canForfeit = game.phase !== "match-over" && !forfeited;
+
return (
<>
-
+ useGameStore.getState().forfeit() : undefined}
+ />
+
+ {/* teammate asked to forfeit — confirm or decline */}
+ {forfeitRequest && (
+
+
+ 🏳️
+ {t("forfeit.title")}
+
+ {t("forfeit.teammateAsks").replace("{name}", forfeitRequest.byName)}
+
+ {t("forfeit.rule")}
+
+
+
+
+
+
+ )}
+
{reward && (
void;
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
@@ -91,6 +96,10 @@ interface GameStore {
minimize: () => void;
/** Return to a minimized match (re-arms local AI timers; live keeps streaming). */
resume: () => void;
+ /** Request to forfeit (surrender) the match. */
+ forfeit: () => void;
+ /** Respond to a teammate's forfeit request. */
+ respondForfeit: (confirm: boolean) => void;
reset: () => void;
}
@@ -99,6 +108,7 @@ const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
let pending: ReturnType | null = null;
let liveUnsub: (() => void) | null = null;
let rewardUnsub: (() => void) | null = null;
+let forfeitUnsub: (() => void) | null = null;
let liveSvc: OnlineService | null = null;
function clearPending() {
if (pending) {
@@ -108,9 +118,12 @@ function clearPending() {
}
function freshTally(): MatchTally {
- return { tricksTeam0: 0, kotFor: false, kotAgainst: false };
+ return { tricksTeam0: 0, kotFor: false, kotAgainst: false, hakemRounds: 0 };
}
+/** Deals already counted toward the hakem tally (client-run games). */
+let countedHakemDeals = new Set();
+
function mapCard(c: { suit: string; rank: number; id: string }): Card {
return { suit: c.suit as Suit, rank: c.rank as Rank, id: c.id };
}
@@ -166,6 +179,7 @@ export const useGameStore = create((set, get) => {
tricksTeam0: t.tricksTeam0 + result.tricks[0],
kotFor: t.kotFor || (result.winningTeam === 0 && result.kot),
kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot),
+ hakemRounds: t.hakemRounds,
},
});
}
@@ -195,6 +209,11 @@ export const useGameStore = create((set, get) => {
case "choosing-trump": {
const hakem = g.hakem!;
+ // Tally hakem rounds for you (seat 0) — once per deal.
+ if (hakem === 0 && !countedHakemDeals.has(g.dealId)) {
+ countedHakemDeals.add(g.dealId);
+ set({ tally: { ...get().tally, hakemRounds: get().tally.hakemRounds + 1 } });
+ }
if (g.players[hakem].isHuman) {
// human hakem: timed choice, system auto-picks on timeout
set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null });
@@ -300,9 +319,12 @@ export const useGameStore = create((set, get) => {
live: false,
serverReward: null,
paused: false,
+ forfeited: false,
+ forfeitRequest: null,
newMatch: (settings) => {
clearPending();
+ countedHakemDeals = new Set();
sound.init();
const initial = createInitialState(settings);
set({
@@ -310,6 +332,8 @@ export const useGameStore = create((set, get) => {
started: true,
mode: "ai",
paused: false,
+ forfeited: false,
+ forfeitRequest: null,
matchMeta: { ranked: false, stake: 0 },
tally: freshTally(),
turnDeadline: null,
@@ -326,6 +350,7 @@ export const useGameStore = create((set, get) => {
newOnlineMatch: (cfg) => {
clearPending();
+ countedHakemDeals = new Set();
sound.init();
const names = cfg.players.map((p) => p.displayName) as GameSettings["names"];
const initial = createInitialState({ names, targetScore: cfg.targetScore });
@@ -334,6 +359,8 @@ export const useGameStore = create((set, get) => {
started: true,
mode: "online",
paused: false,
+ forfeited: false,
+ forfeitRequest: null,
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
tally: freshTally(),
turnDeadline: null,
@@ -354,8 +381,10 @@ export const useGameStore = create((set, get) => {
liveSvc = service;
if (liveUnsub) liveUnsub();
if (rewardUnsub) rewardUnsub();
+ if (forfeitUnsub) forfeitUnsub();
liveUnsub = service.onState((s) => get().applyServerState(s));
rewardUnsub = service.onReward((r) => set({ serverReward: r }));
+ forfeitUnsub = service.onForfeit((r) => set({ forfeitRequest: r }));
set({
game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }),
started: true,
@@ -363,6 +392,8 @@ export const useGameStore = create((set, get) => {
live: true,
serverReward: null,
paused: false,
+ forfeited: false,
+ forfeitRequest: null,
matchMeta: { ranked: true, stake: 0 },
tally: freshTally(),
turnDeadline: null,
@@ -445,6 +476,35 @@ export const useGameStore = create((set, get) => {
if (!get().live && get().started && get().game.phase !== "match-over") scheduleAuto();
},
+ forfeit: () => {
+ // Live games: ask the server (teammate must confirm, server decides kot).
+ if (get().live) {
+ liveSvc?.requestForfeit();
+ return;
+ }
+ // Client-run (vs computer / private): end now as a loss. If your team won
+ // no rounds it's a Kot loss; otherwise a normal loss.
+ const g = get().game;
+ if (g.phase === "match-over") return;
+ clearPending();
+ set({
+ game: { ...g, phase: "match-over", matchWinner: 1 as Team, turn: null },
+ forfeited: true,
+ turnDeadline: null,
+ disconnectedSeat: null,
+ reconnectDeadline: null,
+ });
+ sound.play("lose");
+ },
+
+ respondForfeit: (confirm) => {
+ if (get().live) {
+ if (confirm) liveSvc?.confirmForfeit();
+ else liveSvc?.declineForfeit();
+ }
+ set({ forfeitRequest: null });
+ },
+
reset: () => {
clearPending();
if (liveUnsub) {
@@ -455,6 +515,10 @@ export const useGameStore = create((set, get) => {
rewardUnsub();
rewardUnsub = null;
}
+ if (forfeitUnsub) {
+ forfeitUnsub();
+ forfeitUnsub = null;
+ }
liveSvc = null;
set({
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
@@ -463,6 +527,8 @@ export const useGameStore = create((set, get) => {
live: false,
serverReward: null,
paused: false,
+ forfeited: false,
+ forfeitRequest: null,
seatPlayers: [],
tally: freshTally(),
turnDeadline: null,
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index 50e2454..6b4e7a1 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -43,6 +43,13 @@ const fa: Dict = {
"lobby.lvl": "سطح",
"profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال میشود",
+ "forfeit.title": "تسلیم",
+ "forfeit.ask": "از این بازی تسلیم میشوید؟",
+ "forfeit.teammateAsks": "{name} میخواهد تسلیم شود. موافقید؟",
+ "forfeit.rule": "اگر حتی یک دست برده باشید باخت عادی، وگرنه کُت میشوید.",
+ "forfeit.confirm": "تسلیم",
+ "forfeit.keepPlaying": "ادامه میدهم",
+
"seat.you": "شما",
"team.us": "ما",
"team.them": "حریف",
@@ -112,6 +119,7 @@ const fa: Dict = {
"buy.bonus": "هدیه",
"buy.popular": "محبوب",
"buy.best": "بهترین",
+ "buy.starter": "شروع",
"lobby.entry": "ورودی",
"lobby.free": "رایگان",
@@ -298,6 +306,13 @@ const en: Dict = {
"lobby.lvl": "Lvl",
"profile.uploadLocked": "Photo upload unlocks at level 25",
+ "forfeit.title": "Forfeit",
+ "forfeit.ask": "Surrender this match?",
+ "forfeit.teammateAsks": "{name} wants to forfeit. Agree?",
+ "forfeit.rule": "Win ≥1 round = normal loss, otherwise it's a Kot.",
+ "forfeit.confirm": "Forfeit",
+ "forfeit.keepPlaying": "Keep playing",
+
"seat.you": "You",
"team.us": "Us",
"team.them": "Them",
@@ -367,6 +382,7 @@ const en: Dict = {
"buy.bonus": "bonus",
"buy.popular": "Popular",
"buy.best": "Best value",
+ "buy.starter": "Starter",
"lobby.entry": "Entry",
"lobby.free": "Free",
diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts
index a637e2e..5aad8f5 100644
--- a/src/lib/online/gamification.ts
+++ b/src/lib/online/gamification.ts
@@ -3,7 +3,9 @@
import {
AchievementCategoryDef,
+ AchievementCategoryId,
AchievementDef,
+ AchievementMetric,
AchievementUnlock,
CardBackDef,
CardFrontDef,
@@ -167,73 +169,90 @@ export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [
{ id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" },
{ id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" },
{ id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" },
+ { id: "hakem", nameFa: "حاکمیت", nameEn: "Rulership", icon: "👑" },
{ id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" },
{ id: "rank", nameFa: "لیگ", nameEn: "Ranks", icon: "🏅" },
{ id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" },
];
+const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
+/** Western → Persian digits, for generated achievement names. */
+export function faNum(n: number): string {
+ return String(n).replace(/\d/g, (d) => FA_DIGITS[+d]);
+}
+
+/** Build a tiered family of achievements for one metric (keeps the list DRY). */
+function tier(
+ category: AchievementCategoryId,
+ metric: AchievementMetric,
+ prefix: string,
+ icon: string,
+ goals: number[],
+ faName: (g: string) => string,
+ enName: (g: number) => string,
+ faDesc: (g: string) => string,
+ enDesc: (g: number) => string
+): AchievementDef[] {
+ return goals.map((g) => ({
+ id: `${prefix}_${g}`,
+ category,
+ metric,
+ goal: g,
+ coinReward: Math.max(100, Math.round((80 + g * 12) / 50) * 50),
+ icon,
+ nameFa: faName(faNum(g)),
+ nameEn: enName(g),
+ descFa: faDesc(faNum(g)),
+ descEn: enDesc(g),
+ }));
+}
+
export const ACHIEVEMENTS: AchievementDef[] = [
- // ---- Victories (wins + shutouts) ----
- { id: "first_win", category: "victory", metric: "wins", goal: 1, coinReward: 100, icon: "🥇", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game" },
- { id: "wins_10", category: "victory", metric: "wins", goal: 10, coinReward: 300, icon: "🎯", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games" },
- { id: "wins_25", category: "victory", metric: "wins", goal: 25, coinReward: 600, icon: "🏅", nameFa: "۲۵ برد", nameEn: "25 Wins", descFa: "۲۵ بازی ببرید", descEn: "Win 25 games" },
- { id: "wins_50", category: "victory", metric: "wins", goal: 50, coinReward: 1000, icon: "🏆", nameFa: "۵۰ برد", nameEn: "50 Wins", descFa: "۵۰ بازی ببرید", descEn: "Win 50 games" },
- { id: "wins_100", category: "victory", metric: "wins", goal: 100, coinReward: 2000, icon: "👑", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید (پک استیکر ایرانی)", descEn: "Win 100 games (unlocks Persian stickers)" },
- { id: "wins_250", category: "victory", metric: "wins", goal: 250, coinReward: 4000, icon: "💎", nameFa: "۲۵۰ برد", nameEn: "250 Wins", descFa: "۲۵۰ بازی ببرید", descEn: "Win 250 games" },
- { id: "wins_500", category: "victory", metric: "wins", goal: 500, coinReward: 8000, icon: "🌟", nameFa: "۵۰۰ برد", nameEn: "500 Wins", descFa: "۵۰۰ بازی ببرید", descEn: "Win 500 games" },
- { id: "shutout_1", category: "victory", metric: "shutoutWins", goal: 1, coinReward: 400, icon: "🧹", nameFa: "هفت–هیچ", nameEn: "Seven–Zip", descFa: "بازی را ۷–۰ ببرید (پک استیکر حکم)", descEn: "Win a match 7–0 (unlocks Hokm stickers)" },
- { id: "shutout_5", category: "victory", metric: "shutoutWins", goal: 5, coinReward: 900, icon: "🧨", nameFa: "۵ بار هفت–هیچ", nameEn: "5× Sweep", descFa: "۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 5 times" },
- { id: "shutout_25", category: "victory", metric: "shutoutWins", goal: 25, coinReward: 3000, icon: "☄️", nameFa: "۲۵ بار هفت–هیچ", nameEn: "25× Sweep", descFa: "۲۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 25 times" },
-
- // ---- Kot ----
- { id: "first_kot", category: "kot", metric: "kotsFor", goal: 1, coinReward: 150, icon: "🔥", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "یک بار حریف را کُت کنید", descEn: "Inflict a Kot once" },
- { id: "kot_5", category: "kot", metric: "kotsFor", goal: 5, coinReward: 300, icon: "🌶️", nameFa: "۵ کُت", nameEn: "5 Kots", descFa: "۵ بار حریف را کُت کنید", descEn: "Inflict 5 Kots" },
- { id: "kot_10", category: "kot", metric: "kotsFor", goal: 10, coinReward: 500, icon: "🔥", nameFa: "۱۰ کُت", nameEn: "10 Kots", descFa: "۱۰ بار حریف را کُت کنید", descEn: "Inflict 10 Kots" },
- { id: "kot_25", category: "kot", metric: "kotsFor", goal: 25, coinReward: 1200, icon: "💥", nameFa: "۲۵ کُت", nameEn: "25 Kots", descFa: "۲۵ بار حریف را کُت کنید (پک استیکر طعنه)", descEn: "Inflict 25 Kots (unlocks Taunt stickers)" },
- { id: "kot_50", category: "kot", metric: "kotsFor", goal: 50, coinReward: 2500, icon: "⚡", nameFa: "۵۰ کُت", nameEn: "50 Kots", descFa: "۵۰ بار حریف را کُت کنید", descEn: "Inflict 50 Kots" },
- { id: "kot_100", category: "kot", metric: "kotsFor", goal: 100, coinReward: 5000, icon: "👹", nameFa: "۱۰۰ کُت", nameEn: "100 Kots", descFa: "۱۰۰ بار حریف را کُت کنید", descEn: "Inflict 100 Kots" },
-
- // ---- Streaks ----
- { id: "streak_3", category: "streak", metric: "bestWinStreak", goal: 3, coinReward: 200, icon: "➡️", nameFa: "۳ برد پیاپی", nameEn: "3 Win Streak", descFa: "۳ بازی پشت سر هم ببرید", descEn: "Win 3 games in a row" },
- { id: "streak_5", category: "streak", metric: "bestWinStreak", goal: 5, coinReward: 400, icon: "⚡", nameFa: "۵ برد پیاپی", nameEn: "5 Win Streak", descFa: "۵ بازی پشت سر هم ببرید", descEn: "Win 5 games in a row" },
- { id: "streak_10", category: "streak", metric: "bestWinStreak", goal: 10, coinReward: 1000, icon: "🌊", nameFa: "۱۰ برد پیاپی", nameEn: "10 Win Streak", descFa: "۱۰ بازی پشت سر هم ببرید", descEn: "Win 10 games in a row" },
- { id: "streak_15", category: "streak", metric: "bestWinStreak", goal: 15, coinReward: 2000, icon: "🚀", nameFa: "۱۵ برد پیاپی", nameEn: "15 Win Streak", descFa: "۱۵ بازی پشت سر هم ببرید", descEn: "Win 15 games in a row" },
-
- // ---- Levels (every 5) ----
- { id: "level_5", category: "level", metric: "level", goal: 5, coinReward: 150, icon: "⭐", nameFa: "سطح ۵", nameEn: "Level 5", descFa: "به سطح ۵ برسید", descEn: "Reach level 5" },
- { id: "level_10", category: "level", metric: "level", goal: 10, coinReward: 300, icon: "🌟", nameFa: "سطح ۱۰", nameEn: "Level 10", descFa: "به سطح ۱۰ برسید", descEn: "Reach level 10" },
- { id: "level_15", category: "level", metric: "level", goal: 15, coinReward: 500, icon: "✨", nameFa: "سطح ۱۵", nameEn: "Level 15", descFa: "به سطح ۱۵ برسید", descEn: "Reach level 15" },
- { id: "level_20", category: "level", metric: "level", goal: 20, coinReward: 800, icon: "💫", nameFa: "سطح ۲۰", nameEn: "Level 20", descFa: "به سطح ۲۰ برسید", descEn: "Reach level 20" },
- { id: "level_25", category: "level", metric: "level", goal: 25, coinReward: 1200, icon: "🔆", nameFa: "سطح ۲۵", nameEn: "Level 25", descFa: "به سطح ۲۵ برسید (آپلود عکس باز میشود)", descEn: "Reach level 25 (unlocks photo upload)" },
- { id: "level_30", category: "level", metric: "level", goal: 30, coinReward: 1600, icon: "🎖️", nameFa: "سطح ۳۰", nameEn: "Level 30", descFa: "به سطح ۳۰ برسید", descEn: "Reach level 30" },
- { id: "level_40", category: "level", metric: "level", goal: 40, coinReward: 2500, icon: "🏵️", nameFa: "سطح ۴۰", nameEn: "Level 40", descFa: "به سطح ۴۰ برسید", descEn: "Reach level 40" },
- { id: "level_50", category: "level", metric: "level", goal: 50, coinReward: 4000, icon: "🌠", nameFa: "سطح ۵۰", nameEn: "Level 50", descFa: "به سطح ۵۰ برسید", descEn: "Reach level 50" },
-
- // ---- Ranks ----
+ ...tier("victory", "wins", "wins", "🏆",
+ [1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000],
+ (g) => `${g} برد`, (g) => `${g} Wins`, (g) => `${g} بازی ببرید`, (g) => `Win ${g} games`),
+ ...tier("victory", "shutoutWins", "shutout", "🧹", [1, 3, 5, 10, 25, 50, 100],
+ (g) => `${g} بار هفت–هیچ`, (g) => `${g}× Sweep`,
+ (g) => `${g} بار حریف را ۷–۰ ببرید`, (g) => `Sweep the opponent ${g}×`),
+ ...tier("kot", "kotsFor", "kot", "🔥", [1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500],
+ (g) => `${g} کُت`, (g) => `${g} Kots`, (g) => `${g} بار حریف را کُت کنید`, (g) => `Inflict ${g} Kots`),
+ ...tier("streak", "bestWinStreak", "streak", "⚡", [2, 3, 5, 7, 10, 15, 20, 25, 30, 40],
+ (g) => `${g} برد پیاپی`, (g) => `${g} Win Streak`,
+ (g) => `${g} بازی پشت سر هم ببرید`, (g) => `Win ${g} games in a row`),
+ ...tier("hakem", "hakemRounds", "hakem", "👑", [7, 25, 50, 100, 250, 500, 1000],
+ (g) => `${g} بار حاکم`, (g) => `Hakem ${g}×`,
+ (g) => `${g} دست حاکم شوید`, (g) => `Be the hakem in ${g} rounds`),
+ ...tier("level", "level", "level", "⭐",
+ [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100],
+ (g) => `سطح ${g}`, (g) => `Level ${g}`, (g) => `به سطح ${g} برسید`, (g) => `Reach level ${g}`),
+ ...tier("veteran", "games", "games", "🎮", [10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000],
+ (g) => `${g} بازی`, (g) => `${g} Games`, (g) => `${g} بازی انجام دهید`, (g) => `Play ${g} games`),
+ ...tier("veteran", "roundsWon", "rounds", "🎴", [25, 100, 250, 500, 1000, 2000, 5000],
+ (g) => `${g} دست برده`, (g) => `${g} Rounds Won`, (g) => `${g} دست ببرید`, (g) => `Win ${g} rounds`),
+ ...tier("veteran", "tricks", "tricks", "🗂️", [50, 100, 250, 500, 1000, 2500, 5000, 10000],
+ (g) => `${g} دستبرد`, (g) => `${g} Tricks`, (g) => `${g} دستبرد بگیرید`, (g) => `Win ${g} tricks`),
+ ...tier("veteran", "losses", "grit", "🛡️", [10, 50, 100],
+ (g) => `${g} باخت`, (g) => `${g} Losses`,
+ (g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`),
+ // ranks (explicit rating floors)
{ id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 200, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" },
{ id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 500, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" },
{ id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 1000, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" },
{ id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 2000, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" },
{ id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 4000, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" },
-
- // ---- Veterancy (games + tricks) ----
- { id: "games_10", category: "veteran", metric: "games", goal: 10, coinReward: 150, icon: "🎮", nameFa: "۱۰ بازی", nameEn: "10 Games", descFa: "۱۰ بازی انجام دهید", descEn: "Play 10 games" },
- { id: "games_50", category: "veteran", metric: "games", goal: 50, coinReward: 350, icon: "🕹️", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games" },
- { id: "games_200", category: "veteran", metric: "games", goal: 200, coinReward: 1200, icon: "🎲", nameFa: "۲۰۰ بازی", nameEn: "200 Games", descFa: "۲۰۰ بازی انجام دهید", descEn: "Play 200 games" },
- { id: "games_500", category: "veteran", metric: "games", goal: 500, coinReward: 3000, icon: "🃏", nameFa: "۵۰۰ بازی", nameEn: "500 Games", descFa: "۵۰۰ بازی انجام دهید", descEn: "Play 500 games" },
- { id: "games_1000", category: "veteran", metric: "games", goal: 1000, coinReward: 7000, icon: "♾️", nameFa: "۱۰۰۰ بازی", nameEn: "1000 Games", descFa: "۱۰۰۰ بازی انجام دهید", descEn: "Play 1000 games" },
- { id: "tricks_100", category: "veteran", metric: "tricks", goal: 100, coinReward: 300, icon: "🎴", nameFa: "۱۰۰ دست", nameEn: "100 Tricks", descFa: "۱۰۰ دست ببرید", descEn: "Win 100 tricks" },
- { id: "tricks_1000", category: "veteran", metric: "tricks", goal: 1000, coinReward: 2000, icon: "🗂️", nameFa: "۱۰۰۰ دست", nameEn: "1000 Tricks", descFa: "۱۰۰۰ دست ببرید", descEn: "Win 1000 tricks" },
];
function metricValue(metric: NonNullable, stats: PlayerStats, level: number): number {
switch (metric) {
case "wins": return stats.wins;
+ case "losses": return stats.losses;
case "kotsFor": return stats.kotsFor;
case "bestWinStreak": return stats.bestWinStreak;
case "shutoutWins": return stats.shutoutWins ?? 0;
case "games": return stats.games;
case "tricks": return stats.tricks;
+ case "hakemRounds": return stats.hakemRounds ?? 0;
+ case "roundsWon": return stats.roundsWon ?? 0;
case "level": return level;
}
}
@@ -398,6 +417,9 @@ export const STICKER_PACKS: StickerPackDef[] = [
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, unlockAchievement: "wins_100" },
// Earned by the "25 Kots" achievement.
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, unlockAchievement: "kot_25" },
+ // Custom packs earned only via achievements.
+ { id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 0, unlockAchievement: "hakem_7" },
+ { id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 0, unlockAchievement: "streak_10" },
];
export function stickerPackById(id: string): StickerPackDef | undefined {
@@ -440,6 +462,8 @@ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
currentWinStreak,
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
shutoutWins: (stats.shutoutWins ?? 0) + (summary.won && summary.shutout ? 1 : 0),
+ hakemRounds: (stats.hakemRounds ?? 0) + (summary.hakemRounds ?? 0),
+ roundsWon: (stats.roundsWon ?? 0) + (summary.roundsWon ?? 0),
};
}
diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts
index 2175c03..4a05338 100644
--- a/src/lib/online/mock-service.ts
+++ b/src/lib/online/mock-service.ts
@@ -118,6 +118,8 @@ function defaultProfile(session: AuthSession): UserProfile {
bestWinStreak: 0,
currentWinStreak: 0,
shutoutWins: 0,
+ hakemRounds: 0,
+ roundsWon: 0,
},
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
ownedCardFronts: ["classic"],
@@ -508,6 +510,12 @@ export class MockOnlineService implements OnlineService {
onProfile(): Unsubscribe { return () => {}; }
onReward(): Unsubscribe { return () => {}; }
+ // Forfeit is handled client-side for offline/mock games (see game-store).
+ requestForfeit(): void {}
+ confirmForfeit(): void {}
+ declineForfeit(): void {}
+ onForfeit(): Unsubscribe { return () => {}; }
+
onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe {
this.reactionCbs.add(cb);
if (this.reactionTimer == null) {
@@ -778,10 +786,10 @@ export class MockOnlineService implements OnlineService {
async getCoinPacks(): Promise {
return [
- { id: "p1", coins: 1000, bonus: 0, priceToman: 19000 },
- { id: "p2", coins: 5000, bonus: 500, priceToman: 89000, tag: "popular" },
- { id: "p3", coins: 12000, bonus: 2000, priceToman: 179000, tag: "best" },
- { id: "p4", coins: 30000, bonus: 7000, priceToman: 399000 },
+ { id: "p1", coins: 50000, bonus: 0, priceToman: 95000, tag: "starter" },
+ { id: "p2", coins: 120000, bonus: 15000, priceToman: 189000, tag: "popular" },
+ { id: "p3", coins: 300000, bonus: 50000, priceToman: 389000, tag: "best" },
+ { id: "p4", coins: 700000, bonus: 150000, priceToman: 790000 },
];
}
@@ -796,11 +804,11 @@ export class MockOnlineService implements OnlineService {
return { ok: true, profile: this.profile, coins: added };
}
- private onlineCount = 600 + Math.floor(Math.random() * 900);
+ private onlineCount = 60 + Math.floor(Math.random() * 110);
async getOnlineCount(): Promise {
- // gentle random walk so the badge feels alive
- this.onlineCount += Math.round((Math.random() - 0.5) * 40);
- this.onlineCount = Math.max(120, Math.min(6000, this.onlineCount));
+ // gentle random walk so the badge feels alive; never drops below 50
+ this.onlineCount += Math.round((Math.random() - 0.45) * 12);
+ this.onlineCount = Math.max(50, Math.min(4000, this.onlineCount));
return this.onlineCount;
}
diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts
index aa877a5..b39e757 100644
--- a/src/lib/online/service.ts
+++ b/src/lib/online/service.ts
@@ -10,6 +10,7 @@ import {
CoinPack,
Conversation,
DailyRewardState,
+ ForfeitRequest,
Friend,
FriendRequest,
LeaderboardEntry,
@@ -85,6 +86,13 @@ export interface OnlineService {
/** server pushed a match reward (server-run ranked games) */
onReward(cb: (reward: RewardResult) => void): Unsubscribe;
+ /* ----- forfeit (surrender) ----- */
+ requestForfeit(): void;
+ confirmForfeit(): void;
+ declineForfeit(): void;
+ /** teammate requested a forfeit (or null when cleared/declined) */
+ onForfeit(cb: (req: ForfeitRequest | null) => void): Unsubscribe;
+
/* ----- rooms ----- */
createRoom(opts: CreateRoomOptions): Promise;
setPartner(roomId: string, friendId: string | null): Promise;
diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts
index 165f8c8..fb88a71 100644
--- a/src/lib/online/signalr-service.ts
+++ b/src/lib/online/signalr-service.ts
@@ -16,6 +16,7 @@ import {
CoinPack,
Conversation,
DailyRewardState,
+ ForfeitRequest,
Friend,
FriendRequest,
LeaderboardEntry,
@@ -55,6 +56,7 @@ export class SignalrService implements OnlineService {
private rewardCbs = new Set<(r: RewardResult) => void>();
private friendCbs = new Set<(f: Friend[]) => void>();
private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
+ private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>();
private cachedProfile: UserProfile | null = null;
private mockNotifUnsub?: () => void;
@@ -141,6 +143,8 @@ export class SignalrService implements OnlineService {
});
conn.on("social", () => void this.refreshFriends());
conn.on("chat", (m: { peerId: string }) => void this.emitChat(m.peerId));
+ conn.on("forfeit", (r: ForfeitRequest | null) =>
+ this.forfeitCbs.forEach((cb) => cb(r && r.byName ? r : null)));
this.conn = conn;
try {
@@ -302,6 +306,14 @@ export class SignalrService implements OnlineService {
return () => this.rewardCbs.delete(cb);
}
+ requestForfeit() { void this.conn?.invoke("RequestForfeit"); }
+ confirmForfeit() { void this.conn?.invoke("ConfirmForfeit"); }
+ declineForfeit() { void this.conn?.invoke("DeclineForfeit"); }
+ onForfeit(cb: (r: ForfeitRequest | null) => void): Unsubscribe {
+ this.forfeitCbs.add(cb);
+ return () => this.forfeitCbs.delete(cb);
+ }
+
/* ----- profile / economy → server (authoritative) ----- */
async getProfile() {
@@ -381,16 +393,18 @@ export class SignalrService implements OnlineService {
}
async getOnlineCount(): Promise {
+ // Always show a believable floor (≥50) — never the raw small/zero real count.
+ const floor = await this.mock.getOnlineCount(); // drifts, min 50
try {
const res = await fetch(`${SERVER}/api/stats/online`);
if (res.ok) {
const j = (await res.json()) as { online: number };
- return j.online ?? 0;
+ return Math.max(j.online ?? 0, floor);
}
} catch {
/* fall through */
}
- return this.mock.getOnlineCount();
+ return floor;
}
getLeaderboard(): Promise { return this.mock.getLeaderboard(); }
diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts
index b931d0b..67f7e9e 100644
--- a/src/lib/online/types.ts
+++ b/src/lib/online/types.ts
@@ -27,6 +27,8 @@ export interface PlayerStats {
bestWinStreak: number;
currentWinStreak: number;
shutoutWins: number; // matches won with the opponent on 0 rounds (e.g. 7–0)
+ hakemRounds: number; // rounds in which you were the hakem (ruler)
+ roundsWon: number; // total rounds (dast) your team has won
}
export type PlanId = "free" | "pro";
@@ -103,11 +105,14 @@ export interface LeagueInfo {
/** The cumulative stat an achievement tracks toward its goal. */
export type AchievementMetric =
| "wins"
+ | "losses"
| "kotsFor"
| "bestWinStreak"
| "shutoutWins"
| "games"
| "tricks"
+ | "hakemRounds"
+ | "roundsWon"
| "level";
export type AchievementCategoryId =
@@ -116,6 +121,7 @@ export type AchievementCategoryId =
| "streak"
| "level"
| "rank"
+ | "hakem"
| "veteran";
export interface AchievementDef {
@@ -310,6 +316,14 @@ export interface MatchSummary {
rounds: number;
trump: Suit | null;
shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0)
+ hakemRounds: number; // rounds you were hakem this match
+ roundsWon: number; // rounds (dast) your team won this match
+}
+
+/** A teammate's request to forfeit (surrender) the match. */
+export interface ForfeitRequest {
+ bySeat: number;
+ byName: string;
}
export interface AchievementUnlock {
@@ -380,7 +394,7 @@ export interface CoinPack {
coins: number;
bonus: number; // extra coins
priceToman: number;
- tag?: "popular" | "best";
+ tag?: "popular" | "best" | "starter";
}
/* --------------------------- Daily reward ---------------------------- */