This commit is contained in:
Moritz Ruth 2023-04-22 22:11:53 +02:00
parent 8f54f121f1
commit b76bca9c57
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
22 changed files with 464 additions and 182 deletions

View file

@ -37,6 +37,7 @@
"bufferutil": "^4.0.7", "bufferutil": "^4.0.7",
"cookie": "^0.5.0", "cookie": "^0.5.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"date-fns": "^2.29.3",
"eventemitter3": "^5.0.0", "eventemitter3": "^5.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"immer": "^10.0.1", "immer": "^10.0.1",

7
pnpm-lock.yaml generated
View file

@ -23,6 +23,7 @@ importers:
bufferutil: ^4.0.7 bufferutil: ^4.0.7
cookie: ^0.5.0 cookie: ^0.5.0
cookie-parser: ^1.4.6 cookie-parser: ^1.4.6
date-fns: ^2.29.3
eventemitter3: ^5.0.0 eventemitter3: ^5.0.0
express: ^4.18.2 express: ^4.18.2
immer: ^10.0.1 immer: ^10.0.1
@ -56,6 +57,7 @@ importers:
bufferutil: 4.0.7 bufferutil: 4.0.7
cookie: 0.5.0 cookie: 0.5.0
cookie-parser: 1.4.6 cookie-parser: 1.4.6
date-fns: 2.29.3
eventemitter3: 5.0.0 eventemitter3: 5.0.0
express: 4.18.2 express: 4.18.2
immer: 10.0.1 immer: 10.0.1
@ -1320,6 +1322,11 @@ packages:
/csstype/2.6.21: /csstype/2.6.21:
resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==}
/date-fns/2.29.3:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'}
dev: false
/debug/2.6.9: /debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies:

View file

@ -7,32 +7,9 @@ generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
model Game {
id String @id @default(cuid())
lobbyCodeIfActive String? @unique
actions GameAction[]
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
token String @unique token String @unique
lastActivityDate DateTime @default(now())
gameActions GameAction[]
}
model GameAction {
id String @id @default(cuid())
index Int
gameId String
game Game @relation(references: [id], fields: [gameId], onDelete: Cascade)
playerId String? // null → the server or a deleted user
player User? @relation(references: [id], fields: [playerId], onDelete: SetNull)
data String
@@unique([gameId, index])
} }

View file

@ -2,13 +2,13 @@
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10"> <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 max-w-1200px mx-auto"> <div class="relative max-w-1200px h-full mx-auto">
<button v-if="!game.isActive" @click="game.join('')"> <div v-if="isLoading" class="flex flex-col justify-center items-center text-4xl">
Join <span>Loading</span>
</button> </div>
<button v-else-if="game.state.phase === 'pre-start'" @click="game.start()"> <LoginScreen v-else-if="auth.authenticatedUser === null"/>
Start <JoinScreen v-else-if="!game.isActive"/>
</button> <PreStartScreen v-else-if="game.state.phase === 'pre-start'"/>
<Game v-else/> <Game v-else/>
</div> </div>
</div> </div>
@ -67,24 +67,20 @@
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { trpcClient } from "./trpc"
import { ref } from "vue" import { ref } from "vue"
import { useGame } from "./clientGame" import { useGame } from "./clientGame"
import Game from "./components/Game.vue" import Game from "./components/Game.vue"
import { useAuth } from "./auth" import { useAuth } from "./auth"
import JoinScreen from "./components/JoinScreen.vue"
import LoginScreen from "./components/LoginScreen.vue"
import PreStartScreen from "./components/PreStartScreen.vue"
const isLoading = ref(true) const isLoading = ref(true)
const auth = useAuth() const auth = useAuth()
trpcClient.getSelf.query() auth.fetchSelf().then(() => {
.then(({ user }) => { isLoading.value = false
if (user === null) return trpcClient.loginAsGuest.mutate() })
auth.authenticatedUser = user
return Promise.resolve()
})
.then(() => {
isLoading.value = false
})
const game = useGame() const game = useGame()
</script> </script>

