diff --git a/scripts/entry-test.mjs b/scripts/entry-test.mjs new file mode 100644 index 0000000..a25e2c9 --- /dev/null +++ b/scripts/entry-test.mjs @@ -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); diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 5650510..f6153dc 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -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 _hub; + private readonly IServiceScopeFactory _scopes; private readonly ConcurrentDictionary _rooms = new(); private readonly ConcurrentDictionary _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 hub) => _hub = hub; + public GameManager(IHubContext 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; diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 6940667..1ed5ec3 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -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 _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? OnFinished { get; set; } - public GameRoom(IHubContext hub, SeatSlot[] seats, bool ranked, int stake, int targetScore) + public GameRoom(IHubContext 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(); + 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(); + 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: diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs index 6a2bfc7..4f3e734 100644 --- a/server/src/Hokm.Server/Profiles/ProfileModels.cs +++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs @@ -44,6 +44,10 @@ public class ProfileDto public Dictionary Achievements { get; set; } = new(); public List 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 diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 5f261fc..84b5b9f 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -96,4 +96,65 @@ public class ProfileService await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual"); return (reward, p); } + + /// Deduct a ranked entry/stake up front. Returns null if not enough coins. + public async Task 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); + } } diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 6941538..390203b 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -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("/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); diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index 46c7357..bb51828 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -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 ( <> diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 167ebf9..0d664c6 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -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 | 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((set, get) => { disconnectedSeat: null, reconnectDeadline: null, live: false, + serverReward: null, newMatch: (settings) => { clearPending(); @@ -340,12 +344,15 @@ export const useGameStore = create((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((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, diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index caf1f43..0867154 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -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); diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index c797445..04aab58 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -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; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index 05c3804..d4bb8ff 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -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 { + return this.token ? { Authorization: `Bearer ${this.token}` } : {}; + } + private async getJson(path: string): Promise { + 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(method: string, path: string, body?: unknown): Promise { + 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 { 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 { - return this.mock.submitMatchResult(summary); // local rewards until server-side rewards land + async submitMatchResult(summary: MatchSummary): Promise { + // 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[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("/api/profile"); + this.cachedProfile = p; + return p; + } catch { + return this.mock.getProfile(); // server unreachable → degrade + } + } + async updateProfile(patch: Parameters[0]) { + const p = await this.send("PUT", "/api/profile", patch); + this.cachedProfile = p; + return p; + } + async upgradePlan() { + const p = await this.send("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 { return this.mock.getLeaderboard(); } + + // shop catalog stays client-side; the purchase is server-authoritative getShopItems(): Promise { return this.mock.getShopItems(); } - buyItem(id: string) { return this.mock.buyItem(id); } - getDailyState(): Promise { 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 { return this.getJson("/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 { return this.getJson("/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; + } } diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index b668411..642a8c2 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -35,6 +35,8 @@ export const useSessionStore = create((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 });