last version before 2.0
This commit is contained in:
parent
ce3eb836da
commit
62055855e5
|
|
@ -6,12 +6,13 @@ yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
tsconfig.app.tsbuildinfo
|
tsconfig.*.tsbuildinfo
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
temp_dbs
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.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";
|
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 = {
|
const allowCors = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type",
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
};
|
};
|
||||||
|
|
||||||
const lastMessage = new WeakMap<ServerWebSocket<unknown>, number>();
|
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
async fetch(request: Request) {
|
async fetch(request: Request) {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
|
|
@ -32,67 +16,23 @@ const server = Bun.serve({
|
||||||
if (request.url.endsWith("ws")) {
|
if (request.url.endsWith("ws")) {
|
||||||
if (server.upgrade(request)) return new Response("ok");
|
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: {
|
websocket: {
|
||||||
message: (ws, message) => {
|
message: (ws, message) => {
|
||||||
if (typeof message !== "string") {
|
if (typeof message !== "string") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = JSON.parse(message);
|
const user = userName.get(ws);
|
||||||
const now = Date.now();
|
try {
|
||||||
if (lastMessage.has(ws) && now - lastMessage.get(ws)! < 200) {
|
const msg = JSON.parse(message);
|
||||||
|
console.log(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Faulty request", message, e);
|
||||||
return;
|
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) => {
|
open: async (ws) => {
|
||||||
ws.subscribe("minesweeper");
|
ws.subscribe("minesweeper-global");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
port: 8076,
|
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 { describe, it, expect } from "bun:test";
|
||||||
import { getScoreBoard } from "./score.ts";
|
import { getScoreBoard } from "./scoreRepository.ts";
|
||||||
import { getTestDb } from "../database/getTestDb.ts";
|
import { getTestDb } from "../database/getTestDb.ts";
|
||||||
import { Game, User } from "../schema.ts";
|
import { Game, User } from "../schema.ts";
|
||||||
|
|
||||||
describe("Score", () => {
|
describe("ScoreRepository", () => {
|
||||||
it("should return the score board", async () => {
|
it("should return the scoreboard", async () => {
|
||||||
const db = getTestDb();
|
const db = getTestDb();
|
||||||
await db.insert(User).values({
|
await db.insert(User).values({
|
||||||
name: "TestUser",
|
name: "TestUser",
|
||||||
|
|
@ -16,7 +16,7 @@ describe("Score", () => {
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: "ANY",
|
||||||
finished: 1,
|
finished: 1,
|
||||||
timestamp: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
await db.insert(Game).values({
|
await db.insert(Game).values({
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
|
|
@ -24,7 +24,7 @@ describe("Score", () => {
|
||||||
stage: 10,
|
stage: 10,
|
||||||
gameState: "ANY",
|
gameState: "ANY",
|
||||||
finished: 1,
|
finished: 1,
|
||||||
timestamp: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
await db.insert(Game).values({
|
await db.insert(Game).values({
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
|
|
@ -32,7 +32,7 @@ describe("Score", () => {
|
||||||
stage: 20,
|
stage: 20,
|
||||||
gameState: "ANY",
|
gameState: "ANY",
|
||||||
finished: 0,
|
finished: 0,
|
||||||
timestamp: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
const result = await getScoreBoard(db);
|
const result = await getScoreBoard(db);
|
||||||
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
|
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 { Game } from "../schema";
|
||||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
|
|
||||||
|
|
@ -6,6 +6,6 @@ export const getScoreBoard = async (db: BunSQLiteDatabase) => {
|
||||||
return await db
|
return await db
|
||||||
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
|
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
|
||||||
.from(Game)
|
.from(Game)
|
||||||
.where(eq(Game.finished, 1))
|
.where(not(eq(Game.finished, 0)))
|
||||||
.groupBy(Game.user);
|
.groupBy(Game.user);
|
||||||
};
|
};
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { getTestDb } from "../database/getTestDb";
|
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 () => {
|
it("should register a user", async () => {
|
||||||
const db = getTestDb();
|
const db = getTestDb();
|
||||||
await registerUser(db, "TestUser", "test");
|
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(),
|
gameState: text("gameState").notNull(),
|
||||||
stage: integer("stage").notNull(),
|
stage: integer("stage").notNull(),
|
||||||
finished: integer("finished").notNull().default(0),
|
finished: integer("finished").notNull().default(0),
|
||||||
timestamp: integer("timestamp").notNull(),
|
started: integer("timestamp").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"use-sound": "^4.0.3",
|
"use-sound": "^4.0.3",
|
||||||
|
"zod": "^3.23.8",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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]);
|
}, [toasts, max]);
|
||||||
}
|
}
|
||||||
|
|
||||||
useGameStore.getState().resetGame(4, 4, 2);
|
|
||||||
function App() {
|
function App() {
|
||||||
const game = useGameStore();
|
const game = useGameStore();
|
||||||
const [scores, setScores] = useState<Score[]>([]);
|
const [scores, setScores] = useState<Score[]>([]);
|
||||||
|
|
@ -38,6 +37,10 @@ function App() {
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [game.isGameOver]);
|
}, [game.isGameOver]);
|
||||||
|
useEffect(() => {
|
||||||
|
game.resetGame(4, 4, 2);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("https://mb.gordon.business")
|
fetch("https://mb.gordon.business")
|
||||||
|
|
@ -75,6 +78,15 @@ function App() {
|
||||||
onChange={(e) => game.setName(e.target.value)}
|
onChange={(e) => game.setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Feed:{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => game.setShowFeed(!game.showFeed)}
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
>
|
||||||
|
{game.showFeed ? "Shown" : "Hidden"}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="scores">
|
<div className="scores">
|
||||||
{scores.slice(0, 10).map((score) => (
|
{scores.slice(0, 10).map((score) => (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||||
import { newGame } from "./ws";
|
import { newGame } from "./ws";
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
|
showFeed: boolean;
|
||||||
mines: boolean[][];
|
mines: boolean[][];
|
||||||
minesCount: number;
|
minesCount: number;
|
||||||
isRevealed: boolean[][];
|
isRevealed: boolean[][];
|
||||||
|
|
@ -13,7 +14,6 @@ interface GameState {
|
||||||
stage: number;
|
stage: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
initializeGame: (width: number, height: number, mines: number) => void;
|
|
||||||
flag: (x: number, y: number) => void;
|
flag: (x: number, y: number) => void;
|
||||||
reveal: (x: number, y: number) => boolean;
|
reveal: (x: number, y: number) => boolean;
|
||||||
getValue: (x: number, y: number) => number;
|
getValue: (x: number, y: number) => number;
|
||||||
|
|
@ -31,6 +31,7 @@ interface GameState {
|
||||||
triggerPostGame: () => boolean;
|
triggerPostGame: () => boolean;
|
||||||
expandBoard: () => void;
|
expandBoard: () => void;
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
|
setShowFeed: (showFeed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useGameStore = create<GameState>((set, get) => ({
|
const useGameStore = create<GameState>((set, get) => ({
|
||||||
|
|
@ -44,41 +45,9 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
height: 0,
|
height: 0,
|
||||||
stage: 1,
|
stage: 1,
|
||||||
name: localStorage.getItem("name") || "No Name",
|
name: localStorage.getItem("name") || "No Name",
|
||||||
|
showFeed: !localStorage.getItem("showFeed")
|
||||||
initializeGame: (width, height, mines) => {
|
? true
|
||||||
mines = Math.min(mines, width * height);
|
: localStorage.getItem("showFeed") === "true",
|
||||||
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
flag: (x, y) => {
|
flag: (x, y) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|
@ -389,6 +358,10 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
localStorage.setItem("name", name);
|
localStorage.setItem("name", name);
|
||||||
set({ name });
|
set({ name });
|
||||||
},
|
},
|
||||||
|
setShowFeed: (showFeed) => {
|
||||||
|
localStorage.setItem("showFeed", showFeed.toString());
|
||||||
|
set({ showFeed });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useGameStore;
|
export default useGameStore;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import useGameStore from "./GameState";
|
||||||
|
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ export const connectWS = () => {
|
||||||
if (data.user === name) {
|
if (data.user === name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!useGameStore.getState().showFeed) return;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "new":
|
case "new":
|
||||||
toast(data.user + " started a new game", {
|
toast(data.user + " started a new game", {
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,5 @@
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": 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