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: {
ignoreDuringBuilds: true,
},
reactStrictMode: false,
};
export default config;

View File

@ -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 (
<div className="flex size-full flex-col justify-between p-6">
@ -39,7 +55,7 @@ function GameConfigurator() {
variant={"ghost"}
size={"icon"}
className="ml-auto"
onClick={() => selectGame(undefined)}
onClick={handleUnselectGame}
>
<XIcon className="size-4" />
</Button>

View File

@ -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 (
<div className="grid w-full grid-cols-4 gap-4">
{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)}
/>
))}
</div>

View File

@ -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 (
<LobbyProvider initialLobby={initialLobby}>
<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 />
</div>
<div className="container-bg col-span-3 h-max w-full p-4">
<GameSelector />
<GameSelector isAdmin={isAdmin} />
</div>
</div>
</LobbyProvider>

View File

@ -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]);

View File

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

View File

@ -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<GameStore>((set) => ({
selectedGame: undefined,
setSelectedGame: (game) => set({ selectedGame: game }),
gameConfig: {} as GameConfig,
setGameConfig: (config) => set({ gameConfig: config }),
}));

View File

@ -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<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 { 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

View File

@ -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

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";
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<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>();
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

View File

@ -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<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) => ({
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) => ({

View File

@ -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"];
};

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