feat: UNO-style table, social hub, cosmetics, speed mode, store IAB
Game table & play - UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow, big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round confetti, match coin-rain. - Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert; mirrored server-side in GameRoom.TurnMs. - Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing. - Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint. Rewards / gifts - Richer post-match modal (floating coins, XP bar), celebration overlay reveals the unlocked sticker pack, boosted daily rewards (client+server synced), themed 7-day daily with special day-7. Social - Public profile modal (identity, stats, achievement board) from leaderboard / friends / discover / end-of-game roster; rate-limited add-friend (10/hour). - Social hub: Friends / Discover (player search + suggestions) / Messages inbox. - Profile gender (shown in finder/profile) + social links with public/friends/ hidden visibility, enforced server-side. Cosmetics - Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/ rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts), consistent on table/shop/profile; +Peacock/Rose-Gold backs. - Purchasable titles (shop Titles section); title shown under the seat on the table and in discover/public profile. - 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods). - Persistent level+XP bar on Home and every inner screen. Payments - Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh. - Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture, Myket native-bridge contract, server-side IabService.Verify for both stores, config-driven via Iab__* env. POST /api/coins/iab/verify (JWT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+221
-12
@@ -3,11 +3,14 @@
|
||||
// with timers, and computes rewards via gamification.ts.
|
||||
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
CARD_BACKS,
|
||||
CARD_FRONTS,
|
||||
REACTION_PACKS,
|
||||
STICKER_PACKS,
|
||||
TITLES,
|
||||
XP_PACKS,
|
||||
achievementProgress,
|
||||
addXp,
|
||||
applyMatchResult,
|
||||
dailyRewardFor,
|
||||
@@ -31,10 +34,16 @@ import {
|
||||
DailyRewardState,
|
||||
Friend,
|
||||
FriendRequest,
|
||||
Gender,
|
||||
LeaderboardEntry,
|
||||
MatchSummary,
|
||||
MatchmakingState,
|
||||
PlayerStats,
|
||||
PlayerSummary,
|
||||
PresenceStatus,
|
||||
PublicProfile,
|
||||
SocialLinks,
|
||||
SocialVisibility,
|
||||
RewardResult,
|
||||
Room,
|
||||
RoomSeat,
|
||||
@@ -42,6 +51,10 @@ import {
|
||||
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 = [
|
||||
"آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا",
|
||||
"الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار",
|
||||
@@ -176,6 +189,10 @@ export class MockOnlineService implements OnlineService {
|
||||
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",
|
||||
@@ -342,11 +359,7 @@ export class MockOnlineService implements OnlineService {
|
||||
return this.profile;
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
patch: Partial<
|
||||
Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
|
||||
>
|
||||
) {
|
||||
async updateProfile(patch: Parameters<OnlineService["updateProfile"]>[0]) {
|
||||
const p = await this.getProfile();
|
||||
this.profile = { ...p, ...patch };
|
||||
this.saveProfile();
|
||||
@@ -370,16 +383,183 @@ export class MockOnlineService implements OnlineService {
|
||||
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) {
|
||||
@@ -698,11 +878,18 @@ export class MockOnlineService implements OnlineService {
|
||||
};
|
||||
this.emitMM();
|
||||
|
||||
const reveal = (delay: number) =>
|
||||
// Wait ~15s (randomized 12–18s) for "online" players to show up; whoever
|
||||
// hasn't joined by then is filled with a bot when the match forms. The exact
|
||||
// wait varies so it never feels robotically identical.
|
||||
const searchMs = randInt(12000, 18000);
|
||||
// 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("p"),
|
||||
id: rid(isBot ? "bot" : "p"),
|
||||
displayName: pick(PERSIAN_NAMES),
|
||||
avatar: pick(AVATARS).id,
|
||||
level: randInt(1, 50),
|
||||
@@ -711,11 +898,16 @@ export class MockOnlineService implements OnlineService {
|
||||
this.emitMM();
|
||||
});
|
||||
|
||||
reveal(900);
|
||||
reveal(1900);
|
||||
reveal(2900);
|
||||
// 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(3500, () => {
|
||||
this.after(searchMs, () => {
|
||||
if (this.matchmaking.phase !== "searching") return;
|
||||
this.matchmaking.phase = "found";
|
||||
this.emitMM();
|
||||
@@ -787,6 +979,11 @@ export class MockOnlineService implements OnlineService {
|
||||
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
|
||||
@@ -873,6 +1070,16 @@ export class MockOnlineService implements OnlineService {
|
||||
descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`,
|
||||
descEn: `${p.stickers.length} in-game stickers`,
|
||||
}));
|
||||
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",
|
||||
}));
|
||||
const xpItems: ShopItem[] = XP_PACKS.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "xp",
|
||||
@@ -884,7 +1091,7 @@ export class MockOnlineService implements OnlineService {
|
||||
descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه میشود`,
|
||||
descEn: `${x.xp} XP added to your account instantly`,
|
||||
}));
|
||||
return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems];
|
||||
return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...titleItems, ...xpItems];
|
||||
}
|
||||
|
||||
async buyItem(id: string) {
|
||||
@@ -912,6 +1119,7 @@ export class MockOnlineService implements OnlineService {
|
||||
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" };
|
||||
@@ -930,6 +1138,7 @@ export class MockOnlineService implements OnlineService {
|
||||
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" };
|
||||
|
||||
Reference in New Issue
Block a user