Files
HokmPlay/HANDOFF.md
T
soroush.asadi 36600fa494
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m40s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
docs: HANDOFF — one-game, music, prod config, 100 gated gifts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:04:03 +03:30

19 KiB
Raw Blame History

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)

# 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 15001600 so they coexist with npm run dev/dotnet run:

  • web http://localhost:1500 (nginx serving the static export) → api :1505 (.NET) → :1510 postgres.
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:

docker stop hokm-web && docker rm hokm-web && docker compose up -d --no-deps web

Build / verify (run before committing)

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 1218s) 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.tsProfiles/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 6006,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", "70 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.titleSeatAvatar, 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 rosterPublicProfileModal (global, ui-store.viewProfile(id)) shows their identity, stats, and achievement board + a rate-limited Add-friend button. Server GET /api/profile/{id}/publicPublicProfileDto (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 <MotionConfig reducedMotion="user"> 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<n>-); 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 IABscaffolded (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 (p1p4). 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<void> }. 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).