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:
soroush.asadi
2026-06-04 17:32:47 +03:30
parent d0b8976713
commit 4f2e4e14ea
12 changed files with 341 additions and 31 deletions
+101 -15
View File
@@ -13,6 +13,7 @@ import {
AppNotification,
AuthSession,
ChatMessage,
CoinPack,
Conversation,
DailyRewardState,
Friend,
@@ -50,6 +51,9 @@ export class SignalrService implements OnlineService {
private stateCbs = new Set<(s: ServerGameState) => void>();
private reactionCbs = new Set<(seat: number, reaction: string) => 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;
constructor() {
@@ -78,6 +82,24 @@ export class SignalrService implements OnlineService {
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> {
if (this.conn || !this.token) return;
const conn = new signalR.HubConnectionBuilder()
@@ -94,6 +116,12 @@ export class SignalrService implements OnlineService {
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
conn.on("notification", (n: AppNotification) =>
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;
try {
@@ -123,8 +151,6 @@ export class SignalrService implements OnlineService {
this.session = session;
this.token = r.token;
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();
return session;
}
@@ -138,7 +164,11 @@ export class SignalrService implements OnlineService {
async restore() {
if (this.session && this.token) {
void this.connect();
return { session: this.session, profile: await this.mock.getProfile() };
try {
return { session: this.session, profile: await this.getProfile() };
} catch {
return { session: this.session, profile: await this.mock.getProfile() };
}
}
return null;
}
@@ -187,7 +217,7 @@ export class SignalrService implements OnlineService {
this.mmRanked = opts.ranked;
this.mmStake = opts.stake;
await this.connect();
const p = await this.mock.getProfile();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
this.emitMM("searching");
await this.conn?.invoke("StartMatchmaking", {
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
}
submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
return this.mock.submitMatchResult(summary); // local rewards until server-side rewards land
async submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
// 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 -------------------------- */
@@ -238,11 +274,37 @@ export class SignalrService implements OnlineService {
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(); }
updateProfile(p: Parameters<OnlineService["updateProfile"]>[0]) { return this.mock.updateProfile(p); }
upgradePlan() { return this.mock.upgradePlan(); }
/* ----- profile / economy → server (authoritative) ----- */
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(); }
listRequests() { return this.mock.listRequests(); }
@@ -289,10 +351,34 @@ export class SignalrService implements OnlineService {
}
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
// shop catalog stays client-side; the purchase is server-authoritative
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
buyItem(id: string) { return this.mock.buyItem(id); }
getDailyState(): Promise<DailyRewardState> { return this.mock.getDailyState(); }
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); }
getCoinPacks() { return this.mock.getCoinPacks(); }
buyCoins(id: string) { return this.mock.buyCoins(id); }
async buyItem(id: string) {
const item = (await this.mock.getShopItems()).find((i) => i.id === id);
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
try {
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;
}
}