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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 13:13:48 +03:30
parent a3b797c8a3
commit ceccf70de7
11 changed files with 707 additions and 5 deletions
+7
View File
@@ -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
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
+181 -1
View File
@@ -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",
+1
View File
@@ -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",
+42
View File
@@ -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);
@@ -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");
+123 -2
View File
@@ -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<typeof setTimeout> | 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<GameStore>((set, get) => {
function recordRound(result: RoundResult | null) {
if (!result) return;
@@ -234,6 +288,7 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
live: false,
newMatch: (settings) => {
clearPending();
@@ -280,7 +335,63 @@ export const useGameStore = create<GameStore>((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<GameStore>((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<GameStore>((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,
+6
View File
@@ -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) {
+16 -2
View File
@@ -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<void>;
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<Room>;
setPartner(roomId: string, friendId: string | null): Promise<Room>;
@@ -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;
}
+270
View File
@@ -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<T>(path: string, body: unknown): Promise<T> {
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<void> {
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<AuthSession> {
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<RewardResult> {
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<OnlineService["updateProfile"]>[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<Conversation[]> { return this.mock.listConversations(); }
getMessages(id: string): Promise<ChatMessage[]> { 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<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
buyItem(id: string) { return this.mock.buyItem(id); }
getDailyState(): Promise<DailyRewardState> { return this.mock.getDailyState(); }
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); }
}
+50
View File
@@ -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 }[] = [