gameConfig api and sse event setup

This commit is contained in:
mr-shortman 2025-03-31 18:07:37 +02:00
parent a77bac304e
commit f9ad545e45
17 changed files with 235 additions and 36 deletions

View File

@ -9,6 +9,7 @@ const config = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
reactStrictMode: false,
}; };
export default config; export default config;

View File

@ -5,10 +5,26 @@ import { useGameStore } from "@/lib/store/game-store";
import { InfoIcon, XIcon } from "lucide-react"; import { InfoIcon, XIcon } from "lucide-react";
import GameConfigForm from "./game-config-form"; import GameConfigForm from "./game-config-form";
import GameVariantSelector from "./game-variant-selector"; import GameVariantSelector from "./game-variant-selector";
import { api } from "@/trpc/react";
import { useLobbyStore } from "@/lib/store/lobby-store";
function GameConfigurator() { function GameConfigurator() {
const updateGameConfig = api.gameConfig.update.useMutation();
const selectGame = useGameStore((state) => state.setSelectedGame); const selectGame = useGameStore((state) => state.setSelectedGame);
const selectedGame = useGameStore((state) => state.selectedGame); 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 ( return (
<div className="flex size-full flex-col justify-between p-6"> <div className="flex size-full flex-col justify-between p-6">
@ -39,7 +55,7 @@ function GameConfigurator() {
variant={"ghost"} variant={"ghost"}
size={"icon"} size={"icon"}
className="ml-auto" className="ml-auto"
onClick={() => selectGame(undefined)} onClick={handleUnselectGame}
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
</Button> </Button>

View File

@ -1,13 +1,30 @@
import type { IMinigame } from "@/game-engien"; import type { IMinigame } from "@/game-engien";
import { gameLibary } from "@/game-engien/games"; import { gameLibary } from "@/game-engien/games";
import { useGameStore } from "@/lib/store/game-store"; import { useGameStore } from "@/lib/store/game-store";
import { useLobbyStore } from "@/lib/store/lobby-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import Image from "next/image"; import Image from "next/image";
import React from "react"; 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 selectedGame = useGameStore((state) => state.selectedGame);
const setSelectedGame = useGameStore((state) => state.setSelectedGame); 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 ( return (
<div className="grid w-full grid-cols-4 gap-4"> <div className="grid w-full grid-cols-4 gap-4">
{gameLibary.map((game) => ( {gameLibary.map((game) => (
@ -15,7 +32,7 @@ function GameSelector() {
key={game.id} key={game.id}
game={game} game={game}
selected={selectedGame?.id === game.id} selected={selectedGame?.id === game.id}
onClick={() => setSelectedGame(game)} onClick={() => handleGameSelect(game)}
/> />
))} ))}
</div> </div>

View File

@ -15,6 +15,7 @@ import { useSessionPlayerStore } from "@/lib/store/current-player-store";
import { useLobbyStore } from "@/lib/store/lobby-store"; import { useLobbyStore } from "@/lib/store/lobby-store";
import { Share2, UserPlus } from "lucide-react"; import { Share2, UserPlus } from "lucide-react";
import GameConfigurator from "../game/configurator/game-configurator"; import GameConfigurator from "../game/configurator/game-configurator";
import { useGameStore } from "@/lib/store/game-store";
function LobbyPage({ initialLobby }: { initialLobby: Lobby }) { function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer); const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
@ -25,7 +26,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
: null; : null;
const isAdmin = isJoined?.role === "admin"; const isAdmin = isJoined?.role === "admin";
const gameConfig = useGameStore((state) => state.gameConfig);
return ( return (
<LobbyProvider initialLobby={initialLobby}> <LobbyProvider initialLobby={initialLobby}>
<div className="grid w-full max-w-4xl grid-cols-3 grid-rows-2 gap-4"> <div className="grid w-full max-w-4xl grid-cols-3 grid-rows-2 gap-4">
@ -98,7 +99,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
<GameConfigurator /> <GameConfigurator />
</div> </div>
<div className="container-bg col-span-3 h-max w-full p-4"> <div className="container-bg col-span-3 h-max w-full p-4">
<GameSelector /> <GameSelector isAdmin={isAdmin} />
</div> </div>
</div> </div>
</LobbyProvider> </LobbyProvider>

View File

@ -1,5 +1,7 @@
import { appRoutes } from "@/config/app.routes"; import { appRoutes } from "@/config/app.routes";
import { getGame } from "@/game-engien/games";
import { useSessionPlayerStore } from "@/lib/store/current-player-store"; import { useSessionPlayerStore } from "@/lib/store/current-player-store";
import { useGameStore } from "@/lib/store/game-store";
import { useLobbyStore } from "@/lib/store/lobby-store"; import { useLobbyStore } from "@/lib/store/lobby-store";
import type { Lobby, LobbyMember } from "@/server/db/schema"; import type { Lobby, LobbyMember } from "@/server/db/schema";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
@ -22,6 +24,10 @@ function LobbyProvider({
const addMember = useLobbyStore((state) => state.addMember); const addMember = useLobbyStore((state) => state.addMember);
const removeMember = useLobbyStore((state) => state.removeMember); 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 router = useRouter();
const lobbyId = lobby.id ?? ""; 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(() => { 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); resetLobby(initialLobby);
setMembers(initialLobby?.members ?? []); setMembers(initialLobby?.members ?? []);
}, [initialLobby]); }, [initialLobby]);

View File

@ -1,5 +1,9 @@
import type { IMinigame } from ".."; import type { IMinigame } from "..";
export const getGame = (id: string) => {
return gameLibary.find((g) => g.id === id);
};
export const gameLibary: Array<IMinigame> = [ export const gameLibary: Array<IMinigame> = [
{ {
id: "quiz", id: "quiz",

View File

@ -1,12 +1,17 @@
import { create } from "zustand"; import { create } from "zustand";
import type { IMinigame } from "@/game-engien"; import type { IMinigame } from "@/game-engien";
import type { GameConfig } from "../validations/game";
type GameStore = { type GameStore = {
selectedGame?: IMinigame; selectedGame?: IMinigame;
setSelectedGame: (game?: IMinigame) => void; setSelectedGame: (game?: IMinigame) => void;
gameConfig: GameConfig;
setGameConfig: (config: GameConfig) => void;
}; };
export const useGameStore = create<GameStore>((set) => ({ export const useGameStore = create<GameStore>((set) => ({
selectedGame: undefined, selectedGame: undefined,
setSelectedGame: (game) => set({ selectedGame: game }), setSelectedGame: (game) => set({ selectedGame: game }),
gameConfig: {} as GameConfig,
setGameConfig: (config) => set({ gameConfig: config }),
})); }));

View File

@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
export const gameConfigPatchSchema = z.object({ export const gameConfigPatchSchema = z.object({
timeLimit: z.number().default(2), gameId: z.string().optional(),
scoreMultiplier: z.number().optional(), timeLimit: z.number().default(2).optional(),
allowHints: z.boolean().default(false), scoreMultiplier: z.number().optional().optional(),
allowHints: z.boolean().default(false).optional(),
}); });
export type GameConfig = z.infer<typeof gameConfigPatchSchema>; export type GameConfig = z.infer<typeof gameConfigPatchSchema>;

View File

@ -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();
}

View File

@ -2,7 +2,7 @@ import { db } from "@/server/db";
import { redis } from "@/server/redis"; import { redis } from "@/server/redis";
import { lobbies, lobbyMembers } from "@/server/db/schema"; import { lobbies, lobbyMembers } from "@/server/db/schema";
import { count, eq } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { ee } from "@/server/api/routers/lobby"; import { ee } from "@/server/sse";
export async function getLobbyMemberCount(lobbyId: string) { export async function getLobbyMemberCount(lobbyId: string) {
// Check if the count is in the cache // Check if the count is in the cache
@ -16,7 +16,6 @@ export async function getLobbyMemberCount(lobbyId: string) {
.select({ count: count() }) .select({ count: count() })
.from(lobbyMembers) .from(lobbyMembers)
.where(eq(lobbyMembers.lobbyId, lobbyId)); .where(eq(lobbyMembers.lobbyId, lobbyId));
console.log("rawMemberCount", rawMemberCount);
const memberCount = rawMemberCount?.count ?? 0; const memberCount = rawMemberCount?.count ?? 0;
// Cache the result // Cache the result

View File

@ -1,6 +1,8 @@
import { lobbyRouter } from "@/server/api/routers/lobby"; import { lobbyRouter } from "@/server/api/routers/lobby";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { playerRouter } from "./routers/player"; import { playerRouter } from "./routers/player";
import { gameRouter } from "./routers/game";
import { gameConfigRouter } from "./routers/game-config";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@ -10,6 +12,8 @@ import { playerRouter } from "./routers/player";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
lobby: lobbyRouter, lobby: lobbyRouter,
player: playerRouter, player: playerRouter,
game: gameRouter,
gameConfig: gameConfigRouter,
}); });
// export type definition of API // export type definition of API

View File

@ -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);
}
}),
});

View File

@ -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;
}),
});

View File

@ -12,25 +12,14 @@ import {
} from "@/lib/validations/lobby"; } from "@/lib/validations/lobby";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { tracked } from "@trpc/server"; import { tracked } from "@trpc/server";
import type { EventArgs, SSE_EVENTS } from "@/server/events"; import type { EventArgs } from "@/server/sse/events";
import EventEmitter, { on } from "node:events";
import { import {
decreaseLobbyMemberCount, decreaseLobbyMemberCount,
increaseLobbyMemberCount, increaseLobbyMemberCount,
handleLobbyAfterLeave, handleLobbyAfterLeave,
} from "@/server/api/mutation-utils/lobby-utils"; } from "@/server/api/mutation-utils/lobby-utils";
import { createGameConfig } from "../mutation-utils/game-config";
type EventMap<T> = Record<keyof T, any[]>; import { ee } from "@/server/sse";
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
toIterable<TEventName extends keyof T & string>(
eventName: TEventName,
opts?: NonNullable<Parameters<typeof on>[2]>,
): AsyncIterable<T[TEventName]> {
return on(this as any, eventName, opts) as any;
}
}
export const ee = new IterableEventEmitter<SSE_EVENTS>();
export const lobbyRouter = createTRPCRouter({ export const lobbyRouter = createTRPCRouter({
// queries // queries
@ -40,6 +29,7 @@ export const lobbyRouter = createTRPCRouter({
with: { with: {
lobby: { lobby: {
with: { with: {
gameConfig: true,
members: { members: {
with: { with: {
player: true, player: true,
@ -63,6 +53,7 @@ export const lobbyRouter = createTRPCRouter({
await ctx.db.query.lobbies.findFirst({ await ctx.db.query.lobbies.findFirst({
where: eq(lobbies.id, input.id), where: eq(lobbies.id, input.id),
with: { with: {
gameConfig: true,
members: { members: {
with: { with: {
player: true, player: true,
@ -99,7 +90,10 @@ export const lobbyRouter = createTRPCRouter({
.returning({ .returning({
id: lobbyMembers.playerId, id: lobbyMembers.playerId,
}); });
if (member) increaseLobbyMemberCount(lobby.id); if (member && lobby) {
increaseLobbyMemberCount(lobby.id);
createGameConfig(lobby.id, ctx.db);
}
return lobby; return lobby;
}), }),
update: protectedProcedure update: protectedProcedure

View File

@ -3,12 +3,8 @@ import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters"; import { type AdapterAccount } from "next-auth/adapters";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { LobbyMemberRole } from "@/lib/validations/lobby"; import type { LobbyMemberRole } from "@/lib/validations/lobby";
/** import type { GameConfig } from "@/lib/validations/game";
* 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
*/
export const createTable = pgTableCreator((name) => `game-master_${name}`); export const createTable = pgTableCreator((name) => `game-master_${name}`);
const defaultTimeStamp = (name: string, d: any) => const defaultTimeStamp = (name: string, d: any) =>
@ -17,6 +13,23 @@ const defaultTimeStamp = (name: string, d: any) =>
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(); .notNull();
export const gameConfigurations = createTable(
"game_configuration",
(d) => ({
lobbyId: d
.varchar({ length: 255 })
.notNull()
.references(() => lobbies.id, { onDelete: "cascade" }),
config: d.jsonb().$type<GameConfig>(),
}),
(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) => ({ export const lobbies = createTable("lobby", (d) => ({
id: d id: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@ -35,19 +48,24 @@ export const lobbies = createTable("lobby", (d) => ({
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
})); }));
export type Lobby = typeof lobbies.$inferSelect & {
members?: LobbyMember[];
leader?: Player;
};
export const lobbyRelations = relations(lobbies, ({ many, one }) => ({ export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
leader: one(players, { leader: one(players, {
fields: [lobbies.createdById], fields: [lobbies.createdById],
references: [players.id], references: [players.id],
}), }),
members: many(lobbyMembers), 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( export const lobbyMembers = createTable(
"lobby_member", "lobby_member",
(d) => ({ (d) => ({

View File

@ -1,3 +1,4 @@
import type { GameConfig } from "@/lib/validations/game";
import type { LobbyMember, Post } from "../db/schema"; import type { LobbyMember, Post } from "../db/schema";
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean }; export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
@ -19,10 +20,18 @@ export type EventArgs = {
// deleted // deleted
boolean?, boolean?,
]; ];
"game:config:update": [
{
lobbyId: string;
config: GameConfig;
},
];
}; };
export type SSE_EVENTS = { export type SSE_EVENTS = {
"lobby:update": EventArgs["lobby:update"]; "lobby:update": EventArgs["lobby:update"];
"lobby:member:update": [LobbyMember]; "lobby:member:update": [LobbyMember];
"lobby:member:membership": EventArgs["lobby:member:membership"]; "lobby:member:membership": EventArgs["lobby:member:membership"];
"game:config:update": EventArgs["game:config:update"];
}; };

14
src/server/sse/index.ts Normal file
View File

@ -0,0 +1,14 @@
import EventEmitter, { on } from "node:events";
import type { SSE_EVENTS } from "./events";
type EventMap<T> = Record<keyof T, any[]>;
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
toIterable<TEventName extends keyof T & string>(
eventName: TEventName,
opts?: NonNullable<Parameters<typeof on>[2]>,
): AsyncIterable<T[TEventName]> {
return on(this as any, eventName, opts) as any;
}
}
export const ee = new IterableEventEmitter<SSE_EVENTS>();