added first backend draft

This commit is contained in:
MasterGordon 2024-09-19 23:50:16 +02:00
parent 3494a10fc3
commit edafa021ee
33 changed files with 727 additions and 4351 deletions

View File

@ -11,8 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: oven-sh/setup-bun@v2
with:
node-version: 22
- run: npm ci
- run: npm run build
- run: bun install
- run: bun run build
- run: bun test
- run: bun lint

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
tsconfig.app.tsbuildinfo
node_modules
dist

View File

@ -3,3 +3,9 @@
A simple version of minesweeper built with react in about 1h.
![image](https://github.com/user-attachments/assets/25012972-ebe8-4610-bd28-c181ce8c4e2d)
## Ideas
- Add global big board
- Questinmark after flag
- Earn points for wins

3
backend/database/db.ts Normal file
View File

@ -0,0 +1,3 @@
import { getDb } from "./getDb";
export const db = getDb();

View File

@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
export const getDb = (filename: string = "sqlite.db") => {
const sqlite = new Database(filename);
const db = drizzle(sqlite);
return db;
};

View File

@ -0,0 +1,25 @@
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import crypto from "crypto";
import { getDb } from "./getDb";
import fs from "fs";
const dbs: string[] = [];
export const getTestDb = () => {
const randomId = crypto.randomUUID();
dbs.push(randomId);
fs.existsSync("temp_dbs") || fs.mkdirSync("temp_dbs");
const db = getDb(`temp_dbs/${randomId}.db`);
migrate(db, { migrationsFolder: "./backend/drizzle" });
return db;
};
export const clearTestDbs = () => {
dbs.forEach((db) => {
const dbPath = `temp_dbs/${db}.db`;
if (fs.existsSync(dbPath)) {
fs.rmSync(dbPath);
}
});
dbs.length = 0;
};

View File

@ -0,0 +1,14 @@
CREATE TABLE `games` (
`uuid` text PRIMARY KEY NOT NULL,
`user` text NOT NULL,
`gameState` text NOT NULL,
`stage` integer NOT NULL,
`finished` integer DEFAULT 0 NOT NULL,
`timestamp` integer NOT NULL,
FOREIGN KEY (`user`) REFERENCES `users`(`name`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`name` text PRIMARY KEY NOT NULL,
`password` text NOT NULL
);

View File

@ -0,0 +1,106 @@
{
"version": "6",
"dialect": "sqlite",
"id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"games": {
"name": "games",
"columns": {
"uuid": {
"name": "uuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user": {
"name": "user",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"gameState": {
"name": "gameState",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stage": {
"name": "stage",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"finished": {
"name": "finished",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"games_user_users_name_fk": {
"name": "games_user_users_name_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"user"
],
"columnsTo": [
"name"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1726774158116,
"tag": "0000_nostalgic_next_avengers",
"breakpoints": true
}
]
}

98
backend/index.ts Normal file
View File

@ -0,0 +1,98 @@
import type { ServerWebSocket } from "bun";
interface Scoreboard {
stage: number;
user: string;
}
const loadScoreboard = async (): Promise<Scoreboard[]> => {
try {
const scoreboardFile = Bun.file("./scoreboard.json");
const scoreboard = await scoreboardFile.json();
return scoreboard;
} 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<ServerWebSocket<unknown>, number>();
const server = Bun.serve({
async fetch(request: Request) {
if (request.method === "OPTIONS") {
const res = new Response("Departed", { headers: allowCors });
return res;
}
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) {
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");
},
},
port: 8076,
});

8
backend/migrate.ts Normal file
View File

@ -0,0 +1,8 @@
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
const sqlite = new Database("sqlite.db");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./backend/drizzle" });

22
backend/schema.ts Normal file
View File

@ -0,0 +1,22 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const User = sqliteTable("users", {
name: text("name").primaryKey().notNull(),
password: text("password").notNull(),
});
export const Game = sqliteTable("games", {
uuid: text("uuid").primaryKey().notNull(),
user: text("user")
.notNull()
.references(() => User.name),
gameState: text("gameState").notNull(),
stage: integer("stage").notNull(),
finished: integer("finished").notNull().default(0),
timestamp: integer("timestamp").notNull(),
});
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
password?: undefined;
};
export type GameType = typeof Game.$inferSelect;

View File

@ -0,0 +1,40 @@
import { describe, it, expect } from "bun:test";
import { getScoreBoard } from "./score.ts";
import { getTestDb } from "../database/getTestDb.ts";
import { Game, User } from "../schema.ts";
describe("Score", () => {
it("should return the score board", async () => {
const db = getTestDb();
await db.insert(User).values({
name: "TestUser",
password: "test",
});
await db.insert(Game).values({
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 1,
gameState: "ANY",
finished: 1,
timestamp: Date.now(),
});
await db.insert(Game).values({
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 10,
gameState: "ANY",
finished: 1,
timestamp: Date.now(),
});
await db.insert(Game).values({
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 20,
gameState: "ANY",
finished: 0,
timestamp: Date.now(),
});
const result = await getScoreBoard(db);
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
});
});

11
backend/services/score.ts Normal file
View File

@ -0,0 +1,11 @@
import { eq, sql } from "drizzle-orm";
import { Game } from "../schema";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
export const getScoreBoard = async (db: BunSQLiteDatabase) => {
return await db
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
.from(Game)
.where(eq(Game.finished, 1))
.groupBy(Game.user);
};

View File

@ -0,0 +1,48 @@
import { describe, it, expect } from "bun:test";
import { getTestDb } from "../database/getTestDb";
import { getUser, loginUser, registerUser } from "./user";
describe("User", () => {
it("should register a user", async () => {
const db = getTestDb();
await registerUser(db, "TestUser", "test");
const user = await getUser(db, "TestUser");
expect(user).toEqual({
name: "TestUser",
password: undefined,
});
});
it("should throw an error if user already exists register", async () => {
const db = getTestDb();
await registerUser(db, "TestUser", "test");
expect(registerUser(db, "TestUser", "test")).rejects.toThrow(
"User already exists",
);
});
it("should throw an error if user already exists register case insensitive", async () => {
const db = getTestDb();
await registerUser(db, "TestUser", "test");
expect(registerUser(db, "TESTUSER", "test")).rejects.toThrow(
"User already exists",
);
});
it("should throw an error if user does not exist on login", async () => {
const db = getTestDb();
expect(loginUser(db, "TestUser", "test")).rejects.toThrow(
"User does not exist",
);
});
it("should login a user", async () => {
const db = getTestDb();
await registerUser(db, "TestUser", "test");
const user = await loginUser(db, "TestUser", "test");
expect(user).toEqual({
name: "TestUser",
password: undefined,
});
});
});

53
backend/services/user.ts Normal file
View File

@ -0,0 +1,53 @@
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import { User, type UserType } from "../schema";
import { eq, sql } from "drizzle-orm";
export const registerUser = async (
db: BunSQLiteDatabase,
name: string,
password: string,
) => {
const user = await db
.select()
.from(User)
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
if (user.length > 0) {
throw new Error("User already exists");
}
const hash = await Bun.password.hash(password + Bun.env.SALT ?? "");
await db.insert(User).values({ name, password: hash });
};
export const loginUser = async (
db: BunSQLiteDatabase,
name: string,
password: string,
) => {
const user = await db
.select()
.from(User)
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
if (user.length === 0) {
throw new Error("User does not exist");
}
if (
!(await Bun.password.verify(
password + Bun.env.SALT ?? "",
user[0].password,
))
) {
throw new Error("Incorrect password");
}
return { ...user[0], password: undefined };
};
export const getUser = async (
db: BunSQLiteDatabase,
name: string,
): Promise<UserType | undefined> => {
const user = await db
.select()
.from(User)
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
return { ...user[0], password: undefined };
};

6
backend/test-setup.ts Normal file
View File

@ -0,0 +1,6 @@
import { afterAll } from "bun:test";
import { clearTestDbs } from "./database/getTestDb";
afterAll(() => {
clearTestDbs();
});

BIN
bun.lockb Executable file

Binary file not shown.

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[test]
preload = ["./backend/test-setup.ts"]

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
<meta name="format-detection" content="telephone=no"/>
<title>Minesweeper</title>
</head>
<body>

1
index.ts Normal file
View File

@ -0,0 +1 @@
console.log("Hello via Bun!");

4261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,27 +7,35 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
"drizzle:migrate": "bun run backend/migrate.ts"
},
"dependencies": {
"@uidotdev/usehooks": "^2.4.1",
"drizzle-orm": "^0.33.0",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"use-sound": "^4.0.3",
"zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/react": "^18.3.3",
"@eslint/js": "^9.10.0",
"@types/bun": "latest",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.9.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"drizzle-kit": "^0.24.2",
"eslint": "^9.10.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
"typescript": "^5.6.2",
"typescript-eslint": "^8.6.0",
"vite": "^5.4.6"
},
"module": "index.ts"
}

BIN
sqlite.db Normal file

Binary file not shown.

View File

@ -4,16 +4,27 @@ import explosion from "./sound/explosion.mp3";
import useGameStore from "./GameState";
import { useEffect, useState } from "react";
import useSound from "use-sound";
import { loseGame } from "./ws";
import toast, { useToasterStore } from "react-hot-toast";
interface Score {
user: string;
stage: number;
}
function useMaxToasts(max: number) {
const { toasts } = useToasterStore();
useEffect(() => {
toasts
.filter((t) => t.visible) // Only consider visible toasts
.filter((_, i) => i >= max) // Is toast index over limit?
.forEach((t) => toast.dismiss(t.id)); // Dismiss Use toast.remove(t.id) for no exit animation
}, [toasts, max]);
}
function App() {
const game = useGameStore();
const [scores, setScores] = useState<Score[]>([]);
const [showScores, setShowScores] = useState(false);
const [playSound] = useSound(explosion, {
volume: 0.5,
});
@ -21,6 +32,7 @@ function App() {
useEffect(() => {
if (game.isGameOver) {
playSound();
loseGame(game.name, game.stage);
}
}, [game.isGameOver]);
@ -44,31 +56,35 @@ function App() {
game.resetGame(4, 4, 2);
}, []);
useMaxToasts(5);
return (
<div className="App">
<h1>
Minesweeper Endless{" "}
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
</h1>
<p>
Name:{" "}
<input
value={game.name}
onChange={(e) => game.setName(e.target.value)}
/>
</p>
<button onClick={() => setShowScores(!showScores)}>
{showScores ? "Hide" : "Show"} Scores
</button>
{showScores && (
{import.meta.env.DEV && (
<button onClick={() => game.expandBoard()}>Expand</button>
)}
<div className="header">
<div>
<h1>
Minesweeper Endless{" "}
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
</h1>
<p>
Name:{" "}
<input
value={game.name}
onChange={(e) => game.setName(e.target.value)}
/>
</p>
</div>
<div className="scores">
{scores.slice(0, 10).map((score) => (
<p key={score.user}>
{score.user} - {score.stage}
</p>
))}
</div>
)}
</div>
<div className="game-wrapper">
<div>
<Timer />
@ -88,7 +104,7 @@ function App() {
</div>
</div>
<div className="footer">
<pre>Version: 1.1.3</pre>
<pre>Version: 1.1.6</pre>
<pre>
Made by MasterGordon -{" "}
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">

View File

@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { ReactNode, useRef } from "react";
import { Bomb, Flag } from "lucide-react";
import useGameStore from "./GameState";
import { useLongPress } from "@uidotdev/usehooks";
interface ButtonProps {
x: number;
@ -19,75 +20,98 @@ export const colorMap: Record<string, string> = {
};
export const Button = ({ x, y }: ButtonProps) => {
const game = useGameStore();
const {
isRevealed,
isFlagged,
isMine,
getValue,
reveal,
flag,
getNeighborFlags,
isGameOver,
getHasWon,
} = useGameStore();
let content: ReactNode = "";
if (game.isRevealed[x][y]) {
content = game.isMine(x, y) ? <Bomb /> : game.getValue(x, y).toString();
if (isRevealed[x][y]) {
content = isMine(x, y) ? <Bomb /> : getValue(x, y).toString();
}
if (game.isFlagged[x][y]) {
const attrs = useLongPress(
() => {
if (isRevealed[x][y]) return;
flag(x, y);
},
{
threshold: 400,
},
);
if (isFlagged[x][y]) {
content = <Flag fill="red" />;
}
if (content === "0") content = "";
if (
import.meta.env.DEV &&
window.location.href.includes("xray") &&
game.isMine(x, y) &&
!game.isFlagged[x][y]
isMine(x, y) &&
!isFlagged[x][y]
)
content = <Bomb />;
const touchStart = useRef<number>(0);
return (
<div
className="mine-button"
{...attrs}
style={{
background: game.isRevealed[x][y] ? "#444" : undefined,
borderRight: !game.isRevealed[x][y] ? "3px solid black" : undefined,
borderTop: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
borderLeft: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
borderBottom: !game.isRevealed[x][y] ? "3px solid black" : undefined,
color: game.isRevealed[x][y]
background: isRevealed[x][y] ? "#444" : undefined,
borderRight: !isRevealed[x][y] ? "3px solid black" : undefined,
borderTop: !isRevealed[x][y] ? "3px solid #999" : undefined,
borderLeft: !isRevealed[x][y] ? "3px solid #999" : undefined,
borderBottom: !isRevealed[x][y] ? "3px solid black" : undefined,
color: isRevealed[x][y]
? colorMap[String(content)] ?? "#eee"
: undefined,
fontSize: Number(content) > 0 ? "1.75rem" : undefined,
cursor: game.isRevealed[x][y] ? "default" : "pointer",
cursor: isRevealed[x][y] ? "default" : "pointer",
}}
onMouseDown={() => {
touchStart.current = Date.now();
}}
onMouseUp={(e) => {
if (game.getHasWon() || game.isGameOver) {
if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) {
flag(x, y);
return;
}
if (getHasWon() || isGameOver) {
return;
}
if (e.button === 0) {
// Left click
if (!game.isRevealed[x][y] && !game.isFlagged[x][y]) {
game.reveal(x, y);
if (isFlagged[x][y]) return;
if (!isRevealed[x][y]) {
reveal(x, y);
} else {
const neighborFlagCount = game
.getNeighborFlags(x, y)
.filter((n) => n).length;
const value = game.getValue(x, y);
const neighborFlagCount = getNeighborFlags(x, y).filter(
(n) => n,
).length;
const value = getValue(x, y);
if (neighborFlagCount === value) {
const currentStage = game.stage;
if (!game.isFlagged[x - 1]?.[y] && currentStage == game.stage)
game.reveal(x - 1, y);
if (!game.isFlagged[x - 1]?.[y - 1] && currentStage == game.stage)
game.reveal(x - 1, y - 1);
if (!game.isFlagged[x - 1]?.[y + 1] && currentStage == game.stage)
game.reveal(x - 1, y + 1);
if (!game.isFlagged[x]?.[y - 1] && currentStage == game.stage)
game.reveal(x, y - 1);
if (!game.isFlagged[x]?.[y + 1] && currentStage == game.stage)
game.reveal(x, y + 1);
if (!game.isFlagged[x + 1]?.[y - 1] && currentStage == game.stage)
game.reveal(x + 1, y - 1);
if (!game.isFlagged[x + 1]?.[y] && currentStage == game.stage)
game.reveal(x + 1, y);
if (!game.isFlagged[x + 1]?.[y + 1] && currentStage == game.stage)
game.reveal(x + 1, y + 1);
if (!isFlagged[x - 1]?.[y]) if (reveal(x - 1, y)) return;
if (!isFlagged[x - 1]?.[y - 1]) if (reveal(x - 1, y - 1)) return;
if (!isFlagged[x - 1]?.[y + 1]) if (reveal(x - 1, y + 1)) return;
if (!isFlagged[x]?.[y - 1]) if (reveal(x, y - 1)) return;
if (!isFlagged[x]?.[y + 1]) if (reveal(x, y + 1)) return;
if (!isFlagged[x + 1]?.[y - 1]) if (reveal(x + 1, y - 1)) return;
if (!isFlagged[x + 1]?.[y]) if (reveal(x + 1, y)) return;
if (!isFlagged[x + 1]?.[y + 1]) if (reveal(x + 1, y + 1)) return;
}
}
} else if (e.button === 2 && !game.isRevealed[x][y]) {
game.flag(x, y);
} else if (e.button === 2 && !isRevealed[x][y]) {
flag(x, y);
}
e.preventDefault();
}}

View File

@ -1,4 +1,5 @@
import { create } from "zustand";
import { newGame } from "./ws";
interface GameState {
mines: boolean[][];
@ -14,7 +15,7 @@ interface GameState {
initializeGame: (width: number, height: number, mines: number) => void;
flag: (x: number, y: number) => void;
reveal: (x: number, y: number) => void;
reveal: (x: number, y: number) => boolean;
getValue: (x: number, y: number) => number;
getHasWon: () => boolean;
getMinesLeft: () => number;
@ -27,7 +28,7 @@ interface GameState {
getWidth: () => number;
getHeight: () => number;
isTouched: () => boolean;
triggerPostGame: () => void;
triggerPostGame: () => boolean;
expandBoard: () => void;
setName: (name: string) => void;
}
@ -91,13 +92,14 @@ const useGameStore = create<GameState>((set, get) => ({
reveal: (x, y) => {
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return;
if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return false;
const newRevealed = [...isRevealed];
newRevealed[x][y] = true;
if (mines[x][y]) {
set({ isGameOver: true, isRevealed: newRevealed });
return true;
} else {
set({ isRevealed: newRevealed });
const value = getValue(x, y);
@ -121,7 +123,7 @@ const useGameStore = create<GameState>((set, get) => ({
revealNeighbors(x + 1, y + 1);
}
}
triggerPostGame();
return triggerPostGame();
},
getValue: (x, y) => {
@ -176,6 +178,8 @@ const useGameStore = create<GameState>((set, get) => ({
return x >= 0 && x < width && y >= 0 && y < height;
},
resetGame: (width: number, height: number, mines: number) => {
const { name } = get();
newGame(name);
if (mines > width * height) {
throw new Error("Too many mines");
}
@ -263,11 +267,16 @@ const useGameStore = create<GameState>((set, get) => ({
const { getHasWon, expandBoard } = get();
if (getHasWon()) {
expandBoard();
return true;
}
return false;
},
expandBoard: () => {
const { width, height, stage, mines, isFlagged, isRevealed } = get();
const dir = stage % 2 === 0 ? "down" : "right";
let dir = stage % 2 === 0 ? "down" : "right";
if (stage > 11) {
dir = "down";
}
// Expand the board by the current board size 8x8 -> 16x8
if (dir === "down") {
const newHeight = Math.floor(height * 1.5);

View File

@ -35,6 +35,7 @@ body {
margin: auto;
max-width: 1400px;
padding: 1rem;
font-family: monospace;
}
.timer {
@ -63,3 +64,53 @@ pre {
font-size: 1rem;
font-family: monospace;
}
input {
font-size: 14px;
margin: 12px;
padding: 6px 12px 6px 12px;
border-radius: 0.7em;
background: #333;
color: #eee;
border: 1px solid rgb(251, 21, 242);
}
button {
color: white;
font-weight: 600;
font-size: 14px;
margin: 12px;
padding: 6px 12px 6px 12px;
border-radius: 0.7em;
background: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
rgb(21, 198, 251)) 0% 0% / 300% 300%;
background-size: 200% auto;
}
button:hover {
animation: gradient_move 1s ease infinite;
}
@keyframes gradient_move {
0%{background-position: 0% 92%}
50%{background-position: 100% 9%}
100%{background-position: 0% 92%}
}
/* .scores { */
/* position: fixed; */
/* top: 0; */
/* left: 0; */
/* padding: 1rem; */
/* } */
.header {
display: grid;
grid-template-columns: 1fr 1fr;
margin-bottom: 1rem;
}
.scores {
text-align: right;
}

View File

@ -2,13 +2,18 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { connectWS } from "./ws.ts";
import { Toaster } from "react-hot-toast";
document.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
connectWS();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Toaster position="top-right" reverseOrder={false} />
<App />
</StrictMode>,
);

51
src/ws.ts Normal file
View File

@ -0,0 +1,51 @@
import toast from "react-hot-toast";
let ws: WebSocket;
export const connectWS = () => {
ws = new WebSocket("wss://mb.gordon.business/ws");
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const name = localStorage.getItem("name");
console.log(data);
if (data.user === name) {
return;
}
switch (data.type) {
case "new":
toast(data.user + " started a new game", {
icon: "🚀",
style: {
borderRadius: "10px",
background: "#333",
color: "#fff",
},
});
break;
case "loss":
toast("Game over by " + data.user + " stage " + data.stage, {
icon: "😢",
style: {
borderRadius: "10px",
background: "#333",
color: "#fff",
},
});
break;
}
};
};
export const newGame = (user: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "new", user }));
} else {
setTimeout(() => {
newGame(user);
}, 100);
}
};
export const loseGame = (user: string, stage: number) => {
ws.send(JSON.stringify({ type: "loss", user, stage }));
};

View File

@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/Button.tsx","./src/Game.ts","./src/GameState.ts","./src/Options.tsx","./src/Timer.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.6.2"}

View File

@ -1,22 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
/* Bundler mode */
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
// Best practices
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "backend/**/*.ts"]
}

View File

@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.2"}
{"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"}