Files
HokmPlay/src/lib/online/mock-service.ts
T
soroush.asadi fd33f85e9c
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped
Store XP packs (expensive), winner 2x XP, premium perks
- XP packs in the store (coin-priced, intentionally expensive): xp1 200/5k,
  xp2 600/12k, xp3 1500/25k. Consumable (grant XP, can level up) — server
  ShopBuy handles kind "xp" via an authoritative XpPacks map + Gamification.GrantXp;
  mock mirrors. New shop section + shop.xp/xpHint i18n.
- Every game grants XP and the WINNER earns 2x: matchXp is now
  base*(won?2:1)*leagueFactor (was a flat +80 win bonus). Mirrored server-side.
- Premium (pro) perks: 1.5x XP multiplier (applied in applyMatchResult /
  ApplyMatch by plan), plus animated shimmering gold chat bubbles for your own
  messages (premium-chat CSS; ChatScreen gates on plan).

Verified: tsc + next + dotnet build clean; sim passes; live server — buying xp2
took L1→L3 and deducted 12k coins under the new curve. Images rebuilt :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:08:19 +03:30

959 lines
29 KiB
TypeScript

// 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<T>(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<T>(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<PresenceStatus>(["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<string, ChatMessage[]> = {};
private unread: Record<string, number> = {};
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<typeof setInterval> | null = null;
private timers: ReturnType<typeof setTimeout>[] = [];
constructor() {
this.session = load<AuthSession>(LS.session);
const loaded = load<UserProfile>(LS.profile);
this.profile = loaded ? migrateProfile(loaded) : null;
this.messages = load<Record<string, ChatMessage[]>>(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<UserProfile>(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<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
>
) {
const p = await this.getProfile();
this.profile = { ...p, ...patch };
this.saveProfile();
return this.profile;
}
async upgradePlan(): Promise<UserProfile> {
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<Conversation[]> {
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<ChatMessage[]> {
return [...(this.messages[friendId] ?? [])];
}
async sendMessage(friendId: string, text: string): Promise<ChatMessage> {
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<typeof setInterval> | null = null;
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
this.notifCbs.add(cb);
if (this.notifTimer == null) {
const samples: Array<Pick<AppNotification, "kind" | "titleFa" | "titleEn" | "icon">> = [
{ 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<RewardResult> {
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<CoinPack[]> {
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<number> {
// 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<LeaderboardEntry[]> {
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<ShopItem[]> {
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 <Sticker>
}));
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<string, string[]> = {
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<DailyRewardState> {
const d = load<DailyRewardState>(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 };
}
}