This commit is contained in:
Moritz Ruth 2023-04-22 20:48:06 +02:00
parent 14d7efe0bf
commit 8f54f121f1
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
31 changed files with 929 additions and 114 deletions

View file

@ -4,10 +4,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:ui": "vite", "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": { "devDependencies": {
"@iconify-json/ph": "^1.1.5", "@iconify-json/ph": "^1.1.5",
"@types/cookie": "^0.5.1",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/lodash-es": "^4.17.7", "@types/lodash-es": "^4.17.7",
"@types/node": "18", "@types/node": "18",
@ -16,6 +17,7 @@
"prisma": "^4.13.0", "prisma": "^4.13.0",
"sass": "^1.62.0", "sass": "^1.62.0",
"tsx": "^3.12.6", "tsx": "^3.12.6",
"typescript": "^4.9.5",
"unplugin-icons": "^0.16.1", "unplugin-icons": "^0.16.1",
"vite": "^4.2.2", "vite": "^4.2.2",
"vite-plugin-pages": "^0.29.0", "vite-plugin-pages": "^0.29.0",
@ -29,15 +31,19 @@
"@prisma/client": "^4.13.0", "@prisma/client": "^4.13.0",
"@trpc/client": "^10.20.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", "@vueuse/core": "^10.0.2",
"@vueuse/integrations": "^10.0.2", "@vueuse/integrations": "^10.0.2",
"bufferutil": "^4.0.7", "bufferutil": "^4.0.7",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.6",
"eventemitter3": "^5.0.0", "eventemitter3": "^5.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"immer": "^10.0.1", "immer": "^10.0.1",
"listhen": "^1.0.4", "listhen": "^1.0.4",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"pinia": "^2.0.34",
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",
"ws": "^8.13.0", "ws": "^8.13.0",

65
pnpm-lock.yaml generated
View file

