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:
@@ -13,6 +13,8 @@ import { MatchSummary, RewardResult } from "@/lib/online/types";
|
||||
export function GameScreen() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
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 meta = useGameStore((s) => s.matchMeta);
|
||||
const reset = useGameStore((s) => s.reset);
|
||||
@@ -28,8 +30,21 @@ export function GameScreen() {
|
||||
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(() => {
|
||||
if (mode === "online" && game.phase === "match-over" && !submitted.current) {
|
||||
if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) {
|
||||
submitted.current = true;
|
||||
const summary: MatchSummary = {
|
||||
ranked: meta.ranked,
|
||||
@@ -46,18 +61,20 @@ export function GameScreen() {
|
||||
.then((r) => {
|
||||
setReward(r);
|
||||
refreshProfile();
|
||||
for (const a of r.newAchievements)
|
||||
pushNotification({
|
||||
kind: "achievement",
|
||||
titleFa: "دستاورد جدید",
|
||||
titleEn: "New achievement",
|
||||
bodyFa: a.nameFa,
|
||||
bodyEn: a.nameEn,
|
||||
icon: a.icon,
|
||||
});
|
||||
notifyAchievements(r);
|
||||
});
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
|
||||
+13
-1
@@ -12,7 +12,7 @@ import {
|
||||
startNextRound,
|
||||
} from "./hokm/engine";
|
||||
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 { sound } from "./sound";
|
||||
|
||||
@@ -76,6 +76,8 @@ interface GameStore {
|
||||
|
||||
/** true when the match is driven by the live SignalR server. */
|
||||
live: boolean;
|
||||
/** reward pushed by the server for a server-run (ranked) match. */
|
||||
serverReward: RewardResult | null;
|
||||
|
||||
newMatch: (settings: GameSettings) => void;
|
||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||
@@ -90,6 +92,7 @@ const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
|
||||
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
let liveUnsub: (() => void) | null = null;
|
||||
let rewardUnsub: (() => void) | null = null;
|
||||
let liveSvc: OnlineService | null = null;
|
||||
function clearPending() {
|
||||
if (pending) {
|
||||
@@ -289,6 +292,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
disconnectedSeat: null,
|
||||
reconnectDeadline: null,
|
||||
live: false,
|
||||
serverReward: null,
|
||||
|
||||
newMatch: (settings) => {
|
||||
clearPending();
|
||||
@@ -340,12 +344,15 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
sound.init();
|
||||
liveSvc = service;
|
||||
if (liveUnsub) liveUnsub();
|
||||
if (rewardUnsub) rewardUnsub();
|
||||
liveUnsub = service.onState((s) => get().applyServerState(s));
|
||||
rewardUnsub = service.onReward((r) => set({ serverReward: r }));
|
||||
set({
|
||||
game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }),
|
||||
started: true,
|
||||
mode: "online",
|
||||
live: true,
|
||||
serverReward: null,
|
||||
matchMeta: { ranked: true, stake: 0 },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -417,12 +424,17 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
liveUnsub();
|
||||
liveUnsub = null;
|
||||
}
|
||||
if (rewardUnsub) {
|
||||
rewardUnsub();
|
||||
rewardUnsub = null;
|
||||
}
|
||||
liveSvc = null;
|
||||
set({
|
||||
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
|
||||
started: false,
|
||||
mode: "ai",
|
||||
live: false,
|
||||
serverReward: null,
|
||||
seatPlayers: [],
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
|
||||
@@ -504,6 +504,8 @@ export class MockOnlineService implements OnlineService {
|
||||
onState(): Unsubscribe { return () => {}; }
|
||||
playCard(): void {}
|
||||
chooseTrump(): void {}
|
||||
onProfile(): Unsubscribe { return () => {}; }
|
||||
onReward(): Unsubscribe { return () => {}; }
|
||||
|
||||
onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe {
|
||||
this.reactionCbs.add(cb);
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface OnlineService {
|
||||
onState(cb: (state: ServerGameState) => void): Unsubscribe;
|
||||
playCard(cardId: string): 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 ----- */
|
||||
createRoom(opts: CreateRoomOptions): Promise<Room>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
|
||||
init: async () => {
|
||||
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();
|
||||
if (restored) {
|
||||
set({ session: restored.session, profile: restored.profile, isAuthed: true, loading: false });
|
||||
|
||||
Reference in New Issue
Block a user