+
+
+ Logged in as {session.user.name}
+
+
+ Logout
+
+
+
+ ((modalToggleRef.current as HTMLInputElement).checked = false)}
+ >
+ View profile
+
+ ((modalToggleRef.current as HTMLInputElement).checked = false)}
+ >
+ Account settings
+
+
-
+ ) : (
+ <>
+
+ {
+ setActiveTab("guest");
+ }}
+ className={
+ "tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
+ (activeTab === "guest"
+ ? " tab-active text-base-content"
+ : " text-base-content border-opacity-10 hover:border-opacity-30")
+ }
+ >
+ Guest
+
+ setActiveTab("login")}
+ className={
+ "tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
+ (activeTab === "login"
+ ? " tab-active"
+ : " text-base-content border-opacity-10 hover:border-opacity-30")
+ }
+ >
+ Login
+
+ setActiveTab("register")}
+ className={
+ "tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
+ (activeTab === "register"
+ ? " tab-active"
+ : " text-base-content border-opacity-10 hover:border-opacity-30")
+ }
+ >
+ Register
+
+
+
+
+ >
+ )}
>
diff --git a/client/src/components/auth/Guest.tsx b/client/src/components/auth/Guest.tsx
new file mode 100644
index 0000000..f9853bc
--- /dev/null
+++ b/client/src/components/auth/Guest.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+export default function Guest({ currentName }: { currentName: string }) {
+ return (
+
+
+ Hello, {currentName}!
+
+
+ Name
+
+
+
+ );
+}
diff --git a/client/src/components/auth/Login.tsx b/client/src/components/auth/Login.tsx
new file mode 100644
index 0000000..ccc8c6a
--- /dev/null
+++ b/client/src/components/auth/Login.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+export default function Login() {
+ return (
+
+
+ Username or email
+
+
+
+ Password
+
+
+
+ );
+}
diff --git a/client/src/components/auth/Register.tsx b/client/src/components/auth/Register.tsx
new file mode 100644
index 0000000..3cc9208
--- /dev/null
+++ b/client/src/components/auth/Register.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+export default function Register() {
+ return (
+
+
+ Username
+
+
+
+ Email (optional)
+
+
+
+ Password
+
+
+
+ );
+}
diff --git a/client/src/components/game/GamePage.tsx b/client/src/components/game/GamePage.tsx
index 31b41e0..fe47761 100644
--- a/client/src/components/game/GamePage.tsx
+++ b/client/src/components/game/GamePage.tsx
@@ -10,6 +10,7 @@ import {
} from "@tabler/icons-react";
import type { FormEvent, KeyboardEvent } from "react";
+import Link from "next/link";
import { SessionContext } from "@/context/session";
import { useContext, useEffect, useReducer, useRef, useState } from "react";
@@ -65,6 +66,36 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const chatListRef = useRef
(null);
const moveListRef = useRef(null);
+ const [abandonSeconds, setAbandonSeconds] = useState(60);
+ useEffect(() => {
+ if (
+ lobby.side === "s" ||
+ lobby.endReason ||
+ lobby.winner ||
+ !lobby.pgn ||
+ !lobby.white ||
+ !lobby.black ||
+ (lobby.white.id !== session?.user?.id && lobby.black.id !== session?.user?.id)
+ )
+ return;
+
+ let interval: number;
+ if (!lobby.white?.connected || !lobby.black?.connected) {
+ setAbandonSeconds(60);
+ interval = Number(
+ setInterval(() => {
+ if (abandonSeconds === 0 || (lobby.white?.connected && lobby.black?.connected)) {
+ clearInterval(interval);
+ return;
+ }
+ setAbandonSeconds((s) => s - 1);
+ }, 1000)
+ );
+ }
+ return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lobby.black, lobby.white, lobby.black?.disconnectedOn, lobby.white?.disconnectedOn]);
+
useEffect(() => {
if (!session?.user || !session.user?.id) return;
socket.connect();
@@ -118,7 +149,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function updateTurnTitle() {
if (lobby.side === "s" || !lobby.white?.id || !lobby.black?.id) return;
- if (lobby.side === lobby.actualGame.turn()) {
+ if (lobby.side === lobby.actualGame.turn() && !lobby.actualGame.isGameOver()) {
document.title = "(your turn) chessu";
} else {
document.title = "chessu";
@@ -216,11 +247,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
}
function isDraggablePiece({ piece }: { piece: string }) {
- return piece.startsWith(lobby.side);
+ return piece.startsWith(lobby.side) && !lobby.endReason && !lobby.winner;
}
function onDrop(sourceSquare: Square, targetSquare: Square) {
- if (lobby.side === "s" || navFen) return false;
+ if (lobby.side === "s" || navFen || lobby.endReason || lobby.winner) return false;
// premove
if (lobby.side !== lobby.actualGame.turn()) return true;
@@ -267,7 +298,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
}
function onPieceDragBegin(_piece: string, sourceSquare: Square) {
- if (lobby.side !== lobby.actualGame.turn() || navFen) return;
+ if (lobby.side !== lobby.actualGame.turn() || navFen || lobby.endReason || lobby.winner) return;
getMoveOptions(sourceSquare);
}
@@ -278,7 +309,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function onSquareClick(square: Square) {
updateCustomSquares({ rightClicked: {} });
- if (lobby.side !== lobby.actualGame.turn() || navFen) return;
+ if (lobby.side !== lobby.actualGame.turn() || navFen || lobby.endReason || lobby.winner) return;
function resetFirstMove(square: Square) {
setMoveFrom(square);
@@ -329,9 +360,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function getPlayerHtml(side: "top" | "bottom") {
const blackHtml = (
-
+
{lobby.black?.name || "(no one)"}
-
+
black
{lobby.black?.connected === false && (
@@ -342,9 +381,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
);
const whiteHtml = (
-
+
{lobby.white?.name || "(no one)"}
-
+
white
{lobby.white?.connected === false && (
@@ -362,7 +409,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
}
function copyInvite() {
- const text = `https://ches.su/${initialLobby.code}`;
+ const text = `https://ches.su/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
@@ -461,13 +508,27 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
};
}
+ function claimAbandoned(type: "win" | "draw") {
+ if (
+ lobby.side === "s" ||
+ lobby.endReason ||
+ lobby.winner ||
+ !lobby.pgn ||
+ abandonSeconds > 0 ||
+ (lobby.black?.connected && lobby.white?.connected)
+ ) {
+ return;
+ }
+ socket.emit("claimAbandoned", type);
+ }
+
return (
{/* overlay */}
{(!lobby.white?.id || !lobby.black?.id) && (
-
-
+
+
Waiting for opponent.
{session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && (
- Invite friends:
+ {lobby.endReason ? "Archived link:" : "Invite friends:"}
- ches.su/{initialLobby.code}
+ ches.su/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
copied to clipboard
@@ -577,7 +638,62 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
-
+
+ {(lobby.endReason ||
+ (lobby.pgn &&
+ lobby.white &&
+ session?.user?.id === lobby.white?.id &&
+ lobby.black &&
+ !lobby.black?.connected) ||
+ (lobby.pgn &&
+ lobby.black &&
+ session?.user?.id === lobby.black?.id &&
+ lobby.white &&
+ !lobby.white?.connected)) && (
+
+ {lobby.endReason ? (
+
+ {lobby.endReason === "abandoned"
+ ? lobby.winner === "draw"
+ ? `The game ended in a draw due to abandonment.`
+ : `The game was won by ${lobby.winner} due to abandonment.`
+ : lobby.winner === "draw"
+ ? "The game ended in a draw."
+ : `The game was won by checkmate (${lobby.winner}).`}{" "}
+
+ You can review the archived game at{" "}
+
+ ches.su/archive/{lobby.id}
+
+ .
+
+ ) : abandonSeconds > 0 ? (
+ `Your opponent has disconnected. You can claim the win or draw in ${abandonSeconds} second${
+ abandonSeconds > 1 ? "s" : ""
+ }.`
+ ) : (
+
+
Your opponent has disconnected.
+
+ claimAbandoned("win")}
+ className="btn btn-sm btn-primary"
+ >
+ Claim win
+
+ claimAbandoned("draw")} className="btn btn-sm btn-ghost">
+ Draw
+
+
+
+ )}
+
+ )}
{m.author.id && (
- {m.author.name} :{" "}
+
+ {m.author.name}
+
+ :{" "}
)}
{m.message}
diff --git a/client/src/components/game/socketEvents.ts b/client/src/components/game/socketEvents.ts
index a8fb926..6f8544d 100644
--- a/client/src/components/game/socketEvents.ts
+++ b/client/src/components/game/socketEvents.ts
@@ -58,17 +58,25 @@ export function initSocket(
({
reason,
winnerName,
- winnerSide
+ winnerSide,
+ id
}: {
- reason: string;
+ reason: Game["endReason"];
winnerName?: string;
- winnerSide?: string;
+ winnerSide?: "white" | "black" | "draw";
+ id: number;
}) => {
const m = {
author: { name: "server" }
} as Message;
- if (reason === "checkmate") {
+ if (reason === "abandoned") {
+ if (!winnerSide) {
+ m.message = `${winnerName} has claimed a draw due to abandonment.`;
+ } else {
+ m.message = `${winnerName} (${winnerSide}) has claimed the win due to abandonment.`;
+ }
+ } else if (reason === "checkmate") {
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
} else {
let message = "The game has ended in a draw";
@@ -81,6 +89,10 @@ export function initSocket(
}
m.message = message.concat(".");
}
+ actions.updateLobby({
+ type: "updateLobby",
+ payload: { endReason: reason, winner: winnerSide || "draw", id }
+ });
actions.addMessage(m);
}
);
diff --git a/client/src/components/home/JoinGame.tsx b/client/src/components/home/JoinGame.tsx
index 77090ad..ce9b6eb 100644
--- a/client/src/components/home/JoinGame.tsx
+++ b/client/src/components/home/JoinGame.tsx
@@ -3,7 +3,7 @@
import type { FormEvent } from "react";
import { useState, useContext } from "react";
import { SessionContext } from "@/context/session";
-import { getGame } from "@/lib/game";
+import { fetchActiveGame } from "@/lib/game";
import { useRouter } from "next/navigation";
export default function JoinGame() {
@@ -31,7 +31,7 @@ export default function JoinGame() {
code = new URL(code).pathname.split("/")[1];
}
- const game = await getGame(code);
+ const game = await fetchActiveGame(code);
if (game && game.code) {
router.push(`/${game.code}`);
@@ -58,7 +58,7 @@ export default function JoinGame() {
/>
@@ -24,8 +24,18 @@ export default async function PublicGames() {
{games && games.length > 0 ? (
games.map((game) => (
- {game.host?.name}
-
+
+ {game.host?.name}
+
+
{(game.host?.id === game.white?.id ? game.black?.name : game.white?.name) || ""}
diff --git a/client/src/components/home/PublicGames/RefreshButton.tsx b/client/src/components/home/PublicGames/RefreshButton.tsx
index 3a653fe..7a3722e 100644
--- a/client/src/components/home/PublicGames/RefreshButton.tsx
+++ b/client/src/components/home/PublicGames/RefreshButton.tsx
@@ -17,7 +17,7 @@ export default function RefreshButton() {
return (
diff --git a/client/src/components/user/CopyLink.tsx b/client/src/components/user/CopyLink.tsx
new file mode 100644
index 0000000..e47521d
--- /dev/null
+++ b/client/src/components/user/CopyLink.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useState } from "react";
+import { IconCopy } from "@tabler/icons-react";
+
+export default function CopyLink({ name }: { name: string }) {
+ const [copiedLink, setCopiedLink] = useState(false);
+
+ function copyLink() {
+ const text = `https://ches.su/user/${name}`;
+
+ if ("clipboard" in navigator) {
+ navigator.clipboard.writeText(text);
+ } else {
+ document.execCommand("copy", true, text);
+ }
+ setCopiedLink(true);
+ setTimeout(() => {
+ setCopiedLink(false);
+ }, 5000);
+ }
+ return (
+
+
+
+ ches.su/user/{name}
+
+
+ copied to clipboard
+
+
+ );
+}
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts
index 0c99c9c..1df17b0 100644
--- a/client/src/lib/auth.ts
+++ b/client/src/lib/auth.ts
@@ -11,9 +11,7 @@ export const fetchSession = async () => {
const user: User = await res.json();
return user;
}
- } catch (err) {
- console.error(err);
- }
+ } catch (err) {}
};
export const setGuestSession = async (name: string) => {
@@ -34,3 +32,84 @@ export const setGuestSession = async (name: string) => {
console.error(err);
}
};
+
+export const register = async (name: string, password: string, email?: string) => {
+ try {
+ const res = await fetch(`${API_URL}/v1/auth/register`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ name, password, email })
+ });
+ if (res.status === 201) {
+ const user: User = await res.json();
+ return user;
+ } else if (res.status === 409) {
+ const { message } = await res.json();
+ return message as string;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const login = async (name: string, password: string) => {
+ try {
+ const res = await fetch(`${API_URL}/v1/auth/login`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ name, password })
+ });
+ if (res.status === 200) {
+ const user: User = await res.json();
+ return user;
+ } else if (res.status === 404 || res.status === 401) {
+ const { message } = await res.json();
+ return message as string;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const logout = async () => {
+ try {
+ const res = await fetch(`${API_URL}/v1/auth/logout`, {
+ method: "POST",
+ credentials: "include"
+ });
+ if (res.status === 204) {
+ return true;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const updateUser = async (name?: string, email?: string, password?: string) => {
+ try {
+ if (!name && !email && !password) return;
+ const res = await fetch(`${API_URL}/v1/auth/`, {
+ method: "PATCH",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ name, email, password })
+ });
+ if (res.status === 200) {
+ const user: User = await res.json();
+ return user;
+ } else if (res.status === 409) {
+ const { message } = await res.json();
+ return message as string;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
diff --git a/client/src/lib/game.ts b/client/src/lib/game.ts
index 93fd532..3c56126 100644
--- a/client/src/lib/game.ts
+++ b/client/src/lib/game.ts
@@ -22,7 +22,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
}
};
-export const getGame = async (code: string) => {
+export const fetchActiveGame = async (code: string) => {
try {
const res = await fetch(`${API_URL}/v1/games/${code}`, { cache: "no-store" });
@@ -35,7 +35,7 @@ export const getGame = async (code: string) => {
}
};
-export const getPublicGames = async () => {
+export const fetchPublicGames = async () => {
try {
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
@@ -47,3 +47,30 @@ export const getPublicGames = async () => {
console.error(err);
}
};
+
+export const fetchArchivedGame = async ({ id, userid }: { id?: number; userid?: number }) => {
+ let url = `${API_URL}/v1/games?`;
+ if (id) {
+ url += `id=${id}`;
+ } else {
+ url += `userid=${userid}`;
+ }
+ try {
+ // TODO: handle caching more efficiently
+ const res = await fetch(url, {
+ next: { revalidate: 20 }
+ });
+
+ if (res && res.status === 200) {
+ if (id) {
+ const game: Game = await res.json();
+ if (game.id) return game;
+ } else {
+ const games: Game[] = await res.json();
+ if (games.length && games[0].id) return games;
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
diff --git a/client/src/lib/user.ts b/client/src/lib/user.ts
new file mode 100644
index 0000000..02f0c4e
--- /dev/null
+++ b/client/src/lib/user.ts
@@ -0,0 +1,18 @@
+import { API_URL } from "@/config";
+import type { User, Game } from "@chessu/types";
+
+export const fetchProfileData = async (name: string) => {
+ try {
+ // TODO: handle caching more efficiently?
+ const res = await fetch(`${API_URL}/v1/users/${name}`, {
+ next: { revalidate: 10 }
+ });
+
+ if (res && res.status === 200) {
+ const data: User & { recentGames: Game[] } = await res.json();
+ return data;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
diff --git a/client/src/pages/404.tsx b/client/src/pages/404.tsx
deleted file mode 100644
index 4250fff..0000000
--- a/client/src/pages/404.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useRouter } from "next/router";
-import { useEffect } from "react";
-
-// Temporary. Move to app/ directory when it's supported
-
-export default function NotFound() {
- const router = useRouter();
-
- useEffect(() => {
- router.replace("/");
- });
-
- return null;
-}
diff --git a/package.json b/package.json
index 974dd84..b6596ca 100644
--- a/package.json
+++ b/package.json
@@ -15,12 +15,12 @@
"format": "prettier --write ."
},
"devDependencies": {
- "concurrently": "^7.6.0",
- "eslint": "^8.36.0",
- "eslint-config-next": "13.2.4",
- "eslint-config-prettier": "^8.7.0",
- "prettier": "^2.8.5",
- "prettier-plugin-tailwindcss": "^0.2.5"
+ "concurrently": "^8.0.1",
+ "eslint": "^8.37.0",
+ "eslint-config-next": "13.3.0",
+ "eslint-config-prettier": "^8.8.0",
+ "prettier": "^2.8.7",
+ "prettier-plugin-tailwindcss": "^0.2.7"
},
"engines": {
"node": ">=18"
diff --git a/server/package.json b/server/package.json
index 45324d7..79c7ad0 100644
--- a/server/package.json
+++ b/server/package.json
@@ -9,13 +9,14 @@
"dev": "node --loader ts-node/esm --watch src/server.ts"
},
"dependencies": {
+ "argon2": "^0.30.3",
"chess.js": "^1.0.0-beta.4",
"connect-pg-simple": "^8.0.0",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
- "nanoid": "^4.0.1",
+ "nanoid": "^4.0.2",
"pg": "^8.10.0",
"socket.io": "^4.6.1",
"xss": "^1.0.14"
@@ -25,10 +26,10 @@
"@types/connect-pg-simple": "^7.0.0",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
- "@types/node": "^18.15.3",
+ "@types/node": "^18.15.11",
"@types/pg": "^8.6.6",
"ts-node": "^10.9.1",
- "typescript": "^5.0.2"
+ "typescript": "^5.0.3"
},
"engines": {
"node": ">=18"
diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts
index 5bf5b26..a6bdbaf 100644
--- a/server/src/controllers/auth.controller.ts
+++ b/server/src/controllers/auth.controller.ts
@@ -1,9 +1,11 @@
import type { Request, Response } from "express";
import type { User } from "@chessu/types";
import xss from "xss";
+import { hash, verify } from "argon2";
import { activeGames } from "../db/models/game.model.js";
import { io } from "../server.js";
+import UserModel from "../db/models/user.model.js";
export const getCurrentSession = async (req: Request, res: Response) => {
try {
@@ -20,6 +22,10 @@ export const getCurrentSession = async (req: Request, res: Response) => {
export const guestSession = async (req: Request, res: Response) => {
try {
+ if (req.session.user?.id && typeof req.session.user.id === "number") {
+ res.status(403).end();
+ return;
+ }
const name = xss(req.body.name);
const pattern = /^[A-Za-z0-9]+$/;
@@ -82,3 +88,228 @@ export const logoutSession = async (req: Request, res: Response) => {
res.status(500).end();
}
};
+
+export const registerUser = async (req: Request, res: Response) => {
+ try {
+ if (req.session.user?.id && typeof req.session.user.id === "number") {
+ res.status(403).end();
+ return;
+ }
+
+ const name = xss(req.body.name);
+ const email = xss(req.body.email);
+ const password = await hash(req.body.password);
+
+ const pattern = /^[A-Za-z0-9]+$/;
+
+ if (!pattern.test(name)) {
+ res.status(400).end();
+ return;
+ }
+
+ const duplicateUsers = await UserModel.findByNameEmail({ name, email });
+ if (duplicateUsers && duplicateUsers.length) {
+ const dupl = duplicateUsers[0].name === name ? "Username" : "Email";
+ res.status(409).json({ message: `${dupl} is already in use.` });
+ return;
+ }
+
+ const newUser = await UserModel.create({ name, email }, password);
+ if (!newUser) {
+ throw new Error("Failed to create user");
+ }
+
+ const publicUser = {
+ id: newUser.id,
+ name: newUser.name
+ };
+ if (req.session.user?.id && typeof req.session.user.id === "string") {
+ const game = activeGames.find(
+ (g) =>
+ g.white?.id === req.session.user.id ||
+ g.black?.id === req.session.user.id ||
+ g.observers?.find((o) => o.id === req.session.user.id)
+ );
+ if (game) {
+ if (game.host?.id === req.session.user.id) {
+ game.host = publicUser;
+ }
+ if (game.white && game.white?.id === req.session.user.id) {
+ game.white = publicUser;
+ } else if (game.black && game.black?.id === req.session.user.id) {
+ game.black = publicUser;
+ } else {
+ const observer = game.observers?.find((o) => o.id === req.session.user.id);
+ if (observer) {
+ observer.id = publicUser.id;
+ observer.name = publicUser.name;
+ }
+ }
+ io.to(game.code as string).emit("receivedLatestGame", game);
+ }
+ }
+
+ req.session.user = newUser;
+ req.session.save(() => {
+ res.status(201).json(req.session.user);
+ });
+ } catch (err: unknown) {
+ console.log(err);
+ res.status(500).end();
+ }
+};
+
+export const loginUser = async (req: Request, res: Response) => {
+ try {
+ if (req.session.user?.id && typeof req.session.user.id === "number") {
+ res.status(403).end();
+ return;
+ }
+
+ const nameOrEmail = xss(req.body.name);
+ const password = req.body.password;
+
+ const users = await UserModel.findByNameEmail(
+ { name: nameOrEmail, email: nameOrEmail },
+ true
+ );
+ if (!users || !users.length) {
+ res.status(404).json({ message: "Invalid username/email." });
+ return;
+ }
+
+ const validPassword = await verify(users[0].password as string, password);
+ if (!validPassword) {
+ res.status(401).json({ message: "Invalid password." });
+ return;
+ }
+
+ const publicUser = {
+ id: users[0].id,
+ name: users[0].name
+ };
+
+ if (req.session.user?.id && typeof req.session.user.id === "string") {
+ const game = activeGames.find(
+ (g) =>
+ g.white?.id === req.session.user.id ||
+ g.black?.id === req.session.user.id ||
+ g.observers?.find((o) => o.id === req.session.user.id)
+ );
+ if (game) {
+ if (game.host?.id === req.session.user.id) {
+ game.host = publicUser;
+ }
+ if (game.white && game.white?.id === req.session.user.id) {
+ game.white = publicUser;
+ } else if (game.black && game.black?.id === req.session.user.id) {
+ game.black = publicUser;
+ } else {
+ const observer = game.observers?.find((o) => o.id === req.session.user.id);
+ if (observer) {
+ observer.id = publicUser.id;
+ observer.name = publicUser.name;
+ }
+ }
+ io.to(game.code as string).emit("receivedLatestGame", game);
+ }
+ }
+
+ req.session.user = {
+ id: users[0].id,
+ name: users[0].name,
+ email: users[0].email,
+ wins: users[0].wins,
+ losses: users[0].losses,
+ draws: users[0].draws
+ };
+ req.session.save(() => {
+ res.status(200).json(req.session.user);
+ });
+ } catch (err: unknown) {
+ console.log(err);
+ res.status(500).end();
+ }
+};
+
+export const updateUser = async (req: Request, res: Response) => {
+ try {
+ if (!req.session.user?.id || typeof req.session.user.id === "string") {
+ res.status(403).end();
+ return;
+ }
+
+ if (!req.body.name && !req.body.email && !req.body.password) {
+ res.status(400).end();
+ return;
+ }
+
+ const name = xss(req.body.name || req.session.user.name);
+ const pattern = /^[A-Za-z0-9]+$/;
+ if (!pattern.test(name)) {
+ res.status(400).end();
+ return;
+ }
+
+ const email = xss(req.body.email || req.session.user.email);
+
+ const duplicateUsers = await UserModel.findByNameEmail({ name, email });
+ if (
+ duplicateUsers &&
+ duplicateUsers.length &&
+ duplicateUsers[0].id !== req.session.user.id
+ ) {
+ const dupl = duplicateUsers[0].name === name ? "Username" : "Email";
+ res.status(409).json({ message: `${dupl} is already in use.` });
+ return;
+ }
+
+ let password: string | undefined = undefined;
+ if (req.body.password) {
+ password = await hash(req.body.password);
+ }
+
+ const updatedUser = await UserModel.update(req.session.user.id, { name, email, password });
+
+ if (!updatedUser) {
+ throw new Error("Failed to update user");
+ }
+
+ const publicUser = {
+ id: updatedUser.id,
+ name: updatedUser.name
+ };
+
+ const game = activeGames.find(
+ (g) =>
+ g.white?.id === req.session.user.id ||
+ g.black?.id === req.session.user.id ||
+ g.observers?.find((o) => o.id === req.session.user.id)
+ );
+ if (game) {
+ if (game.host?.id === req.session.user.id) {
+ game.host = publicUser;
+ }
+ if (game.white && game.white?.id === req.session.user.id) {
+ game.white = publicUser;
+ } else if (game.black && game.black?.id === req.session.user.id) {
+ game.black = publicUser;
+ } else {
+ const observer = game.observers?.find((o) => o.id === req.session.user.id);
+ if (observer) {
+ observer.id = publicUser.id;
+ observer.name = publicUser.name;
+ }
+ }
+ io.to(game.code as string).emit("receivedLatestGame", game);
+ }
+
+ req.session.user = updatedUser;
+ req.session.save(() => {
+ res.status(200).json(req.session.user);
+ });
+ } catch (err: unknown) {
+ console.log(err);
+ res.status(500).end();
+ }
+};
diff --git a/server/src/controllers/games.controller.ts b/server/src/controllers/games.controller.ts
index ed41a83..c3249a5 100644
--- a/server/src/controllers/games.controller.ts
+++ b/server/src/controllers/games.controller.ts
@@ -1,11 +1,43 @@
import type { Request, Response } from "express";
-import { activeGames } from "../db/models/game.model.js";
+import GameModel, { activeGames } from "../db/models/game.model.js";
import type { Game, User } from "@chessu/types";
import { nanoid } from "nanoid";
-export const getActivePublicGames = async (req: Request, res: Response) => {
+export const getGames = async (req: Request, res: Response) => {
try {
- res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner));
+ if (!req.query.id && !req.query.userid) {
+ // get all active games
+ res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner));
+ return;
+ }
+
+ let id, userid;
+ if (req.query.id) {
+ id = parseInt(req.query.id as string);
+ }
+ if (req.query.userid) {
+ userid = parseInt(req.query.userid as string);
+ }
+
+ if (id && !isNaN(id)) {
+ // get finished game by id
+ const game = await GameModel.findById(id);
+ if (!game) {
+ res.status(404).end();
+ } else {
+ res.status(200).json(game);
+ }
+ } else if (userid && !isNaN(userid)) {
+ // get finished games by user id
+ const games = await GameModel.findByUserId(userid);
+ if (!games) {
+ res.status(404).end();
+ } else {
+ res.status(200).json(games);
+ }
+ } else {
+ res.status(400).end();
+ }
} catch (err: unknown) {
console.log(err);
res.status(500).end();
diff --git a/server/src/controllers/users.controller.ts b/server/src/controllers/users.controller.ts
new file mode 100644
index 0000000..b400be8
--- /dev/null
+++ b/server/src/controllers/users.controller.ts
@@ -0,0 +1,33 @@
+import type { Request, Response } from "express";
+import xss from "xss";
+
+import GameModel from "../db/models/game.model.js";
+import UserModel from "../db/models/user.model.js";
+
+export const getUserProfile = async (req: Request, res: Response) => {
+ try {
+ const name = xss(req.params.name);
+
+ const users = await UserModel.findByNameEmail({ name, email: name });
+
+ if (!users || !users.length) {
+ res.status(404).end();
+ return;
+ }
+
+ const recentGames = await GameModel.findByUserId(users[0].id as number);
+
+ const publicUser = {
+ id: users[0].id,
+ name: users[0].name,
+ wins: users[0].wins,
+ losses: users[0].losses,
+ draws: users[0].draws
+ };
+
+ res.status(200).json({ ...publicUser, recentGames });
+ } catch (err: unknown) {
+ console.log(err);
+ res.status(500).end();
+ }
+};
diff --git a/server/src/db/index.ts b/server/src/db/index.ts
index 40f6898..8ef0672 100644
--- a/server/src/db/index.ts
+++ b/server/src/db/index.ts
@@ -1,3 +1,28 @@
import pg from "pg";
export const db = new pg.Pool();
+
+export const INIT_TABLES = /* sql */ `
+ CREATE TABLE IF NOT EXISTS "user" (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(128) UNIQUE NOT NULL,
+ email VARCHAR(128),
+ password TEXT,
+ wins INTEGER DEFAULT 0,
+ losses INTEGER DEFAULT 0,
+ draws INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS "game" (
+ id SERIAL PRIMARY KEY,
+ winner VARCHAR(5),
+ end_reason VARCHAR(16),
+ pgn TEXT,
+ white_id INT REFERENCES "user",
+ white_name VARCHAR(32),
+ black_id INT REFERENCES "user",
+ black_name VARCHAR(32),
+ started_at TIMESTAMP NOT NULL,
+ ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+`;
diff --git a/server/src/db/init.sql b/server/src/db/init.sql
deleted file mode 100644
index a4c2c80..0000000
--- a/server/src/db/init.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- users
-CREATE TABLE "user" (
- id SERIAL PRIMARY KEY,
- name VARCHAR(128) UNIQUE NOT NULL,
- email VARCHAR(128),
- password TEXT
-);
-
--- games
-CREATE TABLE "game" (
- id SERIAL PRIMARY KEY,
- pgn TEXT,
- white_id INT REFERENCES "user",
- black_id INT REFERENCES "user",
-);
\ No newline at end of file
diff --git a/server/src/db/models/game.model.ts b/server/src/db/models/game.model.ts
index 1aabe77..5f99305 100644
--- a/server/src/db/models/game.model.ts
+++ b/server/src/db/models/game.model.ts
@@ -1,22 +1,72 @@
import { db } from "../index.js";
-import type { Game } from "@chessu/types";
+import type { Game, User } from "@chessu/types";
export const activeGames: Array = [];
-// todo: join user and game relationship
-
-const create = async (game: Game) => {
+export const save = async (game: Game) => {
try {
+ const white: User = {};
+ const black: User = {};
+ if (typeof game.white?.id === "string") {
+ white.name = game.white?.name;
+ } else {
+ white.id = game.white?.id;
+ }
+ if (typeof game.black?.id === "string") {
+ black.name = game.black?.name;
+ } else {
+ black.id = game.black?.id;
+ }
const res = await db.query(
- `INSERT INTO "game"(pgn, white_id, black_id, winner) VALUES($1, $2, $3, $4) RETURNING *`,
- [game.pgn || null, game.white?.id || null, game.black?.id || null, game.winner || null]
+ `INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
+ [
+ game.winner || null,
+ game.endReason || null,
+ game.pgn,
+ white.id || null,
+ white.name || null,
+ black.id || null,
+ black.name || null,
+ new Date(game.startedAt as number)
+ ]
);
+ if (black.id || white.id) {
+ // draws
+ if (game.winner === "draw") {
+ if (white.id) {
+ await db.query(`UPDATE "user" SET draws = draws + 1 WHERE id = $1`, [white.id]);
+ }
+ if (black.id) {
+ await db.query(`UPDATE "user" SET draws = draws + 1 WHERE id = $1`, [black.id]);
+ }
+ } else {
+ const winner = game.winner === "white" ? white : black;
+ const loser = game.winner === "white" ? black : white;
+ if (winner.id) {
+ await db.query(`UPDATE "user" SET wins = wins + 1 WHERE id = $1`, [winner.id]);
+ }
+ if (loser.id) {
+ await db.query(`UPDATE "user" SET losses = losses + 1 WHERE id = $1`, [
+ loser.id
+ ]);
+ }
+ }
+ }
return {
id: res.rows[0].id,
+ winner: res.rows[0].winner,
+ endReason: res.rows[0].reason,
pgn: res.rows[0].pgn,
- white: { id: res.rows[0].white_id },
- black: { id: res.rows[0].black_id },
- winner: res.rows[0].winner
+ white: {
+ id: res.rows[0].white_id || undefined,
+ name: res.rows[0].white_name || undefined
+ },
+ black: {
+ id: res.rows[0].black_id || undefined,
+ name: res.rows[0].black_name || undefined
+ },
+ startedAt: res.rows[0].started_at.getTime(),
+ endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game;
} catch (err: unknown) {
console.log(err);
@@ -24,23 +74,50 @@ const create = async (game: Game) => {
}
};
-const find = async (where?: string, limit = 1) => {
- const query = where
- ? `SELECT * FROM "game"`
- : {
- text: `SELECT * FROM "game" WHERE $1 LIMIT $2`,
- values: [where, limit]
- };
-
+export const findById = async (id: number) => {
try {
- const res = await db.query(query);
+ const res = await db.query(
+ `SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE game.id=$1`,
+ [id]
+ );
+ if (res.rowCount) {
+ return {
+ id: res.rows[0].id,
+ winner: res.rows[0].winner,
+ endReason: res.rows[0].end_reason,
+ pgn: res.rows[0].pgn,
+ white: { id: res.rows[0].white_id || undefined, name: res.rows[0].white_name },
+ black: { id: res.rows[0].black_id || undefined, name: res.rows[0].black_name },
+ startedAt: res.rows[0].started_at.getTime(),
+ endedAt: res.rows[0].ended_at?.getTime() || undefined
+ } as Game;
+ } else return null;
+ } catch (err: unknown) {
+ console.log(err);
+ return null;
+ }
+};
+
+export const findByUserId = async (id: number, limit = 10) => {
+ if (id == 0) {
+ return null;
+ }
+ try {
+ // TODO: pagination
+ const res = await db.query(
+ `SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE white_user.id=$1 OR black_user.id=$1 ORDER BY id DESC LIMIT $2`,
+ [id, limit]
+ );
return res.rows.map((r) => {
return {
id: r.id,
+ winner: r.winner,
+ endReason: r.end_reason,
pgn: r.pgn,
- white: { id: r.white_id },
- black: { id: r.black_id },
- winner: r.winner
+ white: { id: r.white_id || undefined, name: r.white_name },
+ black: { id: r.black_id || undefined, name: r.black_name },
+ startedAt: r.started_at.getTime(),
+ endedAt: r.ended_at?.getTime() || undefined
} as Game;
});
} catch (err: unknown) {
@@ -49,15 +126,19 @@ const find = async (where?: string, limit = 1) => {
}
};
-const update = async (id: number, data: string) => {
+// TODO: update fields specifically, "data" string doesnt work
+export const update = async (id: number, data: string) => {
try {
const res = await db.query(`UPDATE "game" SET $1 WHERE id = $2 RETURNING *`, [data, id]);
return {
id: res.rows[0].id,
+ winner: res.rows[0].winner,
+ endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn,
- white: { id: res.rows[0].white_id },
- black: { id: res.rows[0].black_id },
- winner: res.rows[0].winner
+ white: { id: res.rows[0].white_id, name: res.rows[0].white_name },
+ black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
+ startedAt: res.rows[0].started_at.getTime(),
+ endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game;
} catch (err: unknown) {
console.log(err);
@@ -65,15 +146,18 @@ const update = async (id: number, data: string) => {
}
};
-const remove = async (id: number) => {
+export const remove = async (id: number) => {
try {
const res = await db.query(`DELETE FROM "game" WHERE id = $1 RETURNING *`, [id]);
return {
id: res.rows[0].id,
+ winner: res.rows[0].winner,
+ endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn,
- white: { id: res.rows[0].white_id },
- black: { id: res.rows[0].black_id },
- winner: res.rows[0].winner
+ white: { id: res.rows[0].white_id, name: res.rows[0].white_name },
+ black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
+ startedAt: res.rows[0].started_at.getTime(),
+ endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game;
} catch (err: unknown) {
console.log(err);
@@ -81,9 +165,12 @@ const remove = async (id: number) => {
}
};
-export const GameModel = {
- create,
- find,
+const GameModel = {
+ save,
+ findById,
+ findByUserId,
update,
remove
};
+
+export default GameModel;
diff --git a/server/src/db/models/user.model.ts b/server/src/db/models/user.model.ts
index a7ec173..5d29f1b 100644
--- a/server/src/db/models/user.model.ts
+++ b/server/src/db/models/user.model.ts
@@ -1,15 +1,15 @@
import { db } from "../index.js";
import type { User } from "@chessu/types";
-const create = async (user: User, password: string) => {
+export const create = async (user: User, password: string) => {
if (user.name === "Guest" || user.email === undefined) {
return null;
}
try {
const res = await db.query(
- `INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email`,
- [user.name, user.email ?? null, password]
+ `INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws`,
+ [user.name, user.email || null, password]
);
return res.rows[0] as User;
} catch (err: unknown) {
@@ -18,12 +18,33 @@ const create = async (user: User, password: string) => {
}
};
-const find = async (where?: string, limit = 1) => {
+export const findById = async (id: number) => {
+ if (id === 0) {
+ return null;
+ }
+ try {
+ const res = await db.query(
+ `SELECT id, name, email, wins, losses, draws FROM "user" WHERE id=$1`,
+ [id]
+ );
+ if (res.rowCount) {
+ return res.rows[0] as User;
+ } else return null;
+ } catch (err: unknown) {
+ console.log(err);
+ return null;
+ }
+};
+
+export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
// if user is not specified, get all users
- if (!where) {
+ if (!user) {
try {
- const res = await db.query(`SELECT id, name, email FROM "user"`);
- return res.rows as Array;
+ const res = await db.query(
+ `SELECT id, name, email, wins, losses, draws FROM "user" LIMIT $1`,
+ [limit ?? 10]
+ );
+ return res.rows as Array;
} catch (err: unknown) {
console.log(err);
return null;
@@ -31,31 +52,33 @@ const find = async (where?: string, limit = 1) => {
}
try {
- /* const res = await db.query(
- `SELECT id, name, email FROM "user" WHERE ${typeof user.id === "number" ? "id" : typeof user.name === "string" ? "name" : "email" } = $1 LIMIT $2`,
- [typeof user.id === "number" ? user.id : typeof user.name === "string" ? user.name : user.email, limit]
- ); */
- const res = await db.query(`SELECT id, name, email FROM "user" WHERE $1 LIMIT $2`, [
- where,
- limit
- ]);
- return res.rows as Array;
+ const res = await db.query(
+ `SELECT id, name, email, wins, losses, draws${
+ includePassword ? `, password` : ""
+ } FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
+ [user.name, user.email, limit ?? 1]
+ );
+ return res.rows as Array;
} catch (err: unknown) {
console.log(err);
return null;
}
};
-const update = async (id: number, data: string) => {
- if (typeof id === "string" || id === 0) {
+export const update = async (id: number, updatedUser: User & { password?: string }) => {
+ if (id === 0) {
return null;
}
try {
- const res = await db.query(`UPDATE "user" SET $1 WHERE id = $2 RETURNING id, name, email`, [
- data,
- id
- ]);
+ let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
+ let values = [updatedUser.name, updatedUser.email, id];
+
+ if (updatedUser.password) {
+ query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws`;
+ values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
+ }
+ const res = await db.query(query, values);
return res.rows[0] as User;
} catch (err: unknown) {
console.log(err);
@@ -63,8 +86,8 @@ const update = async (id: number, data: string) => {
}
};
-const remove = async (id: number) => {
- if (typeof id === "string" || id === 0) {
+export const remove = async (id: number) => {
+ if (id === 0) {
return null;
}
@@ -79,9 +102,12 @@ const remove = async (id: number) => {
}
};
-export const UserModel = {
+const UserModel = {
create,
- find,
+ findById,
+ findByNameEmail,
update,
remove
};
+
+export default UserModel;
diff --git a/server/src/routes/auth.route.ts b/server/src/routes/auth.route.ts
index 11aa1dc..efe6641 100644
--- a/server/src/routes/auth.route.ts
+++ b/server/src/routes/auth.route.ts
@@ -3,14 +3,14 @@ import * as controller from "../controllers/auth.controller.js";
const router = Router();
-router.route("/").get(controller.getCurrentSession);
+router.route("/").get(controller.getCurrentSession).patch(controller.updateUser);
// create or update guest sessions
router.route("/guest").post(controller.guestSession);
router.route("/logout").post(controller.logoutSession);
-//router.route("/register").post(controller.registerUser);
-//router.route("/login").post(controller.loginUser);
+router.route("/register").post(controller.registerUser);
+router.route("/login").post(controller.loginUser);
export default router;
diff --git a/server/src/routes/games.route.ts b/server/src/routes/games.route.ts
index 699a6d2..a64a789 100644
--- a/server/src/routes/games.route.ts
+++ b/server/src/routes/games.route.ts
@@ -3,7 +3,7 @@ import * as controller from "../controllers/games.controller.js";
const router = Router();
-router.route("/").get(controller.getActivePublicGames).post(controller.createGame);
+router.route("/").get(controller.getGames).post(controller.createGame);
router.route("/:code").get(controller.getActiveGame);
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
index 51ebefc..7710b1b 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -1,10 +1,12 @@
import { Router } from "express";
import games from "./games.route.js";
import auth from "./auth.route.js";
+import users from "./users.route.js";
const router = Router();
router.use("/games", games);
router.use("/auth", auth);
+router.use("/users", users);
export default router;
diff --git a/server/src/routes/users.route.ts b/server/src/routes/users.route.ts
new file mode 100644
index 0000000..520e1b0
--- /dev/null
+++ b/server/src/routes/users.route.ts
@@ -0,0 +1,8 @@
+import { Router } from "express";
+import * as controller from "../controllers/users.controller.js";
+
+const router = Router();
+
+router.route("/:name").get(controller.getUserProfile);
+
+export default router;
diff --git a/server/src/server.ts b/server/src/server.ts
index bda46c0..2bc1380 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -6,7 +6,7 @@ import { createServer } from "http";
import session from "./middleware/session.js";
import { Server } from "socket.io";
import { init as initSocket } from "./socket/index.js";
-import { db } from "./db/index.js";
+import { db, INIT_TABLES } from "./db/index.js";
import routes from "./routes/index.js";
const corsConfig = {
@@ -18,7 +18,14 @@ const app = express();
const server = createServer(app);
// database
-db.connect();
+await db.connect();
+db.query(INIT_TABLES, (err) => {
+ if (err) {
+ console.error(err);
+ } else {
+ console.log("Tables initialized");
+ }
+});
// middleware
app.use(cors(corsConfig));
diff --git a/server/src/socket/game.socket.ts b/server/src/socket/game.socket.ts
index b3eed82..fd4d81e 100644
--- a/server/src/socket/game.socket.ts
+++ b/server/src/socket/game.socket.ts
@@ -1,4 +1,5 @@
-import { activeGames } from "../db/models/game.model.js";
+import GameModel, { activeGames } from "../db/models/game.model.js";
+import type { Game } from "@chessu/types";
import type { DisconnectReason, Socket } from "socket.io";
import { Chess } from "chess.js";
import { io } from "../server.js";
@@ -18,11 +19,13 @@ export async function joinLobby(this: Socket, gameCode: string) {
}
if (game.white && game.white?.id === this.request.session.user.id) {
game.white.connected = true;
+ game.white.disconnectedOn = undefined;
if (game.white.name !== this.request.session.user.name) {
game.white.name = this.request.session.user.name;
}
} else if (game.black && game.black?.id === this.request.session.user.id) {
game.black.connected = true;
+ game.black.disconnectedOn = undefined;
if (game.black.name !== this.request.session.user.name) {
game.black.name = this.request.session.user.name;
}
@@ -50,7 +53,7 @@ export async function joinLobby(this: Socket, gameCode: string) {
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
if (this.rooms.size >= 3 && !code) {
- console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`);
+ console.log(`leaveLobby: room size is ${this.rooms.size}, aborting...`);
return;
}
const game = activeGames.find(
@@ -68,8 +71,10 @@ export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?:
}
if (game.black && game.black?.id === this.request.session.user.id) {
game.black.connected = false;
+ game.black.disconnectedOn = Date.now();
} else if (game.white && game.white?.id === this.request.session.user.id) {
game.white.connected = false;
+ game.white.disconnectedOn = Date.now();
}
// count sockets
@@ -94,6 +99,58 @@ export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?:
await this.leave(code || Array.from(this.rooms)[1]);
}
+export async function claimAbandoned(this: Socket, type: "win" | "draw") {
+ const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
+ if (
+ !game ||
+ !game.pgn ||
+ !game.white ||
+ !game.black ||
+ (game.white.id !== this.request.session.user.id &&
+ game.black.id !== this.request.session.user.id)
+ ) {
+ console.log(`claimAbandoned: Invalid game or user is not a player.`);
+ return;
+ }
+
+ if (
+ (game.white &&
+ game.white.id === this.request.session.user.id &&
+ (game.black?.connected ||
+ Date.now() - (game.black?.disconnectedOn as number) < 50000)) ||
+ (game.black &&
+ game.black.id === this.request.session.user.id &&
+ (game.white?.connected || Date.now() - (game.white?.disconnectedOn as number) < 50000))
+ ) {
+ console.log(
+ `claimAbandoned: Invalid claim by ${this.request.session.user.name}. Opponent is still connected or disconnected less than 50 seconds ago.`
+ );
+ return;
+ }
+
+ game.endReason = "abandoned";
+
+ if (type === "draw") {
+ game.winner = "draw";
+ } else if (game.white && game.white?.id === this.request.session.user.id) {
+ game.winner = "white";
+ } else if (game.black && game.black?.id === this.request.session.user.id) {
+ game.winner = "black";
+ }
+
+ const { id } = (await GameModel.save(game)) as Game;
+ game.id = id;
+
+ const gameOver = {
+ reason: game.endReason,
+ winnerName: this.request.session.user.name,
+ winnerSide: game.winner === "draw" ? undefined : game.winner,
+ id
+ };
+
+ io.to(game.code as string).emit("gameOver", gameOver);
+}
+
export async function getLatestGame(this: Socket) {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
if (game) this.emit("receivedLatestGame", game);
@@ -101,7 +158,7 @@ export async function getLatestGame(this: Socket) {
export async function sendMove(this: Socket, m: { from: string; to: string; promotion?: string }) {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
- if (!game) return;
+ if (!game || game.endReason || game.winner) return;
const chess = new Chess();
if (game.pgn) {
chess.loadPgn(game.pgn);
@@ -118,32 +175,37 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
}
const newMove = chess.move(m);
- if (chess.isGameOver()) {
- let reason = "";
- if (chess.isCheckmate()) reason = "checkmate";
- else if (chess.isStalemate()) reason = "stalemate";
- else if (chess.isThreefoldRepetition()) reason = "repetition";
- else if (chess.isInsufficientMaterial()) reason = "insufficient";
- else if (chess.isDraw()) reason = "draw";
- const winnerSide =
- reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
- const winnerName =
- reason === "checkmate"
- ? winnerSide === "white"
- ? game.white?.name
- : game.black?.name
- : undefined;
- if (reason === "checkmate") {
- game.winner = winnerSide;
- } else {
- game.winner = "draw";
- }
- io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide });
- }
if (newMove) {
game.pgn = chess.pgn();
this.to(game.code as string).emit("receivedMove", m);
+ if (chess.isGameOver()) {
+ let reason: Game["endReason"];
+ if (chess.isCheckmate()) reason = "checkmate";
+ else if (chess.isStalemate()) reason = "stalemate";
+ else if (chess.isThreefoldRepetition()) reason = "repetition";
+ else if (chess.isInsufficientMaterial()) reason = "insufficient";
+ else if (chess.isDraw()) reason = "draw";
+
+ const winnerSide =
+ reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
+ const winnerName =
+ reason === "checkmate"
+ ? winnerSide === "white"
+ ? game.white?.name
+ : game.black?.name
+ : undefined;
+ if (reason === "checkmate") {
+ game.winner = winnerSide;
+ } else {
+ game.winner = "draw";
+ }
+ game.endReason = reason;
+
+ const { id } = (await GameModel.save(game)) as Game; // save game to db
+ game.id = id;
+ io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
+ }
} else {
throw new Error("invalid move");
}
@@ -169,6 +231,7 @@ export async function joinAsPlayer(this: Socket) {
name: this.request.session.user.name,
side: "white"
});
+ game.startedAt = Date.now();
} else if (!game.black) {
const sessionUser = {
id: this.request.session.user.id,
@@ -181,8 +244,9 @@ export async function joinAsPlayer(this: Socket) {
name: this.request.session.user.name,
side: "black"
});
+ game.startedAt = Date.now();
} else {
- console.log("[WARNING] attempted to join a game with already 2 players");
+ console.log("joinAsPlayer: attempted to join a game with already 2 players");
}
io.to(game.code as string).emit("receivedLatestGame", game);
}
diff --git a/server/src/socket/index.ts b/server/src/socket/index.ts
index 81ef55e..d14d032 100644
--- a/server/src/socket/index.ts
+++ b/server/src/socket/index.ts
@@ -6,13 +6,14 @@ import {
getLatestGame,
sendMove,
joinAsPlayer,
- chat
+ chat,
+ claimAbandoned
} from "./game.socket.js";
const socketConnect = (socket: Socket) => {
const req = socket.request;
- // re-analyze if this is necessary, or if io.use will handle logout
+ // review if this is necessary, or if io.use will handle logout
socket.use((__, next) => {
req.session.reload((err) => {
if (err) {
@@ -34,6 +35,7 @@ const socketConnect = (socket: Socket) => {
socket.on("sendMove", sendMove);
socket.on("joinAsPlayer", joinAsPlayer);
socket.on("chat", chat);
+ socket.on("claimAbandoned", claimAbandoned);
};
export const init = () => {
diff --git a/types/index.d.ts b/types/index.d.ts
index e2452ec..7e9391b 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -4,16 +4,25 @@ export interface Game {
white?: User;
black?: User;
winner?: "white" | "black" | "draw";
+ endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned";
host?: User;
code?: string;
unlisted?: boolean;
timeout?: number;
observers?: User[];
+ startedAt?: number;
+ endedAt?: number;
}
export interface User {
id?: number | string; // string for guest IDs
- name?: string;
+ name?: string | null;
email?: string;
- connected?: boolean; // mainly for players, not spectators
+ wins?: number;
+ losses?: number;
+ draws?: number;
+
+ // mainly for players, not spectators
+ connected?: boolean;
+ disconnectedOn?: number;
}