feat(elo): add ELO rating system
- DB: elo column on user table (default 1200, added via ALTER TABLE IF NOT EXISTS) - ELO calculated after every game save (K=32, min 100) - Bots use their fixed ELO values; only human players' ELO changes - AI page games save with bot name (e.g. 'Holzpferd Heinz') instead of 'Stockfish' - User profile shows ELO badge below name - Header shows current ELO for logged-in users (links to profile) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ export default function AiGamePage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ pgn: g.pgn(), winner, endReason, playerColor: color, level, startedAt: startedAtRef.current })
|
body: JSON.stringify({ pgn: g.pgn(), winner, endReason, playerColor: color, level, botName: selectedBot.name, startedAt: startedAtRef.current })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { id } = await res.json();
|
const { id } = await res.json();
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export default async function Profile({ params }: { params: { name: string } })
|
|||||||
return (
|
return (
|
||||||
<div className="mt-8 flex w-full flex-col gap-8">
|
<div className="mt-8 flex w-full flex-col gap-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 md:gap-8">
|
<div className="flex flex-wrap items-center justify-between gap-4 md:gap-8">
|
||||||
|
<div>
|
||||||
<h1 className="text-4xl font-bold">{data.name}</h1>
|
<h1 className="text-4xl font-bold">{data.name}</h1>
|
||||||
|
{data.elo != null && (
|
||||||
|
<span className="badge badge-primary badge-lg mt-1 font-mono">{data.elo} ELO</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-sm">Wins</span>
|
<span className="text-sm">Wins</span>
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user?.id && typeof user.id === "number" && <NotificationPanel />}
|
{user?.id && typeof user.id === "number" && <NotificationPanel />}
|
||||||
|
{user?.elo != null && typeof user.id === "number" && (
|
||||||
|
<Link href={`/user/${user.name}`} className="btn btn-ghost btn-sm font-mono tabular-nums" title="Deine ELO-Wertung">
|
||||||
|
{user.elo}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
||||||
<div className="w-10 rounded-full">
|
<div className="w-10 rounded-full">
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const loginUser = async (req: Request, res: Response) => {
|
|||||||
wins: users[0].wins,
|
wins: users[0].wins,
|
||||||
losses: users[0].losses,
|
losses: users[0].losses,
|
||||||
draws: users[0].draws,
|
draws: users[0].draws,
|
||||||
|
elo: users[0].elo,
|
||||||
role: users[0].role
|
role: users[0].role
|
||||||
};
|
};
|
||||||
req.session.save(() => {
|
req.session.save(() => {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export const getUserProfile = async (req: Request, res: Response) => {
|
|||||||
name: users[0].name,
|
name: users[0].name,
|
||||||
wins: users[0].wins,
|
wins: users[0].wins,
|
||||||
losses: users[0].losses,
|
losses: users[0].losses,
|
||||||
draws: users[0].draws
|
draws: users[0].draws,
|
||||||
|
elo: users[0].elo
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(200).json({ ...publicUser, recentGames });
|
res.status(200).json({ ...publicUser, recentGames });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const INIT_TABLES = /* sql */ `
|
|||||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
||||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
||||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS time_control INTEGER;
|
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS time_control INTEGER;
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS elo INTEGER DEFAULT 1200;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "correspondence_game" (
|
CREATE TABLE IF NOT EXISTS "correspondence_game" (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
import type { Game, User } from "@michess/types";
|
import type { Game, User } from "@michess/types";
|
||||||
import { db } from "../index.js";
|
import { db } from "../index.js";
|
||||||
|
import { BOTS } from "../../bots.js";
|
||||||
|
|
||||||
export const activeGames: Game[] = [];
|
export const activeGames: Game[] = [];
|
||||||
|
|
||||||
|
const ELO_K = 32;
|
||||||
|
|
||||||
|
async function updateElo(game: Game): Promise<void> {
|
||||||
|
if (!game.winner) return;
|
||||||
|
const whiteId = typeof game.white?.id === "number" ? game.white.id : undefined;
|
||||||
|
const blackId = typeof game.black?.id === "number" ? game.black.id : undefined;
|
||||||
|
if (!whiteId && !blackId) return;
|
||||||
|
|
||||||
|
let whiteElo = 1200, blackElo = 1200;
|
||||||
|
let whiteIsBot = false, blackIsBot = false;
|
||||||
|
|
||||||
|
if (whiteId) {
|
||||||
|
const r = await db.query(`SELECT elo, role FROM "user" WHERE id=$1`, [whiteId]);
|
||||||
|
if (r.rows[0]) { whiteElo = r.rows[0].elo ?? 1200; whiteIsBot = r.rows[0].role === "bot"; }
|
||||||
|
} else {
|
||||||
|
const bot = BOTS.find(b => b.name === game.white?.name);
|
||||||
|
if (bot) { whiteElo = bot.elo; whiteIsBot = true; }
|
||||||
|
}
|
||||||
|
if (blackId) {
|
||||||
|
const r = await db.query(`SELECT elo, role FROM "user" WHERE id=$1`, [blackId]);
|
||||||
|
if (r.rows[0]) { blackElo = r.rows[0].elo ?? 1200; blackIsBot = r.rows[0].role === "bot"; }
|
||||||
|
} else {
|
||||||
|
const bot = BOTS.find(b => b.name === game.black?.name);
|
||||||
|
if (bot) { blackElo = bot.elo; blackIsBot = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whiteIsBot && blackIsBot) return;
|
||||||
|
|
||||||
|
const actualWhite = game.winner === "white" ? 1 : game.winner === "draw" ? 0.5 : 0;
|
||||||
|
const expectedWhite = 1 / (1 + Math.pow(10, (blackElo - whiteElo) / 400));
|
||||||
|
const newWhiteElo = Math.max(100, Math.round(whiteElo + ELO_K * (actualWhite - expectedWhite)));
|
||||||
|
const newBlackElo = Math.max(100, Math.round(blackElo + ELO_K * ((1 - actualWhite) - (1 - expectedWhite))));
|
||||||
|
|
||||||
|
if (whiteId && !whiteIsBot) await db.query(`UPDATE "user" SET elo=$1 WHERE id=$2`, [newWhiteElo, whiteId]);
|
||||||
|
if (blackId && !blackIsBot) await db.query(`UPDATE "user" SET elo=$1 WHERE id=$2`, [newBlackElo, blackId]);
|
||||||
|
}
|
||||||
|
|
||||||
export const save = async (game: Game) => {
|
export const save = async (game: Game) => {
|
||||||
try {
|
try {
|
||||||
const white: User = { name: game.white?.name };
|
const white: User = { name: game.white?.name };
|
||||||
@@ -50,6 +88,7 @@ export const save = async (game: Game) => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await updateElo(game);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: res.rows[0].id,
|
id: res.rows[0].id,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, role, banned`,
|
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, elo, role, banned`,
|
||||||
[user.name, user.email || null, password]
|
[user.name, user.email || null, password]
|
||||||
);
|
);
|
||||||
return res.rows[0] as User;
|
return res.rows[0] as User;
|
||||||
@@ -24,7 +24,7 @@ export const findById = async (id: number) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" WHERE id=$1`,
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" WHERE id=$1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
if (res.rowCount) {
|
if (res.rowCount) {
|
||||||
@@ -40,7 +40,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" LIMIT $1`,
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" LIMIT $1`,
|
||||||
[limit ?? 10]
|
[limit ?? 10]
|
||||||
);
|
);
|
||||||
return res.rows as (User & { password?: string; banned?: boolean })[];
|
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||||
@@ -52,7 +52,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws, role, banned${
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned${
|
||||||
includePassword ? `, password` : ""
|
includePassword ? `, password` : ""
|
||||||
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
||||||
[user.name, user.email, limit ?? 1]
|
[user.name, user.email, limit ?? 1]
|
||||||
@@ -67,7 +67,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
|||||||
export const searchByName = async (query: string, limit = 10) => {
|
export const searchByName = async (query: string, limit = 10) => {
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, wins, losses, draws FROM "user" WHERE name ILIKE $1 LIMIT $2`,
|
`SELECT id, name, wins, losses, draws, elo FROM "user" WHERE name ILIKE $1 LIMIT $2`,
|
||||||
[`%${query}%`, limit]
|
[`%${query}%`, limit]
|
||||||
);
|
);
|
||||||
return res.rows as User[];
|
return res.rows as User[];
|
||||||
@@ -83,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, elo, role, banned`;
|
||||||
let values: (string | number | null | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
let values: (string | number | null | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
||||||
|
|
||||||
if (updatedUser.password) {
|
if (updatedUser.password) {
|
||||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, elo, role, banned`;
|
||||||
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
||||||
}
|
}
|
||||||
const res = await db.query(query, values);
|
const res = await db.query(query, values);
|
||||||
@@ -129,7 +129,7 @@ export const adminUpdate = async (id: number, fields: { role?: string; banned?:
|
|||||||
|
|
||||||
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
|
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
|
||||||
try {
|
try {
|
||||||
let query = `SELECT id, name, email, wins, losses, draws, role, banned, created_at FROM "user"`;
|
let query = `SELECT id, name, email, wins, losses, draws, elo, role, banned, created_at FROM "user"`;
|
||||||
const values: (string | number)[] = [];
|
const values: (string | number)[] = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ router.get("/levels", (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.post("/save", requireAuth, async (req: Request, res: Response) => {
|
router.post("/save", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { pgn, winner, endReason, playerColor, level, startedAt } = req.body;
|
const { pgn, winner, endReason, playerColor, level, startedAt, botName } = req.body;
|
||||||
const user = req.session.user!;
|
const user = req.session.user!;
|
||||||
|
|
||||||
if (!pgn || !winner || !endReason || !playerColor) {
|
if (!pgn || !winner || !endReason || !playerColor) {
|
||||||
@@ -42,7 +42,7 @@ router.post("/save", requireAuth, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const humanPlayer = { id: user.id as number, name: user.name };
|
const humanPlayer = { id: user.id as number, name: user.name };
|
||||||
const aiPlayer = { name: `Stockfish` };
|
const aiPlayer = { name: botName || `Stockfish` };
|
||||||
|
|
||||||
const game: Game = {
|
const game: Game = {
|
||||||
pgn,
|
pgn,
|
||||||
|
|||||||
1
types/index.d.ts
vendored
1
types/index.d.ts
vendored
@@ -27,6 +27,7 @@ export interface User {
|
|||||||
wins?: number;
|
wins?: number;
|
||||||
losses?: number;
|
losses?: number;
|
||||||
draws?: number;
|
draws?: number;
|
||||||
|
elo?: number;
|
||||||
role?: "user" | "admin";
|
role?: "user" | "admin";
|
||||||
|
|
||||||
// mainly for players, not spectators
|
// mainly for players, not spectators
|
||||||
|
|||||||
Reference in New Issue
Block a user