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); +}