Update dependencies and fix issues

This commit is contained in:
Moritz Ruth 2025-03-02 23:22:12 +01:00
parent a200b46764
commit 14f4e87d9d
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
49 changed files with 3432 additions and 2757 deletions

View file

@ -1,6 +1,3 @@
/node_modules/ /node_modules
/dist/ /dist
/run/ /.idea
/.idea/
./src/server/.prisma
.env

View file

@ -1 +0,0 @@
DATABASE_FILE=file:./run/tapdb.sqlite

9
.gitignore vendored
View file

@ -1,6 +1,3 @@
/node_modules/ /node_modules
/dist/ /dist
/run/ /.idea
/.idea/
./src/server/.prisma
.env

1
.nvmrc
View file

@ -1 +0,0 @@
18

View file

@ -5,7 +5,6 @@ RUN npm install --global pnpm
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build:ui RUN pnpm build:ui
RUN mkdir -p /data RUN mkdir -p /data
RUN pnpm prisma generate
EXPOSE 4000 EXPOSE 4000
EXPOSE 3000 EXPOSE 3000

View file

@ -1,11 +1,8 @@
# Twenty-one # Twenty-one
> The game of Twenty-one in the browser with online-multiplayer and special cards.
> The game of Twenty-one, in the browser, with online-multiplayer and special cards
## Known issues ▶ [**twentyone.deltaa.xyz**](https://twentyone.deltaa.xyz)
- The `return-opponent` special card should not be allowed in the first round of a game.
## License ## License

View file

@ -1,6 +1,5 @@
import EventEmitter from "eventemitter3" import EventEmitter from "eventemitter3"
import type { GameAction } from "../shared/game/actions" import type { GameAction } from "../shared/game/actions"
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 { random } from "lodash-es" import { random } from "lodash-es"
@ -9,6 +8,7 @@ import { customAlphabet as createNanoIdWithCustomAlphabet } from "nanoid/non-sec
import { LOBBY_CODE_LENGTH, LOBBY_SIZE, SPECIAL_CARD_PROBABILITY } from "../shared/constants" import { LOBBY_CODE_LENGTH, LOBBY_SIZE, SPECIAL_CARD_PROBABILITY } from "../shared/constants"
import type { SpecialCardId } from "../shared/game/cards" import type { SpecialCardId } from "../shared/game/cards"
import { specialCardsMeta } from "../shared/game/cards" import { specialCardsMeta } from "../shared/game/cards"
import { usersById, usersByToken } from "./user"
const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH) const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
@ -80,18 +80,8 @@ export class Game extends EventEmitter<Events> {
super() super()
} }
private async getPlayers() { private getPlayers() {
return await prismaClient.user.findMany({ return [...this.lobbyPlayerIds.values()].map(id => usersById.get(id)!)
where: {
id: {
in: [...this.lobbyPlayerIds]
}
},
select: {
id: true,
name: true
}
})
} }
addPlayer(id: string, name: string) { addPlayer(id: string, name: string) {
@ -119,7 +109,7 @@ export class Game extends EventEmitter<Events> {
async start() { async start() {
if (this.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${this.state.phase}`) if (this.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${this.state.phase}`)
const players = await this.getPlayers() const players = this.getPlayers()
if (players.length < 2) throw new Error("At least two players are required for starting the game") if (players.length < 2) throw new Error("At least two players are required for starting the game")
this.addAction({ this.addAction({
@ -263,10 +253,17 @@ export class Game extends EventEmitter<Events> {
} }
private dealSpecialTo(playerId: string) { private dealSpecialTo(playerId: string) {
let cardId: SpecialCardId
// I know. Don't judge me.
do {
cardId = this.state.weightedSpecialCardIdList[random(0, this.state.weightedSpecialCardIdList.length - 1)]
} while (!specialCardsMeta[cardId].isAllowedInFirstRound)
this.addAction({ this.addAction({
type: "deal-special", type: "deal-special",
toPlayerId: playerId, toPlayerId: playerId,
cardId: this.state.weightedSpecialCardIdList[random(0, this.state.weightedSpecialCardIdList.length - 1)] cardId
}) })
} }

View file

@ -8,8 +8,6 @@ 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())
@ -19,14 +17,6 @@ expressApp.use("/trpc", createTrpcMiddleware({
createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res), createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res),
})) }))
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" })
@ -36,6 +26,11 @@ const wssTrpcHandler = applyWSSHandler({
createContext: ({ req }) => { createContext: ({ req }) => {
const cookies = parseCookie(req.headers.cookie ?? "") const cookies = parseCookie(req.headers.cookie ?? "")
return createContext(cookies.token ?? null, undefined) return createContext(cookies.token ?? null, undefined)
},
keepAlive: {
enabled: true,
pingMs: 30000,
pongWaitMs: 5000,
} }
}) })

