Files
HokmPlay/src/lib/online/mock-service.ts
T
soroush.asadi 8033023a1f
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 22s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
matchmaking: deterministic 15s wait before bots fill empty seats
Both the mock and the .NET server already waited then bot-filled, but used a
random 12-18s window. Make it exactly 15s on both sides so the rule is clear:
wait 15s for real online players to join, then replace any unfilled seats with
bots and start.

- client: new MATCH_QUEUE_WAIT_MS = 15000 in gamification.ts; mock beginSearch
  uses it instead of randInt(12000,18000).
- server: GameManager QueueWaitMs = 15000 (was randomized 12-18s per ticket).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:27:46 +03:30

1205 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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[] = [];
/** 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",
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: Parameters<OnlineService["updateProfile"]>[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<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];
}
/**
* 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) {
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(),
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;
// 03 humans actually appear; the rest of the table fills with bots.
const humansFound = randInt(0, 3);
const reveal = (delay: number, isBot: boolean) =>
this.after(delay, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.players.push({
id: rid(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<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: 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<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) => {
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 <Sticker>
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<string, string[]> = {
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<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 };
}
}