Skip to content

Commit

Permalink
Replace sqlite with postgres; Make arena generation a seperate process
Browse files Browse the repository at this point in the history
  • Loading branch information
StanislavNikolov committed Aug 11, 2023
1 parent 31d04c3 commit f6012e3
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 284 deletions.
250 changes: 115 additions & 135 deletions backend/arena.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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
Expand All @@ -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';
Expand All @@ -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() {
Expand All @@ -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`);
}

Expand All @@ -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');
Expand All @@ -171,48 +177,47 @@ 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);
this.moveTimeoutId = null;
}

// 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<number> {
// 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;
Expand All @@ -223,18 +228,20 @@ function pickBotByNumberOfGamesPlayed(): number {
}
}

function pickBotThatHasCloseElo(otherBotId: number): number {
async function pickBotThatHasCloseElo(otherBotId: number): Promise<number> {
// 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) {
Expand All @@ -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();
}
Loading

0 comments on commit f6012e3

Please sign in to comment.