View file

@ -1,12 +1,9 @@
import { createInputMiddleware, initTRPC, TRPCError } from "@trpc/server" import { initTRPC, TRPCError } from "@trpc/server"
import { prismaClient } from "../prisma"
import type { Response } from "express" import type { Response } from "express"
import { type User, usersByToken } from "../user"
export interface Context { export interface Context {
user: { user: User | null
id: string
name: string
} | null
res?: Response res?: Response
} }
@ -14,13 +11,7 @@ export async function createContext(authenticationToken: string | null, res: Res
let user: Context["user"] = null let user: Context["user"] = null
if (authenticationToken !== null && authenticationToken.length > 0) { if (authenticationToken !== null && authenticationToken.length > 0) {
user = await prismaClient.user.findUnique({ user = usersByToken.get(authenticationToken) ?? null
where: { token: authenticationToken },
select: {
id: true,
name: true
}
})
} }
return { return {

View file

@ -1,13 +1,13 @@
import { requireAuthentication, t } from "./base" import { requireAuthentication, t } from "./base"
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/actions" import type { GameAction } from "../../shared/game/actions"
import { isDev } from "../isDev" import { isDev } from "../isDev"
import { gameRouter } from "./game" import { gameRouter } from "./game"
import { nanoid } from "nanoid/async" import { nanoid } from "nanoid"
import { createGame, getGameByLobbyCode } from "../game" import { createGame, getGameByLobbyCode } from "../game"
import type { GameEvent } from "../../shared/game/events" import type { GameEvent } from "../../shared/game/events"
import { type User, usersById, usersByToken } from "../user"
export const appRouter = t.router({ export const appRouter = t.router({
game: gameRouter, game: gameRouter,
@ -17,20 +17,21 @@ export const appRouter = t.router({
name: z.string().min(1).max(20) name: z.string().min(1).max(20)
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user !== null) await prismaClient.user.delete({ where: { id: ctx.user.id } }) if (ctx.user !== null) {
usersById.delete(ctx.user.id)
usersByToken.delete(ctx.user.token)
}
const token = await nanoid(60) const newUser: User = {
const user = await prismaClient.user.create({ id: nanoid(16),
data: { token: nanoid(64),
name: input.name, name: input.name
token }
},
select: {
id: true
}
})
ctx.res!.cookie("token", token, { usersById.set(newUser.id, newUser)
usersByToken.set(newUser.token, newUser)
ctx.res!.cookie("token", newUser.token, {
maxAge: 60 * 60 * 24 * 365, maxAge: 60 * 60 * 24 * 365,
httpOnly: true, httpOnly: true,
secure: !isDev, secure: !isDev,
@ -38,7 +39,7 @@ export const appRouter = t.router({
}) })
return { return {
id: user.id id: newUser.id
} }
}), }),
@ -46,21 +47,8 @@ export const appRouter = t.router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.user === 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({ user: ctx.user
where: { id: ctx.user.id },
select: {
id: true,
name: true
}
})
} }
}), }),
@ -80,10 +68,10 @@ export const appRouter = t.router({
lobbyCode: z.string().nonempty() lobbyCode: z.string().nonempty()
})) }))
.subscription(async ({ input, ctx }) => { .subscription(async ({ input, ctx }) => {
const game = await getGameByLobbyCode(input.lobbyCode) const game = 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.")
await game.addPlayer(ctx.user.id, ctx.user.name) game.addPlayer(ctx.user.id, ctx.user.name)
return observable<GameEvent>(emit => { return observable<GameEvent>(emit => {
const handleBroadcastAction = (action: GameAction) => emit.next({ type: "action", action }) const handleBroadcastAction = (action: GameAction) => emit.next({ type: "action", action })

8
backend/user.ts Normal file
View file

@ -0,0 +1,8 @@
export interface User {
id: string
token: string
name: string
}
export const usersByToken = new Map<string, User>()
export const usersById = new Map<string, User>()

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10"> <div class="bg-gray-900 min-h-100vh overflow-y-auto text-white p-10 font-normal">
<div :class="$style.noise"/> <div :class="$style.noise"/>
<div :class="$style.vignette"/> <div :class="$style.vignette"/>
<div class="relative h-full"> <div class="relative">
<div v-if="isLoading" class="flex flex-col justify-center items-center text-4xl"> <div v-if="isLoading" class="flex flex-col justify-center items-center text-20">
<span>Loading</span> <span>Loading</span>
</div> </div>
<LoginScreen v-else-if="auth.authenticatedUser === null"/> <LoginScreen v-else-if="auth.authenticatedUser === null"/>
@ -16,7 +16,7 @@
<style module lang="scss"> <style module lang="scss">
.noise, .vignette { .noise, .vignette {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
@ -34,11 +34,16 @@
</style> </style>
<style> <style>
body { *, *::before, *::after {
box-sizing: border-box;
}
html, body, #app {
margin: 0;
padding: 0;
min-height: 100vh; min-height: 100vh;
width: 100vw; width: 100vw;
overflow-x: hidden; overflow-x: hidden;
font-size: 16px;
user-select: none; user-select: none;
} }

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,11 +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/actions" import type { GameAction } from "../shared/game/actions"
import { computed, reactive, readonly, ref } from "vue" import { computed, reactive, readonly, ref } from "vue"
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "./shared/game/state" import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state"
import { trpcClient } from "./trpc" import { trpcClient } from "./trpc"
import { useAuth } from "./auth" import { useAuth } from "./auth"
import type { SpecialCardId } from "./shared/game/cards" import type { SpecialCardId } from "../shared/game/cards"
const gameActionsBusKey = Symbol() as EventBusKey<GameAction> const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
const useGameActionsBus = () => useEventBus(gameActionsBusKey) const useGameActionsBus = () => useEventBus(gameActionsBusKey)

View file

@ -1,6 +1,6 @@
<template> <template>
<button <button
class="py-5 px-8 md:px-15 font-fat text-4xl md:text-5xl rounded-md shadow-lg text-center" class="py-5 px-8 md:px-15 font-fat text-4xl text-white md:text-5xl rounded<md shadow-lg text-center border-none cursor-pointer"
:class="$style.root" :class="$style.root"
:disabled="disabled" :disabled="disabled"
> >
@ -26,7 +26,7 @@
} }
&::after { &::after {
@apply bg-gradient-to-b from-dark-600 to-dark-900; background: linear-gradient(to bottom, #1c1c1e, #0f0f0f);
content: ""; content: "";
position: absolute; position: absolute;

View file

@ -1,5 +1,5 @@
<template> <template>
<component :is="asButton ? 'button' : 'div'" class="block flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br" :class="$style.root"> <component :is="asButton ? 'button' : 'div'" class="block flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br border-none text-inherit" :class="$style.root">
<div class="relative flex flex-col items-center justify-center h-full"> <div class="relative flex flex-col items-center justify-center h-full">
<div class="absolute top-2 right-2 bg-dark-800 rounded-full flex gap-2 items-center px-2"> <div class="absolute top-2 right-2 bg-dark-800 rounded-full flex gap-2 items-center px-2">
<div v-for="tag in tags" :key="tag.label" :title="tag.label"> <div v-for="tag in tags" :key="tag.label" :title="tag.label">
@ -29,6 +29,10 @@
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
&:is(button) {
cursor: pointer;
}
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="flex -md:flex-col gap-15 justify-center"> <div class="flex <md:flex-col gap-15 justify-center">
<div> <div>
<div class="font-bold text-lg pb-2 pl-2">Active special cards</div> <div class="font-bold text-lg pb-2 pl-2">Active special cards</div>
<div class="p-4 border border-dark-200 rounded-xl flex flex-col gap-5 flex-wrap w-full"> <div class="p-4 border border-dark-200 rounded-xl flex flex-col gap-5 flex-wrap w-full">
@ -17,7 +17,7 @@
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-14 max-w-1200px flex-grow -md:pl-3 pb-5"> <div class="flex flex-col gap-14 max-w-1200px flex-grow <md:pl-3 pb-5">
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/> <PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
<div class="flex gap-5 justify-end items-center transform transition ease duration-500"> <div class="flex gap-5 justify-end items-center transform transition ease duration-500">
<template v-if="game.state.phase === 'end'"> <template v-if="game.state.phase === 'end'">

View file

@ -1,7 +1,7 @@
<template> <template>
<Modal :is-active="isActive" @close-request="isActive = false"> <Modal :is-active="isActive" @close-request="isActive = false">
<div v-if="game.state.phase === 'end'" class="p-10 text-center"> <div v-if="game.state.phase === 'end'" class="p-10 text-center">
<div class="h-11 transform text-10xl -translate-y-35 opacity-90"> <div class="h-11 transform -translate-y-35 opacity-90 text-40">
<CrownIcon v-if="singleWinner !== null"/> <CrownIcon v-if="singleWinner !== null"/>
<HandshakeIcon v-else/> <HandshakeIcon v-else/>
</div> </div>
@ -32,15 +32,15 @@
import Modal from "./Modal.vue" import Modal from "./Modal.vue"
import CrownIcon from "virtual:icons/ph/crown-simple-duotone" import CrownIcon from "virtual:icons/ph/crown-simple-duotone"
import HandshakeIcon from "virtual:icons/ph/handshake-duotone" import HandshakeIcon from "virtual:icons/ph/handshake-duotone"
import { getNumberCardsSum } from "../shared/game/state" import { getNumberCardsSum } from "../../shared/game/state"
import { naturallyJoinEnumeration } from "../shared/util" import { naturallyJoinEnumeration } from "../../shared/util"
const game = useGame() const game = useGame()
const isActive = ref(false) const isActive = ref(false)
const singleWinner = computed(() => { const singleWinner = computed(() => {
const winnerIds = game.state.winnerIds const winnerIds = game.state.winnerIds
return winnerIds?.length === 1 ? game.state.players.find(p => p.id === winnerIds[0]) : null return winnerIds?.length === 1 ? game.state.players.find(p => p.id === winnerIds[0])! : null
}) })
useGameActionNotification(action => { useGameActionNotification(action => {

View file

@ -1,8 +1,8 @@
<template> <template>
<div class="flex flex-col h-full justify-center items-center gap-8 text-7xl md:text-8xl"> <div class="flex flex-col h-full justify-center items-center gap-8">
<input <input
v-model="lobbyCodeInput" v-model="lobbyCodeInput"
class="uppercase tracking-wider bg-transparent text-white text-center font-fat focus:outline-none pb-2 rounded-lg transition" class="uppercase tracking-wider border-none text-7xl md:text-8xl bg-transparent text-white text-center font-fat focus:outline-none pb-2 rounded-lg transition"
:class="isUnknown ? 'bg-red-800' : ''" :class="isUnknown ? 'bg-red-800' : ''"
:maxlength="LOBBY_CODE_LENGTH" :maxlength="LOBBY_CODE_LENGTH"
:placeholder="'X'.repeat(LOBBY_CODE_LENGTH)" :placeholder="'X'.repeat(LOBBY_CODE_LENGTH)"
@ -10,7 +10,7 @@
@keypress="onKeypress" @keypress="onKeypress"
/> />
<button <button
class="font-bold text-4xl border border-white rounded-md bg-white bg-opacity-0 transition px-10 py-4" class="font-bold text-inherit font-inherit text-4xl border border-solid border-gray-500 rounded<md bg-white bg-opacity-0 transition px-10 py-4"
:class="$style.button" :class="$style.button"
:disabled="lobbyCodeInput.length !== LOBBY_CODE_LENGTH" :disabled="lobbyCodeInput.length !== LOBBY_CODE_LENGTH"
@click="join()" @click="join()"
@ -18,7 +18,7 @@
Join Join
</button> </button>
<button <button
class="font-bold text-2xl border border-white rounded-md bg-white bg-opacity-0 transition px-6 py-2" class="font-bold text-inherit font-inherit text-2xl border border-solid border-gray-500 rounded<md bg-white bg-opacity-0 transition px-6 py-2"
:class="$style.button" :class="$style.button"
@click="create()" @click="create()"
> >
@ -33,16 +33,16 @@
cursor: not-allowed; cursor: not-allowed;
} }
&:not(:disabled) { &:not(:disabled):hover {
@apply hover:bg-opacity-20; --un-bg-opacity: 10%;
} }
} }
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue" import { computed, ref, watch, watchEffect } from "vue"
import { useGame } from "../clientGame" import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/constants" import { LOBBY_CODE_LENGTH } from "../../shared/constants"
import { useBrowserLocation } from "@vueuse/core" import { useBrowserLocation } from "@vueuse/core"
const location = useBrowserLocation() const location = useBrowserLocation()

View file

@ -2,12 +2,12 @@
<div class="flex flex-col h-full justify-center items-center gap-8 text-4xl md:text-8xl"> <div class="flex flex-col h-full justify-center items-center gap-8 text-4xl md:text-8xl">
<input <input
v-model="usernameInput" v-model="usernameInput"
class="bg-transparent text-white text-center font-fat w-full focus:outline-none" class="bg-transparent text-white text-6xl text-center font-fat w-full focus:outline-none border-none"
placeholder="Username" placeholder="Username"
maxlength="20" maxlength="20"
/> />
<button <button
class="font-bold text-2xl md:text-4xl border border-white rounded-md bg-white bg-opacity-0 transition px-10 py-4" class="font-bold font-inherit text-inherit text-2xl md:text-4xl border border-solid border-gray-500 rounded<md bg-white bg-opacity-0 transition px-10 py-4 cursor-pointer"
:class="$style.button" :class="$style.button"
:disabled="usernameInput.length <= 0 || usernameInput.length > 20" :disabled="usernameInput.length <= 0 || usernameInput.length > 20"
@click="submit()" @click="submit()"
@ -23,16 +23,16 @@
cursor: not-allowed; cursor: not-allowed;
} }
&:not(:disabled) { &:not(:disabled):hover {
@apply hover:bg-opacity-20; --un-bg-opacity: 10%;
} }
} }
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect } from "vue" import { ref, watchEffect } from "vue"
import { useGame } from "../clientGame" import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/constants" import { LOBBY_CODE_LENGTH } from "../../shared/constants"
import { useBrowserLocation } from "@vueuse/core" import { useBrowserLocation } from "@vueuse/core"
import { useAuth } from "../auth" import { useAuth } from "../auth"

View file

@ -19,13 +19,13 @@
<div class="flex items-center gap-5 w-full flex-wrap"> <div class="flex items-center gap-5 w-full flex-wrap">
<NumberCard <NumberCard
v-for="(card, index) in playerState.numberCards" v-for="(card, index) in playerState.numberCards"
:key="[index, card.number]" :key="[index, card.number].join('-')"
:number="card.number" :number="card.number"
:is-covert="card.isCovert" :is-covert="card.isCovert"
:is-own="isYou" :is-own="isYou"
/> />
</div> </div>
<div v-if="isYou" class="p-4 border border-dark-200 rounded-xl flex gap-5 flex-wrap w-full"> <div v-if="isYou" class="py-4 border border-dark-200 rounded-xl flex gap-5 flex-wrap w-full">
<div <div
v-if="playerState.specialCards.length === 0" v-if="playerState.specialCards.length === 0"
class="font-fat opacity-40 text-2xl h-32 flex px-4 py-5" class="font-fat opacity-40 text-2xl h-32 flex px-4 py-5"
@ -56,7 +56,7 @@
import { computed } from "vue" import { computed } from "vue"
import NumberCard from "./NumberCard.vue" import NumberCard from "./NumberCard.vue"
import { useAuth } from "../auth" import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state" import { getNumberCardsSum } from "../../shared/game/state"
import CrownIcon from "virtual:icons/ph/crown-simple-bold" import CrownIcon from "virtual:icons/ph/crown-simple-bold"
import DotsThreeOutlineIcon from "virtual:icons/ph/dots-three-outline" import DotsThreeOutlineIcon from "virtual:icons/ph/dots-three-outline"
import SpecialCard from "./SpecialCard.vue" import SpecialCard from "./SpecialCard.vue"

View file

@ -19,7 +19,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useGame } from "../clientGame" import { useGame } from "../clientGame"
import { naturallyJoinEnumeration } from "../shared/util" import { naturallyJoinEnumeration } from "../../shared/util"
import BigButton from "./BigButton.vue" import BigButton from "./BigButton.vue"
const game = useGame() const game = useGame()

View file

@ -24,9 +24,9 @@
<script setup lang="ts"> <script setup lang="ts">
import Card from "./Card.vue" import Card from "./Card.vue"
import type { SpecialCardId } from "../shared/game/cards" import type { SpecialCardId } from "../../shared/game/cards"
import { specialCardIcons } from "../icons" import { specialCardIcons } from "../icons"
import { specialCardsMeta } from "../shared/game/cards" import { specialCardsMeta } from "../../shared/game/cards"
import { computed } from "vue" import { computed } from "vue"
import { useGame } from "../clientGame" import { useGame } from "../clientGame"
import { useThrottleFn } from "@vueuse/core" import { useThrottleFn } from "@vueuse/core"

View file

@ -1,4 +1,4 @@
import type { SpecialCardId } from "./shared/game/cards" import type { SpecialCardId } from "../shared/game/cards"
import type { Component } from "vue" import type { Component } from "vue"
import ArrowArcRightIcon from "virtual:icons/ph/arrow-arc-right-bold" import ArrowArcRightIcon from "virtual:icons/ph/arrow-arc-right-bold"
import PlusCircleIcon from "virtual:icons/ph/plus-circle" import PlusCircleIcon from "virtual:icons/ph/plus-circle"

8
frontend/index.ts Normal file
View file

@ -0,0 +1,8 @@
import "uno.css"
import { createApp } from "vue"
import App from "./App.vue"
import { createPinia } from "pinia"
createApp(App)
.use(createPinia())
.mount("#app")

View file

@ -1,5 +1,5 @@
import { createTRPCProxyClient, httpLink, createWSClient, wsLink, splitLink } from "@trpc/client" import { createTRPCProxyClient, httpLink, createWSClient, wsLink, splitLink } from "@trpc/client"
import type { AppRouter } from "./server" import type { AppRouter } from "../backend"
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")

View file

@ -1,14 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>TwentyOne</title> <title>Twenty-one</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<script type="module" src="./src/index.ts"></script> <script type="module" src="./frontend/index.ts"></script>
<link rel="stylesheet" href="./node_modules/@fontsource/inter/variable.css"/> <link rel="stylesheet" href="./node_modules/@fontsource-variable/inter/wght.css"/>
<link rel="stylesheet" href="./node_modules/@fontsource/titan-one/400.css"/> <link rel="stylesheet" href="./node_modules/@fontsource/titan-one/400.css"/>
</head> <link rel="stylesheet" href="modern-normalize/modern-normalize.css"/>
<body> </head>
<div id="app"></div> <body>
</body> <div id="app"></div>
</body>
</html> </html>

View file

@ -3,54 +3,53 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start:ui": "vite preview --host --port 4000", "start": "NODE_ENV=production tsx ./backend/main.ts",
"start:server": "NODE_ENV=production tsx ./src/server/main.ts", "build": "vite build",
"build:ui": "vite build",
"dev:ui": "vite", "dev:ui": "vite",
"dev:server": "NODE_ENV=development tsx watch --clear-screen=false ./src/server/main.ts" "dev:server": "NODE_ENV=development tsx watch --clear-screen=false ./backend/main.ts"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/ph": "^1.1.5", "@iconify-json/ph": "^1.2.2",
"@types/cookie": "^0.5.1", "@types/express": "^5.0.0",
"@types/express": "^4.17.17", "@types/lodash-es": "^4.17.12",
"@types/lodash-es": "^4.17.7", "@types/node": "^22.13.8",
"@types/node": "18", "@types/ws": "^8.5.14",
"@types/ws": "^8.5.4", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "^4.1.0", "sass": "^1.85.1",
"prisma": "^4.13.0", "tsx": "^4.19.3",
"sass": "^1.62.0", "typescript": "^5.8.2",
"tsx": "^3.12.6", "unocss": "66.1.0-beta.3",
"typescript": "^4.9.5", "unplugin-icons": "^22.1.0",
"unplugin-icons": "^0.16.1", "vite": "^6.2.0",
"vite": "^4.2.2",
"vite-plugin-pages": "^0.29.0",
"vite-plugin-windicss": "^1.8.10",
"windicss": "^3.5.6" "windicss": "^3.5.6"
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^4.5.15", "@fontsource-variable/inter": "^5.2.5",
"@fontsource/titan-one": "^4.5.9", "@fontsource/titan-one": "^5.2.5",
"@headlessui/vue": "^1.7.13", "@headlessui/vue": "^1.7.23",
"@prisma/client": "^4.13.0", "@trpc/client": "^10.45.2",
"@trpc/client": "^10.20.0", "@trpc/server": "^10.45.2",
"@trpc/server": "^10.20.0", "@types/cookie-parser": "^1.4.8",
"@types/cookie-parser": "^1.4.3", "@vueuse/core": "^12.7.0",
"@vueuse/core": "^10.0.2", "@vueuse/integrations": "^12.7.0",
"@vueuse/integrations": "^10.0.2", "bufferutil": "^4.0.9",
"bufferutil": "^4.0.7", "cookie": "^1.0.2",
"cookie": "^0.5.0", "cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.6", "date-fns": "^4.1.0",
"date-fns": "^2.29.3", "eventemitter3": "^5.0.1",
"eventemitter3": "^5.0.0", "express": "^4.21.2",
"express": "^4.18.2", "immer": "^10.1.1",
"immer": "^10.0.1", "listhen": "^1.9.0",
"listhen": "^1.0.4",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nanoid": "^4.0.2", "modern-normalize": "^3.0.1",
"pinia": "^2.0.34", "nanoid": "^5.1.2",
"vue": "^3.2.47", "pinia": "^3.0.1",
"vue-router": "^4.1.6", "vue": "^3.5.13",
"ws": "^8.13.0", "vue-router": "^4.5.0",
"zod": "^3.21.4" "ws": "^8.18.1",
"zod": "^3.24.2"
},
"engines": {
"node": "22"
} }
} }

