8033023a1f
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>
1205 lines
39 KiB
TypeScript
1205 lines
39 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 {
|
||
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;
|
||
// 0–3 humans actually appear; the rest of the table fills with bots.
|
||
const humansFound = randInt(0, 3);
|
||
|
||
const reveal = (delay: number, isBot: boolean) =>
|
||
this.after(delay, () => {
|
||
if (this.matchmaking.phase !== "searching") return;
|
||
this.matchmaking.players.push({
|
||
id: rid(isBot ? "bot" : "p"),
|
||
displayName: pick(PERSIAN_NAMES),
|
||
avatar: pick(AVATARS).id,
|
||
level: randInt(1, 50),
|
||
rating: me.rating + randInt(-150, 150),
|
||
});
|
||
this.emitMM();
|
||
});
|
||
|
||
// Real players trickle in across the search window…
|
||
for (let i = 0; i < humansFound; i++) {
|
||
reveal(Math.round(searchMs * (0.25 + i * 0.22)), false);
|
||
}
|
||
// …then bots fill the remaining seats just before the match forms.
|
||
for (let i = 0; i < 3 - humansFound; i++) {
|
||
reveal(searchMs - 600 + i * 120, true);
|
||
}
|
||
|
||
this.after(searchMs, () => {
|
||
if (this.matchmaking.phase !== "searching") return;
|
||
this.matchmaking.phase = "found";
|
||
this.emitMM();
|
||
this.after(1200, () => {
|
||
if (this.matchmaking.phase !== "found") return;
|
||
this.matchmaking.phase = "ready";
|
||
// seat order: you=0, then revealed players
|
||
const players = this.matchmaking.players;
|
||
this.matchPlayers = players.map((p) => ({
|
||
id: p.id,
|
||
displayName: p.displayName,
|
||
avatar: p.avatar,
|
||
level: p.level,
|
||
}));
|
||
const opps = players.slice(1);
|
||
this.currentOppRating =
|
||
opps.reduce((s, p) => s + p.rating, 0) / Math.max(1, opps.length);
|
||
this.emitMM();
|
||
});
|
||
});
|
||
}
|
||
|
||
async cancelMatchmaking() {
|
||
this.matchmaking = { phase: "cancelled", players: [], elapsedMs: 0, ranked: true, stake: 0 };
|
||
this.emitMM();
|
||
this.matchmaking.phase = "idle";
|
||
}
|
||
|
||
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
||
this.mmCbs.add(cb);
|
||
return () => this.mmCbs.delete(cb);
|
||
}
|
||
|
||
/* ----------------------------- match ------------------------------- */
|
||
|
||
getMatchPlayers() {
|
||
return this.matchPlayers;
|
||
}
|
||
|
||
async submitMatchResult(summary: MatchSummary): Promise<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 };
|
||
}
|
||
}
|