Build Hokm card game: offline vs-AI + online social/gamification (mock backend)
- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots - Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand) - Online platform behind OnlineService seam (mock now, .NET SignalR later): auth (phone OTP + email/Google), profiles, friends, private rooms with partner pick, ranked matchmaking, leaderboard, shop - Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements - i18n fa/en, PWA manifest, engine + gamification sims Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
|
||||
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
||||
|
||||
import {
|
||||
AchievementDef,
|
||||
AchievementUnlock,
|
||||
LeagueInfo,
|
||||
MatchSummary,
|
||||
PlayerStats,
|
||||
RankTier,
|
||||
RankTierId,
|
||||
RewardResult,
|
||||
UserProfile,
|
||||
} from "./types";
|
||||
|
||||
/* ------------------------------- Ranks ------------------------------- */
|
||||
|
||||
export const RANK_TIERS: RankTier[] = [
|
||||
{ id: "bronze", nameFa: "برنز", nameEn: "Bronze", floor: 0, color: "#cd7f32" },
|
||||
{ id: "silver", nameFa: "نقره", nameEn: "Silver", floor: 1100, color: "#c0c7d0" },
|
||||
{ id: "gold", nameFa: "طلا", nameEn: "Gold", floor: 1300, color: "#e6b800" },
|
||||
{ id: "platinum", nameFa: "پلاتین", nameEn: "Platinum", floor: 1500, color: "#46c2c2" },
|
||||
{ id: "diamond", nameFa: "الماس", nameEn: "Diamond", floor: 1700, color: "#6aa6ff" },
|
||||
{ id: "master", nameFa: "استاد", nameEn: "Master", floor: 1900, color: "#c77dff" },
|
||||
];
|
||||
|
||||
const ROMAN = ["", "I", "II", "III"];
|
||||
|
||||
export function divisionLabel(division: number | null): string {
|
||||
if (division == null) return "";
|
||||
return ROMAN[division] ?? "";
|
||||
}
|
||||
|
||||
export function tierById(id: RankTierId): RankTier {
|
||||
return RANK_TIERS.find((t) => t.id === id) ?? RANK_TIERS[0];
|
||||
}
|
||||
|
||||
export function getLeagueInfo(rating: number): LeagueInfo {
|
||||
const r = Math.max(0, Math.round(rating));
|
||||
let idx = 0;
|
||||
for (let i = 0; i < RANK_TIERS.length; i++) {
|
||||
if (r >= RANK_TIERS[i].floor) idx = i;
|
||||
}
|
||||
const tier = RANK_TIERS[idx];
|
||||
const isLast = idx === RANK_TIERS.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return { tier, division: null, rating: r, nextThreshold: null, progress: 1 };
|
||||
}
|
||||
|
||||
const nextTierFloor = RANK_TIERS[idx + 1].floor;
|
||||
const band = nextTierFloor - tier.floor;
|
||||
const third = band / 3;
|
||||
// division 3 (III) is lowest, 1 (I) is highest
|
||||
const within = r - tier.floor;
|
||||
let division: number;
|
||||
let divStart: number;
|
||||
let divEnd: number;
|
||||
if (within < third) {
|
||||
division = 3;
|
||||
divStart = tier.floor;
|
||||
divEnd = tier.floor + third;
|
||||
} else if (within < 2 * third) {
|
||||
division = 2;
|
||||
divStart = tier.floor + third;
|
||||
divEnd = tier.floor + 2 * third;
|
||||
} else {
|
||||
division = 1;
|
||||
divStart = tier.floor + 2 * third;
|
||||
divEnd = nextTierFloor;
|
||||
}
|
||||
const progress = Math.min(1, Math.max(0, (r - divStart) / (divEnd - divStart)));
|
||||
return { tier, division, rating: r, nextThreshold: Math.round(divEnd), progress };
|
||||
}
|
||||
|
||||
/* ------------------------------ Rating ------------------------------- */
|
||||
|
||||
const K_FACTOR = 32;
|
||||
|
||||
/** Elo-style rating delta for a ranked match (0 for casual). */
|
||||
export function ratingDelta(
|
||||
summary: MatchSummary,
|
||||
myRating: number,
|
||||
oppRating: number
|
||||
): number {
|
||||
if (!summary.ranked) return 0;
|
||||
const expected = 1 / (1 + Math.pow(10, (oppRating - myRating) / 400));
|
||||
const score = summary.won ? 1 : 0;
|
||||
let delta = K_FACTOR * (score - expected);
|
||||
if (summary.won && summary.kotFor) delta += 8;
|
||||
if (!summary.won && summary.kotAgainst) delta -= 8;
|
||||
const rounded = Math.round(delta);
|
||||
// never let a win cost rating or a loss gain it
|
||||
if (summary.won) return Math.max(1, rounded);
|
||||
return Math.min(-1, rounded);
|
||||
}
|
||||
|
||||
/* ------------------------------- Coins ------------------------------- */
|
||||
|
||||
export function coinDelta(summary: MatchSummary): number {
|
||||
const base = summary.won ? (summary.ranked ? 50 : 25) : 10;
|
||||
const stakeNet = summary.won ? summary.stake : -summary.stake;
|
||||
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
|
||||
return base + stakeNet + kotBonus;
|
||||
}
|
||||
|
||||
/* ------------------------------- XP ---------------------------------- */
|
||||
|
||||
/** XP required to advance from `level` to `level + 1`. */
|
||||
export function xpNeededForLevel(level: number): number {
|
||||
return 100 * level;
|
||||
}
|
||||
|
||||
export function matchXp(summary: MatchSummary): number {
|
||||
return (
|
||||
40 +
|
||||
(summary.won ? 80 : 0) +
|
||||
summary.tricksWon * 5 +
|
||||
(summary.kotFor ? 30 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
export interface LevelProgress {
|
||||
level: number;
|
||||
xp: number; // xp within the current level
|
||||
leveledUp: boolean;
|
||||
}
|
||||
|
||||
export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress {
|
||||
let lvl = level;
|
||||
let xp = xpInLevel + gained;
|
||||
let leveledUp = false;
|
||||
while (xp >= xpNeededForLevel(lvl)) {
|
||||
xp -= xpNeededForLevel(lvl);
|
||||
lvl += 1;
|
||||
leveledUp = true;
|
||||
}
|
||||
return { level: lvl, xp, leveledUp };
|
||||
}
|
||||
|
||||
/* --------------------------- Achievements ---------------------------- */
|
||||
|
||||
export const ACHIEVEMENTS: AchievementDef[] = [
|
||||
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
|
||||
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
|
||||
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
|
||||
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
|
||||
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
|
||||
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
|
||||
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
|
||||
];
|
||||
|
||||
/** Current raw progress value for an achievement from stats + rating. */
|
||||
export function achievementProgress(
|
||||
id: string,
|
||||
stats: PlayerStats,
|
||||
rating: number
|
||||
): number {
|
||||
switch (id) {
|
||||
case "first_win":
|
||||
return Math.min(1, stats.wins);
|
||||
case "first_kot":
|
||||
return Math.min(1, stats.kotsFor);
|
||||
case "wins_10":
|
||||
return Math.min(10, stats.wins);
|
||||
case "wins_100":
|
||||
return Math.min(100, stats.wins);
|
||||
case "streak_5":
|
||||
return Math.min(5, stats.bestWinStreak);
|
||||
case "reach_gold":
|
||||
return rating >= tierById("gold").floor ? 1 : 0;
|
||||
case "games_50":
|
||||
return Math.min(50, stats.games);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------- Apply a match result ------------------------- */
|
||||
|
||||
function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
|
||||
const wins = stats.wins + (summary.won ? 1 : 0);
|
||||
const losses = stats.losses + (summary.won ? 0 : 1);
|
||||
const currentWinStreak = summary.won ? stats.currentWinStreak + 1 : 0;
|
||||
return {
|
||||
games: stats.games + 1,
|
||||
wins,
|
||||
losses,
|
||||
kotsFor: stats.kotsFor + (summary.kotFor ? 1 : 0),
|
||||
kotsAgainst: stats.kotsAgainst + (summary.kotAgainst ? 1 : 0),
|
||||
tricks: stats.tricks + summary.tricksWon,
|
||||
currentWinStreak,
|
||||
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a finished match to a profile. Returns a new profile + a RewardResult
|
||||
* describing every delta for the post-match UI.
|
||||
*/
|
||||
export function applyMatchResult(
|
||||
profile: UserProfile,
|
||||
summary: MatchSummary,
|
||||
oppRating: number
|
||||
): { profile: UserProfile; reward: RewardResult } {
|
||||
const ratingBefore = profile.rating;
|
||||
const coinsBefore = profile.coins;
|
||||
const levelBefore = profile.level;
|
||||
|
||||
const rDelta = ratingDelta(summary, profile.rating, oppRating);
|
||||
const ratingAfter = Math.max(0, ratingBefore + rDelta);
|
||||
|
||||
const cDelta = coinDelta(summary);
|
||||
const xpGain = matchXp(summary);
|
||||
const lvl = addXp(profile.level, profile.xp, xpGain);
|
||||
|
||||
const stats = applyStats(profile.stats, summary);
|
||||
|
||||
// Evaluate achievements against the new state.
|
||||
const achievements = { ...profile.achievements };
|
||||
const unlocked = [...profile.unlocked];
|
||||
const newAchievements: AchievementUnlock[] = [];
|
||||
let achievementCoins = 0;
|
||||
for (const def of ACHIEVEMENTS) {
|
||||
const prog = achievementProgress(def.id, stats, ratingAfter);
|
||||
achievements[def.id] = prog;
|
||||
if (prog >= def.goal && !unlocked.includes(def.id)) {
|
||||
unlocked.push(def.id);
|
||||
achievementCoins += def.coinReward;
|
||||
newAchievements.push({
|
||||
id: def.id,
|
||||
nameFa: def.nameFa,
|
||||
nameEn: def.nameEn,
|
||||
icon: def.icon,
|
||||
coinReward: def.coinReward,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins);
|
||||
|
||||
const leagueBefore = getLeagueInfo(ratingBefore);
|
||||
const leagueAfter = getLeagueInfo(ratingAfter);
|
||||
const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id);
|
||||
const rankValue = (l: LeagueInfo) =>
|
||||
tierIndex(l.tier.id) * 10 - (l.division ?? 0);
|
||||
const promoted = rankValue(leagueAfter) > rankValue(leagueBefore);
|
||||
const demoted = rankValue(leagueAfter) < rankValue(leagueBefore);
|
||||
|
||||
const newProfile: UserProfile = {
|
||||
...profile,
|
||||
rating: ratingAfter,
|
||||
coins: coinsAfter,
|
||||
level: lvl.level,
|
||||
xp: lvl.xp,
|
||||
stats,
|
||||
achievements,
|
||||
unlocked,
|
||||
};
|
||||
|
||||
const reward: RewardResult = {
|
||||
ratingBefore,
|
||||
ratingAfter,
|
||||
ratingDelta: ratingAfter - ratingBefore,
|
||||
coinsBefore,
|
||||
coinsAfter,
|
||||
coinsDelta: coinsAfter - coinsBefore,
|
||||
xpGained: xpGain,
|
||||
levelBefore,
|
||||
levelAfter: lvl.level,
|
||||
leveledUp: lvl.level > levelBefore,
|
||||
newAchievements,
|
||||
promoted,
|
||||
demoted,
|
||||
};
|
||||
|
||||
return { profile: newProfile, reward };
|
||||
}
|
||||
|
||||
/* --------------------------- Daily reward ---------------------------- */
|
||||
|
||||
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000];
|
||||
|
||||
export function dailyRewardFor(day: number): number {
|
||||
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
// 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,
|
||||
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",
|
||||
};
|
||||
|
||||
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 = "";
|
||||
|
||||
private roomCbs = new Set<(r: Room) => void>();
|
||||
private mmCbs = new Set<(s: MatchmakingState) => void>();
|
||||
private friendCbs = new Set<(f: Friend[]) => void>();
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
constructor() {
|
||||
this.session = load<AuthSession>(LS.session);
|
||||
this.profile = load<UserProfile>(LS.profile);
|
||||
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);
|
||||
}
|
||||
|
||||
/* ------------------------------ 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// The single seam between the UI and any backend.
|
||||
// The mock implements this today; a SignalR/.NET client implements it later
|
||||
// without any UI changes.
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
DailyRewardState,
|
||||
Friend,
|
||||
FriendRequest,
|
||||
LeaderboardEntry,
|
||||
MatchSummary,
|
||||
MatchmakingState,
|
||||
RewardResult,
|
||||
Room,
|
||||
ShopItem,
|
||||
UserProfile,
|
||||
} from "./types";
|
||||
|
||||
export interface CreateRoomOptions {
|
||||
targetScore: number;
|
||||
stake: number;
|
||||
ranked: boolean;
|
||||
}
|
||||
|
||||
export interface MatchmakingOptions {
|
||||
stake: number;
|
||||
ranked: boolean;
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface OnlineService {
|
||||
/* ----- auth ----- */
|
||||
getSession(): AuthSession | null;
|
||||
restore(): Promise<{ session: AuthSession; profile: UserProfile } | null>;
|
||||
requestOtp(phone: string): Promise<{ devCode?: string }>;
|
||||
verifyOtp(phone: string, code: string): Promise<AuthSession>;
|
||||
signInEmail(email: string, password: string): Promise<AuthSession>;
|
||||
signUpEmail(email: string, password: string, displayName: string): Promise<AuthSession>;
|
||||
signInGoogle(): Promise<AuthSession>;
|
||||
signOut(): Promise<void>;
|
||||
|
||||
/* ----- profile ----- */
|
||||
getProfile(): Promise<UserProfile>;
|
||||
updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>): Promise<UserProfile>;
|
||||
|
||||
/* ----- friends ----- */
|
||||
listFriends(): Promise<Friend[]>;
|
||||
listRequests(): Promise<FriendRequest[]>;
|
||||
addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
|
||||
acceptRequest(id: string): Promise<void>;
|
||||
declineRequest(id: string): Promise<void>;
|
||||
removeFriend(id: string): Promise<void>;
|
||||
onFriends(cb: (friends: Friend[]) => void): Unsubscribe;
|
||||
|
||||
/* ----- rooms ----- */
|
||||
createRoom(opts: CreateRoomOptions): Promise<Room>;
|
||||
setPartner(roomId: string, friendId: string | null): Promise<Room>;
|
||||
inviteToSeat(roomId: string, seat: 1 | 3, friendId: string): Promise<Room>;
|
||||
addBot(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
|
||||
clearSeat(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
|
||||
startRoom(roomId: string): Promise<Room>;
|
||||
leaveRoom(roomId: string): Promise<void>;
|
||||
onRoom(cb: (room: Room) => void): Unsubscribe;
|
||||
|
||||
/* ----- matchmaking ----- */
|
||||
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
|
||||
cancelMatchmaking(): Promise<void>;
|
||||
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
|
||||
|
||||
/* ----- match players (for the online game driver) ----- */
|
||||
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
||||
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
||||
|
||||
/* ----- leaderboard / shop / daily ----- */
|
||||
getLeaderboard(): Promise<LeaderboardEntry[]>;
|
||||
getShopItems(): Promise<ShopItem[]>;
|
||||
buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>;
|
||||
getDailyState(): Promise<DailyRewardState>;
|
||||
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }>;
|
||||
}
|
||||
|
||||
import { MockOnlineService } from "./mock-service";
|
||||
|
||||
let _service: OnlineService | null = null;
|
||||
|
||||
/** Lazily create the active service. Swap the implementation here later. */
|
||||
export function getService(): OnlineService {
|
||||
if (!_service) {
|
||||
_service = new MockOnlineService();
|
||||
}
|
||||
return _service;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Online / social / gamification domain types.
|
||||
// These are transport-agnostic: the mock service and the future SignalR
|
||||
// client both speak in these shapes.
|
||||
|
||||
import { Suit } from "../hokm/types";
|
||||
|
||||
/* ------------------------------- Auth -------------------------------- */
|
||||
|
||||
export type AuthMethod = "phone" | "email" | "google" | "guest";
|
||||
|
||||
export interface AuthSession {
|
||||
userId: string;
|
||||
token: string;
|
||||
method: AuthMethod;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/* ------------------------------ Profile ------------------------------ */
|
||||
|
||||
export interface PlayerStats {
|
||||
games: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
kotsFor: number; // kots inflicted
|
||||
kotsAgainst: number;
|
||||
tricks: number;
|
||||
bestWinStreak: number;
|
||||
currentWinStreak: number;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string; // avatar id (see AVATARS)
|
||||
phone?: string;
|
||||
email?: string;
|
||||
|
||||
level: number;
|
||||
xp: number; // xp within the current level
|
||||
coins: number;
|
||||
rating: number; // competitive rating
|
||||
|
||||
stats: PlayerStats;
|
||||
ownedAvatars: string[];
|
||||
ownedThemes: string[];
|
||||
achievements: Record<string, number>; // achievementId -> progress count
|
||||
unlocked: string[]; // achievementId list already unlocked
|
||||
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/* ------------------------------- Ranks ------------------------------- */
|
||||
|
||||
export type RankTierId =
|
||||
| "bronze"
|
||||
| "silver"
|
||||
| "gold"
|
||||
| "platinum"
|
||||
| "diamond"
|
||||
| "master";
|
||||
|
||||
export interface RankTier {
|
||||
id: RankTierId;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
/** inclusive rating floor for this tier */
|
||||
floor: number;
|
||||
color: string; // hex for badge
|
||||
}
|
||||
|
||||
export interface LeagueInfo {
|
||||
tier: RankTier;
|
||||
/** division 1 (highest) .. 3 (lowest); master has no divisions */
|
||||
division: number | null;
|
||||
rating: number;
|
||||
/** rating at which the player promotes to the next division/tier */
|
||||
nextThreshold: number | null;
|
||||
/** progress 0..1 toward nextThreshold within the current band */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/* --------------------------- Achievements ---------------------------- */
|
||||
|
||||
export interface AchievementDef {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descFa: string;
|
||||
descEn: string;
|
||||
icon: string; // emoji or lucide name
|
||||
goal: number; // progress needed to unlock
|
||||
coinReward: number;
|
||||
}
|
||||
|
||||
export interface AchievementView extends AchievementDef {
|
||||
progress: number;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------ Friends ------------------------------ */
|
||||
|
||||
export type PresenceStatus = "online" | "offline" | "in-game";
|
||||
|
||||
export interface Friend {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
level: number;
|
||||
rating: number;
|
||||
status: PresenceStatus;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
from: Friend;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/* ------------------------------- Rooms ------------------------------- */
|
||||
|
||||
export type RoomStatus = "open" | "starting" | "in-game" | "closed";
|
||||
|
||||
export type SeatOccupantKind = "you" | "friend" | "bot" | "empty" | "invited";
|
||||
|
||||
export interface RoomSeat {
|
||||
seat: 0 | 1 | 2 | 3;
|
||||
kind: SeatOccupantKind;
|
||||
/** present for you/friend/bot/invited */
|
||||
player?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
level: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
code: string; // shareable join code
|
||||
hostId: string;
|
||||
status: RoomStatus;
|
||||
/** seats[0] is always the host (you). seat 2 is the partner. */
|
||||
seats: RoomSeat[];
|
||||
targetScore: number;
|
||||
stake: number; // coins
|
||||
ranked: boolean;
|
||||
}
|
||||
|
||||
/* --------------------------- Matchmaking ----------------------------- */
|
||||
|
||||
export type MatchmakingPhase =
|
||||
| "idle"
|
||||
| "searching"
|
||||
| "found"
|
||||
| "ready"
|
||||
| "cancelled";
|
||||
|
||||
export interface MatchmakingState {
|
||||
phase: MatchmakingPhase;
|
||||
/** players revealed so far (incl. you), index = seat */
|
||||
players: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
level: number;
|
||||
rating: number;
|
||||
}[];
|
||||
elapsedMs: number;
|
||||
ranked: boolean;
|
||||
stake: number;
|
||||
}
|
||||
|
||||
/* ------------------------- Match + Rewards --------------------------- */
|
||||
|
||||
export interface MatchSummary {
|
||||
ranked: boolean;
|
||||
stake: number;
|
||||
won: boolean;
|
||||
kotFor: boolean;
|
||||
kotAgainst: boolean;
|
||||
tricksWon: number; // your team's total tricks across the match
|
||||
rounds: number;
|
||||
trump: Suit | null;
|
||||
}
|
||||
|
||||
export interface AchievementUnlock {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
icon: string;
|
||||
coinReward: number;
|
||||
}
|
||||
|
||||
export interface RewardResult {
|
||||
ratingBefore: number;
|
||||
ratingAfter: number;
|
||||
ratingDelta: number;
|
||||
coinsBefore: number;
|
||||
coinsAfter: number;
|
||||
coinsDelta: number;
|
||||
xpGained: number;
|
||||
levelBefore: number;
|
||||
levelAfter: number;
|
||||
leveledUp: boolean;
|
||||
newAchievements: AchievementUnlock[];
|
||||
promoted: boolean;
|
||||
demoted: boolean;
|
||||
}
|
||||
|
||||
/* ---------------------------- Leaderboard ---------------------------- */
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
level: number;
|
||||
rating: number;
|
||||
isYou: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------- Shop -------------------------------- */
|
||||
|
||||
export type ShopItemKind = "avatar" | "theme";
|
||||
|
||||
export interface ShopItem {
|
||||
id: string;
|
||||
kind: ShopItemKind;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
price: number;
|
||||
preview: string; // emoji/avatar id/color
|
||||
}
|
||||
|
||||
/* --------------------------- Daily reward ---------------------------- */
|
||||
|
||||
export interface DailyRewardState {
|
||||
/** day index 1..7 the player is currently on */
|
||||
day: number;
|
||||
/** ISO date (yyyy-mm-dd) the reward was last claimed */
|
||||
lastClaimed: string | null;
|
||||
/** whether today's reward is available to claim */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------ Avatars ------------------------------ */
|
||||
|
||||
export const AVATARS: { id: string; emoji: string }[] = [
|
||||
{ id: "a-fox", emoji: "🦊" },
|
||||
{ id: "a-lion", emoji: "🦁" },
|
||||
{ id: "a-owl", emoji: "🦉" },
|
||||
{ id: "a-tiger", emoji: "🐯" },
|
||||
{ id: "a-panda", emoji: "🐼" },
|
||||
{ id: "a-eagle", emoji: "🦅" },
|
||||
{ id: "a-wolf", emoji: "🐺" },
|
||||
{ id: "a-cat", emoji: "🐱" },
|
||||
{ id: "a-dragon", emoji: "🐲" },
|
||||
{ id: "a-unicorn", emoji: "🦄" },
|
||||
];
|
||||
|
||||
export function avatarEmoji(id: string): string {
|
||||
return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊";
|
||||
}
|
||||
Reference in New Issue
Block a user