Server persistence: EF Core profiles + coin ledger + authoritative rewards
- EF Core (SQLite dev / Postgres prod via config); ProfileRow JSON blob + LedgerRow audit; EnsureCreated at startup - C# Gamification port (ranks/elo/coins/xp/achievements/titles) → server computes match rewards; ProfileService (get/update/plan/buyCoins/applyMatch) - JWT endpoints: profile GET/PUT, plan, coins packs/buy, match/result; auth upserts the profile - Tested end-to-end (buy + ranked win+kot persisted & server-computed) - Client still mock-backed for now (wiring is the next step) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -50,4 +50,7 @@ next-env.d.ts
|
|||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
*.user
|
*.user
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -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
|
`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`.
|
`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
|
## 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
|
- 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)
|
- Private rooms + friend invites over the hub; server-side ranked entry deduction at match start
|
||||||
- Server-side reward calculation (currently client/profile-side)
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Hokm.Server.Data;
|
||||||
|
|
||||||
|
/// <summary>A player's profile, stored as a JSON blob keyed by userId.</summary>
|
||||||
|
public class ProfileRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Json { get; set; } = "{}";
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Audit trail of coin movements (purchases, match results, daily…).</summary>
|
||||||
|
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<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<ProfileRow> Profiles => Set<ProfileRow>();
|
||||||
|
public DbSet<LedgerRow> Ledger => Set<LedgerRow>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
|
{
|
||||||
|
b.Entity<ProfileRow>().HasKey(p => p.Id);
|
||||||
|
b.Entity<LedgerRow>().HasKey(l => l.Id);
|
||||||
|
b.Entity<LedgerRow>().HasIndex(l => l.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
namespace Hokm.Server.Profiles;
|
||||||
|
|
||||||
|
/// <summary>Server-side port of src/lib/online/gamification.ts.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies a finished match to the profile (mutates it) and returns the reward breakdown.</summary>
|
||||||
|
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<AchievementUnlockDto>();
|
||||||
|
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<TitleUnlockDto>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||||
|
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<string> OwnedAvatars { get; set; } = new() { "a-fox", "a-lion" };
|
||||||
|
public List<string> OwnedCardFronts { get; set; } = new() { "classic" };
|
||||||
|
public List<string> OwnedCardBacks { get; set; } = new() { "classic" };
|
||||||
|
public List<string> OwnedTitles { get; set; } = new() { "novice" };
|
||||||
|
public List<string> OwnedReactionPacks { get; set; } = new();
|
||||||
|
public List<string> OwnedStickerPacks { get; set; } = new();
|
||||||
|
public string? Title { get; set; } = "novice";
|
||||||
|
public string CardFront { get; set; } = "classic";
|
||||||
|
public string CardBack { get; set; } = "classic";
|
||||||
|
public Dictionary<string, int> Achievements { get; set; } = new();
|
||||||
|
public List<string> 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<AchievementUnlockDto> NewAchievements { get; set; } = new();
|
||||||
|
public List<TitleUnlockDto> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ProfileDto> 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<ProfileDto>(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<ProfileDto> 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<ProfileDto> 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<ProfileDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Hokm.Server.Auth;
|
using Hokm.Server.Auth;
|
||||||
|
using Hokm.Server.Data;
|
||||||
using Hokm.Server.Game;
|
using Hokm.Server.Game;
|
||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
|
using Hokm.Server.Profiles;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -16,6 +20,18 @@ builder.Services.AddSingleton(jwt);
|
|||||||
builder.Services.AddSingleton<TokenService>();
|
builder.Services.AddSingleton<TokenService>();
|
||||||
builder.Services.AddSingleton<GameManager>();
|
builder.Services.AddSingleton<GameManager>();
|
||||||
|
|
||||||
|
// --- 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<AppDbContext>(o =>
|
||||||
|
{
|
||||||
|
if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
|
||||||
|
o.UseNpgsql(dbConn ?? "");
|
||||||
|
else
|
||||||
|
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
||||||
|
});
|
||||||
|
builder.Services.AddScoped<ProfileService>();
|
||||||
|
|
||||||
// --- SignalR (camelCase to match the TS client) ---
|
// --- SignalR (camelCase to match the TS client) ---
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddSignalR()
|
.AddSignalR()
|
||||||
@@ -66,6 +82,9 @@ builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreated();
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
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) =>
|
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
||||||
Results.Json(new { devCode = "1234", phone = req.Phone }));
|
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")
|
if (req.Code != "1234")
|
||||||
return Results.BadRequest(new { error = "INVALID_CODE" });
|
return Results.BadRequest(new { error = "INVALID_CODE" });
|
||||||
var userId = "phone:" + req.Phone;
|
var userId = "phone:" + req.Phone;
|
||||||
var name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name!;
|
var p = await profiles.GetOrCreate(userId, req.Name);
|
||||||
return Results.Json(new { token = tokens.Create(userId, name, "free"), userId, 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 userId = "email:" + req.Email.ToLowerInvariant();
|
||||||
var name = string.IsNullOrWhiteSpace(req.Name) ? req.Email.Split('@')[0] : req.Name!;
|
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 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<GameHub>("/hub/game");
|
app.MapHub<GameHub>("/hub/game");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -100,3 +151,4 @@ app.Run();
|
|||||||
record OtpRequest(string Phone);
|
record OtpRequest(string Phone);
|
||||||
record OtpVerify(string Phone, string Code, string? Name);
|
record OtpVerify(string Phone, string Code, string? Name);
|
||||||
record EmailLogin(string Email, string Password, string? Name);
|
record EmailLogin(string Email, string Password, string? Name);
|
||||||
|
record BuyReq(string PackId);
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"Key": "dev-only-insecure-key-change-me-please-32+bytes!!",
|
"Key": "dev-only-insecure-key-change-me-please-32+bytes!!",
|
||||||
"Issuer": "hokm",
|
"Issuer": "hokm",
|
||||||
"Audience": "hokm-clients"
|
"Audience": "hokm-clients"
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"Provider": "sqlite"
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": "Data Source=hokm.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user