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#MmZzKM|>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