View file

@ -1,5 +1,6 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { trpcClient } from "./trpc"
export const useAuth = defineStore("auth", () => { export const useAuth = defineStore("auth", () => {
const authenticatedUser = ref<{ id: string; name: string } | null>(null) const authenticatedUser = ref<{ id: string; name: string } | null>(null)
@ -7,6 +8,16 @@ export const useAuth = defineStore("auth", () => {
return { return {
authenticatedUser, authenticatedUser,
requiredUser requiredUser,
async fetchSelf() {
authenticatedUser.value = (await trpcClient.getSelf.query()).user
},
async login(username: string) {
const { id } = await trpcClient.login.mutate({ name: username })
authenticatedUser.value = {
id,
name: username
}
}
} }
}) })

View file

@ -1,9 +1,11 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { EventBusKey, useEventBus } from "@vueuse/core" import { EventBusKey, useEventBus } from "@vueuse/core"
import type { GameAction } from "./shared/game/gameActions" import type { GameAction } from "./shared/game/actions"
import { reactive, readonly, ref } from "vue" import { computed, reactive, readonly, ref } from "vue"
import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state" import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state"
import { trpcClient } from "./trpc" import { trpcClient } from "./trpc"
import { useAuth } from "./auth"
import { read } from "fs"
const gameActionsBusKey = Symbol() as EventBusKey<GameAction> const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
const useGameActionsBus = () => useEventBus(gameActionsBusKey) const useGameActionsBus = () => useEventBus(gameActionsBusKey)
@ -18,10 +20,12 @@ export const useGameActionNotification = (listener: (action: GameAction) => unkn
} }
export const useGame = defineStore("game", () => { export const useGame = defineStore("game", () => {
const isActive = ref(false) const lobbyCode = ref<string | null>(null)
const state = ref<GameState>(getUninitializedGameState()) const state = ref<GameState>(getUninitializedGameState())
const actions = reactive<GameAction[]>([]) const actions = reactive<GameAction[]>([])
const auth = useAuth()
const actionsBus = useGameActionsBus() const actionsBus = useGameActionsBus()
actionsBus.on(action => { actionsBus.on(action => {
actions.push(action) actions.push(action)
@ -30,18 +34,37 @@ export const useGame = defineStore("game", () => {
}) })
return { return {
isActive: readonly(isActive), lobbyCode: readonly(lobbyCode),
isActive: computed(() => lobbyCode.value !== null),
isOwnGame: computed(() => state.value.players.findIndex(p => p.id === (auth.authenticatedUser?.id ?? "")) === 0),
state: readonly(state), state: readonly(state),
actions: readonly(actions), actions: readonly(actions),
join(code: string) { join(code: string) {
trpcClient.join.subscribe({ code: "game" }, { return new Promise<void>((resolve, reject) => {
onData: actionsBus.emit trpcClient.join.subscribe({ lobbyCode: code }, {
onStarted: () => {
lobbyCode.value = code
resolve()
},
onData: event => {
switch (event.type) {
case "action":
actionsBus.emit(event.action)
break
}
},
onError: error => {
console.error("🔴", error)
reject(error)
}
})
}) })
isActive.value = true
}, },
start: () => trpcClient.game.start.mutate(), async create() {
hit: () => trpcClient.game.hit.mutate(), return await trpcClient.createGame.mutate()
stay: () => trpcClient.game.stay.mutate() },
start: () => trpcClient.game.start.mutate({ lobbyCode: lobbyCode.value! }),
hit: () => trpcClient.game.hit.mutate({ lobbyCode: lobbyCode.value! }),
stay: () => trpcClient.game.stay.mutate({ lobbyCode: lobbyCode.value! })
} }
}) })

View file

@ -14,7 +14,7 @@
<span class="text-green-500">{{ getNumberCardsSum(singleWinner.numberCards) }}</span> <span class="text-green-500">{{ getNumberCardsSum(singleWinner.numberCards) }}</span>
point{{ getNumberCardsSum(singleWinner.numberCards) === 1 ? "" : "s" }}. point{{ getNumberCardsSum(singleWinner.numberCards) === 1 ? "" : "s" }}.
</template> </template>
<template> <template v-else>
between {{ naturallyJoinEnumeration(game.state.winnerIds.map(id => game.state.players.find(p => p.id === id).name)) }} between {{ naturallyJoinEnumeration(game.state.winnerIds.map(id => game.state.players.find(p => p.id === id).name)) }}
</template> </template>
</div> </div>

View file

@ -0,0 +1,97 @@
<template>
<div class="flex flex-col h-full justify-center items-center gap-8 text-8xl">
<input
v-model="lobbyCodeInput"
class="uppercase bg-transparent text-white text-center font-fat tracking-wider focus:outline-none pb-2 rounded-lg transition"
:class="isUnknown ? 'bg-red-800' : ''"
:maxlength="LOBBY_CODE_LENGTH"
:placeholder="'X'.repeat(LOBBY_CODE_LENGTH)"
:style="{ width: `${LOBBY_CODE_LENGTH * 0.95}em` }"
@keypress="onKeypress"
/>
<button
class="uppercase font-bold text-4xl border border-white rounded-md bg-white bg-opacity-0 transition px-10 py-4"
:class="$style.button"
:disabled="lobbyCodeInput.length !== LOBBY_CODE_LENGTH"
@click="join()"
>
Join
</button>
<button
class="font-bold text-2xl border border-white rounded-md bg-white bg-opacity-0 transition px-6 py-2"
:class="$style.button"
@click="create()"
>
Create game
</button>
</div>
</template>
<style module lang="scss">
.button {
&:disabled {
cursor: not-allowed;
}
&:not(:disabled) {
@apply hover:bg-opacity-20;
}
}
</style>
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue"
import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
import { useBrowserLocation } from "@vueuse/core"
const location = useBrowserLocation()
const REGEX = new RegExp("^[a-zA-Z]*$")
const game = useGame()
const lobbyCodeInput = ref("")
const normalizedLobbyCode = computed(() => lobbyCodeInput.value.toUpperCase())
const isLoading = ref(false)
const isUnknown = ref(false)
watchEffect(() => {
const hash = (location.value.hash ?? "").slice(1)
if (REGEX.test(hash) && hash.length === LOBBY_CODE_LENGTH) {
lobbyCodeInput.value = hash
}
})
watch(lobbyCodeInput, () => {
isUnknown.value = false
})
function onKeypress(e: KeyboardEvent) {
if (e.key.length !== 1 || !REGEX.test(e.key)) {
e.preventDefault()
e.stopImmediatePropagation()
}
}
async function join() {
if (isLoading.value) return
isLoading.value = true
try {
await game.join(normalizedLobbyCode.value)
window.location.hash = normalizedLobbyCode.value
} catch (e: unknown) {
isUnknown.value = true
}
isLoading.value = false
}
async function create() {
if (isLoading.value) return
isLoading.value = true
const { lobbyCode } = await game.create()
lobbyCodeInput.value = lobbyCode
isLoading.value = false
await join()
}
</script>

View file

@ -0,0 +1,49 @@
<template>
<div class="flex flex-col h-full justify-center items-center gap-8 text-8xl">
<input
v-model="usernameInput"
class="bg-transparent text-white text-center font-fat focus:outline-none"
placeholder="Username"
maxlength="20"
/>
<button
class="uppercase font-bold text-4xl border border-white rounded-md bg-white bg-opacity-0 transition px-10 py-4"
:class="$style.button"
:disabled="usernameInput.length <= 0 || usernameInput.length > 20"
@click="submit()"
>
Continue
</button>
</div>
</template>
<style module lang="scss">
.button {
&:disabled {
cursor: not-allowed;
}
&:not(:disabled) {
@apply hover:bg-opacity-20;
}
}
</style>
<script setup lang="ts">
import { ref, watchEffect } from "vue"
import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
import { useBrowserLocation } from "@vueuse/core"
import { useAuth } from "../auth"
const auth = useAuth()
const usernameInput = ref("")
const isLoading = ref(false)
function submit() {
if (isLoading.value) return
isLoading.value = true
auth.login(usernameInput.value)
isLoading.value = false
}
</script>

View file

@ -0,0 +1,26 @@
<template>
<div class="flex flex-col h-full items-center pt-10 gap-8 text-8xl">
<div class="font-fat text-5xl">Code: <span class="select-all">{{ game.lobbyCode }}</span></div>
<div class="font-bold text-3xl">Players: {{ naturallyJoinEnumeration(game.state.players.map(p => p.name)) }}</div>
<BigButton
v-if="game.isOwnGame"
class="bg-gradient-to-br from-gray-600 to-gray-800"
:disabled="game.state.players.length < 2"
@click="game.start()"
>
Start
</BigButton>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { useGame } from "../clientGame"
import { naturallyJoinEnumeration } from "../shared/util"
import BigButton from "./BigButton.vue"
const game = useGame()
</script>

View file

@ -1,5 +1,5 @@
<template> <template>
<TextualInput v-model="value"/> <TextualInput :class="$style.root" v-model="value"/>
</template> </template>
<style module lang="scss"> <style module lang="scss">

View file

@ -1,23 +1,25 @@
import EventEmitter from "eventemitter3" import EventEmitter from "eventemitter3"
import type { GameAction } from "../shared/game/gameActions" import type { GameAction } from "../shared/game/actions"
import { prismaClient } from "./prisma" import { prismaClient } from "./prisma"
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state" import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state"
import type { RemoveKey } from "../shared/RemoveKey" import type { RemoveKey } from "../shared/RemoveKey"
import { mapValues, random } from "lodash-es" import { random } from "lodash-es"
import type { DeepReadonly } from "vue" import type { DeepReadonly } from "vue"
import { customAlphabet as createNanoIdWithCustomAlphabet } from "nanoid/non-secure"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
const activeGamesByCode = new Map<string, ActiveGame>() const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
export function getActiveGameByCode(code: string): ActiveGame | null { const gamesByLobbyCode = new Map<string, Game>()
return activeGamesByCode.get(code) ?? null
export function getGameByLobbyCode(code: string): Game | null {
return gamesByLobbyCode.get(code.toUpperCase()) ?? null
} }
export function getActiveGameOfPlayer(userId: string): ActiveGame | null { export function createGame() {
for (const game of activeGamesByCode.values()) { const game = new Game()
if (game.lobbyPlayerIds.has(userId)) return game gamesByLobbyCode.set(game.lobbyCode, game)
} return game
return null
} }
const NO_REDACTION = Symbol() const NO_REDACTION = Symbol()
@ -44,9 +46,9 @@ interface Events {
destroyed: [] destroyed: []
} }
export class ActiveGame extends EventEmitter<Events> { export class Game extends EventEmitter<Events> {
private nextIndex = 0 lobbyCode = generateLobbyCode()
/** /**
* @deprecated * @deprecated
*/ */
@ -55,14 +57,20 @@ export class ActiveGame extends EventEmitter<Events> {
return this._state return this._state
} }
lobbyPlayerIds = new Set<string>() private lobbyPlayerIds = new Set<string>()
private actions: GameAction[] = []
constructor(public id: string) { private get nextIndex() {
if (this.actions.length === 0) return 0
return this.actions[this.actions.length - 1].index + 1
}
constructor() {
super() super()
} }
async start() { private async getPlayers() {
const players = await prismaClient.user.findMany({ return await prismaClient.user.findMany({
where: { where: {
id: { id: {
in: [...this.lobbyPlayerIds] in: [...this.lobbyPlayerIds]
@ -73,43 +81,70 @@ export class ActiveGame extends EventEmitter<Events> {
name: true name: true
} }
}) })
}
await this.addAction({ addPlayer(id: string, name: string) {
if (this.lobbyPlayerIds.has(id)) return
if (this.lobbyPlayerIds.size >= 3) throw new Error("The game is full.")
this.lobbyPlayerIds.add(id)
this.addAction({
type: "join",
initiatingPlayerId: id,
name
})
}
removePlayerAndDestroy(userId: string) {
this.lobbyPlayerIds.delete(userId)
this.addAction({
type: "leave",
initiatingPlayerId: userId
})
this.destroy()
}
async start() {
const players = await this.getPlayers()
if (players.length < 2) throw new Error("At least two players are required for starting the game")
this.addAction({
type: "start", type: "start",
targetSum: 21, targetSum: 21,
players players
}) })
for (const player of players) { for (const player of players) {
await this.dealNumberTo(player.id, true) this.dealNumberTo(player.id, true)
} }
} }
async hit() { hit() {
const playerId = this.state.activePlayerId const playerId = this.state.activePlayerId
await this.addAction({ this.addAction({
type: "hit", type: "hit",
initiatingPlayerId: playerId initiatingPlayerId: playerId
}) })
await this.dealNumberTo(playerId, false) this.dealNumberTo(playerId, false)
} }
async stay() { stay() {
const playerId = this.state.activePlayerId const playerId = this.state.activePlayerId
await this.addAction({ this.addAction({
type: "stay", type: "stay",
initiatingPlayerId: playerId initiatingPlayerId: playerId
}) })
if (this.state.players.every(p => p.stayed)) { if (this.state.players.every(p => p.stayed)) {
await this.end() this.end()
} }
} }
async end() { end() {
let closestSafeValue = 0 let closestSafeValue = 0
let closestSafePlayerIds: string[] = [] let closestSafePlayerIds: string[] = []
let closestBustValue = Number.POSITIVE_INFINITY let closestBustValue = Number.POSITIVE_INFINITY
@ -134,17 +169,21 @@ export class ActiveGame extends EventEmitter<Events> {
const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds
await this.addAction({ this.addAction({
type: "end", type: "end",
winnerIds, winnerIds,
cardsByPlayerId: Object.fromEntries(this.state.players.map(player => [player.id, player.numberCards.map(c => c.number)])) cardsByPlayerId: Object.fromEntries(this.state.players.map(player => [player.id, player.numberCards.map(c => c.number)]))
}) })
} }
private async dealNumberTo(playerId: string, isCovert: boolean) { sendAllOldActionsTo(receiverId: string) {
this.actions.forEach(action => this.sendActionTo(action, receiverId))
}
private dealNumberTo(playerId: string, isCovert: boolean) {
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)] const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
await this.addAction({ this.addAction({
type: "deal-number", type: "deal-number",
number, number,
toPlayerId: playerId, toPlayerId: playerId,
@ -152,42 +191,45 @@ export class ActiveGame extends EventEmitter<Events> {
}) })
} }
private async addAction<T extends RemoveKey<GameAction, "index">>(action: T): Promise<T & { index: number }> { private addAction<T extends RemoveKey<GameAction, "index">>(action: T): T & { index: number } {
const fullAction = { const fullAction = {
...action, ...action,
index: this.nextIndex++ index: this.nextIndex
} as T & { index: number } } as T & { index: number }
await prismaClient.gameAction.create({ this.actions.push(fullAction)
data: { this.applyActionToState(fullAction)
gameId: this.id,
playerId: action.initiatingPlayerId, this.sendAction(fullAction)
index: fullAction.index,
data: JSON.stringify(fullAction)
}
})
// noinspection JSDeprecatedSymbols return fullAction
this._state = produceNewState(this._state, fullAction) }
private sendAction(action: GameAction) {
for (const player of this.state.players) { for (const player of this.state.players) {
const redactedAction = redactGameAction(fullAction, player.id) const redactedAction = redactGameAction(action, player.id)
if (redactedAction === NO_REDACTION) { if (redactedAction === NO_REDACTION) {
this.emit("broadcast_action", fullAction) this.emit("broadcast_action", action)
break break
} else { } else {
this.emit("private_action", player.id, redactedAction) this.emit("private_action", player.id, redactedAction)
} }
} }
}
return fullAction private sendActionTo(action: GameAction, receiverId: string) {
const redactedAction = redactGameAction(action, receiverId)
this.emit("private_action", receiverId, redactedAction === NO_REDACTION ? action : redactedAction)
}
private applyActionToState(action: GameAction) {
// noinspection JSDeprecatedSymbols
this._state = produceNewState(this._state, action)
} }
private destroy() { private destroy() {
activeGamesByCode.delete(this.id) gamesByLobbyCode.delete(this.lobbyCode)
this.emit("destroyed") this.emit("destroyed")
} }
} }
activeGamesByCode.set("game", new ActiveGame("game"))

View file

@ -4,11 +4,12 @@ import { WebSocketServer } from "ws"
import { appRouter } from "./trpc" import { appRouter } from "./trpc"
import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express" import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express"
import { applyWSSHandler } from "@trpc/server/adapters/ws" import { applyWSSHandler } from "@trpc/server/adapters/ws"
import { seedDatabase } from "./seed"
import { createContext } from "./trpc/base" import { createContext } from "./trpc/base"
import cookieParser from "cookie-parser" import cookieParser from "cookie-parser"
import { parse as parseCookie } from "cookie" import { parse as parseCookie } from "cookie"
import { isDev } from "./isDev" import { isDev } from "./isDev"
import { prismaClient } from "./prisma"
import * as dateFns from "date-fns"
const expressApp = createExpressApp() const expressApp = createExpressApp()
expressApp.use(cookieParser()) expressApp.use(cookieParser())
@ -18,7 +19,14 @@ expressApp.use("/trpc", createTrpcMiddleware({
createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res), createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res),
})) }))
await seedDatabase() await prismaClient.user.deleteMany({
where: {
lastActivityDate: {
lte: dateFns.subMonths(new Date(), 1)
}
}
})
const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false }) const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
const wss = new WebSocketServer({ server, path: "/ws" }) const wss = new WebSocketServer({ server, path: "/ws" })

