100+ achievements, forfeit, leagues floor, bot humanize, 95k starter
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 <noreply@anthropic.com>
This commit is contained in:
+6
-3
@@ -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");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed class Player
|
||||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
||||
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<string, int> _onlineUsers = new();
|
||||
public int OnlineCount => _onlineUsers.Count;
|
||||
|
||||
@@ -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<ProfileService>();
|
||||
@@ -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 = { "👍", "😎", "🔥", "😂", "👏", "🎯", "🙌" };
|
||||
/// <summary>Occasionally a bot reacts after winning a trick — makes them feel human.</summary>
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<int, string> faName, Func<int, string> 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<AchDef>();
|
||||
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<AchievementUnlockDto>();
|
||||
int achCoins = 0;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } = {}) {
|
||||
<Volume2 className="size-4 text-gold-400" />
|
||||
)}
|
||||
</button>
|
||||
{onForfeit && (
|
||||
<button
|
||||
onClick={() => setAskFf(true)}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
|
||||
title={t("forfeit.title")}
|
||||
>
|
||||
<Flag className="size-4 text-rose-300/90" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={exit}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
|
||||
@@ -94,6 +107,39 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* forfeit confirm (requester) */}
|
||||
<AnimatePresence>
|
||||
{askFf && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div className="glass rounded-3xl p-6 w-full max-w-xs text-center">
|
||||
<div className="text-4xl mb-2">🏳️</div>
|
||||
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
|
||||
<p className="text-cream/70 text-sm mt-2">{t("forfeit.ask")}</p>
|
||||
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
|
||||
<div className="flex gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAskFf(false);
|
||||
onForfeit?.();
|
||||
}}
|
||||
className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold"
|
||||
>
|
||||
{t("forfeit.confirm")}
|
||||
</button>
|
||||
<button onClick={() => setAskFf(false)} className="flex-1 btn-gold rounded-xl py-3">
|
||||
{t("forfeit.keepPlaying")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Felt table */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
|
||||
|
||||
@@ -187,6 +187,43 @@ const STICKERS: Record<string, React.ReactNode> = {
|
||||
</text>
|
||||
</>
|
||||
),
|
||||
|
||||
/* ------------------- custom (achievement-unlocked) ------------------ */
|
||||
"crown-gold": (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#ffe488" />
|
||||
<stop offset="1" stopColor="#d4a017" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="#2a1a4d" stroke="#d4af37" strokeWidth="2" />
|
||||
<path d="M24 64 L24 40 L36 52 L50 32 L64 52 L76 40 L76 64 Z" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2" strokeLinejoin="round" />
|
||||
<rect x="24" y="64" width="52" height="8" rx="2" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2" />
|
||||
<circle cx="50" cy="30" r="4" fill="#ff5fa2" />
|
||||
<circle cx="24" cy="40" r="3" fill="#6aa6ff" />
|
||||
<circle cx="76" cy="40" r="3" fill="#6aa6ff" />
|
||||
</>
|
||||
),
|
||||
"seven-zip": (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="44" fill="#0d6b5e" stroke="#2dd4bf" strokeWidth="2" />
|
||||
<text x="50" y="63" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="900" fontSize="34" fill="#effdf8">7–0</text>
|
||||
</>
|
||||
),
|
||||
"streak-fire": (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="sf" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stopColor="#ff3d00" />
|
||||
<stop offset="0.6" stopColor="#ff9100" />
|
||||
<stop offset="1" stopColor="#ffea00" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="#2b0a0a" stroke="#ff6b35" strokeWidth="2" />
|
||||
<path d="M50 18 C58 34 70 38 66 56 C64 70 54 78 50 78 C46 78 34 72 34 56 C34 46 42 44 44 36 C50 42 48 50 52 52 C58 50 54 38 50 18 Z" fill="url(#sf)" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const STICKER_IDS = Object.keys(STICKERS);
|
||||
|
||||
@@ -77,7 +77,7 @@ export function BuyCoinsScreen() {
|
||||
>
|
||||
{p.tag && (
|
||||
<span className="absolute -top-2 rounded-full btn-gold text-[10px] font-bold px-2 py-0.5">
|
||||
{p.tag === "best" ? t("buy.best") : t("buy.popular")}
|
||||
{p.tag === "best" ? t("buy.best") : p.tag === "starter" ? t("buy.starter") : t("buy.popular")}
|
||||
</span>
|
||||
)}
|
||||
<Coins className="size-7 text-gold-400" />
|
||||
|
||||
@@ -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<RewardResult | null>(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 (
|
||||
<>
|
||||
<GameTable onExit={exit} />
|
||||
<GameTable
|
||||
onExit={exit}
|
||||
onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined}
|
||||
/>
|
||||
|
||||
{/* teammate asked to forfeit — confirm or decline */}
|
||||
{forfeitRequest && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-6 w-full max-w-xs text-center"
|
||||
>
|
||||
<div className="text-4xl mb-2">🏳️</div>
|
||||
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
|
||||
<p className="text-cream/70 text-sm mt-2">
|
||||
{t("forfeit.teammateAsks").replace("{name}", forfeitRequest.byName)}
|
||||
</p>
|
||||
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
|
||||
<div className="flex gap-2 mt-5">
|
||||
<button onClick={() => respondForfeit(true)} className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold">
|
||||
{t("forfeit.confirm")}
|
||||
</button>
|
||||
<button onClick={() => respondForfeit(false)} className="flex-1 btn-gold rounded-xl py-3">
|
||||
{t("forfeit.keepPlaying")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{reward && (
|
||||
<PostMatchRewardsModal
|
||||
reward={reward}
|
||||
|
||||
+68
-2
@@ -12,7 +12,7 @@ import {
|
||||
startNextRound,
|
||||
} from "./hokm/engine";
|
||||
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
|
||||
import { avatarEmoji, RewardResult, ServerGameState } from "./online/types";
|
||||
import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types";
|
||||
import type { OnlineService } from "./online/service";
|
||||
import { sound } from "./sound";
|
||||
|
||||
@@ -58,6 +58,7 @@ interface MatchTally {
|
||||
tricksTeam0: number;
|
||||
kotFor: boolean; // your team kot'd opponents at least once
|
||||
kotAgainst: boolean;
|
||||
hakemRounds: number; // rounds you (seat 0) were the hakem
|
||||
}
|
||||
|
||||
interface GameStore {
|
||||
@@ -80,6 +81,10 @@ interface GameStore {
|
||||
serverReward: RewardResult | null;
|
||||
/** the match is still alive but the player navigated away (resumable). */
|
||||
paused: boolean;
|
||||
/** you forfeited (surrendered) this match. */
|
||||
forfeited: boolean;
|
||||
/** a teammate is asking to forfeit and needs your confirmation. */
|
||||
forfeitRequest: ForfeitRequest | null;
|
||||
|
||||
newMatch: (settings: GameSettings) => 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<typeof setTimeout> | 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<number>();
|
||||
|
||||
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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((set, get) => {
|
||||
live: false,
|
||||
serverReward: null,
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
seatPlayers: [],
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<AchievementDef["metric"]>, 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CoinPack[]> {
|
||||
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<number> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Room>;
|
||||
setPartner(roomId: string, friendId: string | null): Promise<Room>;
|
||||
|
||||
@@ -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<number> {
|
||||
// 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<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
||||
|
||||
+15
-1
@@ -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 ---------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user