diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..69ef6f2 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,11 @@ +# Caddy reverse proxy for production (bargevasat.ir). Auto HTTPS via Let's Encrypt. +bargevasat.ir, www.bargevasat.ir { + encode zstd gzip + reverse_proxy web:80 +} + +api.bargevasat.ir { + encode zstd gzip + # SignalR (WebSockets) proxies transparently through Caddy. + reverse_proxy server:5005 +} diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 0000000..1dc62e7 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,57 @@ +# Production go-live — bargevasat.ir + Cafe Bazaar + +Companion to `HANDOFF.md` / `DEPLOY.md`. Domain: **bargevasat.ir** (web) + +**api.bargevasat.ir** (.NET SignalR API). Android via **Cafe Bazaar**. + +## 1. DNS + firewall (you do this) +- A-records → your server IP: `bargevasat.ir`, `www.bargevasat.ir`, `api.bargevasat.ir`. +- Open ports **80** + **443** (`ufw allow 80 && ufw allow 443`). + +## 2. Production env (Gitea `ENV_FILE` secret) +Use the **PRODUCTION block** in `deploy/ENV_FILE.example`: +- `NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir` (baked at web build → needs a CI rebuild to change) +- `CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir` +- `JWT_KEY` = `openssl rand -hex 32`, strong `POSTGRES_PASSWORD` +- ZarinPal **live**: `ZARINPAL_SANDBOX=false`, live merchant id, callback `https://api.bargevasat.ir/api/coins/pay/callback`, return `https://bargevasat.ir` +- ZarinPal panel: register the callback domain. + +## 3. Deploy with HTTPS (Caddy) +The deploy job (or you, on the server) runs the stack **with the Caddy overlay**: +```bash +docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +``` +Caddy auto-provisions Let's Encrypt certs and proxies `bargevasat.ir → web`, +`api.bargevasat.ir → server`. SignalR WebSockets pass through transparently. +(To wire this into CI, add `-f docker-compose.caddy.yml` to the deploy job's +compose commands once DNS resolves.) + +## 4. Database (Supabase or the bundled Postgres) +- Bundled `db` service works for launch. For Supabase: set `Database__Provider=postgres` + + the Supabase `ConnectionStrings__Default`, and **generate EF migrations** first + (`HANDOFF.md` §5.1) so the server runs `Migrate()` instead of `EnsureCreated()`. +- **Back up before every deploy** (the deploy job already `pg_dump`s). + +## 5. Cafe Bazaar (Android) publish +1. **Build a signed release APK/AAB** — `NEXT_PUBLIC_STORE=bazaar`, + `NEXT_PUBLIC_APP_PACKAGE=com.bargevasat.app`, `NEXT_PUBLIC_USE_SERVER=1`, + `NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir`, then `npm run cap:sync` + + build in Android Studio / gradle (see `ANDROID.md`). App id **com.bargevasat.app**. +2. Upload to **pardakht.cafebazaar.ir**, fill the listing (icon, screenshots, fa + description), submit for review. +3. **In-app billing (after approval):** in the Bazaar dev panel create the coin + SKUs (`p1`–`p4`, matching `ProfileService.Packs`), create the **Pardakht API** + OAuth client, do the one-time consent to get a **refresh token**, and put + `IAB_BAZAAR_CLIENT_ID/SECRET/REFRESH_TOKEN` (+ `IAB_PACKAGE_NAME=com.bargevasat.app`) + into `ENV_FILE`; set `IAB_ALLOW_UNVERIFIED=false`. The web client deep-links + `bazaar://in_app?...` and the server verifies the returned `purchaseToken` + before crediting (see `HANDOFF.md` §5.3 / `src/lib/storeBilling.ts`). +4. Set `Zarinpal__Sandbox=false` only for the **web/PWA** payment path; the + **store build uses IAB, not ZarinPal** (store policy). + +## 6. Pre-launch hardening checklist +- [ ] `JWT_KEY` is a real 32+ char secret (compose `${JWT_KEY:?}` fails if unset). +- [ ] `IAB_ALLOW_UNVERIFIED=false`, `ZARINPAL_SANDBOX=false`. +- [ ] CORS = the real domains only (no localhost). +- [ ] DB backups confirmed (`/opt/hokm-backups`), volumes named (no orphan data — see DEPLOY.md incident rules). +- [ ] CI green: tsc + next build + dotnet build + Hokm.Sim. +- [ ] Smoke test on https://bargevasat.ir: OTP login, vs-AI game, ranked match, buy-coins redirect, friends/chat. diff --git a/deploy/ENV_FILE.example b/deploy/ENV_FILE.example index c373f63..9c98ad1 100644 --- a/deploy/ENV_FILE.example +++ b/deploy/ENV_FILE.example @@ -44,7 +44,7 @@ ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500 # Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels. # SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase # token verifies server-to-server. -IAB_PACKAGE_NAME=com.bargevasat.hokm +IAB_PACKAGE_NAME=com.bargevasat.app # Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent # to obtain a refresh_token. https://pardakht.cafebazaar.ir/ IAB_BAZAAR_CLIENT_ID= @@ -55,3 +55,21 @@ IAB_MYKET_ACCESS_TOKEN= # DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have # store creds). NEVER true in production. IAB_ALLOW_UNVERIFIED=false + +# ────────────────────────────────────────────────────────────────────────── +# PRODUCTION (bargevasat.ir) — use these values instead of the local ones above, +# and deploy with the Caddy overlay (see PRODUCTION.md). DNS: bargevasat.ir, +# www, api → server IP; open 80/443. Caddy fronts TLS, so host ports are internal. +# ────────────────────────────────────────────────────────────────────────── +# WEB_PORT=1500 +# API_PORT=1505 +# DB_PORT=1510 +# POSTGRES_PASSWORD= +# JWT_KEY= +# NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir # baked at web build time +# CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir +# ZARINPAL_MERCHANT_ID= +# ZARINPAL_SANDBOX=false +# ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback +# ZARINPAL_CLIENT_RETURN_URL=https://bargevasat.ir +# IAB_ALLOW_UNVERIFIED=false # fill the IAB_* creds from the Bazaar panel post-publish diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml new file mode 100644 index 0000000..18549c3 --- /dev/null +++ b/docker-compose.caddy.yml @@ -0,0 +1,27 @@ +# Production HTTPS overlay for bargevasat.ir. +# Caddy terminates TLS (auto Let's Encrypt) and reverse-proxies: +# https://bargevasat.ir → web (nginx static) +# https://api.bargevasat.ir → server (.NET SignalR) +# Run: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# (web/server are reached over the compose network by name; their host port +# publishes from docker-compose.yml are harmless but optional in prod.) + +services: + caddy: + image: mirror.soroushasadi.com/caddy:2-alpine + container_name: hokm-caddy + restart: unless-stopped + depends_on: + - web + - server + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - hokm_caddy_data:/data + - hokm_caddy_config:/config + +volumes: + hokm_caddy_data: + hokm_caddy_config: diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 23d0b25..f00ab63 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -47,6 +47,14 @@ public sealed class GameManager public void StartMatchmaking(Player p) { + // One running game per player: if already in a live match, re-sync them to + // it (re-broadcasts current state) instead of starting a second game. + if (RoomOf(p.UserId) is { } existing) + { + existing.SetConnected(p.UserId, true); + return; + } + // Pro players skip the queue entirely. if (p.Plan == "pro") { diff --git a/server/src/Hokm.Server/appsettings.Production.json.example b/server/src/Hokm.Server/appsettings.Production.json.example index cea3bb9..1914cb6 100644 --- a/server/src/Hokm.Server/appsettings.Production.json.example +++ b/server/src/Hokm.Server/appsettings.Production.json.example @@ -1,5 +1,5 @@ { - "// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.", + "// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production. (The Docker deploy uses env vars from ENV_FILE instead — this file is for a bare-metal run.)", "Jwt": { "Key": "CHANGE-ME-to-a-long-random-secret-32+chars", "Issuer": "hokm", @@ -12,10 +12,27 @@ "// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)", "Default": "Host=db..supabase.co;Port=5432;Database=postgres;Username=postgres;Password=;SSL Mode=Require;Trust Server Certificate=true" }, + "Cors": { + "Origins": "https://bargevasat.ir,https://www.bargevasat.ir" + }, "Zarinpal": { "MerchantId": "", "Sandbox": false, - "CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback", - "ClientReturnUrl": "https://yourdomain.com" + "CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback", + "ClientReturnUrl": "https://bargevasat.ir" + }, + "Iab": { + "// note": "Cafe Bazaar / Myket in-app purchase. Fill after publishing & getting store creds.", + "AllowUnverified": false, + "Bazaar": { + "PackageName": "com.bargevasat.app", + "ClientId": "", + "ClientSecret": "", + "RefreshToken": "" + }, + "Myket": { + "PackageName": "com.bargevasat.app", + "AccessToken": "" + } } } diff --git a/src/components/HomeScreen.tsx b/src/components/HomeScreen.tsx index 50430e4..fbd7b70 100644 --- a/src/components/HomeScreen.tsx +++ b/src/components/HomeScreen.tsx @@ -16,7 +16,8 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { Zap } from "lucide-react"; -import { useGameStore } from "@/lib/game-store"; +import { useGameStore, hasActiveMatch } from "@/lib/game-store"; +import { pushNotification } from "@/lib/notification-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore, type Screen } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; @@ -45,6 +46,20 @@ export function HomeScreen() { const [speed, setSpeed] = useState(false); const playVsComputer = () => { + // One game at a time: resume the running match instead of starting a new one. + if (hasActiveMatch()) { + useGameStore.getState().resume(); + goGame("home"); + pushNotification({ + kind: "system", + titleFa: "بازی در جریان", + titleEn: "Game in progress", + bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.", + bodyEn: "Finish or forfeit your current game first.", + icon: "🎮", + }); + return; + } const you = profile?.displayName || t("seat.you"); newMatch({ names: [you, "آرش", "کیان", "نیلوفر"], diff --git a/src/components/screens/OnlineLobbyScreen.tsx b/src/components/screens/OnlineLobbyScreen.tsx index 7be57b0..267ed41 100644 --- a/src/components/screens/OnlineLobbyScreen.tsx +++ b/src/components/screens/OnlineLobbyScreen.tsx @@ -9,9 +9,27 @@ import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification"; import { useOnlineStore } from "@/lib/online-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; +import { useGameStore, hasActiveMatch } from "@/lib/game-store"; +import { pushNotification } from "@/lib/notification-store"; import { useI18n } from "@/lib/i18n"; import { cn } from "@/lib/cn"; +/** Block starting a 2nd game while one is running — resume it instead. */ +function guardActiveMatch(): boolean { + if (!hasActiveMatch()) return false; + useGameStore.getState().resume(); + useUIStore.getState().goGame("online"); + pushNotification({ + kind: "system", + titleFa: "بازی در جریان", + titleEn: "Game in progress", + bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.", + bodyEn: "Finish or forfeit your current game first.", + icon: "🎮", + }); + return true; +} + export function OnlineLobbyScreen() { const { t, locale } = useI18n(); const createRoom = useOnlineStore((s) => s.createRoom); @@ -27,12 +45,14 @@ export function OnlineLobbyScreen() { // Private rooms with friends are free. const onCreate = async () => { + if (guardActiveMatch()) return; await createRoom({ targetScore: 7, stake: 0, ranked: false }); go("room"); }; // Ranked random always costs the entry (you stake it). const onRandom = async () => { + if (guardActiveMatch()) return; if (lockedLeague) return; if (coins < entry) { go("buycoins"); diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index a9b9aba..bfa6749 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -469,12 +469,34 @@ function SocialSettings() { function SoundSettings() { const { t } = useI18n(); - const { sfx, music, toggleSfx, toggleMusic } = useSoundStore(); + const { sfx, music, musicTrack, toggleSfx, toggleMusic, setMusicTrack } = useSoundStore(); + const tracks = [ + { id: "santoor" as const, label: t("settings.trackSantoor") }, + { id: "playful" as const, label: t("settings.trackPlayful") }, + ]; return (

