|
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
|
|
MatchmakingState,
|
|
|
|
|
RewardResult,
|
|
|
|
|
Room,
|
|
|
|
|
RoomInvite,
|
|
|
|
|
ServerGameState,
|
|
|
|
|
ShopItem,
|
|
|
|
|
UserProfile,
|
|
|
|
@@ -35,6 +36,22 @@ import {
|
|
|
|
|
const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005";
|
|
|
|
|
const LS_SESSION = "hokm.session";
|
|
|
|
|
|
|
|
|
|
/** Raw private-room shape pushed by the server (kind: empty|invited|bot|human). */
|
|
|
|
|
interface ServerRoom {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
hostId: string;
|
|
|
|
|
status: string;
|
|
|
|
|
targetScore: number;
|
|
|
|
|
stake: number;
|
|
|
|
|
ranked: boolean;
|
|
|
|
|
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; level: number } }[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const EMPTY_ROOM: Room = {
|
|
|
|
|
id: "", code: "", hostId: "", status: "open", seats: [], targetScore: 7, stake: 0, ranked: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Talks to the .NET SignalR backend for auth, matchmaking, live game state and
|
|
|
|
|
* reactions. Everything not yet server-backed (profile, friends, shop, daily,
|
|
|
|
@@ -61,6 +78,10 @@ export class SignalrService implements OnlineService {
|
|
|
|
|
private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
|
|
|
|
|
private typingCbs = new Set<(fromId: string) => void>();
|
|
|
|
|
private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>();
|
|
|
|
|
private roomCbs = new Set<(r: Room) => void>();
|
|
|
|
|
private roomInviteCbs = new Set<(i: RoomInvite | null) => void>();
|
|
|
|
|
private roomWaiters: ((r: Room) => void)[] = [];
|
|
|
|
|
private lastRoom: Room | null = null;
|
|
|
|
|
private cachedProfile: UserProfile | null = null;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
@@ -133,6 +154,18 @@ export class SignalrService implements OnlineService {
|
|
|
|
|
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
|
|
|
|
conn.on("typing", (m: { from: string }) =>
|
|
|
|
|
this.typingCbs.forEach((cb) => cb(m.from)));
|
|
|
|
|
conn.on("room", (r: ServerRoom) => {
|
|
|
|
|
const room = this.mapRoom(r);
|
|
|
|
|
this.lastRoom = room;
|
|
|
|
|
this.roomWaiters.splice(0).forEach((w) => w(room));
|
|
|
|
|
this.roomCbs.forEach((cb) => cb(room));
|
|
|
|
|
});
|
|
|
|
|
conn.on("roomInvite", (i: RoomInvite) => this.roomInviteCbs.forEach((cb) => cb(i)));
|
|
|
|
|
conn.on("roomInviteCancelled", () => this.roomInviteCbs.forEach((cb) => cb(null)));
|
|
|
|
|
conn.on("roomClosed", () => {
|
|
|
|
|
this.lastRoom = null;
|
|
|
|
|
this.roomInviteCbs.forEach((cb) => cb(null));
|
|
|
|
|
});
|
|
|
|
|
conn.on("notification", (n: AppNotification) =>
|
|
|
|
|
this.notifCbs.forEach((cb) => cb(n)));
|
|
|
|
|
conn.on("profile", (p: UserProfile) =>
|
|
|
|
@@ -399,14 +432,65 @@ export class SignalrService implements OnlineService {
|
|
|
|
|
}
|
|
|
|
|
onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(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); }
|
|
|
|
|
// --- private rooms (server-authoritative, real friend invites) ---
|
|
|
|
|
private mapRoom(r: ServerRoom): Room {
|
|
|
|
|
const myId = this.session?.userId;
|
|
|
|
|
return {
|
|
|
|
|
id: r.id, code: r.code, hostId: r.hostId, status: "open",
|
|
|
|
|
targetScore: r.targetScore, stake: r.stake, ranked: r.ranked,
|
|
|
|
|
seats: r.seats.map((s) => ({
|
|
|
|
|
seat: s.seat as 0 | 1 | 2 | 3,
|
|
|
|
|
kind:
|
|
|
|
|
s.kind === "empty" ? "empty"
|
|
|
|
|
: s.kind === "bot" ? "bot"
|
|
|
|
|
: s.kind === "invited" ? "invited"
|
|
|
|
|
: s.player?.id === myId ? "you" : "friend",
|
|
|
|
|
player: s.player,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
private waitRoom(): Promise<Room> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
this.roomWaiters.push(resolve);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const i = this.roomWaiters.indexOf(resolve);
|
|
|
|
|
if (i >= 0) { this.roomWaiters.splice(i, 1); resolve(this.lastRoom ?? EMPTY_ROOM); }
|
|
|
|
|
}, 5000);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
async createRoom(o: CreateRoomOptions) {
|
|
|
|
|
await this.connect();
|
|
|
|
|
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
|
|
|
|
await this.conn?.invoke("CreatePrivateRoom",
|
|
|
|
|
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan }, o.stake, o.targetScore);
|
|
|
|
|
return this.waitRoom();
|
|
|
|
|
}
|
|
|
|
|
async setPartner(_roomId: string, friendId: string | null) {
|
|
|
|
|
if (friendId) await this.conn?.invoke("InvitePrivate", 2, friendId);
|
|
|
|
|
else await this.conn?.invoke("ClearPrivateSeat", 2);
|
|
|
|
|
return this.lastRoom ?? EMPTY_ROOM;
|
|
|
|
|
}
|
|
|
|
|
async inviteToSeat(_roomId: string, seat: 1 | 3, friendId: string) {
|
|
|
|
|
await this.conn?.invoke("InvitePrivate", seat, friendId);
|
|
|
|
|
return this.lastRoom ?? EMPTY_ROOM;
|
|
|
|
|
}
|
|
|
|
|
async addBot(_roomId: string, seat: 1 | 2 | 3) {
|
|
|
|
|
await this.conn?.invoke("AddPrivateBot", seat);
|
|
|
|
|
return this.lastRoom ?? EMPTY_ROOM;
|
|
|
|
|
}
|
|
|
|
|
async clearSeat(_roomId: string, seat: 1 | 2 | 3) {
|
|
|
|
|
await this.conn?.invoke("ClearPrivateSeat", seat);
|
|
|
|
|
return this.lastRoom ?? EMPTY_ROOM;
|
|
|
|
|
}
|
|
|
|
|
async startRoom(_roomId: string) {
|
|
|
|
|
await this.conn?.invoke("StartPrivate");
|
|
|
|
|
return this.lastRoom ?? EMPTY_ROOM;
|
|
|
|
|
}
|
|
|
|
|
async leaveRoom(_roomId: string) { await this.conn?.invoke("LeavePrivate"); }
|
|
|
|
|
onRoom(cb: (r: Room) => void) { this.roomCbs.add(cb); return () => this.roomCbs.delete(cb); }
|
|
|
|
|
async acceptInvite() { await this.connect(); await this.conn?.invoke("AcceptPrivate"); }
|
|
|
|
|
async declineInvite() { await this.conn?.invoke("DeclinePrivate"); }
|
|
|
|
|
onRoomInvite(cb: (i: RoomInvite | null) => void) { this.roomInviteCbs.add(cb); return () => this.roomInviteCbs.delete(cb); }
|
|
|
|
|
|
|
|
|
|
listConversations(): Promise<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
|
|
|
|
|
getMessages(id: string): Promise<ChatMessage[]> {
|
|
|
|
|