5758
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
datasource db {
url = env("DATABASE_FILE")
provider = "sqlite"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String
token String @unique
lastActivityDate DateTime @default(now())
}

View file

@ -1,16 +0,0 @@
import "virtual:windi.css"
import { createApp } from "vue"
import { createRouter, createWebHistory } from "vue-router"
import App from "./App.vue"
import routes from "virtual:generated-pages"
import { createPinia } from "pinia"
const router = createRouter({
routes,
history: createWebHistory()
})
createApp(App)
.use(router)
.use(createPinia())
.mount("#app")

View file

@ -1,5 +0,0 @@
import { PrismaClient } from "@prisma/client"
export const prismaClient = new PrismaClient()
export type { Prisma } from "@prisma/client"

5
src/types.d.ts vendored
View file

@ -1,5 +0,0 @@
declare module "*.vue" {
import { ConcreteComponent } from "vue"
const C: ConcreteComponent
export default C
}

View file

@ -1,31 +1,31 @@
{ {
"compilerOptions": { "compilerOptions": {
"declaration": true, "declaration": false,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"jsx": "preserve", "jsx": "preserve",
"lib": ["esnext", "dom"], "lib": ["esnext", "dom"],
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "bundler",
"allowJs": true, "allowJs": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"importsNotUsedAsValues": "error", "importsNotUsedAsValues": "error",
"isolatedModules": true, "isolatedModules": true,
"rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": false,
"strict": true, "strict": true,
"stripInternal": true, "stripInternal": true,
"target": "esnext", "target": "esnext",
"types": [ "types": [
"./src/types.d.ts",
"vite/client", "vite/client",
"unplugin-icons/types/vue", "unplugin-icons/types/vue",
"vite-plugin-pages/client" "vite-plugin-pages/client"
] ]
}, },
"include": [ "include": [
"src/**/*.ts", "backend/**/*.ts",
"src/**/*.vue" "shared/**/*.ts",
"frontend/**/*.ts",
"frontend/**/*.vue"
] ]
} }

