diff --git a/.gitignore b/.gitignore
index 0fbb4c8..82a5661 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,7 @@ next-env.d.ts
[Bb]in/
[Oo]bj/
*.user
+*.db
+*.db-shm
+*.db-wal
diff --git a/server/README.md b/server/README.md
index 83ddf57..bba3cdc 100644
--- a/server/README.md
+++ b/server/README.md
@@ -60,9 +60,34 @@ existing `OnlineService` interface (`@microsoft/signalr`), then switch
`getService()` in `../src/lib/online/service.ts` from the mock to it. The hub's
`GameStateDto` is shaped to map directly onto the client `GameState`.
+## Persistence (EF Core)
+
+`AppDbContext` stores each profile as a JSON blob (`ProfileRow`) + a coin
+`LedgerRow` audit trail. Provider is config-driven:
+
+```jsonc
+// appsettings.json
+"Database": { "Provider": "sqlite" }, // or "postgres"
+"ConnectionStrings": { "Default": "Data Source=hokm.db" }
+// Postgres (Supabase): Provider="postgres",
+// Default="Host=...;Database=...;Username=...;Password=...;SSL Mode=Require"
+```
+
+Schema is created at startup via `EnsureCreated()` (swap to EF migrations for prod).
+
+**Server-authoritative endpoints (JWT):**
+- `GET /api/profile` · `PUT /api/profile` (displayName/avatar/title/cardFront/cardBack)
+- `POST /api/profile/plan` (upgrade to pro)
+- `GET /api/coins/packs` · `POST /api/coins/buy` `{ packId }` (credits + ledger; real Zarinpal/IDPay TODO)
+- `POST /api/match/result` `{ MatchSummary }` → computes rewards via `Profiles/Gamification.cs`
+ (C# port of `src/lib/online/gamification.ts`), updates profile + ledger, returns `RewardResult`
+
+Auth (`/api/auth/...`) upserts the profile on first sign-in.
+
## TODO
-- EF Core + Postgres persistence (profiles, coins, rank, cosmetics, match history)
+- Wire the Next client (`SignalrService`) to these endpoints for profile/coins/match
+ (currently still mock-backed to avoid a half-migrated economy)
+- EF migrations; Postgres (Supabase) connection for prod
- JWT issued by the V2 Identity Service; phone OTP via Kavenegar/SMS.ir
-- Private rooms + friend invites over the hub (engine/room already support 4 seats)
-- Server-side reward calculation (currently client/profile-side)
+- Private rooms + friend invites over the hub; server-side ranked entry deduction at match start
diff --git a/server/src/Hokm.Server/Data/AppDbContext.cs b/server/src/Hokm.Server/Data/AppDbContext.cs
new file mode 100644
index 0000000..9c1e3a9
--- /dev/null
+++ b/server/src/Hokm.Server/Data/AppDbContext.cs
@@ -0,0 +1,37 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Hokm.Server.Data;
+
+/// A player's profile, stored as a JSON blob keyed by userId.
+public class ProfileRow
+{
+ public string Id { get; set; } = "";
+ public string Json { get; set; } = "{}";
+ public DateTime UpdatedAt { get; set; }
+}
+
+/// Audit trail of coin movements (purchases, match results, daily…).
+public class LedgerRow
+{
+ public long Id { get; set; }
+ public string UserId { get; set; } = "";
+ public string Kind { get; set; } = ""; // purchase | match | daily | grant
+ public int Amount { get; set; } // signed coin delta
+ public string? Ref { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Profiles => Set();
+ public DbSet Ledger => Set();
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity().HasKey(p => p.Id);
+ b.Entity().HasKey(l => l.Id);
+ b.Entity().HasIndex(l => l.UserId);
+ }
+}
diff --git a/server/src/Hokm.Server/Hokm.Server.csproj b/server/src/Hokm.Server/Hokm.Server.csproj
index 44fec85..db36831 100644
--- a/server/src/Hokm.Server/Hokm.Server.csproj
+++ b/server/src/Hokm.Server/Hokm.Server.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs
new file mode 100644
index 0000000..9c0ceda
--- /dev/null
+++ b/server/src/Hokm.Server/Profiles/Gamification.cs
@@ -0,0 +1,164 @@
+namespace Hokm.Server.Profiles;
+
+/// Server-side port of src/lib/online/gamification.ts.
+public static class Gamification
+{
+ private static readonly (string id, int floor)[] Tiers =
+ { ("bronze", 0), ("silver", 1100), ("gold", 1300), ("platinum", 1500), ("diamond", 1700), ("master", 1900) };
+
+ private static int RankValue(int rating)
+ {
+ int idx = 0;
+ for (int i = 0; i < Tiers.Length; i++) if (rating >= Tiers[i].floor) idx = i;
+ if (idx == Tiers.Length - 1) return idx * 10;
+ double floor = Tiers[idx].floor, next = Tiers[idx + 1].floor, third = (next - floor) / 3.0;
+ double within = rating - floor;
+ int division = within < third ? 3 : within < 2 * third ? 2 : 1;
+ return idx * 10 - division;
+ }
+
+ private const int K = 32;
+
+ public static int RatingDelta(MatchSummaryDto s, int my, int opp)
+ {
+ if (!s.Ranked) return 0;
+ double expected = 1.0 / (1.0 + Math.Pow(10, (opp - my) / 400.0));
+ double delta = K * ((s.Won ? 1 : 0) - expected);
+ if (s.Won && s.KotFor) delta += 8;
+ if (!s.Won && s.KotAgainst) delta -= 8;
+ int r = (int)Math.Round(delta);
+ return s.Won ? Math.Max(1, r) : Math.Min(-1, r);
+ }
+
+ public static int CoinDelta(MatchSummaryDto s)
+ {
+ if (!s.Ranked) return 0;
+ int kot = s.Won && s.KotFor ? 40 : 0;
+ return (s.Won ? s.Stake : -s.Stake) + kot;
+ }
+
+ public static int XpForLevel(int level) => 100 * level;
+ public static int MatchXp(MatchSummaryDto s) =>
+ 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
+
+ private record AchDef(string Id, string NameFa, string NameEn, string Icon, int Goal, int Coin);
+ private static readonly AchDef[] Achs =
+ {
+ new("first_win", "اولین برد", "First Win", "🥇", 1, 100),
+ new("first_kot", "اولین کُت", "First Kot", "🔥", 1, 150),
+ new("wins_10", "۱۰ برد", "10 Wins", "🎯", 10, 300),
+ new("wins_100", "۱۰۰ برد", "100 Wins", "👑", 100, 2000),
+ new("streak_5", "نوار ۵ برد", "5 Win Streak", "⚡", 5, 400),
+ new("reach_gold", "رسیدن به طلا", "Reach Gold", "🏅", 1, 500),
+ new("games_50", "۵۰ بازی", "50 Games", "🎮", 50, 350),
+ };
+
+ private static int AchProgress(string id, StatsDto st, int rating) => id switch
+ {
+ "first_win" => Math.Min(1, st.Wins),
+ "first_kot" => Math.Min(1, st.KotsFor),
+ "wins_10" => Math.Min(10, st.Wins),
+ "wins_100" => Math.Min(100, st.Wins),
+ "streak_5" => Math.Min(5, st.BestWinStreak),
+ "reach_gold" => rating >= 1300 ? 1 : 0,
+ "games_50" => Math.Min(50, st.Games),
+ _ => 0,
+ };
+
+ private record TitleDef(string Id, string NameFa, string NameEn);
+ private static readonly TitleDef[] Titles =
+ {
+ new("novice", "تازهکار", "Novice"), new("winner", "برنده", "Winner"),
+ new("kot_master", "استاد کُت", "Kot Master"), new("veteran", "کهنهکار", "Veteran"),
+ new("champion", "قهرمان", "Champion"), new("legend", "اسطوره", "Legend"),
+ };
+
+ private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
+ {
+ "novice" => true,
+ "winner" => st.Wins >= 10,
+ "kot_master" => st.KotsFor >= 10,
+ "veteran" => level >= 20,
+ "champion" => rating >= 1300,
+ "legend" => rating >= 1900,
+ _ => false,
+ };
+
+ private static (int level, int xp, bool up) AddXp(int level, int xp, int gain)
+ {
+ bool up = false;
+ xp += gain;
+ while (xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; }
+ return (level, xp, up);
+ }
+
+ /// Applies a finished match to the profile (mutates it) and returns the reward breakdown.
+ public static RewardResultDto ApplyMatch(ProfileDto p, MatchSummaryDto s, int oppRating)
+ {
+ int ratingBefore = p.Rating, coinsBefore = p.Coins, levelBefore = p.Level;
+ int rDelta = RatingDelta(s, p.Rating, oppRating);
+ int ratingAfter = Math.Max(0, ratingBefore + rDelta);
+ int cDelta = CoinDelta(s);
+ var lvl = AddXp(p.Level, p.Xp, MatchXp(s));
+
+ var st = p.Stats;
+ int cur = s.Won ? st.CurrentWinStreak + 1 : 0;
+ st.Games += 1;
+ st.Wins += s.Won ? 1 : 0;
+ st.Losses += s.Won ? 0 : 1;
+ st.KotsFor += s.KotFor ? 1 : 0;
+ st.KotsAgainst += s.KotAgainst ? 1 : 0;
+ st.Tricks += s.TricksWon;
+ st.CurrentWinStreak = cur;
+ st.BestWinStreak = Math.Max(st.BestWinStreak, cur);
+
+ var newAch = new List();
+ int achCoins = 0;
+ foreach (var d in Achs)
+ {
+ int prog = AchProgress(d.Id, st, ratingAfter);
+ p.Achievements[d.Id] = prog;
+ if (prog >= d.Goal && !p.Unlocked.Contains(d.Id))
+ {
+ p.Unlocked.Add(d.Id);
+ achCoins += d.Coin;
+ newAch.Add(new() { Id = d.Id, NameFa = d.NameFa, NameEn = d.NameEn, Icon = d.Icon, CoinReward = d.Coin });
+ }
+ }
+ int coinsAfter = Math.Max(0, coinsBefore + cDelta + achCoins);
+
+ var newTitles = new List();
+ foreach (var td in Titles)
+ if (TitleUnlocked(td.Id, st, ratingAfter, lvl.level) && !p.OwnedTitles.Contains(td.Id))
+ {
+ p.OwnedTitles.Add(td.Id);
+ newTitles.Add(new() { Id = td.Id, NameFa = td.NameFa, NameEn = td.NameEn });
+ }
+
+ bool promoted = RankValue(ratingAfter) > RankValue(ratingBefore);
+ bool demoted = RankValue(ratingAfter) < RankValue(ratingBefore);
+
+ p.Rating = ratingAfter;
+ p.Coins = coinsAfter;
+ p.Level = lvl.level;
+ p.Xp = lvl.xp;
+
+ return new RewardResultDto
+ {
+ RatingBefore = ratingBefore,
+ RatingAfter = ratingAfter,
+ RatingDelta = ratingAfter - ratingBefore,
+ CoinsBefore = coinsBefore,
+ CoinsAfter = coinsAfter,
+ CoinsDelta = coinsAfter - coinsBefore,
+ XpGained = MatchXp(s),
+ LevelBefore = levelBefore,
+ LevelAfter = lvl.level,
+ LeveledUp = lvl.level > levelBefore,
+ NewAchievements = newAch,
+ NewTitles = newTitles,
+ Promoted = promoted,
+ Demoted = demoted,
+ };
+ }
+}
diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs
new file mode 100644
index 0000000..6a2bfc7
--- /dev/null
+++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Hokm.Server.Profiles;
+
+public class StatsDto
+{
+ public int Games { get; set; }
+ public int Wins { get; set; }
+ public int Losses { get; set; }
+ public int KotsFor { get; set; }
+ public int KotsAgainst { get; set; }
+ public int Tricks { get; set; }
+ public int BestWinStreak { get; set; }
+ public int CurrentWinStreak { get; set; }
+}
+
+/// Mirrors the client UserProfile (camelCase JSON).
+public class ProfileDto
+{
+ public string Id { get; set; } = "";
+ public string Username { get; set; } = "";
+ public string DisplayName { get; set; } = "بازیکن";
+ public string Avatar { get; set; } = "a-fox";
+ public string? AvatarImage { get; set; }
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+ public string Plan { get; set; } = "free";
+ public long? PlanUntil { get; set; }
+ public int Level { get; set; } = 1;
+ public int Xp { get; set; }
+ public int Coins { get; set; } = 1000;
+ public int Rating { get; set; } = 1000;
+ public StatsDto Stats { get; set; } = new();
+ public List OwnedAvatars { get; set; } = new() { "a-fox", "a-lion" };
+ public List OwnedCardFronts { get; set; } = new() { "classic" };
+ public List OwnedCardBacks { get; set; } = new() { "classic" };
+ public List OwnedTitles { get; set; } = new() { "novice" };
+ public List OwnedReactionPacks { get; set; } = new();
+ public List OwnedStickerPacks { get; set; } = new();
+ public string? Title { get; set; } = "novice";
+ public string CardFront { get; set; } = "classic";
+ public string CardBack { get; set; } = "classic";
+ public Dictionary Achievements { get; set; } = new();
+ public List Unlocked { get; set; } = new();
+ public long CreatedAt { get; set; }
+}
+
+public class MatchSummaryDto
+{
+ public bool Ranked { get; set; }
+ public int Stake { get; set; }
+ public bool Won { get; set; }
+ public bool KotFor { get; set; }
+ public bool KotAgainst { get; set; }
+ public int TricksWon { get; set; }
+ public int Rounds { get; set; }
+}
+
+public class AchievementUnlockDto
+{
+ public string Id { get; set; } = "";
+ public string NameFa { get; set; } = "";
+ public string NameEn { get; set; } = "";
+ public string Icon { get; set; } = "";
+ public int CoinReward { get; set; }
+}
+
+public class TitleUnlockDto
+{
+ public string Id { get; set; } = "";
+ public string NameFa { get; set; } = "";
+ public string NameEn { get; set; } = "";
+}
+
+public class RewardResultDto
+{
+ public int RatingBefore { get; set; }
+ public int RatingAfter { get; set; }
+ public int RatingDelta { get; set; }
+ public int CoinsBefore { get; set; }
+ public int CoinsAfter { get; set; }
+ public int CoinsDelta { get; set; }
+ public int XpGained { get; set; }
+ public int LevelBefore { get; set; }
+ public int LevelAfter { get; set; }
+ public bool LeveledUp { get; set; }
+ public List NewAchievements { get; set; } = new();
+ public List NewTitles { get; set; } = new();
+ public bool Promoted { get; set; }
+ public bool Demoted { get; set; }
+}
+
+public class CoinPackDto
+{
+ public string Id { get; set; } = "";
+ public int Coins { get; set; }
+ public int Bonus { get; set; }
+ public int PriceToman { get; set; }
+ public string? Tag { get; set; }
+}
+
+public static class JsonOpts
+{
+ public static readonly JsonSerializerOptions Default = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+}
diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs
new file mode 100644
index 0000000..5f261fc
--- /dev/null
+++ b/server/src/Hokm.Server/Profiles/ProfileService.cs
@@ -0,0 +1,99 @@
+using System.Text.Json;
+using Hokm.Server.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Hokm.Server.Profiles;
+
+public class ProfileService
+{
+ private readonly AppDbContext _db;
+ public ProfileService(AppDbContext db) => _db = db;
+
+ 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 },
+ };
+
+ private static ProfileDto Default(string userId, string? name) => new()
+ {
+ Id = userId,
+ Username = "player_" + (userId.Length >= 4 ? userId[^4..] : userId),
+ DisplayName = string.IsNullOrWhiteSpace(name) ? "بازیکن" : name!,
+ CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ };
+
+ public async Task GetOrCreate(string userId, string? name)
+ {
+ var row = await _db.Profiles.FindAsync(userId);
+ if (row == null)
+ {
+ var dto = Default(userId, name);
+ await SaveInternal(dto);
+ return dto;
+ }
+ return JsonSerializer.Deserialize(row.Json, JsonOpts.Default) ?? Default(userId, name);
+ }
+
+ private async Task SaveInternal(ProfileDto p)
+ {
+ var json = JsonSerializer.Serialize(p, JsonOpts.Default);
+ var row = await _db.Profiles.FindAsync(p.Id);
+ if (row == null) _db.Profiles.Add(new ProfileRow { Id = p.Id, Json = json, UpdatedAt = DateTime.UtcNow });
+ else { row.Json = json; row.UpdatedAt = DateTime.UtcNow; }
+ await _db.SaveChangesAsync();
+ }
+
+ public async Task Save(ProfileDto p) { await SaveInternal(p); return p; }
+
+ private async Task Ledger(string uid, string kind, int amount, string? @ref)
+ {
+ _db.Ledger.Add(new LedgerRow { UserId = uid, Kind = kind, Amount = amount, Ref = @ref, CreatedAt = DateTime.UtcNow });
+ await _db.SaveChangesAsync();
+ }
+
+ public async Task Update(string uid, JsonElement patch)
+ {
+ var p = await GetOrCreate(uid, null);
+ if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
+ if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
+ if (patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
+ if (patch.TryGetProperty("title", out var ti) && ti.ValueKind == JsonValueKind.String) p.Title = ti.GetString();
+ if (patch.TryGetProperty("cardFront", out var cf) && cf.ValueKind == JsonValueKind.String) p.CardFront = cf.GetString()!;
+ if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!;
+ return await Save(p);
+ }
+
+ public async Task UpgradePlan(string uid)
+ {
+ var p = await GetOrCreate(uid, null);
+ p.Plan = "pro";
+ p.PlanUntil = DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeMilliseconds();
+ return await Save(p);
+ }
+
+ public async Task<(bool ok, ProfileDto? profile, int coins)> BuyCoins(string uid, string packId)
+ {
+ var pack = Packs.FirstOrDefault(x => x.Id == packId);
+ if (pack == null) return (false, null, 0);
+ // NOTE: real payment (Zarinpal/IDPay) verification goes here.
+ var p = await GetOrCreate(uid, null);
+ int added = pack.Coins + pack.Bonus;
+ p.Coins += added;
+ await Save(p);
+ await Ledger(uid, "purchase", added, packId);
+ return (true, p, added);
+ }
+
+ public async Task<(RewardResultDto reward, ProfileDto profile)> ApplyMatch(string uid, MatchSummaryDto s)
+ {
+ var p = await GetOrCreate(uid, null);
+ // No real opponent rating tracked yet — treat as an even match.
+ var reward = Gamification.ApplyMatch(p, s, p.Rating);
+ await Save(p);
+ await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual");
+ return (reward, p);
+ }
+}
diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs
index cc47345..6941538 100644
--- a/server/src/Hokm.Server/Program.cs
+++ b/server/src/Hokm.Server/Program.cs
@@ -1,9 +1,13 @@
+using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Hokm.Server.Auth;
+using Hokm.Server.Data;
using Hokm.Server.Game;
using Hokm.Server.Hubs;
+using Hokm.Server.Profiles;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +20,18 @@ builder.Services.AddSingleton(jwt);
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// --- database (SQLite for dev, Postgres for prod via config) ---
+var dbProvider = builder.Configuration["Database:Provider"] ?? "sqlite";
+var dbConn = builder.Configuration.GetConnectionString("Default");
+builder.Services.AddDbContext(o =>
+{
+ if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
+ o.UseNpgsql(dbConn ?? "");
+ else
+ o.UseSqlite(dbConn ?? "Data Source=hokm.db");
+});
+builder.Services.AddScoped();
+
// --- SignalR (camelCase to match the TS client) ---
builder.Services
.AddSignalR()
@@ -66,6 +82,9 @@ builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
var app = builder.Build();
+using (var scope = app.Services.CreateScope())
+ scope.ServiceProvider.GetRequiredService().Database.EnsureCreated();
+
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
@@ -77,22 +96,54 @@ app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
Results.Json(new { devCode = "1234", phone = req.Phone }));
-app.MapPost("/api/auth/otp/verify", (OtpVerify req, TokenService tokens) =>
+app.MapPost("/api/auth/otp/verify", async (OtpVerify req, TokenService tokens, ProfileService profiles) =>
{
if (req.Code != "1234")
return Results.BadRequest(new { error = "INVALID_CODE" });
var userId = "phone:" + req.Phone;
- var name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name!;
- return Results.Json(new { token = tokens.Create(userId, name, "free"), userId, name });
+ var p = await profiles.GetOrCreate(userId, req.Name);
+ return Results.Json(new { token = tokens.Create(userId, p.DisplayName, p.Plan), userId, name = p.DisplayName });
});
-app.MapPost("/api/auth/email", (EmailLogin req, TokenService tokens) =>
+app.MapPost("/api/auth/email", async (EmailLogin req, TokenService tokens, ProfileService profiles) =>
{
var userId = "email:" + req.Email.ToLowerInvariant();
- var name = string.IsNullOrWhiteSpace(req.Name) ? req.Email.Split('@')[0] : req.Name!;
- return Results.Json(new { token = tokens.Create(userId, name, "free"), userId, name });
+ var name = string.IsNullOrWhiteSpace(req.Name) ? req.Email.Split('@')[0] : req.Name;
+ var p = await profiles.GetOrCreate(userId, name);
+ return Results.Json(new { token = tokens.Create(userId, p.DisplayName, p.Plan), userId, name = p.DisplayName });
});
+// --- profile / economy (JWT-protected, server-authoritative) ---
+string Uid(ClaimsPrincipal u) => u.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon";
+
+app.MapGet("/api/profile", async (ClaimsPrincipal u, ProfileService svc) =>
+ Results.Json(await svc.GetOrCreate(Uid(u), u.FindFirst("name")?.Value), JsonOpts.Default))
+ .RequireAuthorization();
+
+app.MapPut("/api/profile", async (ClaimsPrincipal u, ProfileService svc, JsonElement patch) =>
+ Results.Json(await svc.Update(Uid(u), patch), JsonOpts.Default))
+ .RequireAuthorization();
+
+app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
+ Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
+ .RequireAuthorization();
+
+app.MapGet("/api/coins/packs", () => Results.Json(ProfileService.Packs, JsonOpts.Default));
+
+app.MapPost("/api/coins/buy", async (ClaimsPrincipal u, ProfileService svc, BuyReq req) =>
+{
+ var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.PackId);
+ return ok
+ ? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
+ : Results.BadRequest(new { ok = false });
+}).RequireAuthorization();
+
+app.MapPost("/api/match/result", async (ClaimsPrincipal u, ProfileService svc, MatchSummaryDto s) =>
+{
+ var (reward, p) = await svc.ApplyMatch(Uid(u), s);
+ return Results.Json(new { reward, profile = p }, JsonOpts.Default);
+}).RequireAuthorization();
+
app.MapHub("/hub/game");
app.Run();
@@ -100,3 +151,4 @@ app.Run();
record OtpRequest(string Phone);
record OtpVerify(string Phone, string Code, string? Name);
record EmailLogin(string Email, string Password, string? Name);
+record BuyReq(string PackId);
diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json
index f0f587b..ae7cf1c 100644
--- a/server/src/Hokm.Server/appsettings.json
+++ b/server/src/Hokm.Server/appsettings.json
@@ -11,5 +11,11 @@
"Key": "dev-only-insecure-key-change-me-please-32+bytes!!",
"Issuer": "hokm",
"Audience": "hokm-clients"
+ },
+ "Database": {
+ "Provider": "sqlite"
+ },
+ "ConnectionStrings": {
+ "Default": "Data Source=hokm.db"
}
}