{t("settings.audio")}

} label={t("settings.sound")} on={sfx} onClick={toggleSfx} /> } label={t("settings.music")} on={music} onClick={toggleMusic} /> + {/* music style picker */} +
+
{t("settings.musicStyle")}
+
+ {tracks.map((tr) => ( + + ))} +
+
); } diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index e2fa461..fcdd3f3 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -572,3 +572,12 @@ export const useGameStore = create((set, get) => { }, }; }); + +/** + * True when the player has a running match that hasn't finished — used to enforce + * "one game at a time": entry points should resume this instead of starting another. + */ +export function hasActiveMatch(): boolean { + const s = useGameStore.getState(); + return s.started && s.game.phase !== "match-over"; +} diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 197c73f..a35f244 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -321,6 +321,9 @@ const fa: Dict = { "settings.audio": "تنظیمات صدا", "settings.sound": "افکت صدا", "settings.music": "موسیقی پس‌زمینه", + "settings.musicStyle": "سبک موسیقی", + "settings.trackSantoor": "سنتی (سنتور)", + "settings.trackPlayful": "شاد", "profile.cardFront": "روی کارت", "profile.cardBack": "پشت کارت", @@ -648,6 +651,9 @@ const en: Dict = { "settings.audio": "Audio", "settings.sound": "Sound effects", "settings.music": "Background music", + "settings.musicStyle": "Music style", + "settings.trackSantoor": "Traditional (Santoor)", + "settings.trackPlayful": "Playful", "profile.cardFront": "Card front", "profile.cardBack": "Card back", diff --git a/src/lib/sound-store.ts b/src/lib/sound-store.ts index a17b8c3..9f2aa70 100644 --- a/src/lib/sound-store.ts +++ b/src/lib/sound-store.ts @@ -1,13 +1,15 @@ "use client"; import { create } from "zustand"; -import { sound } from "./sound"; +import { sound, type MusicTrack } from "./sound"; interface SoundStore { sfx: boolean; music: boolean; + musicTrack: MusicTrack; toggleSfx: () => void; toggleMusic: () => void; + setMusicTrack: (t: MusicTrack) => void; /** Master mute: turns BOTH sfx and music off (or both back on). */ toggleAll: () => void; } @@ -15,6 +17,13 @@ interface SoundStore { export const useSoundStore = create((set, get) => ({ sfx: sound.sfxEnabled, music: sound.musicEnabled, + musicTrack: sound.musicTrack, + setMusicTrack: (t) => { + // Picking a track also turns music on so the choice is audible immediately. + sound.setMusicTrack(t); + sound.setMusicEnabled(true); + set({ musicTrack: t, music: true }); + }, toggleSfx: () => { const v = !get().sfx; sound.setSfxEnabled(v); diff --git a/src/lib/sound.ts b/src/lib/sound.ts index 3da6983..bf1375d 100644 --- a/src/lib/sound.ts +++ b/src/lib/sound.ts @@ -18,6 +18,9 @@ export type Sfx = const LS_SFX = "hokm.sfx"; const LS_MUSIC = "hokm.music"; +const LS_TRACK = "hokm.musicTrack"; + +export type MusicTrack = "santoor" | "playful"; function loadBool(key: string, def = true): boolean { if (typeof window === "undefined") return def; @@ -34,6 +37,8 @@ class SoundManager { sfxEnabled = loadBool(LS_SFX); musicEnabled = loadBool(LS_MUSIC); + musicTrack: MusicTrack = + (typeof window !== "undefined" && (localStorage.getItem(LS_TRACK) as MusicTrack)) || "santoor"; /** Must be called from a user gesture to unlock audio. */ init() { @@ -70,6 +75,18 @@ class SoundManager { } } + /** Switch the background music style; restarts the loop if playing. */ + setMusicTrack(track: MusicTrack) { + this.musicTrack = track; + if (typeof window !== "undefined") localStorage.setItem(LS_TRACK, track); + this.step = 0; + if (this.musicEnabled) { + this.stopMusic(); + this.init(); + this.startMusic(); + } + } + private tone( freq: number, start: number, @@ -147,29 +164,44 @@ class SoundManager { } } - // Gentle ambient loop on a Persian-flavored scale (Dastgah-ish). - private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66]; + // Two selectable loops: + // • santoor — calm Persian-flavored (Dastgah-ish) legato loop with fifth harmony. + // • playful — bouncy major-pentatonic staccato loop (UNO-like). + private TRACKS: Record< + MusicTrack, + { notes: number[]; gap: number; type: OscillatorType; attack: number; dur: number; peak: number; fifth: boolean } + > = { + santoor: { + notes: [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66], + gap: 900, type: "sine", attack: 0.3, dur: 1.6, peak: 0.5, fifth: true, + }, + playful: { + notes: [523.25, 659.25, 784, 659.25, 587.33, 698.46, 880, 698.46, 587.33, 523.25], + gap: 360, type: "triangle", attack: 0.02, dur: 0.34, peak: 0.4, fifth: false, + }, + }; startMusic() { if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return; const playNote = () => { if (!this.ctx || !this.musicGain) return; - const freq = this.MUSIC[this.step % this.MUSIC.length]; + const cfg = this.TRACKS[this.musicTrack]; + const freq = cfg.notes[this.step % cfg.notes.length]; this.step++; const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); const t = this.ctx.currentTime; - osc.type = "sine"; + osc.type = cfg.type; osc.frequency.value = freq; g.gain.setValueAtTime(0.0001, t); - g.gain.exponentialRampToValueAtTime(0.5, t + 0.3); - g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6); + g.gain.exponentialRampToValueAtTime(cfg.peak, t + cfg.attack); + g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur); osc.connect(g); g.connect(this.musicGain); osc.start(t); - osc.stop(t + 1.7); - // soft fifth harmony every other note - if (this.step % 2 === 0) { + osc.stop(t + cfg.dur + 0.1); + // soft fifth harmony (santoor) every other note + if (cfg.fifth && this.step % 2 === 0) { const o2 = this.ctx.createOscillator(); const g2 = this.ctx.createGain(); o2.type = "sine"; @@ -184,7 +216,7 @@ class SoundManager { } }; playNote(); - this.musicTimer = setInterval(playNote, 900); + this.musicTimer = setInterval(playNote, this.TRACKS[this.musicTrack].gap); } stopMusic() {