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 b702096..7a664db 100755
Binary files a/bun.lockb and b/bun.lockb differ
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