@ -11,6 +11,8 @@ importers:
'@prisma/client': ^4.13.0 '@prisma/client': ^4.13.0
'@trpc/client': ^10.20.0 '@trpc/client': ^10.20.0
'@trpc/server': ^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/express': ^4.17.17
'@types/lodash-es': ^4.17.7 '@types/lodash-es': ^4.17.7
'@types/node': '18' '@types/node': '18'
@ -19,15 +21,19 @@ importers:
'@vueuse/core': ^10.0.2 '@vueuse/core': ^10.0.2
'@vueuse/integrations': ^10.0.2 '@vueuse/integrations': ^10.0.2
bufferutil: ^4.0.7 bufferutil: ^4.0.7
cookie: ^0.5.0
cookie-parser: ^1.4.6
eventemitter3: ^5.0.0 eventemitter3: ^5.0.0
express: ^4.18.2 express: ^4.18.2
immer: ^10.0.1 immer: ^10.0.1
listhen: ^1.0.4 listhen: ^1.0.4
lodash-es: ^4.17.21 lodash-es: ^4.17.21
nanoid: ^4.0.2 nanoid: ^4.0.2
pinia: ^2.0.34
prisma: ^4.13.0 prisma: ^4.13.0
sass: ^1.62.0 sass: ^1.62.0
tsx: ^3.12.6 tsx: ^3.12.6
typescript: ^4.9.5
unplugin-icons: ^0.16.1 unplugin-icons: ^0.16.1
vite: ^4.2.2 vite: ^4.2.2
vite-plugin-pages: ^0.29.0 vite-plugin-pages: ^0.29.0
@ -44,21 +50,26 @@ importers:
'@prisma/client': 4.13.0_prisma@4.13.0 '@prisma/client': 4.13.0_prisma@4.13.0
'@trpc/client': 10.20.0_@trpc+server@10.20.0 '@trpc/client': 10.20.0_@trpc+server@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/core': 10.0.2_vue@3.2.47
'@vueuse/integrations': 10.0.2_vue@3.2.47 '@vueuse/integrations': 10.0.2_vue@3.2.47
bufferutil: 4.0.7 bufferutil: 4.0.7
cookie: 0.5.0
cookie-parser: 1.4.6
eventemitter3: 5.0.0 eventemitter3: 5.0.0
express: 4.18.2 express: 4.18.2
immer: 10.0.1 immer: 10.0.1
listhen: 1.0.4 listhen: 1.0.4
lodash-es: 4.17.21 lodash-es: 4.17.21
nanoid: 4.0.2 nanoid: 4.0.2
pinia: 2.0.34_hmuptsblhheur2tugfgucj7gc4
vue: 3.2.47 vue: 3.2.47
vue-router: 4.1.6_vue@3.2.47 vue-router: 4.1.6_vue@3.2.47
ws: 8.13.0_bufferutil@4.0.7 ws: 8.13.0_bufferutil@4.0.7
zod: 3.21.4 zod: 3.21.4
devDependencies: devDependencies:
'@iconify-json/ph': 1.1.5 '@iconify-json/ph': 1.1.5
'@types/cookie': 0.5.1
'@types/express': 4.17.17 '@types/express': 4.17.17
'@types/lodash-es': 4.17.7 '@types/lodash-es': 4.17.7
'@types/node': 18.13.0 '@types/node': 18.13.0
@ -67,6 +78,7 @@ importers:
prisma: 4.13.0 prisma: 4.13.0
sass: 1.62.0 sass: 1.62.0
tsx: 3.12.6 tsx: 3.12.6
typescript: 4.9.5
unplugin-icons: 0.16.1 unplugin-icons: 0.16.1
vite: 4.2.2_3wfpih6r6pq57j5negv3imilwi vite: 4.2.2_3wfpih6r6pq57j5negv3imilwi
vite-plugin-pages: 0.29.0_vite@4.2.2 vite-plugin-pages: 0.29.0_vite@4.2.2
@ -750,12 +762,20 @@ packages:
dependencies: dependencies:
'@types/connect': 3.4.35 '@types/connect': 3.4.35
'@types/node': 18.13.0 '@types/node': 18.13.0
dev: true
/@types/connect/3.4.35: /@types/connect/3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies: dependencies:
'@types/node': 18.13.0 '@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 dev: true
/@types/debug/4.1.7: /@types/debug/4.1.7:
@ -770,7 +790,6 @@ packages:
'@types/node': 18.13.0 '@types/node': 18.13.0
'@types/qs': 6.9.7 '@types/qs': 6.9.7
'@types/range-parser': 1.2.4 '@types/range-parser': 1.2.4
dev: true
/@types/express/4.17.17: /@types/express/4.17.17:
resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==}
@ -779,7 +798,6 @@ packages:
'@types/express-serve-static-core': 4.17.33 '@types/express-serve-static-core': 4.17.33
'@types/qs': 6.9.7 '@types/qs': 6.9.7
'@types/serve-static': 1.15.0 '@types/serve-static': 1.15.0
dev: true
/@types/lodash-es/4.17.6: /@types/lodash-es/4.17.6:
resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==} resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==}
@ -799,7 +817,6 @@ packages:
/@types/mime/3.0.1: /@types/mime/3.0.1:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: true
/@types/ms/0.7.31: /@types/ms/0.7.31:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
@ -807,22 +824,18 @@ packages:
/@types/node/18.13.0: /@types/node/18.13.0:
resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==} resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==}
dev: true
/@types/qs/6.9.7: /@types/qs/6.9.7:
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
dev: true
/@types/range-parser/1.2.4: /@types/range-parser/1.2.4:
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
dev: true
/@types/serve-static/1.15.0: /@types/serve-static/1.15.0:
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies: dependencies:
'@types/mime': 3.0.1 '@types/mime': 3.0.1
'@types/node': 18.13.0 '@types/node': 18.13.0
dev: true
/@types/web-bluetooth/0.0.16: /@types/web-bluetooth/0.0.16:
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
@ -1268,10 +1281,23 @@ packages:
resolution: {integrity: sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==} resolution: {integrity: sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==}
dev: false 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: /cookie-signature/1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: false 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: /cookie/0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -2413,6 +2439,24 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true 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: /postcss-import/14.1.0_postcss@8.4.21:
resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -2783,6 +2827,11 @@ packages:
mime-types: 2.1.35 mime-types: 2.1.35
dev: false dev: false
/typescript/4.9.5:
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
engines: {node: '>=4.2.0'}
hasBin: true
/ufo/1.0.1: /ufo/1.0.1:
resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==} resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==}
dev: false dev: false

