feat: add time controls, correspondence games, and tournament mode

**Zeitmodi (Blitz/Rapid):**
- 5, 10, 30 Min Zeitkontrolle bei Spielerstellung
- Server-seitige Uhren: Zeit wird bei jedem Zug abgezogen
- Timeout-Erkennung via claimTimeout Socket-Event
- Schachuhr-Anzeige in GamePage (rot bei < 30s)
- Turnierspiele erhalten automatisch Zeitkontrolle

**Tagespartien (Correspondence):**
- Neue DB-Tabelle correspondence_game
- REST API: erstellen, beitreten, Zug machen, aufgeben
- Seite /correspondence: Liste aktiver Tagespartien
- Seite /correspondence/[code]: Spielen mit interaktivem Brett
- Einladungslink teilen, Gegner tritt via Link bei

**Turniermodus (Round-Robin):**
- Neue DB-Tabellen: tournament, tournament_player, tournament_round
- Round-Robin Paarungen mit Circle-Methode
- Turnier erstellen: /tournament/create
- Turnierliste: /tournament
- Turnierseite /tournament/[code]: Standings, Runden, Spiele
- Automatische Aktivisierung der nächsten Runde
- Turnierspiele werden als normale activeGames erstellt
- Ergebnisse aktualisieren Punkte automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-14 09:44:35 +02:00
parent 09e5271dfb
commit aeb5fe313e
25 changed files with 1653 additions and 16 deletions

View File

@@ -48,4 +48,57 @@ export const INIT_TABLES = /* sql */ `
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned 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 time_control INTEGER;
CREATE TABLE IF NOT EXISTS "correspondence_game" (
id SERIAL PRIMARY KEY,
code VARCHAR(8) UNIQUE NOT NULL,
white_id INT REFERENCES "user"(id),
black_id INT REFERENCES "user"(id),
white_name VARCHAR(128),
black_name VARCHAR(128),
pgn TEXT DEFAULT '',
winner VARCHAR(5),
end_reason VARCHAR(16),
days_per_move INTEGER DEFAULT 3,
last_move_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "tournament" (
id SERIAL PRIMARY KEY,
code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(128) NOT NULL,
host_id INT REFERENCES "user"(id),
host_name VARCHAR(128),
status VARCHAR(16) DEFAULT 'waiting',
time_control INTEGER,
max_players INTEGER DEFAULT 8,
current_round INTEGER DEFAULT 0,
total_rounds INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "tournament_player" (
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
user_id INT REFERENCES "user"(id) ON DELETE CASCADE,
user_name VARCHAR(128),
score DECIMAL(4,1) DEFAULT 0,
games_played INTEGER DEFAULT 0,
PRIMARY KEY (tournament_id, user_id)
);
CREATE TABLE IF NOT EXISTS "tournament_round" (
id SERIAL PRIMARY KEY,
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
round INTEGER NOT NULL,
game_code VARCHAR(8),
game_id INT,
white_id INT REFERENCES "user"(id),
white_name VARCHAR(128),
black_id INT REFERENCES "user"(id),
black_name VARCHAR(128),
result VARCHAR(5)
);
`;

View File

