Phase G: scaffold .NET 10 + SignalR backend (engine port + hub + auth)

- server/ monorepo: Hokm.Engine (C# port of TS engine+AI, validated by sim),
  Hokm.Server (SignalR GameHub, in-memory matchmaking/rooms, server-side turn
  timers + bot fill + disconnect handling, per-seat state broadcast), Hokm.Sim
- JWT dev auth (OTP 1234 + email); CORS for the Next client; /hub/game
- NuGet restored from mirrors (Soroush Nexus + Liara); NuGetAudit off
- README + .NET .gitignore; static class Engine renamed Rules (namespace clash)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 12:42:15 +03:30
parent ae239f4c51
commit aaf66b921f
22 changed files with 1220 additions and 0 deletions
@@ -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);
}
}
+64
View File
@@ -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<CardDto>? 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<PlayedCardDto> CurrentTrick,
List<PlayerDto> Players,
List<PlayedCardDto> HakemDraw,
RoundResultDto? LastRoundResult,
List<SeatPlayerDto> 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",
};
}
+144
View File
@@ -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";
}
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
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<GameHub> _hub;
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
private readonly ConcurrentDictionary<string, string> _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<GameHub> hub) => _hub = hub;
/* ----------------------------- matchmaking ------------------------- */
public void StartMatchmaking(Player p)
{
// Pro players skip the queue entirely.
if (p.Plan == "pro")
{
StartMatch(new List<Player> { 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<Player> 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);
}
}
+244
View File
@@ -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;
}
/// <summary>
/// A live match: owns the authoritative GameState, drives bot/human turns with
/// server-side timers (auto-play on timeout), and broadcasts per-seat views.
/// </summary>
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<GameHub> _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<GameRoom>? OnFinished { get; set; }
public GameRoom(IHubContext<GameHub> 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<string> 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<Suit>(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();
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Hokm.Engine\Hokm.Engine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
</ItemGroup>
</Project>
+43
View File
@@ -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);
}
+101
View File
@@ -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<JwtOptions>() ?? 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<TokenService>();
builder.Services.AddSingleton<GameManager>();
// --- 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<GameHub>("/hub/game");
app.Run();
record OtpRequest(string Phone);
record OtpVerify(string Phone, string Code, string? Name);
record EmailLogin(string Email, string Password, string? Name);
@@ -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"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+15
View File
@@ -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"
}
}