Three changes:
1. GameTable shows a spinner instead of an empty table when mode=online
and seatPlayers is empty (waiting for first state broadcast).
2. enterServerMatch schedules a 3s interval that calls service.resync()
until seatPlayers is populated, guaranteeing the state always lands.
3. resync() added to OnlineService interface + both implementations so
the game store can call it without casting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BroadcastQueueLocked now sends the full waiting-player list (name/avatar/level)
alongside the count. The client maps it onto mm.players so every queued player's
avatar shows in the 4-slot grid for all waiting users, not just the slot-0 viewer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The server only sent the queue size to the player who just joined, and the
client dropped the count entirely (emitMM ignored s.players). So two friends
queuing together never saw each other, even though the server does seat 2+
waiting humans together within ~25s.
- Server: BroadcastQueueLocked() pushes the current queue size to EVERY waiting
player on join/cancel (not just the joiner).
- Client: thread the count through emitMM → MatchmakingState.waiting.
- MatchmakingScreen shows "N players in queue" (mm.inQueue) when ≥2 humans wait,
so friends can tell they're queued together before bots fill the empty seats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production-readiness pass — remove mock-in-prod and harden the server:
- leaderboard: new DB-backed LeaderboardService + /api/leaderboard (ranked by
rating, 30s cache, bounded scan); client now calls it instead of mock fake data.
- online count: client uses real /api/stats/online (dropped the fabricated ≥50 floor).
- boot guards (Production): refuse to start if Sms:ApiKey is missing (OTP would
run in dev mode = fixed code for any phone) or Iab:AllowUnverified is true
(forged tokens could mint coins).
- payments: ZarinPal + IAB HttpClients get 15s timeouts; ZarinPal/FlatPay gateway
failures are now logged instead of silently swallowed.
- OTP: periodic prune of expired codes + stale rate-limit logs (was an unbounded
in-memory leak over a long-running process).
- DB: EnableRetryOnFailure for Postgres (transient-fault resilience).
- docker-compose: ZarinPal sandbox now defaults to false (real payments).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the uploaded profile photo only appeared in a few places (profile,
top bar, leaderboard, public profile); chat, friends, game table, match intro,
post-match roster and private rooms showed the emoji avatar only.
- carry avatarImage end-to-end:
- server DTOs: FriendDto, SeatPlayerDto, RoomPlayerDto, MatchmakeRequest +
Player/SeatSlot/PSeat; ResolveProfile now returns avatarImage; FriendDtoFor
fills it from the profile.
- client types: Friend, RoomSeat.player, MatchmakingState.players,
ServerSeatPlayer, SeatPlayer (adds avatarId + avatarImage).
- signalr-service: send my avatarImage on StartMatchmaking/CreatePrivateRoom;
carry it through mapRoom.
- game-store: applyServerState + newOnlineMatch + offline match now populate
avatarId/avatarImage (seat 0 uses your own profile photo).
- render every avatar through the shared <Avatar> component (image → emoji
fallback): ChatScreen, FriendsScreen (requests/friends/chats), GameTable
seats, MatchIntroOverlay, MatchPlayersList, RoomScreen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: the server sends matchFound then immediately broadcasts the first
state, but the client only subscribes to state inside enterServerMatch, which
runs a React effect later — so the ordered "state" message is dispatched while
there are no subscribers and is dropped. The server then waits for the human
hakem's trump choice that can never come → permanent freeze on the green felt.
- signalr-service: cache lastState; replay it to a late onState subscriber on a
microtask (after enterServerMatch resets its store); clear the cache on every
fresh-match entry (startMatchmaking / createRoom / acceptInvite) so a finished
game's final state is never replayed into a new match.
- safety net: if no state lands within 2.5s of matchFound, the client invokes
the new Resync hub method; server re-sends the current state to that player.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- server: a lone player in the online-league queue now keeps waiting (re-checking
every 15s) up to 75s so an online opponent has a real chance to join; the moment
a 2nd human queues they're matched together, and a full 4 still forms instantly.
Add PlayNow hub method to force-start with bots on demand.
- client: matchmaking screen shows a "شروع با ربات / Start with bots" button after
a few seconds so the player can skip the wait; waiting copy updated; raise the
"connection stuck" hint threshold to 90s so it no longer fires during normal waits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server logs showed REST working but ZERO hub activity — the SignalR WebSocket
upgrade isn't getting through the nginx/CDN stack and auto-fallback wasn't
recovering, so StartMatchmaking never reached the server (matchmaking spun
forever). Force the HttpTransportType.LongPolling transport — plain HTTP that
already works (same path as REST); SignalR holds the poll open so it's
effectively real-time for a turn-based game. Revertable once the api block
proxies WS upgrades.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
api.bargevasat.ir is now CDN-bypassed (origin answers directly), so the
negotiate POST works again. Drop the WS-only skipNegotiation workaround and use
the standard negotiate flow, which auto-falls back WS → SSE → long-poll if a
WebSocket upgrade isn't available.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WCDN rejects the SignalR negotiate POST (404, wcdn-nfc-reason: Http_Method), so
the hub never connects and online matchmaking never starts. Connect directly via
WebSockets with skipNegotiation so there's no negotiate POST; the JWT rides the
?access_token query the server already accepts for /hub. The proper fix remains
bypassing the CDN for api.bargevasat.ir.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Private rooms were 100% client-simulated (the "friend" auto-accepted then bots
filled invited seats). Now they're server-authoritative over SignalR:
Server (GameManager.PrivateRooms + GameHub):
- Room registry with create/invite/accept/decline/addBot/clearSeat/start/leave.
- Invite pushes a `roomInvite` to that user (Clients.User); the seat stays
"invited" (a pending guest with their real profile, resolved server-side) — it
is NEVER replaced by a bot.
- StartPrivate refuses while any invite is pending; only EMPTY seats fill with
bots. Then it spins up a live GameRoom and matchFound → both devices enter.
- Host leave / disconnect closes the room (roomClosed); members free their seat.
Client:
- signalr-service implements the room methods over the hub (+ room/roomInvite/
roomClosed events, room mapping, onRoomInvite); mock keeps offline no-ops.
- online-store accept/declineInvite; RoomScreen blocks "Start" while an invite
is pending and auto-enters the live game on matchFound (host + friend).
- New global InviteModal (accept/decline) + i18n (invite.*, room.waitAccept).
Addresses: (1) no bot replacement, (2) game waits for acceptance, (3) invited
friend shown as a pending guest with their name/avatar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shop: when short on coins the detail sheet now shows "{n} more coins" + a
"Get coins" CTA that opens the buy-coins page (was a dead disabled button).
- Chat: pin/unpin conversations (max 3, persisted to localStorage); pinned float
to the top with a gold pin. i18n chat.pin/unpin/pinLimit.
- Surrender: server now rate-limits forfeit asks at a human teammate
(45s per-user cooldown) so it can't be spammed. (Bot teammate still ends
immediately; teammate confirm dialog already existed.)
- OTP login hardening: Kavenegar send now parses the API status from the body
(HTTP 200 can still be a failure) + logs it + 12s timeout; client auth fetch
gets a 20s AbortController timeout so a lost response surfaces an error
instead of freezing on "sending…".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
auth.rateLimited. Knobs in appsettings Sms (Sms__* env).
Private rooms (invite)
- Cancel-invite button on pending seats; friend picker shows presence
(online/offline/in-game, sorted online-first) and flags in-game players.
- Mock invite stays pending ~3.5s and a cancel truly stops the auto-accept
(was a bug that re-seated cancelled invites).
In-game UI
- Scoreboard is compact + shrink-safe (no overflow on narrow screens).
- Played trick cards land dead-center (were ~2px off the corner anchor).
Plus the in-flight typing-indicator work (GameHub, ChatScreen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mock service intentionally KEPT the persisted profile (hokm.profile) on
signOut, and getProfile() reloads it — so after logout the previous user's
name/gender/avatar resurrected from localStorage. Now signOut clears the
in-memory + persisted profile, and the SignalR service also clears its mock
fallback so the post-logout guest profile is fresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- AuthScreen gated the code-entry step on devCode != null, so with real SMS
(no devCode) it got stuck after "send". Gate on a `sent` flag instead; add
sending state, send-failure message, "code sent" hint, change-number, and
raise the code input cap to 6 (codes are 5 digits).
- signOut now resets the store to a fresh guest profile, and the SignalR
service clears its cachedProfile — so the previous user's name/avatar no
longer linger after logout.
- i18n: auth.sending / sendFailed / codeSent / invalidPhone / changeNumber.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Photo upload:
- Lower the custom profile-photo gate from level 25 to level 3 (client const +
i18n hint + server gate in ProfileService.Update). The level-25 "Expert" title
is unrelated and unchanged.
Report a player:
- New ReportReason type + service.reportUser(targetId, reason, details?).
- Report entry points: a "گزارش تخلف" button + reason picker (nudity / insult /
other) in the public-profile modal, and a flag button in the chat header
(reports the peer for an insulting chat) with a confirmation toast.
- Mock records reports to localStorage; SignalR POSTs /api/report.
- Server: POST /api/report → ProfileService.ReportUser stores the report in the
write-only ledger (kind="report", ref="{targetId}|{reason}|{details}") so no
schema change is needed (server uses EnsureCreated, not migrations).
- i18n: report.* keys (fa + en).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mock emitted random "a friend is online / event is live" notifications on a
35s timer and the live service forwarded them. Dropped both — only real
notifications now fire (friend requests, achievements, daily reward, payment,
match-ended, and server hub events).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Achievements: generator-driven, now 100+ across 7 categories (added Rulership)
mirrored client + server with identical ids/goals/coins. New tracked stats:
hakemRounds (be the hakem — incl. "7× Hakem"), roundsWon, plus losses metric.
Custom achievement-only sticker packs (Rulership 👑, Firestorm 🔥) with new
inline-SVG art (crown-gold, seven-zip, streak-fire), unlocked by hakem_7 /
streak_10. Server GameRoom tallies hakem rounds per seat + rounds won per team;
client tallies the same for vs-computer/private games (dealId-deduped).
Forfeit (surrender): a player can request forfeit; if the teammate is a bot it
auto-confirms, otherwise the human teammate gets a confirm/decline prompt
(20s timeout). Result: forfeiting with ≥1 round won = normal loss; 0 rounds = Kot.
Wired client↔server over the hub (RequestForfeit/ConfirmForfeit/DeclineForfeit
+ "forfeit" event); offline/vs-computer ends immediately in the store. Flag
button + confirm dialogs in the table.
Online count: never shows below 50 — live service floors the real count with a
drifting believable number (mock base lowered to ~50–170).
Matchmaking: real players get a longer priority window (9s) before bots fill;
bots now occasionally react after winning a trick (humanize).
Coins: starter pack is 95,000 Toman (50k coins); packs rescaled up (server + mock).
Verified: dotnet build + tsc + next build clean; sim unlocks 57 achievements/500
matches; live server: starter=95000, a 7-hakem win unlocks hakem_7 + wins_1 with
hakemRounds/roundsWon persisted. Images rebuilt on :1500/:1505.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Social: EF-backed friends graph + chat (SocialService/SocialModels);
REST endpoints (friends add/accept/decline/remove/list/requests,
chat conversations/messages/send) with real-time hub events
(friendRequest/social/chat). GameManager tracks online users for presence.
- Client SignalrService: friends + chat now hit the server and react to
hub events (refetch + emit); no longer delegated to the mock.
- IAB: /api/coins/iab/verify endpoint + IabVerifyReq for Cafe Bazaar/Myket
(token verification is a documented TODO pending store accounts/SKUs).
- Persistence: EF Core Design package + DesignTimeDbContextFactory (Postgres),
Program auto-migrate/EnsureCreated, appsettings.Production.json.example
with Supabase connection + live ZarinPal template.
Verified end-to-end (two users, SQLite dev): request -> accept ->
bidirectional friends, chat send with per-user fromMe, unread count +
read-on-fetch. Server + client builds clean (dotnet build, tsc, next build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ZarinpalService (request/verify) + /api/coins/pay/request (JWT) and
/api/coins/pay/callback (verify → credit via ProfileService.BuyCoins → redirect
back with ?pay=success); merchant id from config (sandbox default)
- Client buyCoins (live) returns the StartPay redirect URL; BuyCoinsScreen
redirects; page.tsx handles the ?pay return (notify + refresh)
- Verified: sandbox request returns a real StartPay URL
- Documented Cafe Bazaar (Poolakey) / Myket IAB as the required store payment path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- Coins only matter for ranked: free games (vs computer / private friend rooms)
cost nothing; random ranked requires an entry (stake), gated by balance →
routes to buy-coins when short
- Buy Coins page (CoinPack/getCoinPacks/buyCoins; mock credits now, real
Zarinpal/IDPay TODO); TopBar coins → buy; lobby create-room is Free
- Friends: removed instant red ✕ delete; UserMinus → inline confirm before remove
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- OnlineService.getOnlineCount(); mock random-walks a believable number,
SignalrService reads GET /api/stats/online (server tracks hub connections)
- Home screen badge with pulsing dot, polls every 8s, localized digits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- @microsoft/signalr client implementing OnlineService: REST auth, hub
matchmaking, server-driven game state (onState), play/trump, reactions;
delegates not-yet-server-backed features (profile/friends/shop/chat/rooms)
to the mock. Selected via NEXT_PUBLIC_USE_SERVER=1 (NEXT_PUBLIC_SERVER_URL)
- game-store live mode: enterServerMatch + applyServerState (maps server DTO,
hides opponent hands, tally + SFX), inputs route to the hub; no local engine
- MatchmakingScreen auto-enters the live match when the server signals ready
- Verified end-to-end via scripts/live-test.mjs (auth -> hub -> match -> state)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>