From ceccf70de742ad10a8a6c7d76b1a7b56679c44aa Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 13:13:48 +0330 Subject: [PATCH] Wire client SignalrService to the live .NET backend - @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 --- .env.example | 7 + .gitignore | 1 + package-lock.json | 182 ++++++++++++- package.json | 1 + scripts/live-test.mjs | 42 +++ src/components/screens/MatchmakingScreen.tsx | 10 + src/lib/game-store.ts | 125 ++++++++- src/lib/online/mock-service.ts | 6 + src/lib/online/service.ts | 18 +- src/lib/online/signalr-service.ts | 270 +++++++++++++++++++ src/lib/online/types.ts | 50 ++++ 11 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 scripts/live-test.mjs create mode 100644 src/lib/online/signalr-service.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba76fec --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env.local + +# Use the live .NET SignalR backend (1) instead of the local mock (default). +NEXT_PUBLIC_USE_SERVER=0 + +# Base URL of the Hokm SignalR server (server/). +NEXT_PUBLIC_SERVER_URL=http://localhost:5005 diff --git a/.gitignore b/.gitignore index 265bcbb..1e45c29 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/package-lock.json b/package-lock.json index fb15c2d..5d1af0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hokm", "version": "0.1.0", "dependencies": { + "@microsoft/signalr": "^10.0.0", "clsx": "^2.1.1", "framer-motion": "^12.40.0", "lucide-react": "^1.17.0", @@ -1040,6 +1041,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2208,6 +2222,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3533,6 +3559,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3594,6 +3638,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5198,6 +5252,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -5519,16 +5593,33 @@ "react-is": "^16.13.1" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5622,6 +5713,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5772,6 +5869,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6285,6 +6388,27 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6485,6 +6609,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -6564,6 +6697,32 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6679,6 +6838,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 4131d03..f381420 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@microsoft/signalr": "^10.0.0", "clsx": "^2.1.1", "framer-motion": "^12.40.0", "lucide-react": "^1.17.0", diff --git a/scripts/live-test.mjs b/scripts/live-test.mjs new file mode 100644 index 0000000..f5197a9 --- /dev/null +++ b/scripts/live-test.mjs @@ -0,0 +1,42 @@ +// End-to-end smoke test of the live SignalR backend (server must be running). +import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr"; + +const SERVER = "http://localhost:5005"; + +const res = await fetch(`${SERVER}/api/auth/otp/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: "0912", code: "1234", name: "Tester" }), +}); +const { token, userId } = await res.json(); +if (!token) throw new Error("no token"); + +const conn = new HubConnectionBuilder() + .withUrl(`${SERVER}/hub/game`, { accessTokenFactory: () => token }) + .configureLogging(LogLevel.Error) + .build(); + +let matchFound = false; +let states = 0; +let sawHand = false; +let phases = new Set(); +let mySeat = null; + +conn.on("matchFound", (m) => { matchFound = true; mySeat = m.seat; }); +conn.on("state", (s) => { + states++; + phases.add(s.phase); + const me = s.players.find((p) => p.seat === s.mySeat); + if (me?.hand?.length) sawHand = true; +}); + +await conn.start(); +await conn.invoke("StartMatchmaking", { name: "Tester", avatar: "a-fox", level: 3, plan: "pro" }); +await new Promise((r) => setTimeout(r, 7000)); + +console.log(JSON.stringify({ + userId, matchFound, mySeat, states, sawHand, phases: [...phases], +})); + +await conn.stop(); +process.exit(0); diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx index 8513245..25ef53f 100644 --- a/src/components/screens/MatchmakingScreen.tsx +++ b/src/components/screens/MatchmakingScreen.tsx @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Crown, Loader2 } from "lucide-react"; +import { useEffect } from "react"; import { ScreenShell } from "@/components/online/ScreenHeader"; import { useGameStore } from "@/lib/game-store"; import { useOnlineStore } from "@/lib/online-store"; @@ -16,6 +17,7 @@ export function MatchmakingScreen() { const mm = useOnlineStore((s) => s.matchmaking); const cancelMatchmaking = useOnlineStore((s) => s.cancelMatchmaking); const newOnlineMatch = useGameStore((s) => s.newOnlineMatch); + const enterServerMatch = useGameStore((s) => s.enterServerMatch); const upgradePlan = useSessionStore((s) => s.upgradePlan); const goGame = useUIStore((s) => s.goGame); const go = useUIStore((s) => s.go); @@ -24,6 +26,14 @@ export function MatchmakingScreen() { const queued = mm.phase === "queued"; const slots = [0, 1, 2, 3]; + // Live server: the server starts the match itself — auto-enter when ready. + useEffect(() => { + if (mm.phase === "ready" && getService().live) { + enterServerMatch(getService()); + goGame("home"); + } + }, [mm.phase, enterServerMatch, goGame]); + const cancel = async () => { await cancelMatchmaking(); go("online"); diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 9711cf3..167ebf9 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -11,8 +11,9 @@ import { selectHakem, startNextRound, } from "./hokm/engine"; -import { Card, GameState, RoundResult, Seat, Suit } from "./hokm/types"; -import { avatarEmoji } from "./online/types"; +import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types"; +import { avatarEmoji, ServerGameState } from "./online/types"; +import type { OnlineService } from "./online/service"; import { sound } from "./sound"; const KOT_POINTS = 2; @@ -73,8 +74,13 @@ interface GameStore { disconnectedSeat: Seat | null; reconnectDeadline: number | null; + /** true when the match is driven by the live SignalR server. */ + live: boolean; + newMatch: (settings: GameSettings) => void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; + enterServerMatch: (service: OnlineService) => void; + applyServerState: (s: ServerGameState) => void; chooseTrump: (suit: Suit) => void; playHuman: (card: Card) => void; reset: () => void; @@ -83,6 +89,8 @@ interface GameStore { const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"]; let pending: ReturnType | null = null; +let liveUnsub: (() => void) | null = null; +let liveSvc: OnlineService | null = null; function clearPending() { if (pending) { clearTimeout(pending); @@ -94,6 +102,52 @@ function freshTally(): MatchTally { return { tricksTeam0: 0, kotFor: false, kotAgainst: false }; } +function mapCard(c: { suit: string; rank: number; id: string }): Card { + return { suit: c.suit as Suit, rank: c.rank as Rank, id: c.id }; +} + +/** Map a server GameStateDto onto the client GameState. */ +function mapServerState(s: ServerGameState): GameState { + const players: Player[] = s.players.map((p) => ({ + seat: p.seat as Seat, + name: p.name, + isHuman: p.isHuman, + team: p.team as Team, + hand: p.hand + ? p.hand.map(mapCard) + : Array.from({ length: p.handCount }, (_, i) => ({ + suit: "spades" as Suit, + rank: 2 as Rank, + id: `hidden-${p.seat}-${i}`, + })), + })); + return { + phase: s.phase as Phase, + players, + deck: [], + hakem: s.hakem as Seat | null, + trump: (s.trump as Suit) ?? null, + turn: s.turn as Seat | null, + currentTrick: s.currentTrick.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })), + leadSeat: s.leadSeat as Seat | null, + roundTricks: [s.roundTricks[0] ?? 0, s.roundTricks[1] ?? 0], + matchScore: [s.matchScore[0] ?? 0, s.matchScore[1] ?? 0], + lastTrickWinner: s.lastTrickWinner as Seat | null, + lastRoundResult: s.lastRoundResult + ? { + winningTeam: s.lastRoundResult.winningTeam as Team, + tricks: [s.lastRoundResult.tricks[0], s.lastRoundResult.tricks[1]], + kot: s.lastRoundResult.kot, + points: s.lastRoundResult.points, + } + : null, + matchWinner: s.matchWinner as Team | null, + hakemDraw: s.hakemDraw.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })), + targetScore: s.targetScore, + dealId: s.dealId, + }; +} + export const useGameStore = create((set, get) => { function recordRound(result: RoundResult | null) { if (!result) return; @@ -234,6 +288,7 @@ export const useGameStore = create((set, get) => { turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, + live: false, newMatch: (settings) => { clearPending(); @@ -280,7 +335,63 @@ export const useGameStore = create((set, get) => { scheduleAuto(); }, + enterServerMatch: (service) => { + clearPending(); + sound.init(); + liveSvc = service; + if (liveUnsub) liveUnsub(); + liveUnsub = service.onState((s) => get().applyServerState(s)); + set({ + game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }), + started: true, + mode: "online", + live: true, + matchMeta: { ranked: true, stake: 0 }, + tally: freshTally(), + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, + seatPlayers: [], + }); + }, + + applyServerState: (s) => { + const prev = get().game; + const next = mapServerState(s); + const seatPlayers: SeatPlayer[] = [...s.seatPlayers] + .sort((a, b) => a.seat - b.seat) + .map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level })); + + // accumulate the reward tally when the match score grows (a round ended) + const prevTotal = prev.matchScore[0] + prev.matchScore[1]; + const newTotal = next.matchScore[0] + next.matchScore[1]; + if (newTotal > prevTotal && next.lastRoundResult) recordRound(next.lastRoundResult); + + // sounds on transitions + if (next.currentTrick.length > prev.currentTrick.length && next.currentTrick.length > 0) + sound.play("cardPlay"); + if (next.phase === "trick-complete" && prev.phase !== "trick-complete") sound.play("trickWin"); + if (next.trump && !prev.trump) sound.play("trump"); + if (next.phase === "match-over" && prev.phase !== "match-over") + sound.play(next.matchWinner === 0 ? "win" : "lose"); + else if (next.phase === "round-over" && prev.phase !== "round-over" && next.lastRoundResult?.kot) + sound.play("kot"); + + set({ + game: next, + seatPlayers, + matchMeta: { ranked: s.ranked, stake: s.stake }, + turnDeadline: s.turnDeadline ?? null, + disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null, + reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null, + }); + }, + chooseTrump: (suit) => { + if (get().live) { + liveSvc?.chooseTrump(suit); + return; + } const g = get().game; if (g.phase !== "choosing-trump") return; set({ game: engineChooseTrump(g, suit), turnDeadline: null }); @@ -289,6 +400,10 @@ export const useGameStore = create((set, get) => { }, playHuman: (card) => { + if (get().live) { + liveSvc?.playCard(card.id); + return; + } const g = get().game; if (g.phase !== "playing" || g.turn !== 0) return; set({ game: playCard(g, 0, card), turnDeadline: null }); @@ -298,10 +413,16 @@ export const useGameStore = create((set, get) => { reset: () => { clearPending(); + if (liveUnsub) { + liveUnsub(); + liveUnsub = null; + } + liveSvc = null; set({ game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }), started: false, mode: "ai", + live: false, seatPlayers: [], tally: freshTally(), turnDeadline: null, diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 10b2602..35dbf49 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -463,6 +463,12 @@ export class MockOnlineService implements OnlineService { for (const cb of this.reactionCbs) cb(0, reaction); } + // The mock drives the game locally (game-store), so these are no-ops. + readonly live = false; + onState(): Unsubscribe { return () => {}; } + playCard(): void {} + chooseTrump(): void {} + onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { this.reactionCbs.add(cb); if (this.reactionTimer == null) { diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 27717cf..3510d33 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -2,6 +2,7 @@ // The mock implements this today; a SignalR/.NET client implements it later // without any UI changes. +import { Suit } from "../hokm/types"; import { AuthSession, ChatMessage, @@ -14,6 +15,7 @@ import { MatchmakingState, RewardResult, Room, + ServerGameState, ShopItem, UserProfile, } from "./types"; @@ -71,6 +73,12 @@ export interface OnlineService { sendReaction(reaction: string): Promise; onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe; + /* ----- live server-driven game (false for the local mock) ----- */ + readonly live: boolean; + onState(cb: (state: ServerGameState) => void): Unsubscribe; + playCard(cardId: string): void; + chooseTrump(suit: Suit): void; + /* ----- rooms ----- */ createRoom(opts: CreateRoomOptions): Promise; setPartner(roomId: string, friendId: string | null): Promise; @@ -99,13 +107,19 @@ export interface OnlineService { } import { MockOnlineService } from "./mock-service"; +import { SignalrService } from "./signalr-service"; let _service: OnlineService | null = null; -/** Lazily create the active service. Swap the implementation here later. */ +/** + * The active service. With NEXT_PUBLIC_USE_SERVER=1 it talks to the .NET + * SignalR backend; otherwise it's the local mock (offline dev). + */ export function getService(): OnlineService { if (!_service) { - _service = new MockOnlineService(); + const useServer = process.env.NEXT_PUBLIC_USE_SERVER === "1"; + _service = useServer ? new SignalrService() : new MockOnlineService(); } return _service; } + diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts new file mode 100644 index 0000000..c51b377 --- /dev/null +++ b/src/lib/online/signalr-service.ts @@ -0,0 +1,270 @@ +"use client"; + +import * as signalR from "@microsoft/signalr"; +import { Suit } from "../hokm/types"; +import { MockOnlineService } from "./mock-service"; +import { + CreateRoomOptions, + MatchmakingOptions, + OnlineService, + Unsubscribe, +} from "./service"; +import { + AuthSession, + ChatMessage, + Conversation, + DailyRewardState, + Friend, + FriendRequest, + LeaderboardEntry, + MatchSummary, + MatchmakingState, + RewardResult, + Room, + ServerGameState, + ShopItem, + UserProfile, +} from "./types"; + +const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005"; +const LS_SESSION = "hokm.session"; + +/** + * Talks to the .NET SignalR backend for auth, matchmaking, live game state and + * reactions. Everything not yet server-backed (profile, friends, shop, daily, + * leaderboard, chat, private rooms) is delegated to the local mock so the app + * stays fully functional during the incremental migration. + */ +export class SignalrService implements OnlineService { + readonly live = true; + + private mock = new MockOnlineService(); + private conn: signalR.HubConnection | null = null; + private session: AuthSession | null = null; + private token: string | null = null; + + private mmRanked = true; + private mmStake = 0; + private mmCbs = new Set<(s: MatchmakingState) => void>(); + private stateCbs = new Set<(s: ServerGameState) => void>(); + private reactionCbs = new Set<(seat: number, reaction: string) => void>(); + + constructor() { + if (typeof window !== "undefined") { + try { + const raw = localStorage.getItem(LS_SESSION); + if (raw) { + this.session = JSON.parse(raw) as AuthSession; + this.token = this.session.token; + } + } catch { + /* ignore */ + } + } + } + + /* ------------------------------ helpers ---------------------------- */ + + private async api(path: string, body: unknown): Promise { + const res = await fetch(`${SERVER}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + 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() + .withUrl(`${SERVER}/hub/game`, { accessTokenFactory: () => this.token ?? "" }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + conn.on("matchmaking", (s: { phase: string; players: number; queuePosition: number | null }) => + this.emitMM(s.phase, s.queuePosition ?? undefined)); + conn.on("matchFound", () => this.emitMM("ready")); + conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s))); + conn.on("reaction", (r: { seat: number; reaction: string }) => + this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction))); + + this.conn = conn; + try { + await conn.start(); + } catch { + this.conn = null; // server unreachable; UI degrades gracefully + } + } + + private emitMM(phase: string, queuePosition?: number) { + const state: MatchmakingState = { + phase: phase as MatchmakingState["phase"], + players: [], + elapsedMs: 0, + ranked: this.mmRanked, + stake: this.mmStake, + queuePosition, + }; + this.mmCbs.forEach((cb) => cb(state)); + } + + private async setSession( + r: { token: string; userId: string; name: string }, + method: AuthSession["method"] + ): Promise { + const session: AuthSession = { userId: r.userId, token: r.token, method, createdAt: Date.now() }; + 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; + } + + /* ------------------------------- auth ------------------------------ */ + + getSession() { + return this.session; + } + + async restore() { + if (this.session && this.token) { + void this.connect(); + return { session: this.session, profile: await this.mock.getProfile() }; + } + return null; + } + + requestOtp(phone: string) { + return this.api<{ devCode?: string }>("/api/auth/otp/request", { phone }); + } + + async verifyOtp(phone: string, code: string) { + const r = await this.api<{ token: string; userId: string; name: string }>( + "/api/auth/otp/verify", { phone, code }); + return this.setSession(r, "phone"); + } + + async signInEmail(email: string, password: string) { + const r = await this.api<{ token: string; userId: string; name: string }>( + "/api/auth/email", { email, password }); + return this.setSession(r, "email"); + } + + async signUpEmail(email: string, password: string, displayName: string) { + const r = await this.api<{ token: string; userId: string; name: string }>( + "/api/auth/email", { email, password, name: displayName }); + return this.setSession(r, "email"); + } + + async signInGoogle() { + // Server has no Google flow yet — mint a token via the email endpoint. + const email = `google_${Math.random().toString(36).slice(2, 8)}@hokm.app`; + const r = await this.api<{ token: string; userId: string; name: string }>( + "/api/auth/email", { email, password: "google" }); + return this.setSession(r, "google"); + } + + async signOut() { + this.session = null; + this.token = null; + if (typeof window !== "undefined") localStorage.removeItem(LS_SESSION); + await this.conn?.stop(); + this.conn = null; + } + + /* --------------------------- matchmaking --------------------------- */ + + async startMatchmaking(opts: MatchmakingOptions) { + this.mmRanked = opts.ranked; + this.mmStake = opts.stake; + await this.connect(); + const p = await this.mock.getProfile(); + this.emitMM("searching"); + await this.conn?.invoke("StartMatchmaking", { + name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, + }); + } + + async cancelMatchmaking() { + await this.conn?.invoke("CancelMatchmaking"); + this.emitMM("idle"); + } + + onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe { + this.mmCbs.add(cb); + return () => this.mmCbs.delete(cb); + } + + getMatchPlayers() { + 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 + } + + /* ------------------------------ live game -------------------------- */ + + onState(cb: (s: ServerGameState) => void): Unsubscribe { + this.stateCbs.add(cb); + return () => this.stateCbs.delete(cb); + } + + playCard(cardId: string) { + void this.conn?.invoke("PlayCard", cardId); + } + + chooseTrump(suit: Suit) { + void this.conn?.invoke("ChooseTrump", suit); + } + + /* ----------------------------- reactions --------------------------- */ + + async sendReaction(reaction: string) { + await this.conn?.invoke("SendReaction", reaction); + } + + onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { + this.reactionCbs.add(cb); + return () => this.reactionCbs.delete(cb); + } + + /* ----- delegated to the mock (not yet on the server) ----- */ + + getProfile() { return this.mock.getProfile(); } + updateProfile(p: Parameters[0]) { return this.mock.updateProfile(p); } + upgradePlan() { return this.mock.upgradePlan(); } + + listFriends() { return this.mock.listFriends(); } + listRequests() { return this.mock.listRequests(); } + addFriend(q: string) { return this.mock.addFriend(q); } + acceptRequest(id: string) { return this.mock.acceptRequest(id); } + declineRequest(id: string) { return this.mock.declineRequest(id); } + removeFriend(id: string) { return this.mock.removeFriend(id); } + onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(cb); } + + createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); } + setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); } + inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) { return this.mock.inviteToSeat(roomId, seat, friendId); } + addBot(roomId: string, seat: 1 | 2 | 3) { return this.mock.addBot(roomId, seat); } + clearSeat(roomId: string, seat: 1 | 2 | 3) { return this.mock.clearSeat(roomId, seat); } + startRoom(roomId: string) { return this.mock.startRoom(roomId); } + leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); } + onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); } + + listConversations(): Promise { return this.mock.listConversations(); } + getMessages(id: string): Promise { return this.mock.getMessages(id); } + sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); } + markRead(id: string) { return this.mock.markRead(id); } + onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); } + + getLeaderboard(): Promise { return this.mock.getLeaderboard(); } + 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(); } +} diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index a0702f1..bf7ee8f 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -353,6 +353,56 @@ export interface Conversation { unread: number; } +/* ------------------- Server (SignalR) game state -------------------- */ + +export interface ServerCard { suit: string; rank: number; id: string } +export interface ServerPlayedCard { seat: number; card: ServerCard } +export interface ServerPlayer { + seat: number; + name: string; + team: number; + isHuman: boolean; + handCount: number; + hand: ServerCard[] | null; // only the viewer's own hand +} +export interface ServerSeatPlayer { + seat: number; + name: string; + avatar: string; + level: number; + connected: boolean; + isBot: boolean; +} +export interface ServerRoundResult { + winningTeam: number; + tricks: number[]; + kot: boolean; + points: number; +} +export interface ServerGameState { + phase: string; + turn: number | null; + hakem: number | null; + trump: string | null; + leadSeat: number | null; + roundTricks: number[]; + matchScore: number[]; + targetScore: number; + dealId: number; + lastTrickWinner: number | null; + matchWinner: number | null; + currentTrick: ServerPlayedCard[]; + players: ServerPlayer[]; + hakemDraw: ServerPlayedCard[]; + lastRoundResult: ServerRoundResult | null; + seatPlayers: ServerSeatPlayer[]; + mySeat: number; + turnDeadline: number | null; + disconnectedSeat: number | null; + ranked: boolean; + stake: number; +} + /* ------------------------------ Avatars ------------------------------ */ export const AVATARS: { id: string; emoji: string }[] = [