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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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 }[] = [
|
||||
|
||||
Reference in New Issue
Block a user