gameConfig api and sse event setup
This commit is contained in:
parent
a77bac304e
commit
f9ad545e45
@ -9,6 +9,7 @@ const config = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
}));
|
||||
|
||||
@ -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>;
|
||||
|
||||
11
src/server/api/mutation-utils/game-config.ts
Normal file
11
src/server/api/mutation-utils/game-config.ts
Normal 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();
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
59
src/server/api/routers/game-config.ts
Normal file
59
src/server/api/routers/game-config.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
15
src/server/api/routers/game.ts
Normal file
15
src/server/api/routers/game.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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
14
src/server/sse/index.ts
Normal 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>();
|
||||
Loading…
x
Reference in New Issue
Block a user