From f1ea064d4c0acbd209066660099f81a7e2d56a71 Mon Sep 17 00:00:00 2001 From: aronmal Date: Sat, 13 May 2023 15:03:12 +0200 Subject: [PATCH] Working real time lobby --- leaky-ships/components/Lobby/LobbyFrame.tsx | 30 +++- leaky-ships/components/Lobby/Player.tsx | 17 ++- .../Lobby/SettingsFrame/Setting.tsx | 20 +-- .../Lobby/SettingsFrame/Settings.tsx | 35 ++++- leaky-ships/hooks/useGameProps.ts | 71 +++++++++- leaky-ships/hooks/useSocket.ts | 104 +++++++------- leaky-ships/interfaces/NextApiSocket.ts | 39 +++-- ...tChecksum.ts => getPayloadwithChecksum.ts} | 0 leaky-ships/lib/zodSchemas.ts | 50 ++++--- leaky-ships/pages/api/game/join.ts | 23 ++- leaky-ships/pages/api/game/running.ts | 3 +- leaky-ships/pages/api/ws.ts | 134 +++++++++++++++--- leaky-ships/pages/start.tsx | 12 +- 13 files changed, 386 insertions(+), 152 deletions(-) rename leaky-ships/lib/{getObjectChecksum.ts => getPayloadwithChecksum.ts} (100%) diff --git a/leaky-ships/components/Lobby/LobbyFrame.tsx b/leaky-ships/components/Lobby/LobbyFrame.tsx index b5e651d..cfe7147 100644 --- a/leaky-ships/components/Lobby/LobbyFrame.tsx +++ b/leaky-ships/components/Lobby/LobbyFrame.tsx @@ -4,14 +4,22 @@ import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { useGameProps } from "@hooks/useGameProps" import useSocket from "@hooks/useSocket" +import { socket } from "@lib/socket" +import { useSession } from "next-auth/react" import { useRouter } from "next/router" import { Fragment, useEffect, useState } from "react" function LobbyFrame({ openSettings }: { openSettings: () => void }) { - const { payload } = useGameProps() + const { payload, full, leave, reset } = useGameProps() const [dots, setDots] = useState(3) const { isConnected } = useSocket() const router = useRouter() + const { data: session } = useSession() + + useEffect(() => { + if (payload?.game?.id || !isConnected) return + socket.emit("update", full) + }, [full, payload?.game?.id, isConnected]) useEffect(() => { if (payload?.player2) return @@ -38,15 +46,15 @@ function LobbyFrame({ openSettings }: { openSettings: () => void }) {

VS

{payload?.player2 ? ( ) : (

@@ -60,9 +68,17 @@ function LobbyFrame({ openSettings }: { openSettings: () => void }) {

+
diff --git a/leaky-ships/components/Lobby/Player.tsx b/leaky-ships/components/Lobby/Player.tsx index 6cef80b..3e9da40 100644 --- a/leaky-ships/components/Lobby/Player.tsx +++ b/leaky-ships/components/Lobby/Player.tsx @@ -1,18 +1,21 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faCaretDown } from "@fortawesome/sharp-solid-svg-icons" +import { PlayerSchema } from "@lib/zodSchemas" import classNames from "classnames" function Player({ src, - text, - primary, - edit, + player, + userId, }: { src: string - text: string - primary?: boolean - edit?: boolean + player?: PlayerSchema + userId?: string }) { + const text = + player?.name ?? "Spieler " + (player?.index === "player2" ? "2" : "1") + const primary = userId === player?.id + return (

{src} - {edit ? ( + {primary ? (
- Erlaube Zuschauer - Erlaube spezial Items - Erlaube den Chat - Erlaube zeichen/makieren + + Erlaube Zuschauer + + + Erlaube spezial Items + + + Erlaube den Chat + + + Erlaube zeichen/makieren +
diff --git a/leaky-ships/hooks/useGameProps.ts b/leaky-ships/hooks/useGameProps.ts index 68d067a..d64a1ea 100644 --- a/leaky-ships/hooks/useGameProps.ts +++ b/leaky-ships/hooks/useGameProps.ts @@ -1,16 +1,26 @@ import { GameSettings } from "@components/Lobby/SettingsFrame/Setting" -import { GamePropsSchema } from "@lib/zodSchemas" +import { getPayloadwithChecksum } from "@lib/getPayloadwithChecksum" +import { socket } from "@lib/socket" +import { GamePropsSchema, PlayerSchema } from "@lib/zodSchemas" import { produce } from "immer" +import { toast } from "react-toastify" import { create } from "zustand" import { devtools } from "zustand/middleware" -const initialState: GamePropsSchema = { payload: null, hash: null } +const initialState: GamePropsSchema & { + queue: { payload: string; hash: string }[] +} = { payload: null, hash: null, queue: [] } -type State = GamePropsSchema +export type State = typeof initialState -type Action = { - setSetting: (by: GameSettings) => void +export type Action = { + setSetting: (settings: GameSettings) => string | null + setPlayer: (payload: { + player1?: PlayerSchema + player2?: PlayerSchema + }) => string | null full: (newProps: GamePropsSchema) => void + leave: (cb: () => void) => void reset: () => void } @@ -18,13 +28,52 @@ export const useGameProps = create()( devtools( (set) => ({ ...initialState, - setSetting: (settings) => + setPlayer: (payload) => { + let hash: string | null = null set( produce((state: State) => { + if (!state.payload) return + Object.assign(state.payload, payload) + const body = getPayloadwithChecksum(state.payload) + if (!body.hash) { + toast.warn("Something is wrong... ", { + toastId: "st_wrong", + theme: "colored", + }) + return + } + hash = body.hash + state.hash = hash + }) + ) + return hash + }, + setSetting: (settings) => { + const payload = JSON.stringify(settings) + let hash: string | null = null + set( + produce((state: State) => { + const length = state.queue.length + state.queue.filter((e) => e.payload !== payload || e.hash !== hash) + if (state.queue.length !== length) return + if (!state.payload?.game) return Object.assign(state.payload.game, settings) + const body = getPayloadwithChecksum(state.payload) + if (!body.hash) { + toast.warn("Something is wrong... ", { + toastId: "st_wrong", + theme: "colored", + }) + return + } + hash = body.hash + state.hash = hash + state.queue.push({ payload, hash }) }) - ), + ) + return hash + }, full: (newGameProps) => set((state) => { if (state.hash === newGameProps.hash) { @@ -47,6 +96,14 @@ export const useGameProps = create()( } return state }), + leave: (cb) => { + socket.emit("leave", (ack) => { + if (!ack) { + toast.error("Something is wrong...") + } + cb() + }) + }, reset: () => { set(initialState) }, diff --git a/leaky-ships/hooks/useSocket.ts b/leaky-ships/hooks/useSocket.ts index 5ee003b..ef7bc42 100644 --- a/leaky-ships/hooks/useSocket.ts +++ b/leaky-ships/hooks/useSocket.ts @@ -1,28 +1,34 @@ -import { useGameProps } from "@hooks/useGameProps" +import { useGameProps } from "./useGameProps" import { socket } from "@lib/socket" +import status from "http-status" +import { useRouter } from "next/router" import { useEffect, useState } from "react" import { toast } from "react-toastify" /** This function should only be called once per page, otherwise there will be multiple socket connections and duplicate event listeners. */ function useSocket() { const [isConnected, setIsConnected] = useState(false) - const { full } = useGameProps() - - useEffect(() => setIsConnected(socket.connected), [socket.connected]) + const { setPlayer, setSetting, full, hash: stateHash } = useGameProps() + const router = useRouter() useEffect(() => { socket.connect() socket.on("connect", () => { + setIsConnected(true) console.log("connected") const toastId = "connect_error" toast.dismiss(toastId) }) - socket.on("connect_error", () => { + socket.on("connect_error", (error) => { + console.log("Connection error:", error.message) + if (error.message === status["403"]) router.push("/") + if (error.message !== "xhr poll error") return const toastId = "connect_error" const isActive = toast.isActive(toastId) + console.log(toastId, isActive) if (isActive) toast.update(toastId, { autoClose: 5000, @@ -31,13 +37,51 @@ function useSocket() { toast.warn("Es gibt Probleme mit der Echtzeitverbindung.", { toastId }) }) - socket.on("disconnect", () => { - console.log("disconnect") + socket.on("gameSetting", (payload, hash) => { + const newHash = setSetting(payload) + if (!newHash || newHash === hash) return + console.log("hash", hash, newHash) + socket.emit("update", (body) => { + console.log("update") + full(body) + }) }) - socket.on("update", (body) => { - console.log("update") - full(body) + socket.on("playerEvent", (payload, hash, type) => { + let message: string + switch (type) { + case "disconnect": + message = "Player is disconnected." + break + + case "leave": + message = "Player has left the lobby." + break + + case "join": + if (hash === stateHash || !stateHash) return + message = "Player has joined the lobby." + break + + default: + message = "Not defined yet." + break + } + toast.info(message, { toastId: message }) + console.log(payload) + const newHash = setPlayer(payload) + console.log(newHash, hash, !newHash, newHash === hash) + if (!newHash || newHash === hash) return + console.log("hash", hash, newHash) + socket.emit("update", (body) => { + console.log("update") + full(body) + }) + }) + + socket.on("disconnect", () => { + console.log("disconnect") + setIsConnected(false) }) return () => { @@ -46,46 +90,6 @@ function useSocket() { } }, []) - // useEffect(() => { - // const toastId = "realtime" - - // toast("Echtzeitverbindung wird hergestellt...", { - // icon: Icons.spinner(), - // toastId, - // autoClose: false, - // hideProgressBar: true, - // closeButton: false, - // }) - // socket.emit("join", ({ ack }) => { - // if (!ack) { - // toast.update(toastId, { - // render: "Bei der Echtzeitverbindung ist ein Fehler aufgetreten 🤯", - // type: "error", - // icon: Icons.error, - // theme: "colored", - // autoClose: 5000, - // hideProgressBar: false, - // closeButton: true, - // }) - // } else { - // setHasJoined(true) - // toast.update(toastId, { - // render: "Echtzeitverbindung hergestellt 👌", - // type: "info", - // icon: Icons.success, - // autoClose: 5000, - // hideProgressBar: false, - // closeButton: true, - // }) - // } - // }) - // }, []) - - // useEffect(() => { - // if (!isConnected) return - // socket.emit("authenticate", { token: `hello from ${session?.user.email}` }) - // }, [isConnected, status, session?.user.email]) - // useEffect(() => { // if (!isConnected) return // let count = 0 diff --git a/leaky-ships/interfaces/NextApiSocket.ts b/leaky-ships/interfaces/NextApiSocket.ts index cbef69f..12467ec 100644 --- a/leaky-ships/interfaces/NextApiSocket.ts +++ b/leaky-ships/interfaces/NextApiSocket.ts @@ -1,11 +1,15 @@ import { GameSettings } from "@components/Lobby/SettingsFrame/Setting" -import { GamePropsSchema } from "@lib/zodSchemas" +import { GamePropsSchema, PlayerSchema } from "@lib/zodSchemas" import { User } from "@prisma/client" import type { Server as HTTPServer } from "http" import type { Socket as NetSocket } from "net" import type { NextApiResponse } from "next" -import type { Server as IOServer, Server } from "socket.io" -import { Socket } from "socket.io-client" +import type { + Server as IOServer, + Server, + Socket as SocketforServer, +} from "socket.io" +import type { Socket as SocketforClient } from "socket.io-client" interface SocketServer extends HTTPServer { io?: IOServer @@ -23,16 +27,20 @@ export interface ServerToClientEvents { // noArg: () => void // basicEmit: (a: number, b: string, c: Buffer) => void // withAck: (d: string, ) => void - update: (game: GamePropsSchema) => void + gameSetting: (payload: GameSettings, hash: string) => void + playerEvent: ( + payload: { player1?: PlayerSchema; player2?: PlayerSchema }, + hash: string, + type: "join" | "leave" | "disconnect" + ) => void } export interface ClientToServerEvents { + update: (callback: (game: GamePropsSchema) => void) => void ping: (count: number, callback: (count: number) => void) => void - join: (withAck: ({ ack }: { ack: boolean }) => void) => void - gameSetting: ( - payload: GameSettings, - withAck: ({ ack }: { ack: boolean }) => void - ) => void + join: (withAck: (ack: boolean) => void) => void + gameSetting: (payload: GameSettings, callback: (hash: string) => void) => void + leave: (withAck: (ack: boolean) => void) => void } interface InterServerEvents { @@ -44,11 +52,20 @@ interface SocketData { gameId: string | null } -export type cServer = Server< +export type sServer = Server< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +> +export type sSocket = SocketforServer< ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData > -export type cSocket = Socket +export type cSocket = SocketforClient< + ServerToClientEvents, + ClientToServerEvents +> diff --git a/leaky-ships/lib/getObjectChecksum.ts b/leaky-ships/lib/getPayloadwithChecksum.ts similarity index 100% rename from leaky-ships/lib/getObjectChecksum.ts rename to leaky-ships/lib/getPayloadwithChecksum.ts diff --git a/leaky-ships/lib/zodSchemas.ts b/leaky-ships/lib/zodSchemas.ts index 0b10588..7063b29 100644 --- a/leaky-ships/lib/zodSchemas.ts +++ b/leaky-ships/lib/zodSchemas.ts @@ -1,25 +1,29 @@ import { GameState, PlayerN } from "@prisma/client" import { z } from "zod" -export const PlayerSchema = z.object({ - id: z.string(), - name: z.string().nullable(), - index: z.nativeEnum(PlayerN), - chats: z - .object({ - id: z.string(), - event: z.string().nullable(), - message: z.string().nullable(), - createdAt: z.date(), - }) - .array(), - moves: z - .object({ - id: z.string(), - index: z.number(), - }) - .array(), -}) +export const PlayerSchema = z + .object({ + id: z.string(), + name: z.string().nullable(), + index: z.nativeEnum(PlayerN), + chats: z + .object({ + id: z.string(), + event: z.string().nullable(), + message: z.string().nullable(), + createdAt: z.date(), + }) + .array(), + moves: z + .object({ + id: z.string(), + index: z.number(), + }) + .array(), + }) + .nullable() + +export type PlayerSchema = z.infer export const CreateSchema = z .object({ @@ -34,13 +38,13 @@ export const CreateSchema = z }) .nullable(), gamePin: z.string().nullable(), - player1: PlayerSchema.nullable(), - player2: PlayerSchema.nullable(), + player1: PlayerSchema, + player2: PlayerSchema, }) - .strict() + .nullable() export const GamePropsSchema = z.object({ - payload: CreateSchema.nullable(), + payload: CreateSchema, hash: z.string().nullable(), }) diff --git a/leaky-ships/pages/api/game/join.ts b/leaky-ships/pages/api/game/join.ts index fc941d4..f6ca22a 100644 --- a/leaky-ships/pages/api/game/join.ts +++ b/leaky-ships/pages/api/game/join.ts @@ -1,5 +1,5 @@ import { authOptions } from "../auth/[...nextauth]" -import { composeBody, gameSelects } from "./running" +import running, { composeBody, gameSelects } from "./running" import sendError from "@backend/sendError" import sendResponse from "@backend/sendResponse" import { rejectionErrors } from "@lib/backend/errors" @@ -39,6 +39,27 @@ export default async function join( }) } + const games = await prisma.game.findMany({ + where: { + NOT: { + state: "ended", + }, + users: { + some: { + userId: id, + }, + }, + }, + ...gameSelects, + }) + if (games.length) { + return sendResponse(req, res, { + message: "Spieler ist bereits in Spiel!", + statusCode: 409, + type: ["infoCyan"], + }) + } + const user_Game = await prisma.user_Game.create({ data: { gameId: game.id, diff --git a/leaky-ships/pages/api/game/running.ts b/leaky-ships/pages/api/game/running.ts index e4ddbca..85ac7ac 100644 --- a/leaky-ships/pages/api/game/running.ts +++ b/leaky-ships/pages/api/game/running.ts @@ -1,7 +1,7 @@ import { authOptions } from "../auth/[...nextauth]" import sendResponse from "@backend/sendResponse" import { rejectionErrors } from "@lib/backend/errors" -import { getPayloadwithChecksum } from "@lib/getObjectChecksum" +import { getPayloadwithChecksum } from "@lib/getPayloadwithChecksum" import prisma from "@lib/prisma" import { GamePropsSchema } from "@lib/zodSchemas" import type { NextApiRequest, NextApiResponse } from "next" @@ -40,6 +40,7 @@ export const gameSelects = { }, user: { select: { + id: true, name: true, }, }, diff --git a/leaky-ships/pages/api/ws.ts b/leaky-ships/pages/api/ws.ts index c757b63..946fa99 100644 --- a/leaky-ships/pages/api/ws.ts +++ b/leaky-ships/pages/api/ws.ts @@ -1,6 +1,7 @@ import { NextApiResponseWithSocket, - cServer, + sServer, + sSocket, } from "../../interfaces/NextApiSocket" import { composeBody, @@ -10,7 +11,9 @@ import { } from "./game/running" import logging from "@lib/backend/logging" import prisma from "@lib/prisma" +import { GamePropsSchema } from "@lib/zodSchemas" import colors from "colors" +import status from "http-status" import { NextApiRequest } from "next" import { getSession } from "next-auth/react" import { Server } from "socket.io" @@ -25,7 +28,7 @@ const SocketHandler = async ( logging("Socket is already running " + req.url, ["infoCyan"], req) } else { logging("Socket is initializing " + req.url, ["infoCyan"], req) - const io: cServer = new Server(res.socket.server, { + const io: sServer = new Server(res.socket.server, { path: "/api/ws", cors: { origin: "https://leaky-ships.mal-noh.de", @@ -54,14 +57,15 @@ const SocketHandler = async ( ["debug"], socket.request ) + next(new Error(status["403"])) return } socket.data.gameId = game.id socket.join(game.id) next() } catch (err) { - logging("Unauthorized", ["warn"], socket.request) - next(new Error("Unauthorized")) + logging(status["401"], ["warn"], socket.request) + next(new Error(status["401"])) } }) @@ -73,37 +77,116 @@ const SocketHandler = async ( ["infoGreen"], socket.request ) - const game = await getAnyGame(socket.data.gameId ?? "") - if (!socket.data.user || !socket.data.gameId || !game) - return socket.disconnect() - const body = composeBody(game) - io.to(socket.data.gameId).emit("update", body) + join(socket, io) + + socket.on("update", async (cb) => { + const game = await getAnyGame(socket.data.gameId ?? "") + if (!game) return + const body = composeBody(game) + cb(body) + }) socket.on("gameSetting", async (payload, cb) => { - if (!socket.data.gameId) { - cb({ ack: false }) - return - } const game = await prisma.game.update({ - where: { id: socket.data.gameId }, + where: { id: socket.data.gameId ?? "" }, data: payload, ...gameSelects, }) - const body = composeBody(game) - cb({ ack: true }) - io.to(game.id).emit("update", body) + const { hash } = composeBody(game) + if (!hash) return + cb(hash) + io.to(game.id).emit("gameSetting", payload, hash) }) socket.on("ping", (count, callback) => { callback(count) }) - socket.on("disconnecting", () => { + socket.on("leave", async (cb) => { + if (!socket.data.gameId || !socket.data.user?.id) return cb(false) + const user_Game = await prisma.user_Game.delete({ + where: { + gameId_userId: { + gameId: socket.data.gameId, + userId: socket.data.user?.id, + }, + }, + }) + const enemy = await prisma.user_Game.findFirst({ + where: { + gameId: socket.data.gameId, + }, + }) + let body: GamePropsSchema + if (user_Game.index === "player1" && enemy) { + console.log(1) + body = composeBody( + ( + await prisma.user_Game.update({ + where: { + gameId_index: { + gameId: socket.data.gameId, + index: "player2", + }, + }, + data: { + index: "player1", + }, + + select: { + game: { ...gameSelects }, + }, + }) + ).game + ) + } else { + console.log(2) + const game = await prisma.game.findUnique({ + where: { + id: socket.data.gameId, + }, + ...gameSelects, + }) + if (!game) return cb(false) + body = composeBody(game) + } + const { payload, hash } = body + console.log(payload?.player1, payload?.player2) + if (!payload || !hash) return cb(false) + io.to(socket.data.gameId).emit( + "playerEvent", + { player1: payload.player1, player2: payload.player2 }, + hash, + "leave" + ) + cb(true) + + if (!payload?.player1 && !payload?.player2) { + await prisma.game.delete({ + where: { + id: socket.data.gameId, + }, + }) + } + }) + + socket.on("disconnecting", async () => { logging( "Disconnecting: " + JSON.stringify(Array.from(socket.rooms)), ["debug"], socket.request - ) // the Set contains at least the socket ID + ) + // if (!socket.data.gameId) return + // const game = await prisma.game.findUnique({ + // where: { + // id: socket.data.gameId + // }, + // ...gameSelects + // }) + // if (!game) return + // const { payload, hash } = composeBody(game, socket.data.user?.id ?? "") + // if (!hash) return + // io.to(socket.data.gameId).emit("playerEvent", {}, hash, "disconnect") }) socket.on("disconnect", () => { @@ -115,4 +198,17 @@ const SocketHandler = async ( res.end() } +async function join(socket: sSocket, io: sServer) { + const game = await getAnyGame(socket.data.gameId ?? "") + if (!game) return socket.disconnect() + const { payload, hash } = composeBody(game) + if (!hash) return socket.disconnect() + io.to(game.id).emit( + "playerEvent", + { player1: payload?.player1, player2: payload?.player2 }, + hash, + "join" + ) +} + export default SocketHandler diff --git a/leaky-ships/pages/start.tsx b/leaky-ships/pages/start.tsx index 45dcb26..cc1701a 100644 --- a/leaky-ships/pages/start.tsx +++ b/leaky-ships/pages/start.tsx @@ -27,7 +27,7 @@ export function isAuthenticated(res: Response) { toast(status[res.status], { position: "top-center", - type: "error", + type: "info", theme: "colored", }) } @@ -184,7 +184,15 @@ export default function Start() { "Raum beitreten" )} - + { + router.push({ + pathname: router.pathname, + query: { q: "watch" }, + }) + }} + > {query.watch ? (