View file

@ -14,7 +14,7 @@ model Game {
actions GameAction[] actions GameAction[]
} }
model Player { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
token String @unique token String @unique
@ -27,10 +27,10 @@ model GameAction {
index Int index Int
gameId String gameId String
game Game @relation(references: [id], fields: [gameId]) game Game @relation(references: [id], fields: [gameId], onDelete: Cascade)
playerId String? // null → the server playerId String? // null → the server or a deleted user
player Player? @relation(references: [id], fields: [playerId]) player User? @relation(references: [id], fields: [playerId], onDelete: SetNull)
data String data String

View file

@ -1,9 +1,15 @@
<template> <template>
<div class="bg-gray-900 h-100vh w-100vw"> <div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10">
<div :class="$style.noise"/> <div :class="$style.noise"/>
<div :class="$style.vignette"/> <div :class="$style.vignette"/>
<div class="relative z-1"> <div class="relative max-w-1200px mx-auto">
<slot/> <button v-if="!game.isActive" @click="game.join('')">
Join
</button>
<button v-else-if="game.state.phase === 'pre-start'" @click="game.start()">
Start
</button>
<Game v-else/>
</div> </div>
</div> </div>
</template> </template>
@ -33,6 +39,7 @@
width: 100vw; width: 100vw;
overflow-x: hidden; overflow-x: hidden;
font-size: 16px; font-size: 16px;
user-select: none;
} }
.fade-enter-active, .fade-enter-active,
@ -60,5 +67,24 @@
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { trpcClient } from "./trpc"
import { ref } from "vue"
import { useGame } from "./clientGame"
import Game from "./components/Game.vue"
import { useAuth } from "./auth"
const isLoading = ref(true)
const auth = useAuth()
trpcClient.getSelf.query()
.then(({ user }) => {
if (user === null) return trpcClient.loginAsGuest.mutate()
auth.authenticatedUser = user
return Promise.resolve()
})
.then(() => {
isLoading.value = false
})
const game = useGame()
</script> </script>

12
src/auth.ts Normal file
View file

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

47
src/clientGame.ts Normal file
View file

@ -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<GameAction>
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<GameState>(getUninitializedGameState())
const actions = reactive<GameAction[]>([])
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()
}
})

View file

@ -0,0 +1,60 @@
<template>
<button
class="py-5 px-15 font-fat text-5xl rounded-md shadow-lg text-center"
:class="$style.root"
:disabled="disabled"
>
<slot/>
</button>
</template>
<style module lang="scss">
.root {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url("../assets/noise.png") repeat;
opacity: 20%;
transition: 200ms ease opacity;
}
&::after {
@apply bg-gradient-to-b from-dark-600 to-dark-900;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: 500ms ease opacity;
}
&:disabled {
cursor: not-allowed;
&::after {
opacity: 70%;
}
}
&:not(:disabled):hover::before {
opacity: 60%;
}
}
</style>
<script setup lang="ts">
const props = defineProps({
disabled: Boolean
})
</script>

43
src/components/Card.vue Normal file
View file

@ -0,0 +1,43 @@
<template>
<div class="flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br w-35 h-45" :class="$style.root">
<div class="relative flex flex-col items-center justify-center h-full">
<div class="absolute top-2 right-2 bg-dark-800 rounded-full flex gap-2 items-center px-2">
<div v-for="tag in tags" :key="tag.label" :title="tag.label">
<component :is="tag.icon" class="relative top-0.75"/>
</div>
</div>
<slot/>
</div>
</div>
</template>
<style module lang="scss">
.root {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url("../assets/noise.png") repeat;
opacity: 10%;
}
}
</style>
<script setup lang="ts">
import type { FunctionalComponent, PropType } from "vue"
const props = defineProps({
tags: {
type: Array as PropType<Array<{
label: string
icon: FunctionalComponent
}>>,
default: []
}
})
</script>