@@ -0,0 +1,79 @@
import { db } from "../index.js";
import type { CorrespondenceGame } from "@michess/types";
import { nanoid } from "nanoid";
function mapRow(r: any): CorrespondenceGame {
return {
id: r.id,
code: r.code,
white: { id: r.white_id, name: r.white_name },
black: r.black_id ? { id: r.black_id, name: r.black_name } : undefined,
pgn: r.pgn || "",
winner: r.winner,
endReason: r.end_reason,
daysPerMove: r.days_per_move,
lastMoveAt: r.last_move_at?.getTime(),
startedAt: r.started_at?.getTime(),
endedAt: r.ended_at?.getTime()
};
}
export const create = async (userId: number, userName: string, daysPerMove = 3): Promise<CorrespondenceGame> => {
const code = nanoid(8);
const res = await db.query(
`INSERT INTO "correspondence_game"(code, white_id, white_name, days_per_move) VALUES($1, $2, $3, $4) RETURNING *`,
[code, userId, userName, daysPerMove]
);
return mapRow(res.rows[0]);
};
export const join = async (code: string, userId: number, userName: string): Promise<CorrespondenceGame | null> => {
const res = await db.query(
`UPDATE "correspondence_game" SET black_id=$1, black_name=$2 WHERE code=$3 AND black_id IS NULL AND white_id != $1 RETURNING *`,
[userId, userName, code]
);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const findByCode = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await db.query(`SELECT * FROM "correspondence_game" WHERE code=$1`, [code]);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const findByUserId = async (userId: number): Promise<CorrespondenceGame[]> => {
const res = await db.query(
`SELECT * FROM "correspondence_game" WHERE (white_id=$1 OR black_id=$1) AND ended_at IS NULL ORDER BY last_move_at DESC`,
[userId]
);
return res.rows.map(mapRow);
};
export const applyMove = async (code: string, pgn: string, winner?: string, endReason?: string): Promise<CorrespondenceGame | null> => {
let res;
if (winner) {
res = await db.query(
`UPDATE "correspondence_game" SET pgn=$1, winner=$2, end_reason=$3, last_move_at=NOW(), ended_at=NOW() WHERE code=$4 RETURNING *`,
[pgn, winner, endReason, code]
);
} else {
res = await db.query(
`UPDATE "correspondence_game" SET pgn=$1, last_move_at=NOW() WHERE code=$2 RETURNING *`,
[pgn, code]
);
}
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const resign = async (code: string, resigningUserId: number): Promise<CorrespondenceGame | null> => {
const game = await findByCode(code);
if (!game) return null;
const winner = game.white?.id === resigningUserId ? "black" : "white";
const res = await db.query(
`UPDATE "correspondence_game" SET winner=$1, end_reason='resign', ended_at=NOW() WHERE code=$2 RETURNING *`,
[winner, code]
);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
const CorrespondenceModel = { create, join, findByCode, findByUserId, applyMove, resign };
export default CorrespondenceModel;

View File

@@ -14,7 +14,7 @@ export const save = async (game: Game) => {
black.id = game.black?.id;
}
const res = await db.query(
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at, vs_ai, ai_level) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at, vs_ai, ai_level, time_control) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[
game.winner || null,
game.endReason || null,
@@ -25,7 +25,8 @@ export const save = async (game: Game) => {
black.name || null,
new Date(game.startedAt as number),
game.vsAi || false,
game.aiLevel || null
game.aiLevel || null,
game.timeControl || null
]
);
if (black.id || white.id) {
@@ -66,7 +67,8 @@ export const save = async (game: Game) => {
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined,
vsAi: res.rows[0].vs_ai || undefined,
aiLevel: res.rows[0].ai_level || undefined
aiLevel: res.rows[0].ai_level || undefined,
timeControl: res.rows[0].time_control || undefined
} as Game;
} catch (err: unknown) {
console.log(err);

View File

@@ -0,0 +1,179 @@
import { db } from "../index.js";
import { nanoid } from "nanoid";
import type { Tournament, TournamentPlayer, TournamentRound } from "@michess/types";
// Circle method for round-robin tournament scheduling
function generateRoundRobinPairings(players: TournamentPlayer[]): { white: TournamentPlayer; black: TournamentPlayer }[][] {
let ps: (TournamentPlayer | null)[] = [...players];
if (ps.length % 2 !== 0) ps.push(null); // BYE slot for odd number
const n = ps.length;
const rounds: { white: TournamentPlayer; black: TournamentPlayer }[][] = [];
for (let r = 0; r < n - 1; r++) {
const games: { white: TournamentPlayer; black: TournamentPlayer }[] = [];
for (let k = 0; k < n / 2; k++) {
const home = ps[k];
const away = ps[n - 1 - k];
if (home && away) {
// Alternate colors each round for fairness
games.push(r % 2 === 0 ? { white: home, black: away } : { white: away, black: home });
}
}
rounds.push(games);
// Rotate: keep index 0 fixed, rotate the rest clockwise
const last = ps[n - 1];
for (let i = n - 1; i > 1; i--) ps[i] = ps[i - 1];
ps[1] = last;
}
return rounds;
}
function mapTournament(t: any, players: any[], rounds: any[]): Tournament {
return {
id: t.id,
code: t.code,
name: t.name,
hostId: t.host_id,
hostName: t.host_name,
status: t.status,
timeControl: t.time_control,
maxPlayers: t.max_players,
currentRound: t.current_round,
totalRounds: t.total_rounds,
players: players.map(p => ({
tournamentId: p.tournament_id,
userId: p.user_id,
userName: p.user_name,
score: parseFloat(p.score),
gamesPlayed: p.games_played
})),
rounds: rounds.map(r => ({
id: r.id,
tournamentId: r.tournament_id,
round: r.round,
gameCode: r.game_code,
gameId: r.game_id,
whiteId: r.white_id,
whiteName: r.white_name,
blackId: r.black_id,
blackName: r.black_name,
result: r.result
})),
createdAt: t.created_at?.getTime()
};
}
export const createTournament = async (hostId: number, hostName: string, name: string, timeControl?: number, maxPlayers = 8): Promise<Tournament> => {
const code = nanoid(6);
const res = await db.query(
`INSERT INTO "tournament"(code, name, host_id, host_name, time_control, max_players) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[code, name, hostId, hostName, timeControl || null, maxPlayers]
);
return mapTournament(res.rows[0], [], []);
};
export const joinTournament = async (code: string, userId: number, userName: string): Promise<boolean | null> => {
const t = await findByCode(code);
if (!t || t.status !== "waiting") return null;
if ((t.players?.length ?? 0) >= (t.maxPlayers ?? 8)) return null;
const existing = t.players?.find(p => p.userId === userId);
if (existing) return true; // already joined
try {
await db.query(
`INSERT INTO "tournament_player"(tournament_id, user_id, user_name) VALUES($1,$2,$3)`,
[t.id, userId, userName]
);
return true;
} catch { return null; }
};
export const startTournament = async (tournamentId: number): Promise<TournamentRound[][] | null> => {
const playersRes = await db.query(
`SELECT * FROM "tournament_player" WHERE tournament_id=$1`,
[tournamentId]
);
const players: TournamentPlayer[] = playersRes.rows.map(r => ({
tournamentId: r.tournament_id, userId: r.user_id, userName: r.user_name, score: 0, gamesPlayed: 0
}));
if (players.length < 2) return null;
const pairings = generateRoundRobinPairings(players);
const totalRounds = pairings.length;
const allRounds: TournamentRound[][] = [];
for (let r = 0; r < pairings.length; r++) {
const roundRows: TournamentRound[] = [];
for (const game of pairings[r]) {
const res = await db.query(
`INSERT INTO "tournament_round"(tournament_id, round, white_id, white_name, black_id, black_name) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[tournamentId, r + 1, game.white.userId, game.white.userName, game.black.userId, game.black.userName]
);
roundRows.push(res.rows[0]);
}
allRounds.push(roundRows);
}
await db.query(
`UPDATE "tournament" SET status='active', current_round=1, total_rounds=$1 WHERE id=$2`,
[totalRounds, tournamentId]
);
return allRounds;
};
export const findByCode = async (code: string): Promise<Tournament | null> => {
const res = await db.query(`SELECT * FROM "tournament" WHERE code=$1`, [code]);
if (!res.rows[0]) return null;
const t = res.rows[0];
const playersRes = await db.query(`SELECT * FROM "tournament_player" WHERE tournament_id=$1 ORDER BY score DESC, games_played`, [t.id]);
const roundsRes = await db.query(`SELECT * FROM "tournament_round" WHERE tournament_id=$1 ORDER BY round, id`, [t.id]);
return mapTournament(t, playersRes.rows, roundsRes.rows);
};
export const findAll = async (): Promise<Tournament[]> => {
const res = await db.query(`SELECT * FROM "tournament" WHERE status != 'finished' ORDER BY created_at DESC`);
return res.rows.map(r => mapTournament(r, [], []));
};
export const setRoundGameCode = async (roundId: number, gameCode: string): Promise<void> => {
await db.query(`UPDATE "tournament_round" SET game_code=$1 WHERE id=$2`, [gameCode, roundId]);
};
export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise<void> => {
const roundRes = await db.query(
`UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`,
[result, gameCode]
);
if (!roundRes.rows[0]) return;
const round = roundRes.rows[0];
// Update scores
if (result === "white") {
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
} else if (result === "black") {
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
} else {
await db.query(`UPDATE "tournament_player" SET score=score+0.5, games_played=games_played+1 WHERE tournament_id=$1 AND (user_id=$2 OR user_id=$3)`, [round.tournament_id, round.white_id, round.black_id]);
}
// Check if all games in this round are done -> advance round or finish tournament
const pendingRes = await db.query(
`SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2 AND result IS NULL`,
[round.tournament_id, round.round]
);
if (parseInt(pendingRes.rows[0].count) === 0) {
const nextRes = await db.query(
`SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2`,
[round.tournament_id, round.round + 1]
);
if (parseInt(nextRes.rows[0].count) > 0) {
await db.query(`UPDATE "tournament" SET current_round=current_round+1 WHERE id=$1`, [round.tournament_id]);
} else {
await db.query(`UPDATE "tournament" SET status='finished' WHERE id=$1`, [round.tournament_id]);
}
}
};
const TournamentModel = { createTournament, joinTournament, startTournament, findByCode, findAll, setRoundGameCode, updateRoundResult };
export default TournamentModel;