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:
@@ -39,3 +39,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# .NET (server/)
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
*.user
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- Don't reach out to api.nuget.org for vulnerability audit (mirror-only / Iran). -->
|
||||||
|
<NuGetAudit>false</NuGetAudit>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Hokm.Engine/Hokm.Engine.csproj" />
|
||||||
|
<Project Path="src/Hokm.Server/Hokm.Server.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tools/">
|
||||||
|
<Project Path="tools/Hokm.Sim/Hokm.Sim.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<!-- Soroush self-hosted Nexus group -->
|
||||||
|
<add key="soroush-nexus" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
|
||||||
|
<!-- Liara public mirror -->
|
||||||
|
<add key="liara" value="https://package-mirror.liara.ir/repository/nuget/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
@@ -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=<jwt>`).
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace Hokm.Engine;
|
||||||
|
|
||||||
|
/// <summary>AI bot — port of src/lib/hokm/ai.ts.</summary>
|
||||||
|
public static class Ai
|
||||||
|
{
|
||||||
|
public static Suit ChooseTrump(IReadOnlyList<Card> 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<Card> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Card> Create()
|
||||||
|
{
|
||||||
|
var deck = new List<Card>(52);
|
||||||
|
foreach (var suit in Suits)
|
||||||
|
for (int rank = 2; rank <= 14; rank++)
|
||||||
|
deck.Add(new Card(suit, rank));
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fisher–Yates shuffle into a new list.</summary>
|
||||||
|
public static List<Card> Shuffle(IReadOnlyList<Card> 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<Card> SortHand(IEnumerable<Card> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
namespace Hokm.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative Hokm rules — a direct port of the TypeScript engine
|
||||||
|
/// (src/lib/hokm/engine.ts). Methods mutate the passed GameState.
|
||||||
|
/// </summary>
|
||||||
|
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<Card> 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<PlayedCard> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
namespace Hokm.Engine;
|
||||||
|
|
||||||
|
public enum Suit { Spades, Hearts, Diamonds, Clubs }
|
||||||
|
|
||||||
|
public enum Phase
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
SelectingHakem,
|
||||||
|
ChoosingTrump,
|
||||||
|
Playing,
|
||||||
|
TrickComplete,
|
||||||
|
RoundOver,
|
||||||
|
MatchOver,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A playing card. Rank 2..14 (Ace = 14).</summary>
|
||||||
|
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<Card> 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Full authoritative game state (mutated by the Engine).</summary>
|
||||||
|
public sealed class GameState
|
||||||
|
{
|
||||||
|
public Phase Phase { get; set; } = Phase.Idle;
|
||||||
|
public List<Player> Players { get; set; } = new();
|
||||||
|
public List<Card> Deck { get; set; } = new();
|
||||||
|
public int? Hakem { get; set; }
|
||||||
|
public Suit? Trump { get; set; }
|
||||||
|
public int? Turn { get; set; }
|
||||||
|
public List<PlayedCard> 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<PlayedCard> 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Hokm.Engine\Hokm.Engine.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user