commit #3
This commit is contained in:
parent
14d7efe0bf
commit
8f54f121f1
31 changed files with 929 additions and 114 deletions
|
@ -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",
|
||||
|
|
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
32
src/App.vue
32
src/App.vue
|
@ -1,9 +1,15 @@
|
|||
<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.vignette"/>
|
||||
<div class="relative z-1">
|
||||
<slot/>
|
||||
<div class="relative max-w-1200px mx-auto">
|
||||
<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>
|
||||
</template>
|
||||
|
@ -33,6 +39,7 @@
|
|||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
@ -60,5 +67,24 @@
|
|||
</style>
|
||||
|
||||
<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>
|
||||
|
|
12
src/auth.ts
Normal file
12
src/auth.ts
Normal 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
47
src/clientGame.ts
Normal 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()
|
||||
}
|
||||
})
|
60
src/components/BigButton.vue
Normal file
60
src/components/BigButton.vue
Normal 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
43
src/components/Card.vue
Normal 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
55
src/components/Game.vue
Normal 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>
|
51
src/components/GameEndModal.vue
Normal file
51
src/components/GameEndModal.vue
Normal 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
32
src/components/Modal.vue
Normal 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>
|
45
src/components/NumberCard.vue
Normal file
45
src/components/NumberCard.vue
Normal 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 couldn’t see the number on this card during the game"
|
||||
: "The other players can’t see the number on this card"
|
||||
})
|
||||
}
|
||||
|
||||
return tags
|
||||
})
|
||||
</script>
|
50
src/components/PlayerCards.vue
Normal file
50
src/components/PlayerCards.vue
Normal 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>
|
10
src/index.ts
10
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")
|
||||
|
|
193
src/server/activeGame.ts
Normal file
193
src/server/activeGame.ts
Normal 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"))
|
|
@ -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
1
src/server/isDev.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const isDev = process.env.NODE_ENV === "development"
|
|
@ -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")
|
||||
})
|
|
@ -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"
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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<Context> {
|
||||
const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters
|
||||
export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise<Context> {
|
||||
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<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
39
src/server/trpc/game.ts
Normal 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 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()
|
||||
})
|
||||
})
|
|
@ -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<GameAction>(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)
|
||||
}
|
||||
})
|
||||
|
|
5
src/shared/RemoveKey.ts
Normal file
5
src/shared/RemoveKey.ts
Normal file
|
@ -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<T, K extends string> = {
|
||||
[P in keyof T as Exclude<P, K>]: T[P];
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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<string, number[]> // 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<string, number[]> // without redactions
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
export type GameAction = {
|
||||
index: number
|
||||
|
|
|
@ -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<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 {
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
6
src/shared/util.ts
Normal file
6
src/shared/util.ts
Normal 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()
|
||||
}
|
|
@ -14,10 +14,10 @@ export const trpcClient = createTRPCProxyClient<AppRouter>({
|
|||
splitLink({
|
||||
condition: o => o.type === "subscription",
|
||||
true: wsLink({
|
||||
client: wsClient
|
||||
client: wsClient,
|
||||
}),
|
||||
false: httpLink({
|
||||
url: "/trpc",
|
||||
url: "/trpc"
|
||||
})
|
||||
})
|
||||
]
|
||||
|
|
5
src/types.d.ts
vendored
Normal file
5
src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
declare module "*.vue" {
|
||||
import { ConcreteComponent } from "vue"
|
||||
const C: ConcreteComponent
|
||||
export default C
|
||||
}
|
|
@ -18,6 +18,7 @@
|
|||
"stripInternal": true,
|
||||
"target": "esnext",
|
||||
"types": [
|
||||
"src/types.d.ts",
|
||||
"vite/client",
|
||||
"unplugin-icons/types/vue",
|
||||
"vite-plugin-pages/client"
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue