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
+87
View File
@@ -0,0 +1,87 @@
import { CardBackDef, CardBackPattern } from "./online/types";
export interface BackVisual {
background: string;
backgroundSize?: string;
}
/**
* Build the CSS background for a card back. Each pattern family looks visibly
* different (not just recoloured) — luxury backs use the fancier ones.
*/
export function cardBackVisual(
c1: string,
c2: string,
accent: string,
pattern: CardBackPattern = "stripes"
): BackVisual {
const base = `linear-gradient(160deg, ${c1}, ${c2})`;
switch (pattern) {
case "argyle":
return {
background:
`repeating-linear-gradient(45deg, ${accent}3a 0 9px, transparent 9px 18px),` +
`repeating-linear-gradient(-45deg, ${accent}3a 0 9px, transparent 9px 18px), ${base}`,
};
case "grid":
return {
background:
`repeating-linear-gradient(0deg, ${accent}38 0 1.5px, transparent 1.5px 11px),` +
`repeating-linear-gradient(90deg, ${accent}38 0 1.5px, transparent 1.5px 11px), ${base}`,
};
case "dots":
return {
background: `radial-gradient(${accent}66 1.6px, transparent 2px), ${base}`,
backgroundSize: "10px 10px, 100% 100%",
};
case "rays":
return {
background: `repeating-conic-gradient(from 0deg at 50% 50%, ${accent}26 0deg 9deg, transparent 9deg 18deg), ${base}`,
};
case "scales":
return {
background:
`radial-gradient(circle at 50% 100%, transparent 5px, ${accent}40 5.5px 6.5px, transparent 7px), ${base}`,
backgroundSize: "13px 9px, 100% 100%",
};
case "crosshatch":
return {
background:
`repeating-linear-gradient(45deg, ${accent}30 0 2px, transparent 2px 7px),` +
`repeating-linear-gradient(-45deg, ${accent}30 0 2px, transparent 2px 7px), ${base}`,
};
case "royal":
return {
background:
`repeating-linear-gradient(0deg, ${accent}22 0 1px, transparent 1px 9px),` +
`repeating-linear-gradient(90deg, ${accent}22 0 1px, transparent 1px 9px),` +
`radial-gradient(circle at 50% 42%, ${accent}38, transparent 58%), ${base}`,
};
case "filigree":
return {
background:
`repeating-linear-gradient(45deg, ${accent}26 0 4px, transparent 4px 10px),` +
`radial-gradient(circle at 50% 50%, ${accent}30, transparent 62%), ${base}`,
};
case "gem":
return {
background:
`repeating-linear-gradient(60deg, ${accent}33 0 6px, transparent 6px 13px),` +
`repeating-linear-gradient(-60deg, ${accent}33 0 6px, transparent 6px 13px), ${base}`,
};
case "stripes":
default:
return { background: `repeating-linear-gradient(45deg, ${accent}44 0 6px, transparent 6px 12px), ${base}` };
}
}
/** Centered emblem glyph for a back (only the fancy ones carry one). */
export function cardBackMotif(pattern: CardBackPattern | undefined, motif: string | undefined): string {
if (motif) return motif;
return pattern == null || pattern === "stripes" ? "✦" : "";
}
/** Convenience: full visual from a CardBackDef. */
export function backVisualFromDef(b: CardBackDef): BackVisual {
return cardBackVisual(b.c1, b.c2, b.accent, b.pattern);
}
+42 -21
View File
@@ -14,6 +14,8 @@ import {
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types";
import type { OnlineService } from "./online/service";
import { turnMsForStake } from "./online/gamification";
import { useSessionStore } from "./session-store";
import { sound } from "./sound";
const KOT_POINTS = 2;
@@ -27,8 +29,9 @@ export const TIMING = {
roundPause: 2600,
} as const;
/** How long a player has to act before the system plays for them. */
export const TURN_MS = 20000;
/** Base turn time (starter league / vs-AI). Higher leagues use less — see
* `turnMsForStake`. Kept for reference; scheduling derives the real value. */
export const TURN_MS = 15000;
/** Grace period to wait for a disconnected player to return. */
export const RECONNECT_MS = 15000;
/** Per-turn chance an online opponent briefly drops (mock). */
@@ -42,11 +45,14 @@ export interface SeatPlayer {
level: number;
id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean;
title?: string | null; // equipped title id (shown under the avatar on the table)
}
export interface GameSettings {
names: [string, string, string, string];
targetScore: number;
/** Blitz/speed mode — fast turn clock + snappier pacing. */
speed?: boolean;
}
export interface OnlineMatchConfig {
@@ -54,6 +60,7 @@ export interface OnlineMatchConfig {
targetScore: number;
stake: number;
ranked: boolean;
speed?: boolean;
}
interface MatchTally {
@@ -68,7 +75,7 @@ interface GameStore {
started: boolean;
mode: GameMode;
seatPlayers: SeatPlayer[];
matchMeta: { ranked: boolean; stake: number };
matchMeta: { ranked: boolean; stake: number; speed: boolean };
tally: MatchTally;
/** epoch ms by which the current actor must act (for the turn-timer UI). */
@@ -198,6 +205,8 @@ export const useGameStore = create<GameStore>((set, get) => {
function scheduleAuto() {
clearPending();
const g = get().game;
// Speed mode → snappier pacing (animations/pauses run ~half time).
const fast = (ms: number) => (get().matchMeta.speed ? Math.round(ms * 0.5) : ms);
switch (g.phase) {
case "selecting-hakem":
@@ -206,7 +215,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({ game: dealForTrump(get().game) });
sound.play("deal");
scheduleAuto();
}, TIMING.hakemDraw);
}, fast(TIMING.hakemDraw));
break;
case "choosing-trump": {
@@ -217,15 +226,16 @@ export const useGameStore = create<GameStore>((set, get) => {
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 });
// human hakem: timed choice (less time in higher leagues), system auto-picks on timeout
const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed);
set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null });
pending = setTimeout(() => {
const cur = get().game;
if (cur.phase !== "choosing-trump") return;
const suit = chooseTrumpAI(cur.players[cur.hakem!].hand);
set({ game: engineChooseTrump(cur, suit), turnDeadline: null });
scheduleAuto();
}, TURN_MS);
}, turnMs);
} else {
set({ turnDeadline: null });
pending = setTimeout(() => {
@@ -234,7 +244,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({ game: engineChooseTrump(cur, suit) });
sound.play("trump");
scheduleAuto();
}, TIMING.aiTrump);
}, fast(TIMING.aiTrump));
}
break;
}
@@ -242,15 +252,16 @@ export const useGameStore = create<GameStore>((set, get) => {
case "playing": {
const seat = g.turn!;
if (g.players[seat].isHuman) {
// human turn: timed; system plays a smart legal move on timeout
set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null });
// human turn: timed (less time in higher leagues); system plays a smart legal move on timeout
const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed);
set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null });
pending = setTimeout(() => {
const cur = get().game;
if (cur.phase !== "playing" || cur.turn !== seat) return;
set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null });
sound.play("cardPlay");
scheduleAuto();
}, TURN_MS);
}, turnMs);
} else {
const st = get();
if (
@@ -271,7 +282,7 @@ export const useGameStore = create<GameStore>((set, get) => {
}, back);
} else {
set({ turnDeadline: null });
pending = setTimeout(() => playSeatAI(seat), TIMING.aiPlay);
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
}
}
break;
@@ -290,7 +301,7 @@ export const useGameStore = create<GameStore>((set, get) => {
sound.play("kot");
}
scheduleAuto();
}, TIMING.trickPause);
}, fast(TIMING.trickPause));
break;
case "round-over":
@@ -299,7 +310,7 @@ export const useGameStore = create<GameStore>((set, get) => {
recordRound(get().game.lastRoundResult);
set({ game: startNextRound(get().game) });
scheduleAuto();
}, TIMING.roundPause);
}, fast(TIMING.roundPause));
break;
default:
@@ -313,7 +324,7 @@ export const useGameStore = create<GameStore>((set, get) => {
started: false,
mode: "ai",
seatPlayers: [],
matchMeta: { ranked: false, stake: 0 },
matchMeta: { ranked: false, stake: 0, speed: false },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -336,7 +347,7 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: false, stake: 0 },
matchMeta: { ranked: false, stake: 0, speed: !!settings.speed },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -346,6 +357,7 @@ export const useGameStore = create<GameStore>((set, get) => {
avatar: AI_AVATARS[i],
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
});
scheduleAuto();
@@ -364,15 +376,16 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: cfg.players.map((p) => ({
seatPlayers: cfg.players.map((p, i) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
});
scheduleAuto();
@@ -397,7 +410,7 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: true, stake: 0 },
matchMeta: { ranked: true, stake: 0, speed: false },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -409,9 +422,17 @@ export const useGameStore = create<GameStore>((set, get) => {
applyServerState: (s) => {
const prev = get().game;
const next = mapServerState(s);
const me = useSessionStore.getState().profile;
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
.sort((a, b) => a.seat - b.seat)
.map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level, id: sp.userId, isBot: sp.isBot }));
.map((sp) => ({
name: sp.name,
avatar: avatarEmoji(sp.avatar),
level: sp.level,
id: sp.userId,
isBot: sp.isBot,
title: sp.userId && me && sp.userId === me.id ? me.title ?? null : null,
}));
// accumulate the reward tally when the match score grows (a round ended)
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
@@ -431,7 +452,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({
game: next,
seatPlayers,
matchMeta: { ranked: s.ranked, stake: s.stake },
matchMeta: { ranked: s.ranked, stake: s.stake, speed: false },
turnDeadline: s.turnDeadline ?? null,
disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null,
reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null,
+102
View File
@@ -74,6 +74,14 @@ const fa: Dict = {
"turn.you": "نوبت شماست",
"turn.other": "نوبت {name}",
"keys.title": "میان‌برهای صفحه‌کلید",
"keys.play": "بازی کردن کارت",
"keys.first": "اولین کارت مجاز",
"keys.trump": "انتخاب حکم",
"keys.mute": "قطع صدا",
"keys.forfeit": "تسلیم",
"keys.quit": "خروج",
"trick.wins": "{name} دست را برد",
"round.over": "پایان دست",
@@ -95,6 +103,9 @@ const fa: Dict = {
"menu.vsComputer": "بازی با کامپیوتر",
"menu.vsComputerDesc": "تمرین با حریف‌های هوشمند",
"speed.label": "سریع",
"speed.normal": "عادی",
"speed.desc": "حالت سریع: نوبت‌های کوتاه‌تر و بازی برق‌آسا",
"menu.online": "بازی آنلاین",
"menu.onlineDesc": "با دوستان یا بازیکن‌های واقعی",
"menu.profile": "پروفایل",
@@ -123,6 +134,8 @@ const fa: Dict = {
"buy.note": "پرداخت امن — درگاه پرداخت ایرانی به‌زودی اضافه می‌شود",
"buy.toman": "تومان",
"buy.bonus": "هدیه",
"buy.redirecting": "صفحهٔ پرداخت در تب جدید باز شد. پس از پرداخت، سکه‌ها به‌صورت خودکار اضافه می‌شوند.",
"buy.failed": "پرداخت در دسترس نیست. بعداً دوباره تلاش کنید.",
"buy.popular": "محبوب",
"buy.best": "بهترین",
"buy.starter": "شروع",
@@ -146,6 +159,10 @@ const fa: Dict = {
"profile.kots": "کُت‌ها",
"profile.streak": "بهترین نوار",
"profile.achievements": "دستاوردها",
"profile.sendRequest": "افزودن دوست",
"profile.requestSent": "درخواست ارسال شد",
"profile.alreadyFriend": "دوست شماست",
"profile.memberSince": "عضو از",
"profile.editName": "ویرایش نام",
"profile.chooseAvatar": "انتخاب آواتار",
@@ -162,6 +179,23 @@ const fa: Dict = {
"friends.remove": "حذف",
"friends.empty": "هنوز دوستی ندارید",
"social.title": "اجتماعی",
"social.tabFriends": "دوستان",
"social.tabDiscover": "یافتن",
"social.tabMessages": "پیام‌ها",
"discover.searchPlaceholder": "جستجوی بازیکن با نام…",
"discover.results": "نتایج جستجو",
"discover.suggested": "بازیکنان پیشنهادی",
"discover.noResults": "بازیکنی پیدا نشد",
"discover.friend": "دوست",
"messages.empty": "هنوز گفتگویی ندارید",
"messages.you": "شما",
"time.now": "همین حالا",
"time.min": "{n} دقیقه پیش",
"time.hour": "{n} ساعت پیش",
"time.day": "{n} روز پیش",
"common.retry": "تلاش دوباره",
"lobby.title": "بازی آنلاین",
"lobby.createRoom": "ساخت اتاق خصوصی",
"lobby.createDesc": "هم‌تیمی و حریف‌ها را خودتان انتخاب کنید",
@@ -186,6 +220,7 @@ const fa: Dict = {
"mm.searching": "در حال یافتن حریف…",
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، ربات‌ها جایگزین می‌شوند",
"mm.cancel": "لغو",
"mm.start": "ورود به بازی",
@@ -195,6 +230,7 @@ const fa: Dict = {
"shop.title": "فروشگاه",
"shop.buy": "خرید",
"shop.owned": "موجود",
"shop.luxury": "ویژه",
"shop.avatars": "آواتارها",
"shop.themes": "تم‌ها",
"shop.notEnough": "سکه کافی نیست",
@@ -229,6 +265,7 @@ const fa: Dict = {
"reward.promoted": "ارتقای لیگ!",
"reward.demoted": "سقوط لیگ",
"reward.newAchievement": "دستاورد جدید",
"reward.stickerUnlocked": "بستهٔ استیکر باز شد",
"reward.continue": "ادامه",
"reward.win": "بردید! 🏆",
"reward.lose": "باختید",
@@ -238,6 +275,7 @@ const fa: Dict = {
"daily.claim": "دریافت",
"daily.claimed": "دریافت شد",
"daily.come": "فردا برگردید",
"daily.special": "پاداش ویژه",
"rank.label": "لیگ",
@@ -264,6 +302,8 @@ const fa: Dict = {
"shop.cardstyles": "طرح کارت‌ها",
"shop.reactions": "بسته شکلک‌ها",
"shop.stickers": "بسته استیکرها",
"shop.titles": "عناوین",
"shop.titlesHint": "عنوان شما زیر نامتان در بازی و لیست‌ها نمایش داده می‌شود",
"shop.xp": "امتیاز تجربه (XP)",
"shop.xpHint": "افزایش سریع سطح — XP گران است",
"shop.includes": "شامل",
@@ -281,6 +321,17 @@ const fa: Dict = {
"profile.cardFront": "روی کارت",
"profile.cardBack": "پشت کارت",
"profile.social": "اجتماعی و ارتباط",
"profile.gender": "جنسیت",
"profile.genderNone": "نامشخص",
"profile.socialLinks": "شبکه‌های اجتماعی",
"profile.socialsVisibility": "نمایش شبکه‌ها به",
"profile.visPublic": "همه",
"profile.visFriends": "دوستان",
"profile.visHidden": "هیچ‌کس",
"profile.socialsHint": "می‌توانید نمایش پیج‌هایتان را عمومی، فقط برای دوستان یا غیرفعال کنید.",
"profile.saveLinks": "ذخیره شبکه‌ها",
"profile.saved": "ذخیره شد",
"shop.cardfronts": "روی کارت‌ها",
"shop.cardbacks": "پشت کارت‌ها",
};
@@ -346,6 +397,14 @@ const en: Dict = {
"turn.you": "Your turn",
"turn.other": "{name}'s turn",
"keys.title": "Keyboard shortcuts",
"keys.play": "Play a card",
"keys.first": "First legal card",
"keys.trump": "Choose trump",
"keys.mute": "Mute",
"keys.forfeit": "Forfeit",
"keys.quit": "Quit",
"trick.wins": "{name} wins the trick",
"round.over": "Round over",
@@ -367,6 +426,9 @@ const en: Dict = {
"menu.vsComputer": "Play vs Computer",
"menu.vsComputerDesc": "Practice against smart bots",
"speed.label": "Speed",
"speed.normal": "Normal",
"speed.desc": "Blitz mode: short turns, lightning-fast match",
"menu.online": "Play Online",
"menu.onlineDesc": "With friends or real players",
"menu.profile": "Profile",
@@ -395,6 +457,8 @@ const en: Dict = {
"buy.note": "Secure payment — Iranian gateway coming soon",
"buy.toman": "Toman",
"buy.bonus": "bonus",
"buy.redirecting": "Payment opened in a new tab. Your coins will be added automatically once you pay.",
"buy.failed": "Payment unavailable. Please try again later.",
"buy.popular": "Popular",
"buy.best": "Best value",
"buy.starter": "Starter",
@@ -418,6 +482,10 @@ const en: Dict = {
"profile.kots": "Kots",
"profile.streak": "Best streak",
"profile.achievements": "Achievements",
"profile.sendRequest": "Add friend",
"profile.requestSent": "Request sent",
"profile.alreadyFriend": "Your friend",
"profile.memberSince": "Member since",
"profile.editName": "Edit name",
"profile.chooseAvatar": "Choose avatar",
@@ -434,6 +502,23 @@ const en: Dict = {
"friends.remove": "Remove",
"friends.empty": "No friends yet",
"social.title": "Social",
"social.tabFriends": "Friends",
"social.tabDiscover": "Discover",
"social.tabMessages": "Messages",
"discover.searchPlaceholder": "Search players by name…",
"discover.results": "Search results",
"discover.suggested": "Suggested players",
"discover.noResults": "No players found",
"discover.friend": "Friend",
"messages.empty": "No conversations yet",
"messages.you": "You",
"time.now": "now",
"time.min": "{n}m ago",
"time.hour": "{n}h ago",
"time.day": "{n}d ago",
"common.retry": "Retry",
"lobby.title": "Play Online",
"lobby.createRoom": "Create private room",
"lobby.createDesc": "Choose your partner and opponents",
@@ -458,6 +543,7 @@ const en: Dict = {
"mm.searching": "Searching for opponents…",
"mm.found": "Players found!",
"mm.ready": "Ready to start",
"mm.fillHint": "If no online players are found, bots will fill in",
"mm.cancel": "Cancel",
"mm.start": "Enter game",
@@ -467,6 +553,7 @@ const en: Dict = {
"shop.title": "Shop",
"shop.buy": "Buy",
"shop.owned": "Owned",
"shop.luxury": "Luxury",
"shop.avatars": "Avatars",
"shop.themes": "Themes",
"shop.notEnough": "Not enough coins",
@@ -501,6 +588,7 @@ const en: Dict = {
"reward.promoted": "Promoted!",
"reward.demoted": "Demoted",
"reward.newAchievement": "New achievement",
"reward.stickerUnlocked": "Sticker pack unlocked",
"reward.continue": "Continue",
"reward.win": "You won! 🏆",
"reward.lose": "You lost",
@@ -510,6 +598,7 @@ const en: Dict = {
"daily.claim": "Claim",
"daily.claimed": "Claimed",
"daily.come": "Come back tomorrow",
"daily.special": "Special Reward",
"rank.label": "League",
@@ -536,6 +625,8 @@ const en: Dict = {
"shop.cardstyles": "Card styles",
"shop.reactions": "Reaction packs",
"shop.stickers": "Sticker packs",
"shop.titles": "Titles",
"shop.titlesHint": "Your title shows under your name in games & lists",
"shop.xp": "XP packs",
"shop.xpHint": "Level up faster — XP is expensive",
"shop.includes": "Includes",
@@ -553,6 +644,17 @@ const en: Dict = {
"profile.cardFront": "Card front",
"profile.cardBack": "Card back",
"profile.social": "Social & contact",
"profile.gender": "Gender",
"profile.genderNone": "Unspecified",
"profile.socialLinks": "Social media",
"profile.socialsVisibility": "Show socials to",
"profile.visPublic": "Everyone",
"profile.visFriends": "Friends",
"profile.visHidden": "Nobody",
"profile.socialsHint": "Choose who can see your social pages: everyone, only friends, or nobody.",
"profile.saveLinks": "Save links",
"profile.saved": "Saved",
"shop.cardfronts": "Card fronts",
"shop.cardbacks": "Card backs",
};
+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 {
+7 -1
View File
@@ -22,7 +22,13 @@ interface SessionStore {
signOut: () => Promise<void>;
updateProfile: (
patch: Partial<Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">>
patch: Partial<
Pick<
UserProfile,
| "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack"
| "gender" | "socials" | "socialsVisibility"
>
>
) => Promise<void>;
upgradePlan: () => Promise<void>;
}
+36
View File
@@ -0,0 +1,36 @@
import { Gender, SocialLinks } from "./online/types";
/** Display metadata for each gender (symbol + colour + localized label). */
export const GENDER_META: Record<
Exclude<Gender, "">,
{ symbol: string; color: string; faLabel: string; enLabel: string }
> = {
male: { symbol: "♂", color: "#5aa6e0", faLabel: "آقا", enLabel: "Male" },
female: { symbol: "♀", color: "#ff7aa8", faLabel: "خانم", enLabel: "Female" },
other: { symbol: "⚧", color: "#c77dff", faLabel: "دیگر", enLabel: "Other" },
};
/** The social platforms a player can share, with link prefixes + brand colours. */
export const SOCIAL_PLATFORMS = [
{ key: "instagram", label: "Instagram", icon: "📸", color: "#E1306C", prefix: "https://instagram.com/" },
{ key: "telegram", label: "Telegram", icon: "✈️", color: "#229ED9", prefix: "https://t.me/" },
{ key: "x", label: "X", icon: "𝕏", color: "#cfd2d6", prefix: "https://x.com/" },
{ key: "youtube", label: "YouTube", icon: "▶️", color: "#FF4444", prefix: "https://youtube.com/@" },
] as const;
export type SocialKey = (typeof SOCIAL_PLATFORMS)[number]["key"];
/** Build a tappable URL from a handle or full URL. */
export function socialUrl(key: string, value: string): string {
const v = value.trim();
if (/^https?:\/\//i.test(v)) return v;
const handle = v.replace(/^@+/, "");
const p = SOCIAL_PLATFORMS.find((x) => x.key === key);
return p ? p.prefix + handle : v;
}
/** Does the player have at least one non-empty social link? */
export function hasSocials(s?: SocialLinks | null): boolean {
if (!s) return false;
return SOCIAL_PLATFORMS.some((p) => (s[p.key] ?? "").trim().length > 0);
}
+102
View File
@@ -0,0 +1,102 @@
// Store in-app billing (Cafe Bazaar / Myket) for coin packs.
//
// - **Bazaar** (embedded PWA): pure deep-link flow. We navigate to
// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and
// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU
// first, then on return POST the token to `/api/coins/iab/verify`.
// - **Myket**: native AIDL billing. A Capacitor plugin must inject
// `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the
// returned token to verify. Without the bridge, Myket is "unavailable".
//
// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web),
// overridden at runtime if the Myket native bridge is present.
import { CoinPack } from "./online/types";
export type StoreId = "bazaar" | "myket" | "web";
const ENV_STORE = ((process.env.NEXT_PUBLIC_STORE as StoreId | undefined) ?? "web");
const PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.hokm";
const PENDING_SKU_KEY = "iab_pending_sku";
/** Native bridge contract a Myket Capacitor plugin must fulfil. */
interface MyketBridge {
available?: boolean;
purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>;
consume?: (token: string) => Promise<void>;
}
declare global {
interface Window {
MyketBilling?: MyketBridge;
}
}
export function getStore(): StoreId {
if (typeof window !== "undefined" && window.MyketBilling?.available) return "myket";
return ENV_STORE;
}
/** True when coin purchases should go through a store (not the web ZarinPal gateway). */
export function isStoreBilling(): boolean {
return getStore() !== "web";
}
function skuFor(pack: CoinPack): string {
return pack.sku ?? pack.id;
}
export type PurchaseStart =
| { kind: "redirect" } // Bazaar — the app navigated away; result arrives on return
| { kind: "token"; store: StoreId; productId: string; token: string } // Myket — verify now
| { kind: "unavailable" };
/** Begin a store purchase for a coin pack. */
export async function purchaseViaStore(pack: CoinPack): Promise<PurchaseStart> {
const store = getStore();
const sku = skuFor(pack);
if (store === "bazaar") {
try {
localStorage.setItem(PENDING_SKU_KEY, sku);
} catch {
/* ignore storage errors */
}
const redirect = encodeURIComponent(window.location.origin + window.location.pathname);
window.location.href =
`bazaar://in_app?package_name=${encodeURIComponent(PACKAGE)}` +
`&sku=${encodeURIComponent(sku)}&redirect_url=${redirect}`;
return { kind: "redirect" };
}
if (store === "myket" && window.MyketBilling) {
const res = await window.MyketBilling.purchase(sku);
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
}
return { kind: "unavailable" };
}
/**
* On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the
* pending purchase to verify, or null. Also clears the stashed SKU.
*/
export function captureBazaarRedirect(): { store: StoreId; productId: string; token: string } | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const token = params.get("purchaseToken");
if (!token) return null;
let productId = params.get("sku") ?? params.get("productId") ?? "";
if (!productId) {
try {
productId = localStorage.getItem(PENDING_SKU_KEY) ?? "";
} catch {
/* ignore */
}
}
try {
localStorage.removeItem(PENDING_SKU_KEY);
} catch {
/* ignore */
}
return { store: "bazaar", productId, token };
}
+10
View File
@@ -52,6 +52,8 @@ interface UIStore {
/** screen to return to from the game table */
returnTo: Screen;
dailyModalOpen: boolean;
/** user id whose public profile is being viewed in a modal (null = closed) */
viewProfileId: string | null;
go: (screen: Screen) => void;
goGame: (returnTo?: Screen) => void;
@@ -64,12 +66,17 @@ interface UIStore {
openDaily: () => void;
closeDaily: () => void;
/** Open another player's public profile in an overlay modal. */
viewProfile: (userId: string) => void;
closeProfile: () => void;
}
export const useUIStore = create<UIStore>((set, get) => ({
screen: "home",
returnTo: "home",
dailyModalOpen: false,
viewProfileId: null,
go: (screen) => {
if (get().screen === screen) return;
@@ -106,4 +113,7 @@ export const useUIStore = create<UIStore>((set, get) => ({
openDaily: () => set({ dailyModalOpen: true }),
closeDaily: () => set({ dailyModalOpen: false }),
viewProfile: (userId) => set({ viewProfileId: userId }),
closeProfile: () => set({ viewProfileId: null }),
}));