View file

@ -1,16 +1,22 @@
import { defineConfig } from "vite-plugin-windicss" import { defineConfig, presetWind } from "unocss"
import colors from "windicss/colors" import colors from "windicss/colors"
export default defineConfig({ export default defineConfig({
presets: [
presetWind({
arbitraryVariants: true,
preflight: true
})
],
theme: { theme: {
fontFamily: { fontFamily: {
"normal": ["InterVariable", "sans-serif"], "normal": `"Inter Variable", sans-serif`,
"fat": ["Titan One", "sans-serif"] "fat": `"Titan One", sans-serif`
}, },
colors: { colors: {
transparent: "transparent", transparent: "transparent",
white: "white", white: colors.white,
black: "black", black: colors.black,
gray: colors.zinc, gray: colors.zinc,
light: colors.light, light: colors.light,
dark: colors.dark, dark: colors.dark,
@ -20,4 +26,4 @@ export default defineConfig({
blue: colors.indigo, blue: colors.indigo,
} }
} }
}) })

View file

@ -1,21 +1,19 @@
import { defineConfig } from "vite" import { defineConfig } from "vite"
import vuePlugin from "@vitejs/plugin-vue" import vuePlugin from "@vitejs/plugin-vue"
import iconsPlugin from "unplugin-icons/vite" import iconsPlugin from "unplugin-icons/vite"
import pagesPlugin from "vite-plugin-pages" import unoCssPlugin from "unocss/vite"
import windiPlugin from "vite-plugin-windicss"
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vuePlugin(), vuePlugin(),
iconsPlugin(), iconsPlugin(),
pagesPlugin(), unoCssPlugin()
windiPlugin()
], ],
server: { server: {
proxy: { proxy: {
"/trpc": "http://127.0.0.1:3000", "/trpc": "http://localhost:3000",
"/ws": { "/ws": {
target: "http://127.0.0.1:3000", target: "http://localhost:3000",
ws: true ws: true
} }
} }