From 62055855e56587fd9dc197933d07a93ae659a7f6 Mon Sep 17 00:00:00 2001 From: MasterGordon Date: Sat, 21 Sep 2024 12:12:39 +0200 Subject: [PATCH] last version before 2.0 --- .gitignore | 3 +- backend/controller/controller.ts | 37 +++++ backend/controller/gameController.ts | 45 ++++++ backend/entities/game.ts | 53 +++++++ backend/errors/BadRequestError.ts | 6 + backend/errors/UnauthorizedError.ts | 6 + backend/events.ts | 39 +++++ backend/index.ts | 76 +--------- backend/repositories/gameRepository.test.ts | 139 ++++++++++++++++++ backend/repositories/gameRepository.ts | 76 ++++++++++ .../scoreRepository.test.ts} | 12 +- .../scoreRepository.ts} | 4 +- .../userRepository.test.ts} | 4 +- .../userRepository.ts} | 0 backend/router.ts | 37 +++++ backend/schema.ts | 2 +- bun.lockb | Bin 101782 -> 102117 bytes package.json | 1 + shared/game.test.ts | 74 ++++++++++ shared/game.ts | 68 +++++++++ src/App.tsx | 14 +- src/GameState.ts | 45 ++---- src/ws.ts | 2 + tsconfig.app.json | 2 +- tsconfig.node.tsbuildinfo | 1 - 25 files changed, 627 insertions(+), 119 deletions(-) create mode 100644 backend/controller/controller.ts create mode 100644 backend/controller/gameController.ts create mode 100644 backend/entities/game.ts create mode 100644 backend/errors/BadRequestError.ts create mode 100644 backend/errors/UnauthorizedError.ts create mode 100644 backend/events.ts create mode 100644 backend/repositories/gameRepository.test.ts create mode 100644 backend/repositories/gameRepository.ts rename backend/{services/score.test.ts => repositories/scoreRepository.test.ts} (79%) rename backend/{services/score.ts => repositories/scoreRepository.ts} (78%) rename backend/{services/user.test.ts => repositories/userRepository.test.ts} (93%) rename backend/{services/user.ts => repositories/userRepository.ts} (100%) create mode 100644 backend/router.ts create mode 100644 shared/game.test.ts create mode 100644 shared/game.ts delete mode 100644 tsconfig.node.tsbuildinfo diff --git a/.gitignore b/.gitignore index 93d40db..497553e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,13 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* -tsconfig.app.tsbuildinfo +tsconfig.*.tsbuildinfo node_modules dist dist-ssr *.local +temp_dbs # Editor directories and files .vscode/* diff --git a/backend/controller/controller.ts b/backend/controller/controller.ts new file mode 100644 index 0000000..d1a70e2 --- /dev/null +++ b/backend/controller/controller.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import type { z } from "zod"; + +interface RequestContext { + user?: string; + db: BunSQLiteDatabase; +} + +export type Endpoint = { + validate: z.ZodType; + handler: (input: TInput, context: RequestContext) => Promise; +}; + +export type Request> = { + method: "POST"; + url: string; + body: z.infer; +}; + +export const createEndpoint = ( + validate: z.ZodType, + handler: (input: TInput, context: RequestContext) => Promise, +): Endpoint => { + return { validate, handler }; +}; + +export type Controller>> = + TEndpoints; + +export const createController = < + TEndpoints extends Record>, +>( + endpoints: TEndpoints, +): Controller => { + return endpoints; +}; diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts new file mode 100644 index 0000000..484a560 --- /dev/null +++ b/backend/controller/gameController.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { createController, createEndpoint } from "./controller"; +import { getGame, upsertGameState } from "../repositories/gameRepository"; +import { + serverGame, + serverToClientGame, + type ServerGame, +} from "../../shared/game"; +import crypto from "crypto"; +import { game } from "../entities/game"; +import { UnauthorizedError } from "../errors/UnauthorizedError"; +import { emit } from "../events"; + +export const gameController = createController({ + getGameState: createEndpoint(z.string(), async (uuid, ctx) => { + const game = await getGame(ctx.db, uuid); + const parsed = JSON.parse(game.gameState); + const gameState = await serverGame.parseAsync(parsed); + if (game.finished) return gameState; + return serverToClientGame(gameState); + }), + createGame: createEndpoint(z.undefined(), async (_, { user, db }) => { + if (!user) throw new UnauthorizedError("Unauthorized"); + const uuid = crypto.randomUUID() as string; + const newGame: ServerGame = game.createGame({ + uuid, + user: user, + mines: 2, + width: 4, + height: 4, + }); + upsertGameState(db, newGame); + emit({ + type: "new", + user, + }); + emit({ + type: "updateStage", + game: uuid, + stage: newGame.stage, + started: newGame.started, + }); + return newGame; + }), +}); diff --git a/backend/entities/game.ts b/backend/entities/game.ts new file mode 100644 index 0000000..783cfb7 --- /dev/null +++ b/backend/entities/game.ts @@ -0,0 +1,53 @@ +import type { ServerGame } from "../../shared/game"; + +interface CreateGameOptions { + uuid: string; + user: string; + width: number; + height: number; + mines: number; +} + +export const game = { + createGame: (options: CreateGameOptions): ServerGame => { + const { uuid, user, width, height, mines } = options; + if (mines > width * height) { + throw new Error("Too many mines"); + } + + const minesArray = Array.from({ length: width }, () => + new Array(height).fill(false), + ); + const isRevealedArray = Array.from({ length: width }, () => + new Array(height).fill(false), + ); + const isFlaggedArray = Array.from({ length: width }, () => + new Array(height).fill(false), + ); + + let remainingMines = mines; + while (remainingMines > 0) { + const x = Math.floor(Math.random() * width); + const y = Math.floor(Math.random() * height); + if (!minesArray[x][y]) { + minesArray[x][y] = true; + remainingMines--; + } + } + + return { + uuid, + user, + finished: 0, + started: Date.now(), + width, + height, + mines: minesArray, + isRevealed: isRevealedArray, + isFlagged: isFlaggedArray, + stage: 1, + lastClick: [-1, -1], + minesCount: mines, + }; + }, +}; diff --git a/backend/errors/BadRequestError.ts b/backend/errors/BadRequestError.ts new file mode 100644 index 0000000..2259c3e --- /dev/null +++ b/backend/errors/BadRequestError.ts @@ -0,0 +1,6 @@ +export class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "BadRequestError"; + } +} diff --git a/backend/errors/UnauthorizedError.ts b/backend/errors/UnauthorizedError.ts new file mode 100644 index 0000000..b2d50ff --- /dev/null +++ b/backend/errors/UnauthorizedError.ts @@ -0,0 +1,6 @@ +export class UnauthorizedError extends Error { + constructor(message: string) { + super(message); + this.name = "UnauthorizedError"; + } +} diff --git a/backend/events.ts b/backend/events.ts new file mode 100644 index 0000000..a64480d --- /dev/null +++ b/backend/events.ts @@ -0,0 +1,39 @@ +import type { ClientGame } from "../shared/game"; + +export type EventType = "new" | "finished" | "updateGame" | "updateStage"; + +type Events = + | { + type: "new"; + user: string; + } + | { + type: "loss"; + user: string; + stage: number; + } + | { + type: "updateGame"; + game: string; + data: ClientGame; + } + | { + type: "updateStage"; + game: string; + stage: number; + started: number; + }; + +const listeners = new Set<(event: Events) => void>(); + +export const on = (listener: (event: Events) => void) => { + listeners.add(listener); +}; + +export const off = (listener: (event: Events) => void) => { + listeners.delete(listener); +}; + +export const emit = (event: Events) => { + listeners.forEach((listener) => listener(event)); +}; diff --git a/backend/index.ts b/backend/index.ts index c211b8c..f5872d6 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,28 +1,12 @@ import type { ServerWebSocket } from "bun"; -interface Scoreboard { - stage: number; - user: string; -} - -const loadScoreboard = async (): Promise => { - try { - const scoreboardFile = Bun.file("./scoreboard.json"); - const scoreboard = await scoreboardFile.json(); - return scoreboard; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - return []; - } -}; - const allowCors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; -const lastMessage = new WeakMap, number>(); +const userName = new WeakMap, string>(); const server = Bun.serve({ async fetch(request: Request) { if (request.method === "OPTIONS") { @@ -32,67 +16,23 @@ const server = Bun.serve({ if (request.url.endsWith("ws")) { if (server.upgrade(request)) return new Response("ok"); } - if (new URL(request.url).pathname === "/submit") { - const body = await request.text(); - const data = JSON.parse(body) as { stage: number; user: string }; - const scoreboardFile = Bun.file("./scoreboard.json"); - const scoreboard = await loadScoreboard(); - const currentScore = scoreboard.find((s) => s.user === data.user); - if (currentScore) { - if (currentScore.stage < data.stage) { - currentScore.stage = data.stage; - Bun.write(scoreboardFile, JSON.stringify(scoreboard)); - } - return new Response(JSON.stringify(currentScore), { - headers: { - "content-type": "application/json", - ...allowCors, - }, - }); - } - scoreboard.push(data); - Bun.write(scoreboardFile, JSON.stringify(scoreboard)); - return new Response(JSON.stringify(data), { - headers: { - "content-type": "application/json", - ...allowCors, - }, - }); - } - const scoreboard = await loadScoreboard(); - const sorted = scoreboard.sort((a, b) => b.stage - a.stage); - return new Response(JSON.stringify(sorted), { - headers: { - "content-type": "application/json", - ...allowCors, - }, - }); }, websocket: { message: (ws, message) => { if (typeof message !== "string") { return; } - const msg = JSON.parse(message); - const now = Date.now(); - if (lastMessage.has(ws) && now - lastMessage.get(ws)! < 200) { + const user = userName.get(ws); + try { + const msg = JSON.parse(message); + console.log(msg); + } catch (e) { + console.error("Faulty request", message, e); return; } - lastMessage.set(ws, now); - if (msg.type === "loss") { - server.publish( - "minesweeper", - JSON.stringify({ type: "loss", user: msg.user, stage: msg.stage }), - ); - } else if (msg.type === "new") { - server.publish( - "minesweeper", - JSON.stringify({ type: "new", user: msg.user }), - ); - } }, open: async (ws) => { - ws.subscribe("minesweeper"); + ws.subscribe("minesweeper-global"); }, }, port: 8076, diff --git a/backend/repositories/gameRepository.test.ts b/backend/repositories/gameRepository.test.ts new file mode 100644 index 0000000..76a9525 --- /dev/null +++ b/backend/repositories/gameRepository.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "bun:test"; +import { getTestDb } from "../database/getTestDb"; +import { Game } from "../schema"; +import { + getCurrentGame, + getGame, + getGamesCount, + upsertGame, +} from "./gameRepository"; + +describe("GameRepository", () => { + it("should get game by uuid", async () => { + const db = getTestDb(); + const started = Date.now(); + await db.insert(Game).values({ + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + const game = await getGame(db, "TestUuid"); + expect(game).toEqual({ + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + }); + + it("should return undefined if game does not exist", async () => { + const db = getTestDb(); + const game = await getGame(db, "TestUuid"); + expect(game).toBeUndefined(); + }); + + it("should return latest game", async () => { + const db = getTestDb(); + const started = Date.now(); + await db.insert(Game).values({ + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + await db.insert(Game).values({ + uuid: "TestUuid2", + user: "TestUser", + stage: 2, + gameState: "ANY", + finished: 1, + started: started + 1, + }); + const game = await getCurrentGame(db, "TestUser"); + expect(game).toEqual({ + uuid: "TestUuid2", + user: "TestUser", + stage: 2, + gameState: "ANY", + finished: 1, + started: started + 1, + }); + }); + + it("should return game count", async () => { + const db = getTestDb(); + const started = Date.now(); + const uuids = ["TestUuid", "TestUuid2", "TestUuid3"]; + for (const uuid of uuids) { + await db.insert(Game).values({ + uuid, + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + } + const count = await getGamesCount(db, "TestUser"); + expect(count).toEqual(3); + }); + + it("should insert game", async () => { + const db = getTestDb(); + const started = Date.now(); + await upsertGame(db, { + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + const game = await getGame(db, "TestUuid"); + expect(game).toEqual({ + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + }); + + it("should update game", async () => { + const db = getTestDb(); + const started = Date.now(); + await db.insert(Game).values({ + uuid: "TestUuid", + user: "TestUser", + stage: 1, + gameState: "ANY", + finished: 1, + started, + }); + await upsertGame(db, { + uuid: "TestUuid", + user: "TestUser", + stage: 2, + gameState: "ANY", + finished: 1, + started: started + 1, + }); + const game = await getGame(db, "TestUuid"); + expect(game).toEqual({ + uuid: "TestUuid", + user: "TestUser", + stage: 2, + gameState: "ANY", + finished: 1, + started: started + 1, + }); + }); +}); diff --git a/backend/repositories/gameRepository.ts b/backend/repositories/gameRepository.ts new file mode 100644 index 0000000..23cf456 --- /dev/null +++ b/backend/repositories/gameRepository.ts @@ -0,0 +1,76 @@ +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import { Game, type GameType } from "../schema"; +import { eq, sql, desc, and, not } from "drizzle-orm"; +import type { ServerGame } from "../../shared/game"; + +export const getGame = async (db: BunSQLiteDatabase, uuid: string) => { + return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0]; +}; + +export const getGames = async (db: BunSQLiteDatabase, user: string) => { + return await db + .select() + .from(Game) + .where(and(eq(Game.user, user), not(eq(Game.finished, 0)))) + .orderBy(Game.started, sql`desc`); +}; + +export const getCurrentGame = async (db: BunSQLiteDatabase, user: string) => { + return ( + await db + .select() + .from(Game) + .where(eq(Game.user, user)) + .orderBy(desc(Game.started)) + .limit(1) + )[0]; +}; + +export const getGamesCount = async (db: BunSQLiteDatabase, user: string) => { + return ( + await db + .select({ count: sql`count(*)` }) + .from(Game) + .where(eq(Game.user, user)) + )[0].count; +}; + +export const upsertGame = async (db: BunSQLiteDatabase, game: GameType) => { + const { uuid, user, stage, gameState, finished, started } = game; + const games = await db.select().from(Game).where(eq(Game.uuid, uuid)); + if (games.length > 0) { + await db + .update(Game) + .set({ + stage, + gameState, + finished, + started, + }) + .where(eq(Game.uuid, uuid)); + } else { + await db.insert(Game).values({ + uuid, + user, + stage, + gameState, + finished, + started, + }); + } +}; + +export const upsertGameState = async ( + db: BunSQLiteDatabase, + game: ServerGame, +) => { + const { uuid, user, stage, finished, started } = game; + upsertGame(db, { + uuid, + user, + stage, + gameState: JSON.stringify(game), + finished, + started, + }); +}; diff --git a/backend/services/score.test.ts b/backend/repositories/scoreRepository.test.ts similarity index 79% rename from backend/services/score.test.ts rename to backend/repositories/scoreRepository.test.ts index 72fc1ed..c48391d 100644 --- a/backend/services/score.test.ts +++ b/backend/repositories/scoreRepository.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from "bun:test"; -import { getScoreBoard } from "./score.ts"; +import { getScoreBoard } from "./scoreRepository.ts"; import { getTestDb } from "../database/getTestDb.ts"; import { Game, User } from "../schema.ts"; -describe("Score", () => { - it("should return the score board", async () => { +describe("ScoreRepository", () => { + it("should return the scoreboard", async () => { const db = getTestDb(); await db.insert(User).values({ name: "TestUser", @@ -16,7 +16,7 @@ describe("Score", () => { stage: 1, gameState: "ANY", finished: 1, - timestamp: Date.now(), + started: Date.now(), }); await db.insert(Game).values({ user: "TestUser", @@ -24,7 +24,7 @@ describe("Score", () => { stage: 10, gameState: "ANY", finished: 1, - timestamp: Date.now(), + started: Date.now(), }); await db.insert(Game).values({ user: "TestUser", @@ -32,7 +32,7 @@ describe("Score", () => { stage: 20, gameState: "ANY", finished: 0, - timestamp: Date.now(), + started: Date.now(), }); const result = await getScoreBoard(db); expect(result).toEqual([{ stage: 10, user: "TestUser" }]); diff --git a/backend/services/score.ts b/backend/repositories/scoreRepository.ts similarity index 78% rename from backend/services/score.ts rename to backend/repositories/scoreRepository.ts index ea54bb3..64a9dfb 100644 --- a/backend/services/score.ts +++ b/backend/repositories/scoreRepository.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { eq, sql, not } from "drizzle-orm"; import { Game } from "../schema"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; @@ -6,6 +6,6 @@ export const getScoreBoard = async (db: BunSQLiteDatabase) => { return await db .select({ stage: sql`max(${Game.stage})`, user: Game.user }) .from(Game) - .where(eq(Game.finished, 1)) + .where(not(eq(Game.finished, 0))) .groupBy(Game.user); }; diff --git a/backend/services/user.test.ts b/backend/repositories/userRepository.test.ts similarity index 93% rename from backend/services/user.test.ts rename to backend/repositories/userRepository.test.ts index de98f4c..f77ff82 100644 --- a/backend/services/user.test.ts +++ b/backend/repositories/userRepository.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from "bun:test"; import { getTestDb } from "../database/getTestDb"; -import { getUser, loginUser, registerUser } from "./user"; +import { getUser, loginUser, registerUser } from "./userRepository"; -describe("User", () => { +describe("UserRepository", () => { it("should register a user", async () => { const db = getTestDb(); await registerUser(db, "TestUser", "test"); diff --git a/backend/services/user.ts b/backend/repositories/userRepository.ts similarity index 100% rename from backend/services/user.ts rename to backend/repositories/userRepository.ts diff --git a/backend/router.ts b/backend/router.ts new file mode 100644 index 0000000..8f6dff3 --- /dev/null +++ b/backend/router.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Controller, Endpoint } from "./controller/controller"; +import { gameController } from "./controller/gameController"; +import { db } from "./database/db"; +import { BadRequestError } from "./errors/BadRequestError"; + +const controllers = { + game: gameController, +} satisfies Record>; + +export const handleRequest = (message: unknown, sessionUser?: string) => { + const ctx = { + user: sessionUser, + db, + }; + if ( + !message || + !(typeof message === "object") || + !("type" in message) || + !("payload" in message) + ) + return; + const { type, payload } = message; + if (!(typeof type === "string")) return; + const [controllerName, action] = type.split("."); + if (!(controllerName in controllers)) return; + // @ts-expect-error controllers[controllerName] is a Controller + const endpoint = controllers[controllerName][action] as Endpoint; + const input = endpoint.validate.safeParse(payload); + if (input.success) { + const result = endpoint.handler(input.data, ctx); + return result; + } + throw new BadRequestError(input.error.message); +}; + +export type Routes = typeof controllers; diff --git a/backend/schema.ts b/backend/schema.ts index 7d5ad71..77bcd14 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -13,7 +13,7 @@ export const Game = sqliteTable("games", { gameState: text("gameState").notNull(), stage: integer("stage").notNull(), finished: integer("finished").notNull().default(0), - timestamp: integer("timestamp").notNull(), + started: integer("timestamp").notNull(), }); export type UserType = Omit & { diff --git a/bun.lockb b/bun.lockb index b7020966bd9d52edbbeeb17bd7d7abbc1040a90c..7a664dbc27735a0ee1b9b5f94d1f6aeb29284514 100755 GIT binary patch delta 15964 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
!+b=s5V4Je2O(mfDlmGp|Qt-DiR zfzVMiWc+3M@aJWlz5OzOsIy@FYx?N-#lh1y^rO0xFWuxzfpE$A+jDZy6K}3v^2e!l z8t=)&Xx99`m;5s;8Ye4uKlu7@Zq1KHxQHZ<3FeBY^bA5wjH4L#MmZzK&#M|>VNkH24Xt zcAUjH#xml9{SWjE#jwy_u7Xu-{k`B@ zbA+shf|x453W2}#WpF6`HO@(Nh<7~JyyTPDRj<^=!8l2A>*H|WwOc;3qoiG|IufU( zh!M@?tWaSyUk;RSg<`3!36y^giB4E!)o<#Hl!$P$svufeA z#(w4xo-<=9)K=m1VRA-;4+mwFji#bE5t3Iep>{6N7N@NRn zS<(vX9c4~yw6UX=2o`(fY0gq)O)C-Q*1e7PcmK<6WQ*3AeyXNkAzOIJHLXRq`EpzN zEefs1A)H=u@2m?rcEm>=ZMC^|GPDhreB*S^&~Yt0J?!q80)+(4fl4{H4Z?wQM(U$3 z;Aok0Y0l1VP)Nix0nZuZ2+wT$`_>+3j%MmrF+|sCHv=@L&!YaC3E zl)d9bXrOi;2j}8~dL&nz|FN?=s^|nh8av7t;}9+5s8ZuiCpPBDOG8yJ+Vcb%D-Xuu z9y87``8LjaV%W%!IzS;Q*2X5zc9OT^L_)H0nrT^+;*7*WZ*+wQW*DA-CgWVw><7D7 z|LE^K1|^vOT=!DCe6k&yHx3XLRCe5))Nu3{{Dh59iA`u{S|As-!yQoH<6Bdx@8PVK zY?YT#6?bDvqVQ>`T2tQ-0?c@@N5`Yt`re_mx+QXOJesZVbw^XE?=?p&$(GwuRZNyS zNk+5kn@E89LpS*oH(THPmsWR_j7vbX^}T;-3iZ7wR0*Y^%`;5OZZ(dr{(D&nJOA-Cl$W^@|`8{f$T#C064| zShuYYwgm3#?5W;Z>RY9dhm#R=cX<)SY8)kNU32{8Ebr|3E+QUxXhJgf{iLT2E*WRf zGR}T-WaYrurqn4Ir_+L(uc_I#Kcq!n$z(YQRjquU%@1$&7rvY)!H(+XJo!-q}@>3f=h`!5~cWq*X)i}uZ{MY#>=QaH*%#7aREdbwk z`{eT}F!jGj2T%V5Os$02lzWfLohib<<=<--G6b<)Ku|CgFVhp;-#Gb0Tr*6A^-pY diff --git a/package.json b/package.json index f735650..c461e0b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "react-dom": "^18.3.1", "react-hot-toast": "^2.4.1", "use-sound": "^4.0.3", + "zod": "^3.23.8", "zustand": "^4.5.5" }, "devDependencies": { diff --git a/shared/game.test.ts b/shared/game.test.ts new file mode 100644 index 0000000..5609c8f --- /dev/null +++ b/shared/game.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "bun:test"; +import { getValue, serverToClientGame } from "./game"; + +describe("Game", () => { + it("should get value", () => { + const mines = [ + [false, false, true, true, true], + [true, false, true, false, true], + [false, false, true, true, true], + [false, false, false, false, false], + ]; + expect(getValue(mines, 0, 0)).toEqual(1); + expect(getValue(mines, 0, 1)).toEqual(3); + expect(getValue(mines, 3, 0)).toEqual(0); + expect(getValue(mines, 1, 3)).toEqual(8); + }); + + it("should convert server to client game", () => { + const serverGame = { + mines: [ + [false, false, true, true, true], + [true, false, true, false, true], + [false, false, true, true, true], + [false, false, false, false, false], + ], + minesCount: 4, + isRevealed: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [false, false, false, false, false], + ], + isFlagged: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [true, false, false, false, false], + ], + isGameOver: false, + started: 1679599200000, + finished: 0, + lastClick: [0, 0] satisfies [number, number], + uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17", + width: 5, + height: 4, + }; + expect(serverToClientGame(serverGame)).toEqual({ + minesCount: 4, + isRevealed: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [false, false, false, false, false], + ], + isFlagged: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [true, false, false, false, false], + ], + values: [ + [-1, -1, 2, -1, 2], + [0, -1, 4, -1, 4], + [-1, -1, 2, -1, 2], + [-1, -1, -1, -1, -1], + ], + lastClick: [0, 0], + started: 1679599200000, + uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17", + width: 5, + height: 4, + }); + }); +}); diff --git a/shared/game.ts b/shared/game.ts new file mode 100644 index 0000000..335aa7c --- /dev/null +++ b/shared/game.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +export const clientGame = z.object({ + user: z.string(), + uuid: z.string(), + width: z.number(), + height: z.number(), + isRevealed: z.array(z.array(z.boolean())), + isFlagged: z.array(z.array(z.boolean())), + values: z.array(z.array(z.number())), + minesCount: z.number(), + lastClick: z.tuple([z.number(), z.number()]), + started: z.number(), + stage: z.number(), +}); + +export const serverGame = z.object({ + user: z.string(), + uuid: z.string(), + width: z.number(), + height: z.number(), + isRevealed: z.array(z.array(z.boolean())), + isFlagged: z.array(z.array(z.boolean())), + mines: z.array(z.array(z.boolean())), + minesCount: z.number(), + lastClick: z.tuple([z.number(), z.number()]), + started: z.number(), + finished: z.number().default(0), + stage: z.number(), +}); + +export type ClientGame = z.infer; +export type ServerGame = z.infer; + +export const getValue = (mines: boolean[][], x: number, y: number) => { + const neighbors = [ + mines[x - 1]?.[y - 1], + mines[x]?.[y - 1], + mines[x + 1]?.[y - 1], + mines[x - 1]?.[y], + mines[x + 1]?.[y], + mines[x - 1]?.[y + 1], + mines[x]?.[y + 1], + mines[x + 1]?.[y + 1], + ]; + return neighbors.filter((n) => n).length; +}; + +export const serverToClientGame = (game: ServerGame): ClientGame => { + return { + user: game.user, + uuid: game.uuid, + width: game.width, + height: game.height, + isRevealed: game.isRevealed, + isFlagged: game.isFlagged, + minesCount: game.minesCount, + values: game.mines.map((_, i) => + game.mines[0].map((_, j) => { + if (!game.isRevealed[i][j]) return -1; + return getValue(game.mines, i, j); + }), + ), + lastClick: game.lastClick, + started: game.started, + stage: game.stage, + }; +}; diff --git a/src/App.tsx b/src/App.tsx index 26d2d6c..805047a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,6 @@ function useMaxToasts(max: number) { }, [toasts, max]); } -useGameStore.getState().resetGame(4, 4, 2); function App() { const game = useGameStore(); const [scores, setScores] = useState([]); @@ -38,6 +37,10 @@ function App() { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [game.isGameOver]); + useEffect(() => { + game.resetGame(4, 4, 2); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { fetch("https://mb.gordon.business") @@ -75,6 +78,15 @@ function App() { onChange={(e) => game.setName(e.target.value)} />

+

+ Feed:{" "} + +

{scores.slice(0, 10).map((score) => ( diff --git a/src/GameState.ts b/src/GameState.ts index ad5cca8..6a6d132 100644 --- a/src/GameState.ts +++ b/src/GameState.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { newGame } from "./ws"; interface GameState { + showFeed: boolean; mines: boolean[][]; minesCount: number; isRevealed: boolean[][]; @@ -13,7 +14,6 @@ interface GameState { stage: number; name: string; - initializeGame: (width: number, height: number, mines: number) => void; flag: (x: number, y: number) => void; reveal: (x: number, y: number) => boolean; getValue: (x: number, y: number) => number; @@ -31,6 +31,7 @@ interface GameState { triggerPostGame: () => boolean; expandBoard: () => void; setName: (name: string) => void; + setShowFeed: (showFeed: boolean) => void; } const useGameStore = create((set, get) => ({ @@ -44,41 +45,9 @@ const useGameStore = create((set, get) => ({ height: 0, stage: 1, name: localStorage.getItem("name") || "No Name", - - initializeGame: (width, height, mines) => { - mines = Math.min(mines, width * height); - - const minesArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - const isRevealedArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - const isFlaggedArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - - let remainingMines = mines; - while (remainingMines > 0) { - const x = Math.floor(Math.random() * width); - const y = Math.floor(Math.random() * height); - if (!minesArray[x][y]) { - minesArray[x][y] = true; - remainingMines--; - } - } - - set({ - width, - height, - mines: minesArray, - isRevealed: isRevealedArray, - isFlagged: isFlaggedArray, - minesCount: mines, - isGameOver: false, - startTime: Date.now(), - }); - }, + showFeed: !localStorage.getItem("showFeed") + ? true + : localStorage.getItem("showFeed") === "true", flag: (x, y) => { set((state) => { @@ -389,6 +358,10 @@ const useGameStore = create((set, get) => ({ localStorage.setItem("name", name); set({ name }); }, + setShowFeed: (showFeed) => { + localStorage.setItem("showFeed", showFeed.toString()); + set({ showFeed }); + }, })); export default useGameStore; diff --git a/src/ws.ts b/src/ws.ts index f1af94f..3ec0d1b 100644 --- a/src/ws.ts +++ b/src/ws.ts @@ -1,4 +1,5 @@ import toast from "react-hot-toast"; +import useGameStore from "./GameState"; let ws: WebSocket; @@ -11,6 +12,7 @@ export const connectWS = () => { if (data.user === name) { return; } + if (!useGameStore.getState().showFeed) return; switch (data.type) { case "new": toast(data.user + " started a new game", { diff --git a/tsconfig.app.json b/tsconfig.app.json index f0a2350..c79a069 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -20,5 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "shared"] } diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo deleted file mode 100644 index 070fc94..0000000 --- a/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./vite.config.ts","./backend/index.ts","./backend/migrate.ts","./backend/schema.ts","./backend/test-setup.ts","./backend/database/db.ts","./backend/database/getDb.ts","./backend/database/getTestDb.ts","./backend/services/score.test.ts","./backend/services/score.ts"],"version":"5.6.2"} \ No newline at end of file