55
src/components/Game.vue Normal file
View file

@ -0,0 +1,55 @@
<template>
<div class="flex flex-col gap-14">
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
<div
class="flex gap-5 justify-end items-center transform transition ease duration-500"
:class="game.state.phase === 'end' ? 'translate-y-4 opacity-0' : ''"
>
<div class="font-fat opacity-20 text-3xl pr-2">
<template v-if="isBust">You are bust.</template>
</div>
<BigButton
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
:disabled="!isYourTurn || isBust"
@click="game.hit()"
>
Hit
</BigButton>
<BigButton
class="bg-gradient-to-br from-blue-800 to-blue-900 uppercase"
:disabled="!isYourTurn"
@click="game.stay()"
>
Stay
</BigButton>
</div>
<GameEndModal/>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { useGame } from "../clientGame"
import PlayerCards from "./PlayerCards.vue"
import BigButton from "./BigButton.vue"
import { computed } from "vue"
import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state"
import GameEndModal from "./GameEndModal.vue"
const game = useGame()
const auth = useAuth()
const isYourTurn = computed(() => game.state.activePlayerId === auth.requiredUser.id)
const isBust = computed(() => getNumberCardsSum(game.state.players.find(p => p.id === auth.requiredUser.id)!.numberCards) > game.state.targetSum)
const reorderedPlayerIds = computed(() => {
const ids = game.state.players.map(p => p.id)
const selfIndex = ids.findIndex(id => id === auth.requiredUser.id)
let before = ids.slice(0, selfIndex)
let rest = ids.slice(selfIndex)
return [...rest, ...before]
})
</script>

View file

@ -0,0 +1,51 @@
<template>
<Modal :is-active="isActive" @close-request="isActive = false">
<div v-if="game.state.phase === 'end'" class="p-10 text-center">
<div class="h-11 transform text-10xl -translate-y-35 opacity-90">
<CrownIcon v-if="singleWinner !== null"/>
<HandshakeIcon v-else/>
</div>
<div class="font-fat text-6xl">
{{ singleWinner?.name ?? "Draw" }}
</div>
<div class="text-3xl pt-5 font-bold">
<template v-if="singleWinner !== null">
wins with
<span class="text-green-500">{{ getNumberCardsSum(singleWinner.numberCards) }}</span>
point{{ getNumberCardsSum(singleWinner.numberCards) === 1 ? "" : "s" }}.
</template>
<template>
between {{ naturallyJoinEnumeration(game.state.winnerIds.map(id => game.state.players.find(p => p.id === id).name)) }}
</template>
</div>
</div>
</Modal>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { computed, ref } from "vue"
import { useGame, useGameActionNotification } from "../clientGame"
import Modal from "./Modal.vue"
import CrownIcon from "virtual:icons/ph/crown-simple-duotone"
import HandshakeIcon from "virtual:icons/ph/handshake-duotone"
import { getNumberCardsSum } from "../shared/game/state"
import { naturallyJoinEnumeration } from "../shared/util"
const game = useGame()
const isActive = ref(false)
const singleWinner = computed(() => {
const winnerIds = game.state.winnerIds
return winnerIds?.length === 1 ? game.state.players.find(p => p.id === winnerIds[0]) : null
})
useGameActionNotification(action => {
if (action.type === "end") {
isActive.value = true
}
})
</script>

32
src/components/Modal.vue Normal file
View file

