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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user