last version before 2.0
This commit is contained in:
parent
ce3eb836da
commit
62055855e5
|
|
@ -6,12 +6,13 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
tsconfig.app.tsbuildinfo
|
||||
tsconfig.*.tsbuildinfo
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
temp_dbs
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import type { z } from "zod";
|
||||
|
||||
interface RequestContext {
|
||||
user?: string;
|
||||
db: BunSQLiteDatabase;
|
||||
}
|
||||
|
||||
export type Endpoint<TInput, TResponse> = {
|
||||
validate: z.ZodType<TInput>;
|
||||
handler: (input: TInput, 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>,
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
||||
): Endpoint<TInput, TResponse> => {
|
||||
return { validate, handler };
|
||||
};
|
||||
|
||||
export type Controller<TEndpoints extends Record<string, Endpoint<any, any>>> =
|
||||
TEndpoints;
|
||||
|
||||
export const createController = <
|
||||
TEndpoints extends Record<string, Endpoint<any, any>>,
|
||||
>(
|
||||
endpoints: TEndpoints,
|
||||
): Controller<TEndpoints> => {
|
||||
return endpoints;
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { z } from "zod";
|
||||
import { createController, createEndpoint } from "./controller";
|
||||
import { getGame, upsertGameState } from "../repositories/gameRepository";
|
||||
import {
|
||||
serverGame,
|
||||
serverToClientGame,
|
||||
type ServerGame,
|
||||
} from "../../shared/game";
|
||||
import crypto from "crypto";
|
||||
import { game } from "../entities/game";
|
||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||
import { emit } from "../events";
|
||||
|
||||
export const gameController = createController({
|
||||
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
||||
const game = await getGame(ctx.db, uuid);
|
||||
const parsed = JSON.parse(game.gameState);
|
||||
const gameState = await serverGame.parseAsync(parsed);
|
||||
if (game.finished) return gameState;
|
||||
return serverToClientGame(gameState);
|
||||
}),
|
||||
createGame: createEndpoint(z.undefined(), async (_, { user, db }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const uuid = crypto.randomUUID() as string;
|
||||
const newGame: ServerGame = game.createGame({
|
||||
uuid,
|
||||
user: user,
|
||||
mines: 2,
|
||||
width: 4,
|
||||
height: 4,
|
||||
});
|
||||
upsertGameState(db, newGame);
|
||||
emit({
|
||||
type: "new",
|
||||
user,
|
||||
});
|
||||
emit({
|
||||
type: "updateStage",
|
||||
game: uuid,
|
||||
stage: newGame.stage,
|
||||
started: newGame.started,
|
||||
});
|
||||
return newGame;
|
||||
}),
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { ServerGame } from "../../shared/game";
|
||||
|
||||
interface CreateGameOptions {
|
||||
uuid: string;
|
||||
user: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mines: number;
|
||||
}
|
||||
|
||||
export const game = {
|
||||
createGame: (options: CreateGameOptions): ServerGame => {
|
||||
const { uuid, user, width, height, mines } = options;
|
||||
if (mines > width * height) {
|
||||
throw new Error("Too many mines");
|
||||
}
|
||||
|
||||
const minesArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isRevealedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isFlaggedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
|
||||
let remainingMines = mines;
|
||||
while (remainingMines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!minesArray[x][y]) {
|
||||
minesArray[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uuid,
|
||||
user,
|
||||
finished: 0,
|
||||
started: Date.now(),
|
||||
width,
|
||||
height,
|
||||
mines: minesArray,
|
||||
isRevealed: isRevealedArray,
|
||||
isFlagged: isFlaggedArray,
|
||||
stage: 1,
|
||||
lastClick: [-1, -1],
|
||||
minesCount: mines,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export class BadRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BadRequestError";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ClientGame } from "../shared/game";
|
||||
|
||||
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
|
||||
|
||||
type Events =
|
||||
| {
|
||||
type: "new";
|
||||
user: string;
|
||||
}
|
||||
| {
|
||||
type: "loss";
|
||||
user: string;
|
||||
stage: number;
|
||||
}
|
||||
| {
|
||||
type: "updateGame";
|
||||
game: string;
|
||||
data: ClientGame;
|
||||
}
|
||||
| {
|
||||
type: "updateStage";
|
||||
game: string;
|
||||
stage: number;
|
||||
started: number;
|
||||
};
|
||||
|
||||
const listeners = new Set<(event: Events) => void>();
|
||||
|
||||
export const on = (listener: (event: Events) => void) => {
|
||||
listeners.add(listener);
|
||||
};
|
||||
|
||||
export const off = (listener: (event: Events) => void) => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
|
||||
export const emit = (event: Events) => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
};
|
||||
|
|
@ -1,28 +1,12 @@
|
|||
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;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const allowCors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
const lastMessage = new WeakMap<ServerWebSocket<unknown>, number>();
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
const server = Bun.serve({
|
||||
async fetch(request: Request) {
|
||||
if (request.method === "OPTIONS") {
|
||||
|
|
@ -32,67 +16,23 @@ const server = Bun.serve({
|
|||
if (request.url.endsWith("ws")) {
|
||||
if (server.upgrade(request)) return new Response("ok");
|
||||
}
|
||||
if (new URL(request.url).pathname === "/submit") {
|
||||
const body = await request.text();
|
||||
const data = JSON.parse(body) as { stage: number; user: string };
|
||||
const scoreboardFile = Bun.file("./scoreboard.json");
|
||||
const scoreboard = await loadScoreboard();
|
||||
const currentScore = scoreboard.find((s) => s.user === data.user);
|
||||
if (currentScore) {
|
||||
if (currentScore.stage < data.stage) {
|
||||
currentScore.stage = data.stage;
|
||||
Bun.write(scoreboardFile, JSON.stringify(scoreboard));
|
||||
}
|
||||
return new Response(JSON.stringify(currentScore), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
}
|
||||
scoreboard.push(data);
|
||||
Bun.write(scoreboardFile, JSON.stringify(scoreboard));
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
}
|
||||
const scoreboard = await loadScoreboard();
|
||||
const sorted = scoreboard.sort((a, b) => b.stage - a.stage);
|
||||
return new Response(JSON.stringify(sorted), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
},
|
||||
websocket: {
|
||||
message: (ws, message) => {
|
||||
if (typeof message !== "string") {
|
||||
return;
|
||||
}
|
||||
const msg = JSON.parse(message);
|
||||
const now = Date.now();
|
||||
if (lastMessage.has(ws) && now - lastMessage.get(ws)! < 200) {
|
||||
const user = userName.get(ws);
|
||||
try {
|
||||
const msg = JSON.parse(message);
|
||||
console.log(msg);
|
||||
} catch (e) {
|
||||
console.error("Faulty request", message, e);
|
||||
return;
|
||||
}
|
||||
lastMessage.set(ws, now);
|
||||
if (msg.type === "loss") {
|
||||
server.publish(
|
||||
"minesweeper",
|
||||
JSON.stringify({ type: "loss", user: msg.user, stage: msg.stage }),
|
||||
);
|
||||
} else if (msg.type === "new") {
|
||||
server.publish(
|
||||
"minesweeper",
|
||||
JSON.stringify({ type: "new", user: msg.user }),
|
||||
);
|
||||
}
|
||||
},
|
||||
open: async (ws) => {
|
||||
ws.subscribe("minesweeper");
|
||||
ws.subscribe("minesweeper-global");
|
||||
},
|
||||
},
|
||||
port: 8076,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getTestDb } from "../database/getTestDb";
|
||||
import { Game } from "../schema";
|
||||
import {
|
||||
getCurrentGame,
|
||||
getGame,
|
||||
getGamesCount,
|
||||
upsertGame,
|
||||
} from "./gameRepository";
|
||||
|
||||
describe("GameRepository", () => {
|
||||
it("should get game by uuid", async () => {
|
||||
const db = getTestDb();
|
||||
const started = Date.now();
|
||||
await db.insert(Game).values({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
const game = await getGame(db, "TestUuid");
|
||||
expect(game).toEqual({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined if game does not exist", async () => {
|
||||
const db = getTestDb();
|
||||
const game = await getGame(db, "TestUuid");
|
||||
expect(game).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return latest game", async () => {
|
||||
const db = getTestDb();
|
||||
const started = Date.now();
|
||||
await db.insert(Game).values({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
uuid: "TestUuid2",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
const game = await getCurrentGame(db, "TestUser");
|
||||
expect(game).toEqual({
|
||||
uuid: "TestUuid2",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return game count", async () => {
|
||||
const db = getTestDb();
|
||||
const started = Date.now();
|
||||
const uuids = ["TestUuid", "TestUuid2", "TestUuid3"];
|
||||
for (const uuid of uuids) {
|
||||
await db.insert(Game).values({
|
||||
uuid,
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
}
|
||||
const count = await getGamesCount(db, "TestUser");
|
||||
expect(count).toEqual(3);
|
||||
});
|
||||
|
||||
it("should insert game", async () => {
|
||||
const db = getTestDb();
|
||||
const started = Date.now();
|
||||
await upsertGame(db, {
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
const game = await getGame(db, "TestUuid");
|
||||
expect(game).toEqual({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
});
|
||||
|
||||
it("should update game", async () => {
|
||||
const db = getTestDb();
|
||||
const started = Date.now();
|
||||
await db.insert(Game).values({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
await upsertGame(db, {
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
const game = await getGame(db, "TestUuid");
|
||||
expect(game).toEqual({
|
||||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { Game, type GameType } from "../schema";
|
||||
import { eq, sql, desc, and, not } from "drizzle-orm";
|
||||
import type { ServerGame } from "../../shared/game";
|
||||
|
||||
export const getGame = async (db: BunSQLiteDatabase, uuid: string) => {
|
||||
return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0];
|
||||
};
|
||||
|
||||
export const getGames = async (db: BunSQLiteDatabase, user: string) => {
|
||||
return await db
|
||||
.select()
|
||||
.from(Game)
|
||||
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
|
||||
.orderBy(Game.started, sql`desc`);
|
||||
};
|
||||
|
||||
export const getCurrentGame = async (db: BunSQLiteDatabase, user: string) => {
|
||||
return (
|
||||
await db
|
||||
.select()
|
||||
.from(Game)
|
||||
.where(eq(Game.user, user))
|
||||
.orderBy(desc(Game.started))
|
||||
.limit(1)
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const getGamesCount = async (db: BunSQLiteDatabase, user: string) => {
|
||||
return (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(Game)
|
||||
.where(eq(Game.user, user))
|
||||
)[0].count;
|
||||
};
|
||||
|
||||
export const upsertGame = async (db: BunSQLiteDatabase, game: GameType) => {
|
||||
const { uuid, user, stage, gameState, finished, started } = game;
|
||||
const games = await db.select().from(Game).where(eq(Game.uuid, uuid));
|
||||
if (games.length > 0) {
|
||||
await db
|
||||
.update(Game)
|
||||
.set({
|
||||
stage,
|
||||
gameState,
|
||||
finished,
|
||||
started,
|
||||
})
|
||||
.where(eq(Game.uuid, uuid));
|
||||
} else {
|
||||
await db.insert(Game).values({
|
||||
uuid,
|
||||
user,
|
||||
stage,
|
||||
gameState,
|
||||
finished,
|
||||
started,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertGameState = async (
|
||||
db: BunSQLiteDatabase,
|
||||
game: ServerGame,
|
||||
) => {
|
||||
const { uuid, user, stage, finished, started } = game;
|
||||
upsertGame(db, {
|
||||
uuid,
|
||||
user,
|
||||
stage,
|
||||
gameState: JSON.stringify(game),
|
||||
finished,
|
||||
started,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getScoreBoard } from "./score.ts";
|
||||
import { getScoreBoard } from "./scoreRepository.ts";
|
||||
import { getTestDb } from "../database/getTestDb.ts";
|
||||
import { Game, User } from "../schema.ts";
|
||||
|
||||
describe("Score", () => {
|
||||
it("should return the score board", async () => {
|
||||
describe("ScoreRepository", () => {
|
||||
it("should return the scoreboard", async () => {
|
||||
const db = getTestDb();
|
||||
await db.insert(User).values({
|
||||
name: "TestUser",
|
||||
|
|
@ -16,7 +16,7 @@ describe("Score", () => {
|
|||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
timestamp: Date.now(),
|
||||
started: Date.now(),
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
user: "TestUser",
|
||||
|
|
@ -24,7 +24,7 @@ describe("Score", () => {
|
|||
stage: 10,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
timestamp: Date.now(),
|
||||
started: Date.now(),
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
user: "TestUser",
|
||||
|
|
@ -32,7 +32,7 @@ describe("Score", () => {
|
|||
stage: 20,
|
||||
gameState: "ANY",
|
||||
finished: 0,
|
||||
timestamp: Date.now(),
|
||||
started: Date.now(),
|
||||
});
|
||||
const result = await getScoreBoard(db);
|
||||
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, sql, not } from "drizzle-orm";
|
||||
import { Game } from "../schema";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
|
|
@ -6,6 +6,6 @@ export const getScoreBoard = async (db: BunSQLiteDatabase) => {
|
|||
return await db
|
||||
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
|
||||
.from(Game)
|
||||
.where(eq(Game.finished, 1))
|
||||
.where(not(eq(Game.finished, 0)))
|
||||
.groupBy(Game.user);
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getTestDb } from "../database/getTestDb";
|
||||
import { getUser, loginUser, registerUser } from "./user";
|
||||
import { getUser, loginUser, registerUser } from "./userRepository";
|
||||
|
||||
describe("User", () => {
|
||||
describe("UserRepository", () => {
|
||||
it("should register a user", async () => {
|
||||
const db = getTestDb();
|
||||
await registerUser(db, "TestUser", "test");
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Controller, Endpoint } from "./controller/controller";
|
||||
import { gameController } from "./controller/gameController";
|
||||
import { db } from "./database/db";
|
||||
import { BadRequestError } from "./errors/BadRequestError";
|
||||
|
||||
const controllers = {
|
||||
game: gameController,
|
||||
} satisfies Record<string, Controller<any>>;
|
||||
|
||||
export const handleRequest = (message: unknown, sessionUser?: string) => {
|
||||
const ctx = {
|
||||
user: sessionUser,
|
||||
db,
|
||||
};
|
||||
if (
|
||||
!message ||
|
||||
!(typeof message === "object") ||
|
||||
!("type" in message) ||
|
||||
!("payload" in message)
|
||||
)
|
||||
return;
|
||||
const { type, payload } = message;
|
||||
if (!(typeof type === "string")) return;
|
||||
const [controllerName, action] = type.split(".");
|
||||
if (!(controllerName in controllers)) return;
|
||||
// @ts-expect-error controllers[controllerName] is a Controller
|
||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||
const input = endpoint.validate.safeParse(payload);
|
||||
if (input.success) {
|
||||
const result = endpoint.handler(input.data, ctx);
|
||||
return result;
|
||||
}
|
||||
throw new BadRequestError(input.error.message);
|
||||
};
|
||||
|
||||
export type Routes = typeof controllers;
|
||||
|
|
@ -13,7 +13,7 @@ export const Game = sqliteTable("games", {
|
|||
gameState: text("gameState").notNull(),
|
||||
stage: integer("stage").notNull(),
|
||||
finished: integer("finished").notNull().default(0),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
started: integer("timestamp").notNull(),
|
||||
});
|
||||
|
||||
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"use-sound": "^4.0.3",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getValue, serverToClientGame } from "./game";
|
||||
|
||||
describe("Game", () => {
|
||||
it("should get value", () => {
|
||||
const mines = [
|
||||
[false, false, true, true, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, true, true],
|
||||
[false, false, false, false, false],
|
||||
];
|
||||
expect(getValue(mines, 0, 0)).toEqual(1);
|
||||
expect(getValue(mines, 0, 1)).toEqual(3);
|
||||
expect(getValue(mines, 3, 0)).toEqual(0);
|
||||
expect(getValue(mines, 1, 3)).toEqual(8);
|
||||
});
|
||||
|
||||
it("should convert server to client game", () => {
|
||||
const serverGame = {
|
||||
mines: [
|
||||
[false, false, true, true, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, true, true],
|
||||
[false, false, false, false, false],
|
||||
],
|
||||
minesCount: 4,
|
||||
isRevealed: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[false, false, false, false, false],
|
||||
],
|
||||
isFlagged: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[true, false, false, false, false],
|
||||
],
|
||||
isGameOver: false,
|
||||
started: 1679599200000,
|
||||
finished: 0,
|
||||
lastClick: [0, 0] satisfies [number, number],
|
||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||
width: 5,
|
||||
height: 4,
|
||||
};
|
||||
expect(serverToClientGame(serverGame)).toEqual({
|
||||
minesCount: 4,
|
||||
isRevealed: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[false, false, false, false, false],
|
||||
],
|
||||
isFlagged: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[true, false, false, false, false],
|
||||
],
|
||||
values: [
|
||||
[-1, -1, 2, -1, 2],
|
||||
[0, -1, 4, -1, 4],
|
||||
[-1, -1, 2, -1, 2],
|
||||
[-1, -1, -1, -1, -1],
|
||||
],
|
||||
lastClick: [0, 0],
|
||||
started: 1679599200000,
|
||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||
width: 5,
|
||||
height: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const clientGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
values: z.array(z.array(z.number())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
stage: z.number(),
|
||||
});
|
||||
|
||||
export const serverGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
mines: z.array(z.array(z.boolean())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
finished: z.number().default(0),
|
||||
stage: z.number(),
|
||||
});
|
||||
|
||||
export type ClientGame = z.infer<typeof clientGame>;
|
||||
export type ServerGame = z.infer<typeof serverGame>;
|
||||
|
||||
export const getValue = (mines: boolean[][], x: number, y: number) => {
|
||||
const neighbors = [
|
||||
mines[x - 1]?.[y - 1],
|
||||
mines[x]?.[y - 1],
|
||||
mines[x + 1]?.[y - 1],
|
||||
mines[x - 1]?.[y],
|
||||
mines[x + 1]?.[y],
|
||||
mines[x - 1]?.[y + 1],
|
||||
mines[x]?.[y + 1],
|
||||
mines[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors.filter((n) => n).length;
|
||||
};
|
||||
|
||||
export const serverToClientGame = (game: ServerGame): ClientGame => {
|
||||
return {
|
||||
user: game.user,
|
||||
uuid: game.uuid,
|
||||
width: game.width,
|
||||
height: game.height,
|
||||
isRevealed: game.isRevealed,
|
||||
isFlagged: game.isFlagged,
|
||||
minesCount: game.minesCount,
|
||||
values: game.mines.map((_, i) =>
|
||||
game.mines[0].map((_, j) => {
|
||||
if (!game.isRevealed[i][j]) return -1;
|
||||
return getValue(game.mines, i, j);
|
||||
}),
|
||||
),
|
||||
lastClick: game.lastClick,
|
||||
started: game.started,
|
||||
stage: game.stage,
|
||||
};
|
||||
};
|
||||
14
src/App.tsx
14
src/App.tsx
|
|
@ -23,7 +23,6 @@ function useMaxToasts(max: number) {
|
|||
}, [toasts, max]);
|
||||
}
|
||||
|
||||
useGameStore.getState().resetGame(4, 4, 2);
|
||||
function App() {
|
||||
const game = useGameStore();
|
||||
const [scores, setScores] = useState<Score[]>([]);
|
||||
|
|
@ -38,6 +37,10 @@ function App() {
|
|||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [game.isGameOver]);
|
||||
useEffect(() => {
|
||||
game.resetGame(4, 4, 2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://mb.gordon.business")
|
||||
|
|
@ -75,6 +78,15 @@ function App() {
|
|||
onChange={(e) => game.setName(e.target.value)}
|
||||
/>
|
||||
</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) => (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||
import { newGame } from "./ws";
|
||||
|
||||
interface GameState {
|
||||
showFeed: boolean;
|
||||
mines: boolean[][];
|
||||
minesCount: number;
|
||||
isRevealed: boolean[][];
|
||||
|
|
@ -13,7 +14,6 @@ interface GameState {
|
|||
stage: number;
|
||||
name: string;
|
||||
|
||||
initializeGame: (width: number, height: number, mines: number) => void;
|
||||
flag: (x: number, y: number) => void;
|
||||
reveal: (x: number, y: number) => boolean;
|
||||
getValue: (x: number, y: number) => number;
|
||||
|
|
@ -31,6 +31,7 @@ interface GameState {
|
|||
triggerPostGame: () => boolean;
|
||||
expandBoard: () => void;
|
||||
setName: (name: string) => void;
|
||||
setShowFeed: (showFeed: boolean) => void;
|
||||
}
|
||||
|
||||
const useGameStore = create<GameState>((set, get) => ({
|
||||
|
|
@ -44,41 +45,9 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
height: 0,
|
||||
stage: 1,
|
||||
name: localStorage.getItem("name") || "No Name",
|
||||
|
||||
initializeGame: (width, height, mines) => {
|
||||
mines = Math.min(mines, width * height);
|
||||
|
||||
const minesArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isRevealedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isFlaggedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
|
||||
let remainingMines = mines;
|
||||
while (remainingMines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!minesArray[x][y]) {
|
||||
minesArray[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
width,
|
||||
height,
|
||||
mines: minesArray,
|
||||
isRevealed: isRevealedArray,
|
||||
isFlagged: isFlaggedArray,
|
||||
minesCount: mines,
|
||||
isGameOver: false,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
},
|
||||
showFeed: !localStorage.getItem("showFeed")
|
||||
? true
|
||||
: localStorage.getItem("showFeed") === "true",
|
||||
|
||||
flag: (x, y) => {
|
||||
set((state) => {
|
||||
|
|
@ -389,6 +358,10 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
localStorage.setItem("name", name);
|
||||
set({ name });
|
||||
},
|
||||
setShowFeed: (showFeed) => {
|
||||
localStorage.setItem("showFeed", showFeed.toString());
|
||||
set({ showFeed });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useGameStore;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import toast from "react-hot-toast";
|
||||
import useGameStore from "./GameState";
|
||||
|
||||
let ws: WebSocket;
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export const connectWS = () => {
|
|||
if (data.user === name) {
|
||||
return;
|
||||
}
|
||||
if (!useGameStore.getState().showFeed) return;
|
||||
switch (data.type) {
|
||||
case "new":
|
||||
toast(data.user + " started a new game", {
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@
|
|||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "shared"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{"root":["./vite.config.ts","./backend/index.ts","./backend/migrate.ts","./backend/schema.ts","./backend/test-setup.ts","./backend/database/db.ts","./backend/database/getDb.ts","./backend/database/getTestDb.ts","./backend/services/score.test.ts","./backend/services/score.ts"],"version":"5.6.2"}
|
||||
Loading…
Reference in New Issue