diff --git a/index.html b/index.html
index d869144..8962d4f 100644
--- a/index.html
+++ b/index.html
@@ -2,13 +2,13 @@
- TapDB
+ TwentyOne
-
+
diff --git a/package.json b/package.json
index 017b8c1..83e0c47 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"bufferutil": "^4.0.7",
"eventemitter3": "^5.0.0",
"express": "^4.18.2",
+ "immer": "^10.0.1",
"listhen": "^1.0.4",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e1da4fe..ed741a0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,6 +21,7 @@ importers:
bufferutil: ^4.0.7
eventemitter3: ^5.0.0
express: ^4.18.2
+ immer: ^10.0.1
listhen: ^1.0.4
lodash-es: ^4.17.21
nanoid: ^4.0.2
@@ -48,6 +49,7 @@ importers:
bufferutil: 4.0.7
eventemitter3: 5.0.0
express: 4.18.2
+ immer: 10.0.1
listhen: 1.0.4
lodash-es: 4.17.21
nanoid: 4.0.2
@@ -1949,6 +1951,10 @@ packages:
safer-buffer: 2.1.2
dev: false
+ /immer/10.0.1:
+ resolution: {integrity: sha512-zg++jJLsKKTwXGeSYIw0HgChSYQGtu0UDTnbKx5aGLYgte4CwTmH9eJDYyQ6FheyUtBe+lQW9FrGxya1G+Dtmg==}
+ dev: false
+
/immutable/4.2.4:
resolution: {integrity: sha512-WDxL3Hheb1JkRN3sQkyujNlL/xRjAo3rJtaU5xeufUauG66JdMr32bLj4gF+vWl84DIA3Zxw7tiAjneYzRRw+w==}
dev: true
diff --git a/schema.prisma b/schema.prisma
index 1d6a812..290faf6 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -15,8 +15,9 @@ model Game {
}
model Player {
- id String @id @default(cuid())
- name String
+ id String @id @default(cuid())
+ name String
+ token String @unique
gameActions GameAction[]
}
diff --git a/src/server/game.ts b/src/server/game.ts
index 0fcef64..60cf8ca 100644
--- a/src/server/game.ts
+++ b/src/server/game.ts
@@ -1,14 +1,29 @@
import EventEmitter from "eventemitter3"
-import type { GameAction } from "../shared/gameActions"
+import type { GameAction } from "../shared/game/gameActions"
-export const gameActionsBus = new EventEmitter>()
+const gamesById = new Map()
+
+export function getGameById(id: string): Game {
+ return gamesById.get(id)!
+}
interface Events {
- broadcastAction: [GameAction]
+ public_action: [GameAction]
+ private_action: [string, GameAction]
+ destroyed: []
}
export class Game extends EventEmitter {
constructor(public id: string) {
super()
}
+
+ start() {
+
+ }
+
+ private destroy() {
+ gamesById.delete(this.id)
+ this.emit("destroyed")
+ }
}
\ No newline at end of file
diff --git a/src/server/main.ts b/src/server/main.ts
index a5603a0..a8c1a05 100644
--- a/src/server/main.ts
+++ b/src/server/main.ts
@@ -11,14 +11,18 @@ const expressApp = createExpressApp()
expressApp.use("/trpc", createTrpcMiddleware({
router: appRouter,
- createContext
+ createContext: ({ req }) => createContext(req.headers.authorization)
}))
await seedDatabase()
const { server } = await listen(expressApp)
const wss = new WebSocketServer({ server, path: "/ws" })
-const wssTrpcHandler = applyWSSHandler({ wss, createContext, router: appRouter })
+const wssTrpcHandler = applyWSSHandler({
+ wss,
+ router: appRouter,
+ createContext: ({ req }) => createContext(req.headers.authorization)
+})
process.on("SIGTERM", () => {
console.log("Received SIGTERM")
diff --git a/src/server/seed.ts b/src/server/seed.ts
index 0a92e58..aa2e1e6 100644
--- a/src/server/seed.ts
+++ b/src/server/seed.ts
@@ -1,3 +1,28 @@
-export async function seedDatabase() {
+import { prismaClient } from "./prisma"
+export async function seedDatabase() {
+ if (await prismaClient.player.count() === 0) {
+ await prismaClient.player.create({
+ data: {
+ name: "max",
+ token: "max"
+ }
+ })
+
+ await prismaClient.player.create({
+ data: {
+ name: "moritz",
+ token: "moritz"
+ }
+ })
+ }
+
+ if (await prismaClient.game.count() === 0) {
+ await prismaClient.game.create({
+ data: {
+ id: "game",
+ lobbyCodeIfActive: "game"
+ }
+ })
+ }
}
\ No newline at end of file
diff --git a/src/server/trpc/base.ts b/src/server/trpc/base.ts
index dfa7cbb..8a469b5 100644
--- a/src/server/trpc/base.ts
+++ b/src/server/trpc/base.ts
@@ -1,11 +1,22 @@
import { initTRPC } from "@trpc/server"
+import { prismaClient } from "../prisma"
export interface Context {
-
+ playerId: string
}
-export async function createContext(): Promise {
- return {}
+export async function createContext(authorizationHeader: string | undefined): Promise {
+ const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters
+
+ if (token !== null && token.length > 0) {
+ const player = await prismaClient.player.findUnique({ where: { token }, select: { id: true } })
+
+ if (player !== null) return {
+ playerId: player.id
+ }
+ }
+
+ throw new Error("Invalid token")
}
export const t = initTRPC.context().create()
diff --git a/src/server/trpc/index.ts b/src/server/trpc/index.ts
index cadd63f..92bb5a0 100644
--- a/src/server/trpc/index.ts
+++ b/src/server/trpc/index.ts
@@ -1,16 +1,38 @@
import { t } from "./base"
import { prismaClient } from "../prisma"
-import type { Prisma } from "../prisma"
import z from "zod"
+import { observable } from "@trpc/server/observable"
+import type { GameAction } from "../../shared/game/gameActions"
+import { getGameById } from "../game"
export const appRouter = t.router({
- create: t.procedure
- .input(z.object({
- skip: z.number().int().min(0),
- groupId: z.string().cuid().optional()
- }))
- .query(async ({ input }) => {
+ start: t.procedure
+ .mutation(async ({ ctx }) => {
+ }),
+
+ join: t.procedure
+ .input(z.object({
+ gameId: z.string().nonempty()
+ }))
+ .subscription(({ input, ctx }) => {
+ const game = getGameById(input.gameId)
+
+ return observable(emit => {
+ const handlePublicAction = (action: GameAction) => emit.next(action)
+
+ const handlePrivateAction = (playerId: string, action: GameAction) => {
+ if (playerId === ctx.playerId) emit.next(action)
+ }
+
+ game.on("public_action", handlePublicAction)
+ game.on("private_action", handlePrivateAction)
+
+ return () => {
+ game.off("public_action", handlePublicAction)
+ game.off("private_action", handlePrivateAction)
+ }
+ })
})
})
diff --git a/src/shared/game/cards.ts b/src/shared/game/cards.ts
index df64960..97c4db0 100644
--- a/src/shared/game/cards.ts
+++ b/src/shared/game/cards.ts
@@ -1,8 +1,12 @@
-export type SpecialCardType =
- | "return-last-opponent"
- | "return-last-own"
- | "destroy-last-opponent-special"
- | "increase-target-by-2"
- | "next-round-covert"
- | "double-draw"
- | "add-2-opponent"
\ No newline at end of file
+const specialCardTypesObject = {
+ "return-last-opponent": true,
+ "return-last-own": true,
+ "destroy-last-opponent-special": true,
+ "increase-target-by-2": true,
+ "next-round-covert": true,
+ "double-draw": true,
+ "add-2-opponent": true
+}
+
+export const specialCardTypes = Object.keys(specialCardTypesObject)
+export type SpecialCardType = keyof typeof specialCardTypesObject
\ No newline at end of file
diff --git a/src/shared/game/gameActions.ts b/src/shared/game/gameActions.ts
index b0f133d..d49d68b 100644
--- a/src/shared/game/gameActions.ts
+++ b/src/shared/game/gameActions.ts
@@ -15,12 +15,11 @@ type PlayerAction = {
}
)
-type ServerAction = {
- initiatingPlayerId: null
-} & (
+type ServerAction =
| {
type: "start"
- cardsByPlayerId: Record // if redacted: only contains the player themself
+ numberCardsByPlayerId: Record // if redacted: only contains the player themself
+ targetSum: number
}
| {
type: "deal-number"
@@ -32,7 +31,6 @@ type ServerAction = {
toPlayerId: string
cardType: SpecialCardType
}
-)
export type GameAction = {
index: number
diff --git a/src/shared/game/state.ts b/src/shared/game/state.ts
index e69de29..0ca355d 100644
--- a/src/shared/game/state.ts
+++ b/src/shared/game/state.ts
@@ -0,0 +1,106 @@
+import type { SpecialCardType } from "./cards"
+import type { GameAction } from "./gameActions"
+import { produce } from "immer"
+import { specialCardTypes } from "./cards"
+
+type SpecialCardCountByType = Record
+
+export interface GameStatePlayer {
+ id: string
+ numberCards: number[]
+ specialCardCountByType: SpecialCardCountByType
+ stayed: boolean
+ nextRoundCovert: boolean
+}
+
+export interface GameState {
+ players: GameStatePlayer[]
+ activeSpecialCardCountByType: SpecialCardCountByType
+ activePlayerId: string
+ targetSum: number
+}
+
+const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType
+
+const UNINITIALIZED_GAME_STATE: GameState = {
+ players: [],
+ activePlayerId: "",
+ targetSum: 0,
+ activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS
+}
+
+export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => {
+ switch (action.type) {
+ case "start":
+ state.players = Object.entries(action.numberCardsByPlayerId).map(([playerId, numberCards]): GameStatePlayer => ({
+ id: playerId,
+ numberCards,
+ specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
+ stayed: false,
+ nextRoundCovert: false
+ }))
+
+ state.activePlayerId = state.players[0].id
+ state.targetSum = action.targetSum
+ state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS
+
+ break
+
+ case "deal-number":
+ const p1 = state.players.find(p => p.id === action.toPlayerId)!
+ p1.numberCards.push(action.number)
+ break
+
+ case "deal-special":
+ const p2 = state.players.find(p => p.id === action.toPlayerId)!
+ p2.specialCardCountByType[action.cardType]++
+ break
+
+ case "draw":
+ case "stay":
+ const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId)
+
+ // activate the next player
+ state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id
+ break
+
+ case "use-special":
+ const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
+ applySpecialCardUsage(state, action.cardType, p3)
+ break
+ }
+})
+
+function applySpecialCardUsage(state: GameState, type: SpecialCardType, player: GameStatePlayer) {
+ player.specialCardCountByType[type]--
+
+ switch (type) {
+ case "add-2-opponent":
+ // nothing
+ break
+
+ case "destroy-last-opponent-special":
+ // TODO
+ break
+
+ case "double-draw":
+ // nothing
+ break
+
+ case "increase-target-by-2":
+ state.targetSum += 2
+ break
+
+ case "next-round-covert":
+ player.nextRoundCovert = true
+ break
+
+ case "return-last-opponent":
+ // TODO
+ break
+
+ case "return-last-own":
+ // TODO
+ break
+ }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 22fd899..7724feb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,7 +13,11 @@ export default defineConfig({
],
server: {
proxy: {
- "/trpc": "http://127.0.0.1:3000"
+ "/trpc": "http://127.0.0.1:3000",
+ "/ws": {
+ target: "http://127.0.0.1:3000",
+ ws: true
+ }
}
}
})