From b66e7f77a55e021350509777f03c22a9cffc96cf Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 22:47:36 +0330 Subject: [PATCH] 100+ achievements, forfeit, leagues floor, bot humanize, 95k starter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achievements: generator-driven, now 100+ across 7 categories (added Rulership) mirrored client + server with identical ids/goals/coins. New tracked stats: hakemRounds (be the hakem — incl. "7× Hakem"), roundsWon, plus losses metric. Custom achievement-only sticker packs (Rulership 👑, Firestorm 🔥) with new inline-SVG art (crown-gold, seven-zip, streak-fire), unlocked by hakem_7 / streak_10. Server GameRoom tallies hakem rounds per seat + rounds won per team; client tallies the same for vs-computer/private games (dealId-deduped). Forfeit (surrender): a player can request forfeit; if the teammate is a bot it auto-confirms, otherwise the human teammate gets a confirm/decline prompt (20s timeout). Result: forfeiting with ≥1 round won = normal loss; 0 rounds = Kot. Wired client↔server over the hub (RequestForfeit/ConfirmForfeit/DeclineForfeit + "forfeit" event); offline/vs-computer ends immediately in the store. Flag button + confirm dialogs in the table. Online count: never shows below 50 — live service floors the real count with a drifting believable number (mock base lowered to ~50–170). Matchmaking: real players get a longer priority window (9s) before bots fill; bots now occasionally react after winning a trick (humanize). Coins: starter pack is 95,000 Toman (50k coins); packs rescaled up (server + mock). Verified: dotnet build + tsc + next build clean; sim unlocks 57 achievements/500 matches; live server: starter=95000, a 7-hakem win unlocks hakem_7 + wins_1 with hakemRounds/roundsWon persisted. Images rebuilt on :1500/:1505. Co-Authored-By: Claude Opus 4.8 --- scripts/sim.ts | 9 +- server/src/Hokm.Server/Game/GameManager.cs | 6 +- server/src/Hokm.Server/Game/GameRoom.cs | 118 +++++++++++++++++- server/src/Hokm.Server/Hubs/GameHub.cs | 3 + .../src/Hokm.Server/Profiles/Gamification.cs | 81 +++++------- .../src/Hokm.Server/Profiles/ProfileModels.cs | 4 + .../Hokm.Server/Profiles/ProfileService.cs | 8 +- src/components/GameTable.tsx | 50 +++++++- src/components/online/Sticker.tsx | 37 ++++++ src/components/screens/BuyCoinsScreen.tsx | 2 +- src/components/screens/GameScreen.tsx | 51 +++++++- src/lib/game-store.ts | 70 ++++++++++- src/lib/i18n.tsx | 16 +++ src/lib/online/gamification.ts | 116 ++++++++++------- src/lib/online/mock-service.ts | 24 ++-- src/lib/online/service.ts | 8 ++ src/lib/online/signalr-service.ts | 18 ++- src/lib/online/types.ts | 16 ++- 18 files changed, 510 insertions(+), 127 deletions(-) diff --git a/scripts/sim.ts b/scripts/sim.ts index 9299cd7..90dd8e6 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -86,6 +86,7 @@ function baseProfile(): UserProfile { stats: { games: 0, wins: 0, losses: 0, kotsFor: 0, kotsAgainst: 0, tricks: 0, bestWinStreak: 0, currentWinStreak: 0, shutoutWins: 0, + hakemRounds: 0, roundsWon: 0, }, plan: "free", ownedAvatars: ["a-fox"], @@ -123,6 +124,8 @@ for (let i = 0; i < M; i++) { rounds: 7, trump: "spades", shutout: won && i % 8 === 0, + hakemRounds: i % 3, + roundsWon: won ? 7 : i % 7, }; const before = profile; const { profile: after, reward } = applyMatchResult(before, summary, 1000); @@ -137,10 +140,10 @@ for (let i = 0; i < M; i++) { assert(after.level >= before.level, "level monotonic"); // unlocked list only grows assert(after.unlocked.length >= before.unlocked.length, "achievements monotonic"); - // first win unlocks first_win + // first win unlocks the first-win achievement (wins_1) if (won && !firstWinSeen) { firstWinSeen = true; - assert(after.unlocked.includes("first_win"), "first_win unlocks on first win"); + assert(after.unlocked.includes("wins_1"), "wins_1 unlocks on first win"); } profile = after; } @@ -149,7 +152,7 @@ for (let i = 0; i < M; i++) { { const r = applyMatchResult(baseProfile(), { ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false, - tricksWon: 7, rounds: 7, trump: null, shutout: false, + tricksWon: 7, rounds: 7, trump: null, shutout: false, hakemRounds: 0, roundsWon: 7, }, 1000); assert(r.reward.ratingDelta === 0, "casual match must not change rating"); } diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 84afd17..9c837b1 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -17,7 +17,8 @@ public sealed class Player /// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.) public sealed class GameManager { - private const int QueueWaitMs = 6000; + // Real players get priority: wait this long for humans before bots fill in. + private const int QueueWaitMs = 9000; private static readonly string[] BotNames = { "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" }; @@ -140,6 +141,9 @@ public sealed class GameManager public void PlayCard(string userId, string cardId) => RoomOf(userId)?.HumanPlay(userId, cardId); public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit); public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction); + public void RequestForfeit(string userId) => RoomOf(userId)?.RequestForfeit(userId); + public void ConfirmForfeit(string userId) => RoomOf(userId)?.ConfirmForfeit(userId); + public void DeclineForfeit(string userId) => RoomOf(userId)?.DeclineForfeit(userId); private readonly ConcurrentDictionary _onlineUsers = new(); public int OnlineCount => _onlineUsers.Count; diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 712d1ba..1c0e7b5 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -42,8 +42,15 @@ public sealed class GameRoom : IDisposable // match-level tally for server-authoritative rewards private readonly int[] _tallyTricks = new int[2]; private readonly bool[] _tallyKot = new bool[2]; + private readonly int[] _hakemRounds = new int[4]; // per-seat hakem count + private int _lastHakemDeal = -1; private bool _rewardsApplied; + // forfeit (surrender) state + private int? _forfeitPendingTeam; + private string? _forfeitRequester; + private Timer? _forfeitTimer; + public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant(); public SeatSlot[] Seats { get; } @@ -95,25 +102,29 @@ public sealed class GameRoom : IDisposable if (rr.Kot) _tallyKot[rr.WinningTeam] = true; } - private async Task ApplyRewardsAsync() + private async Task ApplyRewardsAsync(int? forfeitTeam = null, bool forfeitKot = false) { if (_rewardsApplied) return; _rewardsApplied = true; - int winner = State.MatchWinner ?? 0; + int winner = forfeitTeam.HasValue ? 1 - forfeitTeam.Value : (State.MatchWinner ?? 0); int rounds = State.MatchScore[0] + State.MatchScore[1]; foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null)) { int team = slot.Seat % 2; + bool won = team == winner; var summary = new MatchSummaryDto { Ranked = Ranked, Stake = Stake, - Won = team == winner, - KotFor = _tallyKot[team], - KotAgainst = _tallyKot[1 - team], + Won = won, + // On a forfeit, the kot penalty is the surrendering team scoring 0 rounds. + KotFor = forfeitTeam.HasValue ? (won && forfeitKot) : _tallyKot[team], + KotAgainst = forfeitTeam.HasValue ? (!won && forfeitKot) : _tallyKot[1 - team], TricksWon = _tallyTricks[team], Rounds = rounds, - Shutout = team == winner && State.MatchScore[1 - winner] == 0, + Shutout = won && State.MatchScore[1 - winner] == 0, + HakemRounds = _hakemRounds[slot.Seat], + RoundsWon = State.MatchScore[team], }; using var scope = _scopes.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); @@ -170,6 +181,84 @@ public sealed class GameRoom : IDisposable } } + /* ----------------------------- forfeit ----------------------------- */ + + public void RequestForfeit(string userId) + { + lock (_lock) + { + if (_finished || _forfeitPendingTeam != null) return; + var seat = SeatOf(userId); + if (seat is null) return; + int team = seat.Value % 2; + var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value); + // No human teammate to ask → forfeit immediately. + if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected) + { + FinalizeForfeit(team); + return; + } + _forfeitPendingTeam = team; + _forfeitRequester = userId; + var requester = Seats.First(s => s.UserId == userId); + _ = _hub.Clients.User(mate.UserId!).SendAsync("forfeit", new { bySeat = requester.Seat, byName = requester.Name }); + _forfeitTimer?.Dispose(); + _forfeitTimer = new Timer(_ => + { + lock (_lock) + { + if (_forfeitPendingTeam == team) + { + _forfeitPendingTeam = null; + _forfeitRequester = null; + _ = _hub.Clients.User(mate.UserId!).SendAsync("forfeit", (object?)null); + } + } + }, null, 20000, Timeout.Infinite); + } + } + + public void ConfirmForfeit(string userId) + { + lock (_lock) + { + if (_forfeitPendingTeam is null) return; + var seat = SeatOf(userId); + if (seat is null || seat.Value % 2 != _forfeitPendingTeam.Value || userId == _forfeitRequester) return; + _forfeitTimer?.Dispose(); + FinalizeForfeit(_forfeitPendingTeam.Value); + } + } + + public void DeclineForfeit(string userId) + { + lock (_lock) + { + if (_forfeitPendingTeam is null) return; + var seat = SeatOf(userId); + if (seat is null || seat.Value % 2 != _forfeitPendingTeam.Value) return; + var requester = _forfeitRequester; + _forfeitPendingTeam = null; + _forfeitRequester = null; + _forfeitTimer?.Dispose(); + if (requester != null) _ = _hub.Clients.User(requester).SendAsync("forfeit", (object?)null); + } + } + + private void FinalizeForfeit(int team) + { + _forfeitPendingTeam = null; + _forfeitRequester = null; + _forfeitTimer?.Dispose(); + bool kot = State.MatchScore[team] == 0; + State.MatchWinner = 1 - team; + State.Phase = Phase.MatchOver; + _timer?.Dispose(); + _ = ApplyRewardsAsync(team, kot); + BroadcastState(); + if (!_finished) { _finished = true; OnFinished?.Invoke(this); } + } + /* ----------------------- scheduling / driver ----------------------- */ private void ScheduleAndBroadcast() @@ -187,6 +276,12 @@ public sealed class GameRoom : IDisposable case Phase.ChoosingTrump: { var hakem = State.Hakem!.Value; + // Tally hakem rounds once per deal (for the hakem achievements). + if (State.DealId != _lastHakemDeal) + { + _lastHakemDeal = State.DealId; + _hakemRounds[hakem]++; + } if (Seats[hakem].IsBot) SetTimer(AiTrumpMs, () => { @@ -220,6 +315,7 @@ public sealed class GameRoom : IDisposable } case Phase.TrickComplete: + MaybeBotReact(); SetTimer(TrickPauseMs, () => { Rules.AdvanceAfterTrick(State, 2); @@ -242,6 +338,16 @@ public sealed class GameRoom : IDisposable BroadcastState(); } + private static readonly string[] BotEmotes = { "👍", "😎", "🔥", "😂", "👏", "🎯", "🙌" }; + /// Occasionally a bot reacts after winning a trick — makes them feel human. + private void MaybeBotReact() + { + var w = State.LastTrickWinner; + if (w is null || !Seats[w.Value].IsBot) return; + if (_rng.Next(100) < 18) + Broadcast("reaction", new ReactionDto(w.Value, BotEmotes[_rng.Next(BotEmotes.Length)])); + } + private void AutoPlay(int seat) { lock (_lock) diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs index 36be157..f350213 100644 --- a/server/src/Hokm.Server/Hubs/GameHub.cs +++ b/server/src/Hokm.Server/Hubs/GameHub.cs @@ -40,4 +40,7 @@ public sealed class GameHub : Hub public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId); public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit); public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction); + public void RequestForfeit() => _manager.RequestForfeit(Uid); + public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid); + public void DeclineForfeit() => _manager.DeclineForfeit(Uid); } diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs index f47311a..f09237e 100644 --- a/server/src/Hokm.Server/Profiles/Gamification.cs +++ b/server/src/Hokm.Server/Profiles/Gamification.cs @@ -44,64 +44,47 @@ public static class Gamification // metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach. private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon); - private static readonly AchDef[] Achs = + // Mirrors src/lib/online/gamification.ts (same ids/goals/coins/metrics). + private static int Coin(int g) => Math.Max(100, (int)Math.Floor((80.0 + g * 12) / 50.0 + 0.5) * 50); + private static string Fa(int n) => + new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray()); + + private static AchDef[] Tier(string metric, string prefix, string icon, int[] goals, Func faName, Func enName) + => goals.Select(g => new AchDef($"{prefix}_{g}", metric, 0, g, Coin(g), faName(g), enName(g), icon)).ToArray(); + + private static readonly AchDef[] Achs = BuildAchs(); + private static AchDef[] BuildAchs() { - // victories - new("first_win", "wins", 0, 1, 100, "اولین برد", "First Win", "🥇"), - new("wins_10", "wins", 0, 10, 300, "۱۰ برد", "10 Wins", "🎯"), - new("wins_25", "wins", 0, 25, 600, "۲۵ برد", "25 Wins", "🏅"), - new("wins_50", "wins", 0, 50, 1000, "۵۰ برد", "50 Wins", "🏆"), - new("wins_100", "wins", 0, 100, 2000, "۱۰۰ برد", "100 Wins", "👑"), - new("wins_250", "wins", 0, 250, 4000, "۲۵۰ برد", "250 Wins", "💎"), - new("wins_500", "wins", 0, 500, 8000, "۵۰۰ برد", "500 Wins", "🌟"), - new("shutout_1", "shutoutWins", 0, 1, 400, "هفت–هیچ", "Seven–Zip", "🧹"), - new("shutout_5", "shutoutWins", 0, 5, 900, "۵ بار هفت–هیچ", "5× Sweep", "🧨"), - new("shutout_25", "shutoutWins", 0, 25, 3000, "۲۵ بار هفت–هیچ", "25× Sweep", "☄️"), - // kot - new("first_kot", "kotsFor", 0, 1, 150, "اولین کُت", "First Kot", "🔥"), - new("kot_5", "kotsFor", 0, 5, 300, "۵ کُت", "5 Kots", "🌶️"), - new("kot_10", "kotsFor", 0, 10, 500, "۱۰ کُت", "10 Kots", "🔥"), - new("kot_25", "kotsFor", 0, 25, 1200, "۲۵ کُت", "25 Kots", "💥"), - new("kot_50", "kotsFor", 0, 50, 2500, "۵۰ کُت", "50 Kots", "⚡"), - new("kot_100", "kotsFor", 0, 100, 5000, "۱۰۰ کُت", "100 Kots", "👹"), - // streaks - new("streak_3", "bestWinStreak", 0, 3, 200, "۳ برد پیاپی", "3 Win Streak", "➡️"), - new("streak_5", "bestWinStreak", 0, 5, 400, "۵ برد پیاپی", "5 Win Streak", "⚡"), - new("streak_10", "bestWinStreak", 0, 10, 1000, "۱۰ برد پیاپی", "10 Win Streak", "🌊"), - new("streak_15", "bestWinStreak", 0, 15, 2000, "۱۵ برد پیاپی", "15 Win Streak", "🚀"), - // levels - new("level_5", "level", 0, 5, 150, "سطح ۵", "Level 5", "⭐"), - new("level_10", "level", 0, 10, 300, "سطح ۱۰", "Level 10", "🌟"), - new("level_15", "level", 0, 15, 500, "سطح ۱۵", "Level 15", "✨"), - new("level_20", "level", 0, 20, 800, "سطح ۲۰", "Level 20", "💫"), - new("level_25", "level", 0, 25, 1200, "سطح ۲۵", "Level 25", "🔆"), - new("level_30", "level", 0, 30, 1600, "سطح ۳۰", "Level 30", "🎖️"), - new("level_40", "level", 0, 40, 2500, "سطح ۴۰", "Level 40", "🏵️"), - new("level_50", "level", 0, 50, 4000, "سطح ۵۰", "Level 50", "🌠"), - // ranks - new("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈"), - new("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"), - new("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"), - new("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"), - new("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"), - // veterancy - new("games_10", "games", 0, 10, 150, "۱۰ بازی", "10 Games", "🎮"), - new("games_50", "games", 0, 50, 350, "۵۰ بازی", "50 Games", "🕹️"), - new("games_200", "games", 0, 200, 1200, "۲۰۰ بازی", "200 Games", "🎲"), - new("games_500", "games", 0, 500, 3000, "۵۰۰ بازی", "500 Games", "🃏"), - new("games_1000", "games", 0, 1000, 7000, "۱۰۰۰ بازی", "1000 Games", "♾️"), - new("tricks_100", "tricks", 0, 100, 300, "۱۰۰ دست", "100 Tricks", "🎴"), - new("tricks_1000", "tricks", 0, 1000, 2000, "۱۰۰۰ دست", "1000 Tricks", "🗂️"), - }; + var l = new List(); + l.AddRange(Tier("wins", "wins", "🏆", new[] { 1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000 }, g => $"{Fa(g)} برد", g => $"{g} Wins")); + l.AddRange(Tier("shutoutWins", "shutout", "🧹", new[] { 1, 3, 5, 10, 25, 50, 100 }, g => $"{Fa(g)} بار هفت–هیچ", g => $"{g}× Sweep")); + l.AddRange(Tier("kotsFor", "kot", "🔥", new[] { 1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500 }, g => $"{Fa(g)} کُت", g => $"{g} Kots")); + l.AddRange(Tier("bestWinStreak", "streak", "⚡", new[] { 2, 3, 5, 7, 10, 15, 20, 25, 30, 40 }, g => $"{Fa(g)} برد پیاپی", g => $"{g} Win Streak")); + l.AddRange(Tier("hakemRounds", "hakem", "👑", new[] { 7, 25, 50, 100, 250, 500, 1000 }, g => $"{Fa(g)} بار حاکم", g => $"Hakem {g}×")); + l.AddRange(Tier("level", "level", "⭐", new[] { 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100 }, g => $"سطح {Fa(g)}", g => $"Level {g}")); + l.AddRange(Tier("games", "games", "🎮", new[] { 10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000 }, g => $"{Fa(g)} بازی", g => $"{g} Games")); + l.AddRange(Tier("roundsWon", "rounds", "🎴", new[] { 25, 100, 250, 500, 1000, 2000, 5000 }, g => $"{Fa(g)} دست برده", g => $"{g} Rounds Won")); + l.AddRange(Tier("tricks", "tricks", "🗂️", new[] { 50, 100, 250, 500, 1000, 2500, 5000, 10000 }, g => $"{Fa(g)} دست‌برد", g => $"{g} Tricks")); + l.AddRange(Tier("losses", "grit", "🛡️", new[] { 10, 50, 100 }, g => $"{Fa(g)} باخت", g => $"{g} Losses")); + l.Add(new AchDef("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈")); + l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇")); + l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️")); + l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠")); + l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑")); + return l.ToArray(); + } private static int Metric(string m, StatsDto st, int level) => m switch { "wins" => st.Wins, + "losses" => st.Losses, "kotsFor" => st.KotsFor, "bestWinStreak" => st.BestWinStreak, "shutoutWins" => st.ShutoutWins, "games" => st.Games, "tricks" => st.Tricks, + "hakemRounds" => st.HakemRounds, + "roundsWon" => st.RoundsWon, "level" => level, _ => 0, }; @@ -166,6 +149,8 @@ public static class Gamification st.CurrentWinStreak = cur; st.BestWinStreak = Math.Max(st.BestWinStreak, cur); st.ShutoutWins += s.Won && s.Shutout ? 1 : 0; + st.HakemRounds += s.HakemRounds; + st.RoundsWon += s.RoundsWon; var newAch = new List(); int achCoins = 0; diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs index beb24a2..789eab3 100644 --- a/server/src/Hokm.Server/Profiles/ProfileModels.cs +++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs @@ -14,6 +14,8 @@ public class StatsDto public int BestWinStreak { get; set; } public int CurrentWinStreak { get; set; } public int ShutoutWins { get; set; } + public int HakemRounds { get; set; } + public int RoundsWon { get; set; } } /// Mirrors the client UserProfile (camelCase JSON). @@ -61,6 +63,8 @@ public class MatchSummaryDto public int TricksWon { get; set; } public int Rounds { get; set; } public bool Shutout { get; set; } + public int HakemRounds { get; set; } + public int RoundsWon { get; set; } } public class AchievementUnlockDto diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 2e4ccc9..c722df0 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -11,10 +11,10 @@ public class ProfileService public static readonly CoinPackDto[] Packs = { - new() { Id = "p1", Coins = 1000, Bonus = 0, PriceToman = 19000 }, - new() { Id = "p2", Coins = 5000, Bonus = 500, PriceToman = 89000, Tag = "popular" }, - new() { Id = "p3", Coins = 12000, Bonus = 2000, PriceToman = 179000, Tag = "best" }, - new() { Id = "p4", Coins = 30000, Bonus = 7000, PriceToman = 399000 }, + new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" }, + new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" }, + new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" }, + new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 }, }; private static ProfileDto Default(string userId, string? name) => new() diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 4d2c9fa..f8b6526 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Crown, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react"; +import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react"; import { useEffect, useState } from "react"; import { TURN_MS, useGameStore } from "@/lib/game-store"; import { useSoundStore } from "@/lib/sound-store"; @@ -46,11 +46,15 @@ function useCardSkins() { }; } -export function GameTable({ onExit }: { onExit?: () => void } = {}) { +export function GameTable({ + onExit, + onForfeit, +}: { onExit?: () => void; onForfeit?: () => void } = {}) { const game = useGameStore((s) => s.game); const reset = useGameStore((s) => s.reset); const mode = useGameStore((s) => s.mode); const { t } = useI18n(); + const [askFf, setAskFf] = useState(false); const sfx = useSoundStore((s) => s.sfx); const music = useSoundStore((s) => s.music); @@ -84,6 +88,15 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) { )} + {onForfeit && ( + + )} + + + + + )} + + {/* Felt table */}
diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx index 8b2b492..fed863f 100644 --- a/src/components/online/Sticker.tsx +++ b/src/components/online/Sticker.tsx @@ -187,6 +187,43 @@ const STICKERS: Record = { ), + + /* ------------------- custom (achievement-unlocked) ------------------ */ + "crown-gold": ( + <> + + + + + + + + + + + + + + ), + "seven-zip": ( + <> + + 7–0 + + ), + "streak-fire": ( + <> + + + + + + + + + + + ), }; export const STICKER_IDS = Object.keys(STICKERS); diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx index b369a44..48ee6c0 100644 --- a/src/components/screens/BuyCoinsScreen.tsx +++ b/src/components/screens/BuyCoinsScreen.tsx @@ -77,7 +77,7 @@ export function BuyCoinsScreen() { > {p.tag && ( - {p.tag === "best" ? t("buy.best") : t("buy.popular")} + {p.tag === "best" ? t("buy.best") : p.tag === "starter" ? t("buy.starter") : t("buy.popular")} )} diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index 328771f..841d037 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -1,11 +1,13 @@ "use client"; +import { motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { GameTable } from "@/components/GameTable"; import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; +import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; import { pushNotification } from "@/lib/notification-store"; import { MatchSummary, RewardResult } from "@/lib/online/types"; @@ -18,9 +20,13 @@ export function GameScreen() { const tally = useGameStore((s) => s.tally); const meta = useGameStore((s) => s.matchMeta); const reset = useGameStore((s) => s.reset); + const forfeited = useGameStore((s) => s.forfeited); + const forfeitRequest = useGameStore((s) => s.forfeitRequest); + const respondForfeit = useGameStore((s) => s.respondForfeit); const returnTo = useUIStore((s) => s.returnTo); const go = useUIStore((s) => s.go); const refreshProfile = useSessionStore((s) => s.refreshProfile); + const { t } = useI18n(); const [reward, setReward] = useState(null); const submitted = useRef(false); @@ -69,12 +75,15 @@ export function GameScreen() { stake: meta.stake, won: game.matchWinner === 0, kotFor: tally.kotFor, - kotAgainst: tally.kotAgainst, + // Forfeiting with 0 rounds won = a Kot loss. + kotAgainst: tally.kotAgainst || (forfeited && game.matchScore[0] === 0), tricksWon: tally.tricksTeam0, rounds: game.matchScore[0] + game.matchScore[1], trump: game.trump, // shutout = you won and the opponent never scored a round (e.g. 7–0) shutout: game.matchWinner === 0 && game.matchScore[1] === 0, + hakemRounds: tally.hakemRounds, + roundsWon: game.matchScore[0], }; getService() .submitMatchResult(summary) @@ -84,7 +93,7 @@ export function GameScreen() { notifyAchievements(r); }); } - }, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]); + }, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, forfeited, refreshProfile]); // Server-run ranked games: the reward arrives via the hub. useEffect(() => { @@ -96,9 +105,45 @@ export function GameScreen() { } }, [live, serverReward, refreshProfile]); + const canForfeit = game.phase !== "match-over" && !forfeited; + return ( <> - + useGameStore.getState().forfeit() : undefined} + /> + + {/* teammate asked to forfeit — confirm or decline */} + {forfeitRequest && ( + + +
🏳️
+

{t("forfeit.title")}

+

+ {t("forfeit.teammateAsks").replace("{name}", forfeitRequest.byName)} +

+

{t("forfeit.rule")}

+
+ + +
+
+
+ )} + {reward && ( void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; @@ -91,6 +96,10 @@ interface GameStore { minimize: () => void; /** Return to a minimized match (re-arms local AI timers; live keeps streaming). */ resume: () => void; + /** Request to forfeit (surrender) the match. */ + forfeit: () => void; + /** Respond to a teammate's forfeit request. */ + respondForfeit: (confirm: boolean) => void; reset: () => void; } @@ -99,6 +108,7 @@ const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"]; let pending: ReturnType | null = null; let liveUnsub: (() => void) | null = null; let rewardUnsub: (() => void) | null = null; +let forfeitUnsub: (() => void) | null = null; let liveSvc: OnlineService | null = null; function clearPending() { if (pending) { @@ -108,9 +118,12 @@ function clearPending() { } function freshTally(): MatchTally { - return { tricksTeam0: 0, kotFor: false, kotAgainst: false }; + return { tricksTeam0: 0, kotFor: false, kotAgainst: false, hakemRounds: 0 }; } +/** Deals already counted toward the hakem tally (client-run games). */ +let countedHakemDeals = new Set(); + function mapCard(c: { suit: string; rank: number; id: string }): Card { return { suit: c.suit as Suit, rank: c.rank as Rank, id: c.id }; } @@ -166,6 +179,7 @@ export const useGameStore = create((set, get) => { tricksTeam0: t.tricksTeam0 + result.tricks[0], kotFor: t.kotFor || (result.winningTeam === 0 && result.kot), kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot), + hakemRounds: t.hakemRounds, }, }); } @@ -195,6 +209,11 @@ export const useGameStore = create((set, get) => { case "choosing-trump": { const hakem = g.hakem!; + // Tally hakem rounds for you (seat 0) — once per deal. + if (hakem === 0 && !countedHakemDeals.has(g.dealId)) { + countedHakemDeals.add(g.dealId); + set({ tally: { ...get().tally, hakemRounds: get().tally.hakemRounds + 1 } }); + } if (g.players[hakem].isHuman) { // human hakem: timed choice, system auto-picks on timeout set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null }); @@ -300,9 +319,12 @@ export const useGameStore = create((set, get) => { live: false, serverReward: null, paused: false, + forfeited: false, + forfeitRequest: null, newMatch: (settings) => { clearPending(); + countedHakemDeals = new Set(); sound.init(); const initial = createInitialState(settings); set({ @@ -310,6 +332,8 @@ export const useGameStore = create((set, get) => { started: true, mode: "ai", paused: false, + forfeited: false, + forfeitRequest: null, matchMeta: { ranked: false, stake: 0 }, tally: freshTally(), turnDeadline: null, @@ -326,6 +350,7 @@ export const useGameStore = create((set, get) => { newOnlineMatch: (cfg) => { clearPending(); + countedHakemDeals = new Set(); sound.init(); const names = cfg.players.map((p) => p.displayName) as GameSettings["names"]; const initial = createInitialState({ names, targetScore: cfg.targetScore }); @@ -334,6 +359,8 @@ export const useGameStore = create((set, get) => { started: true, mode: "online", paused: false, + forfeited: false, + forfeitRequest: null, matchMeta: { ranked: cfg.ranked, stake: cfg.stake }, tally: freshTally(), turnDeadline: null, @@ -354,8 +381,10 @@ export const useGameStore = create((set, get) => { liveSvc = service; if (liveUnsub) liveUnsub(); if (rewardUnsub) rewardUnsub(); + if (forfeitUnsub) forfeitUnsub(); liveUnsub = service.onState((s) => get().applyServerState(s)); rewardUnsub = service.onReward((r) => set({ serverReward: r })); + forfeitUnsub = service.onForfeit((r) => set({ forfeitRequest: r })); set({ game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }), started: true, @@ -363,6 +392,8 @@ export const useGameStore = create((set, get) => { live: true, serverReward: null, paused: false, + forfeited: false, + forfeitRequest: null, matchMeta: { ranked: true, stake: 0 }, tally: freshTally(), turnDeadline: null, @@ -445,6 +476,35 @@ export const useGameStore = create((set, get) => { if (!get().live && get().started && get().game.phase !== "match-over") scheduleAuto(); }, + forfeit: () => { + // Live games: ask the server (teammate must confirm, server decides kot). + if (get().live) { + liveSvc?.requestForfeit(); + return; + } + // Client-run (vs computer / private): end now as a loss. If your team won + // no rounds it's a Kot loss; otherwise a normal loss. + const g = get().game; + if (g.phase === "match-over") return; + clearPending(); + set({ + game: { ...g, phase: "match-over", matchWinner: 1 as Team, turn: null }, + forfeited: true, + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, + }); + sound.play("lose"); + }, + + respondForfeit: (confirm) => { + if (get().live) { + if (confirm) liveSvc?.confirmForfeit(); + else liveSvc?.declineForfeit(); + } + set({ forfeitRequest: null }); + }, + reset: () => { clearPending(); if (liveUnsub) { @@ -455,6 +515,10 @@ export const useGameStore = create((set, get) => { rewardUnsub(); rewardUnsub = null; } + if (forfeitUnsub) { + forfeitUnsub(); + forfeitUnsub = null; + } liveSvc = null; set({ game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }), @@ -463,6 +527,8 @@ export const useGameStore = create((set, get) => { live: false, serverReward: null, paused: false, + forfeited: false, + forfeitRequest: null, seatPlayers: [], tally: freshTally(), turnDeadline: null, diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 50e2454..6b4e7a1 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -43,6 +43,13 @@ const fa: Dict = { "lobby.lvl": "سطح", "profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال می‌شود", + "forfeit.title": "تسلیم", + "forfeit.ask": "از این بازی تسلیم می‌شوید؟", + "forfeit.teammateAsks": "{name} می‌خواهد تسلیم شود. موافقید؟", + "forfeit.rule": "اگر حتی یک دست برده باشید باخت عادی، وگرنه کُت می‌شوید.", + "forfeit.confirm": "تسلیم", + "forfeit.keepPlaying": "ادامه می‌دهم", + "seat.you": "شما", "team.us": "ما", "team.them": "حریف", @@ -112,6 +119,7 @@ const fa: Dict = { "buy.bonus": "هدیه", "buy.popular": "محبوب", "buy.best": "بهترین", + "buy.starter": "شروع", "lobby.entry": "ورودی", "lobby.free": "رایگان", @@ -298,6 +306,13 @@ const en: Dict = { "lobby.lvl": "Lvl", "profile.uploadLocked": "Photo upload unlocks at level 25", + "forfeit.title": "Forfeit", + "forfeit.ask": "Surrender this match?", + "forfeit.teammateAsks": "{name} wants to forfeit. Agree?", + "forfeit.rule": "Win ≥1 round = normal loss, otherwise it's a Kot.", + "forfeit.confirm": "Forfeit", + "forfeit.keepPlaying": "Keep playing", + "seat.you": "You", "team.us": "Us", "team.them": "Them", @@ -367,6 +382,7 @@ const en: Dict = { "buy.bonus": "bonus", "buy.popular": "Popular", "buy.best": "Best value", + "buy.starter": "Starter", "lobby.entry": "Entry", "lobby.free": "Free", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index a637e2e..5aad8f5 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -3,7 +3,9 @@ import { AchievementCategoryDef, + AchievementCategoryId, AchievementDef, + AchievementMetric, AchievementUnlock, CardBackDef, CardFrontDef, @@ -167,73 +169,90 @@ export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [ { id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" }, { id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" }, { id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" }, + { id: "hakem", nameFa: "حاکمیت", nameEn: "Rulership", icon: "👑" }, { id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" }, { id: "rank", nameFa: "لیگ", nameEn: "Ranks", icon: "🏅" }, { id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" }, ]; +const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; +/** Western → Persian digits, for generated achievement names. */ +export function faNum(n: number): string { + return String(n).replace(/\d/g, (d) => FA_DIGITS[+d]); +} + +/** Build a tiered family of achievements for one metric (keeps the list DRY). */ +function tier( + category: AchievementCategoryId, + metric: AchievementMetric, + prefix: string, + icon: string, + goals: number[], + faName: (g: string) => string, + enName: (g: number) => string, + faDesc: (g: string) => string, + enDesc: (g: number) => string +): AchievementDef[] { + return goals.map((g) => ({ + id: `${prefix}_${g}`, + category, + metric, + goal: g, + coinReward: Math.max(100, Math.round((80 + g * 12) / 50) * 50), + icon, + nameFa: faName(faNum(g)), + nameEn: enName(g), + descFa: faDesc(faNum(g)), + descEn: enDesc(g), + })); +} + export const ACHIEVEMENTS: AchievementDef[] = [ - // ---- Victories (wins + shutouts) ---- - { id: "first_win", category: "victory", metric: "wins", goal: 1, coinReward: 100, icon: "🥇", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game" }, - { id: "wins_10", category: "victory", metric: "wins", goal: 10, coinReward: 300, icon: "🎯", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games" }, - { id: "wins_25", category: "victory", metric: "wins", goal: 25, coinReward: 600, icon: "🏅", nameFa: "۲۵ برد", nameEn: "25 Wins", descFa: "۲۵ بازی ببرید", descEn: "Win 25 games" }, - { id: "wins_50", category: "victory", metric: "wins", goal: 50, coinReward: 1000, icon: "🏆", nameFa: "۵۰ برد", nameEn: "50 Wins", descFa: "۵۰ بازی ببرید", descEn: "Win 50 games" }, - { id: "wins_100", category: "victory", metric: "wins", goal: 100, coinReward: 2000, icon: "👑", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید (پک استیکر ایرانی)", descEn: "Win 100 games (unlocks Persian stickers)" }, - { id: "wins_250", category: "victory", metric: "wins", goal: 250, coinReward: 4000, icon: "💎", nameFa: "۲۵۰ برد", nameEn: "250 Wins", descFa: "۲۵۰ بازی ببرید", descEn: "Win 250 games" }, - { id: "wins_500", category: "victory", metric: "wins", goal: 500, coinReward: 8000, icon: "🌟", nameFa: "۵۰۰ برد", nameEn: "500 Wins", descFa: "۵۰۰ بازی ببرید", descEn: "Win 500 games" }, - { id: "shutout_1", category: "victory", metric: "shutoutWins", goal: 1, coinReward: 400, icon: "🧹", nameFa: "هفت–هیچ", nameEn: "Seven–Zip", descFa: "بازی را ۷–۰ ببرید (پک استیکر حکم)", descEn: "Win a match 7–0 (unlocks Hokm stickers)" }, - { id: "shutout_5", category: "victory", metric: "shutoutWins", goal: 5, coinReward: 900, icon: "🧨", nameFa: "۵ بار هفت–هیچ", nameEn: "5× Sweep", descFa: "۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 5 times" }, - { id: "shutout_25", category: "victory", metric: "shutoutWins", goal: 25, coinReward: 3000, icon: "☄️", nameFa: "۲۵ بار هفت–هیچ", nameEn: "25× Sweep", descFa: "۲۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 25 times" }, - - // ---- Kot ---- - { id: "first_kot", category: "kot", metric: "kotsFor", goal: 1, coinReward: 150, icon: "🔥", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "یک بار حریف را کُت کنید", descEn: "Inflict a Kot once" }, - { id: "kot_5", category: "kot", metric: "kotsFor", goal: 5, coinReward: 300, icon: "🌶️", nameFa: "۵ کُت", nameEn: "5 Kots", descFa: "۵ بار حریف را کُت کنید", descEn: "Inflict 5 Kots" }, - { id: "kot_10", category: "kot", metric: "kotsFor", goal: 10, coinReward: 500, icon: "🔥", nameFa: "۱۰ کُت", nameEn: "10 Kots", descFa: "۱۰ بار حریف را کُت کنید", descEn: "Inflict 10 Kots" }, - { id: "kot_25", category: "kot", metric: "kotsFor", goal: 25, coinReward: 1200, icon: "💥", nameFa: "۲۵ کُت", nameEn: "25 Kots", descFa: "۲۵ بار حریف را کُت کنید (پک استیکر طعنه)", descEn: "Inflict 25 Kots (unlocks Taunt stickers)" }, - { id: "kot_50", category: "kot", metric: "kotsFor", goal: 50, coinReward: 2500, icon: "⚡", nameFa: "۵۰ کُت", nameEn: "50 Kots", descFa: "۵۰ بار حریف را کُت کنید", descEn: "Inflict 50 Kots" }, - { id: "kot_100", category: "kot", metric: "kotsFor", goal: 100, coinReward: 5000, icon: "👹", nameFa: "۱۰۰ کُت", nameEn: "100 Kots", descFa: "۱۰۰ بار حریف را کُت کنید", descEn: "Inflict 100 Kots" }, - - // ---- Streaks ---- - { id: "streak_3", category: "streak", metric: "bestWinStreak", goal: 3, coinReward: 200, icon: "➡️", nameFa: "۳ برد پیاپی", nameEn: "3 Win Streak", descFa: "۳ بازی پشت سر هم ببرید", descEn: "Win 3 games in a row" }, - { id: "streak_5", category: "streak", metric: "bestWinStreak", goal: 5, coinReward: 400, icon: "⚡", nameFa: "۵ برد پیاپی", nameEn: "5 Win Streak", descFa: "۵ بازی پشت سر هم ببرید", descEn: "Win 5 games in a row" }, - { id: "streak_10", category: "streak", metric: "bestWinStreak", goal: 10, coinReward: 1000, icon: "🌊", nameFa: "۱۰ برد پیاپی", nameEn: "10 Win Streak", descFa: "۱۰ بازی پشت سر هم ببرید", descEn: "Win 10 games in a row" }, - { id: "streak_15", category: "streak", metric: "bestWinStreak", goal: 15, coinReward: 2000, icon: "🚀", nameFa: "۱۵ برد پیاپی", nameEn: "15 Win Streak", descFa: "۱۵ بازی پشت سر هم ببرید", descEn: "Win 15 games in a row" }, - - // ---- Levels (every 5) ---- - { id: "level_5", category: "level", metric: "level", goal: 5, coinReward: 150, icon: "⭐", nameFa: "سطح ۵", nameEn: "Level 5", descFa: "به سطح ۵ برسید", descEn: "Reach level 5" }, - { id: "level_10", category: "level", metric: "level", goal: 10, coinReward: 300, icon: "🌟", nameFa: "سطح ۱۰", nameEn: "Level 10", descFa: "به سطح ۱۰ برسید", descEn: "Reach level 10" }, - { id: "level_15", category: "level", metric: "level", goal: 15, coinReward: 500, icon: "✨", nameFa: "سطح ۱۵", nameEn: "Level 15", descFa: "به سطح ۱۵ برسید", descEn: "Reach level 15" }, - { id: "level_20", category: "level", metric: "level", goal: 20, coinReward: 800, icon: "💫", nameFa: "سطح ۲۰", nameEn: "Level 20", descFa: "به سطح ۲۰ برسید", descEn: "Reach level 20" }, - { id: "level_25", category: "level", metric: "level", goal: 25, coinReward: 1200, icon: "🔆", nameFa: "سطح ۲۵", nameEn: "Level 25", descFa: "به سطح ۲۵ برسید (آپلود عکس باز می‌شود)", descEn: "Reach level 25 (unlocks photo upload)" }, - { id: "level_30", category: "level", metric: "level", goal: 30, coinReward: 1600, icon: "🎖️", nameFa: "سطح ۳۰", nameEn: "Level 30", descFa: "به سطح ۳۰ برسید", descEn: "Reach level 30" }, - { id: "level_40", category: "level", metric: "level", goal: 40, coinReward: 2500, icon: "🏵️", nameFa: "سطح ۴۰", nameEn: "Level 40", descFa: "به سطح ۴۰ برسید", descEn: "Reach level 40" }, - { id: "level_50", category: "level", metric: "level", goal: 50, coinReward: 4000, icon: "🌠", nameFa: "سطح ۵۰", nameEn: "Level 50", descFa: "به سطح ۵۰ برسید", descEn: "Reach level 50" }, - - // ---- Ranks ---- + ...tier("victory", "wins", "wins", "🏆", + [1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000], + (g) => `${g} برد`, (g) => `${g} Wins`, (g) => `${g} بازی ببرید`, (g) => `Win ${g} games`), + ...tier("victory", "shutoutWins", "shutout", "🧹", [1, 3, 5, 10, 25, 50, 100], + (g) => `${g} بار هفت–هیچ`, (g) => `${g}× Sweep`, + (g) => `${g} بار حریف را ۷–۰ ببرید`, (g) => `Sweep the opponent ${g}×`), + ...tier("kot", "kotsFor", "kot", "🔥", [1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500], + (g) => `${g} کُت`, (g) => `${g} Kots`, (g) => `${g} بار حریف را کُت کنید`, (g) => `Inflict ${g} Kots`), + ...tier("streak", "bestWinStreak", "streak", "⚡", [2, 3, 5, 7, 10, 15, 20, 25, 30, 40], + (g) => `${g} برد پیاپی`, (g) => `${g} Win Streak`, + (g) => `${g} بازی پشت سر هم ببرید`, (g) => `Win ${g} games in a row`), + ...tier("hakem", "hakemRounds", "hakem", "👑", [7, 25, 50, 100, 250, 500, 1000], + (g) => `${g} بار حاکم`, (g) => `Hakem ${g}×`, + (g) => `${g} دست حاکم شوید`, (g) => `Be the hakem in ${g} rounds`), + ...tier("level", "level", "level", "⭐", + [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100], + (g) => `سطح ${g}`, (g) => `Level ${g}`, (g) => `به سطح ${g} برسید`, (g) => `Reach level ${g}`), + ...tier("veteran", "games", "games", "🎮", [10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000], + (g) => `${g} بازی`, (g) => `${g} Games`, (g) => `${g} بازی انجام دهید`, (g) => `Play ${g} games`), + ...tier("veteran", "roundsWon", "rounds", "🎴", [25, 100, 250, 500, 1000, 2000, 5000], + (g) => `${g} دست برده`, (g) => `${g} Rounds Won`, (g) => `${g} دست ببرید`, (g) => `Win ${g} rounds`), + ...tier("veteran", "tricks", "tricks", "🗂️", [50, 100, 250, 500, 1000, 2500, 5000, 10000], + (g) => `${g} دست‌برد`, (g) => `${g} Tricks`, (g) => `${g} دست‌برد بگیرید`, (g) => `Win ${g} tricks`), + ...tier("veteran", "losses", "grit", "🛡️", [10, 50, 100], + (g) => `${g} باخت`, (g) => `${g} Losses`, + (g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`), + // ranks (explicit rating floors) { id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 200, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" }, { id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 500, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" }, { id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 1000, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" }, { id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 2000, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" }, { id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 4000, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" }, - - // ---- Veterancy (games + tricks) ---- - { id: "games_10", category: "veteran", metric: "games", goal: 10, coinReward: 150, icon: "🎮", nameFa: "۱۰ بازی", nameEn: "10 Games", descFa: "۱۰ بازی انجام دهید", descEn: "Play 10 games" }, - { id: "games_50", category: "veteran", metric: "games", goal: 50, coinReward: 350, icon: "🕹️", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games" }, - { id: "games_200", category: "veteran", metric: "games", goal: 200, coinReward: 1200, icon: "🎲", nameFa: "۲۰۰ بازی", nameEn: "200 Games", descFa: "۲۰۰ بازی انجام دهید", descEn: "Play 200 games" }, - { id: "games_500", category: "veteran", metric: "games", goal: 500, coinReward: 3000, icon: "🃏", nameFa: "۵۰۰ بازی", nameEn: "500 Games", descFa: "۵۰۰ بازی انجام دهید", descEn: "Play 500 games" }, - { id: "games_1000", category: "veteran", metric: "games", goal: 1000, coinReward: 7000, icon: "♾️", nameFa: "۱۰۰۰ بازی", nameEn: "1000 Games", descFa: "۱۰۰۰ بازی انجام دهید", descEn: "Play 1000 games" }, - { id: "tricks_100", category: "veteran", metric: "tricks", goal: 100, coinReward: 300, icon: "🎴", nameFa: "۱۰۰ دست", nameEn: "100 Tricks", descFa: "۱۰۰ دست ببرید", descEn: "Win 100 tricks" }, - { id: "tricks_1000", category: "veteran", metric: "tricks", goal: 1000, coinReward: 2000, icon: "🗂️", nameFa: "۱۰۰۰ دست", nameEn: "1000 Tricks", descFa: "۱۰۰۰ دست ببرید", descEn: "Win 1000 tricks" }, ]; function metricValue(metric: NonNullable, stats: PlayerStats, level: number): number { switch (metric) { case "wins": return stats.wins; + case "losses": return stats.losses; case "kotsFor": return stats.kotsFor; case "bestWinStreak": return stats.bestWinStreak; case "shutoutWins": return stats.shutoutWins ?? 0; case "games": return stats.games; case "tricks": return stats.tricks; + case "hakemRounds": return stats.hakemRounds ?? 0; + case "roundsWon": return stats.roundsWon ?? 0; case "level": return level; } } @@ -398,6 +417,9 @@ export const STICKER_PACKS: StickerPackDef[] = [ { id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, unlockAchievement: "wins_100" }, // Earned by the "25 Kots" achievement. { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, unlockAchievement: "kot_25" }, + // Custom packs earned only via achievements. + { id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 0, unlockAchievement: "hakem_7" }, + { id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 0, unlockAchievement: "streak_10" }, ]; export function stickerPackById(id: string): StickerPackDef | undefined { @@ -440,6 +462,8 @@ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats { currentWinStreak, bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak), shutoutWins: (stats.shutoutWins ?? 0) + (summary.won && summary.shutout ? 1 : 0), + hakemRounds: (stats.hakemRounds ?? 0) + (summary.hakemRounds ?? 0), + roundsWon: (stats.roundsWon ?? 0) + (summary.roundsWon ?? 0), }; } diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 2175c03..4a05338 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -118,6 +118,8 @@ function defaultProfile(session: AuthSession): UserProfile { bestWinStreak: 0, currentWinStreak: 0, shutoutWins: 0, + hakemRounds: 0, + roundsWon: 0, }, ownedAvatars: [AVATARS[0].id, AVATARS[1].id], ownedCardFronts: ["classic"], @@ -508,6 +510,12 @@ export class MockOnlineService implements OnlineService { onProfile(): Unsubscribe { return () => {}; } onReward(): Unsubscribe { return () => {}; } + // Forfeit is handled client-side for offline/mock games (see game-store). + requestForfeit(): void {} + confirmForfeit(): void {} + declineForfeit(): void {} + onForfeit(): Unsubscribe { return () => {}; } + onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { this.reactionCbs.add(cb); if (this.reactionTimer == null) { @@ -778,10 +786,10 @@ export class MockOnlineService implements OnlineService { async getCoinPacks(): Promise { return [ - { id: "p1", coins: 1000, bonus: 0, priceToman: 19000 }, - { id: "p2", coins: 5000, bonus: 500, priceToman: 89000, tag: "popular" }, - { id: "p3", coins: 12000, bonus: 2000, priceToman: 179000, tag: "best" }, - { id: "p4", coins: 30000, bonus: 7000, priceToman: 399000 }, + { id: "p1", coins: 50000, bonus: 0, priceToman: 95000, tag: "starter" }, + { id: "p2", coins: 120000, bonus: 15000, priceToman: 189000, tag: "popular" }, + { id: "p3", coins: 300000, bonus: 50000, priceToman: 389000, tag: "best" }, + { id: "p4", coins: 700000, bonus: 150000, priceToman: 790000 }, ]; } @@ -796,11 +804,11 @@ export class MockOnlineService implements OnlineService { return { ok: true, profile: this.profile, coins: added }; } - private onlineCount = 600 + Math.floor(Math.random() * 900); + private onlineCount = 60 + Math.floor(Math.random() * 110); async getOnlineCount(): Promise { - // gentle random walk so the badge feels alive - this.onlineCount += Math.round((Math.random() - 0.5) * 40); - this.onlineCount = Math.max(120, Math.min(6000, this.onlineCount)); + // gentle random walk so the badge feels alive; never drops below 50 + this.onlineCount += Math.round((Math.random() - 0.45) * 12); + this.onlineCount = Math.max(50, Math.min(4000, this.onlineCount)); return this.onlineCount; } diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index aa877a5..b39e757 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -10,6 +10,7 @@ import { CoinPack, Conversation, DailyRewardState, + ForfeitRequest, Friend, FriendRequest, LeaderboardEntry, @@ -85,6 +86,13 @@ export interface OnlineService { /** server pushed a match reward (server-run ranked games) */ onReward(cb: (reward: RewardResult) => void): Unsubscribe; + /* ----- forfeit (surrender) ----- */ + requestForfeit(): void; + confirmForfeit(): void; + declineForfeit(): void; + /** teammate requested a forfeit (or null when cleared/declined) */ + onForfeit(cb: (req: ForfeitRequest | null) => void): Unsubscribe; + /* ----- rooms ----- */ createRoom(opts: CreateRoomOptions): Promise; setPartner(roomId: string, friendId: string | null): Promise; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index 165f8c8..fb88a71 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -16,6 +16,7 @@ import { CoinPack, Conversation, DailyRewardState, + ForfeitRequest, Friend, FriendRequest, LeaderboardEntry, @@ -55,6 +56,7 @@ export class SignalrService implements OnlineService { private rewardCbs = new Set<(r: RewardResult) => void>(); private friendCbs = new Set<(f: Friend[]) => void>(); private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>(); + private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>(); private cachedProfile: UserProfile | null = null; private mockNotifUnsub?: () => void; @@ -141,6 +143,8 @@ export class SignalrService implements OnlineService { }); conn.on("social", () => void this.refreshFriends()); conn.on("chat", (m: { peerId: string }) => void this.emitChat(m.peerId)); + conn.on("forfeit", (r: ForfeitRequest | null) => + this.forfeitCbs.forEach((cb) => cb(r && r.byName ? r : null))); this.conn = conn; try { @@ -302,6 +306,14 @@ export class SignalrService implements OnlineService { return () => this.rewardCbs.delete(cb); } + requestForfeit() { void this.conn?.invoke("RequestForfeit"); } + confirmForfeit() { void this.conn?.invoke("ConfirmForfeit"); } + declineForfeit() { void this.conn?.invoke("DeclineForfeit"); } + onForfeit(cb: (r: ForfeitRequest | null) => void): Unsubscribe { + this.forfeitCbs.add(cb); + return () => this.forfeitCbs.delete(cb); + } + /* ----- profile / economy → server (authoritative) ----- */ async getProfile() { @@ -381,16 +393,18 @@ export class SignalrService implements OnlineService { } async getOnlineCount(): Promise { + // Always show a believable floor (≥50) — never the raw small/zero real count. + const floor = await this.mock.getOnlineCount(); // drifts, min 50 try { const res = await fetch(`${SERVER}/api/stats/online`); if (res.ok) { const j = (await res.json()) as { online: number }; - return j.online ?? 0; + return Math.max(j.online ?? 0, floor); } } catch { /* fall through */ } - return this.mock.getOnlineCount(); + return floor; } getLeaderboard(): Promise { return this.mock.getLeaderboard(); } diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index b931d0b..67f7e9e 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -27,6 +27,8 @@ export interface PlayerStats { bestWinStreak: number; currentWinStreak: number; shutoutWins: number; // matches won with the opponent on 0 rounds (e.g. 7–0) + hakemRounds: number; // rounds in which you were the hakem (ruler) + roundsWon: number; // total rounds (dast) your team has won } export type PlanId = "free" | "pro"; @@ -103,11 +105,14 @@ export interface LeagueInfo { /** The cumulative stat an achievement tracks toward its goal. */ export type AchievementMetric = | "wins" + | "losses" | "kotsFor" | "bestWinStreak" | "shutoutWins" | "games" | "tricks" + | "hakemRounds" + | "roundsWon" | "level"; export type AchievementCategoryId = @@ -116,6 +121,7 @@ export type AchievementCategoryId = | "streak" | "level" | "rank" + | "hakem" | "veteran"; export interface AchievementDef { @@ -310,6 +316,14 @@ export interface MatchSummary { rounds: number; trump: Suit | null; shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0) + hakemRounds: number; // rounds you were hakem this match + roundsWon: number; // rounds (dast) your team won this match +} + +/** A teammate's request to forfeit (surrender) the match. */ +export interface ForfeitRequest { + bySeat: number; + byName: string; } export interface AchievementUnlock { @@ -380,7 +394,7 @@ export interface CoinPack { coins: number; bonus: number; // extra coins priceToman: number; - tag?: "popular" | "best"; + tag?: "popular" | "best" | "starter"; } /* --------------------------- Daily reward ---------------------------- */