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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 12–18s) 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);
|
||||
// 0–3 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" };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user