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",
|
"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
7
pnpm-lock.yaml
generated
|
@ -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:
|
||||||
|
|
|
@ -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])
|
|
||||||
}
|
}
|
||||||
|
|
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="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>
|
||||||
|
|
13
src/auth.ts
13
src/auth.ts
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -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! })
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -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>
|
||||||
|
|
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>
|
<template>
|
||||||
<TextualInput v-model="value"/>
|
<TextualInput :class="$style.root" v-model="value"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
|
|
@ -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,8 +46,8 @@ 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,
|
|
||||||
index: fullAction.index,
|
|
||||||
data: JSON.stringify(fullAction)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// noinspection JSDeprecatedSymbols
|
this.sendAction(fullAction)
|
||||||
this._state = produceNewState(this._state, fullAction)
|
|
||||||
|
|
||||||
|
return 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"))
|
|
|
@ -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" })
|
||||||
|
|
|
@ -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"
|
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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -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 player’s turn")
|
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.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 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()
|
await ctx.game.stay()
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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"
|
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 { 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
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)
|
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
|
||||||
|
|
Loading…
Add table
Reference in a new issue