diff --git a/index.html b/index.html index d869144..8962d4f 100644 --- a/index.html +++ b/index.html @@ -2,13 +2,13 @@ - TapDB + TwentyOne - +
diff --git a/package.json b/package.json index 017b8c1..83e0c47 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "bufferutil": "^4.0.7", "eventemitter3": "^5.0.0", "express": "^4.18.2", + "immer": "^10.0.1", "listhen": "^1.0.4", "lodash-es": "^4.17.21", "nanoid": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1da4fe..ed741a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,7 @@ importers: bufferutil: ^4.0.7 eventemitter3: ^5.0.0 express: ^4.18.2 + immer: ^10.0.1 listhen: ^1.0.4 lodash-es: ^4.17.21 nanoid: ^4.0.2 @@ -48,6 +49,7 @@ importers: bufferutil: 4.0.7 eventemitter3: 5.0.0 express: 4.18.2 + immer: 10.0.1 listhen: 1.0.4 lodash-es: 4.17.21 nanoid: 4.0.2 @@ -1949,6 +1951,10 @@ packages: safer-buffer: 2.1.2 dev: false + /immer/10.0.1: + resolution: {integrity: sha512-zg++jJLsKKTwXGeSYIw0HgChSYQGtu0UDTnbKx5aGLYgte4CwTmH9eJDYyQ6FheyUtBe+lQW9FrGxya1G+Dtmg==} + dev: false + /immutable/4.2.4: resolution: {integrity: sha512-WDxL3Hheb1JkRN3sQkyujNlL/xRjAo3rJtaU5xeufUauG66JdMr32bLj4gF+vWl84DIA3Zxw7tiAjneYzRRw+w==} dev: true diff --git a/schema.prisma b/schema.prisma index 1d6a812..290faf6 100644 --- a/schema.prisma +++ b/schema.prisma @@ -15,8 +15,9 @@ model Game { } model Player { - id String @id @default(cuid()) - name String + id String @id @default(cuid()) + name String + token String @unique gameActions GameAction[] } diff --git a/src/server/game.ts b/src/server/game.ts index 0fcef64..60cf8ca 100644 --- a/src/server/game.ts +++ b/src/server/game.ts @@ -1,14 +1,29 @@ import EventEmitter from "eventemitter3" -import type { GameAction } from "../shared/gameActions" +import type { GameAction } from "../shared/game/gameActions" -export const gameActionsBus = new EventEmitter>() +const gamesById = new Map() + +export function getGameById(id: string): Game { + return gamesById.get(id)! +} interface Events { - broadcastAction: [GameAction] + public_action: [GameAction] + private_action: [string, GameAction] + destroyed: [] } export class Game extends EventEmitter { constructor(public id: string) { super() } + + start() { + + } + + private destroy() { + gamesById.delete(this.id) + this.emit("destroyed") + } } \ No newline at end of file diff --git a/src/server/main.ts b/src/server/main.ts index a5603a0..a8c1a05 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -11,14 +11,18 @@ const expressApp = createExpressApp() expressApp.use("/trpc", createTrpcMiddleware({ router: appRouter, - createContext + createContext: ({ req }) => createContext(req.headers.authorization) })) await seedDatabase() const { server } = await listen(expressApp) const wss = new WebSocketServer({ server, path: "/ws" }) -const wssTrpcHandler = applyWSSHandler({ wss, createContext, router: appRouter }) +const wssTrpcHandler = applyWSSHandler({ + wss, + router: appRouter, + createContext: ({ req }) => createContext(req.headers.authorization) +}) process.on("SIGTERM", () => { console.log("Received SIGTERM") diff --git a/src/server/seed.ts b/src/server/seed.ts index 0a92e58..aa2e1e6 100644 --- a/src/server/seed.ts +++ b/src/server/seed.ts @@ -1,3 +1,28 @@ -export async function seedDatabase() { +import { prismaClient } from "./prisma" +export async function seedDatabase() { + if (await prismaClient.player.count() === 0) { + await prismaClient.player.create({ + data: { + name: "max", + token: "max" + } + }) + + await prismaClient.player.create({ + data: { + name: "moritz", + token: "moritz" + } + }) + } + + if (await prismaClient.game.count() === 0) { + await prismaClient.game.create({ + data: { + id: "game", + lobbyCodeIfActive: "game" + } + }) + } } \ No newline at end of file diff --git a/src/server/trpc/base.ts b/src/server/trpc/base.ts index dfa7cbb..8a469b5 100644 --- a/src/server/trpc/base.ts +++ b/src/server/trpc/base.ts @@ -1,11 +1,22 @@ import { initTRPC } from "@trpc/server" +import { prismaClient } from "../prisma" export interface Context { - + playerId: string } -export async function createContext(): Promise { - return {} +export async function createContext(authorizationHeader: string | undefined): Promise { + const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters + + if (token !== null && token.length > 0) { + const player = await prismaClient.player.findUnique({ where: { token }, select: { id: true } }) + + if (player !== null) return { + playerId: player.id + } + } + + throw new Error("Invalid token") } export const t = initTRPC.context().create() diff --git a/src/server/trpc/index.ts b/src/server/trpc/index.ts index cadd63f..92bb5a0 100644 --- a/src/server/trpc/index.ts +++ b/src/server/trpc/index.ts @@ -1,16 +1,38 @@ import { t } from "./base" import { prismaClient } from "../prisma" -import type { Prisma } from "../prisma" import z from "zod" +import { observable } from "@trpc/server/observable" +import type { GameAction } from "../../shared/game/gameActions" +import { getGameById } from "../game" export const appRouter = t.router({ - create: t.procedure - .input(z.object({ - skip: z.number().int().min(0), - groupId: z.string().cuid().optional() - })) - .query(async ({ input }) => { + start: t.procedure + .mutation(async ({ ctx }) => { + }), + + join: t.procedure + .input(z.object({ + gameId: z.string().nonempty() + })) + .subscription(({ input, ctx }) => { + const game = getGameById(input.gameId) + + return observable(emit => { + const handlePublicAction = (action: GameAction) => emit.next(action) + + const handlePrivateAction = (playerId: string, action: GameAction) => { + if (playerId === ctx.playerId) emit.next(action) + } + + game.on("public_action", handlePublicAction) + game.on("private_action", handlePrivateAction) + + return () => { + game.off("public_action", handlePublicAction) + game.off("private_action", handlePrivateAction) + } + }) }) }) diff --git a/src/shared/game/cards.ts b/src/shared/game/cards.ts index df64960..97c4db0 100644 --- a/src/shared/game/cards.ts +++ b/src/shared/game/cards.ts @@ -1,8 +1,12 @@ -export type SpecialCardType = - | "return-last-opponent" - | "return-last-own" - | "destroy-last-opponent-special" - | "increase-target-by-2" - | "next-round-covert" - | "double-draw" - | "add-2-opponent" \ No newline at end of file +const specialCardTypesObject = { + "return-last-opponent": true, + "return-last-own": true, + "destroy-last-opponent-special": true, + "increase-target-by-2": true, + "next-round-covert": true, + "double-draw": true, + "add-2-opponent": true +} + +export const specialCardTypes = Object.keys(specialCardTypesObject) +export type SpecialCardType = keyof typeof specialCardTypesObject \ No newline at end of file diff --git a/src/shared/game/gameActions.ts b/src/shared/game/gameActions.ts index b0f133d..d49d68b 100644 --- a/src/shared/game/gameActions.ts +++ b/src/shared/game/gameActions.ts @@ -15,12 +15,11 @@ type PlayerAction = { } ) -type ServerAction = { - initiatingPlayerId: null -} & ( +type ServerAction = | { type: "start" - cardsByPlayerId: Record // if redacted: only contains the player themself + numberCardsByPlayerId: Record // if redacted: only contains the player themself + targetSum: number } | { type: "deal-number" @@ -32,7 +31,6 @@ type ServerAction = { toPlayerId: string cardType: SpecialCardType } -) export type GameAction = { index: number diff --git a/src/shared/game/state.ts b/src/shared/game/state.ts index e69de29..0ca355d 100644 --- a/src/shared/game/state.ts +++ b/src/shared/game/state.ts @@ -0,0 +1,106 @@ +import type { SpecialCardType } from "./cards" +import type { GameAction } from "./gameActions" +import { produce } from "immer" +import { specialCardTypes } from "./cards" + +type SpecialCardCountByType = Record + +export interface GameStatePlayer { + id: string + numberCards: number[] + specialCardCountByType: SpecialCardCountByType + stayed: boolean + nextRoundCovert: boolean +} + +export interface GameState { + players: GameStatePlayer[] + activeSpecialCardCountByType: SpecialCardCountByType + activePlayerId: string + targetSum: number +} + +const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType + +const UNINITIALIZED_GAME_STATE: GameState = { + players: [], + activePlayerId: "", + targetSum: 0, + activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS +} + +export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => { + switch (action.type) { + case "start": + state.players = Object.entries(action.numberCardsByPlayerId).map(([playerId, numberCards]): GameStatePlayer => ({ + id: playerId, + numberCards, + specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS, + stayed: false, + nextRoundCovert: false + })) + + state.activePlayerId = state.players[0].id + state.targetSum = action.targetSum + state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS + + break + + case "deal-number": + const p1 = state.players.find(p => p.id === action.toPlayerId)! + p1.numberCards.push(action.number) + break + + case "deal-special": + const p2 = state.players.find(p => p.id === action.toPlayerId)! + p2.specialCardCountByType[action.cardType]++ + break + + case "draw": + case "stay": + const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId) + + // activate the next player + state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id + break + + case "use-special": + const p3 = state.players.find(p => p.id === action.initiatingPlayerId)! + applySpecialCardUsage(state, action.cardType, p3) + break + } +}) + +function applySpecialCardUsage(state: GameState, type: SpecialCardType, player: GameStatePlayer) { + player.specialCardCountByType[type]-- + + switch (type) { + case "add-2-opponent": + // nothing + break + + case "destroy-last-opponent-special": + // TODO + break + + case "double-draw": + // nothing + break + + case "increase-target-by-2": + state.targetSum += 2 + break + + case "next-round-covert": + player.nextRoundCovert = true + break + + case "return-last-opponent": + // TODO + break + + case "return-last-own": + // TODO + break + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 22fd899..7724feb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ ], server: { proxy: { - "/trpc": "http://127.0.0.1:3000" + "/trpc": "http://127.0.0.1:3000", + "/ws": { + target: "http://127.0.0.1:3000", + ws: true + } } } })