2026-06-04 10:11:00 +03:30
|
|
|
// In-memory + localStorage mock implementing OnlineService.
|
|
|
|
|
// Simulates remote players, friends presence, room invites and matchmaking
|
|
|
|
|
// with timers, and computes rewards via gamification.ts.
|
|
|
|
|
|
|
|
|
|
import { applyMatchResult, dailyRewardFor } from "./gamification";
|
|
|
|
|
import {
|
|
|
|
|
CreateRoomOptions,
|
|
|
|
|
MatchmakingOptions,
|
|
|
|
|
OnlineService,
|
|
|
|
|
Unsubscribe,
|
|
|
|
|
} from "./service";
|
|
|
|
|
import {
|
|
|
|
|
AVATARS,
|
|
|
|
|
AuthSession,
|
2026-06-04 10:21:44 +03:30
|
|
|
ChatMessage,
|
|
|
|
|
Conversation,
|
2026-06-04 10:11:00 +03:30
|
|
|
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",
|
2026-06-04 10:21:44 +03:30
|
|
|
chats: "hokm.chats",
|
2026-06-04 10:11:00 +03:30
|
|
|
};
|
|
|
|
|
|
2026-06-04 10:21:44 +03:30
|
|
|
const CANNED_REPLIES = [
|
|
|
|
|
"سلام! 👋",
|
|
|
|
|
"بزن بریم 🔥",
|
|
|
|
|
"یه دست دیگه؟",
|
|
|
|
|
"من آمادهام",
|
|
|
|
|
"آفرین، کارت خوب بود",
|
|
|
|
|
"حکم چی بکنیم؟",
|
|
|
|
|
"😂😂",
|
|
|
|
|
"الان میام بازی",
|
|
|
|
|
"حتماً!",
|
|
|
|
|
"تو رو خدا این دفعه کُتمون نکن 😅",
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
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,
|
|
|
|
|
phone: session.method === "phone" ? undefined : undefined,
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
|
|
|
|
|
ownedThemes: ["royal"],
|
|
|
|
|
achievements: {},
|
|
|
|
|
unlocked: [],
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = "";
|
|
|
|
|
|
2026-06-04 10:21:44 +03:30
|
|
|
private messages: Record<string, ChatMessage[]> = {};
|
|
|
|
|
private unread: Record<string, number> = {};
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
private roomCbs = new Set<(r: Room) => void>();
|
|
|
|
|
private mmCbs = new Set<(s: MatchmakingState) => void>();
|
|
|
|
|
private friendCbs = new Set<(f: Friend[]) => void>();
|
2026-06-04 10:21:44 +03:30
|
|
|
private chatCbs = new Set<(friendId: string, m: ChatMessage[]) => void>();
|
2026-06-04 10:11:00 +03:30
|
|
|
private timers: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
this.session = load<AuthSession>(LS.session);
|
|
|
|
|
this.profile = load<UserProfile>(LS.profile);
|
2026-06-04 10:21:44 +03:30
|
|
|
this.messages = load<Record<string, ChatMessage[]>>(LS.chats) ?? {};
|
2026-06-04 10:11:00 +03:30
|
|
|
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) {
|
|
|
|
|
// guest fallback profile (not persisted as session)
|
|
|
|
|
this.profile =
|
|
|
|
|
load<UserProfile>(LS.profile) ??
|
|
|
|
|
defaultProfile({
|
|
|
|
|
userId: rid("guest"),
|
|
|
|
|
token: "",
|
|
|
|
|
method: "guest",
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
this.saveProfile();
|
|
|
|
|
}
|
|
|
|
|
return this.profile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>) {
|
|
|
|
|
const p = await this.getProfile();
|
|
|
|
|
this.profile = { ...p, ...patch };
|
|
|
|
|
this.saveProfile();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 10:21:44 +03:30
|
|
|
/* ------------------------------- 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
/* ------------------------------ 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();
|
|
|
|
|
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 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),
|
|
|
|
|
isYou: false,
|
|
|
|
|
}));
|
|
|
|
|
const you = {
|
|
|
|
|
id: p.id,
|
|
|
|
|
displayName: p.displayName,
|
|
|
|
|
avatar: p.avatar,
|
|
|
|
|
level: p.level,
|
|
|
|
|
rating: p.rating,
|
|
|
|
|
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.slice(2).map((a, i) => ({
|
|
|
|
|
id: a.id,
|
|
|
|
|
kind: "avatar",
|
|
|
|
|
nameFa: "آواتار",
|
|
|
|
|
nameEn: "Avatar",
|
|
|
|
|
price: 500 + i * 150,
|
|
|
|
|
preview: a.emoji,
|
|
|
|
|
}));
|
|
|
|
|
const themes: ShopItem[] = [
|
|
|
|
|
{ id: "midnight", kind: "theme", nameFa: "تم نیمهشب", nameEn: "Midnight", price: 1200, preview: "#0a142e" },
|
|
|
|
|
{ id: "emerald", kind: "theme", nameFa: "تم زمرد", nameEn: "Emerald", price: 1500, preview: "#0d6b6b" },
|
|
|
|
|
{ id: "crimson", kind: "theme", nameFa: "تم یاقوت", nameEn: "Crimson", price: 1800, preview: "#7f1d2e" },
|
|
|
|
|
];
|
|
|
|
|
return [...avatarItems, ...themes];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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" };
|
|
|
|
|
const owned =
|
|
|
|
|
item.kind === "avatar" ? p.ownedAvatars.includes(id) : p.ownedThemes.includes(id);
|
|
|
|
|
if (owned) 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,
|
|
|
|
|
ownedThemes: item.kind === "theme" ? [...p.ownedThemes, id] : p.ownedThemes,
|
|
|
|
|
};
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|