Compare commits

..

8 Commits

24 changed files with 796 additions and 84 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ temp_dbs
deploy.sh
sqlite.db
coverage

98
CLAUDE.md Normal file
View File

@ -0,0 +1,98 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Business Minesweeper is a real-time multiplayer minesweeper game with expanding boards, user accounts, match history, spectating, and collectibles. Built with React frontend and Bun backend using WebSockets for real-time communication.
## Development Commands
### Setup
```bash
# Initial setup (requires Bun installed)
echo "SECRET=SOME_RANDOM_STRING" > .env
bun install
bun run drizzle:migrate
```
### Development
```bash
bun dev # Start both frontend and backend in development mode
bun run dev:client # Start only frontend (Vite dev server)
bun run dev:backend # Start only backend with hot reload
```
### Build & Quality
```bash
bun run build # Build for production (TypeScript compilation + Vite build)
bun run lint # Run ESLint
bun run preview # Preview production build
```
### Database
```bash
bun run drizzle:schema # Generate database migrations
bun run drizzle:migrate # Run database migrations
bun run nukedb # Delete and recreate database (removes sqlite.db)
```
## Architecture
### Frontend (`src/`)
- **Framework**: React 18 + TypeScript with Vite build system
- **Routing**: Wouter for client-side routing
- **State Management**:
- Jotai for component state (atoms in `src/atoms.ts`)
- TanStack Query for server state and caching
- **Styling**: Tailwind CSS v4 with custom CSS variables
- **Animation**: Motion (Framer Motion) for UI animations
- **Game Rendering**: Pixi.js with PixiViewport for the minesweeper board
### Backend (`backend/`)
- **Runtime**: Bun with TypeScript
- **Architecture**: WebSocket-based real-time API with controller pattern
- **Database**: SQLite with Drizzle ORM
- **Structure**:
- `router.ts` - Main request handler and routing
- `controller/` - Business logic controllers (game, user, scoreboard)
- `repositories/` - Data access layer
- `database/` - DB connection and configuration
- `entities/` - Type definitions
- `events.ts` - Real-time event system
### Communication
- **WebSocket Client**: `src/wsClient.ts` handles connection, reconnection, and type-safe API calls
- **Real-time Updates**: Server publishes events to all connected clients for live game updates
- **Type Safety**: Shared types between frontend/backend via `Routes` interface
### Key Components
#### Game Logic
- `src/views/endless/Endless.tsx` - Main game view with Pixi.js board
- `src/components/LazyBoard.tsx` - Game board rendering component
- `backend/controller/gameController.ts` - Server-side game logic
#### UI Architecture
- `src/Shell.tsx` - Main layout with responsive drawer navigation
- `src/main.tsx` - App entry point with routing setup
- `src/components/` - Reusable UI components using Radix UI primitives
#### Data Flow
- WebSocket mutations for game actions (reveal, flag, etc.)
- TanStack Query for caching user data, game state, leaderboards
- Jotai atoms for local UI state (current game ID, settings)
## Key Patterns
### WebSocket API Usage
```typescript
const mutation = useWSMutation("game.reveal");
const { data } = useWSQuery("game.getGameState", gameId, !!gameId);
```
### Database Queries
Uses Drizzle ORM with repository pattern for data access. Each controller has corresponding repository in `backend/repositories/`.
### Real-time Events
Server publishes events via `backend/events.ts`, frontend handles via WebSocket message listeners in `wsClient.ts`.

View File

