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",
"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
View file

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

View file

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

View file

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

View file

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

View file

@ -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
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 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")
return observable(emit => {
const handlePublicAction = (action: GameAction) => emit.next(action)
game.lobbyPlayerIds.add(ctx.userId)
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
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,
"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)

View file

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

View file

@ -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
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({
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
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,
"target": "esnext",
"types": [
"src/types.d.ts",
"vite/client",
"unplugin-icons/types/vue",
"vite-plugin-pages/client"

View file

@ -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",