From f6012e3f707d4c4849f43693ecbd41b735e0b696 Mon Sep 17 00:00:00 2001 From: "Stanislav Ch. Nikolov" Date: Fri, 11 Aug 2023 02:42:23 +0200 Subject: [PATCH] Replace sqlite with postgres; Make arena generation a seperate process --- backend/arena.ts | 250 +++++++++++++++++++++------------------------- backend/db.ts | 62 ++++++------ backend/index.ts | 141 ++++++++++---------------- backend/utils.ts | 9 +- fens.txt | 2 +- frontend/game.ts | 10 +- package.json | 5 +- public/index.html | 26 ++--- readme.md | 23 ++++- 9 files changed, 244 insertions(+), 284 deletions(-) diff --git a/backend/arena.ts b/backend/arena.ts index 90dfb0f..259c41b 100644 --- a/backend/arena.ts +++ b/backend/arena.ts @@ -1,18 +1,31 @@ -import { spawn } from "node:child_process"; +const colors = { + reset: "\x1b[0m", + black: "\x1b[30m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +} + import { Chess } from "chess.js"; +import { Subprocess } from "bun"; import { mkdir } from "node:fs/promises"; +import { ok as assert } from "node:assert"; import { dirname } from "node:path"; -import { db } from "./db"; +import sql from "./db"; import { makeTmpDir, getElo } from "./utils"; +import rawfenstxt from "../fens.txt"; -class BotInstance { - constructor( - public db_id: number, - public hash: string, - public time_ms: number, - public proc: any = null - ) { - } +const startingPositions = rawfenstxt.split('\n'); + +interface BotInstance { + id: number, + time_ms: number, + proc?: Subprocess<"pipe", "pipe", "inherit"> }; function spawnBotProcess(tmpdir: string, hash: string) { @@ -22,10 +35,11 @@ function spawnBotProcess(tmpdir: string, hash: string) { if (Bun.which("bwrap") == null) { console.warn("bubblewrap (bwrap) not installed. Running insecurely!"); - return spawn("dotnet", [`${tmpdir}/${hash}.dll`]); + return Bun.spawn(["dotnet", `${tmpdir}/${hash}.dll`], { stdin: "pipe", stdout: "pipe" }); } - return spawn("bwrap", [ + return Bun.spawn([ + "bwrap", "--ro-bind", "/usr", "/usr", "--dir", "/tmp", // Dotnet needs /tmp to exist "--proc", "/proc", // Dotnet refuses to start without proc to audit itself @@ -36,74 +50,63 @@ function spawnBotProcess(tmpdir: string, hash: string) { "--unshare-all", // This disables practically everything, including reading other pids, network, etc. "--clearenv", // Do not leak any other env variable, not that they would help "--", "/dotnet/dotnet", `${hash}.dll` // Actually start the damn bot! - ]); + ], { stdin: "pipe", stdout: "pipe" }); } /* * This class menages the the bot processes and the database records needed to follow the game live. */ export class Arena { - tmpdir: string; initial_time_ms: number; gameId: number; // As recorded in the db. lastMoveTime: Date; - c2bi: Record<'w' | 'b', BotInstance>; + bots: Record<'w' | 'b', BotInstance>; board: Chess; moveTimeoutId: Timer; gameEnded = false; constructor(wid: number, bid: number, initial_time_ms: number = 60 * 1000) { - const query = db.query("SELECT hash FROM bots WHERE id=?1"); - const whash = query.get(wid)?.hash; - const bhash = query.get(bid)?.hash; - if (!whash || !bhash) throw new Error(); - - // Record the fact that the game is created. - this.gameId = db - .query("INSERT INTO games (wid, bid, initial_time_ms) VALUES (?1, ?2, ?3) RETURNING id") - .get([wid, bid, initial_time_ms]).id; - this.initial_time_ms = initial_time_ms; - - this.c2bi = { - 'w': new BotInstance(wid, whash, initial_time_ms), - 'b': new BotInstance(bid, bhash, initial_time_ms), + this.bots = { + w: { id: wid, time_ms: initial_time_ms }, + b: { id: bid, time_ms: initial_time_ms } }; } async start() { - const fens = (await Bun.file('./fens.txt').text()).split('\n'); - const fen = fens[Math.floor(Math.random() * fens.length)]; - this.board = new Chess(fen); + const whash = (await sql`SELECT hash FROM bots WHERE id = ${this.bots.w.id}`)[0].hash; + const bhash = (await sql`SELECT hash FROM bots WHERE id = ${this.bots.b.id}`)[0].hash; + assert(whash != null && bhash != null); - await this.#prepare(); - db.query("UPDATE games SET started = ?1, initial_position = ?2, current_position = ?3 WHERE id = ?4").run([new Date().toISOString(), fen, fen, this.gameId]); - this.#pokeAtTheBotThatIsOnTurn(); - } + const startingPosition = startingPositions[Math.floor(Math.random() * startingPositions.length)]; + this.board = new Chess(startingPosition); + + this.gameId = (await sql` + INSERT INTO games (wid, bid, started, initial_time_ms, initial_position, current_position) + VALUES (${this.bots.w.id}, ${this.bots.b.id}, NOW(), ${this.initial_time_ms}, ${startingPosition}, ${startingPosition}) + RETURNING id + `)[0].id; - async #prepare() { - this.tmpdir = makeTmpDir(); - await mkdir(this.tmpdir, { recursive: true }); + const tmpdir = makeTmpDir(); + await mkdir(tmpdir, { recursive: true }); // Copy the actual dlls. - await Bun.write(Bun.file(`${this.tmpdir}/${this.c2bi['w'].hash}.dll`), Bun.file(`compiled/${this.c2bi['w'].hash}.dll`)); - await Bun.write(Bun.file(`${this.tmpdir}/${this.c2bi['b'].hash}.dll`), Bun.file(`compiled/${this.c2bi['b'].hash}.dll`)); + await Bun.write(Bun.file(`${tmpdir}/${whash}.dll`), Bun.file(`compiled/${whash}.dll`)); + await Bun.write(Bun.file(`${tmpdir}/${bhash}.dll`), Bun.file(`compiled/${bhash}.dll`)); // Copy the runtimeconfig file that dotnet DEMANDS for some reason. - await Bun.write(Bun.file(`${this.tmpdir}/${this.c2bi['w'].hash}.runtimeconfig.json`), Bun.file(`runtimeconfig.json`)); - await Bun.write(Bun.file(`${this.tmpdir}/${this.c2bi['b'].hash}.runtimeconfig.json`), Bun.file(`runtimeconfig.json`)); + await Bun.write(Bun.file(`${tmpdir}/${whash}.runtimeconfig.json`), Bun.file(`runtimeconfig.json`)); + await Bun.write(Bun.file(`${tmpdir}/${bhash}.runtimeconfig.json`), Bun.file(`runtimeconfig.json`)); - this.c2bi['w'].proc = spawnBotProcess(this.tmpdir, this.c2bi['w'].hash); - this.c2bi['b'].proc = spawnBotProcess(this.tmpdir, this.c2bi['b'].hash); + this.bots.w.proc = spawnBotProcess(tmpdir, whash); + this.bots.b.proc = spawnBotProcess(tmpdir, bhash); - this.c2bi['w'].proc.stdout.on('data', (data: Buffer) => this.#procWrote('w', data)); - this.c2bi['b'].proc.stdout.on('data', (data: Buffer) => this.#procWrote('b', data)); + await this.#pokeAtTheBotThatIsOnTurn(); - // this.c2bi['w'].proc.stderr.on('data', d => console.log('werr', d.toString())); - // this.c2bi['b'].proc.stderr.on('data', d => console.log('berr', d.toString())); + await Bun.spawn(["rm", "-rf", tmpdir]).exited; } - #pokeAtTheBotThatIsOnTurn() { + async #pokeAtTheBotThatIsOnTurn() { this.lastMoveTime = new Date(); const col = this.board.turn(); const other = col === 'w' ? 'b' : 'w'; @@ -113,14 +116,22 @@ export class Arena { clearTimeout(this.moveTimeoutId); this.moveTimeoutId = null; } - this.moveTimeoutId = setTimeout(() => this.#timeout(), this.c2bi[col].time_ms + 1); + this.moveTimeoutId = setTimeout(() => this.#timeout(), this.bots[col].time_ms + 1); - const timerString = `${this.c2bi[col].time_ms} ${this.c2bi[other].time_ms} ${this.initial_time_ms}`; + const timerString = `${this.bots[col].time_ms} ${this.bots[other].time_ms} ${this.initial_time_ms}`; try { - this.c2bi[col].proc.stdin.write(this.board.fen() + '\n' + timerString + '\n'); + this.bots[col].proc.stdin.write(this.board.fen() + '\n' + timerString + '\n'); + this.bots[col].proc.stdin.flush(); + console.log(`${colors.gray}Waiting for response from ${col}${colors.reset}`) } catch (e) { - this.#endGame(other, `${fullname} kicked to bucket early (crashed)`); + return this.#endGame(other, `${fullname} kicked to bucket early (crashed)`); } + + const reader = this.bots[col].proc.stdout.getReader(); + const readResult = await reader.read(); + reader.releaseLock(); + const move = new TextDecoder().decode(readResult.value); + await this.#procWrote(col, move); } #timeout() { @@ -130,23 +141,17 @@ export class Arena { this.#endGame(other, `${fullname} timed out`); } - #procWrote(color: 'w' | 'b', data: Buffer) { - if (this.gameEnded) { - // I couldn't be bother to detach the procWrote event handler, so this should do. - console.log(`It looks like ${color} managed to write after we tried to kill it.`); - return; - } + async #procWrote(color: 'w' | 'b', move: string) { + console.log(`${colors.gray}${color} wrote: ${JSON.stringify(move)}${colors.reset}`); + assert(!this.gameEnded); + assert(this.board.turn() === color); + const other = color === 'w' ? 'b' : 'w'; const fullname = color === 'w' ? 'White' : 'Black'; - const move = data.toString(); - if (this.board.turn() !== color) { - return this.#endGame(other, `${fullname} made a move, but it wasn't on turn`); - } - const moveTime: number = new Date().getTime() - this.lastMoveTime.getTime(); - this.c2bi[color].time_ms -= moveTime; - if (this.c2bi[color].time_ms < 0) { + this.bots[color].time_ms -= moveTime; + if (this.bots[color].time_ms < 0) { return this.#endGame(other, `${fullname} timed out`); } @@ -157,10 +162,11 @@ export class Arena { return this.#endGame(other, `${fullname} made an illegal move: ${move}`); } - db.query("INSERT INTO moves (game_id, move, color, time) VALUES (?1, ?2, ?3, ?4)") - .run([this.gameId, move, color, moveTime]); + const hist = this.board.history(); + const parsedMove = hist[hist.length - 1]; - db.query("UPDATE games SET current_position = ?1 WHERE id = ?2").run([this.board.fen(), this.gameId]); + await sql`INSERT INTO moves (game_id, move, color, time_ms) VALUES (${this.gameId}, ${parsedMove}, ${color}, ${moveTime})` + await sql`UPDATE games SET current_position = ${this.board.fen()} WHERE id = ${this.gameId}`; if (this.board.isGameOver()) { // Be careful about the order of the checks. if (this.board.isStalemate()) return this.#endGame('d', 'Stalemate'); @@ -171,13 +177,15 @@ export class Arena { return this.#endGame('d', 'Unknown'); // This should not happen. } - this.#pokeAtTheBotThatIsOnTurn(); + await this.#pokeAtTheBotThatIsOnTurn(); } - #endGame(winner: string, reason: string) { + async #endGame(winner: string, reason: string) { + console.log(`${colors.cyan}Game ${this.gameId} won by ${winner} - ${reason}${colors.reset}`); + this.gameEnded = true; - this.c2bi['w'].proc.kill(9); - this.c2bi['b'].proc.kill(9); + this.bots.w.proc.kill(9); + this.bots.b.proc.kill(9); if (this.moveTimeoutId != null) { clearTimeout(this.moveTimeoutId); @@ -185,34 +193,31 @@ export class Arena { } // Record the game result - db.query("UPDATE games SET ended = ?1, winner = ?2, reason = ?3 WHERE id = ?4") - .run([new Date().toISOString(), winner, reason, this.gameId]); + await sql`UPDATE games SET ended=NOW(), winner=${winner}, reason=${reason} WHERE id=${this.gameId}`; // Calculate new elo - https://www.youtube.com/watch?v=AsYfbmp0To0 - const welo = getElo([this.c2bi['w'].db_id]); - const belo = getElo([this.c2bi['b'].db_id]); + const welo = await getElo(this.bots.w.id); + const belo = await getElo(this.bots.b.id); const expectedScore = 1 / (1 + Math.pow(10, (belo - welo) / 400)); const actualScore = { 'w': 1.0, 'b': 0.0, 'd': 0.5 }[winner]; - const eloUpdateQuery = db.query('INSERT INTO elo_updates (game_id, bot_id, change) VALUES (?,?,?)'); - eloUpdateQuery.run([this.gameId, this.c2bi['w'].db_id, +1 * 32 * (actualScore - expectedScore)]); - eloUpdateQuery.run([this.gameId, this.c2bi['b'].db_id, -1 * 32 * (actualScore - expectedScore)]); - - Bun.spawn(["rm", "-rf", this.tmpdir]); - console.log(`Game ${this.gameId} won by ${winner} - ${reason}`); + const wchange = +1 * 32 * (actualScore - expectedScore); + const bchange = -1 * 32 * (actualScore - expectedScore); + await sql`INSERT INTO elo_updates (game_id, bot_id, change) VALUES (${this.gameId},${this.bots.w.id},${wchange})`; + await sql`INSERT INTO elo_updates (game_id, bot_id, change) VALUES (${this.gameId},${this.bots.b.id},${bchange})`; } } -function pickBotByNumberOfGamesPlayed(): number { +async function pickBotByNumberOfGamesPlayed(): Promise { // Bots that have played FEWER games have a HIGHER chance to be picked. // Playing more than 100 games does not change your weight. - const stats = db.query(` - SELECT bots.id, MIN(COUNT(*), 100) AS cnt FROM bots + const stats = await sql` + SELECT bots.id, LEAST(COUNT(*), 100)::int AS cnt FROM bots LEFT JOIN games ON games.wid = bots.id OR games.bid = bots.id GROUP BY bots.id ORDER BY cnt - `).all() as { id: number, cnt: number }[]; + ` as { id: number, cnt: number }[]; const weightSum = stats.reduce((tot, curr) => tot + 1 / curr.cnt, 0); let prefSum = 0; @@ -223,18 +228,20 @@ function pickBotByNumberOfGamesPlayed(): number { } } -function pickBotThatHasCloseElo(otherBotId: number): number { +async function pickBotThatHasCloseElo(otherBotId: number): Promise { // Bots that have elo that is CLOSE to otherBotId have MORE chance to be picked up. - const otherElo = getElo(otherBotId); - const stats = db.query(` - SELECT bot_id, coalesce(SUM(change), 0) AS elo - FROM elo_updates WHERE bot_id != ?1 + + const otherElo = await getElo(otherBotId); + const stats = await sql` + SELECT bot_id, coalesce(SUM(change), 0)::int AS elo + FROM elo_updates WHERE bot_id != ${otherBotId} GROUP BY bot_id - `).all([otherBotId]) as { bot_id: number, elo: number }[]; + ` as { bot_id: number, elo: number }[]; - const calcWeight = (elo: number) => 1 / Math.pow(Math.abs(elo - otherElo), 1.5); + const calcWeight = (elo: number) => 1 / Math.pow(Math.abs(elo - otherElo) + 1, 1.5); const weightSum = stats.reduce((tot, curr) => tot + calcWeight(curr.elo), 0); + let prefSum = 0; const random = Math.random() * weightSum; for (const { bot_id, elo } of stats) { @@ -243,49 +250,22 @@ function pickBotThatHasCloseElo(otherBotId: number): number { } } -/* - * Uncomment for debugging. -function testArenaAlgo() { - for (let i = 0; i < 1000; i++) { - let id1 = pickBotByNumberOfGamesPlayed(); - if (id1 == null) return; - - let id2 = pickBotThatHasCloseElo(id1); - if (id2 == null) return; - - if (id1 == id2) return; - - if (Math.random() > 0.5) [id1, id2] = [id2, id1]; - - const dbq = db.query(` - SELECT name, SUM(change) as elo - FROM bots - JOIN elo_updates ON elo_updates.bot_id = bots.id - WHERE bots.id = ?1 - `); - const s1 = dbq.get(id1); - const s2 = dbq.get(id2); - console.log(`Starting game between ${s1.name}(${id1}-${s1.elo?.toFixed(0)}) and ${s2.name}(${id2}-${s2.elo?.toFixed(0)})`); - } -} +async function match() { + let id1 = await pickBotByNumberOfGamesPlayed(); + if (id1 == null) return; -testArenaAlgo(); -*/ + let id2 = await pickBotThatHasCloseElo(id1); + if (id2 == null) return; -export function makeArenaIfNeeded() { - let runningGames = db.query("SELECT COUNT(*) as c FROM games WHERE ended IS NULL").get().c; - for (; runningGames < 4; runningGames++) { - let id1 = pickBotByNumberOfGamesPlayed(); - if (id1 == null) return; + if (id1 == id2) return; - let id2 = pickBotThatHasCloseElo(id1); - if (id2 == null) return; + if (Math.random() > 0.5) [id1, id2] = [id2, id1]; - if (id1 == id2) return; - - if (Math.random() > 0.5) [id1, id2] = [id2, id1]; + console.log(`${colors.gray}Starting game between ${id1} and ${id2}${colors.reset}`); + const arena = new Arena(id1, id2); + await arena.start(); +} - console.log(`Starting game between ${id1} and ${id2}`); - new Arena(id1, id2).start(); - } +while (true) { + await match(); } diff --git a/backend/db.ts b/backend/db.ts index fa6dcf3..95de44a 100644 --- a/backend/db.ts +++ b/backend/db.ts @@ -1,72 +1,68 @@ -import { Database } from "bun:sqlite"; -const db = new Database("db.sqlite"); +import postgres from "postgres"; -db.query('PRAGMA foreign_keys = ON;').run(); +const sql = postgres({ onnotice: () => { } }); -db.query(` - CREATE TABLE IF NOT EXISTS humans ( - id INTEGER PRIMARY KEY, +await sql` + CREATE TABLE IF NOT EXISTS devs ( + id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); -`).run(); +`; -db.query(` +await sql` CREATE TABLE IF NOT EXISTS bots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, code TEXT NOT NULL, - uploaded TEXT NOT NULL, + uploaded TIMESTAMPTZ NOT NULL, hash TEXT, - human_id INTEGER NOT NULL, - FOREIGN KEY(human_id) REFERENCES humans(id) ON DELETE CASCADE + dev_id INTEGER NOT NULL, + FOREIGN KEY(dev_id) REFERENCES devs(id) ON DELETE CASCADE ); -`).run(); +`; -db.query(` +await sql` CREATE TABLE IF NOT EXISTS games ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, bid INTEGER NOT NULL, wid INTEGER NOT NULL, - started TEXT, - ended TEXT, + started TIMESTAMPTZ, + ended TIMESTAMPTZ, winner VARCHAR(1), - initial_time_ms NUMBER NOT NULL, + initial_time_ms INTEGER NOT NULL, initial_position TEXT, current_position TEXT, -- used for quickly showing live games reason TEXT, FOREIGN KEY(wid) REFERENCES bots(id) ON DELETE CASCADE, FOREIGN KEY(bid) REFERENCES bots(id) ON DELETE CASCADE ); -`).run(); +`; -db.query(` +await sql` CREATE TABLE IF NOT EXISTS moves ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, game_id INTEGER NOT NULL, move TEXT NOT NULL, color TEXT NOT NULL, - time NUMBER NOT NULL, + time_ms INTEGER NOT NULL, FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE ); -`).run(); +`; -db.query("CREATE INDEX IF NOT EXISTS moves_game_id ON moves (game_id);").run(); -db.query("CREATE INDEX IF NOT EXISTS elo_game_id ON elo_updates (game_id);").run(); -db.query("CREATE INDEX IF NOT EXISTS elo_bot_id ON elo_updates (bot_id);").run(); - -db.query(` +await sql` CREATE TABLE IF NOT EXISTS elo_updates ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, game_id INTEGER, bot_id INTEGER NOT NULL, change INTEGER NOT NULL, FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE, FOREIGN KEY(bot_id) REFERENCES bots(id) ON DELETE CASCADE ); -`).run(); +`; -// Clean old games. -db.query('DELETE FROM games WHERE ended IS NULL;').run(); +await sql`CREATE INDEX IF NOT EXISTS moves_game_id ON moves (game_id);`; +await sql`CREATE INDEX IF NOT EXISTS elo_game_id ON elo_updates (game_id);`; +await sql`CREATE INDEX IF NOT EXISTS elo_bot_id ON elo_updates (bot_id);`; -export { db }; +export default sql; diff --git a/backend/index.ts b/backend/index.ts index 0e1945d..dbb5a21 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,46 +1,32 @@ -import { Chess } from "chess.js"; - import { Hono } from "hono"; import { serveStatic } from "hono/serve-static.bun"; -import { Arena, makeArenaIfNeeded } from "./arena"; import { compile } from "./compile"; -import { db } from "./db"; +import sql from "./db"; import { getElo } from "./utils"; -function addHumanIfNotExists(name: string, email: string): number { +async function addDevIfNotExists(name: string, email: string): Promise { try { - return db - .query('INSERT INTO humans (name, email) VALUES (?1, ?2) RETURNING ID') - .get([name, email]).id; + return (await sql`INSERT INTO devs (name, email) VALUES (${name}, ${email}) RETURNING ID`)[0].id } catch (err) { - return db - .query('SELECT id FROM humans WHERE email = ?1') - .get([email]).id; + return (await sql`SELECT id FROM devs WHERE email = ${email}`)[0].id; } } -async function addBotToLeague(code: string, name: string, humanId: number): Promise<{ ok: bool, msg: string }> { +async function addBotToLeague(code: string, name: string, devId: number): Promise<{ ok: boolean, msg: string }> { const res = await compile(code); if (!res.ok) { return { ok: false, msg: res.msg }; } // TODO should we make names or hashes unique? - const botId = db.query(` - INSERT INTO bots (name, code, uploaded, hash, human_id) - VALUES ($name, $code, $uploaded, $hash, $human_id) + const botId = (await sql` + INSERT INTO bots (name, code, uploaded, hash, dev_id) + VALUES (${name}, ${code}, NOW(), ${res.hash}, ${devId}) RETURNING id - `).get({ - $name: name, - $code: code, - $uploaded: new Date().toISOString(), - $hash: res.hash, - $human_id: humanId - }).id as number; + `)[0].id; - db.query(`INSERT INTO elo_updates (game_id, bot_id, change) VALUES (null, ?1, 1000)`) - .run([botId]); + await sql`INSERT INTO elo_updates (game_id, bot_id, change) VALUES (null, ${botId}, 1000)`; return { ok: true, msg: "" }; } @@ -56,11 +42,11 @@ app.use("/public/*", serveStatic({ root: "./" })); app.use("*", async (c, next) => { const begin = performance.now(); await next(); - const ms = (performance.now() - begin).toFixed(1); + const ms = (performance.now() - begin); const statusColor = c.res.status === 200 ? "\x1b[32m" : "\x1b[36m"; - const msColor = ms < 40 ? "\x1b[32m" : "\x1b[36m"; + const msColor = ms < 50 ? "\x1b[32m" : "\x1b[36m"; const reset = "\x1b[0m"; - console.log(`${new Date().toISOString()} ${msColor}${ms}ms${reset}\t${statusColor}${c.res.status}${reset} ${c.req.method} ${c.req.url}`); + console.log(`${new Date().toISOString()} ${msColor}${ms.toFixed(1)}ms${reset}\t${statusColor}${c.res.status}${reset} ${c.req.method} ${c.req.url}`); }) app.post("/api/upload/", async (c) => { @@ -77,44 +63,31 @@ app.post("/api/upload/", async (c) => { } if (typeof body.botname !== 'string') return c.text('Missing botname', 400); - if (typeof body.humanname !== 'string') return c.text('Missing humanname', 400); + if (typeof body.devname !== 'string') return c.text('Missing devname', 400); if (typeof body.email !== 'string') return c.text('Missing email', 400); - if (body.humanname.length > 30) return c.text('Human name too long'); + if (body.devname.length > 30) return c.text('Devname name too long'); if (body.botname.length > 30) return c.text('Botname too long'); - console.log("Starting upload", body.humanname, body.email); - const humanId = addHumanIfNotExists(body.humanname, body.email); - const { ok, msg } = await addBotToLeague(code, body.botname, humanId); + console.log("Starting upload", body.devname, body.email); + const devId = await addDevIfNotExists(body.devname, body.email); + const { ok, msg } = await addBotToLeague(code, body.botname, devId); // Is this abusing http status codes? Oh well... return c.text(msg, ok ? 200 : 400); }); -app.post("/fight/:wid/:bid/", async (c) => { - const { wid, bid } = c.req.param(); - - try { - const arena = new Arena(wid, bid); - arena.start(); - } catch (e) { - return c.text('', 400); - } - - return c.text('', 200); -}); - -app.get("/api/bots/", c => { - const bots = db.query(` - SELECT bots.id, name, coalesce(SUM(change), 0) AS elo FROM bots +app.get("/api/bots/", async c => { + const bots = await sql` + SELECT bots.id, name, SUM(change)::float AS elo FROM bots LEFT JOIN elo_updates ON elo_updates.bot_id = bots.id GROUP BY bots.id ORDER BY elo DESC - `).all(); + `; return c.json(bots); }); -app.get("/api/old-games/", c => { - const games = db.query(` +app.get("/api/old-games/", async c => { + const games = await sql` SELECT games.id, wid, bid, w.name as wname, b.name as bname, started, ended, winner FROM games JOIN bots AS w ON w.id = wid @@ -122,13 +95,13 @@ app.get("/api/old-games/", c => { WHERE winner IS NOT NULL ORDER BY ended DESC LIMIT 50 - `).all(); + `; return c.json(games); }); -app.get("/api/live-games/", c => { - const games = db.query(` - WITH bot_elos AS ( SELECT bot_id, coalesce(SUM(change), 0) AS elo FROM elo_updates GROUP BY bot_id ) +app.get("/api/live-games/", async c => { + const games = await sql` + WITH bot_elos AS ( SELECT bot_id, SUM(change)::float AS elo FROM elo_updates GROUP BY bot_id ) SELECT games.id, initial_position, current_position AS fen, wid, bid, w.name as wname, b.name as bname, we.elo as welo, be.elo as belo FROM games JOIN bots AS w ON w.id = wid @@ -137,50 +110,46 @@ app.get("/api/live-games/", c => { JOIN bot_elos AS be ON be.bot_id = bid WHERE winner IS NULL AND initial_position IS NOT NULL ORDER BY games.id DESC - `).all(); + `; return c.json(games); }); -app.get("/api/game/:gameId/", c => { +app.get("/api/game/:gameId/", async c => { const { gameId } = c.req.param(); - const game = db - .query(` + const res = await sql` SELECT initial_time_ms, initial_position, wid, bid, wbot.name AS wname, bbot.name AS bname, winner, reason FROM games JOIN bots AS wbot ON wbot.id = wid JOIN bots AS bbot ON bbot.id = bid - WHERE games.id = ?1 - `).get([gameId]); + WHERE games.id = ${gameId} + `; - if (game == null) return c.text('', 404); + if (res.length === 0) return c.text('', 404); - game.moves = db - .query("SELECT move, color, time FROM moves WHERE game_id = ?1 ORDER BY id") - .values([gameId]); + const game = res[0]; + game.moves = await sql`SELECT move, color, time_ms FROM moves WHERE game_id = ${gameId} ORDER BY id`; return c.json(game); }); -app.get("/api/bot/:botId/", c => { +app.get("/api/bot/:botId/", async c => { const botId = Number(c.req.param("botId")); - const bot = db.query('SELECT name, uploaded FROM bots WHERE bots.id = ?1').get([botId]); + const res = (await sql`SELECT name, uploaded FROM bots WHERE bots.id = ${botId}`); + if (res.length === 0) return c.text('', 404); - if (bot == null) return c.text('', 404); + const bot = res[0]; + bot.elo = await getElo(botId); - bot.elo = getElo(botId); - - bot.games = db - .query(` + bot.games = await sql` SELECT games.id AS id, started, bid, wid, wbot.name AS wname, bbot.name AS bname, winner, reason, change as elo_change FROM games JOIN bots AS wbot ON games.wid = wbot.id JOIN bots AS bbot ON games.bid = bbot.id - JOIN elo_updates ON game_id = games.id AND elo_updates.bot_id = ?1 - WHERE wid = ?1 OR bid = ?1 ORDER BY started DESC - `) - .all([botId]); + JOIN elo_updates ON game_id = games.id AND elo_updates.bot_id = ${botId} + WHERE wid = ${botId} OR bid = ${botId} ORDER BY ended DESC + `; for (const g of bot.games) { // Truncate the illegal move messages. @@ -195,25 +164,25 @@ app.get("/api/bot/:botId/", c => { return c.json(bot); }); -app.get("/api/humans/", c => { - const humans = db.query(` - SELECT humans.id, humans.name, MAX(b.elo) as elo FROM humans +app.get("/api/devs/", async c => { + const devs = await sql` + SELECT devs.id, devs.name, MAX(b.elo) as elo FROM devs JOIN ( - SELECT bots.id, name, coalesce(SUM(change), 0) AS elo, human_id FROM bots + SELECT bots.id, name, SUM(change)::float AS elo, dev_id FROM bots LEFT JOIN elo_updates ON elo_updates.bot_id = bots.id GROUP BY bots.id ORDER BY elo DESC - ) b ON b.human_id = humans.id - GROUP BY humans.id + ) b ON b.dev_id = devs.id + GROUP BY devs.id ORDER BY elo DESC - `).all(); + `; - return c.json(humans); + return c.json(devs); }); if (process.argv.includes('recompile')) { console.log("==== Starting recompilation of all submissions ===="); - const bots = db.query("SELECT id, name, code FROM bots").all(); + const bots = await sql`SELECT id, name, code FROM bots`; for (const bot of bots) { console.log(`Recompiling ${bot.id}:${bot.name}`); await compile(bot.code); @@ -221,8 +190,6 @@ if (process.argv.includes('recompile')) { process.exit(0); } -setInterval(makeArenaIfNeeded, 1000); - // Bundle the frontend before starting the server. for (const file of ["game.ts", "bot.ts"]) { await Bun.build({ diff --git a/backend/utils.ts b/backend/utils.ts index f139e8d..9a22e8d 100644 --- a/backend/utils.ts +++ b/backend/utils.ts @@ -1,14 +1,13 @@ import { tmpdir } from "node:os"; import { randomBytes } from "node:crypto"; -import { db } from "./db"; +import sql from "./db"; export function makeTmpDir() { const rnd = randomBytes(16).toString('base64url'); return `${tmpdir()}/chess-${rnd}`; } -export function getElo(botId: number): number { - return db - .query("SELECT coalesce(SUM(change), 0) AS elo FROM elo_updates WHERE bot_id = ?1") - .get(botId).elo; +export async function getElo(botId: number): Promise { + const res = await sql`SELECT SUM(change)::float FROM elo_updates WHERE bot_id = ${botId}`; + return res[0].sum; } diff --git a/fens.txt b/fens.txt index 7eef024..01e4068 100644 --- a/fens.txt +++ b/fens.txt @@ -1,4 +1,4 @@ -rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 +rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 r1bq1rk1/pp3ppp/2n1p3/3n4/1b1P4/2N2N2/PP2BPPP/R1BQ1RK1 w - - 0 10 rn1q1rk1/pp2b1pp/3pbn2/4p3/8/1N1BB3/PPPN1PPP/R2Q1RK1 w - - 8 11 1rbq1rk1/p3ppbp/3p1np1/2pP4/1nP5/RP3NP1/1BQNPPBP/4K2R w K - 1 13 diff --git a/frontend/game.ts b/frontend/game.ts index e85df2d..74da1ce 100644 --- a/frontend/game.ts +++ b/frontend/game.ts @@ -19,7 +19,7 @@ interface Game { bname: string, winner: "w" | "b" | "d", reason: string, - moves: [string, "w" | "b", number][], + moves: { move: string, color: "w" | "b", time_ms: number }[], }; let game: Game; @@ -32,8 +32,8 @@ infoSide.addEventListener("mouseover", ev => { const board = new Chess(game.initial_position); const times = { 'w': game.initial_time_ms, 'b': game.initial_time_ms }; for (let i = 0; i <= moveIdx; i++) { - const [move, color, time] = game.moves[i]; - times[color] -= time; + const { move, color, time_ms } = game.moves[i]; + times[color] -= time_ms; board.move(move); } document.querySelector(".white .time")!.innerText = `${(times.w / 1000).toFixed(3)}s`; @@ -51,9 +51,9 @@ fetch(`/api/game/${gameId}/`) let html = '
Start
'; for (let i = 0; i < game.moves.length; i++) { - const [move, color, time] = game.moves[i]; + const { move, color, time_ms } = game.moves[i]; html += ` -
${move}${time}ms
+
${move}${time_ms}ms
`; } infoSide.innerHTML = html; diff --git a/package.json b/package.json index 89f46ea..c70c135 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "dependencies": { "chess.js": "^1.0.0-beta.6", "chessboard-element": "^1.2.0", - "hono": "^2.2.5" + "hono": "^2.2.5", + "postgres": "^3.3.5" }, "scripts": { "start": "bun run src/index.ts" }, "module": "src/index.js" -} \ No newline at end of file +} diff --git a/public/index.html b/public/index.html index 5140757..4018a6f 100644 --- a/public/index.html +++ b/public/index.html @@ -28,7 +28,7 @@ "blead live upl"; } #head-tile { grid-area: head; } -#human-leaderboard-tile { +#dev-leaderboard-tile { grid-area: hlead; overflow: hidden; } @@ -114,7 +114,7 @@ color: #ecc39f; } -#human-list, #bot-list, #old-games { +#dev-list, #bot-list, #old-games { overflow-y: auto } @@ -277,7 +277,7 @@

Upload a new bot