feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+81 -14
View File
@@ -161,6 +161,29 @@ export function leagueXpFactor(stake: number): number {
/** XP multiplier for premium (pro) players. */
export const PREMIUM_XP_MULT = 1.5;
/* ----------------------------- Turn time ----------------------------- */
/**
* How long a player has to act, by league (derived from the coin stake). Higher
* leagues give LESS time, so stronger players must think faster:
* Starter / vs-AI / private (stake < 500) → 15s
* Pro league (stake ≥ 500) → 10s
* Expert league (stake ≥ 1000) → 7s
* Both the offline client and the live server use this same mapping so the turn
* clock matches in either mode.
*/
/** Blitz/speed-mode turn time — a flat, fast clock for casual quick games. */
export const SPEED_TURN_MS = 5000;
/** Speed mode races to fewer points so a match is over fast. */
export const SPEED_TARGET_SCORE = 5;
export function turnMsForStake(stake: number, speed = false): number {
if (speed) return SPEED_TURN_MS;
if (stake >= 1000) return 7000;
if (stake >= 500) return 10000;
return 15000;
}
export function matchXp(summary: MatchSummary): number {
// Forfeiting (surrendering) earns no XP.
if (summary.forfeit && !summary.won) return 0;
@@ -362,8 +385,22 @@ export const TITLES: TitleDef[] = [
{ id: "immortal", nameFa: "جاودانه", nameEn: "Immortal", hintFa: "سطح ۵۰", hintEn: "Level 50" },
{ id: "the_one", nameFa: "یگانه", nameEn: "The One", hintFa: "۵۰۰ برد", hintEn: "500 wins" },
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" },
// ✨ Luxury titles — the most prestigious badges in the game
{ id: "sultan", nameFa: "سلطان حکم", nameEn: "Hokm Sultan", hintFa: "۱۰۰ کُت", hintEn: "100 kots" },
{ id: "emperor", nameFa: "امپراتور", nameEn: "Emperor", hintFa: "سطح ۷۵", hintEn: "Level 75" },
{ id: "grandmaster", nameFa: "استاد بزرگ", nameEn: "Grandmaster", hintFa: "امتیاز ۲۱۰۰+", hintEn: "2100+ rating" },
// 💰 Purchasable titles — buy with coins (shown in the shop's Titles section)
{ id: "vip", nameFa: "وی‌آی‌پی", nameEn: "VIP", hintFa: "خرید", hintEn: "Purchase", price: 2500 },
{ id: "maestro", nameFa: "اوستا", nameEn: "Maestro", hintFa: "خرید", hintEn: "Purchase", price: 2000 },
{ id: "prince", nameFa: "شاهزاده", nameEn: "Prince", hintFa: "خرید", hintEn: "Purchase", price: 3500 },
{ id: "mythic", nameFa: "افسانه‌ای", nameEn: "Mythic", hintFa: "خرید", hintEn: "Purchase", price: 6000 },
];
export function titleById(id: string | null | undefined): TitleDef | undefined {
if (!id) return undefined;
return TITLES.find((t) => t.id === id);
}
export function titleUnlocked(
id: string,
stats: PlayerStats,
@@ -407,6 +444,12 @@ export function titleUnlocked(
return stats.wins >= 500;
case "legend":
return rating >= tierById("master").floor;
case "sultan":
return stats.kotsFor >= 100;
case "emperor":
return level >= 75;
case "grandmaster":
return rating >= 2100;
default:
return false;
}
@@ -416,19 +459,25 @@ export function titleUnlocked(
// Card BACKS (pattern on the reverse of every card).
export const CARD_BACKS: CardBackDef[] = [
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true },
{ id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200 },
{ id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 },
{ id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 },
{ id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000 },
{ id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500 },
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true, pattern: "stripes" },
{ id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200, pattern: "grid" },
{ id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800, pattern: "dots" },
{ id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000, pattern: "argyle" },
{ id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000, pattern: "scales" },
{ id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500, pattern: "crosshatch" },
// earned by rank / wins — the higher the rank, the rarer the back
{ id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25 },
{ id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 },
{ id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 },
{ id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500 },
{ id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700 },
{ id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900 },
{ id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25, pattern: "rays" },
{ id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300, pattern: "argyle", motif: "♦" },
{ id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50, pattern: "royal", motif: "♛" },
{ id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500, pattern: "rays" },
{ id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700, pattern: "crosshatch", motif: "✦" },
{ id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900, pattern: "royal", motif: "♔" },
// ✨ Luxury card backs — premium purchasable, each a distinct fancy motif
{ id: "diamond", nameFa: "الماس", nameEn: "Diamond", c1: "#1a3a55", c2: "#0a1a2e", accent: "#9fe6ff", price: 2800, pattern: "gem", motif: "◆" },
{ id: "blackgold", nameFa: "طلای سیاه", nameEn: "Black Gold", c1: "#1a1407", c2: "#000000", accent: "#ffd76a", price: 3500, pattern: "filigree", motif: "♠" },
{ id: "platinum-back", nameFa: "پلاتین", nameEn: "Platinum", c1: "#3a3f4a", c2: "#15171c", accent: "#e6ebf2", price: 4200, pattern: "royal", motif: "✦" },
{ id: "peacock-back", nameFa: "طاووس", nameEn: "Peacock", c1: "#0a3a52", c2: "#06202e", accent: "#16d3c0", price: 3000, pattern: "scales", motif: "❖" },
{ id: "rosegold-back", nameFa: "رزگلد", nameEn: "Rose Gold", c1: "#5a2438", c2: "#2a0e1c", accent: "#ffb0c4", price: 3200, pattern: "argyle", motif: "♥" },
];
// Card FRONTS (the face background/border behind the suit + rank).
@@ -445,6 +494,9 @@ export const CARD_FRONTS: CardFrontDef[] = [
{ id: "goldleaf", nameFa: "زرورق", nameEn: "Gold Leaf", bg1: "#fff7df", bg2: "#f2dd9b", border: "#caa53a", price: 0, unlockRating: 1500 },
{ id: "crystal", nameFa: "بلور", nameEn: "Crystal", bg1: "#eefcff", bg2: "#cdeefa", border: "#5fb6d6", price: 0, unlockRating: 1700 },
{ id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 0, unlockWins: 100 },
// ✨ Luxury card fronts — premium purchasable
{ id: "diamond-face", nameFa: "الماس", nameEn: "Diamond", bg1: "#f4fdff", bg2: "#d7f0fb", border: "#7fc6e6", price: 2500 },
{ id: "blackgold-face", nameFa: "طلای سیاه", nameEn: "Black Gold", bg1: "#2a2410", bg2: "#14110a", border: "#caa53a", price: 3200 },
];
export function cardBackById(id: string): CardBackDef {
@@ -537,7 +589,22 @@ export const STICKER_PACKS: StickerPackDef[] = [
// Custom packs earned only via achievements / rank.
{ 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" },
{ id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text"], price: 0, unlockRating: 1500 },
/* ---- New themed packs: کل‌کل (banter), Persian trends, Hokm/game ---- */
// کل‌کل / تیکه — trash-talk you fling at the table
{ id: "kolkol", nameFa: "کل‌کل", nameEn: "Banter", stickers: ["sukhti", "yad-begir", "nobate-man", "naz-nakon"], price: 800 },
{ id: "tikeh", nameFa: "تیکه‌انداز", nameEn: "Taunts", stickers: ["kojai", "hool-nasho", "didi-goftam", "bendaz-dige"], price: 1000 },
{ id: "shakkak", nameFa: "شاکی", nameEn: "Salty", stickers: ["nakon-eddea", "shans-avordi", "biya-bebin", "kart-nadari"], price: 1000 },
// Persian trend phrases / praise
{ id: "trends", nameFa: "ترندها", nameEn: "Trends", stickers: ["eyval", "torkundi", "gol-kashti", "harf-nadari"], price: 900 },
{ id: "tashvigh", nameFa: "تشویق", nameEn: "Cheers", stickers: ["damet-garm-2", "nush-jan", "be-be", "ghorbunet"], price: 700 },
// Hokm / card-game themed
{ id: "khanevadeh", nameFa: "خانواده خال", nameEn: "Court Cards", stickers: ["tak-khal", "as-del", "shah-khesht", "bibi-gesht"], price: 1200 },
{ id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text", "jam-kon", "kish-mat"], price: 0, unlockRating: 1500 },
// Extra emotions
{ id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 },
// Mega banter bundle (earned, not sold) — the spicy stuff for rivals
{ id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 0, unlockAchievement: "kot_10" },
];
export function stickerPackById(id: string): StickerPackDef | undefined {
@@ -686,7 +753,7 @@ export function applyMatchResult(
/* --------------------------- Daily reward ---------------------------- */
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000];
export const DAILY_REWARDS = [300, 500, 750, 1000, 1500, 2500, 7500];
export function dailyRewardFor(day: number): number {
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
+221 -12
View File
@@ -3,11 +3,14 @@
// with timers, and computes rewards via gamification.ts.
import {
ACHIEVEMENTS,
CARD_BACKS,
CARD_FRONTS,
REACTION_PACKS,
STICKER_PACKS,
TITLES,
XP_PACKS,
achievementProgress,
addXp,
applyMatchResult,
dailyRewardFor,
@@ -31,10 +34,16 @@ import {
DailyRewardState,
Friend,
FriendRequest,
Gender,
LeaderboardEntry,
MatchSummary,
MatchmakingState,
PlayerStats,
PlayerSummary,
PresenceStatus,
PublicProfile,
SocialLinks,
SocialVisibility,
RewardResult,
Room,
RoomSeat,
@@ -42,6 +51,10 @@ import {
UserProfile,
} from "./types";
/** Max friend requests a player may send within a rolling hour. */
export const FRIEND_REQ_LIMIT = 10;
export const FRIEND_REQ_WINDOW_MS = 60 * 60 * 1000;
const PERSIAN_NAMES = [
"آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا",
"الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار",
@@ -176,6 +189,10 @@ export class MockOnlineService implements OnlineService {
private profile: UserProfile | null = null;
private friends: Friend[] = [];
private requests: FriendRequest[] = [];
/** epoch-ms timestamps of friend requests this session sent (for rate limiting) */
private sentRequestTimes: number[] = [];
/** user ids we've already sent a pending request to */
private sentRequestIds = new Set<string>();
private room: Room | null = null;
private matchmaking: MatchmakingState = {
phase: "idle",
@@ -342,11 +359,7 @@ export class MockOnlineService implements OnlineService {
return this.profile;
}
async updateProfile(
patch: Partial<
Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
>
) {
async updateProfile(patch: Parameters<OnlineService["updateProfile"]>[0]) {
const p = await this.getProfile();
this.profile = { ...p, ...patch };
this.saveProfile();
@@ -370,16 +383,183 @@ export class MockOnlineService implements OnlineService {
async listRequests() {
return [...this.requests];
}
/**
* Enforce the rolling-hour cap on outgoing friend requests. Returns an error
* payload when over the limit, or null when the request may proceed (and
* records the timestamp).
*/
private rateLimitFriendRequest():
| { ok: false; messageFa: string; messageEn: string }
| null {
const now = Date.now();
this.sentRequestTimes = this.sentRequestTimes.filter((t) => now - t < FRIEND_REQ_WINDOW_MS);
if (this.sentRequestTimes.length >= FRIEND_REQ_LIMIT) {
const mins = Math.max(
1,
Math.ceil((FRIEND_REQ_WINDOW_MS - (now - this.sentRequestTimes[0])) / 60000)
);
return {
ok: false,
messageFa: `در هر ساعت حداکثر ${faNum(FRIEND_REQ_LIMIT)} درخواست دوستی می‌توانید بفرستید. ${faNum(mins)} دقیقه دیگر تلاش کنید.`,
messageEn: `You can send at most ${FRIEND_REQ_LIMIT} friend requests per hour. Try again in ${mins} min.`,
};
}
this.sentRequestTimes.push(now);
return null;
}
async addFriend(query: string) {
if (!query.trim()) {
return { ok: false, messageFa: "نام یا شماره را وارد کنید", messageEn: "Enter a name or number" };
}
const limited = this.rateLimitFriendRequest();
if (limited) return limited;
const f = makeFriend("offline");
f.displayName = query.trim().startsWith("0") ? pick(PERSIAN_NAMES) : query.trim();
this.friends = [f, ...this.friends];
this.emitFriends();
return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" };
}
async addFriendById(userId: string) {
if (this.friends.some((f) => f.id === userId)) {
return { ok: false, messageFa: "از قبل دوست شماست", messageEn: "Already your friend" };
}
if (this.sentRequestIds.has(userId)) {
return { ok: false, messageFa: "درخواست قبلاً ارسال شده", messageEn: "Request already sent" };
}
const limited = this.rateLimitFriendRequest();
if (limited) return limited;
this.sentRequestIds.add(userId);
return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" };
}
async getPublicProfile(userId: string): Promise<PublicProfile> {
// Viewing yourself → expose your own data.
if (this.profile && userId === this.profile.id) {
const p = this.profile;
return {
id: p.id,
displayName: p.displayName,
avatar: p.avatar,
avatarImage: p.avatarImage,
plan: p.plan,
title: p.title,
level: p.level,
rating: p.rating,
stats: p.stats,
achievements: p.achievements,
unlocked: p.unlocked,
createdAt: p.createdAt,
gender: p.gender ?? "",
socials: p.socials, // always visible to yourself
isFriend: false,
isYou: true,
requestSent: false,
};
}
const friend = this.friends.find((f) => f.id === userId);
// Deterministic pseudo-stats seeded from the id so a player looks consistent.
let seed = 0;
for (let i = 0; i < userId.length; i++) seed = (seed * 31 + userId.charCodeAt(i)) >>> 0;
const rng = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff);
const games = 40 + Math.floor(rng() * 700);
const wins = Math.floor(games * (0.4 + rng() * 0.3));
const stats: PlayerStats = {
games,
wins,
losses: games - wins,
kotsFor: Math.floor(wins * (0.2 + rng() * 0.3)),
kotsAgainst: Math.floor((games - wins) * (0.1 + rng() * 0.2)),
tricks: Math.floor(games * (3 + rng() * 4)),
bestWinStreak: 2 + Math.floor(rng() * 12),
currentWinStreak: Math.floor(rng() * 4),
shutoutWins: Math.floor(rng() * 8),
hakemRounds: Math.floor(games * (0.6 + rng())),
roundsWon: Math.floor(games * (1.5 + rng() * 1.5)),
};
const level = friend?.level ?? 1 + Math.floor(rng() * 60);
const rating = friend?.rating ?? 1000 + Math.floor(rng() * 1100);
// A plausible unlocked subset from the metric-driven achievement defs.
const unlocked = ACHIEVEMENTS.filter(
(a) => achievementProgress(a, stats, rating, level) >= a.goal
).map((a) => a.id);
// Synthesized gender + socials with a synthesized visibility setting.
const gender = (["male", "female", "male", "female", "other", ""] as Gender[])[Math.floor(rng() * 6)];
const isFriend = !!friend;
const vis: SocialVisibility = rng() > 0.66 ? "public" : rng() > 0.5 ? "friends" : "hidden";
const handle = (friend?.displayName ?? "player").replace(/\s+/g, "_").toLowerCase();
const sampleSocials: SocialLinks = { instagram: handle };
const canSeeSocials = vis === "public" || (vis === "friends" && isFriend);
return {
id: userId,
displayName: friend?.displayName ?? pick(PERSIAN_NAMES),
avatar: friend?.avatar ?? pick(AVATARS).id,
plan: rng() > 0.7 ? "pro" : "free",
title: null,
level,
rating,
stats,
achievements: {},
unlocked,
createdAt: Date.now() - Math.floor(rng() * 300) * 864e5,
gender,
socials: canSeeSocials ? sampleSocials : undefined,
isFriend,
isYou: false,
requestSent: this.sentRequestIds.has(userId),
};
}
/** Build a discoverable player summary from a synthesized friend. */
private summaryFromFriend(f: Friend): PlayerSummary {
// Stable-ish gender + title from the id so a player looks consistent across views.
let s = 0;
for (let i = 0; i < f.id.length; i++) s = (s * 31 + f.id.charCodeAt(i)) >>> 0;
const gender = (["male", "female", "male", "female", "other", ""] as Gender[])[s % 6];
const titlePool = ["winner", "expert", "kot_master", "vip", "maestro", "captain", null, null];
const title = titlePool[s % titlePool.length];
return {
id: f.id,
displayName: f.displayName,
avatar: f.avatar,
level: f.level,
rating: f.rating,
status: f.status,
gender,
title,
isFriend: this.friends.some((x) => x.id === f.id),
requestSent: this.sentRequestIds.has(f.id),
};
}
async searchPlayers(query: string): Promise<PlayerSummary[]> {
const q = query.trim();
if (!q) return [];
// Synthesize a handful of "matching" players; the first echoes the query.
const n = 6;
const out: PlayerSummary[] = [];
for (let i = 0; i < n; i++) {
const f = makeFriend(pick<PresenceStatus>(["online", "offline", "in-game", "online"]));
f.displayName = i === 0 && !q.startsWith("0") ? q : `${pick(PERSIAN_NAMES)} ${randInt(1, 99)}`;
out.push(this.summaryFromFriend(f));
}
return out;
}
async suggestedPlayers(): Promise<PlayerSummary[]> {
const me = this.profile;
const lvl = me?.level ?? 10;
return Array.from({ length: 12 }, () => {
const f = makeFriend(pick<PresenceStatus>(["online", "online", "in-game", "offline"]));
// bias suggestions toward a similar level
f.level = Math.max(1, lvl + randInt(-6, 6));
return this.summaryFromFriend(f);
});
}
async acceptRequest(id: string) {
const req = this.requests.find((r) => r.id === id);
if (req) {
@@ -698,11 +878,18 @@ export class MockOnlineService implements OnlineService {
};
this.emitMM();
const reveal = (delay: number) =>
// Wait ~15s (randomized 1218s) for "online" players to show up; whoever
// hasn't joined by then is filled with a bot when the match forms. The exact
// wait varies so it never feels robotically identical.
const searchMs = randInt(12000, 18000);
// 03 humans actually appear; the rest of the table fills with bots.
const humansFound = randInt(0, 3);
const reveal = (delay: number, isBot: boolean) =>
this.after(delay, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.players.push({
id: rid("p"),
id: rid(isBot ? "bot" : "p"),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(1, 50),
@@ -711,11 +898,16 @@ export class MockOnlineService implements OnlineService {
this.emitMM();
});
reveal(900);
reveal(1900);
reveal(2900);
// Real players trickle in across the search window…
for (let i = 0; i < humansFound; i++) {
reveal(Math.round(searchMs * (0.25 + i * 0.22)), false);
}
// …then bots fill the remaining seats just before the match forms.
for (let i = 0; i < 3 - humansFound; i++) {
reveal(searchMs - 600 + i * 120, true);
}
this.after(3500, () => {
this.after(searchMs, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.phase = "found";
this.emitMM();
@@ -787,6 +979,11 @@ export class MockOnlineService implements OnlineService {
return { ok: true, profile: this.profile, coins: added };
}
async verifyIab(_store: string, productId: string, _token: string) {
// Offline/dev: no real store to verify against — credit the matching pack.
return this.buyCoins(productId);
}
private onlineCount = 60 + Math.floor(Math.random() * 110);
async getOnlineCount(): Promise<number> {
// gentle random walk so the badge feels alive; never drops below 50
@@ -873,6 +1070,16 @@ export class MockOnlineService implements OnlineService {
descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`,
descEn: `${p.stickers.length} in-game stickers`,
}));
const titleItems: ShopItem[] = TITLES.filter((tt) => (tt.price ?? 0) > 0).map((tt) => ({
id: tt.id,
kind: "title",
nameFa: tt.nameFa,
nameEn: tt.nameEn,
price: tt.price!,
preview: "🏷️",
descFa: "عنوان نمایه که زیر نام شما در بازی و لیست‌ها نشان داده می‌شود",
descEn: "A profile title shown under your name in games & lists",
}));
const xpItems: ShopItem[] = XP_PACKS.map((x) => ({
id: x.id,
kind: "xp",
@@ -884,7 +1091,7 @@ export class MockOnlineService implements OnlineService {
descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه می‌شود`,
descEn: `${x.xp} XP added to your account instantly`,
}));
return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems];
return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...titleItems, ...xpItems];
}
async buyItem(id: string) {
@@ -912,6 +1119,7 @@ export class MockOnlineService implements OnlineService {
cardback: p.ownedCardBacks,
reactionpack: p.ownedReactionPacks,
stickerpack: p.ownedStickerPacks,
title: p.ownedTitles,
};
if (ownedMap[item.kind]?.includes(id))
return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" };
@@ -930,6 +1138,7 @@ export class MockOnlineService implements OnlineService {
item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks,
ownedStickerPacks:
item.kind === "stickerpack" ? [...p.ownedStickerPacks, id] : p.ownedStickerPacks,
ownedTitles: item.kind === "title" ? [...p.ownedTitles, id] : p.ownedTitles,
};
this.saveProfile();
return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" };
+21 -1
View File
@@ -14,6 +14,8 @@ import {
Friend,
FriendRequest,
LeaderboardEntry,
PlayerSummary,
PublicProfile,
MatchSummary,
MatchmakingState,
RewardResult,
@@ -51,7 +53,11 @@ export interface OnlineService {
getProfile(): Promise<UserProfile>;
updateProfile(
patch: Partial<
Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
Pick<
UserProfile,
| "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack"
| "gender" | "socials" | "socialsVisibility"
>
>
): Promise<UserProfile>;
upgradePlan(): Promise<UserProfile>;
@@ -60,6 +66,14 @@ export interface OnlineService {
listFriends(): Promise<Friend[]>;
listRequests(): Promise<FriendRequest[]>;
addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
/** Send a friend request to a specific user id (from a profile/leaderboard tap). */
addFriendById(userId: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
/** Fetch another player's public profile + achievement board. */
getPublicProfile(userId: string): Promise<PublicProfile>;
/** Search players by display name (for the "find friends" discovery tab). */
searchPlayers(query: string): Promise<PlayerSummary[]>;
/** Suggested players to befriend (online / not-yet-friends). */
suggestedPlayers(): Promise<PlayerSummary[]>;
acceptRequest(id: string): Promise<void>;
declineRequest(id: string): Promise<void>;
removeFriend(id: string): Promise<void>;
@@ -129,6 +143,12 @@ export interface OnlineService {
getCoinPacks(): Promise<CoinPack[]>;
/** Mock credits instantly; live returns a `redirectUrl` to the ZarinPal gateway. */
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number; redirectUrl?: string }>;
/** Verify a store (Cafe Bazaar / Myket) purchase token and credit the pack. */
verifyIab(
store: string,
productId: string,
token: string
): Promise<{ ok: boolean; profile?: UserProfile; coins: number }>;
}
import { MockOnlineService } from "./mock-service";
+25
View File
@@ -20,6 +20,8 @@ import {
Friend,
FriendRequest,
LeaderboardEntry,
PlayerSummary,
PublicProfile,
MatchSummary,
MatchmakingState,
RewardResult,
@@ -359,6 +361,19 @@ export class SignalrService implements OnlineService {
return this.send<{ ok: boolean; messageFa: string; messageEn: string }>(
"POST", "/api/friends/add", { query: q });
}
addFriendById(userId: string) {
return this.send<{ ok: boolean; messageFa: string; messageEn: string }>(
"POST", "/api/friends/add", { userId });
}
getPublicProfile(userId: string): Promise<PublicProfile> {
return this.getJson<PublicProfile>(`/api/profile/${encodeURIComponent(userId)}/public`);
}
searchPlayers(query: string): Promise<PlayerSummary[]> {
return this.getJson<PlayerSummary[]>(`/api/players/search?q=${encodeURIComponent(query)}`);
}
suggestedPlayers(): Promise<PlayerSummary[]> {
return this.getJson<PlayerSummary[]>("/api/players/suggested");
}
async acceptRequest(id: string) { await this.send<unknown>("POST", "/api/friends/accept", { id }); }
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
async removeFriend(id: string) { await this.send<unknown>("POST", "/api/friends/remove", { id }); }
@@ -436,4 +451,14 @@ export class SignalrService implements OnlineService {
"POST", "/api/coins/pay/request", { packId: id });
return { ok: r.ok, coins: 0, redirectUrl: r.url };
}
async verifyIab(store: string, productId: string, token: string) {
try {
const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>(
"POST", "/api/coins/iab/verify", { store, productId, token });
if (r.profile) this.cachedProfile = r.profile;
return { ok: r.ok, profile: r.profile, coins: r.coins ?? 0 };
} catch {
return { ok: false, coins: 0 };
}
}
}
+95
View File
@@ -33,6 +33,20 @@ export interface PlayerStats {
export type PlanId = "free" | "pro";
/** Player-stated gender (empty = unspecified / not shown). */
export type Gender = "" | "male" | "female" | "other";
/** Who may see a player's social links. */
export type SocialVisibility = "public" | "friends" | "hidden";
/** Optional social-media handles/links a player chooses to share. */
export interface SocialLinks {
instagram?: string;
telegram?: string;
x?: string;
youtube?: string;
}
export interface UserProfile {
id: string;
username: string;
@@ -67,9 +81,43 @@ export interface UserProfile {
achievements: Record<string, number>; // achievementId -> progress count
unlocked: string[]; // achievementId list already unlocked
// social
gender?: Gender;
socials?: SocialLinks;
socialsVisibility?: SocialVisibility; // default "public"
createdAt: number;
}
/**
* A public-facing view of another player — what you may see by tapping their
* row in the leaderboard / friends list. No private fields (coins, phone,
* email). Achievements/stats are exposed so others can see their board.
*/
export interface PublicProfile {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
plan: PlanId;
title: string | null;
level: number;
rating: number;
stats: PlayerStats;
achievements: Record<string, number>;
unlocked: string[];
createdAt: number;
gender?: Gender;
/** Only present when the viewer is allowed to see them (public / friend). */
socials?: SocialLinks;
/** is this player already your friend? */
isFriend: boolean;
/** is this you? */
isYou: boolean;
/** have you already sent them a pending friend request? */
requestSent: boolean;
}
/* ------------------------------- Ranks ------------------------------- */
export type RankTierId =
@@ -174,8 +222,23 @@ export interface TitleDef {
/** how it's unlocked (for display) */
hintFa: string;
hintEn: string;
/** >0 = purchasable in the shop (otherwise unlocked via stats/rank) */
price?: number;
}
/** Distinct visual pattern families for card backs (see lib/cardBack.ts). */
export type CardBackPattern =
| "stripes"
| "argyle"
| "grid"
| "dots"
| "rays"
| "scales"
| "crosshatch"
| "royal"
| "filigree"
| "gem";
export interface CardBackDef {
id: string;
nameFa: string;
@@ -184,6 +247,10 @@ export interface CardBackDef {
c2: string; // back gradient end
accent: string; // pattern/border accent
price: number; // >0 = purchasable
/** visual pattern (default "stripes"); luxury backs use fancier ones */
pattern?: CardBackPattern;
/** optional centered emblem glyph (luxury backs) */
motif?: string;
default?: boolean;
unlockRating?: number;
unlockWins?: number;
@@ -381,6 +448,7 @@ export type ShopItemKind =
| "cardback"
| "reactionpack"
| "stickerpack"
| "title"
| "xp";
export interface ShopItem {
@@ -407,6 +475,8 @@ export interface CoinPack {
bonus: number; // extra coins
priceToman: number;
tag?: "popular" | "best" | "starter";
/** store product id (Bazaar/Myket SKU). Defaults to `id` when omitted. */
sku?: string;
}
/* --------------------------- Daily reward ---------------------------- */
@@ -435,6 +505,24 @@ export interface Conversation {
unread: number;
}
/** A discoverable player (search results / suggestions in the social hub). */
export interface PlayerSummary {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
level: number;
rating: number;
status: PresenceStatus;
gender?: Gender;
/** equipped title id (shown under the name) */
title?: string | null;
/** already your friend? */
isFriend: boolean;
/** you've already sent them a pending request? */
requestSent: boolean;
}
/* ------------------- Server (SignalR) game state -------------------- */
export interface ServerCard { suit: string; rank: number; id: string }
@@ -532,6 +620,12 @@ export const AVATARS: AvatarDef[] = [
{ id: "a-dragon", emoji: "🐲", price: 1500 },
{ id: "a-unicorn", emoji: "🦄", price: 1500 },
{ id: "a-peacock", emoji: "🦚", price: 2000 },
// ✨ Luxury avatars — premium, high-price collectibles
{ id: "a-swan", emoji: "🦢", price: 1800 },
{ id: "a-tophat", emoji: "🎩", price: 2200 },
{ id: "a-diamond", emoji: "💎", price: 3000 },
{ id: "a-moneybag", emoji: "💰", price: 3500 },
{ id: "a-trophy", emoji: "🏆", price: 4000 },
// earned by rank / wins — the rarer faces sit behind higher ranks
{ id: "a-robot", emoji: "🤖", unlockWins: 50 },
{ id: "a-wizard", emoji: "🧙", unlockRating: 1300 },
@@ -539,6 +633,7 @@ export const AVATARS: AvatarDef[] = [
{ id: "a-king", emoji: "🤴", unlockRating: 1500 },
{ id: "a-genie", emoji: "🧞", unlockRating: 1700 },
{ id: "a-crown", emoji: "👑", unlockRating: 1900 },
{ id: "a-gem", emoji: "💠", unlockRating: 2100 },
];
export function avatarEmoji(id: string): string {