From 8f54f121f188c2c8ac9410b7ec682c2846e8e978 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Sat, 22 Apr 2023 20:48:06 +0200 Subject: [PATCH] commit #3 --- package.json | 8 +- pnpm-lock.yaml | 65 +++++++++-- schema.prisma | 8 +- src/App.vue | 32 +++++- src/auth.ts | 12 ++ src/clientGame.ts | 47 ++++++++ src/components/BigButton.vue | 60 ++++++++++ src/components/Card.vue | 43 +++++++ src/components/Game.vue | 55 +++++++++ src/components/GameEndModal.vue | 51 +++++++++ src/components/Modal.vue | 32 ++++++ src/components/NumberCard.vue | 45 ++++++++ src/components/PlayerCards.vue | 50 +++++++++ src/index.ts | 10 +- src/server/activeGame.ts | 193 ++++++++++++++++++++++++++++++++ src/server/game.ts | 29 ----- src/server/isDev.ts | 1 + src/server/main.ts | 29 +++-- src/server/seed.ts | 29 +++-- src/server/trpc/base.ts | 36 ++++-- src/server/trpc/game.ts | 39 +++++++ src/server/trpc/index.ts | 49 ++++++-- src/shared/RemoveKey.ts | 5 + src/shared/game/cards.ts | 4 +- src/shared/game/gameActions.ts | 19 +++- src/shared/game/state.ts | 74 ++++++++++-- src/shared/util.ts | 6 + src/trpc.ts | 4 +- src/types.d.ts | 5 + tsconfig.json | 1 + windi.config.ts | 2 +- 31 files changed, 929 insertions(+), 114 deletions(-) create mode 100644 src/auth.ts create mode 100644 src/clientGame.ts create mode 100644 src/components/BigButton.vue create mode 100644 src/components/Card.vue create mode 100644 src/components/Game.vue create mode 100644 src/components/GameEndModal.vue create mode 100644 src/components/Modal.vue create mode 100644 src/components/NumberCard.vue create mode 100644 src/components/PlayerCards.vue create mode 100644 src/server/activeGame.ts delete mode 100644 src/server/game.ts create mode 100644 src/server/isDev.ts create mode 100644 src/server/trpc/game.ts create mode 100644 src/shared/RemoveKey.ts create mode 100644 src/shared/util.ts create mode 100644 src/types.d.ts diff --git a/package.json b/package.json index 83e0c47..3161d45 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "type": "module", "scripts": { "dev:ui": "vite", - "dev:server": "tsx watch ./src/server/main.ts" + "dev:server": "NODE_ENV=development tsx watch --clear-screen=false ./src/server/main.ts" }, "devDependencies": { "@iconify-json/ph": "^1.1.5", + "@types/cookie": "^0.5.1", "@types/express": "^4.17.17", "@types/lodash-es": "^4.17.7", "@types/node": "18", @@ -16,6 +17,7 @@ "prisma": "^4.13.0", "sass": "^1.62.0", "tsx": "^3.12.6", + "typescript": "^4.9.5", "unplugin-icons": "^0.16.1", "vite": "^4.2.2", "vite-plugin-pages": "^0.29.0", @@ -29,15 +31,19 @@ "@prisma/client": "^4.13.0", "@trpc/client": "^10.20.0", "@trpc/server": "^10.20.0", + "@types/cookie-parser": "^1.4.3", "@vueuse/core": "^10.0.2", "@vueuse/integrations": "^10.0.2", "bufferutil": "^4.0.7", + "cookie": "^0.5.0", + "cookie-parser": "^1.4.6", "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", + "pinia": "^2.0.34", "vue": "^3.2.47", "vue-router": "^4.1.6", "ws": "^8.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed741a0..be836c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,8 @@ importers: '@prisma/client': ^4.13.0 '@trpc/client': ^10.20.0 '@trpc/server': ^10.20.0 + '@types/cookie': ^0.5.1 + '@types/cookie-parser': ^1.4.3 '@types/express': ^4.17.17 '@types/lodash-es': ^4.17.7 '@types/node': '18' @@ -19,15 +21,19 @@ importers: '@vueuse/core': ^10.0.2 '@vueuse/integrations': ^10.0.2 bufferutil: ^4.0.7 + cookie: ^0.5.0 + cookie-parser: ^1.4.6 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 + pinia: ^2.0.34 prisma: ^4.13.0 sass: ^1.62.0 tsx: ^3.12.6 + typescript: ^4.9.5 unplugin-icons: ^0.16.1 vite: ^4.2.2 vite-plugin-pages: ^0.29.0 @@ -44,21 +50,26 @@ importers: '@prisma/client': 4.13.0_prisma@4.13.0 '@trpc/client': 10.20.0_@trpc+server@10.20.0 '@trpc/server': 10.20.0 + '@types/cookie-parser': 1.4.3 '@vueuse/core': 10.0.2_vue@3.2.47 '@vueuse/integrations': 10.0.2_vue@3.2.47 bufferutil: 4.0.7 + cookie: 0.5.0 + cookie-parser: 1.4.6 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 + pinia: 2.0.34_hmuptsblhheur2tugfgucj7gc4 vue: 3.2.47 vue-router: 4.1.6_vue@3.2.47 ws: 8.13.0_bufferutil@4.0.7 zod: 3.21.4 devDependencies: '@iconify-json/ph': 1.1.5 + '@types/cookie': 0.5.1 '@types/express': 4.17.17 '@types/lodash-es': 4.17.7 '@types/node': 18.13.0 @@ -67,6 +78,7 @@ importers: prisma: 4.13.0 sass: 1.62.0 tsx: 3.12.6 + typescript: 4.9.5 unplugin-icons: 0.16.1 vite: 4.2.2_3wfpih6r6pq57j5negv3imilwi vite-plugin-pages: 0.29.0_vite@4.2.2 @@ -750,12 +762,20 @@ packages: dependencies: '@types/connect': 3.4.35 '@types/node': 18.13.0 - dev: true /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: '@types/node': 18.13.0 + + /@types/cookie-parser/1.4.3: + resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==} + dependencies: + '@types/express': 4.17.17 + dev: false + + /@types/cookie/0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} dev: true /@types/debug/4.1.7: @@ -770,7 +790,6 @@ packages: '@types/node': 18.13.0 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 - dev: true /@types/express/4.17.17: resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} @@ -779,7 +798,6 @@ packages: '@types/express-serve-static-core': 4.17.33 '@types/qs': 6.9.7 '@types/serve-static': 1.15.0 - dev: true /@types/lodash-es/4.17.6: resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==} @@ -799,7 +817,6 @@ packages: /@types/mime/3.0.1: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} - dev: true /@types/ms/0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} @@ -807,22 +824,18 @@ packages: /@types/node/18.13.0: resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==} - dev: true /@types/qs/6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} - dev: true /@types/range-parser/1.2.4: resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} - dev: true /@types/serve-static/1.15.0: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 '@types/node': 18.13.0 - dev: true /@types/web-bluetooth/0.0.16: resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} @@ -1268,10 +1281,23 @@ packages: resolution: {integrity: sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==} dev: false + /cookie-parser/1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature/1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false + /cookie/0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie/0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -2413,6 +2439,24 @@ packages: engines: {node: '>=0.10.0'} dev: true + /pinia/2.0.34_hmuptsblhheur2tugfgucj7gc4: + resolution: {integrity: sha512-cgOoGUiyqX0SSgX8XelK9+Ri4XA2/YyNtgjogwfzIx1g7iZTaZPxm7/bZYMCLU2qHRiHhxG7SuQO0eBacFNc2Q==} + peerDependencies: + '@vue/composition-api': ^1.4.0 + typescript: '>=4.4.4' + vue: ^2.6.14 || ^3.2.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + typescript: + optional: true + dependencies: + '@vue/devtools-api': 6.5.0 + typescript: 4.9.5 + vue: 3.2.47 + vue-demi: 0.14.0_vue@3.2.47 + dev: false + /postcss-import/14.1.0_postcss@8.4.21: resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} engines: {node: '>=10.0.0'} @@ -2783,6 +2827,11 @@ packages: mime-types: 2.1.35 dev: false + /typescript/4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + /ufo/1.0.1: resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==} dev: false diff --git a/schema.prisma b/schema.prisma index 290faf6..76d1e2b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -14,7 +14,7 @@ model Game { actions GameAction[] } -model Player { +model User { id String @id @default(cuid()) name String token String @unique @@ -27,10 +27,10 @@ model GameAction { index Int gameId String - game Game @relation(references: [id], fields: [gameId]) + game Game @relation(references: [id], fields: [gameId], onDelete: Cascade) - playerId String? // null → the server - player Player? @relation(references: [id], fields: [playerId]) + playerId String? // null → the server or a deleted user + player User? @relation(references: [id], fields: [playerId], onDelete: SetNull) data String diff --git a/src/App.vue b/src/App.vue index d6c6346..529a6d1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,15 @@ @@ -33,6 +39,7 @@ width: 100vw; overflow-x: hidden; font-size: 16px; + user-select: none; } .fade-enter-active, @@ -60,5 +67,24 @@ diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..da067c7 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,12 @@ +import { defineStore } from "pinia" +import { computed, ref } from "vue" + +export const useAuth = defineStore("auth", () => { + const authenticatedUser = ref<{ id: string; name: string } | null>(null) + const requiredUser = computed(() => authenticatedUser.value!) + + return { + authenticatedUser, + requiredUser + } +}) \ No newline at end of file diff --git a/src/clientGame.ts b/src/clientGame.ts new file mode 100644 index 0000000..def0b49 --- /dev/null +++ b/src/clientGame.ts @@ -0,0 +1,47 @@ +import { defineStore } from "pinia" +import { EventBusKey, useEventBus } from "@vueuse/core" +import type { GameAction } from "./shared/game/gameActions" +import { reactive, readonly, ref } from "vue" +import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state" +import { trpcClient } from "./trpc" + +const gameActionsBusKey = Symbol() as EventBusKey +const useGameActionsBus = () => useEventBus(gameActionsBusKey) + +export const useGameActionNotification = (listener: (action: GameAction) => unknown) => { + const bus = useGameActionsBus() + bus.on(listener) + + return { + stop: () => bus.off(listener) + } +} + +export const useGame = defineStore("game", () => { + const isActive = ref(false) + const state = ref(getUninitializedGameState()) + const actions = reactive([]) + + const actionsBus = useGameActionsBus() + actionsBus.on(action => { + actions.push(action) + state.value = produceNewState(state.value, action) + console.log(`⏩ ${action.type}`, action) + }) + + return { + isActive: readonly(isActive), + state: readonly(state), + actions: readonly(actions), + join(code: string) { + trpcClient.join.subscribe({ code: "game" }, { + onData: actionsBus.emit + }) + + isActive.value = true + }, + start: () => trpcClient.game.start.mutate(), + hit: () => trpcClient.game.hit.mutate(), + stay: () => trpcClient.game.stay.mutate() + } +}) \ No newline at end of file diff --git a/src/components/BigButton.vue b/src/components/BigButton.vue new file mode 100644 index 0000000..add8d63 --- /dev/null +++ b/src/components/BigButton.vue @@ -0,0 +1,60 @@ + + + + + \ No newline at end of file diff --git a/src/components/Card.vue b/src/components/Card.vue new file mode 100644 index 0000000..6cda4f7 --- /dev/null +++ b/src/components/Card.vue @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/src/components/Game.vue b/src/components/Game.vue new file mode 100644 index 0000000..9567b05 --- /dev/null +++ b/src/components/Game.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/src/components/GameEndModal.vue b/src/components/GameEndModal.vue new file mode 100644 index 0000000..5e13bea --- /dev/null +++ b/src/components/GameEndModal.vue @@ -0,0 +1,51 @@ + + + + + \ No newline at end of file diff --git a/src/components/Modal.vue b/src/components/Modal.vue new file mode 100644 index 0000000..c51972f --- /dev/null +++ b/src/components/Modal.vue @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/src/components/NumberCard.vue b/src/components/NumberCard.vue new file mode 100644 index 0000000..342d0b0 --- /dev/null +++ b/src/components/NumberCard.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/src/components/PlayerCards.vue b/src/components/PlayerCards.vue new file mode 100644 index 0000000..f33a7f8 --- /dev/null +++ b/src/components/PlayerCards.vue @@ -0,0 +1,50 @@ + + + + + \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index caa15ac..acccf10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,18 +3,14 @@ import { createApp } from "vue" import { createRouter, createWebHistory } from "vue-router" import App from "./App.vue" import routes from "virtual:generated-pages" +import { createPinia } from "pinia" const router = createRouter({ - routes: [ - ...routes, - { - path: "/", - redirect: "/d" - } - ], + routes, history: createWebHistory() }) createApp(App) .use(router) + .use(createPinia()) .mount("#app") diff --git a/src/server/activeGame.ts b/src/server/activeGame.ts new file mode 100644 index 0000000..561a825 --- /dev/null +++ b/src/server/activeGame.ts @@ -0,0 +1,193 @@ +import EventEmitter from "eventemitter3" +import type { GameAction } from "../shared/game/gameActions" +import { prismaClient } from "./prisma" +import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state" +import type { RemoveKey } from "../shared/RemoveKey" +import { mapValues, random } from "lodash-es" +import type { DeepReadonly } from "vue" + +const activeGamesByCode = new Map() + +export function getActiveGameByCode(code: string): ActiveGame | null { + return activeGamesByCode.get(code) ?? null +} + +export function getActiveGameOfPlayer(userId: string): ActiveGame | null { + for (const game of activeGamesByCode.values()) { + if (game.lobbyPlayerIds.has(userId)) return game + } + + return null +} + +const NO_REDACTION = Symbol() + +function redactGameAction(action: T, receiverId: string): T | typeof NO_REDACTION { + switch (action.type) { + case "deal-number": + if (action.isCovert) { + if (action.toPlayerId === receiverId) return action + + return { + ...action, + number: 0 + } + } + } + + return NO_REDACTION +} + +interface Events { + broadcast_action: [GameAction] + private_action: [string, GameAction] + destroyed: [] +} + +export class ActiveGame extends EventEmitter { + private nextIndex = 0 + + /** + * @deprecated + */ + private _state = getUninitializedGameState() + get state(): DeepReadonly { + return this._state + } + + lobbyPlayerIds = new Set() + + constructor(public id: string) { + super() + } + + async start() { + const players = await prismaClient.user.findMany({ + where: { + id: { + in: [...this.lobbyPlayerIds] + } + }, + select: { + id: true, + name: true + } + }) + + await this.addAction({ + type: "start", + targetSum: 21, + players + }) + + for (const player of players) { + await this.dealNumberTo(player.id, true) + } + } + + async hit() { + const playerId = this.state.activePlayerId + + await this.addAction({ + type: "hit", + initiatingPlayerId: playerId + }) + + await this.dealNumberTo(playerId, false) + } + + async stay() { + const playerId = this.state.activePlayerId + + await this.addAction({ + type: "stay", + initiatingPlayerId: playerId + }) + + if (this.state.players.every(p => p.stayed)) { + await this.end() + } + } + + async end() { + let closestSafeValue = 0 + let closestSafePlayerIds: string[] = [] + let closestBustValue = Number.POSITIVE_INFINITY + let closestBustPlayerIds: string[] = [] + + for (const player of this.state.players) { + const sum = getNumberCardsSum(player.numberCards) + if (sum <= this.state.targetSum) { + if (sum >= closestSafeValue) { + if (sum > closestSafeValue) closestSafePlayerIds = [] + closestSafeValue = sum + closestSafePlayerIds.push(player.id) + } + } else { + if (sum <= closestBustValue) { + if (sum < closestBustValue) closestBustPlayerIds = [] + closestBustValue = sum + closestBustPlayerIds.push(player.id) + } + } + } + + const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds + + await this.addAction({ + type: "end", + winnerIds, + cardsByPlayerId: Object.fromEntries(this.state.players.map(player => [player.id, player.numberCards.map(c => c.number)])) + }) + } + + private async dealNumberTo(playerId: string, isCovert: boolean) { + const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)] + + await this.addAction({ + type: "deal-number", + number, + toPlayerId: playerId, + isCovert + }) + } + + private async addAction>(action: T): Promise { + const fullAction = { + ...action, + index: this.nextIndex++ + } as T & { index: number } + + await prismaClient.gameAction.create({ + data: { + gameId: this.id, + playerId: action.initiatingPlayerId, + index: fullAction.index, + data: JSON.stringify(fullAction) + } + }) + + // noinspection JSDeprecatedSymbols + this._state = produceNewState(this._state, fullAction) + + for (const player of this.state.players) { + const redactedAction = redactGameAction(fullAction, player.id) + + if (redactedAction === NO_REDACTION) { + this.emit("broadcast_action", fullAction) + break + } else { + this.emit("private_action", player.id, redactedAction) + } + } + + return fullAction + } + + private destroy() { + activeGamesByCode.delete(this.id) + this.emit("destroyed") + } +} + +activeGamesByCode.set("game", new ActiveGame("game")) \ No newline at end of file diff --git a/src/server/game.ts b/src/server/game.ts deleted file mode 100644 index 60cf8ca..0000000 --- a/src/server/game.ts +++ /dev/null @@ -1,29 +0,0 @@ -import EventEmitter from "eventemitter3" -import type { GameAction } from "../shared/game/gameActions" - -const gamesById = new Map() - -export function getGameById(id: string): Game { - return gamesById.get(id)! -} - -interface Events { - 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/isDev.ts b/src/server/isDev.ts new file mode 100644 index 0000000..38bb893 --- /dev/null +++ b/src/server/isDev.ts @@ -0,0 +1 @@ +export const isDev = process.env.NODE_ENV === "development" \ No newline at end of file diff --git a/src/server/main.ts b/src/server/main.ts index a8c1a05..c989d89 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -6,26 +6,41 @@ import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/ad import { applyWSSHandler } from "@trpc/server/adapters/ws" import { seedDatabase } from "./seed" import { createContext } from "./trpc/base" +import cookieParser from "cookie-parser" +import { parse as parseCookie } from "cookie" +import { isDev } from "./isDev" const expressApp = createExpressApp() +expressApp.use(cookieParser()) expressApp.use("/trpc", createTrpcMiddleware({ router: appRouter, - createContext: ({ req }) => createContext(req.headers.authorization) + createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res), })) await seedDatabase() -const { server } = await listen(expressApp) +const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false }) const wss = new WebSocketServer({ server, path: "/ws" }) const wssTrpcHandler = applyWSSHandler({ wss, router: appRouter, - createContext: ({ req }) => createContext(req.headers.authorization) + createContext: ({ req }) => { + const cookies = parseCookie(req.headers.cookie ?? "") + return createContext(cookies.token ?? null, undefined) + } }) -process.on("SIGTERM", () => { - console.log("Received SIGTERM") +const stop = () => { + console.log("Received stop signal") + server.close() wssTrpcHandler.broadcastReconnectNotification() - wss.close() -}) + wss.close(console.error) +} + +process.on("SIGTERM", stop) +process.on("SIGINT", stop) + +process.on("exit", () => { + console.log("exit") +}) \ No newline at end of file diff --git a/src/server/seed.ts b/src/server/seed.ts index aa2e1e6..4df2b97 100644 --- a/src/server/seed.ts +++ b/src/server/seed.ts @@ -1,28 +1,27 @@ import { prismaClient } from "./prisma" export async function seedDatabase() { - if (await prismaClient.player.count() === 0) { - await prismaClient.player.create({ + if (await prismaClient.user.count() === 0) { + await prismaClient.user.create({ data: { - name: "max", - token: "max" + name: "Guest 1", + token: "guest1" } }) - await prismaClient.player.create({ + await prismaClient.user.create({ data: { - name: "moritz", - token: "moritz" + name: "Guest 2", + token: "guest2" } }) } - if (await prismaClient.game.count() === 0) { - await prismaClient.game.create({ - data: { - id: "game", - lobbyCodeIfActive: "game" - } - }) - } + await prismaClient.game.deleteMany({}) + 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 8a469b5..bfba465 100644 --- a/src/server/trpc/base.ts +++ b/src/server/trpc/base.ts @@ -1,22 +1,36 @@ -import { initTRPC } from "@trpc/server" +import { createInputMiddleware, initTRPC, TRPCError } from "@trpc/server" import { prismaClient } from "../prisma" +import type { Response } from "express" export interface Context { - playerId: string + userId: string | null + res?: Response } -export async function createContext(authorizationHeader: string | undefined): Promise { - const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters +export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise { + let userId = null - if (token !== null && token.length > 0) { - const player = await prismaClient.player.findUnique({ where: { token }, select: { id: true } }) - - if (player !== null) return { - playerId: player.id - } + if (authenticationToken !== null && authenticationToken.length > 0) { + const user = await prismaClient.user.findUnique({ where: { token: authenticationToken }, select: { id: true } }) + if (user !== null) userId = user.id } - throw new Error("Invalid token") + return { + userId, + res + } } export const t = initTRPC.context().create() + +export const requireAuthentication = t.middleware(({ ctx, next }) => { + let userId = ctx.userId + if (userId === null) throw new TRPCError({ code: "UNAUTHORIZED" }) + + return next({ + ctx: { + ...ctx, + userId + } + }) +}) \ No newline at end of file diff --git a/src/server/trpc/game.ts b/src/server/trpc/game.ts new file mode 100644 index 0000000..fb087ca --- /dev/null +++ b/src/server/trpc/game.ts @@ -0,0 +1,39 @@ +import { requireAuthentication, t } from "./base" +import { getActiveGameOfPlayer } from "../activeGame" +import { getNumberCardsSum } from "../../shared/game/state" + +const gameProcedure = t.procedure + .use(requireAuthentication) + .use(t.middleware(async ({ ctx, next }) => { + const game = getActiveGameOfPlayer(ctx.userId!) + + if (game === null) throw new Error("The player is not in an active game") + return await next({ + ctx: { + game + } + }) + })) + +export const gameRouter = t.router({ + start: gameProcedure + .mutation(async ({ ctx }) => { + if (ctx.game.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${ctx.game.state.phase}`) + ctx.game.start() + }), + + hit: gameProcedure + .mutation(async ({ ctx }) => { + if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the player’s turn") + if (getNumberCardsSum(ctx.game.state.players.find(p => p.id === ctx.userId)!.numberCards) > ctx.game.state.targetSum) + throw new Error("The player cannot hit when bust") + + await ctx.game.hit() + }), + + stay: gameProcedure + .mutation(async ({ ctx }) => { + if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the player’s turn") + await ctx.game.stay() + }) +}) \ No newline at end of file diff --git a/src/server/trpc/index.ts b/src/server/trpc/index.ts index 92bb5a0..1bc2598 100644 --- a/src/server/trpc/index.ts +++ b/src/server/trpc/index.ts @@ -1,35 +1,62 @@ -import { t } from "./base" +import { requireAuthentication, t } from "./base" import { prismaClient } from "../prisma" import z from "zod" import { observable } from "@trpc/server/observable" import type { GameAction } from "../../shared/game/gameActions" -import { getGameById } from "../game" +import { isDev } from "../isDev" +import { getActiveGameByCode } from "../activeGame" +import { gameRouter } from "./game" + +let lastGuestWasOne = false export const appRouter = t.router({ - start: t.procedure - .mutation(async ({ ctx }) => { + game: gameRouter, + loginAsGuest: t.procedure + .mutation(async ({ ctx }) => { + let token = lastGuestWasOne ? "guest1" : "guest2" + lastGuestWasOne = !lastGuestWasOne + ctx.res!.cookie("token", token, { + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + secure: !isDev, + sameSite: "strict" + }) + }), + + getSelf: t.procedure + .query(async ({ ctx }) => { + if (ctx.userId === null) return { user: null } + + return { + user: await prismaClient.user.findUnique({ where: { id: ctx.userId } }) + } }), join: t.procedure + .use(requireAuthentication) .input(z.object({ - gameId: z.string().nonempty() + code: z.string().nonempty() })) .subscription(({ input, ctx }) => { - const game = getGameById(input.gameId) + const game = getActiveGameByCode(input.code) + if (game === null) throw new Error("There is no game with this code") + + game.lobbyPlayerIds.add(ctx.userId) - return observable(emit => { - const handlePublicAction = (action: GameAction) => emit.next(action) + return observable(emit => { + const handleBroadcastAction = (action: GameAction) => emit.next(action) const handlePrivateAction = (playerId: string, action: GameAction) => { - if (playerId === ctx.playerId) emit.next(action) + if (playerId === ctx.userId) emit.next(action) } - game.on("public_action", handlePublicAction) + game.on("broadcast_action", handleBroadcastAction) game.on("private_action", handlePrivateAction) return () => { - game.off("public_action", handlePublicAction) + game.lobbyPlayerIds.delete(ctx.userId) + game.off("broadcast_action", handleBroadcastAction) game.off("private_action", handlePrivateAction) } }) diff --git a/src/shared/RemoveKey.ts b/src/shared/RemoveKey.ts new file mode 100644 index 0000000..67f125d --- /dev/null +++ b/src/shared/RemoveKey.ts @@ -0,0 +1,5 @@ +// I don’t understand why, but Omit (built-in) breaks discriminated unions. This type does not. +// See https://github.com/microsoft/TypeScript/issues/31501#issuecomment-1079728677 +export type RemoveKey = { + [P in keyof T as Exclude]: T[P]; +} \ No newline at end of file diff --git a/src/shared/game/cards.ts b/src/shared/game/cards.ts index 97c4db0..e318a62 100644 --- a/src/shared/game/cards.ts +++ b/src/shared/game/cards.ts @@ -5,7 +5,9 @@ const specialCardTypesObject = { "increase-target-by-2": true, "next-round-covert": true, "double-draw": true, - "add-2-opponent": true + "add-2-opponent": true, + "get-1": true, + "get-11": true } export const specialCardTypes = Object.keys(specialCardTypesObject) diff --git a/src/shared/game/gameActions.ts b/src/shared/game/gameActions.ts index d49d68b..0e45aed 100644 --- a/src/shared/game/gameActions.ts +++ b/src/shared/game/gameActions.ts @@ -4,7 +4,7 @@ type PlayerAction = { initiatingPlayerId: string } & ( | { - type: "draw" + type: "hit" } | { type: "stay" @@ -15,22 +15,35 @@ type PlayerAction = { } ) -type ServerAction = +type ServerAction = { + initiatingPlayerId?: never +} & ( | { type: "start" - numberCardsByPlayerId: Record // if redacted: only contains the player themself targetSum: number + players: Array<{ + id: string + name: string + }> } | { type: "deal-number" toPlayerId: string number: number + isCovert: boolean } | { type: "deal-special" toPlayerId: string cardType: SpecialCardType } + | { + type: "end" + winnerIds: string[] + cardsByPlayerId: Record // without redactions + } +) + export type GameAction = { index: number diff --git a/src/shared/game/state.ts b/src/shared/game/state.ts index 0ca355d..3b0cd4a 100644 --- a/src/shared/game/state.ts +++ b/src/shared/game/state.ts @@ -2,53 +2,94 @@ import type { SpecialCardType } from "./cards" import type { GameAction } from "./gameActions" import { produce } from "immer" import { specialCardTypes } from "./cards" +import { cloneDeep, tail, without } from "lodash-es" type SpecialCardCountByType = Record +export interface GameStateNumberCard { + number: number + isCovert: boolean +} + +export const getNumberCardsSum = (cards: Readonly) => cards.reduce((acc, card) => acc + card.number, 0) + export interface GameStatePlayer { id: string - numberCards: number[] + name: string + numberCards: GameStateNumberCard[] specialCardCountByType: SpecialCardCountByType stayed: boolean nextRoundCovert: boolean } export interface GameState { + phase: "pre-start" | "running" | "end" players: GameStatePlayer[] activeSpecialCardCountByType: SpecialCardCountByType activePlayerId: string targetSum: number + numberCardsStack: number[] // if redacted: contains more cards than there actually are + actualNumberCardsStackSize: number // the length of numberCardsStack, smaller if redacted + winnerIds: string[] | null } const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType const UNINITIALIZED_GAME_STATE: GameState = { + phase: "pre-start", players: [], activePlayerId: "", targetSum: 0, - activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS + activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS, + numberCardsStack: [], + actualNumberCardsStackSize: 0, + winnerIds: null } +export const getUninitializedGameState = () => cloneDeep(UNINITIALIZED_GAME_STATE) +export const getFullNumbersCardStack = () => tail([...Array(12).keys()]) + export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => { + const activateNextPlayer = () => { + const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId) + state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id + } + switch (action.type) { case "start": - state.players = Object.entries(action.numberCardsByPlayerId).map(([playerId, numberCards]): GameStatePlayer => ({ - id: playerId, - numberCards, + state.players = action.players.map((player): GameStatePlayer => ({ + id: player.id, + name: player.name, + numberCards: [], specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS, stayed: false, nextRoundCovert: false })) + state.phase = "running" state.activePlayerId = state.players[0].id state.targetSum = action.targetSum state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS + state.numberCardsStack = getFullNumbersCardStack() + state.actualNumberCardsStackSize = state.numberCardsStack.length break case "deal-number": const p1 = state.players.find(p => p.id === action.toPlayerId)! - p1.numberCards.push(action.number) + p1.numberCards.push({ + number: action.number, + isCovert: action.isCovert + }) + + state.numberCardsStack = without(state.numberCardsStack, action.number) + state.actualNumberCardsStackSize-- + + if (state.actualNumberCardsStackSize === 0) { + state.numberCardsStack = getFullNumbersCardStack() + state.actualNumberCardsStackSize = state.numberCardsStack.length + } + break case "deal-special": @@ -56,18 +97,29 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod p2.specialCardCountByType[action.cardType]++ break - case "draw": - case "stay": - const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId) + case "hit": + activateNextPlayer() + const p4 = state.players.find(p => p.id === action.initiatingPlayerId)! + p4.stayed = false + break - // activate the next player - state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id + case "stay": + activateNextPlayer() + const p5 = state.players.find(p => p.id === action.initiatingPlayerId)! + p5.stayed = true break case "use-special": const p3 = state.players.find(p => p.id === action.initiatingPlayerId)! applySpecialCardUsage(state, action.cardType, p3) break + + case "end": + state.phase = "end" + state.winnerIds = action.winnerIds + for (let player of state.players) { + player.numberCards = action.cardsByPlayerId[player.id].map((number, index) => ({ number, isCovert: player.numberCards[index].isCovert })) + } } }) diff --git a/src/shared/util.ts b/src/shared/util.ts new file mode 100644 index 0000000..97fc8e0 --- /dev/null +++ b/src/shared/util.ts @@ -0,0 +1,6 @@ +export const naturallyJoinEnumeration = (enumeration: string[]) => { + const start = enumeration.slice(0, -1) + if (start.length === 0) return enumeration.join("") + const last = enumeration[enumeration.length - 1] + return start.join(", ") + " and " + last.toString() +} \ No newline at end of file diff --git a/src/trpc.ts b/src/trpc.ts index 4d8bbfa..fbdc124 100644 --- a/src/trpc.ts +++ b/src/trpc.ts @@ -14,10 +14,10 @@ export const trpcClient = createTRPCProxyClient({ splitLink({ condition: o => o.type === "subscription", true: wsLink({ - client: wsClient + client: wsClient, }), false: httpLink({ - url: "/trpc", + url: "/trpc" }) }) ] diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..61dfea7 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,5 @@ +declare module "*.vue" { + import { ConcreteComponent } from "vue" + const C: ConcreteComponent + export default C +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 25507b2..1360c31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "stripInternal": true, "target": "esnext", "types": [ + "src/types.d.ts", "vite/client", "unplugin-icons/types/vue", "vite-plugin-pages/client" diff --git a/windi.config.ts b/windi.config.ts index 9737898..d156fe8 100644 --- a/windi.config.ts +++ b/windi.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ theme: { fontFamily: { "normal": ["InterVariable", "sans-serif"], - "fat": ["TitanOne", "sans-serif"] + "fat": ["Titan One", "sans-serif"] }, colors: { transparent: "transparent",