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:
soroush.asadi
2026-06-04 10:11:00 +03:30
parent dff1a34f95
commit e2d0a602b6
41 changed files with 5766 additions and 93 deletions
+286
View File
@@ -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;
}