diff --git a/.gitignore b/.gitignore
index 5ef6a52..265bcbb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# .NET (server/)
+[Bb]in/
+[Oo]bj/
+*.user
+
diff --git a/server/Directory.Build.props b/server/Directory.Build.props
new file mode 100644
index 0000000..07c488f
--- /dev/null
+++ b/server/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ net10.0
+ enable
+ enable
+
+ false
+
+
diff --git a/server/Hokm.slnx b/server/Hokm.slnx
new file mode 100644
index 0000000..74e84cb
--- /dev/null
+++ b/server/Hokm.slnx
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/server/NuGet.config b/server/NuGet.config
new file mode 100644
index 0000000..8c0199a
--- /dev/null
+++ b/server/NuGet.config
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..83ddf57
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,68 @@
+# Hokm — .NET 10 + SignalR backend
+
+Authoritative realtime server for the Hokm game. The TypeScript engine
+(`../src/lib/hokm`) is ported to C# here so the server is the source of truth.
+
+## Projects
+
+| Project | What |
+|---|---|
+| `src/Hokm.Engine` | Pure C# rules + AI (port of `src/lib/hokm`) — deal, hakem, trump, tricks, scoring, Kot, bot |
+| `src/Hokm.Server` | ASP.NET Core + **SignalR** `GameHub`, in-memory matchmaking/rooms, JWT auth |
+| `tools/Hokm.Sim` | All-AI simulation that validates the engine port |
+
+## NuGet sources
+
+`NuGet.config` restores **only** from the configured mirrors (no nuget.org):
+
+- `https://mirror.soroushasadi.com/repository/nuget-group/index.json` (Nexus)
+- `https://package-mirror.liara.ir/repository/nuget/index.json` (Liara)
+
+`Directory.Build.props` disables NuGet audit (avoids reaching api.nuget.org).
+
+## Run
+
+```bash
+cd server
+dotnet run --project tools/Hokm.Sim -c Release # validate the engine
+dotnet run --project src/Hokm.Server -c Release # → http://localhost:5005
+```
+
+## API
+
+Dev auth (replace with the V2 Identity Service + Kavenegar/SMS.ir later):
+
+- `POST /api/auth/otp/request` `{ phone }` → `{ devCode: "1234" }`
+- `POST /api/auth/otp/verify` `{ phone, code, name? }` → `{ token, userId, name }` (code `1234`)
+- `POST /api/auth/email` `{ email, password, name? }` → `{ token, userId, name }`
+
+SignalR hub: **`/hub/game`** (JWT required; pass `?access_token=`).
+
+Client → server methods:
+- `StartMatchmaking({ name, avatar, level, plan })` — pro skips the queue
+- `CancelMatchmaking()`
+- `ChooseTrump(suit)` · `PlayCard(cardId)` · `SendReaction(reaction)`
+
+Server → client events:
+- `matchmaking` `{ phase, players, queuePosition }`
+- `matchFound` `{ roomId, seat }`
+- `state` `GameStateDto` (per-seat: only your own hand is included; others are counts)
+- `reaction` `{ seat, reaction }`
+
+The server runs server-side **turn timers** (20s → AI auto-plays), fills empty
+seats with **bots**, drives bot turns, and handles **disconnect** (the seat is
+marked offline and the timer auto-plays until they reconnect).
+
+## Wiring the Next.js client (next step)
+
+Implement `SignalrService` in `../src/lib/online/signalr-service.ts` against the
+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`.
+
+## TODO
+
+- EF Core + Postgres persistence (profiles, coins, rank, cosmetics, match history)
+- 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)
diff --git a/server/src/Hokm.Engine/Ai.cs b/server/src/Hokm.Engine/Ai.cs
new file mode 100644
index 0000000..cad3d59
--- /dev/null
+++ b/server/src/Hokm.Engine/Ai.cs
@@ -0,0 +1,59 @@
+namespace Hokm.Engine;
+
+/// AI bot — port of src/lib/hokm/ai.ts.
+public static class Ai
+{
+ public static Suit ChooseTrump(IReadOnlyList hand)
+ {
+ Suit best = Suit.Spades;
+ int bestScore = -1;
+ foreach (var suit in Deck.Suits)
+ {
+ var cards = hand.Where(c => c.Suit == suit).ToList();
+ int strength = cards.Sum(c => Math.Max(0, c.Rank - 9));
+ int score = cards.Count * 10 + strength;
+ if (score > bestScore) { bestScore = score; best = suit; }
+ }
+ return best;
+ }
+
+ public static Card ChooseCard(GameState g, int seat)
+ {
+ var legal = Rules.LegalMoves(g, seat);
+ if (legal.Count == 1) return legal[0];
+
+ var trump = g.Trump;
+ var trick = g.CurrentTrick;
+
+ static Card Lowest(IEnumerable cs) => cs.Aggregate((lo, c) => c.Rank < lo.Rank ? c : lo);
+ Card Dump()
+ {
+ var nonTrump = legal.Where(c => c.Suit != trump).ToList();
+ return Lowest(nonTrump.Count > 0 ? nonTrump : legal);
+ }
+
+ // Leading
+ if (trick.Count == 0)
+ {
+ var nonTrump = legal.Where(c => c.Suit != trump).ToList();
+ var aces = nonTrump.Where(c => c.Rank == 14).ToList();
+ if (aces.Count > 0) return aces[0];
+ if (nonTrump.Count > 0) return Lowest(nonTrump);
+ return Lowest(legal);
+ }
+
+ // Following
+ var best = trick[0];
+ foreach (var pc in trick.Skip(1))
+ if (Rules.TrickWinner(new[] { best, pc }, trump) == pc.Seat) best = pc;
+
+ bool partnerWinning = Seats.Team(best.Seat) == Seats.Team(seat);
+ var winning = legal
+ .Where(card => Rules.TrickWinner(trick.Append(new PlayedCard(seat, card)).ToList(), trump) == seat)
+ .ToList();
+
+ if (partnerWinning) return Dump();
+ if (winning.Count > 0) return Lowest(winning);
+ return Dump();
+ }
+}
diff --git a/server/src/Hokm.Engine/Deck.cs b/server/src/Hokm.Engine/Deck.cs
new file mode 100644
index 0000000..1c61dd3
--- /dev/null
+++ b/server/src/Hokm.Engine/Deck.cs
@@ -0,0 +1,40 @@
+namespace Hokm.Engine;
+
+public static class Deck
+{
+ public static readonly Suit[] Suits = { Suit.Spades, Suit.Hearts, Suit.Diamonds, Suit.Clubs };
+
+ public static List Create()
+ {
+ var deck = new List(52);
+ foreach (var suit in Suits)
+ for (int rank = 2; rank <= 14; rank++)
+ deck.Add(new Card(suit, rank));
+ return deck;
+ }
+
+ /// Fisher–Yates shuffle into a new list.
+ public static List Shuffle(IReadOnlyList input, Random rng)
+ {
+ var arr = input.ToList();
+ for (int i = arr.Count - 1; i > 0; i--)
+ {
+ int j = rng.Next(i + 1);
+ (arr[i], arr[j]) = (arr[j], arr[i]);
+ }
+ return arr;
+ }
+
+ public static List SortHand(IEnumerable hand)
+ {
+ static int Order(Suit s) => s switch
+ {
+ Suit.Spades => 0,
+ Suit.Hearts => 1,
+ Suit.Clubs => 2,
+ Suit.Diamonds => 3,
+ _ => 4,
+ };
+ return hand.OrderBy(c => Order(c.Suit)).ThenByDescending(c => c.Rank).ToList();
+ }
+}
diff --git a/server/src/Hokm.Engine/Engine.cs b/server/src/Hokm.Engine/Engine.cs
new file mode 100644
index 0000000..fe3983c
--- /dev/null
+++ b/server/src/Hokm.Engine/Engine.cs
@@ -0,0 +1,176 @@
+namespace Hokm.Engine;
+
+///
+/// Authoritative Hokm rules — a direct port of the TypeScript engine
+/// (src/lib/hokm/engine.ts). Methods mutate the passed GameState.
+///
+public static class Rules
+{
+ public const int TricksToWinRound = 7;
+
+ public static GameState CreateInitial(string[] names, int targetScore = 7)
+ {
+ var g = new GameState { TargetScore = targetScore };
+ for (int seat = 0; seat < 4; seat++)
+ g.Players.Add(new Player
+ {
+ Seat = seat,
+ Name = names[seat],
+ IsHuman = seat == 0,
+ Team = Seats.Team(seat),
+ });
+ return g;
+ }
+
+ public static void SelectHakem(GameState g, Random rng)
+ {
+ var deck = Deck.Shuffle(Deck.Create(), rng);
+ g.HakemDraw.Clear();
+ int seat = 0, hakem = 0;
+ foreach (var card in deck)
+ {
+ g.HakemDraw.Add(new PlayedCard(seat, card));
+ if (card.Rank == 14) { hakem = seat; break; }
+ seat = Seats.Next(seat);
+ }
+ g.Hakem = hakem;
+ g.Phase = Phase.SelectingHakem;
+ }
+
+ public static void DealForTrump(GameState g, Random rng)
+ {
+ if (g.Hakem is null) throw new InvalidOperationException("hakem not selected");
+ var deck = Deck.Shuffle(Deck.Create(), rng);
+ foreach (var p in g.Players) p.Hand = new();
+ g.Players[g.Hakem.Value].Hand = deck.Take(5).ToList();
+ g.Deck = deck.Skip(5).ToList();
+ g.Phase = Phase.ChoosingTrump;
+ g.Trump = null;
+ g.Turn = g.Hakem;
+ g.CurrentTrick.Clear();
+ g.LeadSeat = null;
+ g.RoundTricks = new int[2];
+ g.LastTrickWinner = null;
+ g.LastRoundResult = null;
+ g.HakemDraw.Clear();
+ g.DealId++;
+ }
+
+ public static void ChooseTrump(GameState g, Suit trump)
+ {
+ if (g.Phase != Phase.ChoosingTrump) throw new InvalidOperationException("not choosing trump");
+ var deck = g.Deck;
+ int di = 0;
+ foreach (var p in g.Players)
+ {
+ int need = 13 - p.Hand.Count;
+ for (int k = 0; k < need; k++) p.Hand.Add(deck[di++]);
+ }
+ g.Deck = deck.Skip(di).ToList();
+ g.Trump = trump;
+ g.Phase = Phase.Playing;
+ g.Turn = g.Hakem;
+ g.LeadSeat = g.Hakem;
+ g.CurrentTrick.Clear();
+ }
+
+ public static List LegalMoves(GameState g, int seat)
+ {
+ var hand = g.Players[seat].Hand;
+ if (g.CurrentTrick.Count == 0) return hand.ToList();
+ var lead = g.CurrentTrick[0].Card.Suit;
+ var same = hand.Where(c => c.Suit == lead).ToList();
+ return same.Count > 0 ? same : hand.ToList();
+ }
+
+ public static bool IsLegal(GameState g, int seat, Card card) =>
+ g.Turn == seat && LegalMoves(g, seat).Any(c => c.Id == card.Id);
+
+ public static int TrickWinner(IReadOnlyList trick, Suit? trump)
+ {
+ if (trick.Count == 0) throw new InvalidOperationException("empty trick");
+ var lead = trick[0].Card.Suit;
+ var best = trick[0];
+ foreach (var pc in trick.Skip(1))
+ {
+ bool bestTrump = trump != null && best.Card.Suit == trump;
+ bool pcTrump = trump != null && pc.Card.Suit == trump;
+ if (pcTrump && !bestTrump) best = pc;
+ else if (pcTrump == bestTrump)
+ {
+ var relevant = bestTrump ? trump!.Value : lead;
+ if (pc.Card.Suit == relevant && pc.Card.Rank > best.Card.Rank) best = pc;
+ }
+ }
+ return best.Seat;
+ }
+
+ public static void PlayCard(GameState g, int seat, Card card)
+ {
+ if (!IsLegal(g, seat, card)) throw new InvalidOperationException($"illegal play: seat {seat} {card.Id}");
+ var p = g.Players[seat];
+ p.Hand = p.Hand.Where(c => c.Id != card.Id).ToList();
+ g.CurrentTrick.Add(new PlayedCard(seat, card));
+ g.LeadSeat ??= seat;
+
+ if (g.CurrentTrick.Count < 4)
+ {
+ g.Turn = Seats.Next(seat);
+ return;
+ }
+
+ var winner = TrickWinner(g.CurrentTrick, g.Trump);
+ g.Turn = null;
+ g.Phase = Phase.TrickComplete;
+ g.LastTrickWinner = winner;
+ }
+
+ public static void AdvanceAfterTrick(GameState g, int kotPoints = 2)
+ {
+ if (g.Phase != Phase.TrickComplete || g.LastTrickWinner is null) return;
+ int winner = g.LastTrickWinner.Value;
+ int wTeam = Seats.Team(winner);
+ g.RoundTricks[wTeam]++;
+ g.CurrentTrick.Clear();
+
+ if (g.RoundTricks[wTeam] >= TricksToWinRound)
+ {
+ int loser = 1 - wTeam;
+ bool kot = g.RoundTricks[loser] == 0;
+ var result = new RoundResult
+ {
+ WinningTeam = wTeam,
+ Tricks = (int[])g.RoundTricks.Clone(),
+ Kot = kot,
+ Points = kot ? kotPoints : 1,
+ };
+ g.MatchScore[wTeam] += result.Points;
+ g.LastRoundResult = result;
+ g.LastTrickWinner = winner;
+ g.Turn = null;
+ if (g.MatchScore[wTeam] >= g.TargetScore)
+ {
+ g.MatchWinner = wTeam;
+ g.Phase = Phase.MatchOver;
+ }
+ else g.Phase = Phase.RoundOver;
+ return;
+ }
+
+ g.LeadSeat = winner;
+ g.Turn = winner;
+ g.LastTrickWinner = winner;
+ g.Phase = Phase.Playing;
+ }
+
+ public static void StartNextRound(GameState g, Random rng)
+ {
+ if (g.Hakem is null) throw new InvalidOperationException("no hakem");
+ var result = g.LastRoundResult;
+ int hakem = g.Hakem.Value;
+ if (result != null && Seats.Team(hakem) != result.WinningTeam)
+ hakem = Seats.Next(hakem);
+ g.Hakem = hakem;
+ DealForTrump(g, rng);
+ }
+}
diff --git a/server/src/Hokm.Engine/Hokm.Engine.csproj b/server/src/Hokm.Engine/Hokm.Engine.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/server/src/Hokm.Engine/Hokm.Engine.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/server/src/Hokm.Engine/Models.cs b/server/src/Hokm.Engine/Models.cs
new file mode 100644
index 0000000..4e8faea
--- /dev/null
+++ b/server/src/Hokm.Engine/Models.cs
@@ -0,0 +1,67 @@
+namespace Hokm.Engine;
+
+public enum Suit { Spades, Hearts, Diamonds, Clubs }
+
+public enum Phase
+{
+ Idle,
+ SelectingHakem,
+ ChoosingTrump,
+ Playing,
+ TrickComplete,
+ RoundOver,
+ MatchOver,
+}
+
+/// A playing card. Rank 2..14 (Ace = 14).
+public sealed record Card(Suit Suit, int Rank)
+{
+ public string Id => $"{Suit}-{Rank}";
+}
+
+public sealed class Player
+{
+ public int Seat { get; init; }
+ public string Name { get; set; } = "";
+ public bool IsHuman { get; set; }
+ public int Team { get; init; }
+ public List Hand { get; set; } = new();
+}
+
+public sealed record PlayedCard(int Seat, Card Card);
+
+public sealed class RoundResult
+{
+ public int WinningTeam { get; set; }
+ public int[] Tricks { get; set; } = new int[2];
+ public bool Kot { get; set; }
+ public int Points { get; set; }
+}
+
+/// Full authoritative game state (mutated by the Engine).
+public sealed class GameState
+{
+ public Phase Phase { get; set; } = Phase.Idle;
+ public List Players { get; set; } = new();
+ public List Deck { get; set; } = new();
+ public int? Hakem { get; set; }
+ public Suit? Trump { get; set; }
+ public int? Turn { get; set; }
+ public List CurrentTrick { get; set; } = new();
+ public int? LeadSeat { get; set; }
+ public int[] RoundTricks { get; set; } = new int[2];
+ public int[] MatchScore { get; set; } = new int[2];
+ public int? LastTrickWinner { get; set; }
+ public RoundResult? LastRoundResult { get; set; }
+ public int? MatchWinner { get; set; }
+ public List HakemDraw { get; set; } = new();
+ public int TargetScore { get; set; } = 7;
+ public int DealId { get; set; }
+}
+
+public static class Seats
+{
+ public static int Team(int seat) => seat % 2;
+ public static int Next(int seat) => (seat + 1) % 4;
+ public static int Partner(int seat) => (seat + 2) % 4;
+}
diff --git a/server/src/Hokm.Server/Auth/TokenService.cs b/server/src/Hokm.Server/Auth/TokenService.cs
new file mode 100644
index 0000000..ae30136
--- /dev/null
+++ b/server/src/Hokm.Server/Auth/TokenService.cs
@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using System.Text;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Hokm.Server.Auth;
+
+public sealed class JwtOptions
+{
+ public string Key { get; set; } = "";
+ public string Issuer { get; set; } = "hokm";
+ public string Audience { get; set; } = "hokm-clients";
+}
+
+public sealed class TokenService
+{
+ private readonly JwtOptions _opts;
+ public TokenService(JwtOptions opts) => _opts = opts;
+
+ public SymmetricSecurityKey SecurityKey => new(Encoding.UTF8.GetBytes(_opts.Key));
+ public string Issuer => _opts.Issuer;
+ public string Audience => _opts.Audience;
+
+ public string Create(string userId, string name, string plan)
+ {
+ var creds = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256);
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = _opts.Issuer,
+ Audience = _opts.Audience,
+ Expires = DateTime.UtcNow.AddDays(7),
+ SigningCredentials = creds,
+ Subject = new ClaimsIdentity(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId),
+ new Claim("name", name),
+ new Claim("plan", plan),
+ }),
+ };
+ return new JsonWebTokenHandler().CreateToken(descriptor);
+ }
+}
diff --git a/server/src/Hokm.Server/Game/Contracts.cs b/server/src/Hokm.Server/Game/Contracts.cs
new file mode 100644
index 0000000..34e323b
--- /dev/null
+++ b/server/src/Hokm.Server/Game/Contracts.cs
@@ -0,0 +1,64 @@
+using Hokm.Engine;
+
+namespace Hokm.Server.Game;
+
+// Wire DTOs broadcast to clients. SignalR is configured for camelCase JSON,
+// so these map cleanly onto the TypeScript client types.
+
+public record CardDto(string Suit, int Rank, string Id);
+public record PlayedCardDto(int Seat, CardDto Card);
+public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List? Hand);
+public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot);
+public record RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
+
+public record GameStateDto(
+ string Phase,
+ int? Turn,
+ int? Hakem,
+ string? Trump,
+ int? LeadSeat,
+ int[] RoundTricks,
+ int[] MatchScore,
+ int TargetScore,
+ int DealId,
+ int? LastTrickWinner,
+ int? MatchWinner,
+ List CurrentTrick,
+ List Players,
+ List HakemDraw,
+ RoundResultDto? LastRoundResult,
+ List SeatPlayers,
+ int MySeat,
+ long? TurnDeadline,
+ int? DisconnectedSeat,
+ bool Ranked,
+ int Stake);
+
+public record MatchmakingStateDto(string Phase, int Players, int? QueuePosition);
+public record ReactionDto(int Seat, string Reaction);
+
+public static class Map
+{
+ public static string SuitStr(Suit s) => s switch
+ {
+ Suit.Spades => "spades",
+ Suit.Hearts => "hearts",
+ Suit.Diamonds => "diamonds",
+ Suit.Clubs => "clubs",
+ _ => "spades",
+ };
+
+ public static CardDto Card(Card c) => new(SuitStr(c.Suit), c.Rank, $"{SuitStr(c.Suit)}-{c.Rank}");
+
+ public static string PhaseStr(Phase p) => p switch
+ {
+ Phase.Idle => "idle",
+ Phase.SelectingHakem => "selecting-hakem",
+ Phase.ChoosingTrump => "choosing-trump",
+ Phase.Playing => "playing",
+ Phase.TrickComplete => "trick-complete",
+ Phase.RoundOver => "round-over",
+ Phase.MatchOver => "match-over",
+ _ => "idle",
+ };
+}
diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs
new file mode 100644
index 0000000..acb7054
--- /dev/null
+++ b/server/src/Hokm.Server/Game/GameManager.cs
@@ -0,0 +1,144 @@
+using System.Collections.Concurrent;
+using Hokm.Server.Hubs;
+using Microsoft.AspNetCore.SignalR;
+
+namespace Hokm.Server.Game;
+
+public sealed class Player
+{
+ public required string UserId { get; init; }
+ public string Name { get; init; } = "";
+ public string Avatar { get; init; } = "a-fox";
+ public int Level { get; init; }
+ public string Plan { get; init; } = "free";
+}
+
+/// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)
+public sealed class GameManager
+{
+ private const int QueueWaitMs = 6000;
+
+ private static readonly string[] BotNames =
+ { "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
+ private static readonly string[] Avatars =
+ { "a-fox", "a-lion", "a-owl", "a-tiger", "a-panda", "a-eagle", "a-wolf", "a-cat" };
+
+ private readonly IHubContext _hub;
+ private readonly ConcurrentDictionary _rooms = new();
+ private readonly ConcurrentDictionary _userRoom = new(); // userId -> roomId
+ private readonly object _mmLock = new();
+ private readonly List<(Player player, Timer timer)> _waiting = new();
+ private readonly Random _rng = new();
+
+ public GameManager(IHubContext hub) => _hub = hub;
+
+ /* ----------------------------- matchmaking ------------------------- */
+
+ public void StartMatchmaking(Player p)
+ {
+ // Pro players skip the queue entirely.
+ if (p.Plan == "pro")
+ {
+ StartMatch(new List { p });
+ return;
+ }
+
+ lock (_mmLock)
+ {
+ if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
+ var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
+ _waiting.Add((p, timer));
+ _ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
+ new MatchmakingStateDto("searching", _waiting.Count, null));
+ if (_waiting.Count >= 4) FormGroupLocked(4);
+ }
+ }
+
+ public void CancelMatchmaking(string userId)
+ {
+ lock (_mmLock)
+ {
+ var idx = _waiting.FindIndex(w => w.player.UserId == userId);
+ if (idx >= 0) { _waiting[idx].timer.Dispose(); _waiting.RemoveAt(idx); }
+ }
+ }
+
+ private void FlushTicket(string userId)
+ {
+ lock (_mmLock)
+ {
+ if (!_waiting.Any(w => w.player.UserId == userId)) return;
+ FormGroupLocked(_waiting.Count); // start with whoever is waiting; bots fill the rest
+ }
+ }
+
+ private void FormGroupLocked(int count)
+ {
+ var take = _waiting.Take(Math.Min(count, 4)).ToList();
+ foreach (var w in take) w.timer.Dispose();
+ _waiting.RemoveAll(w => take.Any(t => t.player.UserId == w.player.UserId));
+ StartMatch(take.Select(t => t.player).ToList());
+ }
+
+ /* ------------------------------- rooms ----------------------------- */
+
+ private void StartMatch(List humans)
+ {
+ var seats = new SeatSlot[4];
+ for (int i = 0; i < 4; i++)
+ {
+ if (i < humans.Count)
+ {
+ var h = humans[i];
+ seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, Level = h.Level };
+ }
+ else
+ {
+ seats[i] = new SeatSlot
+ {
+ Seat = i,
+ IsBot = true,
+ Name = BotNames[_rng.Next(BotNames.Length)],
+ Avatar = Avatars[_rng.Next(Avatars.Length)],
+ Level = _rng.Next(1, 50),
+ };
+ }
+ }
+
+ var room = new GameRoom(_hub, seats, ranked: true, stake: 100, targetScore: 7);
+ room.OnFinished = FinishRoom;
+ _rooms[room.Id] = room;
+ foreach (var h in humans) _userRoom[h.UserId] = room.Id;
+
+ foreach (var h in humans)
+ _ = _hub.Clients.User(h.UserId).SendAsync("matchFound", new { roomId = room.Id, seat = room.SeatOf(h.UserId) });
+
+ room.Start();
+ }
+
+ private void FinishRoom(GameRoom room)
+ {
+ foreach (var uid in room.UserIds) _userRoom.TryRemove(uid, out _);
+ // keep briefly for any late reads, then dispose
+ _ = Task.Delay(15000).ContinueWith(_ =>
+ {
+ if (_rooms.TryRemove(room.Id, out var r)) r.Dispose();
+ });
+ }
+
+ private GameRoom? RoomOf(string userId) =>
+ _userRoom.TryGetValue(userId, out var id) && _rooms.TryGetValue(id, out var r) ? r : null;
+
+ /* --------------------------- player actions ------------------------ */
+
+ 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 OnConnected(string userId) => RoomOf(userId)?.SetConnected(userId, true);
+ public void OnDisconnected(string userId)
+ {
+ CancelMatchmaking(userId);
+ RoomOf(userId)?.SetConnected(userId, false);
+ }
+}
diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs
new file mode 100644
index 0000000..6940667
--- /dev/null
+++ b/server/src/Hokm.Server/Game/GameRoom.cs
@@ -0,0 +1,244 @@
+using Hokm.Engine;
+using Hokm.Server.Hubs;
+using Microsoft.AspNetCore.SignalR;
+
+namespace Hokm.Server.Game;
+
+public sealed class SeatSlot
+{
+ public int Seat { get; init; }
+ public string? UserId { get; set; }
+ public string Name { get; set; } = "";
+ public string Avatar { get; set; } = "a-fox";
+ public int Level { get; set; }
+ public bool IsBot { get; set; }
+ public bool Connected { get; set; } = true;
+}
+
+///
+/// A live match: owns the authoritative GameState, drives bot/human turns with
+/// server-side timers (auto-play on timeout), and broadcasts per-seat views.
+///
+public sealed class GameRoom : IDisposable
+{
+ private const int HakemDrawMs = 1200;
+ private const int AiTrumpMs = 1000;
+ private const int AiPlayMs = 800;
+ private const int TrickPauseMs = 1100;
+ private const int RoundPauseMs = 2500;
+ public const int TurnMs = 20000;
+
+ private readonly object _lock = new();
+ private readonly IHubContext _hub;
+ private readonly Random _rng = new();
+ private Timer? _timer;
+ private long? _turnDeadline;
+ private int? _disconnectedSeat;
+ private bool _finished;
+
+ public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
+ public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
+ public SeatSlot[] Seats { get; }
+ public GameState State { get; }
+ public bool Ranked { get; }
+ public int Stake { get; }
+ public Action? OnFinished { get; set; }
+
+ public GameRoom(IHubContext hub, SeatSlot[] seats, bool ranked, int stake, int targetScore)
+ {
+ _hub = hub;
+ Seats = seats;
+ Ranked = ranked;
+ Stake = stake;
+ State = Rules.CreateInitial(seats.Select(s => s.Name).ToArray(), targetScore);
+ foreach (var s in seats) State.Players[s.Seat].IsHuman = !s.IsBot;
+ }
+
+ public IEnumerable UserIds => Seats.Where(s => s.UserId != null).Select(s => s.UserId!);
+
+ public void Start()
+ {
+ lock (_lock)
+ {
+ Rules.SelectHakem(State, _rng);
+ ScheduleAndBroadcast();
+ }
+ }
+
+ public void HumanChooseTrump(string userId, string suit)
+ {
+ lock (_lock)
+ {
+ if (State.Phase != Phase.ChoosingTrump) return;
+ var seat = SeatOf(userId);
+ if (seat is null || seat != State.Hakem) return;
+ if (!Enum.TryParse(Capitalize(suit), out var t)) return;
+ Rules.ChooseTrump(State, t);
+ ScheduleAndBroadcast();
+ }
+ }
+
+ public void HumanPlay(string userId, string cardId)
+ {
+ lock (_lock)
+ {
+ if (State.Phase != Phase.Playing) return;
+ var seat = SeatOf(userId);
+ if (seat is null || seat != State.Turn) return;
+ var card = State.Players[seat.Value].Hand.FirstOrDefault(c => Map.Card(c).Id == cardId);
+ if (card is null || !Rules.IsLegal(State, seat.Value, card)) return;
+ Rules.PlayCard(State, seat.Value, card);
+ ScheduleAndBroadcast();
+ }
+ }
+
+ public void Reaction(string userId, string reaction)
+ {
+ var seat = SeatOf(userId);
+ if (seat is null) return;
+ Broadcast("reaction", new ReactionDto(seat.Value, reaction));
+ }
+
+ public void SetConnected(string userId, bool connected)
+ {
+ lock (_lock)
+ {
+ var slot = Seats.FirstOrDefault(s => s.UserId == userId);
+ if (slot is null) return;
+ slot.Connected = connected;
+ _disconnectedSeat = connected ? null
+ : (State.Turn == slot.Seat ? slot.Seat : _disconnectedSeat);
+ BroadcastState();
+ }
+ }
+
+ /* ----------------------- scheduling / driver ----------------------- */
+
+ private void ScheduleAndBroadcast()
+ {
+ _timer?.Dispose();
+ _turnDeadline = null;
+ long Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ switch (State.Phase)
+ {
+ case Phase.SelectingHakem:
+ SetTimer(HakemDrawMs, () => { Rules.DealForTrump(State, _rng); ScheduleAndBroadcast(); });
+ break;
+
+ case Phase.ChoosingTrump:
+ {
+ var hakem = State.Hakem!.Value;
+ if (Seats[hakem].IsBot)
+ SetTimer(AiTrumpMs, () =>
+ {
+ Rules.ChooseTrump(State, Ai.ChooseTrump(State.Players[hakem].Hand));
+ ScheduleAndBroadcast();
+ });
+ else
+ {
+ _turnDeadline = Now() + TurnMs;
+ SetTimer(TurnMs, () =>
+ {
+ if (State.Phase != Phase.ChoosingTrump) return;
+ Rules.ChooseTrump(State, Ai.ChooseTrump(State.Players[hakem].Hand));
+ ScheduleAndBroadcast();
+ });
+ }
+ break;
+ }
+
+ case Phase.Playing:
+ {
+ var seat = State.Turn!.Value;
+ if (Seats[seat].IsBot || !Seats[seat].Connected)
+ SetTimer(Seats[seat].IsBot ? AiPlayMs : 1500, () => AutoPlay(seat));
+ else
+ {
+ _turnDeadline = Now() + TurnMs;
+ SetTimer(TurnMs, () => AutoPlay(seat));
+ }
+ break;
+ }
+
+ case Phase.TrickComplete:
+ SetTimer(TrickPauseMs, () => { Rules.AdvanceAfterTrick(State, 2); ScheduleAndBroadcast(); });
+ break;
+
+ case Phase.RoundOver:
+ SetTimer(RoundPauseMs, () => { Rules.StartNextRound(State, _rng); ScheduleAndBroadcast(); });
+ break;
+
+ case Phase.MatchOver:
+ if (!_finished) { _finished = true; OnFinished?.Invoke(this); }
+ break;
+ }
+
+ BroadcastState();
+ }
+
+ private void AutoPlay(int seat)
+ {
+ lock (_lock)
+ {
+ if (State.Phase != Phase.Playing || State.Turn != seat) return;
+ Rules.PlayCard(State, seat, Ai.ChooseCard(State, seat));
+ ScheduleAndBroadcast();
+ }
+ }
+
+ private void SetTimer(int ms, Action action)
+ {
+ _timer?.Dispose();
+ _timer = new Timer(_ =>
+ {
+ lock (_lock) { if (!_finished) action(); }
+ }, null, ms, Timeout.Infinite);
+ }
+
+ /* ----------------------------- broadcast --------------------------- */
+
+ private void BroadcastState()
+ {
+ foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected))
+ _ = _hub.Clients.User(slot.UserId!).SendAsync("state", ToDto(slot.Seat));
+ }
+
+ private void Broadcast(string method, object payload)
+ {
+ foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected))
+ _ = _hub.Clients.User(slot.UserId!).SendAsync(method, payload);
+ }
+
+ public GameStateDto ToDto(int viewerSeat)
+ {
+ var players = State.Players.Select(p => new PlayerDto(
+ p.Seat, p.Name, p.Team, p.IsHuman, p.Hand.Count,
+ p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
+
+ var seatPlayers = Seats.OrderBy(s => s.Seat)
+ .Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot)).ToList();
+
+ RoundResultDto? rr = State.LastRoundResult is null ? null
+ : new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
+ State.LastRoundResult.Kot, State.LastRoundResult.Points);
+
+ return new GameStateDto(
+ Map.PhaseStr(State.Phase), State.Turn, State.Hakem,
+ State.Trump is null ? null : Map.SuitStr(State.Trump.Value),
+ State.LeadSeat, State.RoundTricks, State.MatchScore, State.TargetScore, State.DealId,
+ State.LastTrickWinner, State.MatchWinner,
+ State.CurrentTrick.Select(pc => new PlayedCardDto(pc.Seat, Map.Card(pc.Card))).ToList(),
+ players,
+ State.HakemDraw.Select(pc => new PlayedCardDto(pc.Seat, Map.Card(pc.Card))).ToList(),
+ rr, seatPlayers, viewerSeat, _turnDeadline, _disconnectedSeat, Ranked, Stake);
+ }
+
+ public int? SeatOf(string userId) =>
+ Seats.FirstOrDefault(s => s.UserId == userId)?.Seat;
+
+ private static string Capitalize(string s) =>
+ string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s[1..];
+
+ public void Dispose() => _timer?.Dispose();
+}
diff --git a/server/src/Hokm.Server/Hokm.Server.csproj b/server/src/Hokm.Server/Hokm.Server.csproj
new file mode 100644
index 0000000..44fec85
--- /dev/null
+++ b/server/src/Hokm.Server/Hokm.Server.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs
new file mode 100644
index 0000000..36be157
--- /dev/null
+++ b/server/src/Hokm.Server/Hubs/GameHub.cs
@@ -0,0 +1,43 @@
+using Hokm.Server.Game;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+
+namespace Hokm.Server.Hubs;
+
+public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan);
+
+[Authorize]
+public sealed class GameHub : Hub
+{
+ private readonly GameManager _manager;
+ public GameHub(GameManager manager) => _manager = manager;
+
+ private string Uid => Context.UserIdentifier ?? Context.ConnectionId;
+
+ public override Task OnConnectedAsync()
+ {
+ _manager.OnConnected(Uid);
+ return base.OnConnectedAsync();
+ }
+
+ public override Task OnDisconnectedAsync(Exception? exception)
+ {
+ _manager.OnDisconnected(Uid);
+ return base.OnDisconnectedAsync(exception);
+ }
+
+ public void StartMatchmaking(MatchmakeRequest req) =>
+ _manager.StartMatchmaking(new Player
+ {
+ UserId = Uid,
+ Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
+ Avatar = req.Avatar,
+ Level = req.Level,
+ Plan = req.Plan,
+ });
+
+ public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
+ 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);
+}
diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs
new file mode 100644
index 0000000..1f847de
--- /dev/null
+++ b/server/src/Hokm.Server/Program.cs
@@ -0,0 +1,101 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Hokm.Server.Auth;
+using Hokm.Server.Game;
+using Hokm.Server.Hubs;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// --- options ---
+var jwt = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions();
+if (string.IsNullOrWhiteSpace(jwt.Key))
+ jwt.Key = "dev-only-insecure-key-change-me-please-32+bytes!!";
+builder.Services.AddSingleton(jwt);
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// --- SignalR (camelCase to match the TS client) ---
+builder.Services
+ .AddSignalR()
+ .AddJsonProtocol(o =>
+ {
+ o.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ });
+
+// --- auth ---
+var tokenService = new TokenService(jwt);
+builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ ValidIssuer = jwt.Issuer,
+ ValidAudience = jwt.Audience,
+ IssuerSigningKey = tokenService.SecurityKey,
+ };
+ // Allow SignalR to pass the token via the query string.
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = ctx =>
+ {
+ var token = ctx.Request.Query["access_token"];
+ if (!string.IsNullOrEmpty(token) && ctx.HttpContext.Request.Path.StartsWithSegments("/hub"))
+ ctx.Token = token;
+ return Task.CompletedTask;
+ },
+ };
+ });
+builder.Services.AddAuthorization();
+
+// --- CORS for the Next.js client ---
+builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
+ .WithOrigins(
+ "http://localhost:3000", "http://localhost:3002", "http://localhost:3020",
+ "http://127.0.0.1:3000", "http://127.0.0.1:3002", "http://127.0.0.1:3020")
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials()));
+
+var app = builder.Build();
+
+app.UseCors();
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.MapGet("/", () => Results.Json(new { service = "Hokm SignalR server", status = "ok" }));
+
+// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
+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) =>
+{
+ 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 });
+});
+
+app.MapPost("/api/auth/email", (EmailLogin req, TokenService tokens) =>
+{
+ 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 });
+});
+
+app.MapHub("/hub/game");
+
+app.Run();
+
+record OtpRequest(string Phone);
+record OtpVerify(string Phone, string Code, string? Name);
+record EmailLogin(string Email, string Password, string? Name);
diff --git a/server/src/Hokm.Server/Properties/launchSettings.json b/server/src/Hokm.Server/Properties/launchSettings.json
new file mode 100644
index 0000000..1cda3ba
--- /dev/null
+++ b/server/src/Hokm.Server/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5039",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7188;http://localhost:5039",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/server/src/Hokm.Server/appsettings.Development.json b/server/src/Hokm.Server/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/server/src/Hokm.Server/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json
new file mode 100644
index 0000000..f0f587b
--- /dev/null
+++ b/server/src/Hokm.Server/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Urls": "http://localhost:5005",
+ "Jwt": {
+ "Key": "dev-only-insecure-key-change-me-please-32+bytes!!",
+ "Issuer": "hokm",
+ "Audience": "hokm-clients"
+ }
+}
diff --git a/server/tools/Hokm.Sim/Hokm.Sim.csproj b/server/tools/Hokm.Sim/Hokm.Sim.csproj
new file mode 100644
index 0000000..5bba146
--- /dev/null
+++ b/server/tools/Hokm.Sim/Hokm.Sim.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/server/tools/Hokm.Sim/Program.cs b/server/tools/Hokm.Sim/Program.cs
new file mode 100644
index 0000000..965cbbb
--- /dev/null
+++ b/server/tools/Hokm.Sim/Program.cs
@@ -0,0 +1,52 @@
+using Hokm.Engine;
+
+// All-AI simulation to validate the C# engine port (mirrors scripts/sim.ts).
+var rng = new Random(12345);
+int N = 300;
+int totalRounds = 0;
+
+for (int i = 0; i < N; i++)
+{
+ var (rounds, tricks) = PlayMatch(rng);
+ totalRounds += rounds;
+ if (tricks <= 0) throw new Exception("no tricks played");
+}
+
+Console.WriteLine($"OK: {N} matches completed. avg rounds/match = {(double)totalRounds / N:0.0}");
+
+static (int rounds, int tricks) PlayMatch(Random rng)
+{
+ var g = Rules.CreateInitial(new[] { "P0", "P1", "P2", "P3" }, 7);
+ Rules.SelectHakem(g, rng);
+ Rules.DealForTrump(g, rng);
+
+ int rounds = 0, tricks = 0, guard = 0;
+ while (g.Phase != Phase.MatchOver)
+ {
+ if (++guard > 200000) throw new Exception("loop guard tripped");
+
+ switch (g.Phase)
+ {
+ case Phase.ChoosingTrump:
+ Rules.ChooseTrump(g, Ai.ChooseTrump(g.Players[g.Hakem!.Value].Hand));
+ break;
+ case Phase.Playing:
+ int seat = g.Turn!.Value;
+ Rules.PlayCard(g, seat, Ai.ChooseCard(g, seat));
+ break;
+ case Phase.TrickComplete:
+ tricks++;
+ if (g.CurrentTrick.Count != 4) throw new Exception("trick not full");
+ Rules.AdvanceAfterTrick(g, 2);
+ break;
+ case Phase.RoundOver:
+ rounds++;
+ if (g.RoundTricks[0] + g.RoundTricks[1] > 13) throw new Exception("too many tricks");
+ Rules.StartNextRound(g, rng);
+ break;
+ default:
+ throw new Exception("unexpected phase: " + g.Phase);
+ }
+ }
+ return (rounds, tricks);
+}