# Barg-e Vasat (برگ وسط) — Project Handoff Persian **Hokm** card game for the Iran market. Web/PWA + Android (Capacitor), vs-AI and online multiplayer, with a full gamification economy. This doc is the single source of truth for any agent/session continuing the project. --- ## 1. Repo, remotes, run - **Code root:** `D:\Projects\hokm` (Next.js app at root; .NET backend under `server/`). - **Git remote `origin`** = Gitea: `https://git.soroushasadi.com/soroushdes/HokmPlay.git`, branch **`main`**. (No GitHub remote.) Pushing `origin main` triggers CI/CD (Gitea Actions). - **Brand:** name «برگ وسط» / "Barg-e Vasat" (insider Hokm slang — middle card when weak). Don't rename to "Hokm"; folder/repo stay hokm/HokmPlay. ### Run locally (dev) ```bash # frontend (root) — http://localhost:3000 npm run dev # backend (.NET 10 + SignalR) — http://localhost:5005 cd server && HOKM_USE_SQLITE=1 dotnet run --project src/Hokm.Server/Hokm.Server.csproj ``` `.env.local` for live mode: `NEXT_PUBLIC_USE_SERVER=1`, `NEXT_PUBLIC_SERVER_URL=http://localhost:5005`. Without `NEXT_PUBLIC_USE_SERVER=1` the app runs fully offline against the in-memory **mock** service. ### Run the Docker stack (local, prod-like) — what the user actually tests Ports are in **1500–1600** so they coexist with `npm run dev`/`dotnet run`: - web `http://localhost:1500` (nginx serving the static export) → api `:1505` (.NET) → `:1510` postgres. ```bash cd D:\Projects\hokm copy deploy\ENV_FILE.example .env # set JWT_KEY, POSTGRES_PASSWORD; registries default to HTTP Nexus docker compose build server web docker compose up -d ``` **Gotcha:** after rebuilding the web image, `docker compose up -d` sometimes says "Running" and keeps the old image. Force it: ```bash docker stop hokm-web && docker rm hokm-web && docker compose up -d --no-deps web ``` ### Build / verify (run before committing) ```bash rm -rf .next && npx tsc --noEmit # types (rm .next first — stale .next/dev breaks typedRoutes) npx tsx scripts/sim.ts # engine + gamification self-test (200 matches + 500 rewards) dotnet build server/Hokm.slnx -c Release # server npm run build # next static export ``` --- ## 2. Architecture (the important bits) - **Frontend:** Next 16 (App Router, `output:"export"` → static), React 19, Tailwind v4, Framer Motion, Zustand. RTL Persian default + English; custom i18n in `src/lib/i18n.tsx` (NOT next-intl) — **every string must be added to BOTH the `fa` and `en` dicts**. - **Engine:** pure-TS Hokm engine/AI in `src/lib/hokm/`; mirrored as C# (`server/src/Hokm.Engine`, static class **`Rules`** — not `Engine`, to avoid namespace clash). Validated by `scripts/sim.ts` (TS) and `server/tools/Hokm.Sim` (C#). - **Service seam:** all networking goes through `OnlineService` (`src/lib/online/service.ts`). Two impls: `MockOnlineService` (offline) and `SignalrService` (live). `getService()` picks via `NEXT_PUBLIC_USE_SERVER==="1"`. - **Backend:** .NET 10 ASP.NET Core + SignalR (`server/src/Hokm.Server`), EF Core (SQLite dev / Npgsql Postgres prod), JWT. Hub `/hub/game`; REST under `/api/*`. Profile stored as a JSON blob (`ProfileRow`) + coin `Ledger`. In-memory matchmaking/rooms in `GameManager`/`GameRoom`. - **⚠️ CRITICAL — keep gamification in sync:** `src/lib/online/gamification.ts` (client) and `server/src/Hokm.Server/Profiles/Gamification.cs` (server) implement the SAME rules (rating/Elo, coins, XP, achievements, titles). **In live mode the server is authoritative** — ranked games run server-side and push `reward`/`profile` over the hub; client-run (vs-computer/private) games submit a `MatchSummary` to `POST /api/match/result`. If you change a rule, change BOTH files identically (ids/goals/coins/metrics/formulas). - **Zustand stores:** `ui-store` (History-API screen routing via hash), `session-store` (auth + profile, subscribes hub `onProfile`), `online-store` (friends/chat/leaderboard/matchmaking), `game-store` (the table driver — `mode: ai|online`, `live`, turn timer, minimize/resume, forfeit), `sound-store`, `notification-store`, `celebration-store`. --- ## 3. Feature status (DONE) - Full offline vs-AI game (engine, AI, turn timer + auto-play, disconnect/reconnect sim). - Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint. - **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`. - **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded. - **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven) + store IAB. **⚠️ Economy is balanced as one system — keep ALL of these in sync client↔server when tuning** (`gamification.ts` ↔ `Profiles/Gamification.cs` + `ProfileService`): coin packs (`p1` 5,000c/99k﷼ · `p2` 12,000c/199k · `p3` 28,000c/399k · `p4` 65,000c/799k — a starter pack ≈ one premium cosmetic, NOT "buy everything"), item prices (cosmetics 600–6,000c, XP packs 1,500/4,000/8,000c), achievement coin reward `min(1500, max(50, round((40+goal·6)/50)·50))`, rank rewards 150/300/500/900/1500, daily `[100,150,200,300,400,600,1500]`. Stores (Bazaar/Myket) **IAB** — see §6. - **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements). - **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**. - **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring. - **Card backs are pattern-distinct** (not just recoloured): each `CardBackDef` carries a `pattern` (`stripes/argyle/grid/dots/rays/scales/crosshatch/royal/filigree/gem`) + optional `motif` glyph. Rendering lives in `src/lib/cardBack.ts` (`cardBackVisual`/`cardBackMotif`/`backVisualFromDef`), used by `PlayingCard`, the shop preview, and the profile picker so all three match. - **Purchasable titles:** `TitleDef.price` makes a title buyable; shop **Titles** section (`ShopItemKind` now includes `"title"`, server `ShopBuy` handles `title → OwnedTitles`, mock mirrors it). The equipped title shows **under your name on the table** (`SeatPlayer.title` → `SeatAvatar`, seat 0 from your profile in every mode incl. live via `applyServerState`) and in the **Discover/find list** (`PlayerSummary.title`, server `ToSummary`). Localize via `titleById(id)`. New themed packs: **کل‌کل/banter** (kolkol, tikeh, shakkak, raghib), **Persian trends/praise** (trends, tashvigh), **court cards** (khanevadeh: تک‌خال/آس دل/شاه خشت/بی‌بی گشنیز), moods (ehsasat). Banter uses the `Stamp` helper (rounded badge + Persian phrase); court cards use `CourtCard`. Profile **photo upload gated at level ≥ 25**. New sticker packs are **client-only** (server `ShopBuy` is generic) — add art to `Sticker.tsx` + an entry in `STICKER_PACKS`. - **Daily rewards** (boosted): `[300, 500, 750, 1000, 1500, 2500, 7500]` — **must stay in sync** between client `gamification.ts DAILY_REWARDS` and server `ProfileService.DailyRewards` (server is authoritative for the claim). Day 7 is the gold "special" tier. - **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**. **Friend-request rate limit = 10 / rolling hour** per user (server `SocialService.TryRecordRequest`, static in-memory; mirrored in the mock). Both `addFriend(query)` and `addFriendById(userId)` funnel through it. - **Public profiles:** tap any player in the **leaderboard / friends list / end-of-game roster** → `PublicProfileModal` (global, `ui-store.viewProfile(id)`) shows their identity, stats, and **achievement board** + a rate-limited **Add-friend** button. Server `GET /api/profile/{id}/public` → `PublicProfileDto` (no coins/phone/email); mock synthesizes deterministic stats seeded from the id. Client `OnlineService.getPublicProfile(id)` / `addFriendById(id)`. - **Social hub:** `FriendsScreen` is now tabbed — **Friends / Discover / Messages**. **Discover** = find-friends search (debounced) + suggested players, each row taps to the public profile and has a rate-limited Add button. **Messages** = conversation inbox (`listConversations`, unread badges, relative time) → opens `ChatScreen`. New: `OnlineService.searchPlayers(q)` / `suggestedPlayers()` → `PlayerSummary[]`; server `GET /api/players/search?q=` + `/api/players/suggested` (`SocialService.SearchPlayers`/`Suggested`, online-first, excludes friends/self). Mock synthesizes results. - **Profile gender + social links:** `UserProfile.gender` (`""|male|female|other`, shown as ♂/♀/⚧ in Discover + public profile + edited in `ProfileScreen`'s `SocialSettings`), `socials` (instagram/telegram/x/youtube handles or URLs, rendered as tappable chips), and `socialsVisibility` (**public / friends / hidden**). Helpers in `src/lib/social.ts` (`GENDER_META`, `SOCIAL_PLATFORMS`, `socialUrl`, `hasSocials`). **Privacy is server-enforced:** `SocialService.GetPublicProfile` only includes `Socials` when `public`, or `friends` && the viewer is a friend, or it's you; `hidden` → never. `PlayerSummary.gender` carried in discovery. Server fields on `ProfileDto` (`Gender`/`Socials`/`SocialsVisibility`); `ProfileService.Update` parses them; `updateProfile` patch widened (client interface + session-store + mock + signalr). - **Forfeit:** request + teammate-confirm (server `GameRoom` forfeit flow); penalty = **lose 2× coins + 0 XP** (NO kot, and never mention kot); confirm dialog alerts the penalty. - **End-of-game roster:** `MatchPlayersList` on the final screen (reward modal + AI match-over) lists everyone; **Add-friend** button for real non-bot players (seat `userId` threaded from server). - **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`. Achievement rows (here + `PostMatchRewardsModal`) now reveal the **sticker pack** an achievement unlocks (`stickerPackForAchievement`). - **Persistent level + XP bar:** `LevelXpBar` (avatar + Lv + progress, taps to profile) shows on Home (`TopBar`) and atop every inner screen (`ScreenHeader`, `showXp` default on) so level/XP is always visible. - **Buy-coins gateway** opens in a **new tab** (`window.open(_blank)`, same-tab fallback if popup-blocked) so a slow/blocked ZarinPal page can't dead-end the SPA; balance refreshes on window focus. (Fixes the old `window.location.href` "page couldn't load" crash.) - **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`, **`.tap`** (44px min hit area). Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam). - **Accessibility pass:** global **`:focus-visible`** gold ring (keyboard/controller/switch nav — no pointer required); **reduced-motion** honored app-wide via a `@media (prefers-reduced-motion)` block (kills decorative CSS loops) **and** `` in `page.tsx` (tames all Framer Motion). Cramped 32px icon buttons (Friends accept/decline/msg/remove, Chat back/send, Room invite/bot/clear) bumped to **44px**. Empty states (Friends/Chat/Notifications) and loading **skeletons** (Leaderboard/Shop) added. - **Capacitor Android APK** builds (Myket maven mirror at root `https://maven.myket.ir`; init script pins buildTools 36 + JDK17). See `ANDROID.md`. - **CI/CD** (Gitea Actions + Nexus mirror) + Docker stack. See `DEPLOY.md`. - **Production prep (bargevasat.ir):** `docker-compose.caddy.yml` + `Caddyfile` (auto-HTTPS: `bargevasat.ir`→web, `api.bargevasat.ir`→server), prod `appsettings`/`ENV_FILE` blocks pointing at the domain, and **`PRODUCTION.md`** (go-live + Cafe Bazaar publish/IAB checklist). Prepare-only — deploy/DNS is manual. - **One running game per player:** server `GameManager.StartMatchmaking` re-syncs an existing live room instead of starting a 2nd; client (`game-store.hasActiveMatch()`) guards Home vs-computer + Lobby random/create → resumes the running match + notifies. Clears on forfeit/finish. - **Selectable background music:** `santoor` (سنتی) + `playful` (UNO-like), procedural in `sound.ts` (`TRACKS`, `setMusicTrack`, persisted), `sound-store.musicTrack`, picker in Profile → Audio. - **100 gated gifts:** purchasable cosmetics LOCKED until a level/rating gate (45 avatars in types.ts, 35 titles + 20 card backs in gamification.ts). Tier (1-5) is encoded in the id (`-t-`); `GIFT_TIERS` is the shared gate table (t1 free → t5 rating 1700). Shop shows locked + requirement; **server enforces generically** by parsing the tier (`ProfileService.GiftGateFor`, mirrors GIFT_TIERS) — no per-item catalog mirror. Items reuse the existing avatar/title/card-back renderers + owned lists. --- ## 4. CI/CD + mirrors (Iran constraints) - `.gitea/workflows/ci-cd.yml`: **api-build** (dotnet build slnx + Hokm.Sim), **web-check** (tsc + next build), **deploy** (self-hosted; pg_dump backup → rollback tag → build → stop+rm+up `--no-deps` → health-wait → prune). Set the **`ENV_FILE`** repo secret (see `deploy/ENV_FILE.example`). - **NuGet/npm go through the Nexus mirror over PLAIN HTTP** `http://171.22.25.73:8081/repository/{nuget,npm}-group/` — the HTTPS mirror serves a **partial cert chain** that container trust stores reject (NU1301 PartialChain / npm UNABLE_TO_GET_ISSUER). npm also uses `--strict-ssl=false`; NuGet HTTP source needs `allowInsecureConnections="true"`. Local Windows dev + Docker base-image pulls work over HTTPS (Windows trust store has the intermediate) — only in-container package feeds use HTTP. - **Fonts are self-hosted** (`@fontsource-variable/vazirmatn` + `plus-jakarta-sans`) — `next/font/google` fetches Google at build time and FAILS on the Iran runner. Do not reintroduce `next/font/google`. - Memory: localhost can be VPN-hijacked (EonVPN) → reach local services via LAN IP if needed. --- ## 5. TODO / next 1. **Generate real EF migrations** (`dotnet ef migrations add Init`, DesignTimeDbContextFactory targets Postgres) + point at live **Supabase**; today the server uses `EnsureCreated()` (auto-switches to `Migrate()` once migrations exist). 2. **Game-table UNO restyle — DONE** (bolder suit-aware cards + `xl` size, pulsing playable-card glow, big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round confetti, match coin-rain). 3. Store **IAB** — **scaffolded** (`server/.../Payments/IabService.cs`, config-driven via `Iab__*`). **Cafe Bazaar** path is end-to-end pure-web: client deep-links `bazaar://in_app?...&sku=...&redirect_url=...` (`src/lib/storeBilling.ts`), Bazaar returns `?purchaseToken=`, `page.tsx` captures it → `verifyIab` → server OAuth-refresh→validate→credit. **Myket** verification is wired server-side, but its purchase trigger needs a **native Capacitor bridge** (`window.MyketBilling.purchase(sku)` → `{purchaseToken,productId}`) — see §6. **Remaining:** fill `IAB_*` creds (Bazaar client id/secret/refresh token, Myket access token) in `ENV_FILE`, confirm the exact Bazaar/Myket validate endpoints against your panels, write the Myket native plugin, and set per-build `NEXT_PUBLIC_STORE`=`bazaar|myket` + `NEXT_PUBLIC_APP_PACKAGE`. SKU == coin-pack id (`p1`–`p4`). `IAB_ALLOW_UNVERIFIED=true` credits without verifying — **dev only**. 4. Iranian **push** provider for closed-app notifications (FCM/APNs blocked); in-app + real-time notifications already work. 5. Optional polish — **DONE**: premium gold chat is visible to the recipient (`ChatMessage.senderPro`, server `SocialService.IsPro` stamps it; mock marks ~half its friends pro); daily reward routes through the global `CelebrationOverlay` (`"daily"` variant + coins count-up). 6. **Match intro / "players joining" loading screen — DONE** (`MatchIntroOverlay`: seats slide in with "?" placeholders until live data arrives, 3-2-1-GO countdown; online-only via `matchIntroPending`/`consumeIntro`). --- ## 6. Working notes / gotchas - **Can't use the headless preview** to verify visuals (it pauses animations/screenshots) — verify via builds + ask the user for screenshots. UI changes have been shipped "by the numbers". - Server binds **0.0.0.0** in Docker via `ENTRYPOINT … --urls http://0.0.0.0:5005` (appsettings `Urls=localhost` wins over env, so command-line args are used). - After schema changes in SQLite dev with `EnsureCreated()`, delete `server/src/Hokm.Server/hokm.db*` to recreate. - Background `dotnet run &` from a Git-Bash shell dies when the shell exits; use a tracked background runner. - Commit messages end with `Co-Authored-By: Claude …`. Both `messages/`-style i18n strings live in `src/lib/i18n.tsx` (fa+en). - **Myket native bridge contract** (for the Capacitor plugin to inject on `window`): `window.MyketBilling = { available: true, purchase(sku): Promise<{purchaseToken, productId}>, consume?(token): Promise }`. `storeBilling.getStore()` returns `"myket"` when `available` is true; the client then calls `purchase(sku)` and POSTs the token to `/api/coins/iab/verify`. Until the plugin exists, Myket purchases report "unavailable" and fall back to the web gateway. Cafe Bazaar needs **no** native code (deep-link only).