@ -0,0 +1,32 @@
<template>
<div
class="fixed top-0 left-0 w-100vw h-100vh backdrop-filter bg-black transition p-10 flex items-center justify-center"
:class="isActive ? 'bg-opacity-30 backdrop-blur-5' : 'pointer-events-none bg-opacity-0'"
@click.passive.self="emit('close-request')"
>
<div
class="transform transition rounded-lg max-h-full max-w-full shadow-lg bg-dark-500"
:class="isActive ? 'opacity-100' : 'opacity-0 scale-90'"
>
<slot/>
</div>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { onKeyDown } from "@vueuse/core"
const props = defineProps<{
isActive: boolean
}>()
const emit = defineEmits(["close-request"])
onKeyDown("Escape", () => {
if (props.isActive) emit("close-request")
}, { passive: true })
</script>

View file

@ -0,0 +1,45 @@
<template>
<Card
:class="isUnknown ? 'bg-gradient-to-br from-gray-700 to-gray-800' : 'bg-gradient-to-br from-yellow-600 to-yellow-900'"
:tags="tags"
>
<div class="font-fat text-white relative bottom-1 text-shadow-xl" :class="[isUnknown ? 'text-8xl' : 'text-7xl']">
{{ isUnknown ? "?" : number }}
</div>
</Card>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import Card from "./Card.vue"
import { computed, FunctionalComponent } from "vue"
import EyeSlashIcon from "virtual:icons/ph/eye-slash"
import { useGame } from "../clientGame"
const props = defineProps<{
number: number
isCovert: boolean
isOwn: boolean
}>()
const game = useGame()
const isUnknown = computed(() => props.number === 0)
const tags = computed(() => {
const tags: Array<{ icon: FunctionalComponent; label: string }> = []
if (props.isCovert && !isUnknown.value) {
tags.push({
icon: EyeSlashIcon,
label: game.state.phase === 'end' && !props.isOwn
? "You couldnt see the number on this card during the game"
: "The other players cant see the number on this card"
})
}
return tags
})
</script>

View file

@ -0,0 +1,50 @@
<template>
<div class="flex flex-col gap-4">
<div class="font-fat text-2xl relative">
{{ playerState.name }}
<template v-if="isYou">(You)</template>
<span
class="text-3xl absolute -top-0.5 -left-13 transition"
:class="game.state.winnerIds?.includes(playerState.id) ? '' : 'opacity-0'"
>
<CrownIcon/>
</span>
</div>
<div class="flex items-center gap-5 w-full flex-wrap">
<NumberCard
v-for="(card, index) in playerState.numberCards"
:key="[index, card.number]"
:number="card.number"
:is-covert="card.isCovert"
:is-own="isYou"
/>
</div>
<div class="font-fat opacity-30 text-3xl">
{{ getNumberCardsSum(playerState.numberCards) }}
<template v-if="!isYou && game.state.phase !== 'end' && playerState.numberCards.some(c => c.isCovert)">+ ?</template>
/ {{ game.state.targetSum }}
</div>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { useGame } from "../clientGame"
import { computed } from "vue"
import NumberCard from "./NumberCard.vue"
import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state"
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
const props = defineProps<{
playerId: string
}>()
const game = useGame()
const auth = useAuth()
const playerState = computed(() => game.state.players.find(p => p.id === props.playerId)!)
const isYou = computed(() => playerState.value.id === auth.requiredUser.id)
</script>

View file

@ -3,18 +3,14 @@ import { createApp } from "vue"
import { createRouter, createWebHistory } from "vue-router" import { createRouter, createWebHistory } from "vue-router"
import App from "./App.vue" import App from "./App.vue"
import routes from "virtual:generated-pages" import routes from "virtual:generated-pages"
import { createPinia } from "pinia"
const router = createRouter({ const router = createRouter({
routes: [ routes,
...routes,
{
path: "/",
redirect: "/d"
}
],
history: createWebHistory() history: createWebHistory()
}) })
createApp(App) createApp(App)
.use(router) .use(router)
.use(createPinia())
.mount("#app") .mount("#app")

193
src/server/activeGame.ts Normal file
View file

