// In-memory + localStorage mock implementing OnlineService. // Simulates remote players, friends presence, room invites and matchmaking // with timers, and computes rewards via gamification.ts. import { ACHIEVEMENTS, CARD_BACKS, CARD_FRONTS, CITY_REWARD, MATCH_QUEUE_WAIT_MS, REACTION_PACKS, STICKER_PACKS, TITLES, XP_PACKS, achievementProgress, addXp, applyMatchResult, dailyRewardFor, evaluateAchievements, faNum, xpNeededForLevel, } from "./gamification"; import { CreateRoomOptions, MatchmakingOptions, OnlineService, Unsubscribe, } from "./service"; import { AVATAR_ART } from "@/components/online/avatarArt"; import { AVATARS, AppNotification, AuthSession, ChatMessage, CoinPack, Conversation, DailyRewardState, Friend, FriendRequest, Gender, LeaderboardEntry, MatchSummary, MatchmakingState, PlayerStats, PlayerSummary, PresenceStatus, PublicProfile, SocialLinks, SocialVisibility, RewardResult, Room, RoomSeat, ShopItem, 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 = [ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار", "نگار", "سهراب", "بهار", "فرهاد", "یاسمن", "آرمان", "دنیا", "سینا", ]; function rid(prefix = "id"): string { return `${prefix}_${Math.random().toString(36).slice(2, 9)}`; } function pick(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } function randInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } function todayStr(): string { return new Date().toISOString().slice(0, 10); } function isBrowser(): boolean { return typeof window !== "undefined"; } const LS = { session: "hokm.session", profile: "hokm.profile", daily: "hokm.daily", chats: "hokm.chats", }; const CANNED_REPLIES = [ "سلام! 👋", "بزن بریم 🔥", "یه دست دیگه؟", "من آماده‌ام", "آفرین، کارت خوب بود", "حکم چی بکنیم؟", "😂😂", "الان میام بازی", "حتماً!", "تو رو خدا این دفعه کُتمون نکن 😅", ]; function load(key: string): T | null { if (!isBrowser()) return null; try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : null; } catch { return null; } } function save(key: string, value: unknown): void { if (!isBrowser()) return; try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ } } function defaultProfile(session: AuthSession): UserProfile { return { id: session.userId, username: "player_" + session.userId.slice(-4), displayName: "بازیکن", avatar: AVATARS[0].id, plan: "free", level: 1, xp: 0, coins: 1000, rating: 1000, stats: { games: 0, wins: 0, losses: 0, kotsFor: 0, kotsAgainst: 0, tricks: 0, bestWinStreak: 0, currentWinStreak: 0, shutoutWins: 0, hakemRounds: 0, roundsWon: 0, }, ownedAvatars: [AVATARS[0].id, AVATARS[1].id], ownedCardFronts: ["classic"], ownedCardBacks: ["classic"], ownedTitles: ["novice"], ownedReactionPacks: [], ownedStickerPacks: [], title: "novice", cardFront: "classic", cardBack: "classic", achievements: {}, unlocked: [], createdAt: Date.now(), }; } /** Backfill fields on older persisted profiles so the app never crashes. */ function migrateProfile(p: UserProfile): UserProfile { const legacy = p as unknown as { ownedCardStyles?: string[]; cardStyle?: string }; return { ...p, plan: p.plan ?? "free", ownedAvatars: p.ownedAvatars ?? [AVATARS[0].id], ownedCardFronts: p.ownedCardFronts ?? ["classic"], ownedCardBacks: p.ownedCardBacks ?? legacy.ownedCardStyles ?? ["classic"], ownedTitles: p.ownedTitles ?? ["novice"], ownedReactionPacks: p.ownedReactionPacks ?? [], ownedStickerPacks: p.ownedStickerPacks ?? [], title: p.title ?? "novice", cardFront: p.cardFront ?? "classic", cardBack: p.cardBack ?? legacy.cardStyle ?? "classic", }; } function makeFriend(status?: PresenceStatus): Friend { return { id: rid("fr"), username: "u" + randInt(1000, 9999), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 40), rating: randInt(900, 1800), status: status ?? pick(["online", "offline", "in-game", "online"]), }; } export class MockOnlineService implements OnlineService { private session: AuthSession | null = null; 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(); private room: Room | null = null; private matchmaking: MatchmakingState = { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0, }; private matchPlayers: | { id: string; displayName: string; avatar: string; level: number }[] | null = null; private currentOppRating = 1000; private lastOtp = ""; private mmOpts: MatchmakingOptions | null = null; private messages: Record = {}; private unread: Record = {}; private roomCbs = new Set<(r: Room) => void>(); private mmCbs = new Set<(s: MatchmakingState) => void>(); private friendCbs = new Set<(f: Friend[]) => void>(); private chatCbs = new Set<(friendId: string, m: ChatMessage[]) => void>(); private reactionCbs = new Set<(seat: number, reaction: string) => void>(); private reactionTimer: ReturnType | null = null; private timers: ReturnType[] = []; constructor() { this.session = load(LS.session); const loaded = load(LS.profile); this.profile = loaded ? migrateProfile(loaded) : null; this.messages = load>(LS.chats) ?? {}; this.seedFriends(); } private seedFriends() { this.friends = Array.from({ length: 8 }, () => makeFriend()); // one pending request this.requests = [{ id: rid("req"), from: makeFriend("online"), createdAt: Date.now() }]; } private emitRoom() { if (this.room) for (const cb of this.roomCbs) cb(this.room); } private emitMM() { for (const cb of this.mmCbs) cb({ ...this.matchmaking }); } private emitFriends() { for (const cb of this.friendCbs) cb([...this.friends]); } private after(ms: number, fn: () => void) { const t = setTimeout(fn, ms); this.timers.push(t); return t; } private saveProfile() { if (this.profile) save(LS.profile, this.profile); } /* ------------------------------ auth ------------------------------- */ getSession() { return this.session; } async restore() { if (this.session && this.profile) { return { session: this.session, profile: this.profile }; } return null; } private establish(session: AuthSession): AuthSession { this.session = session; save(LS.session, session); if (!this.profile) { this.profile = defaultProfile(session); this.saveProfile(); } return session; } async requestOtp(phone: string) { this.lastOtp = String(randInt(1000, 9999)); void phone; // In dev we surface the code so it can be entered without a real SMS. return { devCode: this.lastOtp }; } async verifyOtp(phone: string, code: string) { if (code !== this.lastOtp && code !== "1234") { throw new Error("INVALID_CODE"); } const session: AuthSession = { userId: rid("user"), token: rid("tok"), method: "phone", createdAt: Date.now(), }; const s = this.establish(session); if (this.profile && !this.profile.phone) { this.profile.phone = phone; this.saveProfile(); } return s; } async signInEmail(email: string, password: string) { void password; const session: AuthSession = { userId: rid("user"), token: rid("tok"), method: "email", createdAt: Date.now(), }; const s = this.establish(session); if (this.profile && !this.profile.email) { this.profile.email = email; this.saveProfile(); } return s; } async signUpEmail(email: string, password: string, displayName: string) { const s = await this.signInEmail(email, password); if (this.profile) { this.profile.email = email; if (displayName.trim()) this.profile.displayName = displayName.trim(); this.saveProfile(); } return s; } async signInGoogle() { const session: AuthSession = { userId: rid("user"), token: rid("tok"), method: "google", createdAt: Date.now(), }; return this.establish(session); } async signOut() { this.session = null; if (isBrowser()) localStorage.removeItem(LS.session); // keep profile so progress persists across sign-ins on the same device } /* ----------------------------- profile ----------------------------- */ async getProfile() { if (!this.profile) { const loaded = load(LS.profile); this.profile = loaded ? migrateProfile(loaded) : defaultProfile({ userId: rid("guest"), token: "", method: "guest", createdAt: Date.now(), }); this.saveProfile(); } return this.profile; } async updateProfile(patch: Parameters[0]) { const p = await this.getProfile(); const next = { ...p, ...patch }; // One-time reward: first time the player sets a (non-empty) city → +500 coins. if (patch.city && patch.city.trim() && !p.cityRewardClaimed) { next.coins = p.coins + CITY_REWARD; next.cityRewardClaimed = true; } this.profile = next; this.saveProfile(); return this.profile; } async upgradePlan(): Promise { const p = await this.getProfile(); this.profile = { ...p, plan: "pro", planUntil: Date.now() + 30 * 864e5 }; this.saveProfile(); // pro players skip the queue immediately if (this.matchmaking.phase === "queued") this.beginSearch(); return this.profile; } /* ----------------------------- friends ----------------------------- */ async listFriends() { return [...this.friends]; } 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 { // 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 { 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(["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 { const me = this.profile; const lvl = me?.level ?? 10; return Array.from({ length: 12 }, () => { const f = makeFriend(pick(["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) { this.friends = [{ ...req.from, status: "online" }, ...this.friends]; this.requests = this.requests.filter((r) => r.id !== id); this.emitFriends(); } } async declineRequest(id: string) { this.requests = this.requests.filter((r) => r.id !== id); } async removeFriend(id: string) { this.friends = this.friends.filter((f) => f.id !== id); this.emitFriends(); } onFriends(cb: (f: Friend[]) => void): Unsubscribe { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); } /* ------------------------------- chat ------------------------------ */ private saveChats() { save(LS.chats, this.messages); } private emitChat(friendId: string) { const msgs = this.messages[friendId] ?? []; for (const cb of this.chatCbs) cb(friendId, [...msgs]); } async listConversations(): Promise { const convs: Conversation[] = []; for (const friend of this.friends) { const msgs = this.messages[friend.id]; if (!msgs || msgs.length === 0) continue; convs.push({ friend, lastMessage: msgs[msgs.length - 1], unread: this.unread[friend.id] ?? 0, }); } return convs.sort( (a, b) => (b.lastMessage?.ts ?? 0) - (a.lastMessage?.ts ?? 0) ); } async getMessages(friendId: string): Promise { return [...(this.messages[friendId] ?? [])]; } async sendMessage(friendId: string, text: string): Promise { const msg: ChatMessage = { id: rid("m"), fromMe: true, text: text.trim(), ts: Date.now(), senderPro: this.profile?.plan === "pro", }; this.messages[friendId] = [...(this.messages[friendId] ?? []), msg]; this.saveChats(); this.emitChat(friendId); // deterministic: ~half of mock friends are "pro" so the gold bubble is visible offline const friendPro = [...friendId].reduce((a, c) => a + c.charCodeAt(0), 0) % 2 === 0; // simulate a reply from the friend this.after(randInt(900, 1900), () => { const reply: ChatMessage = { id: rid("m"), fromMe: false, text: pick(CANNED_REPLIES), ts: Date.now(), senderPro: friendPro, }; this.messages[friendId] = [...(this.messages[friendId] ?? []), reply]; this.unread[friendId] = (this.unread[friendId] ?? 0) + 1; this.saveChats(); this.emitChat(friendId); }); return msg; } async markRead(friendId: string) { this.unread[friendId] = 0; } onChat(cb: (friendId: string, m: ChatMessage[]) => void): Unsubscribe { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); } /* ---------------------------- reactions ---------------------------- */ async sendReaction(reaction: string) { for (const cb of this.reactionCbs) cb(0, reaction); } /* --------------------------- notifications ------------------------- */ private notifCbs = new Set<(n: AppNotification) => void>(); // Real notifications only — no periodic fake/"liveliness" spam. onNotification(cb: (n: AppNotification) => void): Unsubscribe { this.notifCbs.add(cb); return () => { this.notifCbs.delete(cb); }; } // The mock drives the game locally (game-store), so these are no-ops. readonly live = false; onState(): Unsubscribe { return () => {}; } playCard(): void {} chooseTrump(): void {} onProfile(): Unsubscribe { return () => {}; } onReward(): Unsubscribe { return () => {}; } // Forfeit is handled client-side for offline/mock games (see game-store). requestForfeit(): void {} confirmForfeit(): void {} declineForfeit(): void {} onForfeit(): Unsubscribe { return () => {}; } onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { this.reactionCbs.add(cb); if (this.reactionTimer == null) { const pool = [ "👍", "😂", "🔥", "😮", "👏", "🙄", "sticker:happy", "sticker:cool", "sticker:kot-stamp", "sticker:crown", ]; this.reactionTimer = setInterval(() => { if (this.reactionCbs.size === 0) return; const seat = randInt(1, 3); const r = pick(pool); for (const c of this.reactionCbs) c(seat, r); }, 9000); } return () => { this.reactionCbs.delete(cb); if (this.reactionCbs.size === 0 && this.reactionTimer) { clearInterval(this.reactionTimer); this.reactionTimer = null; } }; } /* ------------------------------ rooms ------------------------------ */ private seatYou(): RoomSeat { const p = this.profile!; return { seat: 0, kind: "you", player: { id: p.id, displayName: p.displayName, avatar: p.avatar, level: p.level }, }; } async createRoom(opts: CreateRoomOptions) { await this.getProfile(); this.room = { id: rid("room"), code: Math.random().toString(36).slice(2, 8).toUpperCase(), hostId: this.profile!.id, status: "open", seats: [ this.seatYou(), { seat: 1, kind: "empty" }, { seat: 2, kind: "empty" }, { seat: 3, kind: "empty" }, ], targetScore: opts.targetScore, stake: opts.stake, ranked: opts.ranked, }; return this.room; } private setSeat(seat: number, s: RoomSeat) { if (!this.room) return; this.room.seats = this.room.seats.map((x) => (x.seat === seat ? s : x)); } private friendSeat(seat: 1 | 2 | 3, friendId: string, invited: boolean): RoomSeat { const f = this.friends.find((x) => x.id === friendId); return { seat, kind: invited ? "invited" : "friend", player: f ? { id: f.id, displayName: f.displayName, avatar: f.avatar, level: f.level } : { id: friendId, displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 30) }, }; } async setPartner(roomId: string, friendId: string | null) { void roomId; if (!this.room) throw new Error("NO_ROOM"); if (friendId == null) { this.setSeat(2, { seat: 2, kind: "empty" }); } else { this.setSeat(2, this.friendSeat(2, friendId, true)); this.after(1100, () => { this.setSeat(2, this.friendSeat(2, friendId, false)); this.emitRoom(); }); } this.emitRoom(); return this.room; } async inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) { void roomId; if (!this.room) throw new Error("NO_ROOM"); this.setSeat(seat, this.friendSeat(seat, friendId, true)); this.after(1100, () => { this.setSeat(seat, this.friendSeat(seat, friendId, false)); this.emitRoom(); }); this.emitRoom(); return this.room; } async addBot(roomId: string, seat: 1 | 2 | 3) { void roomId; if (!this.room) throw new Error("NO_ROOM"); this.setSeat(seat, { seat, kind: "bot", player: { id: rid("bot"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50) }, }); this.emitRoom(); return this.room; } async clearSeat(roomId: string, seat: 1 | 2 | 3) { void roomId; if (!this.room) throw new Error("NO_ROOM"); this.setSeat(seat, { seat, kind: "empty" }); this.emitRoom(); return this.room; } async startRoom(roomId: string) { void roomId; if (!this.room) throw new Error("NO_ROOM"); // fill empty seats with bots for (const s of this.room.seats) { if (s.kind === "empty" || s.kind === "invited") { await this.addBot(roomId, s.seat as 1 | 2 | 3); } } this.room.status = "in-game"; this.matchPlayers = this.room.seats .slice() .sort((a, b) => a.seat - b.seat) .map((s) => s.player!) as typeof this.matchPlayers; this.currentOppRating = this.profile?.rating ?? 1000; this.emitRoom(); return this.room; } async leaveRoom(roomId: string) { void roomId; this.room = null; } onRoom(cb: (r: Room) => void): Unsubscribe { this.roomCbs.add(cb); return () => this.roomCbs.delete(cb); } /* --------------------------- matchmaking --------------------------- */ async startMatchmaking(opts: MatchmakingOptions) { await this.getProfile(); this.mmOpts = opts; const me = this.profile!; const pro = me.plan === "pro"; const busy = Math.random() < 0.7; if (!pro && busy) { // server is busy and the player is on the free plan → queue them let pos = randInt(3, 8); this.matchmaking = { phase: "queued", players: [{ id: me.id, displayName: me.displayName, avatar: me.avatar, level: me.level, rating: me.rating }], elapsedMs: 0, ranked: opts.ranked, stake: opts.stake, queuePosition: pos, }; this.emitMM(); const tick = () => this.after(1100, () => { if (this.matchmaking.phase !== "queued") return; pos -= 1; if (pos <= 0) { this.beginSearch(); } else { this.matchmaking.queuePosition = pos; this.emitMM(); tick(); } }); tick(); return; } this.beginSearch(); } private beginSearch() { const opts = this.mmOpts!; const me = this.profile!; this.matchmaking = { phase: "searching", players: [{ id: me.id, displayName: me.displayName, avatar: me.avatar, level: me.level, rating: me.rating }], elapsedMs: 0, ranked: opts.ranked, stake: opts.stake, }; this.emitMM(); // Wait 15s for "online" players to show up; whoever hasn't joined by then is // filled with a bot when the match forms. const searchMs = MATCH_QUEUE_WAIT_MS; // 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(isBot ? "bot" : "p"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50), rating: me.rating + randInt(-150, 150), }); this.emitMM(); }); // 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(searchMs, () => { if (this.matchmaking.phase !== "searching") return; this.matchmaking.phase = "found"; this.emitMM(); this.after(1200, () => { if (this.matchmaking.phase !== "found") return; this.matchmaking.phase = "ready"; // seat order: you=0, then revealed players const players = this.matchmaking.players; this.matchPlayers = players.map((p) => ({ id: p.id, displayName: p.displayName, avatar: p.avatar, level: p.level, })); const opps = players.slice(1); this.currentOppRating = opps.reduce((s, p) => s + p.rating, 0) / Math.max(1, opps.length); this.emitMM(); }); }); } async cancelMatchmaking() { this.matchmaking = { phase: "cancelled", players: [], elapsedMs: 0, ranked: true, stake: 0 }; this.emitMM(); this.matchmaking.phase = "idle"; } onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe { this.mmCbs.add(cb); return () => this.mmCbs.delete(cb); } /* ----------------------------- match ------------------------------- */ getMatchPlayers() { return this.matchPlayers; } async submitMatchResult(summary: MatchSummary): Promise { const p = await this.getProfile(); const { profile, reward } = applyMatchResult(p, summary, this.currentOppRating); this.profile = profile; this.saveProfile(); if (this.room) this.room = null; this.matchmaking.phase = "idle"; return reward; } /* --------------------- leaderboard / shop / daily ------------------ */ async getCoinPacks(): Promise { return [ { id: "p1", coins: 5000, bonus: 0, priceToman: 99000, tag: "starter" }, { id: "p2", coins: 11000, bonus: 1000, priceToman: 199000, tag: "popular" }, { id: "p3", coins: 24000, bonus: 4000, priceToman: 399000, tag: "best" }, { id: "p4", coins: 50000, bonus: 15000, priceToman: 799000 }, ]; } async buyCoins(packId: string) { const p = await this.getProfile(); const pack = (await this.getCoinPacks()).find((x) => x.id === packId); if (!pack) return { ok: false, coins: 0 }; // NOTE: real payment (Zarinpal/IDPay) goes here. For now we credit instantly. const added = pack.coins + pack.bonus; this.profile = { ...p, coins: p.coins + added }; this.saveProfile(); 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 { // gentle random walk so the badge feels alive; never drops below 50 this.onlineCount += Math.round((Math.random() - 0.45) * 12); this.onlineCount = Math.max(50, Math.min(4000, this.onlineCount)); return this.onlineCount; } async getLeaderboard(): Promise { const p = await this.getProfile(); const others = Array.from({ length: 24 }, () => ({ id: rid("lb"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(5, 60), rating: randInt(1000, 2200), levelProgress: Math.random(), isYou: false, })); const you = { id: p.id, displayName: p.displayName, avatar: p.avatar, avatarImage: p.avatarImage, level: p.level, rating: p.rating, levelProgress: Math.min(1, p.xp / xpNeededForLevel(p.level)), isYou: true, }; const all = [...others, you].sort((a, b) => b.rating - a.rating); return all.map((e, i) => ({ rank: i + 1, ...e })); } async getShopItems(): Promise { const avatarItems: ShopItem[] = AVATARS.filter((a) => (a.price ?? 0) > 0).map((a) => { const art = AVATAR_ART[a.id]; return { id: a.id, kind: "avatar" as const, nameFa: art?.nameFa ?? "آواتار", nameEn: art?.nameEn ?? "Avatar", price: a.price!, preview: a.emoji, descFa: "آواتار افسانه‌ای نمایه شما در بازی و جدول", descEn: "A legendary profile avatar shown in games & the leaderboard", reqLevel: a.reqLevel, reqRating: a.reqRating, reqAchievement: a.reqAchievement, }; }); const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ id: c.id, kind: "cardback", nameFa: c.nameFa, nameEn: c.nameEn, price: c.price, preview: c.accent, descFa: "طرح پشت کارت‌ها روی میز", descEn: "The pattern on the back of your cards", reqLevel: c.reqLevel, reqRating: c.reqRating, reqAchievement: c.reqAchievement, })); const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({ id: c.id, kind: "cardfront", nameFa: c.nameFa, nameEn: c.nameEn, price: c.price, preview: c.bg2, descFa: "ظاهر روی کارت‌های شما", descEn: "The face style of your cards", reqLevel: c.reqLevel, reqRating: c.reqRating, reqAchievement: c.reqAchievement, })); const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ id: r.id, kind: "reactionpack", nameFa: r.nameFa, nameEn: r.nameEn, price: r.price, preview: r.reactions[0], contents: r.reactions, descFa: `${faNum(r.reactions.length)} ایموجی برای استفاده در بازی`, descEn: `${r.reactions.length} in-game emotes`, reqLevel: r.reqLevel, reqRating: r.reqRating, reqAchievement: r.reqAchievement, })); const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({ id: p.id, kind: "stickerpack", nameFa: p.nameFa, nameEn: p.nameEn, price: p.price, preview: p.stickers[0], // sticker id; ShopScreen renders via contents: p.stickers, descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`, descEn: `${p.stickers.length} in-game stickers`, reqLevel: p.reqLevel, reqRating: p.reqRating, reqAchievement: p.reqAchievement, })); 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", reqLevel: tt.reqLevel, reqRating: tt.reqRating, })); const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ id: x.id, kind: "xp", nameFa: `${faNum(x.xp)} امتیاز تجربه`, nameEn: `${x.xp} XP`, price: x.price, preview: "⚡", xp: x.xp, descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه می‌شود`, descEn: `${x.xp} XP added to your account instantly`, })); return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...titleItems, ...xpItems]; } async buyItem(id: string) { const p = await this.getProfile(); const items = await this.getShopItems(); const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; // Purchase gate: locked until the level / rating / achievement requirement is met. if ( (item.reqLevel && p.level < item.reqLevel) || (item.reqRating && p.rating < item.reqRating) || (item.reqAchievement && !(p.unlocked ?? []).includes(item.reqAchievement)) ) return { ok: false, messageFa: "هنوز باز نشده است", messageEn: "Locked — requirement not met" }; // XP packs are consumable — grant XP instead of adding to an owned list. if (item.kind === "xp") { if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; const pack = XP_PACKS.find((x) => x.id === id)!; const lvl = addXp(p.level, p.xp, pack.xp); const leveled = { ...p, coins: p.coins - item.price, level: lvl.level, xp: lvl.xp }; // unlock any level milestones the new level reaches const { profile: evaluated } = evaluateAchievements(leveled); this.profile = evaluated; this.saveProfile(); return { ok: true, profile: this.profile, messageFa: "امتیاز اضافه شد", messageEn: "XP added" }; } const ownedMap: Record = { avatar: p.ownedAvatars, cardfront: p.ownedCardFronts, 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" }; if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; this.profile = { ...p, coins: p.coins - item.price, ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars, ownedCardFronts: item.kind === "cardfront" ? [...p.ownedCardFronts, id] : p.ownedCardFronts, ownedCardBacks: item.kind === "cardback" ? [...p.ownedCardBacks, id] : p.ownedCardBacks, ownedReactionPacks: 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" }; } async getDailyState(): Promise { const d = load(LS.daily) ?? { day: 1, lastClaimed: null, available: true }; d.available = d.lastClaimed !== todayStr(); return d; } async claimDaily() { const p = await this.getProfile(); const d = await this.getDailyState(); if (!d.available) return { reward: 0, profile: p, day: d.day }; const reward = dailyRewardFor(d.day); this.profile = { ...p, coins: p.coins + reward }; this.saveProfile(); const nextDay = d.day >= 7 ? 1 : d.day + 1; save(LS.daily, { day: nextDay, lastClaimed: todayStr(), available: false }); return { reward, profile: this.profile, day: d.day }; } }