View file

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

View file

@ -3,20 +3,28 @@ import { prismaClient } from "../prisma"
import type { Response } from "express" import type { Response } from "express"
export interface Context { export interface Context {
userId: string | null user: {
id: string
name: string
} | null
res?: Response res?: Response
} }
export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise<Context> { export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise<Context> {
let userId = null let user: Context["user"] = null
if (authenticationToken !== null && authenticationToken.length > 0) { if (authenticationToken !== null && authenticationToken.length > 0) {
const user = await prismaClient.user.findUnique({ where: { token: authenticationToken }, select: { id: true } }) user = await prismaClient.user.findUnique({
if (user !== null) userId = user.id where: { token: authenticationToken },
select: {
id: true,
name: true
}
})
} }
return { return {
userId, user,
res res
} }
} }
@ -24,13 +32,13 @@ export async function createContext(authenticationToken: string | null, res: Res
export const t = initTRPC.context<Context>().create() export const t = initTRPC.context<Context>().create()
export const requireAuthentication = t.middleware(({ ctx, next }) => { export const requireAuthentication = t.middleware(({ ctx, next }) => {
let userId = ctx.userId let user = ctx.user
if (userId === null) throw new TRPCError({ code: "UNAUTHORIZED" }) if (user === null) throw new TRPCError({ code: "UNAUTHORIZED" })
return next({ return next({
ctx: { ctx: {
...ctx, ...ctx,
userId user
} }
}) })
}) })

View file

@ -1,13 +1,17 @@
import { requireAuthentication, t } from "./base" import { requireAuthentication, t } from "./base"
import { getActiveGameOfPlayer } from "../activeGame"
import { getNumberCardsSum } from "../../shared/game/state" import { getNumberCardsSum } from "../../shared/game/state"
import { z } from "zod"
import { getGameByLobbyCode } from "../game"
const gameProcedure = t.procedure const gameProcedure = t.procedure
.use(requireAuthentication) .use(requireAuthentication)
.use(t.middleware(async ({ ctx, next }) => { .input(z.object({
const game = getActiveGameOfPlayer(ctx.userId!) lobbyCode: z.string()
}))
.use(t.middleware(async ({ input, ctx, next }) => {
const game = getGameByLobbyCode((input as { lobbyCode: string }).lobbyCode)
if (game === null) throw new Error("The player is not in an active game") if (game === null) throw new Error("The game does not exist or is not active")
return await next({ return await next({
ctx: { ctx: {
game game
@ -19,13 +23,14 @@ export const gameRouter = t.router({
start: gameProcedure start: gameProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
if (ctx.game.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${ctx.game.state.phase}`) if (ctx.game.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${ctx.game.state.phase}`)
if (ctx.game.state.players.findIndex(p => p.id === ctx.user.id) !== 0) throw new Error("Only the creator can start the game")
ctx.game.start() ctx.game.start()
}), }),
hit: gameProcedure hit: gameProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the players turn") if (ctx.game.state.activePlayerId !== ctx.user.id) 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) if (getNumberCardsSum(ctx.game.state.players.find(p => p.id === ctx.user.id)!.numberCards) > ctx.game.state.targetSum)
throw new Error("The player cannot hit when bust") throw new Error("The player cannot hit when bust")
await ctx.game.hit() await ctx.game.hit()
@ -33,7 +38,7 @@ export const gameRouter = t.router({
stay: gameProcedure stay: gameProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the players turn") if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the players turn")
await ctx.game.stay() await ctx.game.stay()
}) })
}) })

View file

@ -2,62 +2,107 @@ 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/actions"
import { isDev } from "../isDev" import { isDev } from "../isDev"
import { getActiveGameByCode } from "../activeGame"
import { gameRouter } from "./game" import { gameRouter } from "./game"
import { nanoid } from "nanoid/async"
let lastGuestWasOne = false import { createGame, getGameByLobbyCode } from "../game"
import type { GameEvent } from "../../shared/game/events"
export const appRouter = t.router({ export const appRouter = t.router({
game: gameRouter, game: gameRouter,
loginAsGuest: t.procedure login: t.procedure
.mutation(async ({ ctx }) => { .input(z.object({
let token = lastGuestWasOne ? "guest1" : "guest2" name: z.string().min(1).max(20)
lastGuestWasOne = !lastGuestWasOne }))
.mutation(async ({ input, ctx }) => {
if (ctx.user !== null) await prismaClient.user.delete({ where: { id: ctx.user.id } })
const token = await nanoid(60)
const user = await prismaClient.user.create({
data: {
name: input.name,
token
},
select: {
id: true
}
})
ctx.res!.cookie("token", token, { ctx.res!.cookie("token", token, {
maxAge: 60 * 60 * 24 * 365, maxAge: 60 * 60 * 24 * 365,
httpOnly: true, httpOnly: true,
secure: !isDev, secure: !isDev,
sameSite: "strict" sameSite: "strict"
}) })
return {
id: user.id
}
}), }),
getSelf: t.procedure getSelf: t.procedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.userId === null) return { user: null } if (ctx.user === null) return { user: null }
await prismaClient.user.update({
where: { id: ctx.user.id },
data: {
lastActivityDate: new Date()
}
})
return { return {
user: await prismaClient.user.findUnique({ where: { id: ctx.userId } }) user: await prismaClient.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
name: true
}
})
}
}),
createGame: t.procedure
.use(requireAuthentication)
.mutation(async ({}) => {
const game = createGame()
return {
lobbyCode: game.lobbyCode
} }
}), }),
join: t.procedure join: t.procedure
.use(requireAuthentication) .use(requireAuthentication)
.input(z.object({ .input(z.object({
code: z.string().nonempty() lobbyCode: z.string().nonempty()
})) }))
.subscription(({ input, ctx }) => { .subscription(async ({ input, ctx }) => {
const game = getActiveGameByCode(input.code) const game = await getGameByLobbyCode(input.lobbyCode)
if (game === null) throw new Error("There is no game with this code") if (game === null) throw new Error("There is no game with this code.")
game.lobbyPlayerIds.add(ctx.userId) await game.addPlayer(ctx.user.id, ctx.user.name)
return observable<GameAction>(emit => { return observable<GameEvent>(emit => {
const handleBroadcastAction = (action: GameAction) => emit.next(action) const handleBroadcastAction = (action: GameAction) => emit.next({ type: "action", action })
const handleDestroyed = () => setTimeout(() => emit.complete(), 500)
const handlePrivateAction = (playerId: string, action: GameAction) => { const handlePrivateAction = (playerId: string, action: GameAction) => {
if (playerId === ctx.userId) emit.next(action) if (playerId === ctx.user.id) emit.next({ type: "action", action })
} }
game.on("broadcast_action", handleBroadcastAction) game.on("broadcast_action", handleBroadcastAction)
game.on("private_action", handlePrivateAction) game.on("private_action", handlePrivateAction)
game.on("destroyed", handleDestroyed)
game.sendAllOldActionsTo(ctx.user.id)
return () => { return () => {
game.lobbyPlayerIds.delete(ctx.userId)
game.off("broadcast_action", handleBroadcastAction) game.off("broadcast_action", handleBroadcastAction)
game.off("private_action", handlePrivateAction) game.off("private_action", handlePrivateAction)
game.off("destroyed", handleDestroyed)
} }
}) })
}) })

