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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user