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" } }