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 deploy.sh
sqlite.db 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 { import {
getCurrentGame, getCurrentGame,
getGame, getGame,
getGames,
getGamesCount, getGamesCount,
getTotalGamesPlayed,
parseGameState,
upsertGame, upsertGame,
upsertGameState,
} from "./gameRepository"; } from "./gameRepository";
import { encode } from "@msgpack/msgpack";
describe("GameRepository", () => { describe("GameRepository", () => {
it("should get game by uuid", async () => { it("should get game by uuid", async () => {
@ -136,4 +141,131 @@ describe("GameRepository", () => {
started: started + 1, 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, game: ServerGame,
) => { ) => {
const { uuid, user, stage, finished, started } = game; const { uuid, user, stage, finished, started } = game;
upsertGame(db, { await upsertGame(db, {
uuid, uuid,
user, user,
stage, stage,

View File

@ -37,4 +37,55 @@ describe("ScoreRepository", () => {
const result = await getScoreBoard(db); const result = await getScoreBoard(db);
expect(result).toEqual([{ stage: 10, user: "TestUser" }]); 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 { describe, it, expect } from "bun:test";
import { getTestDb } from "../database/getTestDb"; import { getTestDb } from "../database/getTestDb";
import { getUser, loginUser, registerUser } from "./userRepository"; import {
getUser,
getUserCount,
getUserSettings,
loginUser,
registerUser,
upsertUserSettings,
} from "./userRepository";
describe("UserRepository", () => { describe("UserRepository", () => {
it("should register a user", async () => { it("should register a user", async () => {
@ -45,4 +52,72 @@ describe("UserRepository", () => {
password: undefined, 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 || !message ||
!(typeof message === "object") || !(typeof message === "object") ||
!("type" in message) || !("type" in message) ||
!("payload" in message) ||
!("id" in message) !("id" in message)
) )
return; return;
const { type, payload, id } = message; const { type, id } = message;
if (!(typeof type === "string")) return; 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("."); const [controllerName, action] = type.split(".");
if (!(controllerName in controllers)) return; if (!(controllerName in controllers)) return;
try { try {

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { getValue, ServerGame, serverToClientGame } from "./game"; import { getValue, isServerGame, isClientGame, ServerGame, ClientGame, serverToClientGame } from "./game";
describe("Game", () => { describe("Game", () => {
it("should get value", () => { it("should get value", () => {
@ -15,6 +15,46 @@ describe("Game", () => {
expect(getValue(mines, 1, 3)).toEqual(8); 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", () => { it("should convert server to client game", () => {
const serverGame: ServerGame = { const serverGame: ServerGame = {
theme: "default", theme: "default",

View File

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

View File

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

View File

@ -15,3 +15,6 @@ interface LootboxResult {
lootbox: string; lootbox: string;
} }
export const lootboxResultAtom = atom<LootboxResult | undefined>(); 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 RegisterButton = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoginMode, setIsLoginMode] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const register = useWSMutation("user.register"); const register = useWSMutation("user.register");
const login = useWSMutation("user.login");
const [, setToken] = useAtom(loginTokenAtom); const [, setToken] = useAtom(loginTokenAtom);
useEffect(() => { useEffect(() => {
setUsername(""); setUsername("");
setPassword(""); setPassword("");
setError("");
}, [isOpen]); }, [isOpen]);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="primary">Register</Button> <Button variant="primary" className="self-start">
Register
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Register</DialogTitle> <DialogTitle>{isLoginMode ? "Login" : "Register"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<label className="text-white/70 font-bold">Username</label> <label className="text-white/70 font-bold">Username</label>
@ -49,6 +54,18 @@ const RegisterButton = () => {
<PasswordInput value={password} onChange={setPassword} /> <PasswordInput value={password} onChange={setPassword} />
</div> </div>
{error && <p className="text-red-500">{error}</p>} {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> <DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}> <Button variant="ghost" onClick={() => setIsOpen(false)}>
Cancel Cancel
@ -56,7 +73,8 @@ const RegisterButton = () => {
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
register const mutation = isLoginMode ? login : register;
mutation
.mutateAsync({ username, password }) .mutateAsync({ username, password })
.then(async (res) => { .then(async (res) => {
setToken(res.token); setToken(res.token);
@ -64,13 +82,14 @@ const RegisterButton = () => {
token: res.token, token: res.token,
}); });
await queryClient.resetQueries(); await queryClient.resetQueries();
setIsOpen(false);
}) })
.catch((e) => { .catch((e) => {
setError(e); setError(e);
}); });
}} }}
> >
Register {isLoginMode ? "Login" : "Register"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -29,7 +29,7 @@ import Coords from "./Coords";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "./Button"; import { Button } from "./Button";
import { Maximize2, Minimize2, RotateCcw } from "lucide-react"; import { Maximize2, Minimize2, RotateCcw } from "lucide-react";
import useSound from "use-sound"; import { useAudio } from "../hooks/useAudio";
import explosion from "../sound/explosion.mp3"; import explosion from "../sound/explosion.mp3";
import "@pixi/canvas-display"; import "@pixi/canvas-display";
import "@pixi/canvas-extract"; import "@pixi/canvas-extract";
@ -80,7 +80,7 @@ const Board: React.FC<BoardProps> = (props) => {
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0); const [height, setHeight] = useState(0);
const showLastPos = game.user !== user || isServerGame(game); const showLastPos = game.user !== user || isServerGame(game);
const [playSound] = useSound(explosion, { const [playSound] = useAudio(explosion, {
volume: 0.5, 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 { useAtom } from "jotai";
import { loginTokenAtom } from "../atoms"; import { loginTokenAtom } from "../atoms";
import Gems from "./Gems"; import Gems from "./Gems";
import { ConnectionStatus } from "./ConnectionStatus";
const Header = () => { const Header = () => {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@ -28,7 +29,8 @@ const Header = () => {
const { data: gems } = useWSQuery("user.getOwnGems", null); const { data: gems } = useWSQuery("user.getOwnGems", null);
return ( return (
<div className="w-full flex gap-4"> <div className="w-full flex gap-4 items-center">
<ConnectionStatus />
<div className="grow" /> <div className="grow" />
{username ? ( {username ? (

View File

@ -10,7 +10,7 @@ interface PastMatchProps {
const PastMatch = ({ game }: PastMatchProps) => { const PastMatch = ({ game }: PastMatchProps) => {
return ( return (
<div className="flex flex-col gap-4 items-center w-full @container"> <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="flex-col flex">
<div className="text-white/90 text-lg">Endless</div> <div className="text-white/90 text-lg">Endless</div>
<div className="text-white/50 text-lg"> <div className="text-white/50 text-lg">
@ -24,10 +24,10 @@ const PastMatch = ({ game }: PastMatchProps) => {
{game.minesCount - game.isFlagged.flat().filter((f) => f).length} {game.minesCount - game.isFlagged.flat().filter((f) => f).length}
</div> </div>
</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>Duration: {formatTimeSpan(game.finished - game.started)}</div>
</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 */} {/* @ts-expect-error as is cheaply typed */}
<Button as={Link} href={`/play/${game.uuid}`} variant="outline"> <Button as={Link} href={`/play/${game.uuid}`} variant="outline">
Show Board Show Board

View File

@ -6,12 +6,14 @@ import { cn } from "../lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
disableHoverableContent = false,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return ( return (
<TooltipPrimitive.Provider <TooltipPrimitive.Provider
data-slot="tooltip-provider" data-slot="tooltip-provider"
delayDuration={delayDuration} delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props} {...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 { useWSMutation, useWSQuery } from "../../hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { gameIdAtom } from "../../atoms"; import { gameIdAtom, loginTokenAtom } from "../../atoms";
import { Button } from "../../components/Button"; import { Button } from "../../components/Button";
import LeaderboardButton from "../../components/LeaderboardButton"; import LeaderboardButton from "../../components/LeaderboardButton";
import { Fragment, startTransition, Suspense, useEffect } from "react"; import { Fragment, startTransition, Suspense, useEffect } from "react";
import { Board } from "../../components/LazyBoard"; import { Board } from "../../components/LazyBoard";
import RegisterButton from "../../components/Auth/RegisterButton";
interface EndlessProps { interface EndlessProps {
gameId?: string; gameId?: string;
@ -12,6 +13,7 @@ interface EndlessProps {
const Endless: React.FC<EndlessProps> = (props) => { const Endless: React.FC<EndlessProps> = (props) => {
const [gameId, setGameId] = useAtom(gameIdAtom); const [gameId, setGameId] = useAtom(gameIdAtom);
const [loginToken] = useAtom(loginTokenAtom);
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId); const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
const { data: settings } = useWSQuery("user.getSettings", null); const { data: settings } = useWSQuery("user.getSettings", null);
const startGame = useWSMutation("game.createGame"); const startGame = useWSMutation("game.createGame");
@ -73,18 +75,22 @@ const Endless: React.FC<EndlessProps> = (props) => {
<div className="w-full grid md:grid-cols-[350px_1fr]"> <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"> <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> <h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
<Button {loginToken ? (
className="w-fit" <Button
variant="primary" className="w-fit"
onClick={async () => { variant="primary"
const gameId = await startGame.mutateAsync(null); onClick={async () => {
startTransition(() => { const gameId = await startGame.mutateAsync(null);
setGameId(gameId.uuid); startTransition(() => {
}); setGameId(gameId.uuid);
}} });
> }}
Start Game >
</Button> Start Game
</Button>
) : (
<RegisterButton />
)}
<h2 className="text-white/80 text-lg mt-8">How to play</h2> <h2 className="text-white/80 text-lg mt-8">How to play</h2>
<p className="text-white/90"> <p className="text-white/90">
Endless minesweeper is just like regular minesweeper but you 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 { useWSQuery } from "../../hooks";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
@ -9,7 +9,72 @@ import {
import PastMatch from "../../components/PastMatch"; import PastMatch from "../../components/PastMatch";
import GemsIcon from "../../components/GemIcon"; 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 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: username } = useWSQuery("user.getSelf", null);
const { data: heatmap } = useWSQuery( const { data: heatmap } = useWSQuery(
"user.getHeatmap", "user.getHeatmap",
@ -30,15 +95,72 @@ const Profile: React.FC = () => {
!!username, !!username,
); );
const now = useMemo(() => dayjs(), []); 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; 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 ( return (
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6"> <div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
<div className="m-8 text-white flex self-center"> <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">{username}</div> <div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">
<div className="border-l-white border-l p-2 text-lg"> {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>Total Games: {profile?.totalGames}</p>
<p>Highest Stage: {profile?.highestStage}</p> <p>Highest Stage: {profile?.highestStage}</p>
<p> <p>
@ -57,40 +179,42 @@ const Profile: React.FC = () => {
.map((game) => <PastMatch key={game.uuid} game={game} />)} .map((game) => <PastMatch key={game.uuid} game={game} />)}
</div> </div>
{heatmap && ( {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> <h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
<div className="flex gap-2 "> <div className="overflow-x-auto" ref={containerRef}>
{Array.from({ length: weeks }).map((_, w) => ( <div className="flex gap-1 sm:gap-2 min-w-fit">
<div key={w} className="w-6 flex gap-2 flex-col"> {Array.from({ length: availableWeeks }).map((_, w) => (
{Array.from({ length: 7 }).map((_, d) => { <div
const index = w * 7 + d; key={w}
if (index >= heatmap.length) return; className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"
return ( >
<Tooltip key={d}> {Array.from({ length: 7 }).map((_, d) => {
<TooltipTrigger> const index =
<div className="w-5 h-5 border border-white"> 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 <div
className="w-5 h-5 bg-brand -m-px" className="w-3 h-3 sm:w-5 sm:h-5 bg-brand -m-px pointer-events-none"
style={{ style={{
opacity: heatmap[index] / maxHeat, opacity: heatmap[index] / maxHeat,
}} }}
/> />
</div> </button>
</TooltipTrigger> </TouchTooltip>
<TooltipContent> );
<p> })}
{firstOfYear </div>
.clone() ))}
.add(index, "days") </div>
.format("DD/MM/YYYY")}
</p>
<p>{heatmap[index]} Games Played</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</div> </div>
</div> </div>
)} )}

View File

@ -71,6 +71,15 @@ const Settings = () => {
refetch(); 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> </div>
</div> </div>

View File

@ -1,6 +1,8 @@
import type { Routes } from "../backend/router"; import type { Routes } from "../backend/router";
import type { Events } from "../shared/events"; import type { Events } from "../shared/events";
import { queryClient } from "./queryClient"; import { queryClient } from "./queryClient";
import { connectionStatusAtom } from "./atoms";
import { getDefaultStore } from "jotai";
const connectionString = import.meta.env.DEV const connectionString = import.meta.env.DEV
? "ws://localhost:8072/ws" ? "ws://localhost:8072/ws"
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
}; };
const createWSClient = () => { const createWSClient = () => {
const store = getDefaultStore();
let ws = new WebSocket(connectionString); let ws = new WebSocket(connectionString);
let reconnectAttempts = 0; let reconnectAttempts = 0;
const maxReconnectAttempts = 5; 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 = () => { const tryReconnect = () => {
store.set(connectionStatusAtom, "reconnecting");
stopHeartbeat();
if (reconnectAttempts < maxReconnectAttempts) { if (reconnectAttempts < maxReconnectAttempts) {
setTimeout(() => { setTimeout(() => {
reconnectAttempts++; reconnectAttempts++;
@ -33,14 +67,18 @@ const createWSClient = () => {
}, 1000 * reconnectAttempts); }, 1000 * reconnectAttempts);
} else { } else {
console.error("Max reconnect attempts reached"); console.error("Max reconnect attempts reached");
store.set(connectionStatusAtom, "disconnected");
} }
}; };
const connect = () => { const connect = () => {
store.set(connectionStatusAtom, "connecting");
ws = new WebSocket(connectionString); ws = new WebSocket(connectionString);
ws.onopen = async () => { ws.onopen = async () => {
reconnectAttempts = 0; reconnectAttempts = 0;
store.set(connectionStatusAtom, "connected");
startHeartbeat();
const token = localStorage.getItem("loginToken"); const token = localStorage.getItem("loginToken");
if (token) { if (token) {
try { try {
@ -69,25 +107,36 @@ const createWSClient = () => {
}); });
addMessageListener((event: MessageEvent) => { addMessageListener((event: MessageEvent) => {
const data = JSON.parse(event.data) as Events; const data = JSON.parse(event.data);
if (data.type === "updateGame") {
// 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( queryClient.setQueryData(
["game.getGameState", data.game], ["game.getGameState", eventData.game],
data.gameState, eventData.gameState,
); );
} }
if (data.type === "loss") { if (eventData.type === "loss") {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["scoreboard.getScoreBoard", 10], queryKey: ["scoreboard.getScoreBoard", 10],
}); });
} }
if (data.type === "gemsRewarded") { if (eventData.type === "gemsRewarded") {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["user.getOwnGems", null], queryKey: ["user.getOwnGems", null],
}); });
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("Received message", data); console.log("Received message", eventData);
} }
}); });