// In-memory + localStorage mock implementing OnlineService. // Simulates remote players, friends presence, room invites and matchmaking // with timers, and computes rewards via gamification.ts. import { CARD_BACKS, CARD_FRONTS, REACTION_PACKS, STICKER_PACKS, XP_PACKS, addXp, applyMatchResult, dailyRewardFor, faNum, xpNeededForLevel, } from "./gamification"; import { CreateRoomOptions, MatchmakingOptions, OnlineService, Unsubscribe, } from "./service"; import { AVATARS, AppNotification, AuthSession, ChatMessage, CoinPack, Conversation, DailyRewardState, Friend, FriendRequest, LeaderboardEntry, MatchSummary, MatchmakingState, PresenceStatus, RewardResult, Room, RoomSeat, ShopItem, UserProfile, } from "./types"; 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[] = []; 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: Partial< Pick > ) { const p = await this.getProfile(); this.profile = { ...p, ...patch }; 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]; } async addFriend(query: string) { if (!query.trim()) { return { ok: false, messageFa: "نام یا شماره را وارد کنید", messageEn: "Enter a name or number" }; } 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 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(), }; this.messages[friendId] = [...(this.messages[friendId] ?? []), msg]; this.saveChats(); this.emitChat(friendId); // 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(), }; 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>(); private notifTimer: ReturnType | null = null; onNotification(cb: (n: AppNotification) => void): Unsubscribe { this.notifCbs.add(cb); if (this.notifTimer == null) { const samples: Array> = [ { kind: "system", titleFa: "یک دوست آنلاین شد", titleEn: "A friend is online", icon: "👋" }, { kind: "system", titleFa: "مسابقه‌ی امروز شروع شد", titleEn: "Today's event is live", icon: "🏆" }, { kind: "invite", titleFa: "یک نفر دنبال هم‌بازیه", titleEn: "Someone is looking for a partner", icon: "🎴" }, ]; this.notifTimer = setInterval(() => { if (this.notifCbs.size === 0) return; const s = pick(samples); const n: AppNotification = { id: rid("ntf"), ts: Date.now(), read: false, ...s, }; for (const c of this.notifCbs) c(n); }, 35000); } return () => { this.notifCbs.delete(cb); if (this.notifCbs.size === 0 && this.notifTimer) { clearInterval(this.notifTimer); this.notifTimer = null; } }; } // 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(); const reveal = (delay: number) => this.after(delay, () => { if (this.matchmaking.phase !== "searching") return; this.matchmaking.players.push({ id: rid("p"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50), rating: me.rating + randInt(-150, 150), }); this.emitMM(); }); reveal(900); reveal(1900); reveal(2900); this.after(3500, () => { 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: 50000, bonus: 0, priceToman: 95000, tag: "starter" }, { id: "p2", coins: 120000, bonus: 15000, priceToman: 189000, tag: "popular" }, { id: "p3", coins: 300000, bonus: 50000, priceToman: 389000, tag: "best" }, { id: "p4", coins: 700000, bonus: 150000, priceToman: 790000 }, ]; } 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 }; } 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) => ({ id: a.id, kind: "avatar", nameFa: "آواتار", nameEn: "Avatar", price: a.price!, preview: a.emoji, })); 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, })); 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, })); 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], })); 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 })); const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ id: x.id, kind: "xp", nameFa: `${faNum(x.xp)} امتیاز تجربه`, nameEn: `${x.xp} XP`, price: x.price, preview: "⚡", })); return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...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" }; // 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); this.profile = { ...p, coins: p.coins - item.price, level: lvl.level, xp: lvl.xp }; 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, }; 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, }; 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 }; } }