From f9ad545e45068e666c0b7340d9a0a4a677aceb50 Mon Sep 17 00:00:00 2001 From: mr-shortman Date: Mon, 31 Mar 2025 18:07:37 +0200 Subject: [PATCH] gameConfig api and sse event setup --- next.config.js | 1 + .../game/configurator/game-configurator.tsx | 18 +++++- src/app/_components/game/game-selector.tsx | 21 ++++++- src/app/_components/lobby/lobby-page.tsx | 5 +- src/app/_components/lobby/lobby-provider.tsx | 31 ++++++++++ src/game-engien/games/index.ts | 4 ++ src/lib/store/game-store.ts | 5 ++ src/lib/validations/game.ts | 7 ++- src/server/api/mutation-utils/game-config.ts | 11 ++++ src/server/api/mutation-utils/lobby-utils.ts | 3 +- src/server/api/root.ts | 4 ++ src/server/api/routers/game-config.ts | 59 +++++++++++++++++++ src/server/api/routers/game.ts | 15 +++++ src/server/api/routers/lobby.ts | 24 +++----- src/server/db/schema.ts | 40 +++++++++---- src/server/{ => sse}/events.d.ts | 9 +++ src/server/sse/index.ts | 14 +++++ 17 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 src/server/api/mutation-utils/game-config.ts create mode 100644 src/server/api/routers/game-config.ts create mode 100644 src/server/api/routers/game.ts rename src/server/{ => sse}/events.d.ts (73%) create mode 100644 src/server/sse/index.ts diff --git a/next.config.js b/next.config.js index 76e1207..f9a17c8 100644 --- a/next.config.js +++ b/next.config.js @@ -9,6 +9,7 @@ const config = { eslint: { ignoreDuringBuilds: true, }, + reactStrictMode: false, }; export default config; diff --git a/src/app/_components/game/configurator/game-configurator.tsx b/src/app/_components/game/configurator/game-configurator.tsx index a68e343..83b804e 100644 --- a/src/app/_components/game/configurator/game-configurator.tsx +++ b/src/app/_components/game/configurator/game-configurator.tsx @@ -5,10 +5,26 @@ import { useGameStore } from "@/lib/store/game-store"; import { InfoIcon, XIcon } from "lucide-react"; import GameConfigForm from "./game-config-form"; import GameVariantSelector from "./game-variant-selector"; +import { api } from "@/trpc/react"; +import { useLobbyStore } from "@/lib/store/lobby-store"; function GameConfigurator() { + const updateGameConfig = api.gameConfig.update.useMutation(); const selectGame = useGameStore((state) => state.setSelectedGame); const selectedGame = useGameStore((state) => state.selectedGame); + const setGameConfig = useGameStore((state) => state.setGameConfig); + const gameConfig = useGameStore((state) => state.gameConfig); + const lobbyId = useLobbyStore((state) => state.lobby.id); + const handleUnselectGame = () => { + selectGame(undefined); + updateGameConfig.mutate({ + lobbyId, + config: { + ...gameConfig, + gameId: undefined, + }, + }); + }; return (
@@ -39,7 +55,7 @@ function GameConfigurator() { variant={"ghost"} size={"icon"} className="ml-auto" - onClick={() => selectGame(undefined)} + onClick={handleUnselectGame} > diff --git a/src/app/_components/game/game-selector.tsx b/src/app/_components/game/game-selector.tsx index f404334..b81abe4 100644 --- a/src/app/_components/game/game-selector.tsx +++ b/src/app/_components/game/game-selector.tsx @@ -1,13 +1,30 @@ import type { IMinigame } from "@/game-engien"; import { gameLibary } from "@/game-engien/games"; import { useGameStore } from "@/lib/store/game-store"; +import { useLobbyStore } from "@/lib/store/lobby-store"; import { cn } from "@/lib/utils"; +import { api } from "@/trpc/react"; import Image from "next/image"; import React from "react"; +import { toast } from "sonner"; -function GameSelector() { +function GameSelector({ isAdmin }: { isAdmin: boolean }) { + const updateGameConfig = api.gameConfig.update.useMutation(); const selectedGame = useGameStore((state) => state.selectedGame); const setSelectedGame = useGameStore((state) => state.setSelectedGame); + const gameConfig = useGameStore((state) => state.gameConfig); + const lobbyId = useLobbyStore((state) => state.lobby.id); + const handleGameSelect = (game: IMinigame) => { + if (!isAdmin) return toast.error("Only admins can change games"); + setSelectedGame(game); + updateGameConfig.mutate({ + lobbyId, + config: { + ...gameConfig, + gameId: game.id, + }, + }); + }; return (
{gameLibary.map((game) => ( @@ -15,7 +32,7 @@ function GameSelector() { key={game.id} game={game} selected={selectedGame?.id === game.id} - onClick={() => setSelectedGame(game)} + onClick={() => handleGameSelect(game)} /> ))}
diff --git a/src/app/_components/lobby/lobby-page.tsx b/src/app/_components/lobby/lobby-page.tsx index ff70af9..4319d3b 100644 --- a/src/app/_components/lobby/lobby-page.tsx +++ b/src/app/_components/lobby/lobby-page.tsx @@ -15,6 +15,7 @@ import { useSessionPlayerStore } from "@/lib/store/current-player-store"; import { useLobbyStore } from "@/lib/store/lobby-store"; import { Share2, UserPlus } from "lucide-react"; import GameConfigurator from "../game/configurator/game-configurator"; +import { useGameStore } from "@/lib/store/game-store"; function LobbyPage({ initialLobby }: { initialLobby: Lobby }) { const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer); @@ -25,7 +26,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) { : null; const isAdmin = isJoined?.role === "admin"; - + const gameConfig = useGameStore((state) => state.gameConfig); return (
@@ -98,7 +99,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
- +
diff --git a/src/app/_components/lobby/lobby-provider.tsx b/src/app/_components/lobby/lobby-provider.tsx index 97766cb..ad49403 100644 --- a/src/app/_components/lobby/lobby-provider.tsx +++ b/src/app/_components/lobby/lobby-provider.tsx @@ -1,5 +1,7 @@ import { appRoutes } from "@/config/app.routes"; +import { getGame } from "@/game-engien/games"; import { useSessionPlayerStore } from "@/lib/store/current-player-store"; +import { useGameStore } from "@/lib/store/game-store"; import { useLobbyStore } from "@/lib/store/lobby-store"; import type { Lobby, LobbyMember } from "@/server/db/schema"; import { api } from "@/trpc/react"; @@ -22,6 +24,10 @@ function LobbyProvider({ const addMember = useLobbyStore((state) => state.addMember); const removeMember = useLobbyStore((state) => state.removeMember); + const setGameConfig = useGameStore((state) => state.setGameConfig); + const setSelectedGame = useGameStore((state) => state.setSelectedGame); + const selectedGame = useGameStore((state) => state.selectedGame); + const router = useRouter(); const lobbyId = lobby.id ?? ""; @@ -59,7 +65,32 @@ function LobbyProvider({ }, }, ); + + api.gameConfig.onUpdate.useSubscription( + { lobbyId }, + { + onData({ data: config }) { + if (config) { + setGameConfig(config); + if (config.gameId !== selectedGame?.id) { + const game = getGame(config.gameId!); + setSelectedGame(game); + } + } + }, + }, + ); + React.useEffect(() => { + const setConfigWithGame = () => { + const initialConfig = initialLobby?.gameConfig?.config; + setGameConfig(initialConfig ?? {}); + const game = initialConfig?.gameId?.length + ? getGame(initialConfig?.gameId!) + : undefined; + setSelectedGame(game); + }; + if (initialLobby?.gameConfig?.config) setConfigWithGame(); resetLobby(initialLobby); setMembers(initialLobby?.members ?? []); }, [initialLobby]); diff --git a/src/game-engien/games/index.ts b/src/game-engien/games/index.ts index f40d651..bfcf662 100644 --- a/src/game-engien/games/index.ts +++ b/src/game-engien/games/index.ts @@ -1,5 +1,9 @@ import type { IMinigame } from ".."; +export const getGame = (id: string) => { + return gameLibary.find((g) => g.id === id); +}; + export const gameLibary: Array = [ { id: "quiz", diff --git a/src/lib/store/game-store.ts b/src/lib/store/game-store.ts index 4bbb837..3f1c115 100644 --- a/src/lib/store/game-store.ts +++ b/src/lib/store/game-store.ts @@ -1,12 +1,17 @@ import { create } from "zustand"; import type { IMinigame } from "@/game-engien"; +import type { GameConfig } from "../validations/game"; type GameStore = { selectedGame?: IMinigame; setSelectedGame: (game?: IMinigame) => void; + gameConfig: GameConfig; + setGameConfig: (config: GameConfig) => void; }; export const useGameStore = create((set) => ({ selectedGame: undefined, setSelectedGame: (game) => set({ selectedGame: game }), + gameConfig: {} as GameConfig, + setGameConfig: (config) => set({ gameConfig: config }), })); diff --git a/src/lib/validations/game.ts b/src/lib/validations/game.ts index 1eff03a..1765518 100644 --- a/src/lib/validations/game.ts +++ b/src/lib/validations/game.ts @@ -1,9 +1,10 @@ import { z } from "zod"; export const gameConfigPatchSchema = z.object({ - timeLimit: z.number().default(2), - scoreMultiplier: z.number().optional(), - allowHints: z.boolean().default(false), + gameId: z.string().optional(), + timeLimit: z.number().default(2).optional(), + scoreMultiplier: z.number().optional().optional(), + allowHints: z.boolean().default(false).optional(), }); export type GameConfig = z.infer; diff --git a/src/server/api/mutation-utils/game-config.ts b/src/server/api/mutation-utils/game-config.ts new file mode 100644 index 0000000..edd0a68 --- /dev/null +++ b/src/server/api/mutation-utils/game-config.ts @@ -0,0 +1,11 @@ +import type { DBType } from "@/server/db"; +import { gameConfigurations } from "@/server/db/schema"; + +export async function createGameConfig(lobbyId: string, db: DBType) { + await db + .insert(gameConfigurations) + .values({ + lobbyId, + }) + .returning(); +} diff --git a/src/server/api/mutation-utils/lobby-utils.ts b/src/server/api/mutation-utils/lobby-utils.ts index 7e7f737..81983d0 100644 --- a/src/server/api/mutation-utils/lobby-utils.ts +++ b/src/server/api/mutation-utils/lobby-utils.ts @@ -2,7 +2,7 @@ import { db } from "@/server/db"; import { redis } from "@/server/redis"; import { lobbies, lobbyMembers } from "@/server/db/schema"; import { count, eq } from "drizzle-orm"; -import { ee } from "@/server/api/routers/lobby"; +import { ee } from "@/server/sse"; export async function getLobbyMemberCount(lobbyId: string) { // Check if the count is in the cache @@ -16,7 +16,6 @@ export async function getLobbyMemberCount(lobbyId: string) { .select({ count: count() }) .from(lobbyMembers) .where(eq(lobbyMembers.lobbyId, lobbyId)); - console.log("rawMemberCount", rawMemberCount); const memberCount = rawMemberCount?.count ?? 0; // Cache the result diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 9f132a3..d814bad 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,8 @@ import { lobbyRouter } from "@/server/api/routers/lobby"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; import { playerRouter } from "./routers/player"; +import { gameRouter } from "./routers/game"; +import { gameConfigRouter } from "./routers/game-config"; /** * This is the primary router for your server. @@ -10,6 +12,8 @@ import { playerRouter } from "./routers/player"; export const appRouter = createTRPCRouter({ lobby: lobbyRouter, player: playerRouter, + game: gameRouter, + gameConfig: gameConfigRouter, }); // export type definition of API diff --git a/src/server/api/routers/game-config.ts b/src/server/api/routers/game-config.ts new file mode 100644 index 0000000..5732fb0 --- /dev/null +++ b/src/server/api/routers/game-config.ts @@ -0,0 +1,59 @@ +import { gameConfigurations } from "@/server/db/schema"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { z } from "zod"; +import { gameConfigPatchSchema } from "@/lib/validations/game"; +import { eq } from "drizzle-orm"; +import { ee } from "@/server/sse"; +import { tracked } from "@trpc/server"; +import type { EventArgs } from "@/server/sse/events"; + +export const gameConfigRouter = createTRPCRouter({ + // game configurator + update: protectedProcedure + .input( + z.object({ + lobbyId: z.string(), + config: gameConfigPatchSchema, + }), + ) + .mutation(async ({ ctx, input }) => { + console.log("Check if user is admin"); + + const [config] = await ctx.db + .update(gameConfigurations) + .set({ config: input.config }) + .where(eq(gameConfigurations.lobbyId, input.lobbyId)) + .returning({ + config: gameConfigurations.config, + }); + if (config?.config) + ee.emit("game:config:update", { + lobbyId: input.lobbyId, + config: config.config, + }); + + return config; + }), + + // subscriptions + onUpdate: publicProcedure + .input(z.object({ lobbyId: z.string() })) + .subscription(async function* (opts) { + const iterable = ee.toIterable("game:config:update", { + signal: opts.signal, + }); + + function* maybeYield([ + { config, lobbyId }, + ]: EventArgs["game:config:update"]) { + if (lobbyId !== opts.input.lobbyId) { + return; + } + yield tracked(lobbyId, config); + } + + for await (const args of iterable) { + yield* maybeYield(args); + } + }), +}); diff --git a/src/server/api/routers/game.ts b/src/server/api/routers/game.ts new file mode 100644 index 0000000..7514774 --- /dev/null +++ b/src/server/api/routers/game.ts @@ -0,0 +1,15 @@ +import { gameConfigurations } from "@/server/db/schema"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { z } from "zod"; +import { gameConfigPatchSchema } from "@/lib/validations/game"; +import { eq } from "drizzle-orm"; +import { ee } from "@/server/sse"; + +export const gameRouter = createTRPCRouter({ + // game sessions + createGameSession: publicProcedure + .input(z.object({ lobbyId: z.string() })) + .query(async ({ ctx }) => { + return null; + }), +}); diff --git a/src/server/api/routers/lobby.ts b/src/server/api/routers/lobby.ts index 57179f3..abbd354 100644 --- a/src/server/api/routers/lobby.ts +++ b/src/server/api/routers/lobby.ts @@ -12,25 +12,14 @@ import { } from "@/lib/validations/lobby"; import { and, eq } from "drizzle-orm"; import { tracked } from "@trpc/server"; -import type { EventArgs, SSE_EVENTS } from "@/server/events"; -import EventEmitter, { on } from "node:events"; +import type { EventArgs } from "@/server/sse/events"; import { decreaseLobbyMemberCount, increaseLobbyMemberCount, handleLobbyAfterLeave, } from "@/server/api/mutation-utils/lobby-utils"; - -type EventMap = Record; -class IterableEventEmitter> extends EventEmitter { - toIterable( - eventName: TEventName, - opts?: NonNullable[2]>, - ): AsyncIterable { - return on(this as any, eventName, opts) as any; - } -} - -export const ee = new IterableEventEmitter(); +import { createGameConfig } from "../mutation-utils/game-config"; +import { ee } from "@/server/sse"; export const lobbyRouter = createTRPCRouter({ // queries @@ -40,6 +29,7 @@ export const lobbyRouter = createTRPCRouter({ with: { lobby: { with: { + gameConfig: true, members: { with: { player: true, @@ -63,6 +53,7 @@ export const lobbyRouter = createTRPCRouter({ await ctx.db.query.lobbies.findFirst({ where: eq(lobbies.id, input.id), with: { + gameConfig: true, members: { with: { player: true, @@ -99,7 +90,10 @@ export const lobbyRouter = createTRPCRouter({ .returning({ id: lobbyMembers.playerId, }); - if (member) increaseLobbyMemberCount(lobby.id); + if (member && lobby) { + increaseLobbyMemberCount(lobby.id); + createGameConfig(lobby.id, ctx.db); + } return lobby; }), update: protectedProcedure diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0c83100..c0973ac 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -3,12 +3,8 @@ import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core"; import { type AdapterAccount } from "next-auth/adapters"; import { createId } from "@paralleldrive/cuid2"; import type { LobbyMemberRole } from "@/lib/validations/lobby"; -/** - * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same - * database instance for multiple projects. - * - * @see https://orm.drizzle.team/docs/goodies#multi-project-schema - */ +import type { GameConfig } from "@/lib/validations/game"; + export const createTable = pgTableCreator((name) => `game-master_${name}`); const defaultTimeStamp = (name: string, d: any) => @@ -17,6 +13,23 @@ const defaultTimeStamp = (name: string, d: any) => .default(sql`CURRENT_TIMESTAMP`) .notNull(); +export const gameConfigurations = createTable( + "game_configuration", + (d) => ({ + lobbyId: d + .varchar({ length: 255 }) + .notNull() + .references(() => lobbies.id, { onDelete: "cascade" }), + config: d.jsonb().$type(), + }), + (t) => [ + primaryKey({ columns: [t.lobbyId] }), + index("game_configuration_lobby_id_idx").on(t.lobbyId), + ], +); + +export type GameConfigurationTable = typeof gameConfigurations.$inferSelect; + export const lobbies = createTable("lobby", (d) => ({ id: d .varchar({ length: 255 }) @@ -35,19 +48,24 @@ export const lobbies = createTable("lobby", (d) => ({ .$onUpdate(() => new Date()), })); -export type Lobby = typeof lobbies.$inferSelect & { - members?: LobbyMember[]; - leader?: Player; -}; - export const lobbyRelations = relations(lobbies, ({ many, one }) => ({ leader: one(players, { fields: [lobbies.createdById], references: [players.id], }), members: many(lobbyMembers), + gameConfig: one(gameConfigurations, { + fields: [lobbies.id], + references: [gameConfigurations.lobbyId], + }), })); +export type Lobby = typeof lobbies.$inferSelect & { + members?: LobbyMember[]; + leader?: Player; + gameConfig?: GameConfigurationTable; +}; + export const lobbyMembers = createTable( "lobby_member", (d) => ({ diff --git a/src/server/events.d.ts b/src/server/sse/events.d.ts similarity index 73% rename from src/server/events.d.ts rename to src/server/sse/events.d.ts index 57e3135..c82abcf 100644 --- a/src/server/events.d.ts +++ b/src/server/sse/events.d.ts @@ -1,3 +1,4 @@ +import type { GameConfig } from "@/lib/validations/game"; import type { LobbyMember, Post } from "../db/schema"; export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean }; @@ -19,10 +20,18 @@ export type EventArgs = { // deleted boolean?, ]; + "game:config:update": [ + { + lobbyId: string; + config: GameConfig; + }, + ]; }; export type SSE_EVENTS = { "lobby:update": EventArgs["lobby:update"]; "lobby:member:update": [LobbyMember]; "lobby:member:membership": EventArgs["lobby:member:membership"]; + + "game:config:update": EventArgs["game:config:update"]; }; diff --git a/src/server/sse/index.ts b/src/server/sse/index.ts new file mode 100644 index 0000000..eee891c --- /dev/null +++ b/src/server/sse/index.ts @@ -0,0 +1,14 @@ +import EventEmitter, { on } from "node:events"; +import type { SSE_EVENTS } from "./events"; + +type EventMap = Record; +class IterableEventEmitter> extends EventEmitter { + toIterable( + eventName: TEventName, + opts?: NonNullable[2]>, + ): AsyncIterable { + return on(this as any, eventName, opts) as any; + } +} + +export const ee = new IterableEventEmitter();