Server-authoritative economy: wire client to server; entry + rewards on hub
Server: - daily (/api/daily, /api/daily/claim) + shop (/api/shop/buy) + ChargeEntry - GameRoom (via IServiceScopeFactory) deducts ranked entry at match start and applies match rewards at match-over, broadcasting profile + reward over the hub - tested: daily, shop (owned-guard), ranked entry deduction pushed over hub Client: - SignalrService routes profile/coins/plan/daily/shop/match to the server (Bearer); onProfile/onReward hub events; guest/offline fall back to local - session-store syncs profile from hub; game-store serverReward; GameScreen shows live ranked reward from hub (no double submit), submits client-run games - single source of truth in live mode (no economy divergence) Postgres-ready via config (Provider=postgres); EnsureCreated for now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Hokm.Server.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Hokm.Server.Game;
|
||||
|
||||
@@ -24,13 +25,18 @@ public sealed class GameManager
|
||||
{ "a-fox", "a-lion", "a-owl", "a-tiger", "a-panda", "a-eagle", "a-wolf", "a-cat" };
|
||||
|
||||
private readonly IHubContext<GameHub> _hub;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
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;
|
||||
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
||||
{
|
||||
_hub = hub;
|
||||
_scopes = scopes;
|
||||
}
|
||||
|
||||
/* ----------------------------- matchmaking ------------------------- */
|
||||
|
||||
@@ -105,7 +111,7 @@ public sealed class GameManager
|
||||
}
|
||||
}
|
||||
|
||||
var room = new GameRoom(_hub, seats, ranked: true, stake: 100, targetScore: 7);
|
||||
var room = new GameRoom(_hub, _scopes, seats, ranked: true, stake: 100, targetScore: 7);
|
||||
room.OnFinished = FinishRoom;
|
||||
_rooms[room.Id] = room;
|
||||
foreach (var h in humans) _userRoom[h.UserId] = room.Id;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Hokm.Engine;
|
||||
using Hokm.Server.Hubs;
|
||||
using Hokm.Server.Profiles;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Hokm.Server.Game;
|
||||
|
||||
@@ -30,12 +32,18 @@ public sealed class GameRoom : IDisposable
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly IHubContext<GameHub> _hub;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly Random _rng = new();
|
||||
private Timer? _timer;
|
||||
private long? _turnDeadline;
|
||||
private int? _disconnectedSeat;
|
||||
private bool _finished;
|
||||
|
||||
// match-level tally for server-authoritative rewards
|
||||
private readonly int[] _tallyTricks = new int[2];
|
||||
private readonly bool[] _tallyKot = new bool[2];
|
||||
private bool _rewardsApplied;
|
||||
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||
public SeatSlot[] Seats { get; }
|
||||
@@ -44,9 +52,10 @@ public sealed class GameRoom : IDisposable
|
||||
public int Stake { get; }
|
||||
public Action<GameRoom>? OnFinished { get; set; }
|
||||
|
||||
public GameRoom(IHubContext<GameHub> hub, SeatSlot[] seats, bool ranked, int stake, int targetScore)
|
||||
public GameRoom(IHubContext<GameHub> hub, IServiceScopeFactory scopes, SeatSlot[] seats, bool ranked, int stake, int targetScore)
|
||||
{
|
||||
_hub = hub;
|
||||
_scopes = scopes;
|
||||
Seats = seats;
|
||||
Ranked = ranked;
|
||||
Stake = stake;
|
||||
@@ -63,6 +72,54 @@ public sealed class GameRoom : IDisposable
|
||||
Rules.SelectHakem(State, _rng);
|
||||
ScheduleAndBroadcast();
|
||||
}
|
||||
if (Ranked && Stake > 0) _ = ChargeEntriesAsync();
|
||||
}
|
||||
|
||||
private async Task ChargeEntriesAsync()
|
||||
{
|
||||
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null))
|
||||
{
|
||||
using var scope = _scopes.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
||||
var p = await svc.ChargeEntry(slot.UserId!, Stake);
|
||||
if (p != null) await _hub.Clients.User(slot.UserId!).SendAsync("profile", p);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordRound()
|
||||
{
|
||||
var rr = State.LastRoundResult;
|
||||
if (rr == null) return;
|
||||
_tallyTricks[0] += rr.Tricks[0];
|
||||
_tallyTricks[1] += rr.Tricks[1];
|
||||
if (rr.Kot) _tallyKot[rr.WinningTeam] = true;
|
||||
}
|
||||
|
||||
private async Task ApplyRewardsAsync()
|
||||
{
|
||||
if (_rewardsApplied) return;
|
||||
_rewardsApplied = true;
|
||||
int winner = State.MatchWinner ?? 0;
|
||||
int rounds = State.MatchScore[0] + State.MatchScore[1];
|
||||
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null))
|
||||
{
|
||||
int team = slot.Seat % 2;
|
||||
var summary = new MatchSummaryDto
|
||||
{
|
||||
Ranked = Ranked,
|
||||
Stake = Stake,
|
||||
Won = team == winner,
|
||||
KotFor = _tallyKot[team],
|
||||
KotAgainst = _tallyKot[1 - team],
|
||||
TricksWon = _tallyTricks[team],
|
||||
Rounds = rounds,
|
||||
};
|
||||
using var scope = _scopes.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
||||
var (reward, profile) = await svc.ApplyMatch(slot.UserId!, summary);
|
||||
await _hub.Clients.User(slot.UserId!).SendAsync("reward", reward);
|
||||
await _hub.Clients.User(slot.UserId!).SendAsync("profile", profile);
|
||||
}
|
||||
}
|
||||
|
||||
public void HumanChooseTrump(string userId, string suit)
|
||||
@@ -162,7 +219,14 @@ public sealed class GameRoom : IDisposable
|
||||
}
|
||||
|
||||
case Phase.TrickComplete:
|
||||
SetTimer(TrickPauseMs, () => { Rules.AdvanceAfterTrick(State, 2); ScheduleAndBroadcast(); });
|
||||
SetTimer(TrickPauseMs, () =>
|
||||
{
|
||||
Rules.AdvanceAfterTrick(State, 2);
|
||||
if ((State.Phase == Phase.RoundOver || State.Phase == Phase.MatchOver) && State.LastRoundResult != null)
|
||||
RecordRound();
|
||||
if (State.Phase == Phase.MatchOver) _ = ApplyRewardsAsync();
|
||||
ScheduleAndBroadcast();
|
||||
});
|
||||
break;
|
||||
|
||||
case Phase.RoundOver:
|
||||
|
||||
@@ -44,6 +44,10 @@ public class ProfileDto
|
||||
public Dictionary<string, int> Achievements { get; set; } = new();
|
||||
public List<string> Unlocked { get; set; } = new();
|
||||
public long CreatedAt { get; set; }
|
||||
|
||||
// daily reward streak
|
||||
public int DailyDay { get; set; } = 1;
|
||||
public string? DailyLastClaimed { get; set; } // yyyy-MM-dd
|
||||
}
|
||||
|
||||
public class MatchSummaryDto
|
||||
|
||||
@@ -96,4 +96,65 @@ public class ProfileService
|
||||
await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual");
|
||||
return (reward, p);
|
||||
}
|
||||
|
||||
/// <summary>Deduct a ranked entry/stake up front. Returns null if not enough coins.</summary>
|
||||
public async Task<ProfileDto?> ChargeEntry(string uid, int amount)
|
||||
{
|
||||
var p = await GetOrCreate(uid, null);
|
||||
if (amount <= 0) return p;
|
||||
if (p.Coins < amount) return null;
|
||||
p.Coins -= amount;
|
||||
await Save(p);
|
||||
await Ledger(uid, "entry", -amount, "ranked");
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ----------------------------- shop ------------------------------- */
|
||||
|
||||
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
||||
{
|
||||
var p = await GetOrCreate(uid, null);
|
||||
var list = kind switch
|
||||
{
|
||||
"avatar" => p.OwnedAvatars,
|
||||
"cardfront" => p.OwnedCardFronts,
|
||||
"cardback" => p.OwnedCardBacks,
|
||||
"reactionpack" => p.OwnedReactionPacks,
|
||||
"stickerpack" => p.OwnedStickerPacks,
|
||||
_ => null,
|
||||
};
|
||||
if (list == null) return (false, null, "bad_kind");
|
||||
if (list.Contains(id)) return (false, p, "owned");
|
||||
if (price < 0 || p.Coins < price) return (false, p, "insufficient");
|
||||
p.Coins -= price;
|
||||
list.Add(id);
|
||||
await Save(p);
|
||||
await Ledger(uid, "shop", -price, $"{kind}:{id}");
|
||||
return (true, p, "");
|
||||
}
|
||||
|
||||
/* ----------------------------- daily ------------------------------ */
|
||||
|
||||
private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 500, 1000 };
|
||||
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
||||
{
|
||||
var p = await GetOrCreate(uid, null);
|
||||
return (p.DailyDay, p.DailyLastClaimed, p.DailyLastClaimed != Today);
|
||||
}
|
||||
|
||||
public async Task<(int reward, ProfileDto profile, int day)> ClaimDaily(string uid)
|
||||
{
|
||||
var p = await GetOrCreate(uid, null);
|
||||
if (p.DailyLastClaimed == Today) return (0, p, p.DailyDay);
|
||||
int day = p.DailyDay;
|
||||
int reward = DailyRewards[Math.Min(day, DailyRewards.Length) - 1];
|
||||
p.Coins += reward;
|
||||
p.DailyDay = day >= 7 ? 1 : day + 1;
|
||||
p.DailyLastClaimed = Today;
|
||||
await Save(p);
|
||||
await Ledger(uid, "daily", reward, "day" + day);
|
||||
return (reward, p, day);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,26 @@ app.MapPost("/api/match/result", async (ClaimsPrincipal u, ProfileService svc, M
|
||||
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||
{
|
||||
var (day, last, avail) = await svc.GetDaily(Uid(u));
|
||||
return Results.Json(new { day, lastClaimed = last, available = avail }, JsonOpts.Default);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/daily/claim", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||
{
|
||||
var (reward, p, day) = await svc.ClaimDaily(Uid(u));
|
||||
return Results.Json(new { reward, profile = p, day }, JsonOpts.Default);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/shop/buy", async (ClaimsPrincipal u, ProfileService svc, ShopBuyReq req) =>
|
||||
{
|
||||
var (ok, p, err) = await svc.ShopBuy(Uid(u), req.Kind, req.Id, req.Price);
|
||||
return ok
|
||||
? Results.Json(new { ok, profile = p }, JsonOpts.Default)
|
||||
: Results.BadRequest(new { ok = false, error = err });
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapHub<GameHub>("/hub/game");
|
||||
|
||||
app.Run();
|
||||
@@ -152,3 +172,4 @@ record OtpRequest(string Phone);
|
||||
record OtpVerify(string Phone, string Code, string? Name);
|
||||
record EmailLogin(string Email, string Password, string? Name);
|
||||
record BuyReq(string PackId);
|
||||
record ShopBuyReq(string Kind, string Id, int Price);
|
||||
|
||||
Reference in New Issue
Block a user