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:
@@ -0,0 +1,31 @@
|
|||||||
|
// Verify server deducts the ranked entry at match start (server must be running).
|
||||||
|
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
|
||||||
|
|
||||||
|
const S = "http://localhost:5005";
|
||||||
|
const auth = await (await fetch(`${S}/api/auth/otp/verify`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: "0977", code: "1234", name: "Entry" }),
|
||||||
|
})).json();
|
||||||
|
const H = { Authorization: `Bearer ${auth.token}` };
|
||||||
|
|
||||||
|
const before = await (await fetch(`${S}/api/profile`, { headers: H })).json();
|
||||||
|
|
||||||
|
const conn = new HubConnectionBuilder()
|
||||||
|
.withUrl(`${S}/hub/game`, { accessTokenFactory: () => auth.token })
|
||||||
|
.configureLogging(LogLevel.Error).build();
|
||||||
|
let gotProfile = null, gotReward = false;
|
||||||
|
conn.on("profile", (p) => { gotProfile = p; });
|
||||||
|
conn.on("reward", () => { gotReward = true; });
|
||||||
|
await conn.start();
|
||||||
|
await conn.invoke("StartMatchmaking", { name: "Entry", avatar: "a-fox", level: 1, plan: "pro" });
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
|
||||||
|
const after = await (await fetch(`${S}/api/profile`, { headers: H })).json();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
coinsBefore: before.coins,
|
||||||
|
coinsAfter: after.coins,
|
||||||
|
entryDeducted: before.coins - after.coins,
|
||||||
|
pushedProfileCoins: gotProfile?.coins ?? null,
|
||||||
|
}));
|
||||||
|
await conn.stop();
|
||||||
|
process.exit(0);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Hokm.Server.Game;
|
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" };
|
{ "a-fox", "a-lion", "a-owl", "a-tiger", "a-panda", "a-eagle", "a-wolf", "a-cat" };
|
||||||
|
|
||||||
private readonly IHubContext<GameHub> _hub;
|
private readonly IHubContext<GameHub> _hub;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
||||||
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
||||||
private readonly object _mmLock = new();
|
private readonly object _mmLock = new();
|
||||||
private readonly List<(Player player, Timer timer)> _waiting = new();
|
private readonly List<(Player player, Timer timer)> _waiting = new();
|
||||||
private readonly Random _rng = 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 ------------------------- */
|
/* ----------------------------- 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;
|
room.OnFinished = FinishRoom;
|
||||||
_rooms[room.Id] = room;
|
_rooms[room.Id] = room;
|
||||||
foreach (var h in humans) _userRoom[h.UserId] = room.Id;
|
foreach (var h in humans) _userRoom[h.UserId] = room.Id;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Hokm.Engine;
|
using Hokm.Engine;
|
||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
|
using Hokm.Server.Profiles;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Hokm.Server.Game;
|
namespace Hokm.Server.Game;
|
||||||
|
|
||||||
@@ -30,12 +32,18 @@ public sealed class GameRoom : IDisposable
|
|||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly IHubContext<GameHub> _hub;
|
private readonly IHubContext<GameHub> _hub;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
private readonly Random _rng = new();
|
private readonly Random _rng = new();
|
||||||
private Timer? _timer;
|
private Timer? _timer;
|
||||||
private long? _turnDeadline;
|
private long? _turnDeadline;
|
||||||
private int? _disconnectedSeat;
|
private int? _disconnectedSeat;
|
||||||
private bool _finished;
|
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 Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
||||||
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||||
public SeatSlot[] Seats { get; }
|
public SeatSlot[] Seats { get; }
|
||||||
@@ -44,9 +52,10 @@ public sealed class GameRoom : IDisposable
|
|||||||
public int Stake { get; }
|
public int Stake { get; }
|
||||||
public Action<GameRoom>? OnFinished { get; set; }
|
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;
|
_hub = hub;
|
||||||
|
_scopes = scopes;
|
||||||
Seats = seats;
|
Seats = seats;
|
||||||
Ranked = ranked;
|
Ranked = ranked;
|
||||||
Stake = stake;
|
Stake = stake;
|
||||||
@@ -63,6 +72,54 @@ public sealed class GameRoom : IDisposable
|
|||||||
Rules.SelectHakem(State, _rng);
|
Rules.SelectHakem(State, _rng);
|
||||||
ScheduleAndBroadcast();
|
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)
|
public void HumanChooseTrump(string userId, string suit)
|
||||||
@@ -162,7 +219,14 @@ public sealed class GameRoom : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
case Phase.TrickComplete:
|
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;
|
break;
|
||||||
|
|
||||||
case Phase.RoundOver:
|
case Phase.RoundOver:
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ public class ProfileDto
|
|||||||
public Dictionary<string, int> Achievements { get; set; } = new();
|
public Dictionary<string, int> Achievements { get; set; } = new();
|
||||||
public List<string> Unlocked { get; set; } = new();
|
public List<string> Unlocked { get; set; } = new();
|
||||||
public long CreatedAt { get; set; }
|
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
|
public class MatchSummaryDto
|
||||||
|
|||||||
@@ -96,4 +96,65 @@ public class ProfileService
|
|||||||
await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual");
|
await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual");
|
||||||
return (reward, p);
|
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);
|
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
|
||||||
}).RequireAuthorization();
|
}).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.MapHub<GameHub>("/hub/game");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -152,3 +172,4 @@ record OtpRequest(string Phone);
|
|||||||
record OtpVerify(string Phone, string Code, string? Name);
|
record OtpVerify(string Phone, string Code, string? Name);
|
||||||
record EmailLogin(string Email, string Password, string? Name);
|
record EmailLogin(string Email, string Password, string? Name);
|
||||||
record BuyReq(string PackId);
|
record BuyReq(string PackId);
|
||||||
|
record ShopBuyReq(string Kind, string Id, int Price);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { MatchSummary, RewardResult } from "@/lib/online/types";
|
|||||||
export function GameScreen() {
|
export function GameScreen() {
|
||||||
const game = useGameStore((s) => s.game);
|
const game = useGameStore((s) => s.game);
|
||||||
const mode = useGameStore((s) => s.mode);
|
const mode = useGameStore((s) => s.mode);
|
||||||
|
const live = useGameStore((s) => s.live);
|
||||||
|
const serverReward = useGameStore((s) => s.serverReward);
|
||||||
const tally = useGameStore((s) => s.tally);
|
const tally = useGameStore((s) => s.tally);
|
||||||
const meta = useGameStore((s) => s.matchMeta);
|
const meta = useGameStore((s) => s.matchMeta);
|
||||||
const reset = useGameStore((s) => s.reset);
|
const reset = useGameStore((s) => s.reset);
|
||||||
@@ -28,8 +30,21 @@ export function GameScreen() {
|
|||||||
go(returnTo);
|
go(returnTo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const notifyAchievements = (r: RewardResult) => {
|
||||||
|
for (const a of r.newAchievements)
|
||||||
|
pushNotification({
|
||||||
|
kind: "achievement",
|
||||||
|
titleFa: "دستاورد جدید",
|
||||||
|
titleEn: "New achievement",
|
||||||
|
bodyFa: a.nameFa,
|
||||||
|
bodyEn: a.nameEn,
|
||||||
|
icon: a.icon,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client-run games (private rooms / casual): submit the result to the server.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "online" && game.phase === "match-over" && !submitted.current) {
|
if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) {
|
||||||
submitted.current = true;
|
submitted.current = true;
|
||||||
const summary: MatchSummary = {
|
const summary: MatchSummary = {
|
||||||
ranked: meta.ranked,
|
ranked: meta.ranked,
|
||||||
@@ -46,18 +61,20 @@ export function GameScreen() {
|
|||||||
.then((r) => {
|
.then((r) => {
|
||||||
setReward(r);
|
setReward(r);
|
||||||
refreshProfile();
|
refreshProfile();
|
||||||
for (const a of r.newAchievements)
|
notifyAchievements(r);
|
||||||
pushNotification({
|
|
||||||
kind: "achievement",
|
|
||||||
titleFa: "دستاورد جدید",
|
|
||||||
titleEn: "New achievement",
|
|
||||||
bodyFa: a.nameFa,
|
|
||||||
bodyEn: a.nameEn,
|
|
||||||
icon: a.icon,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
|
}, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
|
||||||
|
|
||||||
|
// Server-run ranked games: the reward arrives via the hub.
|
||||||
|
useEffect(() => {
|
||||||
|
if (live && serverReward && !submitted.current) {
|
||||||
|
submitted.current = true;
|
||||||
|
setReward(serverReward);
|
||||||
|
refreshProfile();
|
||||||
|
notifyAchievements(serverReward);
|
||||||
|
}
|
||||||
|
}, [live, serverReward, refreshProfile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+13
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
startNextRound,
|
startNextRound,
|
||||||
} from "./hokm/engine";
|
} from "./hokm/engine";
|
||||||
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
|
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
|
||||||
import { avatarEmoji, ServerGameState } from "./online/types";
|
import { avatarEmoji, RewardResult, ServerGameState } from "./online/types";
|
||||||
import type { OnlineService } from "./online/service";
|
import type { OnlineService } from "./online/service";
|
||||||
import { sound } from "./sound";
|
import { sound } from "./sound";
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ interface GameStore {
|
|||||||
|
|
||||||
/** true when the match is driven by the live SignalR server. */
|
/** true when the match is driven by the live SignalR server. */
|
||||||
live: boolean;
|
live: boolean;
|
||||||
|
/** reward pushed by the server for a server-run (ranked) match. */
|
||||||
|
serverReward: RewardResult | null;
|
||||||
|
|
||||||
newMatch: (settings: GameSettings) => void;
|
newMatch: (settings: GameSettings) => void;
|
||||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||||
@@ -90,6 +92,7 @@ const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
|
|||||||
|
|
||||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||||
let liveUnsub: (() => void) | null = null;
|
let liveUnsub: (() => void) | null = null;
|
||||||
|
let rewardUnsub: (() => void) | null = null;
|
||||||
let liveSvc: OnlineService | null = null;
|
let liveSvc: OnlineService | null = null;
|
||||||
function clearPending() {
|
function clearPending() {
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -289,6 +292,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
disconnectedSeat: null,
|
disconnectedSeat: null,
|
||||||
reconnectDeadline: null,
|
reconnectDeadline: null,
|
||||||
live: false,
|
live: false,
|
||||||
|
serverReward: null,
|
||||||
|
|
||||||
newMatch: (settings) => {
|
newMatch: (settings) => {
|
||||||
clearPending();
|
clearPending();
|
||||||
@@ -340,12 +344,15 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
sound.init();
|
sound.init();
|
||||||
liveSvc = service;
|
liveSvc = service;
|
||||||
if (liveUnsub) liveUnsub();
|
if (liveUnsub) liveUnsub();
|
||||||
|
if (rewardUnsub) rewardUnsub();
|
||||||
liveUnsub = service.onState((s) => get().applyServerState(s));
|
liveUnsub = service.onState((s) => get().applyServerState(s));
|
||||||
|
rewardUnsub = service.onReward((r) => set({ serverReward: r }));
|
||||||
set({
|
set({
|
||||||
game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }),
|
game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }),
|
||||||
started: true,
|
started: true,
|
||||||
mode: "online",
|
mode: "online",
|
||||||
live: true,
|
live: true,
|
||||||
|
serverReward: null,
|
||||||
matchMeta: { ranked: true, stake: 0 },
|
matchMeta: { ranked: true, stake: 0 },
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
@@ -417,12 +424,17 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
liveUnsub();
|
liveUnsub();
|
||||||
liveUnsub = null;
|
liveUnsub = null;
|
||||||
}
|
}
|
||||||
|
if (rewardUnsub) {
|
||||||
|
rewardUnsub();
|
||||||
|
rewardUnsub = null;
|
||||||
|
}
|
||||||
liveSvc = null;
|
liveSvc = null;
|
||||||
set({
|
set({
|
||||||
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
|
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
|
||||||
started: false,
|
started: false,
|
||||||
mode: "ai",
|
mode: "ai",
|
||||||
live: false,
|
live: false,
|
||||||
|
serverReward: null,
|
||||||
seatPlayers: [],
|
seatPlayers: [],
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ export class MockOnlineService implements OnlineService {
|
|||||||
onState(): Unsubscribe { return () => {}; }
|
onState(): Unsubscribe { return () => {}; }
|
||||||
playCard(): void {}
|
playCard(): void {}
|
||||||
chooseTrump(): void {}
|
chooseTrump(): void {}
|
||||||
|
onProfile(): Unsubscribe { return () => {}; }
|
||||||
|
onReward(): Unsubscribe { return () => {}; }
|
||||||
|
|
||||||
onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe {
|
onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe {
|
||||||
this.reactionCbs.add(cb);
|
this.reactionCbs.add(cb);
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export interface OnlineService {
|
|||||||
onState(cb: (state: ServerGameState) => void): Unsubscribe;
|
onState(cb: (state: ServerGameState) => void): Unsubscribe;
|
||||||
playCard(cardId: string): void;
|
playCard(cardId: string): void;
|
||||||
chooseTrump(suit: Suit): void;
|
chooseTrump(suit: Suit): void;
|
||||||
|
/** server pushed an updated profile (entry charge, reward, …) */
|
||||||
|
onProfile(cb: (profile: UserProfile) => void): Unsubscribe;
|
||||||
|
/** server pushed a match reward (server-run ranked games) */
|
||||||
|
onReward(cb: (reward: RewardResult) => void): Unsubscribe;
|
||||||
|
|
||||||
/* ----- rooms ----- */
|
/* ----- rooms ----- */
|
||||||
createRoom(opts: CreateRoomOptions): Promise<Room>;
|
createRoom(opts: CreateRoomOptions): Promise<Room>;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
AppNotification,
|
AppNotification,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
CoinPack,
|
||||||
Conversation,
|
Conversation,
|
||||||
DailyRewardState,
|
DailyRewardState,
|
||||||
Friend,
|
Friend,
|
||||||
@@ -50,6 +51,9 @@ export class SignalrService implements OnlineService {
|
|||||||
private stateCbs = new Set<(s: ServerGameState) => void>();
|
private stateCbs = new Set<(s: ServerGameState) => void>();
|
||||||
private reactionCbs = new Set<(seat: number, reaction: string) => void>();
|
private reactionCbs = new Set<(seat: number, reaction: string) => void>();
|
||||||
private notifCbs = new Set<(n: AppNotification) => void>();
|
private notifCbs = new Set<(n: AppNotification) => void>();
|
||||||
|
private profileCbs = new Set<(p: UserProfile) => void>();
|
||||||
|
private rewardCbs = new Set<(r: RewardResult) => void>();
|
||||||
|
private cachedProfile: UserProfile | null = null;
|
||||||
private mockNotifUnsub?: () => void;
|
private mockNotifUnsub?: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -78,6 +82,24 @@ export class SignalrService implements OnlineService {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private authHeaders(): Record<string, string> {
|
||||||
|
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
|
||||||
|
}
|
||||||
|
private async getJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${SERVER}${path}`, { headers: this.authHeaders() });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${SERVER}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
private async connect(): Promise<void> {
|
private async connect(): Promise<void> {
|
||||||
if (this.conn || !this.token) return;
|
if (this.conn || !this.token) return;
|
||||||
const conn = new signalR.HubConnectionBuilder()
|
const conn = new signalR.HubConnectionBuilder()
|
||||||
@@ -94,6 +116,12 @@ export class SignalrService implements OnlineService {
|
|||||||
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
||||||
conn.on("notification", (n: AppNotification) =>
|
conn.on("notification", (n: AppNotification) =>
|
||||||
this.notifCbs.forEach((cb) => cb(n)));
|
this.notifCbs.forEach((cb) => cb(n)));
|
||||||
|
conn.on("profile", (p: UserProfile) =>
|
||||||
|
{
|
||||||
|
this.cachedProfile = p;
|
||||||
|
this.profileCbs.forEach((cb) => cb(p));
|
||||||
|
});
|
||||||
|
conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r)));
|
||||||
|
|
||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
try {
|
try {
|
||||||
@@ -123,8 +151,6 @@ export class SignalrService implements OnlineService {
|
|||||||
this.session = session;
|
this.session = session;
|
||||||
this.token = r.token;
|
this.token = r.token;
|
||||||
if (typeof window !== "undefined") localStorage.setItem(LS_SESSION, JSON.stringify(session));
|
if (typeof window !== "undefined") localStorage.setItem(LS_SESSION, JSON.stringify(session));
|
||||||
const profile = await this.mock.getProfile();
|
|
||||||
if (r.name && profile.displayName === "بازیکن") await this.mock.updateProfile({ displayName: r.name });
|
|
||||||
await this.connect();
|
await this.connect();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -138,8 +164,12 @@ export class SignalrService implements OnlineService {
|
|||||||
async restore() {
|
async restore() {
|
||||||
if (this.session && this.token) {
|
if (this.session && this.token) {
|
||||||
void this.connect();
|
void this.connect();
|
||||||
|
try {
|
||||||
|
return { session: this.session, profile: await this.getProfile() };
|
||||||
|
} catch {
|
||||||
return { session: this.session, profile: await this.mock.getProfile() };
|
return { session: this.session, profile: await this.mock.getProfile() };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +217,7 @@ export class SignalrService implements OnlineService {
|
|||||||
this.mmRanked = opts.ranked;
|
this.mmRanked = opts.ranked;
|
||||||
this.mmStake = opts.stake;
|
this.mmStake = opts.stake;
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const p = await this.mock.getProfile();
|
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
||||||
this.emitMM("searching");
|
this.emitMM("searching");
|
||||||
await this.conn?.invoke("StartMatchmaking", {
|
await this.conn?.invoke("StartMatchmaking", {
|
||||||
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan,
|
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan,
|
||||||
@@ -208,8 +238,14 @@ export class SignalrService implements OnlineService {
|
|||||||
return null; // server streams identities via the state event
|
return null; // server streams identities via the state event
|
||||||
}
|
}
|
||||||
|
|
||||||
submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
|
async submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
|
||||||
return this.mock.submitMatchResult(summary); // local rewards until server-side rewards land
|
// Used for client-run (private/casual) games; server-run ranked rewards
|
||||||
|
// arrive via the "reward" hub event instead.
|
||||||
|
const r = await this.send<{ reward: RewardResult; profile: UserProfile }>(
|
||||||
|
"POST", "/api/match/result", summary);
|
||||||
|
this.cachedProfile = r.profile;
|
||||||
|
this.profileCbs.forEach((cb) => cb(r.profile));
|
||||||
|
return r.reward;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ live game -------------------------- */
|
/* ------------------------------ live game -------------------------- */
|
||||||
@@ -238,11 +274,37 @@ export class SignalrService implements OnlineService {
|
|||||||
return () => this.reactionCbs.delete(cb);
|
return () => this.reactionCbs.delete(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- delegated to the mock (not yet on the server) ----- */
|
onProfile(cb: (p: UserProfile) => void): Unsubscribe {
|
||||||
|
this.profileCbs.add(cb);
|
||||||
|
return () => this.profileCbs.delete(cb);
|
||||||
|
}
|
||||||
|
onReward(cb: (r: RewardResult) => void): Unsubscribe {
|
||||||
|
this.rewardCbs.add(cb);
|
||||||
|
return () => this.rewardCbs.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
getProfile() { return this.mock.getProfile(); }
|
/* ----- profile / economy → server (authoritative) ----- */
|
||||||
updateProfile(p: Parameters<OnlineService["updateProfile"]>[0]) { return this.mock.updateProfile(p); }
|
|
||||||
upgradePlan() { return this.mock.upgradePlan(); }
|
async getProfile() {
|
||||||
|
if (!this.token) return this.mock.getProfile(); // guest / pre-login
|
||||||
|
try {
|
||||||
|
const p = await this.getJson<UserProfile>("/api/profile");
|
||||||
|
this.cachedProfile = p;
|
||||||
|
return p;
|
||||||
|
} catch {
|
||||||
|
return this.mock.getProfile(); // server unreachable → degrade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async updateProfile(patch: Parameters<OnlineService["updateProfile"]>[0]) {
|
||||||
|
const p = await this.send<UserProfile>("PUT", "/api/profile", patch);
|
||||||
|
this.cachedProfile = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
async upgradePlan() {
|
||||||
|
const p = await this.send<UserProfile>("POST", "/api/profile/plan", {});
|
||||||
|
this.cachedProfile = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
listFriends() { return this.mock.listFriends(); }
|
listFriends() { return this.mock.listFriends(); }
|
||||||
listRequests() { return this.mock.listRequests(); }
|
listRequests() { return this.mock.listRequests(); }
|
||||||
@@ -289,10 +351,34 @@ export class SignalrService implements OnlineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
||||||
|
|
||||||
|
// shop catalog stays client-side; the purchase is server-authoritative
|
||||||
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
||||||
buyItem(id: string) { return this.mock.buyItem(id); }
|
async buyItem(id: string) {
|
||||||
getDailyState(): Promise<DailyRewardState> { return this.mock.getDailyState(); }
|
const item = (await this.mock.getShopItems()).find((i) => i.id === id);
|
||||||
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); }
|
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
|
||||||
getCoinPacks() { return this.mock.getCoinPacks(); }
|
try {
|
||||||
buyCoins(id: string) { return this.mock.buyCoins(id); }
|
const r = await this.send<{ ok: boolean; profile?: UserProfile }>(
|
||||||
|
"POST", "/api/shop/buy", { kind: item.kind, id, price: item.price });
|
||||||
|
if (r.profile) this.cachedProfile = r.profile;
|
||||||
|
return { ok: true, profile: r.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDailyState(): Promise<DailyRewardState> { return this.getJson<DailyRewardState>("/api/daily"); }
|
||||||
|
async claimDaily() {
|
||||||
|
const r = await this.send<{ reward: number; profile: UserProfile; day: number }>(
|
||||||
|
"POST", "/api/daily/claim", {});
|
||||||
|
this.cachedProfile = r.profile;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
getCoinPacks(): Promise<CoinPack[]> { return this.getJson<CoinPack[]>("/api/coins/packs"); }
|
||||||
|
async buyCoins(id: string) {
|
||||||
|
const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>(
|
||||||
|
"POST", "/api/coins/buy", { packId: id });
|
||||||
|
if (r.profile) this.cachedProfile = r.profile;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const svc = getService();
|
const svc = getService();
|
||||||
|
// keep the profile in sync with server-pushed updates (entry charge, reward…)
|
||||||
|
svc.onProfile((p) => set({ profile: p }));
|
||||||
const restored = await svc.restore();
|
const restored = await svc.restore();
|
||||||
if (restored) {
|
if (restored) {
|
||||||
set({ session: restored.session, profile: restored.profile, isAuthed: true, loading: false });
|
set({ session: restored.session, profile: restored.profile, isAuthed: true, loading: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user