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;
|
||||
}
|
||||
Reference in New Issue
Block a user