@ -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<string, ActiveGame>()
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<T extends GameAction>(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<Events> {
private nextIndex = 0
/**
* @deprecated
*/
private _state = getUninitializedGameState()
get state(): DeepReadonly<GameState> {
return this._state
}
lobbyPlayerIds = new Set<string>()
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<T extends RemoveKey<GameAction, "index">>(action: T): Promise<T & { index: number }> {
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"))

View file

@ -1,29 +0,0 @@
import EventEmitter from "eventemitter3"
import type { GameAction } from "../shared/game/gameActions"
const gamesById = new Map<string, Game>()
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<Events> {
constructor(public id: string) {
super()
}
start() {
}
private destroy() {
gamesById.delete(this.id)
this.emit("destroyed")
}
}

1
src/server/isDev.ts Normal file
View file

@ -0,0 +1 @@
export const isDev = process.env.NODE_ENV === "development"

View file

@ -6,26 +6,41 @@ import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/ad
import { applyWSSHandler } from "@trpc/server/adapters/ws" import { applyWSSHandler } from "@trpc/server/adapters/ws"
import { seedDatabase } from "./seed" import { seedDatabase } from "./seed"
import { createContext } from "./trpc/base" import { createContext } from "./trpc/base"
import cookieParser from "cookie-parser"
import { parse as parseCookie } from "cookie"
import { isDev } from "./isDev"
const expressApp = createExpressApp() const expressApp = createExpressApp()
expressApp.use(cookieParser())
expressApp.use("/trpc", createTrpcMiddleware({ expressApp.use("/trpc", createTrpcMiddleware({
router: appRouter, router: appRouter,
createContext: ({ req }) => createContext(req.headers.authorization) createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res),
})) }))
await seedDatabase() await seedDatabase()
const { server } = await listen(expressApp) const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
const wss = new WebSocketServer({ server, path: "/ws" }) const wss = new WebSocketServer({ server, path: "/ws" })
const wssTrpcHandler = applyWSSHandler({ const wssTrpcHandler = applyWSSHandler({
wss, wss,
router: appRouter, 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", () => { const stop = () => {
console.log("Received SIGTERM") console.log("Received stop signal")
server.close()
wssTrpcHandler.broadcastReconnectNotification() wssTrpcHandler.broadcastReconnectNotification()
wss.close() wss.close(console.error)
}) }
process.on("SIGTERM", stop)
process.on("SIGINT", stop)
process.on("exit", () => {
console.log("exit")
})

View file

@ -1,28 +1,27 @@
import { prismaClient } from "./prisma" import { prismaClient } from "./prisma"
export async function seedDatabase() { export async function seedDatabase() {
if (await prismaClient.player.count() === 0) { if (await prismaClient.user.count() === 0) {
await prismaClient.player.create({ await prismaClient.user.create({
data: { data: {
name: "max", name: "Guest 1",
token: "max" token: "guest1"
} }
}) })
await prismaClient.player.create({ await prismaClient.user.create({
data: { data: {
name: "moritz", name: "Guest 2",
token: "moritz" token: "guest2"
} }
}) })
} }
if (await prismaClient.game.count() === 0) { await prismaClient.game.deleteMany({})
await prismaClient.game.create({ await prismaClient.game.create({
data: { data: {
id: "game", id: "game",
lobbyCodeIfActive: "game" lobbyCodeIfActive: "game"
} }
}) })
}
} }

View file

@ -1,22 +1,36 @@
import { initTRPC } from "@trpc/server" import { createInputMiddleware, initTRPC, TRPCError } from "@trpc/server"
import { prismaClient } from "../prisma" import { prismaClient } from "../prisma"
import type { Response } from "express"
export interface Context { export interface Context {
playerId: string userId: string | null
res?: Response
} }
export async function createContext(authorizationHeader: string | undefined): Promise<Context> { export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise<Context> {
const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters let userId = null
if (token !== null && token.length > 0) { if (authenticationToken !== null && authenticationToken.length > 0) {
const player = await prismaClient.player.findUnique({ where: { token }, select: { id: true } }) const user = await prismaClient.user.findUnique({ where: { token: authenticationToken }, select: { id: true } })
if (user !== null) userId = user.id
if (player !== null) return {
playerId: player.id
}
} }
throw new Error("Invalid token") return {
userId,
res
}
} }
export const t = initTRPC.context<Context>().create() export const t = initTRPC.context<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
}
})
})

