Merge pull request #2 from MasterGordon/v2

V2
This commit is contained in:
Gordon Goldbach 2024-10-17 20:05:35 +02:00 committed by GitHub
commit 1bab0af55f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
750 changed files with 5849 additions and 1332 deletions

4
.gitignore vendored
View File

@ -1,4 +1,5 @@
# Logs
.env
logs
*.log
npm-debug.log*
@ -24,3 +25,6 @@ temp_dbs
*.njsproj
*.sln
*.sw?
deploy.sh
sqlite.db

View File

@ -1,11 +1,32 @@
# Minesweeper
# 💣 Business Minesweeper
A simple version of minesweeper built with react in about 1h.
This is a version of minesweeper with a expanding board after each stage. This also includes a account system with match history, spectating live matches and collectables.
![image](https://github.com/user-attachments/assets/25012972-ebe8-4610-bd28-c181ce8c4e2d)
## 🚀 Local Development
## Ideas
For local development you are required to have [bun](https://bun.sh/) installed.
```bash
# Create a .env file for token signing
echo "SECRET=SOME_RANDOM_STRING" > .env
bun install
bun run drizzle:migrate
bun dev
```
## 📦 Used Libraries
- [Pixi.js](https://github.com/pixijs/pixi-react)
- [PixiViewport](https://github.com/davidfig/pixi-viewport)
- [Tanstack Query](https://github.com/TanStack/query)
- [Zod](https://github.com/colinhacks/zod)
- [Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)
- [Tailwind CSS v4](https://github.com/tailwindlabs/tailwindcss)
- [React](https://github.com/facebook/react)
## 📋 Ideas
- Add global big board
- Questinmark after flag
- Earn points for wins
- Powerups

View File

@ -1,27 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ServerWebSocket } from "bun";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import type { z } from "zod";
import type { z, ZodType } from "zod";
interface RequestContext {
user?: string;
db: BunSQLiteDatabase;
ws: ServerWebSocket<unknown>;
}
export type Endpoint<TInput, TResponse> = {
validate: z.ZodType<TInput>;
handler: (input: TInput, context: RequestContext) => Promise<TResponse>;
export type Endpoint<TInputSchema extends ZodType, TResponse> = {
validate: TInputSchema;
handler: (
input: z.infer<TInputSchema>,
context: RequestContext,
) => Promise<TResponse>;
};
export type Request<TEndpoint extends Endpoint<any, any>> = {
method: "POST";
url: string;
body: z.infer<TEndpoint["validate"]>;
};
export const createEndpoint = <TInput, TResponse>(
validate: z.ZodType<TInput>,
export const createEndpoint = <
TInputSchema extends ZodType,
TResponse,
TInput = z.infer<TInputSchema>,
>(
validate: TInputSchema,
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
): Endpoint<TInput, TResponse> => {
): Endpoint<TInputSchema, TResponse> => {
return { validate, handler };
};

View File

@ -1,33 +1,42 @@
import { z } from "zod";
import { createController, createEndpoint } from "./controller";
import { getGame, upsertGameState } from "../repositories/gameRepository";
import {
serverGame,
serverToClientGame,
type ServerGame,
} from "../../shared/game";
getCurrentGame,
getGame,
getGames,
getTotalGamesPlayed,
parseGameState,
upsertGameState,
} from "../repositories/gameRepository";
import { serverToClientGame, type ServerGame } from "../../shared/game";
import crypto from "crypto";
import { game } from "../entities/game";
import { UnauthorizedError } from "../errors/UnauthorizedError";
import { emit } from "../events";
import { emit, emitToWS } from "../events";
import { serverGame } from "../../shared/gameType";
import { pickRandom } from "../../shared/utils";
import { addGems } from "../repositories/gemsRepository";
import { getCollection } from "../repositories/collectionRepository";
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 parsed = parseGameState(game.gameState);
const gameState = await serverGame.parseAsync(parsed);
if (game.finished) return gameState;
return serverToClientGame(gameState);
}),
createGame: createEndpoint(z.undefined(), async (_, { user, db }) => {
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const uuid = crypto.randomUUID() as string;
const collection = await getCollection(db, user);
const newGame: ServerGame = game.createGame({
uuid,
user: user,
mines: 2,
width: 4,
height: 4,
theme: pickRandom(collection.entries.filter((e) => e.selected)).id,
});
upsertGameState(db, newGame);
emit({
@ -42,4 +51,145 @@ export const gameController = createController({
});
return newGame;
}),
reveal: createEndpoint(
z.object({ x: z.number(), y: z.number() }),
async ({ x, y }, { db, user, ws }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const dbGame = await getCurrentGame(db, user);
const serverGame = parseGameState(dbGame.gameState);
const ts = serverGame.finished;
game.reveal(serverGame, x, y, true);
await upsertGameState(db, serverGame);
emit({
type: "updateGame",
game: dbGame.uuid,
});
if (ts === 0 && serverGame.finished !== 0) {
emit({
type: "loss",
stage: serverGame.stage,
user,
time: serverGame.finished - serverGame.started,
});
const reward = game.getRewards(serverGame);
emitToWS(
{
type: "gemsRewarded",
stage: serverGame.stage,
gems: reward,
},
ws,
);
await addGems(db, user, reward);
}
},
),
placeFlag: createEndpoint(
z.object({ x: z.number(), y: z.number() }),
async ({ x, y }, { db, user, ws }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const dbGame = await getCurrentGame(db, user);
const serverGame = parseGameState(dbGame.gameState);
const ts = serverGame.finished;
game.placeFlag(serverGame, x, y);
await upsertGameState(db, serverGame);
emit({
type: "updateGame",
game: dbGame.uuid,
});
if (ts === 0 && serverGame.finished !== 0) {
emit({
type: "loss",
stage: serverGame.stage,
user,
time: serverGame.finished - serverGame.started,
});
const reward = game.getRewards(serverGame);
emitToWS(
{
type: "gemsRewarded",
stage: serverGame.stage,
gems: reward,
},
ws,
);
await addGems(db, user, reward);
}
},
),
placeQuestionMark: createEndpoint(
z.object({ x: z.number(), y: z.number() }),
async ({ x, y }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const dbGame = await getCurrentGame(db, user);
const serverGame = parseGameState(dbGame.gameState);
game.placeQuestionMark(serverGame, x, y);
await upsertGameState(db, serverGame);
emit({
type: "updateGame",
game: dbGame.uuid,
});
},
),
clearTile: createEndpoint(
z.object({ x: z.number(), y: z.number() }),
async ({ x, y }, { db, user, ws }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const dbGame = await getCurrentGame(db, user);
const serverGame = parseGameState(dbGame.gameState);
const ts = serverGame.finished;
game.clearTile(serverGame, x, y);
upsertGameState(db, serverGame);
emit({
type: "updateGame",
game: dbGame.uuid,
});
if (ts === 0 && serverGame.finished !== 0) {
emit({
type: "loss",
stage: serverGame.stage,
user,
time: serverGame.finished - serverGame.started,
});
const reward = game.getRewards(serverGame);
emitToWS(
{
type: "gemsRewarded",
stage: serverGame.stage,
gems: reward,
},
ws,
);
await addGems(db, user, reward);
}
},
),
getGames: createEndpoint(
z.object({
page: z.number().default(0),
user: z.string(),
}),
async ({ page, user }, { db }) => {
const perPage = 20;
const offset = page * perPage;
const games = await getGames(db, user);
const parsedGames = games
.slice(offset, offset + perPage)
.map((game) => parseGameState(game.gameState));
const isLastPage = games.length <= offset + perPage;
return {
data: parsedGames,
nextPage: isLastPage ? undefined : page + 1,
};
},
),
getTotalGamesPlayed: createEndpoint(
z.object({
user: z.string().optional(),
}),
async ({ user }, { db }) => {
const total = await getTotalGamesPlayed(db, user);
return total;
},
),
});

View File

@ -0,0 +1,9 @@
import { z } from "zod";
import { createController, createEndpoint } from "./controller";
import { getScoreBoard } from "../repositories/scoreRepository";
export const scoreboardController = createController({
getScoreBoard: createEndpoint(z.number(), async (limit, { db }) => {
return (await getScoreBoard(db)).slice(0, limit);
}),
});

View File

@ -0,0 +1,170 @@
import { z } from "zod";
import { createController, createEndpoint } from "./controller";
import {
getUserCount,
getUserSettings,
loginUser,
registerUser,
upsertUserSettings,
} from "../repositories/userRepository";
import crypto from "crypto";
import { resetSessionUser, setSessionUser } from "../router";
import { userSettings } from "../../shared/user-settings";
import { UnauthorizedError } from "../errors/UnauthorizedError";
import { getGems, removeGems } from "../repositories/gemsRepository";
import {
getCollection,
upsertCollection,
} from "../repositories/collectionRepository";
import { getWeight, lootboxes } from "../../shared/lootboxes";
import { weightedPickRandom } from "../../shared/utils";
import { emit } from "../events";
const secret = process.env.SECRET!;
const signString = (payload: string) => {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
};
export const userController = createController({
getSelf: createEndpoint(z.null(), async (_, { user }) => {
return user || null;
}),
login: createEndpoint(
z.object({ username: z.string(), password: z.string() }),
async (input, { db, ws }) => {
const { name: user } = await loginUser(
db,
input.username,
input.password,
);
const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 };
const sig = signString(JSON.stringify(session));
setSessionUser(ws, user);
return { token: JSON.stringify({ session, sig }) };
},
),
loginWithToken: createEndpoint(
z.object({ token: z.string() }),
async (input, { ws }) => {
const { session, sig } = JSON.parse(input.token);
const { user } = session;
if (sig !== signString(JSON.stringify(session))) {
return { success: false };
}
if (Date.now() > session.expires) {
return { success: false };
}
setSessionUser(ws, user);
return { success: true };
},
),
logout: createEndpoint(z.null(), async (_, { ws }) => {
resetSessionUser(ws);
}),
register: createEndpoint(
z.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(15, "Username cannot be longer than 15 characters"),
password: z.string().min(6, "Password must be at least 6 characters"),
}),
async (input, { db, ws }) => {
await registerUser(db, input.username, input.password);
const user = input.username;
const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 };
const sig = signString(JSON.stringify(session));
setSessionUser(ws, user);
return { token: JSON.stringify({ session, sig }) };
},
),
getSettings: createEndpoint(z.null(), async (_, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const settings = await getUserSettings(db, user);
return settings;
}),
updateSettings: createEndpoint(userSettings, async (input, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const settings = await getUserSettings(db, user);
const newSettings = { ...settings, ...input };
await upsertUserSettings(db, user, input);
return newSettings;
}),
getUserCount: createEndpoint(z.null(), async (_, { db }) => {
const count = await getUserCount(db);
return count;
}),
getOwnGems: createEndpoint(z.null(), async (_, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const gems = await getGems(db, user);
return gems;
}),
getOwnCollection: createEndpoint(z.null(), async (_, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const collection = await getCollection(db, user);
return collection;
}),
selectCollectionEntry: createEndpoint(
z.object({
id: z.string(),
}),
async ({ id }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const collection = await getCollection(db, user);
if (!collection.entries.some((e) => e.id === id)) {
throw new Error("Entry not found");
}
for (const entry of collection.entries) {
entry.selected = entry.id === id;
}
await upsertCollection(db, user, collection);
},
),
addCollectionEntryToShuffle: createEndpoint(
z.object({
id: z.string(),
}),
async ({ id }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const collection = await getCollection(db, user);
const entry = collection.entries.find((e) => e.id === id);
if (!entry) {
throw new Error("Entry not found");
}
entry.selected = true;
await upsertCollection(db, user, collection);
},
),
openLootbox: createEndpoint(
z.object({
id: z.string(),
}),
async ({ id }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const collection = await getCollection(db, user);
const lootbox = lootboxes.find((l) => l.id === id);
if (!lootbox) {
throw new Error("Lootbox not found");
}
await removeGems(db, user, lootbox.price);
const result = weightedPickRandom(lootbox.items, (i) =>
getWeight(i.rarity),
);
console.log(result);
collection.entries.push({
id: result.id,
aquired: Date.now(),
selected: false,
});
await upsertCollection(db, user, collection);
emit({
type: "lootboxPurchased",
user,
lootbox: lootbox.id,
reward: result.id,
rarity: result.rarity,
});
},
),
});

View File

@ -0,0 +1,24 @@
CREATE TABLE `games` (
`uuid` text PRIMARY KEY NOT NULL,
`user` text NOT NULL,
`gameState` blob 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
);
--> statement-breakpoint
CREATE TABLE `userSettings` (
`user` text PRIMARY KEY NOT NULL,
`settings` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `user_idx` ON `games` (`user`);--> statement-breakpoint
CREATE INDEX `started_idx` ON `games` (`timestamp`);--> statement-breakpoint
CREATE INDEX `user_started_idx` ON `games` (`user`,`timestamp`);--> statement-breakpoint
CREATE INDEX `full_idx` ON `games` (`user`,`timestamp`,`uuid`);

View File

@ -1,14 +0,0 @@
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,10 @@
CREATE TABLE `collection` (
`user` text PRIMARY KEY NOT NULL,
`collection` blob NOT NULL
);
--> statement-breakpoint
CREATE TABLE `gems` (
`user` text PRIMARY KEY NOT NULL,
`count` integer NOT NULL,
`totalCount` integer NOT NULL
);

View File

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c",
"id": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"games": {
@ -23,7 +23,7 @@
},
"gameState": {
"name": "gameState",
"type": "text",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@ -51,7 +51,39 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"user_idx": {
"name": "user_idx",
"columns": [
"user"
],
"isUnique": false
},
"started_idx": {
"name": "started_idx",
"columns": [
"timestamp"
],
"isUnique": false
},
"user_started_idx": {
"name": "user_started_idx",
"columns": [
"user",
"timestamp"
],
"isUnique": false
},
"full_idx": {
"name": "full_idx",
"columns": [
"user",
"timestamp",
"uuid"
],
"isUnique": false
}
},
"foreignKeys": {
"games_user_users_name_fk": {
"name": "games_user_users_name_fk",
@ -92,6 +124,29 @@
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"userSettings": {
"name": "userSettings",
"columns": {
"user": {
"name": "user",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},

View File

@ -0,0 +1,214 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7347c405-254d-4a1f-9196-47b2935f1733",
"prevId": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
"tables": {
"collection": {
"name": "collection",
"columns": {
"user": {
"name": "user",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"collection": {
"name": "collection",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"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": "blob",
"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": {
"user_idx": {
"name": "user_idx",
"columns": [
"user"
],
"isUnique": false
},
"started_idx": {
"name": "started_idx",
"columns": [
"timestamp"
],
"isUnique": false
},
"user_started_idx": {
"name": "user_started_idx",
"columns": [
"user",
"timestamp"
],
"isUnique": false
},
"full_idx": {
"name": "full_idx",
"columns": [
"user",
"timestamp",
"uuid"
],
"isUnique": false
}
},
"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": {}
},
"gems": {
"name": "gems",
"columns": {
"user": {
"name": "user",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"totalCount": {
"name": "totalCount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"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": {}
},
"userSettings": {
"name": "userSettings",
"columns": {
"user": {
"name": "user",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -5,8 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1726774158116,
"tag": "0000_nostalgic_next_avengers",
"when": 1727551167145,
"tag": "0000_gigantic_wasp",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1728834181598,
"tag": "0001_breezy_martin_li",
"breakpoints": true
}
]

View File

@ -1,3 +1,4 @@
import { getValue } from "../../shared/game";
import type { ServerGame } from "../../shared/game";
interface CreateGameOptions {
@ -6,8 +7,172 @@ interface CreateGameOptions {
width: number;
height: number;
mines: number;
theme: string;
}
const isValid = (game: ServerGame, x: number, y: number) => {
const { width, height } = game;
return x >= 0 && x < width && y >= 0 && y < height;
};
const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => {
const { isFlagged } = game;
const neighbors = [
isFlagged[x - 1]?.[y - 1],
isFlagged[x]?.[y - 1],
isFlagged[x + 1]?.[y - 1],
isFlagged[x - 1]?.[y],
isFlagged[x + 1]?.[y],
isFlagged[x - 1]?.[y + 1],
isFlagged[x]?.[y + 1],
isFlagged[x + 1]?.[y + 1],
];
return neighbors.filter((n) => n).length;
};
const hasWon = (serverGame: ServerGame) => {
const { mines, isRevealed, isFlagged, finished, width, height } = serverGame;
if (finished) return false;
for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
if (!isRevealed[i][j] && !isFlagged[i][j]) return false;
if (mines[i][j] && !isFlagged[i][j]) return false;
if (isFlagged[i][j] && !mines[i][j]) return false;
}
}
return true;
};
const expandBoard = (serverGame: ServerGame) => {
const { width, height, stage, mines, isFlagged, isRevealed, isQuestionMark } =
serverGame;
let dir = stage % 2 === 0 ? "down" : "right";
if (stage > 13) {
dir = "down";
}
// Expand the board by the current board size 8x8 -> 16x8
if (dir === "down") {
const newHeight = Math.floor(Math.min(height + 7, height * 1.5));
const newWidth = width;
const newMinesCount = Math.floor(
width * height * 0.5 * (0.2 + 0.0015 * stage),
);
// expand mines array
const newMines = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsRevealed = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsFlagged = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsQuestionMark = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
for (let i = 0; i < newWidth; i++) {
for (let j = 0; j < newHeight; j++) {
const x = i;
const y = j;
if (mines[x]?.[y]) {
newMines[i][j] = true;
}
if (isRevealed[x]?.[y]) {
newIsRevealed[i][j] = true;
}
if (isFlagged[x]?.[y]) {
newIsFlagged[i][j] = true;
}
if (isQuestionMark[x]?.[y]) {
newIsQuestionMark[i][j] = true;
}
}
}
// generate new mines
let remainingMines = newMinesCount;
while (remainingMines > 0) {
const x = Math.floor(Math.random() * width);
const y = height + Math.floor(Math.random() * (newHeight - height));
if (!newMines[x][y]) {
newMines[x][y] = true;
remainingMines--;
}
}
Object.assign(serverGame, {
width: newWidth,
height: newHeight,
mines: newMines,
minesCount: newMinesCount,
stage: stage + 1,
isRevealed: newIsRevealed,
isFlagged: newIsFlagged,
isQuestionMark: newIsQuestionMark,
});
}
if (dir === "right") {
const newWidth = Math.floor(Math.min(width + 7, width * 1.5));
const newHeight = height;
const newMinesCount = Math.floor(
width * height * 0.5 * (0.2 + 0.0015 * stage),
);
// expand mines array
const newMines = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsRevealed = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsFlagged = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsQuestionMark = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
for (let i = 0; i < newWidth; i++) {
for (let j = 0; j < newHeight; j++) {
const x = i;
const y = j;
if (mines[x]?.[y]) {
newMines[i][j] = true;
}
if (isRevealed[x]?.[y]) {
newIsRevealed[i][j] = true;
}
if (isFlagged[x]?.[y]) {
newIsFlagged[i][j] = true;
}
if (isQuestionMark[x]?.[y]) {
newIsQuestionMark[i][j] = true;
}
}
}
// generate new mines
let remainingMines = newMinesCount;
while (remainingMines > 0) {
const x = width + Math.floor(Math.random() * (newWidth - width));
const y = Math.floor(Math.random() * height);
if (!newMines[x][y]) {
newMines[x][y] = true;
remainingMines--;
}
}
Object.assign(serverGame, {
width: newWidth,
height: newHeight,
mines: newMines,
minesCount: newMinesCount,
stage: stage + 1,
isRevealed: newIsRevealed,
isFlagged: newIsFlagged,
isQuestionMark: newIsQuestionMark,
});
}
const newMinesCount = serverGame.mines.flat().filter((m) => m).length;
Object.assign(serverGame, { minesCount: newMinesCount });
};
export const game = {
createGame: (options: CreateGameOptions): ServerGame => {
const { uuid, user, width, height, mines } = options;
@ -24,6 +189,9 @@ export const game = {
const isFlaggedArray = Array.from({ length: width }, () =>
new Array(height).fill(false),
);
const isQuestionMarkArray = Array.from({ length: width }, () =>
new Array(height).fill(false),
);
let remainingMines = mines;
while (remainingMines > 0) {
@ -45,9 +213,104 @@ export const game = {
mines: minesArray,
isRevealed: isRevealedArray,
isFlagged: isFlaggedArray,
isQuestionMark: isQuestionMarkArray,
stage: 1,
lastClick: [-1, -1],
minesCount: mines,
theme: options.theme,
};
},
reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => {
const aux = (
serverGame: ServerGame,
x: number,
y: number,
initial: boolean = false,
) => {
const { mines, isRevealed, isFlagged, isQuestionMark, finished } =
serverGame;
if (finished) return;
if (!isValid(serverGame, x, y)) return;
if (isQuestionMark[x][y]) return;
if (isFlagged[x][y]) return;
serverGame.lastClick = [x, y];
if (mines[x][y]) {
serverGame.finished = Date.now();
return;
}
const value = getValue(serverGame.mines, x, y);
const neighborFlagCount = getNeighborFlagCount(serverGame, x, y);
if (isRevealed[x][y] && value === neighborFlagCount && initial) {
if (!isFlagged[x - 1]?.[y]) aux(serverGame, x - 1, y);
if (!isFlagged[x - 1]?.[y - 1]) aux(serverGame, x - 1, y - 1);
if (!isFlagged[x - 1]?.[y + 1]) aux(serverGame, x - 1, y + 1);
if (!isFlagged[x]?.[y - 1]) aux(serverGame, x, y - 1);
if (!isFlagged[x]?.[y + 1]) aux(serverGame, x, y + 1);
if (!isFlagged[x + 1]?.[y - 1]) aux(serverGame, x + 1, y - 1);
if (!isFlagged[x + 1]?.[y]) aux(serverGame, x + 1, y);
if (!isFlagged[x + 1]?.[y + 1]) aux(serverGame, x + 1, y + 1);
}
serverGame.isRevealed[x][y] = true;
if (value === 0 && neighborFlagCount === 0) {
const revealNeighbors = (nx: number, ny: number) => {
if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) {
aux(serverGame, nx, ny);
}
};
revealNeighbors(x - 1, y - 1);
revealNeighbors(x, y - 1);
revealNeighbors(x + 1, y - 1);
revealNeighbors(x - 1, y);
revealNeighbors(x + 1, y);
revealNeighbors(x - 1, y + 1);
revealNeighbors(x, y + 1);
revealNeighbors(x + 1, y + 1);
}
};
aux(serverGame, x, y, initial);
if (hasWon(serverGame)) {
expandBoard(serverGame);
}
},
placeFlag: (serverGame: ServerGame, x: number, y: number) => {
const { isRevealed, finished } = serverGame;
if (finished) return;
if (!isValid(serverGame, x, y)) return;
if (isRevealed[x][y]) return;
serverGame.isFlagged[x][y] = true;
if (hasWon(serverGame)) {
expandBoard(serverGame);
}
},
placeQuestionMark: (serverGame: ServerGame, x: number, y: number) => {
const { isRevealed, finished } = serverGame;
if (finished) return;
if (!isValid(serverGame, x, y)) return;
if (isRevealed[x][y]) return;
serverGame.isFlagged[x][y] = false;
serverGame.isQuestionMark[x][y] = true;
},
clearTile: (serverGame: ServerGame, x: number, y: number) => {
const { isRevealed, finished } = serverGame;
if (finished) return;
if (!isValid(serverGame, x, y)) return;
if (isRevealed[x][y]) return;
serverGame.isFlagged[x][y] = false;
serverGame.isQuestionMark[x][y] = false;
if (hasWon(serverGame)) {
expandBoard(serverGame);
}
},
getRewards: (serverGame: ServerGame) => {
const { finished, stage } = serverGame;
if (finished == 0) return 0;
if (stage < 2) return 0;
return Math.floor(Math.pow(2, stage * 0.93) + stage * 4 + 5);
},
};

View File

@ -1,28 +1,5 @@
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;
};
import type { ServerWebSocket } from "bun";
import type { Events } from "../shared/events";
const listeners = new Set<(event: Events) => void>();
@ -37,3 +14,7 @@ export const off = (listener: (event: Events) => void) => {
export const emit = (event: Events) => {
listeners.forEach((listener) => listener(event));
};
export const emitToWS = (event: Events, ws: ServerWebSocket<unknown>) => {
ws.send(JSON.stringify(event));
};

View File

@ -1,4 +1,5 @@
import type { ServerWebSocket } from "bun";
import { on } from "./events";
import { handleRequest } from "./router";
const allowCors = {
"Access-Control-Allow-Origin": "*",
@ -6,7 +7,6 @@ const allowCors = {
"Access-Control-Allow-Headers": "Content-Type",
};
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
const server = Bun.serve({
async fetch(request: Request) {
if (request.method === "OPTIONS") {
@ -22,10 +22,10 @@ const server = Bun.serve({
if (typeof message !== "string") {
return;
}
const user = userName.get(ws);
try {
const msg = JSON.parse(message);
console.log(msg);
console.log("Received message", msg);
handleRequest(msg, ws);
} catch (e) {
console.error("Faulty request", message, e);
return;
@ -35,5 +35,10 @@ const server = Bun.serve({
ws.subscribe("minesweeper-global");
},
},
port: 8076,
port: 8072,
});
on((event) => {
server.publish("minesweeper-global", JSON.stringify(event));
});
console.log("Listening on port 8072");

View File

@ -0,0 +1,52 @@
import { eq } from "drizzle-orm";
import { Collection, type CollectionType } from "../schema";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import { decode, encode } from "@msgpack/msgpack";
import type { UserCollection } from "../../shared/gameType";
export const getCollection = async (
db: BunSQLiteDatabase,
user: string,
): Promise<UserCollection> => {
const res = (
await db.select().from(Collection).where(eq(Collection.user, user))
)[0];
if (res) return parseCollection(res);
return {
entries: [
{
id: "default",
aquired: Date.now(),
selected: true,
},
],
};
};
export const upsertCollection = async (
db: BunSQLiteDatabase,
user: string,
collection: UserCollection,
) => {
const dbCollection = await db
.select()
.from(Collection)
.where(eq(Collection.user, user));
if (dbCollection.length > 0) {
await db
.update(Collection)
.set({
collection: Buffer.from(encode(collection)),
})
.where(eq(Collection.user, user));
} else {
await db.insert(Collection).values({
user,
collection: Buffer.from(encode(collection)),
});
}
};
export const parseCollection = (collection: CollectionType) => {
return decode(collection.collection) as UserCollection;
};

View File

@ -16,7 +16,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -25,7 +25,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -44,7 +44,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -52,7 +52,7 @@ describe("GameRepository", () => {
uuid: "TestUuid2",
user: "TestUser",
stage: 2,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 1,
});
@ -61,7 +61,7 @@ describe("GameRepository", () => {
uuid: "TestUuid2",
user: "TestUser",
stage: 2,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 1,
});
@ -76,7 +76,7 @@ describe("GameRepository", () => {
uuid,
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -92,7 +92,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -101,7 +101,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -114,7 +114,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
@ -122,7 +122,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 2,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 1,
});
@ -131,7 +131,7 @@ describe("GameRepository", () => {
uuid: "TestUuid",
user: "TestUser",
stage: 2,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 1,
});

View File

@ -2,6 +2,7 @@ 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";
import { decode, encode } from "@msgpack/msgpack";
export const getGame = async (db: BunSQLiteDatabase, uuid: string) => {
return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0];
@ -12,7 +13,7 @@ export const getGames = async (db: BunSQLiteDatabase, user: string) => {
.select()
.from(Game)
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
.orderBy(Game.started, sql`desc`);
.orderBy(desc(Game.started));
};
export const getCurrentGame = async (db: BunSQLiteDatabase, user: string) => {
@ -69,8 +70,31 @@ export const upsertGameState = async (
uuid,
user,
stage,
gameState: JSON.stringify(game),
gameState: Buffer.from(encode(game)),
finished,
started,
});
};
export const getTotalGamesPlayed = async (
db: BunSQLiteDatabase,
user?: string,
) => {
if (user)
return (
await db
.select({ count: sql<number>`count(*)` })
.from(Game)
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
)[0].count;
return (
await db
.select({ count: sql<number>`count(*)` })
.from(Game)
.where(not(eq(Game.finished, 0)))
)[0].count;
};
export const parseGameState = (gameState: Buffer) => {
return decode(gameState) as ServerGame;
};

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { Gems } from "../schema";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
export const getGems = async (db: BunSQLiteDatabase, user: string) => {
const res = (await db.select().from(Gems).where(eq(Gems.user, user)))[0];
const count = res?.count ?? 0;
const totalCount = res?.totalCount ?? 0;
return { count, totalCount };
};
export const addGems = async (
db: BunSQLiteDatabase,
user: string,
gems: number,
) => {
const { count, totalCount } = await getGems(db, user);
if ((await db.select().from(Gems).where(eq(Gems.user, user))).length === 0) {
await db
.insert(Gems)
.values({ user, count: count + gems, totalCount: totalCount + gems });
return;
}
await db
.update(Gems)
.set({ count: count + gems, totalCount: totalCount + gems })
.where(eq(Gems.user, user));
};
export const removeGems = async (
db: BunSQLiteDatabase,
user: string,
gems: number,
) => {
const { count, totalCount } = await getGems(db, user);
if (count - gems < 0) throw new Error("Not enough gems");
await db
.update(Gems)
.set({ count: count - gems, totalCount: totalCount - gems })
.where(eq(Gems.user, user));
};

View File

@ -14,7 +14,7 @@ describe("ScoreRepository", () => {
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 1,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: Date.now(),
});
@ -22,7 +22,7 @@ describe("ScoreRepository", () => {
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 10,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 1,
started: Date.now(),
});
@ -30,7 +30,7 @@ describe("ScoreRepository", () => {
user: "TestUser",
uuid: crypto.randomUUID(),
stage: 20,
gameState: "ANY",
gameState: Buffer.from("ANY"),
finished: 0,
started: Date.now(),
});

View File

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

View File

@ -1,6 +1,10 @@
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import { User, type UserType } from "../schema";
import { User, UserSettings, type UserType } from "../schema";
import { eq, sql } from "drizzle-orm";
import {
userSettings as userSettingsSchema,
type UserSettings as UserSettingsType,
} from "../../shared/user-settings";
export const registerUser = async (
db: BunSQLiteDatabase,
@ -46,3 +50,44 @@ export const getUser = async (
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
return { ...user[0], password: undefined };
};
export const getUserSettings = async (
db: BunSQLiteDatabase,
user: string,
): Promise<UserSettingsType | undefined> => {
const userSettings = await db
.select()
.from(UserSettings)
.where(eq(UserSettings.user, user));
const settings = userSettings[0]?.settings || "{}";
return userSettingsSchema.parse(JSON.parse(settings));
};
export const upsertUserSettings = async (
db: BunSQLiteDatabase,
user: string,
settings: UserSettingsType,
) => {
const dbSettings = await db
.select()
.from(UserSettings)
.where(eq(UserSettings.user, user));
if (dbSettings.length > 0) {
await db
.update(UserSettings)
.set({
settings: JSON.stringify(settings),
})
.where(eq(UserSettings.user, user));
} else {
await db.insert(UserSettings).values({
user,
settings: JSON.stringify(settings),
});
}
};
export const getUserCount = async (db: BunSQLiteDatabase) => {
return (await db.select({ count: sql<number>`count(*)` }).from(User))[0]
.count;
};

View File

@ -1,37 +1,71 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ServerWebSocket } from "bun";
import type { Controller, Endpoint } from "./controller/controller";
import { gameController } from "./controller/gameController";
import { db } from "./database/db";
import { BadRequestError } from "./errors/BadRequestError";
import { userController } from "./controller/userController";
import { ZodError } from "zod";
import { scoreboardController } from "./controller/scoreboardController";
const controllers = {
game: gameController,
user: userController,
scoreboard: scoreboardController,
} satisfies Record<string, Controller<any>>;
export const handleRequest = (message: unknown, sessionUser?: string) => {
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
export const setSessionUser = (ws: ServerWebSocket<unknown>, user: string) => {
userName.set(ws, user);
};
export const resetSessionUser = (ws: ServerWebSocket<unknown>) => {
userName.delete(ws);
};
export const handleRequest = async (
message: unknown,
ws: ServerWebSocket<unknown>,
) => {
const sessionUser = userName.get(ws) || undefined;
const ctx = {
user: sessionUser,
db,
ws,
};
if (
!message ||
!(typeof message === "object") ||
!("type" in message) ||
!("payload" in message)
!("payload" in message) ||
!("id" in message)
)
return;
const { type, payload } = message;
const { type, payload, id } = message;
if (!(typeof type === "string")) return;
const [controllerName, action] = type.split(".");
if (!(controllerName in controllers)) return;
try {
// @ts-expect-error controllers[controllerName] is a Controller
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
const input = endpoint.validate.safeParse(payload);
if (input.success) {
const result = endpoint.handler(input.data, ctx);
return result;
const input = endpoint.validate.parse(payload);
console.time(action);
const result = await endpoint.handler(input, ctx);
ws.send(JSON.stringify({ id, payload: result }));
console.timeEnd(action);
return;
} catch (e) {
if (e instanceof ZodError) {
ws.send(
JSON.stringify({ id, error: e.issues[0].message, type: message.type }),
);
} else if (e instanceof Error) {
ws.send(JSON.stringify({ id, error: e.message, type: message.type }));
} else {
ws.send(JSON.stringify({ id, error: "Bad Request", type: message.type }));
}
console.error(e);
}
throw new BadRequestError(input.error.message);
};
export type Routes = typeof controllers;

View File

@ -1,22 +1,58 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import {
sqliteTable,
text,
integer,
index,
blob,
} from "drizzle-orm/sqlite-core";
export const User = sqliteTable("users", {
name: text("name").primaryKey().notNull(),
password: text("password").notNull(),
});
export const Game = sqliteTable("games", {
export const Game = sqliteTable(
"games",
{
uuid: text("uuid").primaryKey().notNull(),
user: text("user")
.notNull()
.references(() => User.name),
gameState: text("gameState").notNull(),
gameState: blob("gameState", { mode: "buffer" }).notNull(),
stage: integer("stage").notNull(),
finished: integer("finished").notNull().default(0),
started: integer("timestamp").notNull(),
},
(table) => {
return {
userIdx: index("user_idx").on(table.user),
startedIdx: index("started_idx").on(table.started),
userStartedIdx: index("user_started_idx").on(table.user, table.started),
fullIdx: index("full_idx").on(table.user, table.started, table.uuid),
};
},
);
export const UserSettings = sqliteTable("userSettings", {
user: text("user").primaryKey().notNull(),
settings: text("settings").notNull(),
});
export const Gems = sqliteTable("gems", {
user: text("user").primaryKey().notNull(),
count: integer("count").notNull(),
totalCount: integer("totalCount").notNull(),
});
export const Collection = sqliteTable("collection", {
user: text("user").primaryKey().notNull(),
collection: blob("collection", { mode: "buffer" }).notNull(),
});
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
password?: undefined;
};
export type GameType = typeof Game.$inferSelect;
export type UserSettingsType = typeof UserSettings.$inferSelect;
export type GemsType = typeof Gems.$inferSelect;
export type CollectionType = typeof Collection.$inferSelect;

1
bad Normal file
View File

@ -0,0 +1 @@
f7360d42-dcc1-4e1e-a90b-ef2295298872

BIN
bun.lockb

Binary file not shown.

3
dev.ts Normal file
View File

@ -0,0 +1,3 @@
import { $ } from "bun";
await Promise.all([$`bun run dev:backend`, $`bun run dev:client`]);

9
drizzle.config.js Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./backend/schema.ts",
out: "./backend/drizzle",
dbCredentials: {
url: "file:./sqlite.db",
},
});

View File

@ -1,28 +1,39 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{ ignores: ['dist'] },
export default [
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
ignores: ["dist/", "node_modules/"],
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
settings: {
react: {
version: "detect",
},
},
},
{
plugins: { "react-hooks": reactHooks },
rules: reactHooks.configs.recommended.rules,
},
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/display-name": "off",
},
},
)
{
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: { ...globals.browser, ...globals.node },
},
},
];

View File

@ -3,8 +3,10 @@
<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"/>
<meta name="darkreader-lock">
<meta name="description" content="Minesweeper Endless is a game where you have to clear the board without getting hit by mines. You can win the game by getting the highest score.">
<title>Minesweeper</title>
</head>
<body>

View File

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

View File

@ -4,39 +4,66 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "bun run dev.ts",
"dev:backend": "bun run backend/index.ts --watch --hot",
"dev:client": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint": "eslint",
"preview": "vite preview",
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
"drizzle:migrate": "bun run backend/migrate.ts"
"drizzle:schema": "drizzle-kit generate",
"drizzle:migrate": "bun run backend/migrate.ts",
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/canvas-display": "^7.4.2",
"@pixi/canvas-renderer": "^7.4.2",
"@pixi/react": "^7.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@tanstack/react-query": "^5.59.11",
"@tanstack/react-query-devtools": "^5.59.11",
"@tsparticles/engine": "^3.5.0",
"@tsparticles/preset-sea-anemone": "^3.1.0",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.5.0",
"@uidotdev/usehooks": "^2.4.1",
"drizzle-orm": "^0.33.0",
"lucide-react": "^0.441.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"drizzle-orm": "0.33.0",
"framer-motion": "^11.11.8",
"jotai": "^2.10.0",
"lucide-react": "^0.452.0",
"pixi-viewport": "^5.0.3",
"pixi.js": "^7.0.0",
"pixi.js-legacy": "^7.4.2",
"react": "^18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"tailwind-merge": "^2.5.3",
"use-sound": "^4.0.3",
"zod": "^3.23.8",
"zustand": "^4.5.5"
"wouter": "^3.3.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"vite-imagetools": "^7.0.4",
"tailwindcss": "^4.0.0-alpha.26",
"@eslint/compat": "^1.2.0",
"@eslint/js": "^9.12.0",
"@types/bun": "latest",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.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.12",
"globals": "^15.9.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.6.0",
"vite": "^5.4.6"
},
"module": "index.ts"
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.7.1",
"drizzle-kit": "0.24.2",
"eslint": "^9.12.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "5.0.0",
"globals": "^15.11.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8",
"@tailwindcss/vite": "next"
}
}

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

36
shared/events.ts Normal file
View File

@ -0,0 +1,36 @@
import type { Rarity } from "../shared/lootboxes";
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
export type Events =
| {
type: "new";
user: string;
}
| {
type: "loss";
user: string;
stage: number;
time: number;
}
| {
type: "updateGame";
game: string;
}
| {
type: "updateStage";
game: string;
stage: number;
started: number;
}
| {
type: "gemsRewarded";
stage: number;
gems: number;
}
| {
type: "lootboxPurchased";
lootbox: string;
reward: string;
user: string;
rarity: Rarity;
};

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { getValue, serverToClientGame } from "./game";
import { getValue, ServerGame, serverToClientGame } from "./game";
describe("Game", () => {
it("should get value", () => {
@ -16,7 +16,8 @@ describe("Game", () => {
});
it("should convert server to client game", () => {
const serverGame = {
const serverGame: ServerGame = {
theme: "default",
mines: [
[false, false, true, true, true],
[true, false, true, false, true],
@ -36,15 +37,23 @@ describe("Game", () => {
[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,
user: "TestUser",
stage: 1,
isQuestionMark: [
[false, false, true, false, true],
[true, false, true, false, true],
[false, false, true, false, true],
[false, false, false, false, false],
],
};
expect(serverToClientGame(serverGame)).toEqual({
theme: "default",
minesCount: 4,
isRevealed: [
[false, false, true, false, true],
@ -69,6 +78,14 @@ describe("Game", () => {
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
width: 5,
height: 4,
user: "TestUser",
stage: 1,
isQuestionMark: [
[false, false, true, false, true],
[true, false, true, false, true],
[false, false, true, false, true],
[false, false, false, false, false],
],
});
});
});

View File

@ -1,36 +1,10 @@
import { z } from "zod";
import type { ServerGame, ClientGame } from "./gameType";
export type { ServerGame, ClientGame } from "./gameType";
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<typeof clientGame>;
export type ServerGame = z.infer<typeof serverGame>;
export const isServerGame = (game: ServerGame | ClientGame) => "mines" in game;
export const isClientGame = (
game: ServerGame | ClientGame,
): game is ClientGame => !("mines" in game);
export const getValue = (mines: boolean[][], x: number, y: number) => {
const neighbors = [
@ -54,6 +28,7 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
height: game.height,
isRevealed: game.isRevealed,
isFlagged: game.isFlagged,
isQuestionMark: game.isQuestionMark,
minesCount: game.minesCount,
values: game.mines.map((_, i) =>
game.mines[0].map((_, j) => {
@ -64,5 +39,6 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
lastClick: game.lastClick,
started: game.started,
stage: game.stage,
theme: game.theme,
};
};

47
shared/gameType.ts Normal file
View File

@ -0,0 +1,47 @@
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())),
isQuestionMark: 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(),
theme: z.string().default("default"),
});
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())),
isQuestionMark: 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(),
theme: z.string().default("default"),
});
export type ClientGame = z.infer<typeof clientGame>;
export type ServerGame = z.infer<typeof serverGame>;
export interface UserCollectionEntry {
id: string;
aquired: number;
selected: boolean;
}
export interface UserCollection {
entries: UserCollectionEntry[];
}

211
shared/lootboxes.ts Normal file
View File

@ -0,0 +1,211 @@
import type { themes } from "../src/themes";
import lootbox1 from "../src/assets/illustrations/lootbox1.png?w=360&inline";
export const rarities = [
{
name: "Common",
id: "common",
weight: 1,
},
{
name: "Uncommon",
id: "uncommon",
weight: 0.5,
},
{
name: "Rare",
id: "rare",
weight: 0.25,
},
{
name: "Legendary",
id: "legendary",
weight: 0.1,
},
] as const;
export const getWeight = (rarity: Rarity) =>
rarities.find((r) => r.id === rarity)?.weight ?? 0;
export type Rarity = (typeof rarities)[number]["id"];
type ThemeId = (typeof themes)[number]["id"];
interface Lootbox {
name: string;
id: string;
price: number;
priceText: string;
image: string;
items: {
id: ThemeId;
rarity: Rarity;
}[];
}
export const series1: Lootbox = {
name: "Series 1",
id: "series1",
price: 5000,
priceText: "5.000",
image: lootbox1,
items: [
{
id: "basic",
rarity: "common",
},
{
id: "black-and-white",
rarity: "common",
},
{
id: "blue",
rarity: "common",
},
{
id: "green",
rarity: "common",
},
{
id: "orange",
rarity: "common",
},
{
id: "pink",
rarity: "common",
},
{
id: "purple",
rarity: "common",
},
{
id: "red",
rarity: "common",
},
{
id: "turquoise",
rarity: "common",
},
{
id: "yellow",
rarity: "common",
},
{
id: "nautical",
rarity: "uncommon",
},
{
id: "up-in-smoke",
rarity: "uncommon",
},
{
id: "shadow-warrior",
rarity: "uncommon",
},
{
id: "crimson",
rarity: "uncommon",
},
{
id: "romance",
rarity: "uncommon",
},
{
id: "flowers",
rarity: "rare",
},
{
id: "dinos",
rarity: "rare",
},
{
id: "cats",
rarity: "rare",
},
{
id: "mine-dogs",
rarity: "rare",
},
{
id: "tron-blue",
rarity: "rare",
},
{
id: "tron-orange",
rarity: "rare",
},
{
id: "circuit",
rarity: "rare",
},
{
id: "circuit-binary",
rarity: "rare",
},
{
id: "farm",
rarity: "rare",
},
{
id: "halli-galli",
rarity: "rare",
},
{
id: "insects",
rarity: "rare",
},
{
id: "poop",
rarity: "rare",
},
{
id: "underwater",
rarity: "rare",
},
{
id: "retro-wave",
rarity: "legendary",
},
{
id: "elden-ring",
rarity: "legendary",
},
{
id: "janitor-tresh",
rarity: "legendary",
},
{
id: "teemo",
rarity: "legendary",
},
{
id: "ziggs",
rarity: "legendary",
},
{
id: "minecraft-nether",
rarity: "legendary",
},
{
id: "minecraft-overworld",
rarity: "legendary",
},
{
id: "techies-dire",
rarity: "legendary",
},
{
id: "techies-radiant",
rarity: "legendary",
},
{
id: "isaac",
rarity: "legendary",
},
{
id: "mlg",
rarity: "legendary",
},
],
};
export const lootboxes = [series1];

42
shared/testBoard.ts Normal file
View File

@ -0,0 +1,42 @@
import { ServerGame } from "./gameType";
const rotate = (arr: boolean[][]) => {
return arr[0].map((_, colIndex) => arr.map((row) => row[colIndex]));
};
export const testBoard: (theme: string) => ServerGame = (theme: string) => ({
user: "TestUser",
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
width: 11,
height: 4,
isRevealed: rotate([
[false, false, false, false, false, ...Array<boolean>(6).fill(true)],
[...Array<boolean>(11).fill(true)],
[...Array<boolean>(11).fill(true)],
[...Array<boolean>(6).fill(true), ...Array<boolean>(5).fill(false)],
]),
isFlagged: rotate([
[true, ...Array<boolean>(10).fill(false)],
[...Array<boolean>(11).fill(false)],
[...Array<boolean>(11).fill(false)],
[...Array<boolean>(11).fill(false)],
]),
finished: 1,
started: 1,
stage: 420,
lastClick: [2, 2],
mines: rotate([
[false, false, false, false, false, ...Array<boolean>(6).fill(true)],
[...Array<boolean>(8).fill(false), true, false, true],
[false, false, ...Array<boolean>(9).fill(true)],
[...Array<boolean>(11).fill(false)],
]),
minesCount: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8,
isQuestionMark: rotate([
[false, true, ...Array<boolean>(9).fill(false)],
[...Array<boolean>(11).fill(false)],
[...Array<boolean>(11).fill(false)],
[...Array<boolean>(11).fill(false)],
]),
theme,
});

60
shared/time.ts Normal file
View File

@ -0,0 +1,60 @@
export const formatTimeSpan = (timespan: number) => {
const days = Math.floor(timespan / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(timespan % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((timespan % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((timespan % (1000 * 60)) / 1000);
const result = [];
if (days > 0) {
result.push(`${days}d`);
}
if (hours > 0) {
result.push(`${hours}h`);
}
if (minutes > 0) {
result.push(`${minutes}m`);
}
if (seconds > 0) {
result.push(`${seconds}s`);
}
if (result.length === 0) {
return timespan + "ms";
}
return result.join(" ");
};
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
export const formatRelativeTime = (date: number) => {
const now = Date.now();
const diff = date - now;
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
const hours = Math.ceil((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.ceil((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
if (days <= -1) {
return rtf.format(days, "day");
}
if (hours <= -1) {
return rtf.format(hours, "hour");
}
if (minutes <= -1) {
return rtf.format(minutes, "minute");
}
if (seconds <= -1) {
return rtf.format(seconds, "second");
}
return "just now";
};

9
shared/user-settings.ts Normal file
View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const userSettings = z.object({
placeQuestionMark: z.boolean().default(false),
longPressOnDesktop: z.boolean().default(false),
});
export type UserSettings = z.infer<typeof userSettings>;
export type UserSettingsInput = z.input<typeof userSettings>;

29
shared/utils.ts Normal file
View File

@ -0,0 +1,29 @@
export const pickRandom = <T>(arr: T[]) => {
const index = Math.floor(Math.random() * arr.length);
return arr[index];
};
export const hashStr = (str: string) => {
return [...str].reduce(
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
0,
);
};
export const weightedPickRandom = <T>(
arr: T[],
getWeight: (item: T) => number = () => 1,
getRandom: (tw: number) => number = (totalWeight) =>
Math.random() * totalWeight,
): T => {
const totalWeight = arr.reduce((acc, cur) => acc + getWeight(cur), 0);
const random = getRandom(totalWeight);
let currentWeight = 0;
for (const entry of arr) {
currentWeight += getWeight(entry);
if (random < currentWeight) {
return entry;
}
}
return arr[arr.length - 1];
};

BIN
sqlite.db

Binary file not shown.

View File

@ -1,130 +0,0 @@
import { Button } from "./Button";
import Timer from "./Timer";
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 [playSound] = useSound(explosion, {
volume: 0.5,
});
useEffect(() => {
if (game.isGameOver) {
playSound();
loseGame(game.name, game.stage);
}
// 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")
.then((res) => res.json())
.then((data) => {
setScores(data);
});
const i = setInterval(() => {
fetch("https://mb.gordon.business")
.then((res) => res.json())
.then((data) => {
setScores(data);
});
}, 2000);
return () => clearInterval(i);
}, []);
useMaxToasts(5);
return (
<div className="App">
{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>
<p>
Feed:{" "}
<button
onClick={() => game.setShowFeed(!game.showFeed)}
style={{ padding: "0.5rem" }}
>
{game.showFeed ? "Shown" : "Hidden"}
</button>
</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 />
<div
className="game-board"
style={{
gridTemplateColumns: `repeat(${game.getWidth()}, 1fr)`,
gridTemplateRows: `repeat(${game.getHeight()}, 1fr)`,
}}
>
{game.mines[0].map((_, y) =>
game.mines.map((_, x) => (
<Button key={`${x},${y}`} x={x} y={y} />
)),
)}
</div>
</div>
</div>
<div className="footer">
<pre>Version: 1.1.6</pre>
<pre>
Made by MasterGordon -{" "}
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
Source Code
</a>
</pre>
</div>
</div>
);
}
export default App;

View File

@ -1,123 +0,0 @@
import { ReactNode, useRef } from "react";
import { Bomb, Flag } from "lucide-react";
import useGameStore from "./GameState";
import { useLongPress } from "@uidotdev/usehooks";
interface ButtonProps {
x: number;
y: number;
}
// eslint-disable-next-line react-refresh/only-export-components
export const colorMap: Record<string, string> = {
"1": "#049494",
"2": "#8c9440",
"3": "#cc6666",
"4": "#b294bb",
"5": "#f7c530",
"6": "#81a2be",
"7": "#707880",
"8": "#b5bd68",
};
export const Button = ({ x, y }: ButtonProps) => {
const {
isRevealed,
isFlagged,
isMine,
getValue,
reveal,
flag,
getNeighborFlags,
isGameOver,
getHasWon,
} = useGameStore();
let content: ReactNode = "";
if (isRevealed[x][y]) {
content = isMine(x, y) ? <Bomb /> : getValue(x, y).toString();
}
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") &&
isMine(x, y) &&
!isFlagged[x][y]
)
content = <Bomb />;
const touchStart = useRef<number>(0);
return (
<div
className="mine-button"
{...attrs}
style={{
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: isRevealed[x][y] ? "default" : "pointer",
}}
onMouseDown={() => {
touchStart.current = Date.now();
}}
onMouseUp={(e) => {
if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) {
flag(x, y);
return;
}
if (getHasWon() || isGameOver) {
return;
}
if (e.button === 0) {
// Left click
if (isFlagged[x][y]) return;
if (!isRevealed[x][y]) {
reveal(x, y);
} else {
const neighborFlagCount = getNeighborFlags(x, y).filter(
(n) => n,
).length;
const value = getValue(x, y);
if (neighborFlagCount === value) {
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 && !isRevealed[x][y]) {
flag(x, y);
}
e.preventDefault();
}}
>
{content}
</div>
);
};

View File

@ -1,144 +0,0 @@
export class Game {
mines: boolean[][] = [];
minesCount: number = 0;
isRevealed: boolean[][] = [];
isFlagged: boolean[][] = [];
isGameOver: boolean = false;
startTime: number = Date.now();
constructor(width: number, height: number, mines: number) {
if (mines > width * height) {
throw new Error("Too many mines");
}
this.minesCount = mines;
for (let i = 0; i < width; i++) {
this.mines.push(new Array(height).fill(false));
this.isRevealed.push(new Array(height).fill(false));
this.isFlagged.push(new Array(height).fill(false));
}
while (mines > 0) {
const x = Math.floor(Math.random() * width);
const y = Math.floor(Math.random() * height);
if (!this.mines[x][y]) {
this.mines[x][y] = true;
mines--;
}
}
}
getWidth() {
return this.mines.length;
}
getHeight() {
return this.mines[0].length;
}
isMine(x: number, y: number) {
return this.mines[x][y];
}
flag(x: number, y: number) {
this.isFlagged[x][y] = !this.isFlagged[x][y];
}
isValid(x: number, y: number) {
return x >= 0 && x < this.getWidth() && y >= 0 && y < this.getHeight();
}
reveal(x: number, y: number) {
if (!this.isValid(x, y)) return;
this.isRevealed[x][y] = true;
if (this.isMine(x, y)) {
this.isGameOver = true;
return;
}
const value = this.getValue(x, y);
if (value === 0) {
if (this.isValid(x - 1, y - 1) && !this.isRevealed[x - 1]?.[y - 1])
this.reveal(x - 1, y - 1);
if (this.isValid(x, y - 1) && !this.isRevealed[x]?.[y - 1])
this.reveal(x, y - 1);
if (this.isValid(x + 1, y - 1) && !this.isRevealed[x + 1]?.[y - 1])
this.reveal(x + 1, y - 1);
if (this.isValid(x - 1, y) && !this.isRevealed[x - 1]?.[y])
this.reveal(x - 1, y);
if (this.isValid(x + 1, y) && !this.isRevealed[x + 1]?.[y])
this.reveal(x + 1, y);
if (this.isValid(x - 1, y + 1) && !this.isRevealed[x - 1]?.[y + 1])
this.reveal(x - 1, y + 1);
if (this.isValid(x, y + 1) && !this.isRevealed[x]?.[y + 1])
this.reveal(x, y + 1);
if (this.isValid(x + 1, y + 1) && !this.isRevealed[x + 1]?.[y + 1])
this.reveal(x + 1, y + 1);
}
}
getHasWon() {
if (this.isGameOver) {
return false;
}
for (let i = 0; i < this.getWidth(); i++) {
for (let j = 0; j < this.getHeight(); j++) {
if (!this.isRevealed[i][j] && !this.isFlagged[i][j]) {
return false;
}
if (this.isMine(i, j) && !this.isFlagged[i][j]) {
return false;
}
}
}
return true;
}
getMinesLeft() {
return this.minesCount - this.isFlagged.flat().filter((m) => m).length;
}
getNeighborFlags(x: number, y: number) {
const neighbors = [
this.isFlagged[x - 1]?.[y - 1],
this.isFlagged[x]?.[y - 1],
this.isFlagged[x + 1]?.[y - 1],
this.isFlagged[x - 1]?.[y],
this.isFlagged[x + 1]?.[y],
this.isFlagged[x - 1]?.[y + 1],
this.isFlagged[x]?.[y + 1],
this.isFlagged[x + 1]?.[y + 1],
];
return neighbors;
}
getNeighborMines(x: number, y: number) {
const neighbors = [
this.mines[x - 1]?.[y - 1],
this.mines[x]?.[y - 1],
this.mines[x + 1]?.[y - 1],
this.mines[x - 1]?.[y],
this.mines[x + 1]?.[y],
this.mines[x - 1]?.[y + 1],
this.mines[x]?.[y + 1],
this.mines[x + 1]?.[y + 1],
];
return neighbors;
}
getValue(x: number, y: number) {
const neighbors = this.getNeighborMines(x, y);
const mines = neighbors.filter((n) => n).length;
return mines;
}
quickStart() {
for (let i = 0; i < this.getWidth(); i++) {
for (let j = 0; j < this.getHeight(); j++) {
const value = this.getValue(i, j);
const isMine = this.isMine(i, j);
if (value === 0 && !isMine) {
this.reveal(i, j);
return;
}
}
}
}
}

View File

@ -1,367 +0,0 @@
import { create } from "zustand";
import { newGame } from "./ws";
interface GameState {
showFeed: boolean;
mines: boolean[][];
minesCount: number;
isRevealed: boolean[][];
isFlagged: boolean[][];
isGameOver: boolean;
startTime: number;
width: number;
height: number;
stage: number;
name: string;
flag: (x: number, y: number) => void;
reveal: (x: number, y: number) => boolean;
getValue: (x: number, y: number) => number;
getHasWon: () => boolean;
getMinesLeft: () => number;
quickStart: () => void;
isValid: (x: number, y: number) => boolean;
resetGame: (width: number, height: number, mines: number) => void;
isMine: (x: number, y: number) => boolean;
getNeighborMines: (x: number, y: number) => boolean[];
getNeighborFlags: (x: number, y: number) => boolean[];
getWidth: () => number;
getHeight: () => number;
isTouched: () => boolean;
triggerPostGame: () => boolean;
expandBoard: () => void;
setName: (name: string) => void;
setShowFeed: (showFeed: boolean) => void;
}
const useGameStore = create<GameState>((set, get) => ({
mines: [[]],
minesCount: 0,
isRevealed: [[]],
isFlagged: [[]],
isGameOver: false,
startTime: Date.now(),
width: 0,
height: 0,
stage: 1,
name: localStorage.getItem("name") || "No Name",
showFeed: !localStorage.getItem("showFeed")
? true
: localStorage.getItem("showFeed") === "true",
flag: (x, y) => {
set((state) => {
const isFlagged = [...state.isFlagged];
isFlagged[x][y] = !isFlagged[x][y];
return { isFlagged };
});
const { triggerPostGame } = get();
triggerPostGame();
},
reveal: (x, y) => {
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
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);
const neighborFlagCount = get()
.getNeighborFlags(x, y)
.filter((n) => n).length;
if (value === 0 && neighborFlagCount === 0) {
const revealNeighbors = (nx: number, ny: number) => {
if (get().isValid(nx, ny) && !isRevealed[nx]?.[ny]) {
get().reveal(nx, ny);
}
};
revealNeighbors(x - 1, y - 1);
revealNeighbors(x, y - 1);
revealNeighbors(x + 1, y - 1);
revealNeighbors(x - 1, y);
revealNeighbors(x + 1, y);
revealNeighbors(x - 1, y + 1);
revealNeighbors(x, y + 1);
revealNeighbors(x + 1, y + 1);
}
}
return triggerPostGame();
},
getValue: (x, y) => {
const { mines } = get();
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;
},
getHasWon: () => {
const { mines, isRevealed, isFlagged, isGameOver, width, height } = get();
if (isGameOver) return false;
for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
if (!isRevealed[i][j] && !isFlagged[i][j]) return false;
if (mines[i][j] && !isFlagged[i][j]) return false;
if (isFlagged[i][j] && !mines[i][j]) return false;
}
}
return true;
},
getMinesLeft: () => {
const { minesCount, isFlagged } = get();
return minesCount - isFlagged.flat().filter((flag) => flag).length;
},
quickStart: () => {
const { width, height, mines, getValue, reveal } = get();
for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
const value = getValue(i, j);
if (value === 0 && !mines[i][j]) {
reveal(i, j);
return;
}
}
}
},
isValid: (x: number, y: number) => {
const { width, height } = 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");
}
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(),
stage: 1,
});
},
isMine: (x: number, y: number) => {
const { mines } = get();
return mines[x][y];
},
getNeighborMines: (x: number, y: number) => {
const { mines } = get();
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;
},
getNeighborFlags: (x: number, y: number) => {
const { isFlagged } = get();
const neighbors = [
isFlagged[x - 1]?.[y - 1],
isFlagged[x]?.[y - 1],
isFlagged[x + 1]?.[y - 1],
isFlagged[x - 1]?.[y],
isFlagged[x + 1]?.[y],
isFlagged[x - 1]?.[y + 1],
isFlagged[x]?.[y + 1],
isFlagged[x + 1]?.[y + 1],
];
return neighbors;
},
getWidth: () => {
const { width } = get();
return width;
},
getHeight: () => {
const { height } = get();
return height;
},
isTouched: () => {
const { isRevealed, isFlagged } = get();
return (
isRevealed.flat().filter((flag) => flag).length > 0 ||
isFlagged.flat().filter((flag) => flag).length > 0
);
},
triggerPostGame: () => {
const { getHasWon, expandBoard } = get();
if (getHasWon()) {
expandBoard();
return true;
}
return false;
},
expandBoard: () => {
const { width, height, stage, mines, isFlagged, isRevealed } = get();
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);
const newWidth = width;
const newMinesCount = Math.floor(
width * height * 0.5 * (0.2 + 0.003 * stage),
);
// expand mines array
const newMines = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsRevealed = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsFlagged = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
for (let i = 0; i < newWidth; i++) {
for (let j = 0; j < newHeight; j++) {
const x = i;
const y = j;
if (mines[x]?.[y]) {
newMines[i][j] = true;
}
if (isRevealed[x]?.[y]) {
newIsRevealed[i][j] = true;
}
if (isFlagged[x]?.[y]) {
newIsFlagged[i][j] = true;
}
}
}
// generate new mines
let remainingMines = newMinesCount;
while (remainingMines > 0) {
const x = Math.floor(Math.random() * width);
const y = height + Math.floor(Math.random() * (newHeight - height));
if (!newMines[x][y]) {
newMines[x][y] = true;
remainingMines--;
}
}
set({
width: newWidth,
height: newHeight,
mines: newMines,
minesCount: newMinesCount,
stage: stage + 1,
isRevealed: newIsRevealed,
isFlagged: newIsFlagged,
});
}
if (dir === "right") {
const newWidth = Math.floor(width * 1.5);
const newHeight = height;
const newMinesCount = Math.floor(
width * height * 0.5 * (0.2 + 0.003 * stage),
);
// expand mines array
const newMines = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsRevealed = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
const newIsFlagged = Array.from({ length: newWidth }, () =>
new Array(newHeight).fill(false),
);
for (let i = 0; i < newWidth; i++) {
for (let j = 0; j < newHeight; j++) {
const x = i;
const y = j;
if (mines[x]?.[y]) {
newMines[i][j] = true;
}
if (isRevealed[x]?.[y]) {
newIsRevealed[i][j] = true;
}
if (isFlagged[x]?.[y]) {
newIsFlagged[i][j] = true;
}
}
}
// generate new mines
let remainingMines = newMinesCount;
while (remainingMines > 0) {
const x = width + Math.floor(Math.random() * (newWidth - width));
const y = Math.floor(Math.random() * height);
if (!newMines[x][y]) {
newMines[x][y] = true;
remainingMines--;
}
}
set({
width: newWidth,
height: newHeight,
mines: newMines,
minesCount: newMinesCount,
stage: stage + 1,
isRevealed: newIsRevealed,
isFlagged: newIsFlagged,
});
}
const newMinesCount = get()
.mines.flat()
.filter((m) => m).length;
set({ minesCount: newMinesCount });
},
setName: (name) => {
localStorage.setItem("name", name);
set({ name });
},
setShowFeed: (showFeed) => {
localStorage.setItem("showFeed", showFeed.toString());
set({ showFeed });
},
}));
export default useGameStore;

View File

@ -1,96 +0,0 @@
import { useEffect, useState } from "react";
import useGameStore from "./GameState";
const presets = {
Easy: { width: 10, height: 10, mines: 20 },
Medium: { width: 16, height: 16, mines: 32 },
Expert: { width: 30, height: 16, mines: 99 },
"Max Mode": { width: 40, height: 40, mines: 350 },
} as const;
function Options() {
const game = useGameStore();
const [width, setWidth] = useState(16);
const [height, setHeight] = useState(16);
const [mines, setMines] = useState(32);
const [showOptions, setShowOptions] = useState(false);
useEffect(() => {
const fixWidth = Math.min(40, width);
const fixHeight = Math.min(40, height);
setWidth(fixWidth);
setHeight(fixHeight);
}, [width, height]);
useEffect(() => {
if (!game.isTouched()) {
if (width <= 0 || height <= 0 || mines <= 0) {
return;
}
game.resetGame(width, height, mines);
}
}, [width, height, mines, game]);
return (
<div>
<button onClick={() => setShowOptions(!showOptions)}>
{showOptions ? "Hide" : "Show"} Options
</button>
{showOptions && (
<>
<p>
Presets:{" "}
{(Object.keys(presets) as Array<keyof typeof presets>).map(
(key) => (
<button
key={key}
onClick={() => {
const { width, height, mines } = presets[key];
setWidth(width);
setHeight(height);
setMines(mines);
}}
>
{key}
</button>
),
)}
</p>
<p>
Width:{" "}
<input
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
/>
</p>
<p>
Height:{" "}
<input
type="number"
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
/>
</p>
<p>
Mines:{" "}
<input
type="number"
value={mines}
onChange={(e) => setMines(Number(e.target.value))}
/>
</p>
</>
)}
<button
onClick={() => {
game.resetGame(width, height, mines);
}}
>
Reset
</button>
</div>
);
}
export default Options;

129
src/Shell.tsx Normal file
View File

@ -0,0 +1,129 @@
import { PropsWithChildren, useEffect, useRef, useState } from "react";
import { Button } from "./components/Button";
import { motion } from "framer-motion";
import {
GitBranch,
History,
Home,
Library,
Menu,
Play,
Settings,
Store,
} from "lucide-react";
import Hr from "./components/Hr";
import NavLink from "./components/NavLink";
import { useMediaQuery } from "@uidotdev/usehooks";
import Header from "./components/Header";
import { Tag } from "./components/Tag";
import Feed from "./components/Feed/Feed";
const drawerWidth = 256;
const drawerWidthWithPadding = drawerWidth;
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null);
const x = isOpen ? 0 : -drawerWidthWithPadding;
const width = isOpen ? drawerWidthWithPadding : 0;
const isMobile = useMediaQuery("(max-width: 768px)");
useEffect(() => {
setIsOpen(!isMobile);
}, [isMobile]);
useEffect(() => {
const onOutsideClick = (e: MouseEvent) => {
if (
drawerRef.current &&
!drawerRef.current.contains(e.target as Node) &&
isMobile
) {
setIsOpen(false);
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener("click", onOutsideClick);
return () => {
document.removeEventListener("click", onOutsideClick);
};
});
return (
<div className="bg-black min-h-screen">
<motion.div
className="bg-black p-4 fixed h-screen w-64 flex border-white/10 border-1"
ref={drawerRef}
animate={{ x }}
transition={{ type: "tween" }}
>
<div className="w-full p-2 flex flex-col gap-6">
<h2 className="[background:var(--bg-brand)] [-webkit-text-fill-color:transparent] font-black [-webkit-background-clip:text!important] font-mono text-3xl">
Business
<br />
Minesweeper
</h2>
<Hr />
<NavLink href="/">
<Home />
Home
</NavLink>
<NavLink href="/play">
<Play />
Play
</NavLink>
<NavLink href="/history">
<History />
History
</NavLink>
<NavLink href="/store">
<Store />
Store
</NavLink>
<NavLink href="/collection">
<Library />
Collection <Tag size="sm">NEW</Tag>
</NavLink>
<NavLink href="/settings">
<Settings />
Settings
</NavLink>
<Hr />
<Feed />
{/* <Hr /> */}
<div className="grow" />
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
<GitBranch />
Source
</NavLink>
</div>
<div className="relative">
<Button
className="absolute left-4 bg-black border-white/10 border-y-1 border-r-1 rounded-l-none"
variant="ghost"
onClick={() => setIsOpen((isOpen) => !isOpen)}
aria-label="Menu"
>
<Menu />
</Button>
</div>
</motion.div>
<motion.div className="flex max-w-[100vw]">
<motion.div
className="hidden md:block"
animate={{ width: width }}
transition={{ type: "tween" }}
layout
/>
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-[calc(100vw-256px)]">
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
<Header />
{children}
</div>
</motion.div>
</motion.div>
</div>
);
};
export default Shell;

View File

@ -1,92 +0,0 @@
import { useEffect, useState } from "react";
import Confetti from "react-confetti-boom";
import useGameStore from "./GameState";
const emoteByStage = [
"😐",
"😐",
"🙂",
"🤔",
"👀",
"😎",
"💀",
"🤯",
"🐐",
"⚡",
"🦸",
"🔥",
"💥",
"🐶",
"🦉",
"🚀",
"👾",
];
const Timer = () => {
const game = useGameStore();
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
if (game.isGameOver || game.getHasWon()) {
if (game.stage === 1) return;
const name = game.name;
if (name) {
fetch("https://mb.gordon.business/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user: name,
stage: game.stage,
}),
});
}
return;
}
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [game, game.isGameOver]);
return (
<>
<div className="stage">
<p>
Stage: {game.stage} ({game.getWidth()}x{game.getHeight()})
</p>
</div>
<div className="timer">
<p style={{ width: "100px" }}>{game.getMinesLeft()}</p>
<p
style={{
fontSize: "2rem",
}}
>
{game.getHasWon()
? "😎"
: game.isGameOver
? "😢"
: emoteByStage[game.stage] || "😐"}
{game.stage > 1 && (
<Confetti
mode="boom"
particleCount={20 * game.stage}
key={game.stage}
/>
)}
</p>
<p style={{ width: "100px", textAlign: "right" }}>
{Math.max(
0,
Math.floor((currentTime - (game.startTime || 0)) / 1000),
)}
</p>
</div>
</>
);
};
export default Timer;

BIN
src/assets/gem.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
src/assets/themes/MLG/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

BIN
src/assets/themes/MLG/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

BIN
src/assets/themes/MLG/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

BIN
src/assets/themes/MLG/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

BIN
src/assets/themes/MLG/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

BIN
src/assets/themes/MLG/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

BIN
src/assets/themes/MLG/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

BIN
src/assets/themes/MLG/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Some files were not shown because too many files have changed in this diff Show More