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:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+221 -12
View File
@@ -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 1218s) 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);
// 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("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" };