39
src/server/trpc/game.ts Normal file
View file

@ -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 players 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 players turn")
await ctx.game.stay()
})
})

View file

@ -1,35 +1,62 @@
import { t } from "./base" import { requireAuthentication, t } from "./base"
import { prismaClient } from "../prisma" import { prismaClient } from "../prisma"
import z from "zod" import z from "zod"
import { observable } from "@trpc/server/observable" import { observable } from "@trpc/server/observable"
import type { GameAction } from "../../shared/game/gameActions" 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({ export const appRouter = t.router({
start: t.procedure game: gameRouter,
.mutation(async ({ ctx }) => {
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 join: t.procedure
.use(requireAuthentication)
.input(z.object({ .input(z.object({
gameId: z.string().nonempty() code: z.string().nonempty()
})) }))
.subscription(({ input, ctx }) => { .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 => { return observable<GameAction>(emit => {
const handlePublicAction = (action: GameAction) => emit.next(action) const handleBroadcastAction = (action: GameAction) => emit.next(action)
const handlePrivateAction = (playerId: string, action: GameAction) => { 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) game.on("private_action", handlePrivateAction)
return () => { return () => {
game.off("public_action", handlePublicAction) game.lobbyPlayerIds.delete(ctx.userId)
game.off("broadcast_action", handleBroadcastAction)
game.off("private_action", handlePrivateAction) game.off("private_action", handlePrivateAction)
} }
}) })

5
src/shared/RemoveKey.ts Normal file
View file

@ -0,0 +1,5 @@
// I dont 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<T, K extends string> = {
[P in keyof T as Exclude<P, K>]: T[P];
}

View file

@ -5,7 +5,9 @@ const specialCardTypesObject = {
"increase-target-by-2": true, "increase-target-by-2": true,
"next-round-covert": true, "next-round-covert": true,
"double-draw": true, "double-draw": true,
"add-2-opponent": true "add-2-opponent": true,
"get-1": true,
"get-11": true
} }
export const specialCardTypes = Object.keys(specialCardTypesObject) export const specialCardTypes = Object.keys(specialCardTypesObject)

View file

@ -4,7 +4,7 @@ type PlayerAction = {
initiatingPlayerId: string initiatingPlayerId: string
} & ( } & (
| { | {
type: "draw" type: "hit"
} }
| { | {
type: "stay" type: "stay"
@ -15,22 +15,35 @@ type PlayerAction = {
} }
) )
type ServerAction = type ServerAction = {
initiatingPlayerId?: never
} & (
| { | {
type: "start" type: "start"
numberCardsByPlayerId: Record<string, number[]> // if redacted: only contains the player themself
targetSum: number targetSum: number
players: Array<{
id: string
name: string
}>
} }
| { | {
type: "deal-number" type: "deal-number"
toPlayerId: string toPlayerId: string
number: number number: number
isCovert: boolean
} }
| { | {
type: "deal-special" type: "deal-special"
toPlayerId: string toPlayerId: string
cardType: SpecialCardType cardType: SpecialCardType
} }
| {
type: "end"
winnerIds: string[]
cardsByPlayerId: Record<string, number[]> // without redactions
}
)
export type GameAction = { export type GameAction = {
index: number index: number

View file

@ -2,53 +2,94 @@ import type { SpecialCardType } from "./cards"
import type { GameAction } from "./gameActions" import type { GameAction } from "./gameActions"
import { produce } from "immer" import { produce } from "immer"
import { specialCardTypes } from "./cards" import { specialCardTypes } from "./cards"
import { cloneDeep, tail, without } from "lodash-es"
type SpecialCardCountByType = Record<SpecialCardType, number> type SpecialCardCountByType = Record<SpecialCardType, number>
export interface GameStateNumberCard {
number: number
isCovert: boolean
}
export const getNumberCardsSum = (cards: Readonly<GameStateNumberCard[]>) => cards.reduce((acc, card) => acc + card.number, 0)
export interface GameStatePlayer { export interface GameStatePlayer {
id: string id: string
numberCards: number[] name: string
numberCards: GameStateNumberCard[]
specialCardCountByType: SpecialCardCountByType specialCardCountByType: SpecialCardCountByType
stayed: boolean stayed: boolean
nextRoundCovert: boolean nextRoundCovert: boolean
} }
export interface GameState { export interface GameState {
phase: "pre-start" | "running" | "end"
players: GameStatePlayer[] players: GameStatePlayer[]
activeSpecialCardCountByType: SpecialCardCountByType activeSpecialCardCountByType: SpecialCardCountByType
activePlayerId: string activePlayerId: string
targetSum: number 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 ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType
const UNINITIALIZED_GAME_STATE: GameState = { const UNINITIALIZED_GAME_STATE: GameState = {
phase: "pre-start",
players: [], players: [],
activePlayerId: "", activePlayerId: "",
targetSum: 0, 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 => { 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) { switch (action.type) {
case "start": case "start":
state.players = Object.entries(action.numberCardsByPlayerId).map(([playerId, numberCards]): GameStatePlayer => ({ state.players = action.players.map((player): GameStatePlayer => ({
id: playerId, id: player.id,
numberCards, name: player.name,
numberCards: [],
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS, specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
stayed: false, stayed: false,
nextRoundCovert: false nextRoundCovert: false
})) }))
state.phase = "running"
state.activePlayerId = state.players[0].id state.activePlayerId = state.players[0].id
state.targetSum = action.targetSum state.targetSum = action.targetSum
state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS
state.numberCardsStack = getFullNumbersCardStack()
state.actualNumberCardsStackSize = state.numberCardsStack.length
break break
case "deal-number": case "deal-number":
const p1 = state.players.find(p => p.id === action.toPlayerId)! 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 break
case "deal-special": case "deal-special":
@ -56,18 +97,29 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
p2.specialCardCountByType[action.cardType]++ p2.specialCardCountByType[action.cardType]++
break break
case "draw": case "hit":
case "stay": activateNextPlayer()
const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId) const p4 = state.players.find(p => p.id === action.initiatingPlayerId)!
p4.stayed = false
break
// activate the next player case "stay":
state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id activateNextPlayer()
const p5 = state.players.find(p => p.id === action.initiatingPlayerId)!
p5.stayed = true
break break
case "use-special": case "use-special":
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)! const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
applySpecialCardUsage(state, action.cardType, p3) applySpecialCardUsage(state, action.cardType, p3)
break 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 }))
}
} }
}) })

6
src/shared/util.ts Normal file
View file

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

View file

@ -14,10 +14,10 @@ export const trpcClient = createTRPCProxyClient<AppRouter>({
splitLink({ splitLink({
condition: o => o.type === "subscription", condition: o => o.type === "subscription",
true: wsLink({ true: wsLink({
client: wsClient client: wsClient,
}), }),
false: httpLink({ false: httpLink({
url: "/trpc", url: "/trpc"
}) })
}) })
] ]

5
src/types.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module "*.vue" {
import { ConcreteComponent } from "vue"
const C: ConcreteComponent
export default C
}

View file

@ -18,6 +18,7 @@
"stripInternal": true, "stripInternal": true,
"target": "esnext", "target": "esnext",
"types": [ "types": [
"src/types.d.ts",
"vite/client", "vite/client",
"unplugin-icons/types/vue", "unplugin-icons/types/vue",
"vite-plugin-pages/client" "vite-plugin-pages/client"

View file

@ -5,7 +5,7 @@ export default defineConfig({
theme: { theme: {
fontFamily: { fontFamily: {
"normal": ["InterVariable", "sans-serif"], "normal": ["InterVariable", "sans-serif"],
"fat": ["TitanOne", "sans-serif"] "fat": ["Titan One", "sans-serif"]
}, },
colors: { colors: {
transparent: "transparent", transparent: "transparent",