View file

@ -3,6 +3,10 @@ import type { SpecialCardType } from "./cards"
type PlayerAction = { type PlayerAction = {
initiatingPlayerId: string initiatingPlayerId: string
} & ( } & (
| {
type: "join"
name: string
}
| { | {
type: "hit" type: "hit"
} }
@ -13,6 +17,9 @@ type PlayerAction = {
type: "use-special" type: "use-special"
cardType: SpecialCardType cardType: SpecialCardType
} }
| {
type: "leave"
}
) )
type ServerAction = { type ServerAction = {
@ -21,10 +28,6 @@ type ServerAction = {
| { | {
type: "start" type: "start"
targetSum: number targetSum: number
players: Array<{
id: string
name: string
}>
} }
| { | {
type: "deal-number" type: "deal-number"

View file

@ -0,0 +1,6 @@
import type { GameAction } from "./actions"
export type GameEvent = {
type: "action"
action: GameAction
}

View file

@ -1,5 +1,5 @@
import type { SpecialCardType } from "./cards" import type { SpecialCardType } from "./cards"
import type { GameAction } from "./gameActions" import type { GameAction } from "./actions"
import { produce } from "immer" import { produce } from "immer"
import { specialCardTypes } from "./cards" import { specialCardTypes } from "./cards"
import { cloneDeep, tail, without } from "lodash-es" import { cloneDeep, tail, without } from "lodash-es"
@ -56,16 +56,19 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
} }
switch (action.type) { switch (action.type) {
case "start": case "join":
state.players = action.players.map((player): GameStatePlayer => ({ state.players.push({
id: player.id, id: action.initiatingPlayerId,
name: player.name, name: action.name,
numberCards: [], numberCards: [],
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS, specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
stayed: false, stayed: false,
nextRoundCovert: false nextRoundCovert: false
})) })
break
case "start":
state.phase = "running" state.phase = "running"
state.activePlayerId = state.players[0].id state.activePlayerId = state.players[0].id
state.targetSum = action.targetSum state.targetSum = action.targetSum

1
src/shared/lobbyCode.ts Normal file
View file

@ -0,0 +1 @@
export const LOBBY_CODE_LENGTH = 5

View file

@ -4,6 +4,7 @@ import type { AppRouter } from "./server"
const wsUrl = new URL(location.href) const wsUrl = new URL(location.href)
wsUrl.protocol = wsUrl.protocol.replace("http", "ws") wsUrl.protocol = wsUrl.protocol.replace("http", "ws")
wsUrl.pathname = "/ws" wsUrl.pathname = "/ws"
wsUrl.hash = ""
const wsClient = createWSClient({ const wsClient = createWSClient({
url: wsUrl.href url: wsUrl.href