commit #4
This commit is contained in:
parent
8f54f121f1
commit
b76bca9c57
22 changed files with 464 additions and 182 deletions
|
@ -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
7
pnpm-lock.yaml
generated
|
@ -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:
|
||||
|
|
|
@ -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])
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
token String @unique
|
||||
lastActivityDate DateTime @default(now())
|
||||
}
|
||||
|
|
30
src/App.vue
30
src/App.vue
|
@ -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,24 +67,20 @@
|
|||
</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(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
auth.fetchSelf().then(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const game = useGame()
|
||||
</script>
|
||||
|
|
13
src/auth.ts
13
src/auth.ts
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
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(),
|
||||
hit: () => trpcClient.game.hit.mutate(),
|
||||
stay: () => trpcClient.game.stay.mutate()
|
||||
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! })
|
||||
}
|
||||
})
|
|
@ -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>
|
||||
|
|
97
src/components/JoinScreen.vue
Normal file
97
src/components/JoinScreen.vue
Normal 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>
|
49
src/components/LoginScreen.vue
Normal file
49
src/components/LoginScreen.vue
Normal 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>
|
26
src/components/PreStartScreen.vue
Normal file
26
src/components/PreStartScreen.vue
Normal 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>
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<TextualInput v-model="value"/>
|
||||
<TextualInput :class="$style.root" v-model="value"/>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
|
|
|
@ -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)
|
||||
|
||||
// noinspection JSDeprecatedSymbols
|
||||
this._state = produceNewState(this._state, fullAction)
|
||||
this.sendAction(fullAction)
|
||||
|
||||
return 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"))
|
|
@ -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" })
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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 player’s 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 player’s 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 player’s turn")
|
||||
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the player’s turn")
|
||||
await ctx.game.stay()
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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"
|
6
src/shared/game/events.ts
Normal file
6
src/shared/game/events.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { GameAction } from "./actions"
|
||||
|
||||
export type GameEvent = {
|
||||
type: "action"
|
||||
action: GameAction
|
||||
}
|
|
@ -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
1
src/shared/lobbyCode.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const LOBBY_CODE_LENGTH = 5
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue