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",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.6",
"date-fns": "^2.29.3",
"eventemitter3": "^5.0.0",
"express": "^4.18.2",
"immer": "^10.0.1",

7
pnpm-lock.yaml generated
View file

@ -23,6 +23,7 @@ importers:
bufferutil: ^4.0.7
cookie: ^0.5.0
cookie-parser: ^1.4.6
date-fns: ^2.29.3
eventemitter3: ^5.0.0
express: ^4.18.2
immer: ^10.0.1
@ -56,6 +57,7 @@ importers:
bufferutil: 4.0.7
cookie: 0.5.0
cookie-parser: 1.4.6
date-fns: 2.29.3
eventemitter3: 5.0.0
express: 4.18.2
immer: 10.0.1
@ -1320,6 +1322,11 @@ packages:
/csstype/2.6.21:
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:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:

View file

@ -7,32 +7,9 @@ generator client {
provider = "prisma-client-js"
}
model Game {
id String @id @default(cuid())
lobbyCodeIfActive String? @unique
actions GameAction[]
}
model User {
id String @id @default(cuid())
name String
token String @unique
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])
lastActivityDate DateTime @default(now())
}

View file

@ -2,13 +2,13 @@
<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 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>
<div class="relative max-w-1200px h-full mx-auto">
<div v-if="isLoading" class="flex flex-col justify-center items-center text-4xl">
<span>Loading</span>
</div>
<LoginScreen v-else-if="auth.authenticatedUser === null"/>
<JoinScreen v-else-if="!game.isActive"/>
<PreStartScreen v-else-if="game.state.phase === 'pre-start'"/>
<Game v-else/>
</div>
</div>
@ -67,22 +67,18 @@
</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"
import JoinScreen from "./components/JoinScreen.vue"
import LoginScreen from "./components/LoginScreen.vue"
import PreStartScreen from "./components/PreStartScreen.vue"
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(() => {
auth.fetchSelf().then(() => {
isLoading.value = false
})

View file

@ -1,5 +1,6 @@
import { defineStore } from "pinia"
import { computed, ref } from "vue"
import { trpcClient } from "./trpc"
export const useAuth = defineStore("auth", () => {
const authenticatedUser = ref<{ id: string; name: string } | null>(null)
@ -7,6 +8,16 @@ export const useAuth = defineStore("auth", () => {
return {
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 { EventBusKey, useEventBus } from "@vueuse/core"
import type { GameAction } from "./shared/game/gameActions"
import { reactive, readonly, ref } from "vue"
import type { GameAction } from "./shared/game/actions"
import { computed, reactive, readonly, ref } from "vue"
import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state"
import { trpcClient } from "./trpc"
import { useAuth } from "./auth"
import { read } from "fs"
const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
const useGameActionsBus = () => useEventBus(gameActionsBusKey)
@ -18,10 +20,12 @@ export const useGameActionNotification = (listener: (action: GameAction) => unkn
}
export const useGame = defineStore("game", () => {
const isActive = ref(false)
const lobbyCode = ref<string | null>(null)
const state = ref<GameState>(getUninitializedGameState())
const actions = reactive<GameAction[]>([])
const auth = useAuth()
const actionsBus = useGameActionsBus()
actionsBus.on(action => {
actions.push(action)
@ -30,18 +34,37 @@ export const useGame = defineStore("game", () => {
})
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),
actions: readonly(actions),
join(code: string) {
trpcClient.join.subscribe({ code: "game" }, {
onData: actionsBus.emit
})
isActive.value = true
return new Promise<void>((resolve, reject) => {
trpcClient.join.subscribe({ lobbyCode: code }, {
onStarted: () => {
lobbyCode.value = code
resolve()
},
start: () => trpcClient.game.start.mutate(),
hit: () => trpcClient.game.hit.mutate(),
stay: () => trpcClient.game.stay.mutate()
onData: event => {
switch (event.type) {
case "action":
actionsBus.emit(event.action)
break
}
},
onError: error => {
console.error("🔴", error)
reject(error)
}
})
})
},
async create() {
return await trpcClient.createGame.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>
point{{ getNumberCardsSum(singleWinner.numberCards) === 1 ? "" : "s" }}.
</template>
<template>
<template v-else>
between {{ naturallyJoinEnumeration(game.state.winnerIds.map(id => game.state.players.find(p => p.id === id).name)) }}
</template>
</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>
<TextualInput v-model="value"/>
<TextualInput :class="$style.root" v-model="value"/>
</template>
<style module lang="scss">

View file

@ -1,23 +1,25 @@
import EventEmitter from "eventemitter3"
import type { GameAction } from "../shared/game/gameActions"
import type { GameAction } from "../shared/game/actions"
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 { random } from "lodash-es"
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 {
return activeGamesByCode.get(code) ?? null
const gamesByLobbyCode = new Map<string, Game>()
export function getGameByLobbyCode(code: string): Game | null {
return gamesByLobbyCode.get(code.toUpperCase()) ?? null
}
export function getActiveGameOfPlayer(userId: string): ActiveGame | null {
for (const game of activeGamesByCode.values()) {
if (game.lobbyPlayerIds.has(userId)) return game
}
return null
export function createGame() {
const game = new Game()
gamesByLobbyCode.set(game.lobbyCode, game)
return game
}
const NO_REDACTION = Symbol()
@ -44,8 +46,8 @@ interface Events {
destroyed: []
}
export class ActiveGame extends EventEmitter<Events> {
private nextIndex = 0
export class Game extends EventEmitter<Events> {
lobbyCode = generateLobbyCode()
/**
* @deprecated
@ -55,14 +57,20 @@ export class ActiveGame extends EventEmitter<Events> {
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()
}
async start() {
const players = await prismaClient.user.findMany({
private async getPlayers() {
return await prismaClient.user.findMany({
where: {
id: {
in: [...this.lobbyPlayerIds]
@ -73,43 +81,70 @@ export class ActiveGame extends EventEmitter<Events> {
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",
targetSum: 21,
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
await this.addAction({
this.addAction({
type: "hit",
initiatingPlayerId: playerId
})
await this.dealNumberTo(playerId, false)
this.dealNumberTo(playerId, false)
}
async stay() {
stay() {
const playerId = this.state.activePlayerId
await this.addAction({
this.addAction({
type: "stay",
initiatingPlayerId: playerId
})
if (this.state.players.every(p => p.stayed)) {
await this.end()
this.end()
}
}
async end() {
end() {
let closestSafeValue = 0
let closestSafePlayerIds: string[] = []
let closestBustValue = Number.POSITIVE_INFINITY
@ -134,17 +169,21 @@ export class ActiveGame extends EventEmitter<Events> {
const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds
await this.addAction({
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) {
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)]
await this.addAction({
this.addAction({
type: "deal-number",
number,
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 = {
...action,
index: this.nextIndex++
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)
this.actions.push(fullAction)
this.applyActionToState(fullAction)
this.sendAction(fullAction)
return fullAction
}
})
// noinspection JSDeprecatedSymbols
this._state = produceNewState(this._state, fullAction)
private sendAction(action: GameAction) {
for (const player of this.state.players) {
const redactedAction = redactGameAction(fullAction, player.id)
const redactedAction = redactGameAction(action, player.id)
if (redactedAction === NO_REDACTION) {
this.emit("broadcast_action", fullAction)
this.emit("broadcast_action", action)
break
} else {
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() {
activeGamesByCode.delete(this.id)
gamesByLobbyCode.delete(this.lobbyCode)
this.emit("destroyed")
}
}
activeGamesByCode.set("game", new ActiveGame("game"))

View file

@ -4,11 +4,12 @@ import { WebSocketServer } from "ws"
import { appRouter } from "./trpc"
import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express"
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"
import { prismaClient } from "./prisma"
import * as dateFns from "date-fns"
const expressApp = createExpressApp()
expressApp.use(cookieParser())
@ -18,7 +19,14 @@ expressApp.use("/trpc", createTrpcMiddleware({
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 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"
export interface Context {
userId: string | null
user: {
id: string
name: string
} | null
res?: Response
}
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) {
const user = await prismaClient.user.findUnique({ where: { token: authenticationToken }, select: { id: true } })
if (user !== null) userId = user.id
user = await prismaClient.user.findUnique({
where: { token: authenticationToken },
select: {
id: true,
name: true
}
})
}
return {
userId,
user,
res
}
}
@ -24,13 +32,13 @@ export async function createContext(authenticationToken: string | null, res: 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" })
let user = ctx.user
if (user === null) throw new TRPCError({ code: "UNAUTHORIZED" })
return next({
ctx: {
...ctx,
userId
user
}
})
})

View file

@ -1,13 +1,17 @@
import { requireAuthentication, t } from "./base"
import { getActiveGameOfPlayer } from "../activeGame"
import { getNumberCardsSum } from "../../shared/game/state"
import { z } from "zod"
import { getGameByLobbyCode } from "../game"
const gameProcedure = t.procedure
.use(requireAuthentication)
.use(t.middleware(async ({ ctx, next }) => {
const game = getActiveGameOfPlayer(ctx.userId!)
.input(z.object({
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({
ctx: {
game
@ -19,13 +23,14 @@ 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}`)
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()
}),
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)
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.user.id)!.numberCards) > ctx.game.state.targetSum)
throw new Error("The player cannot hit when bust")
await ctx.game.hit()
@ -33,7 +38,7 @@ export const gameRouter = t.router({
stay: gameProcedure
.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()
})
})

View file

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

View file

@ -3,6 +3,10 @@ import type { SpecialCardType } from "./cards"
type PlayerAction = {
initiatingPlayerId: string
} & (
| {
type: "join"
name: string
}
| {
type: "hit"
}
@ -13,6 +17,9 @@ type PlayerAction = {
type: "use-special"
cardType: SpecialCardType
}
| {
type: "leave"
}
)
type ServerAction = {
@ -21,10 +28,6 @@ type ServerAction = {
| {
type: "start"
targetSum: number
players: Array<{
id: string
name: string
}>
}
| {
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 { GameAction } from "./gameActions"
import type { GameAction } from "./actions"
import { produce } from "immer"
import { specialCardTypes } from "./cards"
import { cloneDeep, tail, without } from "lodash-es"
@ -56,16 +56,19 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
}
switch (action.type) {
case "start":
state.players = action.players.map((player): GameStatePlayer => ({
id: player.id,
name: player.name,
case "join":
state.players.push({
id: action.initiatingPlayerId,
name: action.name,
numberCards: [],
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
stayed: false,
nextRoundCovert: false
}))
})
break
case "start":
state.phase = "running"
state.activePlayerId = state.players[0].id
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)
wsUrl.protocol = wsUrl.protocol.replace("http", "ws")
wsUrl.pathname = "/ws"
wsUrl.hash = ""
const wsClient = createWSClient({
url: wsUrl.href