@ -4,9 +4,14 @@ import { Game } from "../schema";
import {
getCurrentGame,
getGame,
getGames,
getGamesCount,
getTotalGamesPlayed,
parseGameState,
upsertGame,
upsertGameState,
} from "./gameRepository";
import { encode } from "@msgpack/msgpack";
describe("GameRepository", () => {
it("should get game by uuid", async () => {
@ -136,4 +141,131 @@ describe("GameRepository", () => {
started: started + 1,
});
});
it("should get finished games for user", async () => {
const db = getTestDb();
const started = Date.now();
await db.insert(Game).values({
uuid: "TestUuid1",
user: "TestUser",
stage: 1,
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
await db.insert(Game).values({
uuid: "TestUuid2",
user: "TestUser",
stage: 2,
gameState: Buffer.from("ANY"),
finished: 0,
started: started + 1,
});
await db.insert(Game).values({
uuid: "TestUuid3",
user: "OtherUser",
stage: 1,
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 2,
});
const games = await getGames(db, "TestUser");
expect(games).toHaveLength(1);
expect(games[0].uuid).toBe("TestUuid1");
});
it("should get total games played for user", async () => {
const db = getTestDb();
const started = Date.now();
await db.insert(Game).values({
uuid: "TestUuid1",
user: "TestUser",
stage: 1,
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
await db.insert(Game).values({
uuid: "TestUuid2",
user: "TestUser",
stage: 2,
gameState: Buffer.from("ANY"),
finished: 0,
started: started + 1,
});
const totalGames = await getTotalGamesPlayed(db, "TestUser");
expect(totalGames).toBe(1);
});
it("should get total games played for all users", async () => {
const db = getTestDb();
const started = Date.now();
await db.insert(Game).values({
uuid: "TestUuid1",
user: "TestUser",
stage: 1,
gameState: Buffer.from("ANY"),
finished: 1,
started,
});
await db.insert(Game).values({
uuid: "TestUuid2",
user: "OtherUser",
stage: 2,
gameState: Buffer.from("ANY"),
finished: 1,
started: started + 1,
});
const totalGames = await getTotalGamesPlayed(db);
expect(totalGames).toBe(2);
});
it("should parse game state", () => {
const gameData = {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
finished: 1,
started: Date.now(),
// Other ServerGame properties don't matter for this test
mines: [[false]],
width: 1,
height: 1,
isRevealed: [[false]],
isFlagged: [[false]],
isQuestionMark: [[false]],
minesCount: 0,
lastClick: [0, 0] as [number, number],
theme: "default" as const,
};
const buffer = Buffer.from(encode(gameData));
const parsed = parseGameState(buffer);
expect(parsed).toEqual(gameData);
});
it("should upsert game state", async () => {
const db = getTestDb();
const serverGame = {
uuid: "TestUuid",
user: "TestUser",
stage: 1,
finished: 1,
started: Date.now(),
// Other ServerGame properties don't matter for this test
mines: [[false]],
width: 1,
height: 1,
isRevealed: [[false]],
isFlagged: [[false]],
isQuestionMark: [[false]],
minesCount: 0,
lastClick: [0, 0] as [number, number],
theme: "default" as const,
};
await upsertGameState(db, serverGame);
const game = await getGame(db, "TestUuid");
expect(game?.uuid).toBe("TestUuid");
expect(game?.user).toBe("TestUser");
expect(game?.stage).toBe(1);
});
});

View File

@ -82,7 +82,7 @@ export const upsertGameState = async (
game: ServerGame,
) => {
const { uuid, user, stage, finished, started } = game;
upsertGame(db, {
await upsertGame(db, {
uuid,
user,
stage,

View File

@ -37,4 +37,55 @@ describe("ScoreRepository", () => {
const result = await getScoreBoard(db);
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
});
it("should return empty array when no finished games exist", async () => {
const db = getTestDb();
await db.insert(User).values({
name: "TestUser2",
password: "test",
});
await db.insert(Game).values({
user: "TestUser2",
uuid: crypto.randomUUID(),
stage: 5,
gameState: Buffer.from("ANY"),
finished: 0,
started: Date.now(),
});
const result = await getScoreBoard(db);
expect(result).toEqual([]);
});
it("should handle multiple users and sort by highest stage", async () => {
const db = getTestDb();
await db.insert(User).values({
name: "User1",
password: "test",
});
await db.insert(User).values({
name: "User2",
password: "test",
});
await db.insert(Game).values({
user: "User1",
uuid: crypto.randomUUID(),
stage: 15,
gameState: Buffer.from("ANY"),
finished: 1,
started: Date.now(),
});
await db.insert(Game).values({
user: "User2",
uuid: crypto.randomUUID(),
stage: 25,
gameState: Buffer.from("ANY"),
finished: 1,
started: Date.now(),
});
const result = await getScoreBoard(db);
expect(result).toEqual([
{ stage: 25, user: "User2" },
{ stage: 15, user: "User1" }
]);
});
});

View File

@ -1,6 +1,13 @@
import { describe, it, expect } from "bun:test";
import { getTestDb } from "../database/getTestDb";
import { getUser, loginUser, registerUser } from "./userRepository";
import {
getUser,
getUserCount,
getUserSettings,
loginUser,
registerUser,
upsertUserSettings,
} from "./userRepository";
describe("UserRepository", () => {
it("should register a user", async () => {
@ -45,4 +52,72 @@ describe("UserRepository", () => {
password: undefined,
});
});
it("should throw error if password is incorrect", async () => {
const db = getTestDb();
await registerUser(db, "TestUser", "test");
expect(loginUser(db, "TestUser", "wrongpassword")).rejects.toThrow(
"Incorrect password",
);
});
it("should handle getUser for nonexistent user", async () => {
const db = getTestDb();
const user = await getUser(db, "NonexistentUser");
expect(user?.name).toBeUndefined();
});
it("should get user count", async () => {
const db = getTestDb();
await registerUser(db, "TestUser1", "test");
await registerUser(db, "TestUser2", "test");
const count = await getUserCount(db);
expect(count).toBe(2);
});
it("should get default user settings", async () => {
const db = getTestDb();
const settings = await getUserSettings(db, "TestUser");
expect(settings).toEqual({
placeQuestionMark: false,
longPressOnDesktop: false,
showRevealAnimation: true,
soundEnabled: true,
});
});
it("should upsert user settings - insert", async () => {
const db = getTestDb();
const newSettings = {
placeQuestionMark: true,
longPressOnDesktop: true,
showRevealAnimation: false,
soundEnabled: false,
};
await upsertUserSettings(db, "TestUser", newSettings);
const settings = await getUserSettings(db, "TestUser");
expect(settings).toEqual(newSettings);
});
it("should upsert user settings - update", async () => {
const db = getTestDb();
const initialSettings = {
placeQuestionMark: false,
longPressOnDesktop: false,
showRevealAnimation: true,
soundEnabled: true,
};
await upsertUserSettings(db, "TestUser", initialSettings);
const updatedSettings = {
placeQuestionMark: true,
longPressOnDesktop: true,
showRevealAnimation: false,
soundEnabled: false,
};
await upsertUserSettings(db, "TestUser", updatedSettings);
const settings = await getUserSettings(db, "TestUser");
expect(settings).toEqual(updatedSettings);
});
});

View File

@ -37,12 +37,20 @@ export const handleRequest = async (
!message ||
!(typeof message === "object") ||
!("type" in message) ||
!("payload" in message) ||
!("id" in message)
)
return;
const { type, payload, id } = message;
const { type, id } = message;
if (!(typeof type === "string")) return;
// Handle ping message
if (type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', id }));
return;
}
if (!("payload" in message)) return;
const { payload } = message;
const [controllerName, action] = type.split(".");
if (!(controllerName in controllers)) return;
try {

BIN
bun.lockb

Binary file not shown.

View File

@ -47,7 +47,6 @@
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.3",
"use-sound": "^4.0.3",
"wouter": "^3.3.5",
"zod": "^3.23.8"
},

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { getValue, ServerGame, serverToClientGame } from "./game";
import { getValue, isServerGame, isClientGame, ServerGame, ClientGame, serverToClientGame } from "./game";
describe("Game", () => {
it("should get value", () => {
@ -15,6 +15,46 @@ describe("Game", () => {
expect(getValue(mines, 1, 3)).toEqual(8);
});
it("should identify server game", () => {
const serverGame: ServerGame = {
theme: "default",
mines: [[false]],
minesCount: 0,
isRevealed: [[false]],
isFlagged: [[false]],
isQuestionMark: [[false]],
started: Date.now(),
finished: 0,
lastClick: [0, 0],
uuid: "test-uuid",
width: 1,
height: 1,
user: "TestUser",
stage: 1,
};
expect(isServerGame(serverGame)).toBe(true);
});
it("should identify client game", () => {
const clientGame: ClientGame = {
theme: "default",
minesCount: 0,
isRevealed: [[false]],
isFlagged: [[false]],
isQuestionMark: [[false]],
values: [[0]],
started: Date.now(),
lastClick: [0, 0],
uuid: "test-uuid",
width: 1,
height: 1,
user: "TestUser",
stage: 1,
};
expect(isClientGame(clientGame)).toBe(true);
expect(isServerGame(clientGame)).toBe(false);
});
it("should convert server to client game", () => {
const serverGame: ServerGame = {
theme: "default",

View File

@ -4,6 +4,7 @@ export const userSettings = z.object({
placeQuestionMark: z.boolean().default(false),
longPressOnDesktop: z.boolean().default(false),
showRevealAnimation: z.boolean().default(true),
soundEnabled: z.boolean().default(true),
});
export type UserSettings = z.infer<typeof userSettings>;

View File

@ -17,6 +17,8 @@ import { useMediaQuery } from "@uidotdev/usehooks";
import Header from "./components/Header";
import { Tag } from "./components/Tag";
import Feed from "./components/Feed/Feed";
import { useAtom } from "jotai";
import { loginTokenAtom } from "./atoms";
const drawerWidth = 256;
const drawerWidthWithPadding = drawerWidth;
@ -24,6 +26,7 @@ const drawerWidthWithPadding = drawerWidth;
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null);
const [loginToken] = useAtom(loginTokenAtom);
const x = isOpen ? 0 : -drawerWidthWithPadding;
const width = isOpen ? drawerWidthWithPadding : 0;
@ -79,22 +82,28 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
<Play />
Play
</NavLink>
{loginToken && (
<NavLink href="/history">
<History />
History
</NavLink>
)}
<NavLink href="/store">
<Store />
Store
</NavLink>
{loginToken && (
<NavLink href="/collection">
<Library />
Collection <Tag size="sm">NEW</Tag>
</NavLink>
)}
{loginToken && (
<NavLink href="/settings">
<Settings />
Settings
</NavLink>
)}
<Hr />
<Feed />
{/* <Hr /> */}
@ -125,7 +134,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
transition={{ type: "tween" }}
layout
/>
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-[calc(100vw-256px)]">
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-full md: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}

View File

@ -15,3 +15,6 @@ interface LootboxResult {
lootbox: string;
}
export const lootboxResultAtom = atom<LootboxResult | undefined>();
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
export const connectionStatusAtom = atom<ConnectionStatus>('connecting');

View File

@ -17,26 +17,31 @@ import { wsClient } from "../../wsClient";
const RegisterButton = () => {
const [isOpen, setIsOpen] = useState(false);
const [isLoginMode, setIsLoginMode] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const queryClient = useQueryClient();
const register = useWSMutation("user.register");
const login = useWSMutation("user.login");
const [, setToken] = useAtom(loginTokenAtom);
useEffect(() => {
setUsername("");
setPassword("");
setError("");
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="primary">Register</Button>
<Button variant="primary" className="self-start">
Register
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Register</DialogTitle>
<DialogTitle>{isLoginMode ? "Login" : "Register"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<label className="text-white/70 font-bold">Username</label>
@ -49,6 +54,18 @@ const RegisterButton = () => {
<PasswordInput value={password} onChange={setPassword} />
</div>
{error && <p className="text-red-500">{error}</p>}
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => {
setIsLoginMode(!isLoginMode);
setError("");
}}
className="text-sm text-white/60 hover:text-white/80"
>
{isLoginMode ? "Need an account? Register" : "Already have an account? Login"}
</Button>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}>
Cancel
@ -56,7 +73,8 @@ const RegisterButton = () => {
<Button
variant="primary"
onClick={() => {
register
const mutation = isLoginMode ? login : register;
mutation
.mutateAsync({ username, password })
.then(async (res) => {
setToken(res.token);
@ -64,13 +82,14 @@ const RegisterButton = () => {
token: res.token,
});
await queryClient.resetQueries();
setIsOpen(false);
})
.catch((e) => {
setError(e);
});
}}
>
Register
{isLoginMode ? "Login" : "Register"}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -29,7 +29,7 @@ import Coords from "./Coords";
import { cn } from "../lib/utils";
import { Button } from "./Button";
import { Maximize2, Minimize2, RotateCcw } from "lucide-react";
import useSound from "use-sound";
import { useAudio } from "../hooks/useAudio";
import explosion from "../sound/explosion.mp3";
import "@pixi/canvas-display";
import "@pixi/canvas-extract";
@ -80,7 +80,7 @@ const Board: React.FC<BoardProps> = (props) => {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const showLastPos = game.user !== user || isServerGame(game);
const [playSound] = useSound(explosion, {
const [playSound] = useAudio(explosion, {
volume: 0.5,
});

View File

@ -0,0 +1,40 @@
import { useAtom } from "jotai";
import { connectionStatusAtom, type ConnectionStatus as ConnectionStatusType } from "../atoms";
import { Wifi, WifiOff } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
const statusConfig: Record<ConnectionStatusType, { color: string; icon: React.ReactNode; label: string }> = {
connecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Connecting..." },
connected: { color: "text-green-400", icon: <Wifi className="w-3 h-3" />, label: "Connected" },
disconnected: { color: "text-red-400", icon: <WifiOff className="w-3 h-3" />, label: "Disconnected" },
reconnecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Reconnecting..." },
};
export const ConnectionStatus = () => {
const [status] = useAtom(connectionStatusAtom);
const config = statusConfig[status];
// Only show when not connected
const shouldShow = status !== 'connected';
return (
<AnimatePresence>
{shouldShow && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`flex items-center gap-1.5 text-xs ${config.color} bg-white/5 px-2 py-1 rounded-md backdrop-blur-sm`}
>
<motion.div
animate={status === 'connecting' || status === 'reconnecting' ? { rotate: 360 } : {}}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
{config.icon}
</motion.div>
<span className="font-medium">{config.label}</span>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -15,6 +15,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { loginTokenAtom } from "../atoms";
import Gems from "./Gems";
import { ConnectionStatus } from "./ConnectionStatus";
const Header = () => {
const [, setLocation] = useLocation();
@ -28,7 +29,8 @@ const Header = () => {
const { data: gems } = useWSQuery("user.getOwnGems", null);
return (
<div className="w-full flex gap-4">
<div className="w-full flex gap-4 items-center">
<ConnectionStatus />
<div className="grow" />
{username ? (

View File

@ -10,7 +10,7 @@ interface PastMatchProps {
const PastMatch = ({ game }: PastMatchProps) => {
return (
<div className="flex flex-col gap-4 items-center w-full @container">
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 @max-xl:grid-cols-3 p-4">
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
<div className="flex-col flex">
<div className="text-white/90 text-lg">Endless</div>
<div className="text-white/50 text-lg">
@ -24,10 +24,10 @@ const PastMatch = ({ game }: PastMatchProps) => {
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
</div>
</div>
<div className="text-white/80 text-lg @max-xl:hidden">
<div className="text-white/80 text-lg hidden lg:block">
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
</div>
<div className="flex justify-end">
<div className="flex justify-center sm:justify-end col-span-full sm:col-span-1">
{/* @ts-expect-error as is cheaply typed */}
<Button as={Link} href={`/play/${game.uuid}`} variant="outline">
Show Board

View File

@ -6,12 +6,14 @@ import { cn } from "../lib/utils";
function TooltipProvider({
delayDuration = 0,
disableHoverableContent = false,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props}
/>
);

44
src/hooks/useAudio.ts Normal file
View File

@ -0,0 +1,44 @@
import { useCallback, useRef } from 'react';
import { useWSQuery } from '../hooks';
interface AudioOptions {
volume?: number;
loop?: boolean;
}
export const useAudio = (src: string, options: AudioOptions = {}) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const { data: settings } = useWSQuery("user.getSettings", null);
const play = useCallback(() => {
// Check if sound is disabled in settings
if (settings && settings.soundEnabled === false) {
return;
}
if (!audioRef.current) {
audioRef.current = new Audio(src);
audioRef.current.volume = options.volume ?? 1;
audioRef.current.loop = options.loop ?? false;
}
// Reset to beginning and play
audioRef.current.currentTime = 0;
audioRef.current.play().catch((error) => {
console.warn('Audio play failed:', error);
});
}, [src, options.volume, options.loop, settings]);
const pause = useCallback(() => {
audioRef.current?.pause();
}, []);
const stop = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
}, []);
return [play, { pause, stop }] as const;
};

View File

@ -1,10 +1,11 @@
import { useWSMutation, useWSQuery } from "../../hooks";
import { useAtom } from "jotai";
import { gameIdAtom } from "../../atoms";
import { gameIdAtom, loginTokenAtom } from "../../atoms";
import { Button } from "../../components/Button";
import LeaderboardButton from "../../components/LeaderboardButton";
import { Fragment, startTransition, Suspense, useEffect } from "react";
import { Board } from "../../components/LazyBoard";
import RegisterButton from "../../components/Auth/RegisterButton";
interface EndlessProps {
gameId?: string;
@ -12,6 +13,7 @@ interface EndlessProps {
const Endless: React.FC<EndlessProps> = (props) => {
const [gameId, setGameId] = useAtom(gameIdAtom);
const [loginToken] = useAtom(loginTokenAtom);
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
const { data: settings } = useWSQuery("user.getSettings", null);
const startGame = useWSMutation("game.createGame");
@ -73,6 +75,7 @@ const Endless: React.FC<EndlessProps> = (props) => {
<div className="w-full grid md:grid-cols-[350px_1fr]">
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
{loginToken ? (
<Button
className="w-fit"
variant="primary"
@ -85,6 +88,9 @@ const Endless: React.FC<EndlessProps> = (props) => {
>
Start Game
</Button>
) : (
<RegisterButton />
)}
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
<p className="text-white/90">
Endless minesweeper is just like regular minesweeper but you

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useRef, useEffect, useState } from "react";
import { useWSQuery } from "../../hooks";
import dayjs from "dayjs";
import {
@ -9,7 +9,72 @@ import {
import PastMatch from "../../components/PastMatch";
import GemsIcon from "../../components/GemIcon";
const TouchTooltip = ({
children,
date,
games,
}: {
children: React.ReactNode;
date: string;
games: number;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const timeoutRef = useRef<Timer>();
useEffect(() => {
// Detect if device supports touch
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
}, []);
const handleTouch = () => {
if (isTouchDevice) {
setIsOpen(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsOpen(false), 2000);
}
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
// For touch devices, use controlled tooltip
if (isTouchDevice) {
return (
<Tooltip open={isOpen}>
<TooltipTrigger
asChild
onClick={handleTouch}
onTouchStart={handleTouch}
>
{children}
</TooltipTrigger>
<TooltipContent>
<p>{date}</p>
<p>{games} Games Played</p>
</TooltipContent>
</Tooltip>
);
}
// For non-touch devices, use default hover behavior
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>
<p>{date}</p>
<p>{games} Games Played</p>
</TooltipContent>
</Tooltip>
);
};
const Profile: React.FC = () => {
const [availableWeeks, setAvailableWeeks] = useState(16); // Default to 4 months
const containerRef = useRef<HTMLDivElement>(null);
const { data: username } = useWSQuery("user.getSelf", null);
const { data: heatmap } = useWSQuery(
"user.getHeatmap",
@ -30,15 +95,72 @@ const Profile: React.FC = () => {
!!username,
);
const now = useMemo(() => dayjs(), []);
const firstOfYear = useMemo(() => now.startOf("year"), [now]);
const weeks = now.diff(firstOfYear, "weeks") + 1;
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
// Calculate available space and adjust weeks accordingly
useEffect(() => {
const calculateAvailableWeeks = () => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640; // sm breakpoint
// Calculate dot and gap sizes based on breakpoint
const dotWidth = isMobile ? 16 : 24; // w-4 = 16px, w-6 = 24px
const gapSize = isMobile ? 4 : 8; // gap-1 = 4px, gap-2 = 8px
// Each week takes: dotWidth + gapSize
const weekWidth = dotWidth + gapSize;
// Calculate max weeks that fit, with some padding
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); // 32px for padding
// Limit between 4 weeks (1 month) and 26 weeks (6 months)
const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks);
};
// Use setTimeout to ensure DOM is fully rendered
const timer = setTimeout(calculateAvailableWeeks, 0);
window.addEventListener("resize", calculateAvailableWeeks);
return () => {
clearTimeout(timer);
window.removeEventListener("resize", calculateAvailableWeeks);
};
}, []);
// Additional effect to recalculate when heatmap data is available
useEffect(() => {
if (heatmap && containerRef.current) {
const timer = setTimeout(() => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640;
const dotWidth = isMobile ? 16 : 24;
const gapSize = isMobile ? 4 : 8;
const weekWidth = dotWidth + gapSize;
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth);
const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks);
}, 100);
return () => clearTimeout(timer);
}
}, [heatmap]);
return (
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6">
<div className="m-8 text-white flex self-center">
<div className="p-2 flex items-center text-2xl">{username}</div>
<div className="border-l-white border-l p-2 text-lg">
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
<div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
<div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">
{username}
</div>
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
<p>Total Games: {profile?.totalGames}</p>
<p>Highest Stage: {profile?.highestStage}</p>
<p>
@ -57,42 +179,44 @@ const Profile: React.FC = () => {
.map((game) => <PastMatch key={game.uuid} game={game} />)}
</div>
{heatmap && (
<div className="col-span-full">
<div className="col-span-full mb-8">
<h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
<div className="flex gap-2 ">
{Array.from({ length: weeks }).map((_, w) => (
<div key={w} className="w-6 flex gap-2 flex-col">
{Array.from({ length: 7 }).map((_, d) => {
const index = w * 7 + d;
if (index >= heatmap.length) return;
return (
<Tooltip key={d}>
<TooltipTrigger>
<div className="w-5 h-5 border border-white">
<div className="overflow-x-auto" ref={containerRef}>
<div className="flex gap-1 sm:gap-2 min-w-fit">
{Array.from({ length: availableWeeks }).map((_, w) => (
<div
className="w-5 h-5 bg-brand -m-px"
key={w}
className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"
>
{Array.from({ length: 7 }).map((_, d) => {
const index =
heatmap.length - availableWeeks * 7 + (w * 7 + d);
if (index < 0 || index >= heatmap.length) return;
const date = now
.clone()
.subtract(availableWeeks * 7 - 1 - (w * 7 + d), "days")
.format("DD/MM/YYYY");
return (
<TouchTooltip key={d} date={date} games={heatmap[index]}>
<button
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
type="button"
>
<div
className="w-3 h-3 sm:w-5 sm:h-5 bg-brand -m-px pointer-events-none"
style={{
opacity: heatmap[index] / maxHeat,
}}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{firstOfYear
.clone()
.add(index, "days")
.format("DD/MM/YYYY")}
</p>
<p>{heatmap[index]} Games Played</p>
</TooltipContent>
</Tooltip>
</button>
</TouchTooltip>
);
})}
</div>
))}
</div>
</div>
</div>
)}
</div>
);

View File

@ -71,6 +71,15 @@ const Settings = () => {
refetch();
}}
/>
<BoolSetting
label="Sound Effects"
description={<>Enable or disable sound effects in the game.</>}
value={settings?.soundEnabled ?? true}
onChange={async (value) => {
await updateSettings.mutateAsync({ soundEnabled: value });
refetch();
}}
/>
</div>
</div>
</div>

View File

@ -1,6 +1,8 @@
import type { Routes } from "../backend/router";
import type { Events } from "../shared/events";
import { queryClient } from "./queryClient";
import { connectionStatusAtom } from "./atoms";
import { getDefaultStore } from "jotai";
const connectionString = import.meta.env.DEV
? "ws://localhost:8072/ws"
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
};
const createWSClient = () => {
const store = getDefaultStore();
let ws = new WebSocket(connectionString);
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
let heartbeatInterval: number | null = null;
let heartbeatTimeoutId: number | null = null;
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds to wait for pong
const startHeartbeat = () => {
stopHeartbeat();
heartbeatInterval = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", id: crypto.randomUUID() }));
heartbeatTimeoutId = window.setTimeout(() => {
console.warn("Heartbeat timeout - closing connection");
ws.close();
}, HEARTBEAT_TIMEOUT);
}
}, HEARTBEAT_INTERVAL);
};
const stopHeartbeat = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (heartbeatTimeoutId) {
clearTimeout(heartbeatTimeoutId);
heartbeatTimeoutId = null;
}
};
const tryReconnect = () => {
store.set(connectionStatusAtom, "reconnecting");
stopHeartbeat();
if (reconnectAttempts < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts++;
@ -33,14 +67,18 @@ const createWSClient = () => {
}, 1000 * reconnectAttempts);
} else {
console.error("Max reconnect attempts reached");
store.set(connectionStatusAtom, "disconnected");
}
};
const connect = () => {
store.set(connectionStatusAtom, "connecting");
ws = new WebSocket(connectionString);
ws.onopen = async () => {
reconnectAttempts = 0;
store.set(connectionStatusAtom, "connected");
startHeartbeat();
const token = localStorage.getItem("loginToken");
if (token) {
try {
@ -69,25 +107,36 @@ const createWSClient = () => {
});
addMessageListener((event: MessageEvent) => {
const data = JSON.parse(event.data) as Events;
if (data.type === "updateGame") {
const data = JSON.parse(event.data);
// Handle pong response
if (data.type === "pong") {
if (heartbeatTimeoutId) {
clearTimeout(heartbeatTimeoutId);
heartbeatTimeoutId = null;
}
return;
}
const eventData = data as Events;
if (eventData.type === "updateGame") {
queryClient.setQueryData(
["game.getGameState", data.game],
data.gameState,
["game.getGameState", eventData.game],
eventData.gameState,
);
}
if (data.type === "loss") {
if (eventData.type === "loss") {
queryClient.invalidateQueries({
queryKey: ["scoreboard.getScoreBoard", 10],
});
}
if (data.type === "gemsRewarded") {
if (eventData.type === "gemsRewarded") {
queryClient.invalidateQueries({
queryKey: ["user.getOwnGems", null],
});
}
if (import.meta.env.DEV) {
console.log("Received message", data);
console.log("Received message", eventData);
}
});