From 08b0f6d36cd01b912db352d6452543800a76d1a9 Mon Sep 17 00:00:00 2001 From: furo Date: Sat, 30 Oct 2021 20:26:37 +0200 Subject: [PATCH 01/28] Initial commit --- .gitignore | 125 +++++++++++++++++++++++++++++++++ api/api.js | 26 +++++++ api/finishes.js | 19 +++++ api/graph.js | 46 +++++++++++++ api/maps.js | 175 +++++++++++++++++++++++++++++++++++++++++++++++ api/players.js | 91 ++++++++++++++++++++++++ db/generate.js | 91 ++++++++++++++++++++++++ db/init.js | 26 +++++++ db/tasks.js | 143 ++++++++++++++++++++++++++++++++++++++ ddnet-links.txt | 3 + ddnss/handler.js | 102 +++++++++++++++++++++++++++ dotenv-template | 1 + index.js | 28 ++++++++ package.json | 20 ++++++ 14 files changed, 896 insertions(+) create mode 100644 .gitignore create mode 100644 api/api.js create mode 100644 api/finishes.js create mode 100644 api/graph.js create mode 100644 api/maps.js create mode 100644 api/players.js create mode 100644 db/generate.js create mode 100644 db/init.js create mode 100644 db/tasks.js create mode 100644 ddnet-links.txt create mode 100644 ddnss/handler.js create mode 100644 dotenv-template create mode 100644 index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb130e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,125 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + + +package-lock.json +pnpm-lock.yaml +*.sql* +players.msgpack +.env +ddnss/build \ No newline at end of file diff --git a/api/api.js b/api/api.js new file mode 100644 index 0000000..17fc8f5 --- /dev/null +++ b/api/api.js @@ -0,0 +1,26 @@ +import { Router } from 'express' +import playerApi from './players.js' +import mapApi from './maps.js' +import finishesApi from './finishes.js' +import graphApi from './graph.js' + +const api = Router() + +api.get( + '/', + (req, res) => res.json({ + success: true, + response: "You connected to DDStats API! :D" + }) +) + +api.use('/players', playerApi) +api.use('/maps', mapApi) +api.use('/finishes', finishesApi) +api.use('/graph', graphApi) + +/** + * This module is the entrypoint for the API. + * @module api/api + */ +export default api \ No newline at end of file diff --git a/api/finishes.js b/api/finishes.js new file mode 100644 index 0000000..98e9063 --- /dev/null +++ b/api/finishes.js @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { sqlite } from '../db/init.js' + +const finishApi = Router() + +/* TODO: precalculate this */ +finishApi.get( + '/count', + (req, res) => { + const finishes = sqlite.prepare(`SELECT COUNT(*) as count FROM race`).get() + + return res.json({ + success: true, + response: finishes.count, + }) + } +) + +export default finishApi \ No newline at end of file diff --git a/api/graph.js b/api/graph.js new file mode 100644 index 0000000..e5251c6 --- /dev/null +++ b/api/graph.js @@ -0,0 +1,46 @@ +import { Router } from 'express' +import { sqlite } from '../db/init.js' + +const graphApi = Router() + +/* TODO: precalculate this */ +graphApi.get( + '/points', + (req, res) => { + /* Check if a query was provided */ + if (!req.query.q) { + return res.json({ + success: false, + response: "No query ('host/path?q=query') provided!" + }) + } + let player = req.query.q + + const finishes = sqlite.prepare( + ` + SELECT DISTINCT(a.map), a.timestamp, b.points + FROM race AS a + INNER JOIN maps AS b + ON a.map = b.map + WHERE a.NAME = ? + AND a.map LIKE '%' + GROUP BY a.map + ORDER BY a.timestamp; + `) + + let currentPoints = 0 + let array = [] + for (const finish of finishes.iterate(player)) { + console.log(finish) + currentPoints += finish.Points + array.push({ t: new Date(finish.Timestamp), y: currentPoints }) + } + + return res.json({ + success: true, + response: array, + }) + } +) + +export default graphApi \ No newline at end of file diff --git a/api/maps.js b/api/maps.js new file mode 100644 index 0000000..1d38999 --- /dev/null +++ b/api/maps.js @@ -0,0 +1,175 @@ +import { Router } from 'express' +import { sqlite } from '../db/init.js' + +const mapApi = Router() + +mapApi.get( + '/count', + (req, res) => { + const totalMaps = sqlite.prepare(`SELECT COUNT(*) as amount FROM maps`).get() + + return res.json({ + success: true, + response: totalMaps.amount, + }) + } +) + +mapApi.get( + '/get/:map', + (req, res) => { + let map = req.params.map + + /* Check if map exists */ + const check = sqlite.prepare(`SELECT map FROM maps WHERE map = ?`).get(map) + if (!check) { + return res.json({ + success: false, + response: "No map found!", + }) + } + + const info = sqlite.prepare(`SELECT * FROM maps WHERE map = ?`).get(map) + + /* TODO: Generate a table with this as a cache */ + /* Also generate indexes */ + const avgTime = sqlite.prepare(`SELECT avg(Time) as 'averageTime' FROM race WHERE map = ?`).get(map) + const total = sqlite.prepare(`SELECT COUNT(*) as 'total' FROM race WHERE map = ?`).get(map) + const unique = sqlite.prepare(`SELECT COUNT(distinct(name)) as 'unique' FROM race WHERE map = ?`).get(map) + const teams = sqlite.prepare(`SELECT COUNT(distinct(ID)) as 'teams' FROM teamrace WHERE map = ?`).get(map) + + return res.json({ + success: true, + response: { + name: info.Map, + category: info.Server, + awardPoints: info.Points, + rating: info.Stars, + mapper: info.Mapper, + release: info.Timestamp, + /* TODO Get median time*/ + averageTime: avgTime.averageTime, + finishes: { + unique: unique.unique, + total: total.total, + teams: teams.teams, + } + } + }) + } +) + +mapApi.get( + '/getAll', + (req, res) => { + + const allMaps = sqlite.prepare( + `SELECT + map as name, + server as category, + points as awardPoints, + stars as rating, + mapper as mapper, + timestamp as release + FROM maps`).all() + + return res.json({ + success: true, + response: allMaps, + }) + } +) + +mapApi.get( + '/category/:category', + (req, res) => { + let category = req.params.category + + /* Check if category exists */ + const check = sqlite.prepare(`SELECT server FROM maps WHERE server = ? LIMIT 1`).get(category) + if (!check) { + return res.json({ + success: false, + response: "Invalid category name!", + }) + } + + const allMaps = sqlite.prepare( + `SELECT + map as name, + server as category, + points as awardPoints, + stars as rating, + mapper as mapper, + timestamp as release + FROM maps WHERE server = ?`).all(category) + + return res.json({ + success: true, + response: allMaps, + }) + } +) + +/* Searching allows you to attach sql LIKE % + * Example to search for maps beginning with Kobra + * You can do search for Kobra% + + * This thing is a complete mess, send help. BUT it does work <3 */ +mapApi.get( + '/search', + async (req, res) => { + /* Check if a query was provided */ + if (!req.query.q || !req.query.byMapper) { + return res.json({ + success: false, + response: "No query ('host/path?q=query') provided!" + }) + } + + let query = req.query.q + + /* Set defaults */ + let limit = 20 + let offset = 0 + let sortBy = "timestamp" + let order = "asc" + let column = "map" + let startsWith = "" + + if (req.query.limit) + limit = req.query.limit + + if (req.query.sortBy) + sortBy = req.query.sortBy + + if (req.query.order) + order = req.query.order + + if (req.query.byMapper) + column = "mapper" + + /* TODO: do this in one query? */ + const amount = sqlite.prepare( + `SELECT COUNT(*) as amount FROM maps WHERE ${column} LIKE "${query}"`).get() + + let pages = Math.floor(amount.amount / limit) + + if (req.query.page) + offset = (req.query.page * limit) + + const maps = sqlite.prepare( + `SELECT * FROM maps WHERE ${column} LIKE "${query}" ORDER BY ${sortBy} ${order} LIMIT ? OFFSET ?`).all(limit, offset) + + return res.json({ + success: true, + response: { + amount: amount.amount, + pages, + maps, + } + }) + } +) + +export default mapApi diff --git a/api/players.js b/api/players.js new file mode 100644 index 0000000..723caaf --- /dev/null +++ b/api/players.js @@ -0,0 +1,91 @@ +import { Router } from 'express' +import { sqlite } from '../db/init.js' + +const playerApi = Router() + + +playerApi.get( + '/get/:player', + async (req, res) => { + let player = req.params.player + + /* Misc */ + const firstFinish = sqlite.prepare(`SELECT server as server, map as map, time as time, Timestamp as date FROM race WHERE name = ? ORDER BY Timestamp ASC LIMIT 1`).get(player) + + /* TODO, make points a single table. Would be alot more efficent but longer cache creation time */ + /* Points */ + const points = sqlite.prepare(`SELECT rank, points FROM points WHERE name = ?`).get(player) + const pointsRank = sqlite.prepare(`SELECT rank, points FROM pointsRank WHERE name = ?`).get(player) + const pointsTeam = sqlite.prepare(`SELECT rank, points FROM pointsTeam WHERE name = ?`).get(player) + + const pointsThisWeek = sqlite.prepare(`SELECT rank, points FROM pointsThisWeek WHERE name = ?`).get(player) + const pointsThisMonth = sqlite.prepare(`SELECT rank, points FROM pointsThisMonth WHERE name = ?`).get(player) + + return res.json({ + success: true, + response: { + firstFinish, + + points, + pointsRank, + pointsTeam, + + pointsThisWeek, + pointsThisMonth, + } + }) + } +) + +/* Searching allows you to attach sql LIKE % + * Example to search for players beginning with Test + * You can do search for Test% + */ +playerApi.get( + '/search', + async (req, res) => { + /* Check if a query was provided */ + if (!req.query.q) { + return res.json({ + success: false, + response: "No query ('host/path?q=query') provided!" + }) + } +1 + let name = req.query.q + + /* Set defaults */ + let limit = 20 + let offset = 0 + + if (req.query.limit) { + limit = req.query.limit + } + + const amount = sqlite.prepare( + `SELECT COUNT(*) as amount FROM points + WHERE name LIKE "${name}" + `).get() + + let pages = Math.floor(amount.amount / limit) + + if (req.query.page) + offset = (req.query.page * limit) + + const players = sqlite.prepare( + `SELECT Rank, Name, Points FROM points + WHERE name LIKE "${name}" LIMIT ? OFFSET ? + `).all(limit, offset) + + return res.json({ + success: true, + response: { + amount: amount.amount, + pages: pages, + players: players, + } + }) + } +) + +export default playerApi diff --git a/db/generate.js b/db/generate.js new file mode 100644 index 0000000..c462124 --- /dev/null +++ b/db/generate.js @@ -0,0 +1,91 @@ +import { sqlite, skinDB } from "./init.js" +import tasks from "./tasks.js" + +/** + * This constructs the DB with indexes and rankings... + * @module db/generateDB + */ +export function generateDB() { + /* TODO: Clean this up as it is a mess */ + console.log("Generating race index") + + /* Generate race index TODO: Remove useless ones */ + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Map_2" ON "race" ("Map","Name")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Name" ON "race" ("Name","Timestamp")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Server" ON "race" ("Server")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_MapTimestamp" ON "race" ("Map","Timestamp")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Timestamp" ON "race" ("Timestamp")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_MapNameTime" ON "race" ("Map", "Name", "Time")`) + + /* Create rankings table */ + console.log("Creating rankings table") + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "rankings" ( + "Map" varchar(128) NOT NULL, + "Name" varchar(16) NOT NULL, + "Time" float NOT NULL DEFAULT 0, + "Timestamp" timestamp NOT NULL DEFAULT current_timestamp, + "Server" char(4) NOT NULL DEFAULT '', + "rank" INTEGER NOT NULL); + `) + + console.log("Calculating rankings for each map") + tasks.processRankings() + + /* Generate rankings index */ + console.log("Generating rankings index") + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("Map")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")`) + + /* Generate teamrace index */ + console.log("Generating teamrace index") + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_Map" ON "teamrace" ("Map")`); + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`); + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_MapID" ON "teamrace" ("Map", "ID")`); + + console.log("Creating teamrankings table") + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "teamrankings" ( + "Map" varchar(128) NOT NULL, + "ID" varbinary(16) NOT NULL, + "Name" varchar(16) NOT NULL, + "Time" float NOT NULL DEFAULT 0, + "Timestamp" timestamp NOT NULL DEFAULT current_timestamp, + "Server" char(4) NOT NULL DEFAULT '', + "teamrank" INTEGER NOT NULL); + `) + + console.log("Calculating teamrankings for each map") + tasks.processTeamRankings() + + console.log("Generating teamrankings index") + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("Map")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("name")`) + + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "points" ( + "rank" INTEGER NOT NULL, + "name" varchar(16) NOT NULL, + "points" INTEGER NOT NULL); + `) + + /* Process all types of points */ + console.log("Inserting points to DB") + tasks.processAllPoints() + + skinDB.exec(` + CREATE TABLE IF NOT EXISTS "skindata" ( + "timestamp" INTEGER NOT NULL, + "player" varchar(16) NOT NULL, + "clan" varchar(12) NOT NULL, + "flag" INTEGER NOT NULL, + "skin" varchar(16) NOT NULL, + "useColor" INTEGER NOT NULL, + "colorBodyRaw" INTEGER NOT NULL, + "colorBodyHex" varchar(8) NOT NULL, + "colorFeetRaw" INTEGER NOT NULL, + "colorFeetHex" varchar(8) NOT NULL); + `) +} diff --git a/db/init.js b/db/init.js new file mode 100644 index 0000000..0496bd2 --- /dev/null +++ b/db/init.js @@ -0,0 +1,26 @@ +import Database from 'better-sqlite3' + +/* Export DB for use in other files */ +export let sqlite = undefined +export let skinDB = undefined + +/** + * This initalizes the ddnet.sqlite and skindata.sqlite DB... + * @module db/dbInit + */ +export function dbInit() { + console.log("Starting up databases...") + + /* load in db using better-sqlite3 */ + sqlite = new Database('ddnet.sqlite', { verbose: console.log }); + skinDB = new Database('skindata.sqlite', { }); + + /* WAL mode */ + sqlite.pragma('journal_mode = WAL'); + + /* Unsafe mode */ + sqlite.unsafeMode() + + console.log("Loaded in 'ddnet.sqlite'!") + console.log("Loaded in 'skindata.sqlite'!") +} diff --git a/db/tasks.js b/db/tasks.js new file mode 100644 index 0000000..91c188e --- /dev/null +++ b/db/tasks.js @@ -0,0 +1,143 @@ +import msgpack from '@msgpack/msgpack' +import fs from 'fs' + +import { sqlite } from "./init.js" + +/** + * This module parses the msgpack provided by DDNet... + * @module db/decodeMsgpack + */ + export function decodeMsgpack() { + const data = fs.readFileSync('players.msgpack') + const decoded = msgpack.decodeMulti(data, {wrap: true}) + const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] + let final = {} + + let i = 0 + for (const part of decoded) { + final[order[i]] = part + ++i + } + return final +} + +/** + * This generates rankings for each map... + * @module db/processRankings + */ +export function processRankings() { + const maps = sqlite.prepare(`SELECT map FROM maps`); + + for (const map of maps.iterate()) { + sqlite.prepare( + ` + INSERT INTO rankings + ( + map, name, time, timestamp, rank, server + ) + SELECT map, name, time, timestamp, rank, server + FROM ( + SELECT rank() OVER w AS rank, + map, + timestamp, + NAME, + min(time) AS time, + server + FROM race + WHERE map = ? + GROUP BY NAME window w AS (ORDER BY time) ) AS a + ORDER BY rank + `).run(map.Map) + } +} + +/** + * This generates teamrankings for each map... + * @module db/processTeamRankings + */ +export function processTeamRankings() { + const maps = sqlite.prepare(`SELECT map FROM maps`); + + for (const map of maps.iterate()) { + sqlite.prepare( + ` + INSERT INTO teamrankings + ( + name, map, id, time, timestamp, server, teamrank + ) + SELECT DISTINCT(r.NAME), + r.map, r.id, r.time, r.timestamp, + Substring(n.server, 1, 3), + dense_rank() OVER w AS rank + FROM (( + SELECT DISTINCT id + FROM teamrace + WHERE map = ? + ORDER BY time) AS l + ) + LEFT JOIN teamrace AS r + ON l.id = r.id + INNER JOIN race AS n + ON r.map = n.map + AND r.NAME = n.NAME + AND r.time = n.time window w AS (ORDER BY r.time) + `).run(map.Map) + } +} + +/** + * This inserts all types of points into a table... + * @module db/processAllPoints + */ +export function processAllPoints() { + const msgpack = decodeMsgpack() + + let types = { + points: msgpack.pointsRanks, + pointsThisWeek: msgpack.pointsThisWeek, + pointsThisMonth: msgpack.pointsThisMonth, + pointsTeam: msgpack.teamRankPoints, + pointsRank: msgpack.rankPoints, + } + + /* Generate tables */ + for(const type in types) { + sqlite.exec( + ` + CREATE TABLE IF NOT EXISTS "${type}" + ( + "rank" INTEGER NOT NULL, + "name" varchar(16) NOT NULL, + "points" INTEGER NOT NULL); + `) + } + + /* Insert data */ + for(const type in types) { + let rank = 1 + + for (const entry of types[type]) { + sqlite.prepare( + ` + INSERT INTO "${type}" + ( + rank, name, points + ) VALUES (?, ?, ?)`).run( + rank, entry[0], entry[1]) + + ++rank + } + } + + /* Generate indexes */ + for(const type in types) { + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_${type}_player" ON "${type}" ("Name")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_${type}_rank" on "${type}" ("Rank")`) + } +} + +export default { + processAllPoints, + processRankings, + processTeamRankings +} \ No newline at end of file diff --git a/ddnet-links.txt b/ddnet-links.txt new file mode 100644 index 0000000..3c81ab7 --- /dev/null +++ b/ddnet-links.txt @@ -0,0 +1,3 @@ +http://ddnet.tw/players.msgpack +https://ddnet.tw/status/index.json +https://ddnet.tw/stats/ddnet.sqlite.zip \ No newline at end of file diff --git a/ddnss/handler.js b/ddnss/handler.js new file mode 100644 index 0000000..5192653 --- /dev/null +++ b/ddnss/handler.js @@ -0,0 +1,102 @@ +import fetch from 'node-fetch' +import { exec } from 'child_process' +import { skinDB } from "../db/init.js" + +export async function ddnssStart() { + const response = await fetch('https://ddnet.tw/status/index.json'); + const data = await response.json(); + + console.log(data) + + for (const servers of data) { + /* Check if server isn't empty and not full */ + if (servers.num_clients > 0 && servers.num_clients < 59) { + let server = `${servers.ip}:${servers.port}` + console.log(`Connecting: ${servers.ip}:${servers.port}`) + + /* exec ddnss */ + await scrapeServer(`${server}`) + } + else + console.log(`Server full: ${servers.ip}:${servers.port}`) + } +} + +export function scrapeServer(server) { + let command = `./ddnss/build/DDNet "ui_server_address ${server}" -f ddnss/build/config.conf` + let skinData + + return new Promise((done, failed) => { + exec(command, { encoding: 'utf8', timeout: 10000 }, (err, stdout, stderr) => { + if (err) { + err.stdout = stdout + err.stderr = stderr + } + + /* Handle error from parsing of JSON */ + try { + skinData = JSON.parse(stdout) + } catch (e) { + done() + return + } + + if (skinData === null) { + done() + return + } + + /* Get timestamp */ + let ts = (Date.now()) + + /* Insert skindata */ + for (const entry of skinData) { + skinDB.prepare(`INSERT INTO "skindata" + ( + timestamp, + player, + clan, + flag, + skin, + useColor, + colorBodyRaw, + colorBodyHex, + colorFeetRaw, + ColorFeetHex + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`). + run( + ts, + entry.player, + entry.clan, + entry.flag, + entry.skindata.skin, + entry.skindata.useColor, + entry.skindata.colorBody.raw, + entry.skindata.colorBody.hex, + entry.skindata.colorFeet.raw, + entry.skindata.colorFeet.hex, + ) + } + done() + + /* A bit hacky way of killing ddnss */ + //exec(`pkill -9 -f ddnss`) + + }) + }) +} + +/* +CREATE TABLE IF NOT EXISTS "skindata" +( + "timestamp" INTEGER NOT NULL, + "player" varchar(16) NOT NULL, + "clan" varchar(12) NOT NULL, + "flag" INTEGER NOT NULL, + "skin" varchar(16) NOT NULL, + "useColor" INTEGER NOT NULL, + "colorBodyRaw" INTEGER NOT NULL, + "colorBodyHex" varchar(8) NOT NULL, + "colorFeetRaw" INTEGER NOT NULL, + "colorFeetHex" varchar(8) NOT NULL); +*/ diff --git a/dotenv-template b/dotenv-template new file mode 100644 index 0000000..5193393 --- /dev/null +++ b/dotenv-template @@ -0,0 +1 @@ +PORT = 12345 \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..f857491 --- /dev/null +++ b/index.js @@ -0,0 +1,28 @@ +import express from 'express' +import dotenv from 'dotenv' +import api from './api/api.js' +import { generateDB } from "./db/generate.js" +import { sqlite, dbInit } from "./db/init.js" +import { ddnssStart, scrapeServer } from './ddnss/handler.js' + +/* Read the .env file */ +dotenv.config() + +/* Init db */ +dbInit() + +/* check if the table points exists */ +const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() + +/* Generate the DB if false */ +if(!exists.a) + generateDB() + + +/* Init express */ +const Server = express() +Server.use('/api', api) + +Server.listen(process.env.PORT, () => console.log(`Server started and listening on port ${process.env.PORT}.`)) + +//ddnssStart() \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..d40e9d6 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "ddstats-server-sqlite", + "version": "1.0.0", + "description": "", + "main": "index.js", + "dependencies": { + "@msgpack/msgpack": "^2.7.1", + "better-sqlite3": "^7.4.3", + "dotenv": "^10.0.0", + "express": "^4.17.1", + "mime-types": "^2.1.33", + "node-fetch": "^3.0.0" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "type": "module", + "license": "ISC" +} From dfb54ba50ef6632c74147449000bf2e8a132ea65 Mon Sep 17 00:00:00 2001 From: furo Date: Sun, 31 Oct 2021 20:46:43 +0100 Subject: [PATCH 02/28] Slight improvments --- api/graph.js | 35 ++++++++++++++++++++++++++++++++++- api/players.js | 27 ++++++++++----------------- db/tasks.js | 34 ++++++++++++++++------------------ ddnss/handler.js | 25 ++++--------------------- index.js | 2 +- 5 files changed, 65 insertions(+), 58 deletions(-) diff --git a/api/graph.js b/api/graph.js index e5251c6..e19307a 100644 --- a/api/graph.js +++ b/api/graph.js @@ -3,7 +3,6 @@ import { sqlite } from '../db/init.js' const graphApi = Router() -/* TODO: precalculate this */ graphApi.get( '/points', (req, res) => { @@ -43,4 +42,38 @@ graphApi.get( } ) +graphApi.get( + '/map', + (req, res) => { + /* Check if a query was provided */ + if (!req.query.q) { + return res.json({ + success: false, + response: "No query ('host/path?q=query') provided!" + }) + } + let map = req.query.q + + const finishes = sqlite.prepare( + ` + SELECT * FROM race WHERE map = ? ORDER BY Timestamp; + `) + let currentFinish + let currentBest = 0; + let array = [] + for (const record of finishes.iterate(map)) { + currentFinish = record.Time + if(currentFinish <= currentBest || currentBest == 0) { + currentBest = currentFinish + array.push({ player: record.Name, Time: record.Time, Date: new Date(record.Timestamp) }) + } + } + + return res.json({ + success: true, + response: array, + }) + } +) + export default graphApi \ No newline at end of file diff --git a/api/players.js b/api/players.js index 723caaf..942f656 100644 --- a/api/players.js +++ b/api/players.js @@ -9,29 +9,22 @@ playerApi.get( async (req, res) => { let player = req.params.player - /* Misc */ + /* Misc, may be worth to cache this? */ const firstFinish = sqlite.prepare(`SELECT server as server, map as map, time as time, Timestamp as date FROM race WHERE name = ? ORDER BY Timestamp ASC LIMIT 1`).get(player) - /* TODO, make points a single table. Would be alot more efficent but longer cache creation time */ /* Points */ - const points = sqlite.prepare(`SELECT rank, points FROM points WHERE name = ?`).get(player) - const pointsRank = sqlite.prepare(`SELECT rank, points FROM pointsRank WHERE name = ?`).get(player) - const pointsTeam = sqlite.prepare(`SELECT rank, points FROM pointsTeam WHERE name = ?`).get(player) - - const pointsThisWeek = sqlite.prepare(`SELECT rank, points FROM pointsThisWeek WHERE name = ?`).get(player) - const pointsThisMonth = sqlite.prepare(`SELECT rank, points FROM pointsThisMonth WHERE name = ?`).get(player) + let points = {} + const pointsData = sqlite.prepare(`SELECT type, rank, points FROM points WHERE name = ?`) + for (const pointsType of pointsData.iterate(player)) { + points[pointsType.type] = pointsType + } + return res.json({ success: true, response: { firstFinish, - points, - pointsRank, - pointsTeam, - - pointsThisWeek, - pointsThisMonth, } }) } @@ -39,7 +32,7 @@ playerApi.get( /* Searching allows you to attach sql LIKE % * Example to search for players beginning with Test - * You can do search for Test% + * You can do search for %Test */ playerApi.get( '/search', @@ -51,7 +44,7 @@ playerApi.get( response: "No query ('host/path?q=query') provided!" }) } -1 + 1 let name = req.query.q /* Set defaults */ @@ -70,7 +63,7 @@ playerApi.get( let pages = Math.floor(amount.amount / limit) if (req.query.page) - offset = (req.query.page * limit) + offset = (req.query.page * limit) const players = sqlite.prepare( `SELECT Rank, Name, Points FROM points diff --git a/db/tasks.js b/db/tasks.js index 91c188e..ef3ca8e 100644 --- a/db/tasks.js +++ b/db/tasks.js @@ -101,16 +101,15 @@ export function processAllPoints() { } /* Generate tables */ - for(const type in types) { - sqlite.exec( - ` - CREATE TABLE IF NOT EXISTS "${type}" - ( - "rank" INTEGER NOT NULL, - "name" varchar(16) NOT NULL, - "points" INTEGER NOT NULL); - `) - } + sqlite.exec( + ` + CREATE TABLE IF NOT EXISTS "points" + ( + "type" varchar(16) NOT NULL, + "rank" INTEGER NOT NULL, + "name" varchar(16) NOT NULL, + "points" INTEGER NOT NULL); + `) /* Insert data */ for(const type in types) { @@ -119,21 +118,20 @@ export function processAllPoints() { for (const entry of types[type]) { sqlite.prepare( ` - INSERT INTO "${type}" + INSERT INTO "points" ( - rank, name, points - ) VALUES (?, ?, ?)`).run( - rank, entry[0], entry[1]) + type, rank, name, points + ) VALUES (?, ?, ?, ?)`).run( + type, rank, entry[0], entry[1]) ++rank } } /* Generate indexes */ - for(const type in types) { - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_${type}_player" ON "${type}" ("Name")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_${type}_rank" on "${type}" ("Rank")`) - } + sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_points_type" ON "points" ("type")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_points_rank" on "points" ("rank")`) + sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_points_name" on "points" ("name")`) } export default { diff --git a/ddnss/handler.js b/ddnss/handler.js index 5192653..3f30ab1 100644 --- a/ddnss/handler.js +++ b/ddnss/handler.js @@ -18,8 +18,10 @@ export async function ddnssStart() { await scrapeServer(`${server}`) } else - console.log(`Server full: ${servers.ip}:${servers.port}`) + console.log(`${servers.num_clients}/63 Server full: ${servers.ip}:${servers.port}`) } + /* A bit hacky way of killing ddnss */ + exec(`pkill -9 -f ddnss`) } export function scrapeServer(server) { @@ -78,25 +80,6 @@ export function scrapeServer(server) { ) } done() - - /* A bit hacky way of killing ddnss */ - //exec(`pkill -9 -f ddnss`) - }) }) -} - -/* -CREATE TABLE IF NOT EXISTS "skindata" -( - "timestamp" INTEGER NOT NULL, - "player" varchar(16) NOT NULL, - "clan" varchar(12) NOT NULL, - "flag" INTEGER NOT NULL, - "skin" varchar(16) NOT NULL, - "useColor" INTEGER NOT NULL, - "colorBodyRaw" INTEGER NOT NULL, - "colorBodyHex" varchar(8) NOT NULL, - "colorFeetRaw" INTEGER NOT NULL, - "colorFeetHex" varchar(8) NOT NULL); -*/ +} \ No newline at end of file diff --git a/index.js b/index.js index f857491..a5e3ed1 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ import api from './api/api.js' import { generateDB } from "./db/generate.js" import { sqlite, dbInit } from "./db/init.js" import { ddnssStart, scrapeServer } from './ddnss/handler.js' +//import tasks from './db/tasks.js' /* Read the .env file */ dotenv.config() @@ -18,7 +19,6 @@ const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE typ if(!exists.a) generateDB() - /* Init express */ const Server = express() Server.use('/api', api) From 62dd9a4db0e0e9bdfc0d4eb5d6434405a11d7240 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Sun, 31 Oct 2021 21:24:55 +0100 Subject: [PATCH 03/28] Started some refactoring... --- api/finishes.js | 2 +- api/graph.js | 2 +- api/maps.js | 2 +- api/players.js | 2 +- index.js | 11 +++------ {db => libs/db}/generate.js | 40 ++++++++++++++++++-------------- libs/db/helper.js | 12 ++++++++++ {db => libs/db}/init.js | 0 {db => libs/db}/tasks.js | 0 {ddnss => libs/ddnss}/handler.js | 0 libs/utils/log.js | 13 +++++++++++ 11 files changed, 55 insertions(+), 29 deletions(-) rename {db => libs/db}/generate.js (71%) create mode 100644 libs/db/helper.js rename {db => libs/db}/init.js (100%) rename {db => libs/db}/tasks.js (100%) rename {ddnss => libs/ddnss}/handler.js (100%) create mode 100644 libs/utils/log.js diff --git a/api/finishes.js b/api/finishes.js index 98e9063..f56655e 100644 --- a/api/finishes.js +++ b/api/finishes.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../db/init.js' +import { sqlite } from '../libs/db/init.js' const finishApi = Router() diff --git a/api/graph.js b/api/graph.js index e19307a..0a00067 100644 --- a/api/graph.js +++ b/api/graph.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../db/init.js' +import { sqlite } from '../libs/db/init.js' const graphApi = Router() diff --git a/api/maps.js b/api/maps.js index 1d38999..5ec29c3 100644 --- a/api/maps.js +++ b/api/maps.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../db/init.js' +import { sqlite } from '../libs/db/init.js' const mapApi = Router() diff --git a/api/players.js b/api/players.js index 942f656..75131cc 100644 --- a/api/players.js +++ b/api/players.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../db/init.js' +import { sqlite } from '../libs/db/init.js' const playerApi = Router() diff --git a/index.js b/index.js index a5e3ed1..59388e6 100644 --- a/index.js +++ b/index.js @@ -1,25 +1,20 @@ import express from 'express' import dotenv from 'dotenv' import api from './api/api.js' -import { generateDB } from "./db/generate.js" -import { sqlite, dbInit } from "./db/init.js" -import { ddnssStart, scrapeServer } from './ddnss/handler.js' +import { generateDB } from "./libs/db/generate.js" +import { sqlite, dbInit } from "./libs/db/init.js" +import { ddnssStart, scrapeServer } from './libs/ddnss/handler.js' //import tasks from './db/tasks.js' -/* Read the .env file */ dotenv.config() -/* Init db */ dbInit() -/* check if the table points exists */ const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() -/* Generate the DB if false */ if(!exists.a) generateDB() -/* Init express */ const Server = express() Server.use('/api', api) diff --git a/db/generate.js b/libs/db/generate.js similarity index 71% rename from db/generate.js rename to libs/db/generate.js index c462124..d147867 100644 --- a/db/generate.js +++ b/libs/db/generate.js @@ -1,5 +1,9 @@ -import { sqlite, skinDB } from "./init.js" -import tasks from "./tasks.js" +import { sqlite, skinDB } from './init.js' +import tasks from './tasks.js' +import { execMany } from './helper.js' +import initLog from '../utils/log.js' + +const log = initLog("DB Generation") /** * This constructs the DB with indexes and rankings... @@ -7,18 +11,20 @@ import tasks from "./tasks.js" */ export function generateDB() { /* TODO: Clean this up as it is a mess */ - console.log("Generating race index") + log("Generating race index...") /* Generate race index TODO: Remove useless ones */ - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Map_2" ON "race" ("Map","Name")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Name" ON "race" ("Name","Timestamp")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Server" ON "race" ("Server")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_MapTimestamp" ON "race" ("Map","Timestamp")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_Timestamp" ON "race" ("Timestamp")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_race_MapNameTime" ON "race" ("Map", "Name", "Time")`) + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_race_Map_2" ON "race" ("Map","Name")`, + `CREATE INDEX IF NOT EXISTS "idx_race_Name" ON "race" ("Name","Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_Server" ON "race" ("Server")`, + `CREATE INDEX IF NOT EXISTS "idx_race_MapTimestamp" ON "race" ("Map","Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_Timestamp" ON "race" ("Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_MapNameTime" ON "race" ("Map", "Name", "Time")` + ]) /* Create rankings table */ - console.log("Creating rankings table") + log("Creating rankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "rankings" ( "Map" varchar(128) NOT NULL, @@ -29,22 +35,22 @@ export function generateDB() { "rank" INTEGER NOT NULL); `) - console.log("Calculating rankings for each map") + log("Calculating rankings for each map...") tasks.processRankings() /* Generate rankings index */ - console.log("Generating rankings index") + log("Generating rankings index...") sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("Map")`) sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`) sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")`) /* Generate teamrace index */ - console.log("Generating teamrace index") + log("Generating teamrace index...") sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_Map" ON "teamrace" ("Map")`); sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`); sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_MapID" ON "teamrace" ("Map", "ID")`); - console.log("Creating teamrankings table") + log("Creating teamrankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "teamrankings" ( "Map" varchar(128) NOT NULL, @@ -56,10 +62,10 @@ export function generateDB() { "teamrank" INTEGER NOT NULL); `) - console.log("Calculating teamrankings for each map") + log("Calculating teamrankings for each map...") tasks.processTeamRankings() - console.log("Generating teamrankings index") + log("Generating teamrankings index...") sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("Map")`) sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`) sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("name")`) @@ -72,7 +78,7 @@ export function generateDB() { `) /* Process all types of points */ - console.log("Inserting points to DB") + log("Inserting points to DB...") tasks.processAllPoints() skinDB.exec(` diff --git a/libs/db/helper.js b/libs/db/helper.js new file mode 100644 index 0000000..8382380 --- /dev/null +++ b/libs/db/helper.js @@ -0,0 +1,12 @@ +import { sqlite } from './init.js' + +/** + * This function takes an array of strings to be ran on the DB. + * + * @param {array} instructions Array of instructions to be ran. + * @author BurnyLlama + */ +export function execMany(instructions) { + for (const instruction of instructions) + sqlite.exec(instruction) +} \ No newline at end of file diff --git a/db/init.js b/libs/db/init.js similarity index 100% rename from db/init.js rename to libs/db/init.js diff --git a/db/tasks.js b/libs/db/tasks.js similarity index 100% rename from db/tasks.js rename to libs/db/tasks.js diff --git a/ddnss/handler.js b/libs/ddnss/handler.js similarity index 100% rename from ddnss/handler.js rename to libs/ddnss/handler.js diff --git a/libs/utils/log.js b/libs/utils/log.js new file mode 100644 index 0000000..88647a8 --- /dev/null +++ b/libs/utils/log.js @@ -0,0 +1,13 @@ +/** + * This function creates a custom logging method that adds a prefix evrytime used. + * This is so that you can see what component has done what. + * Example: + * The database-component would log with the prefix 'database' + * + * @param {string} prefix The prefix for the logging-function. + * @returns {function} The created log function. + */ + +export default function initLog(prefix) { + return string => console.log(`${prefix} >>> ${string}`) +} From bdd0b44561695d80872a38d5ca8808518ec63f23 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Sun, 31 Oct 2021 23:43:36 +0100 Subject: [PATCH 04/28] Major refactor... ^^ --- api/finishes.js | 2 +- api/graph.js | 2 +- api/maps.js | 2 +- api/players.js | 2 +- index.js | 4 +- libs/{db => database}/generate.js | 31 +++--- libs/{db => database}/helper.js | 2 +- libs/{db => database}/init.js | 0 libs/database/tasks.js | 150 ++++++++++++++++++++++++++++++ libs/db/tasks.js | 141 ---------------------------- libs/ddnss/handler.js | 124 ++++++++++++------------ 11 files changed, 232 insertions(+), 228 deletions(-) rename libs/{db => database}/generate.js (73%) rename libs/{db => database}/helper.js (79%) rename libs/{db => database}/init.js (100%) create mode 100644 libs/database/tasks.js delete mode 100644 libs/db/tasks.js diff --git a/api/finishes.js b/api/finishes.js index f56655e..1fd68f6 100644 --- a/api/finishes.js +++ b/api/finishes.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../libs/db/init.js' +import { sqlite } from '../libs/database/init.js' const finishApi = Router() diff --git a/api/graph.js b/api/graph.js index 0a00067..c934aaf 100644 --- a/api/graph.js +++ b/api/graph.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../libs/db/init.js' +import { sqlite } from '../libs/database/init.js' const graphApi = Router() diff --git a/api/maps.js b/api/maps.js index 5ec29c3..d80d193 100644 --- a/api/maps.js +++ b/api/maps.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../libs/db/init.js' +import { sqlite } from '../libs/database/init.js' const mapApi = Router() diff --git a/api/players.js b/api/players.js index 75131cc..b8e82aa 100644 --- a/api/players.js +++ b/api/players.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sqlite } from '../libs/db/init.js' +import { sqlite } from '../libs/database/init.js' const playerApi = Router() diff --git a/index.js b/index.js index 59388e6..eb79dd5 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,8 @@ import express from 'express' import dotenv from 'dotenv' import api from './api/api.js' -import { generateDB } from "./libs/db/generate.js" -import { sqlite, dbInit } from "./libs/db/init.js" +import { generateDB } from "./libs/database/generate.js" +import { sqlite, dbInit } from "./libs/database/init.js" import { ddnssStart, scrapeServer } from './libs/ddnss/handler.js' //import tasks from './db/tasks.js' diff --git a/libs/db/generate.js b/libs/database/generate.js similarity index 73% rename from libs/db/generate.js rename to libs/database/generate.js index d147867..10fa71e 100644 --- a/libs/db/generate.js +++ b/libs/database/generate.js @@ -11,9 +11,8 @@ const log = initLog("DB Generation") */ export function generateDB() { /* TODO: Clean this up as it is a mess */ + /* TODO: Remove useless ones */ log("Generating race index...") - - /* Generate race index TODO: Remove useless ones */ execMany([ `CREATE INDEX IF NOT EXISTS "idx_race_Map_2" ON "race" ("Map","Name")`, `CREATE INDEX IF NOT EXISTS "idx_race_Name" ON "race" ("Name","Timestamp")`, @@ -23,7 +22,6 @@ export function generateDB() { `CREATE INDEX IF NOT EXISTS "idx_race_MapNameTime" ON "race" ("Map", "Name", "Time")` ]) - /* Create rankings table */ log("Creating rankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "rankings" ( @@ -38,17 +36,19 @@ export function generateDB() { log("Calculating rankings for each map...") tasks.processRankings() - /* Generate rankings index */ log("Generating rankings index...") - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("Map")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")`) + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`, + `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")` + ]) - /* Generate teamrace index */ log("Generating teamrace index...") - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_Map" ON "teamrace" ("Map")`); - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`); - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrace_MapID" ON "teamrace" ("Map", "ID")`); + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_teamrace_Map" ON "teamrace" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrace_MapID" ON "teamrace" ("Map", "ID")` + ]) log("Creating teamrankings table...") sqlite.exec(` @@ -66,9 +66,11 @@ export function generateDB() { tasks.processTeamRankings() log("Generating teamrankings index...") - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("Map")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("name")`) + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("name")` + ]) sqlite.exec(` CREATE TABLE IF NOT EXISTS "points" ( @@ -77,7 +79,6 @@ export function generateDB() { "points" INTEGER NOT NULL); `) - /* Process all types of points */ log("Inserting points to DB...") tasks.processAllPoints() diff --git a/libs/db/helper.js b/libs/database/helper.js similarity index 79% rename from libs/db/helper.js rename to libs/database/helper.js index 8382380..1565b68 100644 --- a/libs/db/helper.js +++ b/libs/database/helper.js @@ -3,7 +3,7 @@ import { sqlite } from './init.js' /** * This function takes an array of strings to be ran on the DB. * - * @param {array} instructions Array of instructions to be ran. + * @param {[string]} instructions Array of instructions to be ran. * @author BurnyLlama */ export function execMany(instructions) { diff --git a/libs/db/init.js b/libs/database/init.js similarity index 100% rename from libs/db/init.js rename to libs/database/init.js diff --git a/libs/database/tasks.js b/libs/database/tasks.js new file mode 100644 index 0000000..2d81072 --- /dev/null +++ b/libs/database/tasks.js @@ -0,0 +1,150 @@ +import msgpack from '@msgpack/msgpack' +import fs from 'fs' +import { execMany } from './helper.js' + +import { sqlite } from './init.js' + +/** + * This module parses the msgpack provided by DDNet... + * @module db/decodeMsgpack + */ +export function decodeMsgpack() { + const data = fs.readFileSync('players.msgpack') + const decoded = msgpack.decodeMulti(data, { wrap: true }) + const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] + let final = {} + + let i = 0 + for (const part of decoded) { + final[order[i]] = part + ++i + } + return final +} + +/** + * This generates rankings for each map... + * @module db/processRankings + */ +export function processRankings() { + const maps = sqlite.prepare(`SELECT map FROM maps`); + + for (const map of maps.iterate()) + sqlite + .prepare(` + INSERT INTO rankings + ( + map, name, time, timestamp, rank, server + ) + SELECT map, name, time, timestamp, rank, server + FROM ( + SELECT rank() OVER w AS rank, + map, + timestamp, + NAME, + min(time) AS time, + server + FROM race + WHERE map = ? + GROUP BY NAME window w AS (ORDER BY time) ) AS a + ORDER BY rank + `) + .run(map.Map) +} + +/** + * This generates teamrankings for each map... + * @module db/processTeamRankings + */ +export function processTeamRankings() { + const maps = sqlite.prepare(`SELECT map FROM maps`); + + for (const map of maps.iterate()) + sqlite + .prepare(` + INSERT INTO teamrankings + ( + name, map, id, time, timestamp, server, teamrank + ) + SELECT DISTINCT(r.NAME), + r.map, r.id, r.time, r.timestamp, + Substring(n.server, 1, 3), + dense_rank() OVER w AS rank + FROM (( + SELECT DISTINCT id + FROM teamrace + WHERE map = ? + ORDER BY time) AS l + ) + LEFT JOIN teamrace AS r + ON l.id = r.id + INNER JOIN race AS n + ON r.map = n.map + AND r.NAME = n.NAME + AND r.time = n.time window w AS (ORDER BY r.time) + `) + .run(map.Map) +} + +/** + * This inserts all types of points into a table... + * @module db/processAllPoints + */ +export function processAllPoints() { + const msgpack = decodeMsgpack() + + const types = { + points: msgpack.pointsRanks, + pointsThisWeek: msgpack.pointsThisWeek, + pointsThisMonth: msgpack.pointsThisMonth, + pointsTeam: msgpack.teamRankPoints, + pointsRank: msgpack.rankPoints, + } + + /* Generate tables */ + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "points" + ( + "type" varchar(16) NOT NULL, + "rank" INTEGER NOT NULL, + "name" varchar(16) NOT NULL, + "points" INTEGER NOT NULL + ); + `) + + /* Insert data */ + for(const type in types) { + let rank = 1 + + for (const entry of types[type]) { + sqlite + .prepare(` + INSERT INTO "points" + ( + type, rank, name, points + ) VALUES (?, ?, ?, ?) + `) + .run( + type, + rank, + entry[0], + entry[1] + ) + + ++rank + } + } + + /* Generate indexes */ + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_points_type" ON "points" ("type")`, + `CREATE INDEX IF NOT EXISTS "Idx_points_rank" on "points" ("rank")`, + `CREATE INDEX IF NOT EXISTS "Idx_points_name" on "points" ("name")` + ]) +} + +export default { + processAllPoints, + processRankings, + processTeamRankings +} \ No newline at end of file diff --git a/libs/db/tasks.js b/libs/db/tasks.js deleted file mode 100644 index ef3ca8e..0000000 --- a/libs/db/tasks.js +++ /dev/null @@ -1,141 +0,0 @@ -import msgpack from '@msgpack/msgpack' -import fs from 'fs' - -import { sqlite } from "./init.js" - -/** - * This module parses the msgpack provided by DDNet... - * @module db/decodeMsgpack - */ - export function decodeMsgpack() { - const data = fs.readFileSync('players.msgpack') - const decoded = msgpack.decodeMulti(data, {wrap: true}) - const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] - let final = {} - - let i = 0 - for (const part of decoded) { - final[order[i]] = part - ++i - } - return final -} - -/** - * This generates rankings for each map... - * @module db/processRankings - */ -export function processRankings() { - const maps = sqlite.prepare(`SELECT map FROM maps`); - - for (const map of maps.iterate()) { - sqlite.prepare( - ` - INSERT INTO rankings - ( - map, name, time, timestamp, rank, server - ) - SELECT map, name, time, timestamp, rank, server - FROM ( - SELECT rank() OVER w AS rank, - map, - timestamp, - NAME, - min(time) AS time, - server - FROM race - WHERE map = ? - GROUP BY NAME window w AS (ORDER BY time) ) AS a - ORDER BY rank - `).run(map.Map) - } -} - -/** - * This generates teamrankings for each map... - * @module db/processTeamRankings - */ -export function processTeamRankings() { - const maps = sqlite.prepare(`SELECT map FROM maps`); - - for (const map of maps.iterate()) { - sqlite.prepare( - ` - INSERT INTO teamrankings - ( - name, map, id, time, timestamp, server, teamrank - ) - SELECT DISTINCT(r.NAME), - r.map, r.id, r.time, r.timestamp, - Substring(n.server, 1, 3), - dense_rank() OVER w AS rank - FROM (( - SELECT DISTINCT id - FROM teamrace - WHERE map = ? - ORDER BY time) AS l - ) - LEFT JOIN teamrace AS r - ON l.id = r.id - INNER JOIN race AS n - ON r.map = n.map - AND r.NAME = n.NAME - AND r.time = n.time window w AS (ORDER BY r.time) - `).run(map.Map) - } -} - -/** - * This inserts all types of points into a table... - * @module db/processAllPoints - */ -export function processAllPoints() { - const msgpack = decodeMsgpack() - - let types = { - points: msgpack.pointsRanks, - pointsThisWeek: msgpack.pointsThisWeek, - pointsThisMonth: msgpack.pointsThisMonth, - pointsTeam: msgpack.teamRankPoints, - pointsRank: msgpack.rankPoints, - } - - /* Generate tables */ - sqlite.exec( - ` - CREATE TABLE IF NOT EXISTS "points" - ( - "type" varchar(16) NOT NULL, - "rank" INTEGER NOT NULL, - "name" varchar(16) NOT NULL, - "points" INTEGER NOT NULL); - `) - - /* Insert data */ - for(const type in types) { - let rank = 1 - - for (const entry of types[type]) { - sqlite.prepare( - ` - INSERT INTO "points" - ( - type, rank, name, points - ) VALUES (?, ?, ?, ?)`).run( - type, rank, entry[0], entry[1]) - - ++rank - } - } - - /* Generate indexes */ - sqlite.exec(`CREATE INDEX IF NOT EXISTS "idx_points_type" ON "points" ("type")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_points_rank" on "points" ("rank")`) - sqlite.exec(`CREATE INDEX IF NOT EXISTS "Idx_points_name" on "points" ("name")`) -} - -export default { - processAllPoints, - processRankings, - processTeamRankings -} \ No newline at end of file diff --git a/libs/ddnss/handler.js b/libs/ddnss/handler.js index 3f30ab1..939efba 100644 --- a/libs/ddnss/handler.js +++ b/libs/ddnss/handler.js @@ -1,85 +1,79 @@ import fetch from 'node-fetch' import { exec } from 'child_process' -import { skinDB } from "../db/init.js" +import { skinDB } from '../database/init.js' +import initLog from '../utils/log.js' + +const log = initLog("DDNSS") export async function ddnssStart() { - const response = await fetch('https://ddnet.tw/status/index.json'); - const data = await response.json(); + const getServers = await fetch('https://ddnet.tw/status/index.json'); + const servers = await getServers.json(); - console.log(data) + console.log(servers) - for (const servers of data) { - /* Check if server isn't empty and not full */ - if (servers.num_clients > 0 && servers.num_clients < 59) { - let server = `${servers.ip}:${servers.port}` - console.log(`Connecting: ${servers.ip}:${servers.port}`) + for (const server of servers) { + const connection = `${server.ip}:${server.port}` - /* exec ddnss */ - await scrapeServer(`${server}`) - } - else - console.log(`${servers.num_clients}/63 Server full: ${servers.ip}:${servers.port}`) + if (!(server.num_clients > 0 && server.num_clients < (server.max_clients - 2))) + return log(`Server (essentially) full! >> ${server.ip}:${server.port} -> ${server.num_clients}/${server.max_clients} clients`) + + log(`Connecting to server >> ${connection}`) + await scrapeServer(`${connection}`) } - /* A bit hacky way of killing ddnss */ + exec(`pkill -9 -f ddnss`) } -export function scrapeServer(server) { - let command = `./ddnss/build/DDNet "ui_server_address ${server}" -f ddnss/build/config.conf` - let skinData +function scrapeServer(server) { - return new Promise((done, failed) => { - exec(command, { encoding: 'utf8', timeout: 10000 }, (err, stdout, stderr) => { - if (err) { - err.stdout = stdout - err.stderr = stderr - } + const command = `./ddnss/build/DDNet "ui_server_address ${server}" -f ddnss/build/config.conf` - /* Handle error from parsing of JSON */ + return new Promise((resolve, reject) => { + exec(command, { encoding: 'utf8' }, (err, stdout, stderr) => { try { - skinData = JSON.parse(stdout) + const skinData = JSON.parse(stdout) + + if (skinData === null) { + resolve() + return + } + + const currentTime = Date.now() + + for (const entry of skinData) + skinDB + .prepare(` + INSERT INTO "skindata" + ( + timestamp, + player, + clan, + flag, + skin, + useColor, + colorBodyRaw, + colorBodyHex, + colorFeetRaw, + ColorFeetHex + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + currentTime, + entry.player, + entry.clan, + entry.flag, + entry.skindata.skin, + entry.skindata.useColor, + entry.skindata.colorBody.raw, + entry.skindata.colorBody.hex, + entry.skindata.colorFeet.raw, + entry.skindata.colorFeet.hex, + ) } catch (e) { - done() - return + log(`Failed to handle ${server}!`) } - if (skinData === null) { - done() - return - } - - /* Get timestamp */ - let ts = (Date.now()) - - /* Insert skindata */ - for (const entry of skinData) { - skinDB.prepare(`INSERT INTO "skindata" - ( - timestamp, - player, - clan, - flag, - skin, - useColor, - colorBodyRaw, - colorBodyHex, - colorFeetRaw, - ColorFeetHex - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`). - run( - ts, - entry.player, - entry.clan, - entry.flag, - entry.skindata.skin, - entry.skindata.useColor, - entry.skindata.colorBody.raw, - entry.skindata.colorBody.hex, - entry.skindata.colorFeet.raw, - entry.skindata.colorFeet.hex, - ) - } - done() + resolve() }) }) } \ No newline at end of file From fcc6a19903121347b8c40687d5751d3da97e4b54 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Sun, 31 Oct 2021 23:55:42 +0100 Subject: [PATCH 05/28] Small fix. --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index eb79dd5..a6c61fb 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,7 @@ import dotenv from 'dotenv' import api from './api/api.js' import { generateDB } from "./libs/database/generate.js" import { sqlite, dbInit } from "./libs/database/init.js" -import { ddnssStart, scrapeServer } from './libs/ddnss/handler.js' +import { ddnssStart } from './libs/ddnss/handler.js' //import tasks from './db/tasks.js' dotenv.config() From b3548cfee7fab42ddf81aa5720cad8cb9fcd7d6d Mon Sep 17 00:00:00 2001 From: furo Date: Mon, 1 Nov 2021 01:05:37 +0100 Subject: [PATCH 06/28] I'm unsure what I'm doing... --- .gitignore | 2 +- libs/ddnss/handler.js | 62 ++++++++++++++++++++++++------------------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index bb130e1..7603cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -122,4 +122,4 @@ pnpm-lock.yaml *.sql* players.msgpack .env -ddnss/build \ No newline at end of file +ddnss/ \ No newline at end of file diff --git a/libs/ddnss/handler.js b/libs/ddnss/handler.js index 939efba..7f2a6ce 100644 --- a/libs/ddnss/handler.js +++ b/libs/ddnss/handler.js @@ -14,8 +14,14 @@ export async function ddnssStart() { for (const server of servers) { const connection = `${server.ip}:${server.port}` - if (!(server.num_clients > 0 && server.num_clients < (server.max_clients - 2))) - return log(`Server (essentially) full! >> ${server.ip}:${server.port} -> ${server.num_clients}/${server.max_clients} clients`) + if (!(server.num_clients > 0 && server.num_clients < (server.max_clients - 2))) { + log(`Server (essentially) full! >> ${connection} -> ${server.num_clients}/${server.max_clients} clients`) + continue + } + if(server.password) { + log(`Server is locked >> ${connection}`) + continue + } log(`Connecting to server >> ${connection}`) await scrapeServer(`${connection}`) @@ -39,36 +45,36 @@ function scrapeServer(server) { } const currentTime = Date.now() - + // TODO: Store statement once and reuse same statment. (whatever that means) for (const entry of skinData) - skinDB - .prepare(` + skinDB.prepare(` INSERT INTO "skindata" ( - timestamp, - player, - clan, - flag, - skin, - useColor, - colorBodyRaw, - colorBodyHex, - colorFeetRaw, - ColorFeetHex - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + $timestamp, + $player, + $clan, + $flag, + $skin, + $useColor, + $colorBodyRaw, + $colorBodyHex, + $colorFeetRaw, + $ColorFeetHex + ) `) - .run( - currentTime, - entry.player, - entry.clan, - entry.flag, - entry.skindata.skin, - entry.skindata.useColor, - entry.skindata.colorBody.raw, - entry.skindata.colorBody.hex, - entry.skindata.colorFeet.raw, - entry.skindata.colorFeet.hex, - ) + .run({ + timestamp: currentTime, + player: entry.player, + clan: entry.clan, + flag: entry.flag, + skin: entry.skindata.skin, + useColor: entry.skindata.useColor, + colorBodyRaw: entry.skindata.colorBody.raw, + colorBodyHex: entry.skindata.colorBody.hex, + colorFeetRaw: entry.skindata.colorFeet.raw, + colorFeetHex: entry.skindata.colorFeet.hex, + }) + } catch (e) { log(`Failed to handle ${server}!`) } From c4df7a735a6a48a3828d1022853dfec4892422de Mon Sep 17 00:00:00 2001 From: furo Date: Mon, 1 Nov 2021 15:09:03 +0100 Subject: [PATCH 07/28] Some refactoring and cache generation for graphs --- api/graph.js | 20 +++---------------- api/players.js | 2 +- index.js | 2 +- libs/database/generate.js | 39 ++++++++++++++++++++++++++++-------- libs/database/init.js | 7 +++++-- libs/database/tasks.js | 42 +++++++++++++++++++++++++++++++++++++-- libs/ddnss/handler.js | 2 -- libs/utils/log.js | 2 +- 8 files changed, 82 insertions(+), 34 deletions(-) diff --git a/api/graph.js b/api/graph.js index c934aaf..9f8ea80 100644 --- a/api/graph.js +++ b/api/graph.js @@ -22,15 +22,12 @@ graphApi.get( INNER JOIN maps AS b ON a.map = b.map WHERE a.NAME = ? - AND a.map LIKE '%' GROUP BY a.map ORDER BY a.timestamp; `) - let currentPoints = 0 let array = [] for (const finish of finishes.iterate(player)) { - console.log(finish) currentPoints += finish.Points array.push({ t: new Date(finish.Timestamp), y: currentPoints }) } @@ -52,22 +49,11 @@ graphApi.get( response: "No query ('host/path?q=query') provided!" }) } - let map = req.query.q + const finishes = sqlite.prepare(`SELECT * FROM graphRecordCache WHERE map = ? ORDER BY Timestamp`) - const finishes = sqlite.prepare( - ` - SELECT * FROM race WHERE map = ? ORDER BY Timestamp; - `) - let currentFinish - let currentBest = 0; let array = [] - for (const record of finishes.iterate(map)) { - currentFinish = record.Time - if(currentFinish <= currentBest || currentBest == 0) { - currentBest = currentFinish - array.push({ player: record.Name, Time: record.Time, Date: new Date(record.Timestamp) }) - } - } + for (const record of finishes.iterate(req.query.q)) + array.push({ t: new Date(record.timestamp), y: record.time, player: record.player}) return res.json({ success: true, diff --git a/api/players.js b/api/players.js index b8e82aa..3dd337a 100644 --- a/api/players.js +++ b/api/players.js @@ -9,7 +9,7 @@ playerApi.get( async (req, res) => { let player = req.params.player - /* Misc, may be worth to cache this? */ + /* Misc */ const firstFinish = sqlite.prepare(`SELECT server as server, map as map, time as time, Timestamp as date FROM race WHERE name = ? ORDER BY Timestamp ASC LIMIT 1`).get(player) /* Points */ diff --git a/index.js b/index.js index a6c61fb..38869b3 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import api from './api/api.js' import { generateDB } from "./libs/database/generate.js" import { sqlite, dbInit } from "./libs/database/init.js" import { ddnssStart } from './libs/ddnss/handler.js' -//import tasks from './db/tasks.js' +import tasks from './libs/database/tasks.js' dotenv.config() diff --git a/libs/database/generate.js b/libs/database/generate.js index 10fa71e..f629367 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -12,14 +12,21 @@ const log = initLog("DB Generation") export function generateDB() { /* TODO: Clean this up as it is a mess */ /* TODO: Remove useless ones */ + + log("Generating map index...") + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_maps_map" ON "maps" ("map")`, + `CREATE INDEX IF NOT EXISTS "idx_maps_category" ON "maps" ("server");` + ]) + log("Generating race index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_race_Map_2" ON "race" ("Map","Name")`, - `CREATE INDEX IF NOT EXISTS "idx_race_Name" ON "race" ("Name","Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_Server" ON "race" ("Server")`, - `CREATE INDEX IF NOT EXISTS "idx_race_MapTimestamp" ON "race" ("Map","Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_Timestamp" ON "race" ("Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_MapNameTime" ON "race" ("Map", "Name", "Time")` + `CREATE INDEX IF NOT EXISTS "idx_race_player" ON "race" ("Name")`, + `CREATE INDEX IF NOT EXISTS "idx_race_name" ON "race" ("Name","Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_server" ON "race" ("Server")`, + `CREATE INDEX IF NOT EXISTS "idx_race_mapTimestamp" ON "race" ("Map","Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_timestamp" ON "race" ("Timestamp")`, + `CREATE INDEX IF NOT EXISTS "idx_race_mapNameTime" ON "race" ("Map", "Name", "Time")` ]) log("Creating rankings table...") @@ -45,9 +52,9 @@ export function generateDB() { log("Generating teamrace index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_teamrace_Map" ON "teamrace" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrace_map" ON "teamrace" ("Map")`, `CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`, - `CREATE INDEX IF NOT EXISTS "idx_teamrace_MapID" ON "teamrace" ("Map", "ID")` + `CREATE INDEX IF NOT EXISTS "idx_teamrace_mapID" ON "teamrace" ("Map", "ID")` ]) log("Creating teamrankings table...") @@ -79,6 +86,22 @@ export function generateDB() { "points" INTEGER NOT NULL); `) + log("Generating graphRecordCache...") + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "graphRecordCache" ( + "map" varchar(128) NOT NULL, + "player" varchar(16) NOT NULL, + "time" float NOT NULL DEFAULT 0, + "timestamp" timestamp NOT NULL DEFAULT current_timestamp, + "Server" char(4) NOT NULL DEFAULT ''); + `) + tasks.processTimeGraph() + + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_graphCache_player" ON "graphRecordCache" ("player")`, + `CREATE INDEX IF NOT EXISTS "idx_graphCache_map" ON "graphRecordCache" ("map");` + ]) + log("Inserting points to DB...") tasks.processAllPoints() diff --git a/libs/database/init.js b/libs/database/init.js index 0496bd2..7aa1573 100644 --- a/libs/database/init.js +++ b/libs/database/init.js @@ -1,9 +1,12 @@ import Database from 'better-sqlite3' +import initLog from '../utils/log.js' /* Export DB for use in other files */ export let sqlite = undefined export let skinDB = undefined +const log = initLog("DB Init") + /** * This initalizes the ddnet.sqlite and skindata.sqlite DB... * @module db/dbInit @@ -21,6 +24,6 @@ export function dbInit() { /* Unsafe mode */ sqlite.unsafeMode() - console.log("Loaded in 'ddnet.sqlite'!") - console.log("Loaded in 'skindata.sqlite'!") + log("Loaded in ddnet.sqlite...") + log("Loaded in skindata.sqlite...") } diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 2d81072..97a47fd 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -86,6 +86,43 @@ export function processTeamRankings() { .run(map.Map) } +/** + * This generates a cache for all the dates the top record has been improved for each map... + * @module libs/database/processTimeGraph + */ +export function processTimeGraph() { + + let currentFinish + let currentBest = 0; + + const maps = sqlite.prepare(`SELECT map FROM maps`); + const finishes = sqlite.prepare(`SELECT * FROM race WHERE map = ? ORDER BY Timestamp`) + for (const map of maps.iterate()) { + let currentFinish + let currentBest = 0; + + for (const record of finishes.iterate(map.Map)) { + currentFinish = record.Time + if (currentFinish <= currentBest || currentBest == 0) { + currentBest = currentFinish + + sqlite.prepare(` + INSERT INTO "graphRecordCache" + ( + map, player, time, timestamp, server + ) VALUES (?, ?, ?, ?, ?) + `).run( + map.Map, + record.Name, + record.Time, + record.Timestamp, + record.Server + ) + } + } + } +} + /** * This inserts all types of points into a table... * @module db/processAllPoints @@ -113,7 +150,7 @@ export function processAllPoints() { `) /* Insert data */ - for(const type in types) { + for (const type in types) { let rank = 1 for (const entry of types[type]) { @@ -146,5 +183,6 @@ export function processAllPoints() { export default { processAllPoints, processRankings, - processTeamRankings + processTeamRankings, + processTimeGraph } \ No newline at end of file diff --git a/libs/ddnss/handler.js b/libs/ddnss/handler.js index 7f2a6ce..5e215aa 100644 --- a/libs/ddnss/handler.js +++ b/libs/ddnss/handler.js @@ -9,8 +9,6 @@ export async function ddnssStart() { const getServers = await fetch('https://ddnet.tw/status/index.json'); const servers = await getServers.json(); - console.log(servers) - for (const server of servers) { const connection = `${server.ip}:${server.port}` diff --git a/libs/utils/log.js b/libs/utils/log.js index 88647a8..238e009 100644 --- a/libs/utils/log.js +++ b/libs/utils/log.js @@ -1,5 +1,5 @@ /** - * This function creates a custom logging method that adds a prefix evrytime used. + * This function creates a custom logging method that adds a prefix everytime used. * This is so that you can see what component has done what. * Example: * The database-component would log with the prefix 'database' From 0cbd14018e6df9dc3db1e4f6e7587535964c1acc Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 18:12:46 +0100 Subject: [PATCH 08/28] Small refactor... --- libs/database/init.js | 8 ++++---- libs/ddnss/handler.js | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/database/init.js b/libs/database/init.js index 7aa1573..4498804 100644 --- a/libs/database/init.js +++ b/libs/database/init.js @@ -5,14 +5,14 @@ import initLog from '../utils/log.js' export let sqlite = undefined export let skinDB = undefined -const log = initLog("DB Init") +const log = initLog("Database") /** * This initalizes the ddnet.sqlite and skindata.sqlite DB... * @module db/dbInit */ export function dbInit() { - console.log("Starting up databases...") + log("Starting up databases...") /* load in db using better-sqlite3 */ sqlite = new Database('ddnet.sqlite', { verbose: console.log }); @@ -24,6 +24,6 @@ export function dbInit() { /* Unsafe mode */ sqlite.unsafeMode() - log("Loaded in ddnet.sqlite...") - log("Loaded in skindata.sqlite...") + log("Loaded in 'ddnet.sqlite'!") + log("Loaded in 'skindata.sqlite'!") } diff --git a/libs/ddnss/handler.js b/libs/ddnss/handler.js index 5e215aa..d18f1af 100644 --- a/libs/ddnss/handler.js +++ b/libs/ddnss/handler.js @@ -9,6 +9,8 @@ export async function ddnssStart() { const getServers = await fetch('https://ddnet.tw/status/index.json'); const servers = await getServers.json(); + log(`Found ${servers.length} online servers!`) + for (const server of servers) { const connection = `${server.ip}:${server.port}` @@ -25,11 +27,13 @@ export async function ddnssStart() { await scrapeServer(`${connection}`) } + // PLEASE!! exec(`pkill -9 -f ddnss`) } function scrapeServer(server) { - + // TODO: Maybe fix the paths to be dynamic? Or have some sort of buildscript... + // -- BurnyLlama const command = `./ddnss/build/DDNet "ui_server_address ${server}" -f ddnss/build/config.conf` return new Promise((resolve, reject) => { From bc582c35356823240dd3633dfbdce70b01279c01 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:08:57 +0100 Subject: [PATCH 09/28] Added a general search function... --- libs/database/searcher.js | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 libs/database/searcher.js diff --git a/libs/database/searcher.js b/libs/database/searcher.js new file mode 100644 index 0000000..79abac8 --- /dev/null +++ b/libs/database/searcher.js @@ -0,0 +1,62 @@ +import { sqlite } from './init.js' + +const entriesPerPage = process.env.ENTRIES_PER_PAGE ?? 50 + +/** + * This is a 'general' search function for the sqlite database... + * + * @param {string} table The table to search in. + * @param {string} matchField If not 'undefined' or 'null' match 'matchQuery' in this field. + * @param {string} matchQuery The value to search for in 'matchField'. + * @param {string} orderBy The field of which to order by, if 'null' or 'undefined' it is whatever sqlite sees as default. + * @param {boolean} descending If true: sort in ascending order instead of ascending order. + * @param {string} method If set to "all" it will give all results instead of only one. + * @param {number} page The function paginates; this sets the page to look for. + * + * @returns {Promise} Returns a promise which wither resolves with the data or rejects with an error. + * + * @author BurnyLlama + */ +export default function searcher(table, matchField=undefined, matchQuery=undefined, orderBy=undefined, descending=false, method="get", page=1) { + return new Promise( + (resolve, reject) => { + const pageCount = + method === "get" ? 0 : + parseInt(sqlite + .prepare(` + SELECT count(*) FROM $table + ${!matchField ?? "WHERE $matchField = $matchQuery"} + `) + .get({ + table, + matchField, matchQuery + }) + ) + + if (method === "all" && page > pageCount) + reject(`Page number too high! Page count: ${pageCount}`) + + const result = sqlite + .prepare(` + SELECT * FROM $table + ${!matchField ?? "WHERE $matchField = $matchQuery"} + ${!orderBy ?? `"ORDER BY $orderBy" ${descending === true ? "DESC" : "ASC"}`} + ${method === "all" ? `LIMIT ${entriesPerPage * (page - 1)}, ${entriesPerPage}` : ""} + `) + [method === "all" ? "all" : "get"]({ + table, + matchField, matchQuery, + orderBy + }) + + // This check should work? + if ((typeof result === "object" && !result[0]) || (!result)) + reject("No search results found!") + + resolve({ + result, + pageCount + }) + } + ) +} \ No newline at end of file From 94635e510b32f6c3f993f3c36b928fa6f4fa6e20 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:13:46 +0100 Subject: [PATCH 10/28] Clarified the dotenv-template... --- dotenv-template | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/dotenv-template b/dotenv-template index 5193393..140d847 100644 --- a/dotenv-template +++ b/dotenv-template @@ -1 +1,11 @@ -PORT = 12345 \ No newline at end of file +# The database connect string +MONGO_URI = "mongodb://user:passwd@host/db" + +# The port on which the server starts... +PORT = 12345 + +# Should the server try to generate the database? +GENERATE_DB = "false" + +# The API paginates. How many entries per page? +ENTRIES_PER_PAGE = 50 \ No newline at end of file From 0c234c89d9d68a8081758bc18680ef35bca22d90 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:18:53 +0100 Subject: [PATCH 11/28] Refactor to allow GENERATE_DB to work properly. --- index.js | 13 +++---------- libs/database/generate.js | 7 +++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index 38869b3..28a1025 100644 --- a/index.js +++ b/index.js @@ -2,22 +2,15 @@ import express from 'express' import dotenv from 'dotenv' import api from './api/api.js' import { generateDB } from "./libs/database/generate.js" -import { sqlite, dbInit } from "./libs/database/init.js" -import { ddnssStart } from './libs/ddnss/handler.js' -import tasks from './libs/database/tasks.js' +import { dbInit } from "./libs/database/init.js" + dotenv.config() dbInit() - -const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() - -if(!exists.a) - generateDB() +generateDB() const Server = express() Server.use('/api', api) Server.listen(process.env.PORT, () => console.log(`Server started and listening on port ${process.env.PORT}.`)) - -//ddnssStart() \ No newline at end of file diff --git a/libs/database/generate.js b/libs/database/generate.js index f629367..7007755 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -13,6 +13,13 @@ export function generateDB() { /* TODO: Clean this up as it is a mess */ /* TODO: Remove useless ones */ + if (process.env.GENERATE_DB !== "true") + return log("Won't generate the database since 'GENERATE_DB' is not set to \"true\" in '.env'!") + + const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() + if(!exists.a) + return log("Database already generated!") + log("Generating map index...") execMany([ `CREATE INDEX IF NOT EXISTS "idx_maps_map" ON "maps" ("map")`, From f91be3aa28ac3379353994f48705275a0916a631 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:51:31 +0100 Subject: [PATCH 12/28] Fixed SQL bullshit... --- libs/database/searcher.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/libs/database/searcher.js b/libs/database/searcher.js index 79abac8..bb05f62 100644 --- a/libs/database/searcher.js +++ b/libs/database/searcher.js @@ -2,6 +2,10 @@ import { sqlite } from './init.js' const entriesPerPage = process.env.ENTRIES_PER_PAGE ?? 50 +function simpleSanitize(str) { + return String(str).replace(/\s/g, "") +} + /** * This is a 'general' search function for the sqlite database... * @@ -24,12 +28,11 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin method === "get" ? 0 : parseInt(sqlite .prepare(` - SELECT count(*) FROM $table - ${!matchField ?? "WHERE $matchField = $matchQuery"} + SELECT count(*) FROM ${simpleSanitize(table)} + ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} `) .get({ - table, - matchField, matchQuery + matchQuery }) ) @@ -38,15 +41,13 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin const result = sqlite .prepare(` - SELECT * FROM $table - ${!matchField ?? "WHERE $matchField = $matchQuery"} - ${!orderBy ?? `"ORDER BY $orderBy" ${descending === true ? "DESC" : "ASC"}`} + SELECT * FROM ${simpleSanitize(table)} + ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} + ${orderBy ? `ORDER BY ${simpleSanitize(orderBy)} ${descending === true ? "DESC" : "ASC"}` : ""} ${method === "all" ? `LIMIT ${entriesPerPage * (page - 1)}, ${entriesPerPage}` : ""} `) [method === "all" ? "all" : "get"]({ - table, - matchField, matchQuery, - orderBy + matchQuery }) // This check should work? From f4213888a8af1c0850b845dd0b711c003bcd3dcf Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:54:59 +0100 Subject: [PATCH 13/28] Parsed an int to be an in (BAD). --- libs/database/searcher.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/database/searcher.js b/libs/database/searcher.js index bb05f62..7c6d5d6 100644 --- a/libs/database/searcher.js +++ b/libs/database/searcher.js @@ -26,7 +26,7 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin (resolve, reject) => { const pageCount = method === "get" ? 0 : - parseInt(sqlite + sqlite .prepare(` SELECT count(*) FROM ${simpleSanitize(table)} ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} @@ -34,7 +34,6 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin .get({ matchQuery }) - ) if (method === "all" && page > pageCount) reject(`Page number too high! Page count: ${pageCount}`) From 7fff166c3e46fd3496b3e49ed3237a593429592e Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:56:38 +0100 Subject: [PATCH 14/28] Forgot some SQLite BS... :) --- libs/database/searcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/database/searcher.js b/libs/database/searcher.js index 7c6d5d6..3cd391b 100644 --- a/libs/database/searcher.js +++ b/libs/database/searcher.js @@ -33,7 +33,7 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin `) .get({ matchQuery - }) + })['count(*)'] if (method === "all" && page > pageCount) reject(`Page number too high! Page count: ${pageCount}`) From 07b4dfe14b87e8fa88e887dfb74ab811efb8ccaf Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 19:59:11 +0100 Subject: [PATCH 15/28] Now dividing total rows by entries/page for actual page count. --- libs/database/searcher.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/libs/database/searcher.js b/libs/database/searcher.js index 3cd391b..59bf4c6 100644 --- a/libs/database/searcher.js +++ b/libs/database/searcher.js @@ -26,14 +26,17 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin (resolve, reject) => { const pageCount = method === "get" ? 0 : - sqlite - .prepare(` - SELECT count(*) FROM ${simpleSanitize(table)} - ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} - `) - .get({ - matchQuery - })['count(*)'] + Math.ceil( + sqlite + .prepare(` + SELECT count(*) FROM ${simpleSanitize(table)} + ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} + `) + .get({ + matchQuery + })['count(*)'] + / entriesPerPage + ) if (method === "all" && page > pageCount) reject(`Page number too high! Page count: ${pageCount}`) From 089e0d6fb7ce3100334bf44fc5e683e9aaf1c759 Mon Sep 17 00:00:00 2001 From: furo Date: Mon, 1 Nov 2021 21:52:48 +0100 Subject: [PATCH 16/28] Column renaming --- api/graph.js | 14 +++--- api/maps.js | 96 ++++++--------------------------------- api/players.js | 41 +---------------- index.js | 5 +- libs/database/generate.js | 85 +++++++++++++++++++++------------- libs/database/tasks.js | 40 ++++++++-------- 6 files changed, 98 insertions(+), 183 deletions(-) diff --git a/api/graph.js b/api/graph.js index 9f8ea80..2b27ddf 100644 --- a/api/graph.js +++ b/api/graph.js @@ -17,19 +17,19 @@ graphApi.get( const finishes = sqlite.prepare( ` - SELECT DISTINCT(a.map), a.timestamp, b.points + SELECT DISTINCT(a.map), a.date, b.points FROM race AS a INNER JOIN maps AS b ON a.map = b.map - WHERE a.NAME = ? + WHERE a.player = ? GROUP BY a.map - ORDER BY a.timestamp; + ORDER BY a.date; `) let array = [] for (const finish of finishes.iterate(player)) { - currentPoints += finish.Points - array.push({ t: new Date(finish.Timestamp), y: currentPoints }) + currentPoints += finish.points + array.push({ t: new Date(finish.date), y: currentPoints }) } return res.json({ @@ -49,11 +49,11 @@ graphApi.get( response: "No query ('host/path?q=query') provided!" }) } - const finishes = sqlite.prepare(`SELECT * FROM graphRecordCache WHERE map = ? ORDER BY Timestamp`) + const finishes = sqlite.prepare(`SELECT * FROM graphRecordCache WHERE map = ? ORDER BY date`) let array = [] for (const record of finishes.iterate(req.query.q)) - array.push({ t: new Date(record.timestamp), y: record.time, player: record.player}) + array.push({ t: new Date(record.date), y: record.time, player: record.player}) return res.json({ success: true, diff --git a/api/maps.js b/api/maps.js index d80d193..b3582f9 100644 --- a/api/maps.js +++ b/api/maps.js @@ -6,11 +6,11 @@ const mapApi = Router() mapApi.get( '/count', (req, res) => { - const totalMaps = sqlite.prepare(`SELECT COUNT(*) as amount FROM maps`).get() + const totalMaps = sqlite.prepare(`SELECT COUNT(*) as count FROM maps`).get() return res.json({ success: true, - response: totalMaps.amount, + response: totalMaps.count, }) } ) @@ -28,25 +28,19 @@ mapApi.get( response: "No map found!", }) } - const info = sqlite.prepare(`SELECT * FROM maps WHERE map = ?`).get(map) /* TODO: Generate a table with this as a cache */ - /* Also generate indexes */ - const avgTime = sqlite.prepare(`SELECT avg(Time) as 'averageTime' FROM race WHERE map = ?`).get(map) - const total = sqlite.prepare(`SELECT COUNT(*) as 'total' FROM race WHERE map = ?`).get(map) - const unique = sqlite.prepare(`SELECT COUNT(distinct(name)) as 'unique' FROM race WHERE map = ?`).get(map) - const teams = sqlite.prepare(`SELECT COUNT(distinct(ID)) as 'teams' FROM teamrace WHERE map = ?`).get(map) + const avgTime = sqlite.prepare(`SELECT avg(time) AS 'averageTime' FROM race WHERE map = ?`).get(map) + const total = sqlite.prepare(`SELECT COUNT(*) AS 'total' FROM race WHERE map = ?`).get(map) + const unique = sqlite.prepare(`SELECT COUNT(distinct(player)) AS 'unique' FROM race WHERE map = ?`).get(map) + const teams = sqlite.prepare(`SELECT COUNT(distinct(id)) AS 'teams' FROM teamrace WHERE map = ?`).get(map) return res.json({ success: true, response: { - name: info.Map, - category: info.Server, - awardPoints: info.Points, - rating: info.Stars, - mapper: info.Mapper, - release: info.Timestamp, + info, + /* TODO Get median time*/ averageTime: avgTime.averageTime, finishes: { @@ -62,16 +56,7 @@ mapApi.get( mapApi.get( '/getAll', (req, res) => { - - const allMaps = sqlite.prepare( - `SELECT - map as name, - server as category, - points as awardPoints, - stars as rating, - mapper as mapper, - timestamp as release - FROM maps`).all() + const allMaps = sqlite.prepare(`SELECT * FROM maps`).all() return res.json({ success: true, @@ -86,7 +71,7 @@ mapApi.get( let category = req.params.category /* Check if category exists */ - const check = sqlite.prepare(`SELECT server FROM maps WHERE server = ? LIMIT 1`).get(category) + const check = sqlite.prepare(`SELECT category FROM maps WHERE server = ? LIMIT 1`).get(category) if (!check) { return res.json({ success: false, @@ -94,15 +79,7 @@ mapApi.get( }) } - const allMaps = sqlite.prepare( - `SELECT - map as name, - server as category, - points as awardPoints, - stars as rating, - mapper as mapper, - timestamp as release - FROM maps WHERE server = ?`).all(category) + const allMaps = sqlite.prepare(`SELECT * FROM maps WHERE category = ?`).all(category) return res.json({ success: true, @@ -111,64 +88,17 @@ mapApi.get( } ) -/* Searching allows you to attach sql LIKE % - * Example to search for maps beginning with Kobra - * You can do search for Kobra% - - * This thing is a complete mess, send help. BUT it does work <3 */ mapApi.get( '/search', async (req, res) => { /* Check if a query was provided */ - if (!req.query.q || !req.query.byMapper) { + if (!req.query.q) { return res.json({ success: false, response: "No query ('host/path?q=query') provided!" }) } - - let query = req.query.q - - /* Set defaults */ - let limit = 20 - let offset = 0 - let sortBy = "timestamp" - let order = "asc" - let column = "map" - let startsWith = "" - - if (req.query.limit) - limit = req.query.limit - - if (req.query.sortBy) - sortBy = req.query.sortBy - - if (req.query.order) - order = req.query.order - - if (req.query.byMapper) - column = "mapper" - - /* TODO: do this in one query? */ - const amount = sqlite.prepare( - `SELECT COUNT(*) as amount FROM maps WHERE ${column} LIKE "${query}"`).get() - - let pages = Math.floor(amount.amount / limit) - - if (req.query.page) - offset = (req.query.page * limit) - - const maps = sqlite.prepare( - `SELECT * FROM maps WHERE ${column} LIKE "${query}" ORDER BY ${sortBy} ${order} LIMIT ? OFFSET ?`).all(limit, offset) - - return res.json({ - success: true, - response: { - amount: amount.amount, - pages, - maps, - } - }) + /* TODO: Use the searcher function */ } ) diff --git a/api/players.js b/api/players.js index 3dd337a..cc9b511 100644 --- a/api/players.js +++ b/api/players.js @@ -10,7 +10,7 @@ playerApi.get( let player = req.params.player /* Misc */ - const firstFinish = sqlite.prepare(`SELECT server as server, map as map, time as time, Timestamp as date FROM race WHERE name = ? ORDER BY Timestamp ASC LIMIT 1`).get(player) + const firstFinish = sqlite.prepare(`SELECT * FROM race WHERE player = ? ORDER BY date ASC LIMIT 1`).get(player) /* Points */ let points = {} @@ -30,10 +30,6 @@ playerApi.get( } ) -/* Searching allows you to attach sql LIKE % - * Example to search for players beginning with Test - * You can do search for %Test - */ playerApi.get( '/search', async (req, res) => { @@ -44,40 +40,7 @@ playerApi.get( response: "No query ('host/path?q=query') provided!" }) } - 1 - let name = req.query.q - - /* Set defaults */ - let limit = 20 - let offset = 0 - - if (req.query.limit) { - limit = req.query.limit - } - - const amount = sqlite.prepare( - `SELECT COUNT(*) as amount FROM points - WHERE name LIKE "${name}" - `).get() - - let pages = Math.floor(amount.amount / limit) - - if (req.query.page) - offset = (req.query.page * limit) - - const players = sqlite.prepare( - `SELECT Rank, Name, Points FROM points - WHERE name LIKE "${name}" LIMIT ? OFFSET ? - `).all(limit, offset) - - return res.json({ - success: true, - response: { - amount: amount.amount, - pages: pages, - players: players, - } - }) + /* TODO: Use the searcher function */ } ) diff --git a/index.js b/index.js index 28a1025..5efdcc6 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,8 @@ import express from 'express' import dotenv from 'dotenv' import api from './api/api.js' -import { generateDB } from "./libs/database/generate.js" -import { dbInit } from "./libs/database/init.js" - +import { generateDB } from './libs/database/generate.js' +import { dbInit } from './libs/database/init.js' dotenv.config() diff --git a/libs/database/generate.js b/libs/database/generate.js index 7007755..e3214a3 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -10,40 +10,63 @@ const log = initLog("DB Generation") * @module db/generateDB */ export function generateDB() { - /* TODO: Clean this up as it is a mess */ - /* TODO: Remove useless ones */ - if (process.env.GENERATE_DB !== "true") return log("Won't generate the database since 'GENERATE_DB' is not set to \"true\" in '.env'!") const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() - if(!exists.a) + if(!exists.a === 0) return log("Database already generated!") + /* Check if columns are already renamed */ + const renamed = sqlite.prepare(`SELECT COUNT(*) AS CNTREC FROM pragma_table_info('race') WHERE name='date'`).get() + + if(!renamed.a === 0) { + log("Renaming columns...") + execMany([ + `ALTER TABLE race RENAME COLUMN Map TO map`, + `ALTER TABLE race RENAME COLUMN Name TO player`, + `ALTER TABLE race RENAME COLUMN Time TO time`, + `ALTER TABLE race RENAME COLUMN Timestamp TO date`, + `ALTER TABLE race RENAME COLUMN Server TO server`, + + `ALTER TABLE teamrace RENAME COLUMN Map TO map`, + `ALTER TABLE teamrace RENAME COLUMN Name TO player`, + `ALTER TABLE teamrace RENAME COLUMN Time TO time`, + `ALTER TABLE teamrace RENAME COLUMN ID TO id`, + `ALTER TABLE teamrace RENAME COLUMN Timestamp TO date`, + + `ALTER TABLE maps RENAME COLUMN Map TO map`, + `ALTER TABLE maps RENAME COLUMN Server TO category`, + `ALTER TABLE maps RENAME COLUMN Points TO points`, + `ALTER TABLE maps RENAME COLUMN Stars TO stars`, + `ALTER TABLE maps RENAME COLUMN Mapper TO mapper`, + `ALTER TABLE maps RENAME COLUMN Timestamp TO release` + ]) + } log("Generating map index...") execMany([ `CREATE INDEX IF NOT EXISTS "idx_maps_map" ON "maps" ("map")`, - `CREATE INDEX IF NOT EXISTS "idx_maps_category" ON "maps" ("server");` + `CREATE INDEX IF NOT EXISTS "idx_maps_category" ON "maps" ("category")` ]) log("Generating race index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_race_player" ON "race" ("Name")`, - `CREATE INDEX IF NOT EXISTS "idx_race_name" ON "race" ("Name","Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_server" ON "race" ("Server")`, - `CREATE INDEX IF NOT EXISTS "idx_race_mapTimestamp" ON "race" ("Map","Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_timestamp" ON "race" ("Timestamp")`, - `CREATE INDEX IF NOT EXISTS "idx_race_mapNameTime" ON "race" ("Map", "Name", "Time")` + `CREATE INDEX IF NOT EXISTS "idx_race_player" ON "race" ("player")`, + `CREATE INDEX IF NOT EXISTS "idx_race_name" ON "race" ("player", "date")`, + `CREATE INDEX IF NOT EXISTS "idx_race_server" ON "race" ("server")`, + `CREATE INDEX IF NOT EXISTS "idx_race_mapTimestamp" ON "race" ("map", "date")`, + `CREATE INDEX IF NOT EXISTS "idx_race_timestamp" ON "race" ("date")`, + `CREATE INDEX IF NOT EXISTS "idx_race_mapNameTime" ON "race" ("map", "player", "time")` ]) log("Creating rankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "rankings" ( - "Map" varchar(128) NOT NULL, - "Name" varchar(16) NOT NULL, - "Time" float NOT NULL DEFAULT 0, - "Timestamp" timestamp NOT NULL DEFAULT current_timestamp, - "Server" char(4) NOT NULL DEFAULT '', + "map" varchar(128) NOT NULL, + "player" varchar(16) NOT NULL, + "time" float NOT NULL DEFAULT 0, + "date" timestamp NOT NULL DEFAULT current_timestamp, + "server" char(4) NOT NULL DEFAULT '', "rank" INTEGER NOT NULL); `) @@ -52,27 +75,27 @@ export function generateDB() { log("Generating rankings index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("map")`, `CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`, `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")` ]) log("Generating teamrace index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_teamrace_map" ON "teamrace" ("Map")`, - `CREATE INDEX IF NOT EXISTS "idx_teamrace_ID" ON "teamrace" ("ID")`, - `CREATE INDEX IF NOT EXISTS "idx_teamrace_mapID" ON "teamrace" ("Map", "ID")` + `CREATE INDEX IF NOT EXISTS "idx_teamrace_map" ON "teamrace" ("map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrace_id" ON "teamrace" ("id")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrace_mapID" ON "teamrace" ("map", "id")` ]) log("Creating teamrankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "teamrankings" ( - "Map" varchar(128) NOT NULL, - "ID" varbinary(16) NOT NULL, - "Name" varchar(16) NOT NULL, - "Time" float NOT NULL DEFAULT 0, - "Timestamp" timestamp NOT NULL DEFAULT current_timestamp, - "Server" char(4) NOT NULL DEFAULT '', + "map" varchar(128) NOT NULL, + "id" varbinary(16) NOT NULL, + "player" varchar(16) NOT NULL, + "time" float NOT NULL DEFAULT 0, + "date" timestamp NOT NULL DEFAULT current_timestamp, + "server" char(4) NOT NULL DEFAULT '', "teamrank" INTEGER NOT NULL); `) @@ -81,15 +104,15 @@ export function generateDB() { log("Generating teamrankings index...") execMany([ - `CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("Map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("map")`, `CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`, - `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("name")` + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("player")` ]) sqlite.exec(` CREATE TABLE IF NOT EXISTS "points" ( "rank" INTEGER NOT NULL, - "name" varchar(16) NOT NULL, + "player" varchar(16) NOT NULL, "points" INTEGER NOT NULL); `) @@ -99,8 +122,8 @@ export function generateDB() { "map" varchar(128) NOT NULL, "player" varchar(16) NOT NULL, "time" float NOT NULL DEFAULT 0, - "timestamp" timestamp NOT NULL DEFAULT current_timestamp, - "Server" char(4) NOT NULL DEFAULT ''); + "date" timestamp NOT NULL DEFAULT current_timestamp, + "server" char(4) NOT NULL DEFAULT ''); `) tasks.processTimeGraph() diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 97a47fd..47ac4fe 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -34,19 +34,19 @@ export function processRankings() { .prepare(` INSERT INTO rankings ( - map, name, time, timestamp, rank, server + map, player, time, date, rank, server ) - SELECT map, name, time, timestamp, rank, server + SELECT map, player, time, date, rank, server FROM ( SELECT rank() OVER w AS rank, map, - timestamp, - NAME, + date, + player, min(time) AS time, server FROM race WHERE map = ? - GROUP BY NAME window w AS (ORDER BY time) ) AS a + GROUP BY player window w AS (ORDER BY time) ) AS a ORDER BY rank `) .run(map.Map) @@ -64,10 +64,10 @@ export function processTeamRankings() { .prepare(` INSERT INTO teamrankings ( - name, map, id, time, timestamp, server, teamrank + player, map, id, time, date, server, teamrank ) - SELECT DISTINCT(r.NAME), - r.map, r.id, r.time, r.timestamp, + SELECT DISTINCT(r.player), + r.map, r.id, r.time, r.date, Substring(n.server, 1, 3), dense_rank() OVER w AS rank FROM (( @@ -80,10 +80,10 @@ export function processTeamRankings() { ON l.id = r.id INNER JOIN race AS n ON r.map = n.map - AND r.NAME = n.NAME + AND r.player = n.player AND r.time = n.time window w AS (ORDER BY r.time) `) - .run(map.Map) + .run(map.map) } /** @@ -96,12 +96,12 @@ export function processTimeGraph() { let currentBest = 0; const maps = sqlite.prepare(`SELECT map FROM maps`); - const finishes = sqlite.prepare(`SELECT * FROM race WHERE map = ? ORDER BY Timestamp`) + const finishes = sqlite.prepare(`SELECT * FROM race WHERE map = ? ORDER BY date`) for (const map of maps.iterate()) { let currentFinish let currentBest = 0; - for (const record of finishes.iterate(map.Map)) { + for (const record of finishes.iterate(map.map)) { currentFinish = record.Time if (currentFinish <= currentBest || currentBest == 0) { currentBest = currentFinish @@ -109,14 +109,14 @@ export function processTimeGraph() { sqlite.prepare(` INSERT INTO "graphRecordCache" ( - map, player, time, timestamp, server + map, player, time, date, server ) VALUES (?, ?, ?, ?, ?) `).run( map.Map, - record.Name, - record.Time, - record.Timestamp, - record.Server + record.name, + record.time, + record.timestamp, + record.server ) } } @@ -144,7 +144,7 @@ export function processAllPoints() { ( "type" varchar(16) NOT NULL, "rank" INTEGER NOT NULL, - "name" varchar(16) NOT NULL, + "player" varchar(16) NOT NULL, "points" INTEGER NOT NULL ); `) @@ -158,7 +158,7 @@ export function processAllPoints() { .prepare(` INSERT INTO "points" ( - type, rank, name, points + type, rank, player, points ) VALUES (?, ?, ?, ?) `) .run( @@ -176,7 +176,7 @@ export function processAllPoints() { execMany([ `CREATE INDEX IF NOT EXISTS "idx_points_type" ON "points" ("type")`, `CREATE INDEX IF NOT EXISTS "Idx_points_rank" on "points" ("rank")`, - `CREATE INDEX IF NOT EXISTS "Idx_points_name" on "points" ("name")` + `CREATE INDEX IF NOT EXISTS "Idx_points_name" on "points" ("player")` ]) } From bb0e8899343e3905bd9423c561a86c1d03e7d5b9 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 21:58:19 +0100 Subject: [PATCH 17/28] Small refacor of SQL statements. --- libs/database/generate.js | 17 +++++++++++------ libs/database/tasks.js | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/libs/database/generate.js b/libs/database/generate.js index e3214a3..a65fdb0 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -67,7 +67,8 @@ export function generateDB() { "time" float NOT NULL DEFAULT 0, "date" timestamp NOT NULL DEFAULT current_timestamp, "server" char(4) NOT NULL DEFAULT '', - "rank" INTEGER NOT NULL); + "rank" INTEGER NOT NULL + ) `) log("Calculating rankings for each map...") @@ -96,7 +97,8 @@ export function generateDB() { "time" float NOT NULL DEFAULT 0, "date" timestamp NOT NULL DEFAULT current_timestamp, "server" char(4) NOT NULL DEFAULT '', - "teamrank" INTEGER NOT NULL); + "teamrank" INTEGER NOT NULL + ) `) log("Calculating teamrankings for each map...") @@ -113,7 +115,8 @@ export function generateDB() { CREATE TABLE IF NOT EXISTS "points" ( "rank" INTEGER NOT NULL, "player" varchar(16) NOT NULL, - "points" INTEGER NOT NULL); + "points" INTEGER NOT NULL + ) `) log("Generating graphRecordCache...") @@ -123,13 +126,14 @@ export function generateDB() { "player" varchar(16) NOT NULL, "time" float NOT NULL DEFAULT 0, "date" timestamp NOT NULL DEFAULT current_timestamp, - "server" char(4) NOT NULL DEFAULT ''); + "server" char(4) NOT NULL DEFAULT '' + ) `) tasks.processTimeGraph() execMany([ `CREATE INDEX IF NOT EXISTS "idx_graphCache_player" ON "graphRecordCache" ("player")`, - `CREATE INDEX IF NOT EXISTS "idx_graphCache_map" ON "graphRecordCache" ("map");` + `CREATE INDEX IF NOT EXISTS "idx_graphCache_map" ON "graphRecordCache" ("map")` ]) log("Inserting points to DB...") @@ -146,6 +150,7 @@ export function generateDB() { "colorBodyRaw" INTEGER NOT NULL, "colorBodyHex" varchar(8) NOT NULL, "colorFeetRaw" INTEGER NOT NULL, - "colorFeetHex" varchar(8) NOT NULL); + "colorFeetHex" varchar(8) NOT NULL + ) `) } diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 47ac4fe..265bd50 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -146,7 +146,7 @@ export function processAllPoints() { "rank" INTEGER NOT NULL, "player" varchar(16) NOT NULL, "points" INTEGER NOT NULL - ); + ) `) /* Insert data */ From c92d71c66ffccc3e5ce584770b9be17ca7d9f082 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 22:15:13 +0100 Subject: [PATCH 18/28] Refactored env settings. --- .gitignore | 4 +--- dotenv-template | 11 ----------- index.js | 7 +++---- libs/database/init.js | 4 ++-- libs/database/tasks.js | 2 +- template.env | 22 ++++++++++++++++++++++ 6 files changed, 29 insertions(+), 21 deletions(-) delete mode 100644 dotenv-template create mode 100644 template.env diff --git a/.gitignore b/.gitignore index 7603cfd..2cbaf56 100644 --- a/.gitignore +++ b/.gitignore @@ -119,7 +119,5 @@ dist package-lock.json pnpm-lock.yaml -*.sql* -players.msgpack .env -ddnss/ \ No newline at end of file +data/* \ No newline at end of file diff --git a/dotenv-template b/dotenv-template deleted file mode 100644 index 140d847..0000000 --- a/dotenv-template +++ /dev/null @@ -1,11 +0,0 @@ -# The database connect string -MONGO_URI = "mongodb://user:passwd@host/db" - -# The port on which the server starts... -PORT = 12345 - -# Should the server try to generate the database? -GENERATE_DB = "false" - -# The API paginates. How many entries per page? -ENTRIES_PER_PAGE = 50 \ No newline at end of file diff --git a/index.js b/index.js index 5efdcc6..ddfbd09 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,14 @@ import express from 'express' -import dotenv from 'dotenv' +import { config as loadEnv } from 'dotenv' import api from './api/api.js' import { generateDB } from './libs/database/generate.js' import { dbInit } from './libs/database/init.js' -dotenv.config() - +loadEnv() dbInit() generateDB() const Server = express() Server.use('/api', api) -Server.listen(process.env.PORT, () => console.log(`Server started and listening on port ${process.env.PORT}.`)) +Server.listen(process.env.PORT ?? 12345, () => console.log(`Server started and listening on port ${process.env.PORT ?? 12345}.`)) diff --git a/libs/database/init.js b/libs/database/init.js index 4498804..281f7be 100644 --- a/libs/database/init.js +++ b/libs/database/init.js @@ -15,8 +15,8 @@ export function dbInit() { log("Starting up databases...") /* load in db using better-sqlite3 */ - sqlite = new Database('ddnet.sqlite', { verbose: console.log }); - skinDB = new Database('skindata.sqlite', { }); + sqlite = new Database(process.env.DDNET_SQLITE_PATH ?? 'data/ddnet.sqlite', { verbose: console.log }); + skinDB = new Database(process.env.DDNSS_SQLITE_PATH ?? 'data/skindata.sqlite', { }); /* WAL mode */ sqlite.pragma('journal_mode = WAL'); diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 265bd50..93c69f6 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -9,7 +9,7 @@ import { sqlite } from './init.js' * @module db/decodeMsgpack */ export function decodeMsgpack() { - const data = fs.readFileSync('players.msgpack') + const data = fs.readFileSync(process.env.MSGPACK_PATH ?? 'data/players.msgpack') const decoded = msgpack.decodeMulti(data, { wrap: true }) const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] let final = {} diff --git a/template.env b/template.env new file mode 100644 index 0000000..a6a9cf1 --- /dev/null +++ b/template.env @@ -0,0 +1,22 @@ +# +# You should copy this file to '.env' +# and set all settings there. +# + + +# MongoDB connection URI +MONGO_URI = "mongodb://user:passwd@host/db" + +# Paths to SQLite databases... +DDNET_SQLITE_PATH = "data/ddnet.sqlite" +DDNSS_SQLITE_PATH = "data/skindata.sqlite" +MSGPACK_PATH = "data/players.msgpack" + +# Should the server try to generate the database? +GENERATE_DB = "true" + +# The port on which the server listens... +PORT = 12345 + +# The API paginates. How many entries per page? +ENTRIES_PER_PAGE = 50 \ No newline at end of file From bf2a8b3bc0329c676dbe319f3d87b2d00d1dc492 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Mon, 1 Nov 2021 22:20:13 +0100 Subject: [PATCH 19/28] Moved decodeMsgpack to seprate file. --- libs/database/decodeMsgpack.js | 21 +++++++++++++++++++++ libs/database/tasks.js | 21 +-------------------- 2 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 libs/database/decodeMsgpack.js diff --git a/libs/database/decodeMsgpack.js b/libs/database/decodeMsgpack.js new file mode 100644 index 0000000..47f1a8b --- /dev/null +++ b/libs/database/decodeMsgpack.js @@ -0,0 +1,21 @@ +import msgpack from '@msgpack/msgpack' +import fs from 'fs' + + +/** + * This module parses the msgpack provided by DDNet... + * @module db/decodeMsgpack + */ +export default function decodeMsgpack() { + const data = fs.readFileSync(process.env.MSGPACK_PATH ?? 'data/players.msgpack') + const decoded = msgpack.decodeMulti(data, { wrap: true }) + const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] + let final = {} + + let i = 0 + for (const part of decoded) { + final[order[i]] = part + ++i + } + return final +} diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 93c69f6..93ccbfc 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -1,26 +1,7 @@ -import msgpack from '@msgpack/msgpack' -import fs from 'fs' +import decodeMsgpack from './decodeMsgpack.js' import { execMany } from './helper.js' - import { sqlite } from './init.js' -/** - * This module parses the msgpack provided by DDNet... - * @module db/decodeMsgpack - */ -export function decodeMsgpack() { - const data = fs.readFileSync(process.env.MSGPACK_PATH ?? 'data/players.msgpack') - const decoded = msgpack.decodeMulti(data, { wrap: true }) - const order = ['categories', 'maps', 'totalPoints', 'pointsRanks', 'pointsThisWeek', 'pointsThisMonth', 'teamRankPoints', 'rankPoints', 'serverRanks'] - let final = {} - - let i = 0 - for (const part of decoded) { - final[order[i]] = part - ++i - } - return final -} /** * This generates rankings for each map... From f90312a7c705e42a174c6e30b80c8f1a8dec3f7b Mon Sep 17 00:00:00 2001 From: furo Date: Mon, 1 Nov 2021 22:49:52 +0100 Subject: [PATCH 20/28] Some fixes --- api/graph.js | 3 ++- api/maps.js | 2 +- api/players.js | 2 +- libs/database/generate.js | 11 ++--------- libs/database/tasks.js | 28 ++++++++++++---------------- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/api/graph.js b/api/graph.js index 2b27ddf..8e72a39 100644 --- a/api/graph.js +++ b/api/graph.js @@ -25,8 +25,9 @@ graphApi.get( GROUP BY a.map ORDER BY a.date; `) - + let currentPoints = 0 let array = [] + for (const finish of finishes.iterate(player)) { currentPoints += finish.points array.push({ t: new Date(finish.date), y: currentPoints }) diff --git a/api/maps.js b/api/maps.js index b3582f9..851ff06 100644 --- a/api/maps.js +++ b/api/maps.js @@ -71,7 +71,7 @@ mapApi.get( let category = req.params.category /* Check if category exists */ - const check = sqlite.prepare(`SELECT category FROM maps WHERE server = ? LIMIT 1`).get(category) + const check = sqlite.prepare(`SELECT category FROM maps WHERE category = ? LIMIT 1`).get(category) if (!check) { return res.json({ success: false, diff --git a/api/players.js b/api/players.js index cc9b511..94634a7 100644 --- a/api/players.js +++ b/api/players.js @@ -14,7 +14,7 @@ playerApi.get( /* Points */ let points = {} - const pointsData = sqlite.prepare(`SELECT type, rank, points FROM points WHERE name = ?`) + const pointsData = sqlite.prepare(`SELECT type, rank, points FROM points WHERE player = ?`) for (const pointsType of pointsData.iterate(player)) { points[pointsType.type] = pointsType diff --git a/libs/database/generate.js b/libs/database/generate.js index e3214a3..4cb71fb 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -18,7 +18,7 @@ export function generateDB() { return log("Database already generated!") /* Check if columns are already renamed */ - const renamed = sqlite.prepare(`SELECT COUNT(*) AS CNTREC FROM pragma_table_info('race') WHERE name='date'`).get() + const renamed = sqlite.prepare(`SELECT COUNT(*) AS a FROM pragma_table_info('race') WHERE name='date'`).get() if(!renamed.a === 0) { log("Renaming columns...") @@ -77,7 +77,7 @@ export function generateDB() { execMany([ `CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("map")`, `CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`, - `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("Name")` + `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("player")` ]) log("Generating teamrace index...") @@ -109,13 +109,6 @@ export function generateDB() { `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("player")` ]) - sqlite.exec(` - CREATE TABLE IF NOT EXISTS "points" ( - "rank" INTEGER NOT NULL, - "player" varchar(16) NOT NULL, - "points" INTEGER NOT NULL); - `) - log("Generating graphRecordCache...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "graphRecordCache" ( diff --git a/libs/database/tasks.js b/libs/database/tasks.js index 47ac4fe..eaf4e0b 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -6,7 +6,7 @@ import { sqlite } from './init.js' /** * This module parses the msgpack provided by DDNet... - * @module db/decodeMsgpack + * @module libs/database/decodeMsgpack */ export function decodeMsgpack() { const data = fs.readFileSync('players.msgpack') @@ -24,10 +24,10 @@ export function decodeMsgpack() { /** * This generates rankings for each map... - * @module db/processRankings + * @module libs/database/processRankings */ export function processRankings() { - const maps = sqlite.prepare(`SELECT map FROM maps`); + const maps = sqlite.prepare(`SELECT map FROM maps`) for (const map of maps.iterate()) sqlite @@ -49,15 +49,15 @@ export function processRankings() { GROUP BY player window w AS (ORDER BY time) ) AS a ORDER BY rank `) - .run(map.Map) + .run(map.map) } /** * This generates teamrankings for each map... - * @module db/processTeamRankings + * @module libs/database/processTeamRankings */ export function processTeamRankings() { - const maps = sqlite.prepare(`SELECT map FROM maps`); + const maps = sqlite.prepare(`SELECT map FROM maps`) for (const map of maps.iterate()) sqlite @@ -91,18 +91,14 @@ export function processTeamRankings() { * @module libs/database/processTimeGraph */ export function processTimeGraph() { - - let currentFinish - let currentBest = 0; - - const maps = sqlite.prepare(`SELECT map FROM maps`); + const maps = sqlite.prepare(`SELECT map FROM maps`) const finishes = sqlite.prepare(`SELECT * FROM race WHERE map = ? ORDER BY date`) for (const map of maps.iterate()) { let currentFinish - let currentBest = 0; + let currentBest = 0 for (const record of finishes.iterate(map.map)) { - currentFinish = record.Time + currentFinish = record.time if (currentFinish <= currentBest || currentBest == 0) { currentBest = currentFinish @@ -112,10 +108,10 @@ export function processTimeGraph() { map, player, time, date, server ) VALUES (?, ?, ?, ?, ?) `).run( - map.Map, - record.name, + map.map, + record.player, record.time, - record.timestamp, + record.date, record.server ) } From 054a84f6fd18f5bcdd9a16e06fa66a31083d1189 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Tue, 2 Nov 2021 00:01:11 +0100 Subject: [PATCH 21/28] Created a script to automatically download ddnet.sqlite and players.msgpack --- index.js | 5 +++ libs/ddnss/handler.js | 6 +-- libs/download/dowload.js | 84 ++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 libs/download/dowload.js diff --git a/index.js b/index.js index ddfbd09..773ce7d 100644 --- a/index.js +++ b/index.js @@ -3,8 +3,13 @@ import { config as loadEnv } from 'dotenv' import api from './api/api.js' import { generateDB } from './libs/database/generate.js' import { dbInit } from './libs/database/init.js' +import { downloadEssentialData } from './libs/download/dowload.js' + loadEnv() + +await downloadEssentialData() + dbInit() generateDB() diff --git a/libs/ddnss/handler.js b/libs/ddnss/handler.js index d18f1af..d105d32 100644 --- a/libs/ddnss/handler.js +++ b/libs/ddnss/handler.js @@ -1,13 +1,13 @@ -import fetch from 'node-fetch' import { exec } from 'child_process' import { skinDB } from '../database/init.js' import initLog from '../utils/log.js' +import { download } from '../download/dowload.js' const log = initLog("DDNSS") export async function ddnssStart() { - const getServers = await fetch('https://ddnet.tw/status/index.json'); - const servers = await getServers.json(); + const getServers = await download('https://ddnet.tw/status/index.json', "_RETURN_VALUE_") + const servers = await getServers.json() log(`Found ${servers.length} online servers!`) diff --git a/libs/download/dowload.js b/libs/download/dowload.js new file mode 100644 index 0000000..88f2573 --- /dev/null +++ b/libs/download/dowload.js @@ -0,0 +1,84 @@ +import fs from 'fs' +import https from 'https' +import initLog from '../utils/log.js' +import { exec } from 'child_process' + +const log = initLog("Downloader") + + +/** + * This function can download and save data to files, or simply return the data. + * @param {string} url The URL of which to download from... + * @param {string} target This is the file path you want to save to. Alterntively use "_RETURN_VALUE_" to get value returned instead of saved to a file. + * + * @returns {Promise} + * + * @author BurnyLlama + */ +export function download(url, target) { + return new Promise( + (resolve, reject) => { + log(`Starting a download >> ${url}`) + + https.get( + url, + data => { + if (target === "_RETURN_VALUE_") { + let result + + data.on( + 'data', + chunk => result += chunk + ) + + data.on( + 'end', + () => resolve(result) + ) + } + + const file = fs.createWriteStream(target) + + data.pipe(file) + + data.on( + 'end', + () => { + log(`Done with a download >> ${url}`) + file.close() + resolve() + } + ) + } + ) + } + ) +} + +export function downloadEssentialData() { + return new Promise( + (resolve, reject) => { + log("Downloading 'ddnet.sqlite.zip' and 'players.msgpack'...") + Promise.all([ + download("https://ddnet.tw/stats/ddnet.sqlite.zip", process.env.DDNET_SQLITE_PATH ? `${process.env.DDNET_SQLITE_PATH}.zip` : 'data/ddnet.sqlite.zip'), + download("https://ddnet.tw/players.msgpack", process.env.MSGPACK_PATH ?? 'data/players.msgpack') + ]).then(() => { + log("All downloads done! Going to unzip 'ddnet.sqlite.zip'...") + + exec( + `unzip -o ${process.env.DDNET_SQLITE_PATH ? `${process.env.DDNET_SQLITE_PATH}.zip` : 'data/ddnet.sqlite.zip'} \ + -d ${process.env.DDNET_SQLITE_PATH.replace(/\/[\s\S]*\.sqlite/, "") ?? 'data'}`, + err => { + if (err) { + log("Error while unzipping!") + reject() + } + + log("Done unzipping!") + resolve() + } + ) + }) + } + ) +} \ No newline at end of file diff --git a/package.json b/package.json index d40e9d6..68d9098 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "better-sqlite3": "^7.4.3", "dotenv": "^10.0.0", "express": "^4.17.1", - "mime-types": "^2.1.33", - "node-fetch": "^3.0.0" + "mime-types": "^2.1.33" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" From cb7e83de38a31ac16d36f70a185be063dc4587aa Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Tue, 2 Nov 2021 00:03:50 +0100 Subject: [PATCH 22/28] We are not using mongodb anymore... (lol?) --- template.env | 3 --- 1 file changed, 3 deletions(-) diff --git a/template.env b/template.env index a6a9cf1..2c4cacf 100644 --- a/template.env +++ b/template.env @@ -4,9 +4,6 @@ # -# MongoDB connection URI -MONGO_URI = "mongodb://user:passwd@host/db" - # Paths to SQLite databases... DDNET_SQLITE_PATH = "data/ddnet.sqlite" DDNSS_SQLITE_PATH = "data/skindata.sqlite" From 952cc443214ab896cfc5605e79366a2bd17ccaae Mon Sep 17 00:00:00 2001 From: furo Date: Tue, 2 Nov 2021 00:10:00 +0100 Subject: [PATCH 23/28] Brain issue --- libs/database/generate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/database/generate.js b/libs/database/generate.js index dc89ef7..dd59bbd 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -14,7 +14,7 @@ export function generateDB() { return log("Won't generate the database since 'GENERATE_DB' is not set to \"true\" in '.env'!") const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() - if(!exists.a === 0) + if(exists.a === 0) return log("Database already generated!") /* Check if columns are already renamed */ From 06fcfb65ac1bc155d67b2152cd1f9039e1d83420 Mon Sep 17 00:00:00 2001 From: furo Date: Tue, 2 Nov 2021 00:19:38 +0100 Subject: [PATCH 24/28] brain issue (again) --- libs/database/generate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/database/generate.js b/libs/database/generate.js index dd59bbd..b9c5f69 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -14,13 +14,13 @@ export function generateDB() { return log("Won't generate the database since 'GENERATE_DB' is not set to \"true\" in '.env'!") const exists = sqlite.prepare(`SELECT count(*) as a FROM sqlite_master WHERE type='table' AND name='points'`).get() - if(exists.a === 0) + if(!exists.a === 0) return log("Database already generated!") /* Check if columns are already renamed */ const renamed = sqlite.prepare(`SELECT COUNT(*) AS a FROM pragma_table_info('race') WHERE name='date'`).get() - if(!renamed.a === 0) { + if(renamed.a === 0) { log("Renaming columns...") execMany([ `ALTER TABLE race RENAME COLUMN Map TO map`, From c3b09f96c9a61b15ee266f2161c10ae926e48191 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Tue, 2 Nov 2021 09:58:10 +0100 Subject: [PATCH 25/28] Fixed checking path for unzip output... --- libs/download/dowload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/download/dowload.js b/libs/download/dowload.js index 88f2573..5addf4a 100644 --- a/libs/download/dowload.js +++ b/libs/download/dowload.js @@ -67,7 +67,7 @@ export function downloadEssentialData() { exec( `unzip -o ${process.env.DDNET_SQLITE_PATH ? `${process.env.DDNET_SQLITE_PATH}.zip` : 'data/ddnet.sqlite.zip'} \ - -d ${process.env.DDNET_SQLITE_PATH.replace(/\/[\s\S]*\.sqlite/, "") ?? 'data'}`, + -d ${process.env.DDNET_SQLITE_PATH ? process.env.DDNET_SQLITE_PATH.replace(/\/[\s\S]*\.sqlite/, "") : 'data'}`, err => { if (err) { log("Error while unzipping!") From f2ae45b2b8d69835cf497ff3357d953f3f8b0512 Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Tue, 2 Nov 2021 10:03:09 +0100 Subject: [PATCH 26/28] Removed unnecessary files... --- ddnet-links.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 ddnet-links.txt diff --git a/ddnet-links.txt b/ddnet-links.txt deleted file mode 100644 index 3c81ab7..0000000 --- a/ddnet-links.txt +++ /dev/null @@ -1,3 +0,0 @@ -http://ddnet.tw/players.msgpack -https://ddnet.tw/status/index.json -https://ddnet.tw/stats/ddnet.sqlite.zip \ No newline at end of file From 5319966d7fa9cf6dceb17046450e3d5c05c081da Mon Sep 17 00:00:00 2001 From: BurnyLlama Date: Tue, 2 Nov 2021 11:32:59 +0100 Subject: [PATCH 27/28] Started reworking API... --- api/players.js | 64 +++++++++++++++++++++++++-------------- index.js | 3 +- libs/database/searcher.js | 4 +-- template.env | 3 ++ 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/api/players.js b/api/players.js index 94634a7..d8d7f4d 100644 --- a/api/players.js +++ b/api/players.js @@ -1,5 +1,6 @@ import { Router } from 'express' import { sqlite } from '../libs/database/init.js' +import searcher from '../libs/database/searcher.js' const playerApi = Router() @@ -7,40 +8,57 @@ const playerApi = Router() playerApi.get( '/get/:player', async (req, res) => { - let player = req.params.player - - /* Misc */ - const firstFinish = sqlite.prepare(`SELECT * FROM race WHERE player = ? ORDER BY date ASC LIMIT 1`).get(player) - - /* Points */ - let points = {} - const pointsData = sqlite.prepare(`SELECT type, rank, points FROM points WHERE player = ?`) - - for (const pointsType of pointsData.iterate(player)) { - points[pointsType.type] = pointsType - } - - return res.json({ - success: true, - response: { - firstFinish, - points, - } - }) + searcher( + 'points', + 'player', + req.params.player, + undefined, + false, + "get", + 0 + ).then( + player => res.json({ + success: true, + response: player + }) + ).catch( + error => res.json({ + success: false, + response: error + }) + ) } ) playerApi.get( '/search', async (req, res) => { - /* Check if a query was provided */ if (!req.query.q) { return res.json({ success: false, - response: "No query ('host/path?q=query') provided!" + response: "No query ('?q=query') provided!" }) } - /* TODO: Use the searcher function */ + + searcher( + 'points', + 'player', + `%${req.query.q}%`, + req.query.sort ?? undefined, + req.query.order === "asc", + "all", + req.query.page + ).then( + player => res.json({ + success: true, + response: player + }) + ).catch( + error => res.json({ + success: false, + response: error + }) + ) } ) diff --git a/index.js b/index.js index 773ce7d..3f6b3e2 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,8 @@ import { downloadEssentialData } from './libs/download/dowload.js' loadEnv() -await downloadEssentialData() +if (process.env.DOWNLOAD_FILES === "true") + await downloadEssentialData() dbInit() generateDB() diff --git a/libs/database/searcher.js b/libs/database/searcher.js index 59bf4c6..1102150 100644 --- a/libs/database/searcher.js +++ b/libs/database/searcher.js @@ -30,7 +30,7 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin sqlite .prepare(` SELECT count(*) FROM ${simpleSanitize(table)} - ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} + ${matchField ? `WHERE ${simpleSanitize(matchField)} LIKE $matchQuery` : ""} `) .get({ matchQuery @@ -44,7 +44,7 @@ export default function searcher(table, matchField=undefined, matchQuery=undefin const result = sqlite .prepare(` SELECT * FROM ${simpleSanitize(table)} - ${matchField ? `WHERE ${simpleSanitize(matchField)} = $matchQuery` : ""} + ${matchField ? `WHERE ${simpleSanitize(matchField)} LIKE $matchQuery` : ""} ${orderBy ? `ORDER BY ${simpleSanitize(orderBy)} ${descending === true ? "DESC" : "ASC"}` : ""} ${method === "all" ? `LIMIT ${entriesPerPage * (page - 1)}, ${entriesPerPage}` : ""} `) diff --git a/template.env b/template.env index 2c4cacf..dae7afe 100644 --- a/template.env +++ b/template.env @@ -12,6 +12,9 @@ MSGPACK_PATH = "data/players.msgpack" # Should the server try to generate the database? GENERATE_DB = "true" +# Should download files from DDNet servers? +DOWNLOAD_FILES = "true" + # The port on which the server listens... PORT = 12345 From 2aaa108521f530e427c7245ead3df2473f28aa09 Mon Sep 17 00:00:00 2001 From: furo Date: Thu, 4 Nov 2021 15:40:12 +0100 Subject: [PATCH 28/28] Started working on functions for the UI --- api/finishes.js | 40 +++++ api/maps.js | 74 +++++---- api/players.js | 32 ++-- index.js | 2 +- libs/database/generate.js | 52 +++++- libs/database/init.js | 3 + libs/database/tasks.js | 61 ++++++- libs/database/wrapper.js | 335 ++++++++++++++++++++++++++++++++++++++ math-func.so | Bin 0 -> 86392 bytes 9 files changed, 530 insertions(+), 69 deletions(-) create mode 100644 libs/database/wrapper.js create mode 100755 math-func.so diff --git a/api/finishes.js b/api/finishes.js index 1fd68f6..f339806 100644 --- a/api/finishes.js +++ b/api/finishes.js @@ -1,5 +1,6 @@ import { Router } from 'express' import { sqlite } from '../libs/database/init.js' +import wrapper from '../libs/database/wrapper.js' const finishApi = Router() @@ -16,4 +17,43 @@ finishApi.get( } ) +finishApi.get( + '/finishedMaps/:player', + (req, res) => { + /* Check if player exists */ + if(!wrapper.playerExists(req.params.player)) { + return res.json({ + success: false, + response: "No such player!" + }) + } + const finishes = wrapper.finishedMaps(req.params.player) + + return res.json({ + success: true, + response: finishes, + }) + } +) + +finishApi.get( + '/unfinishedMaps/:player', + (req, res) => { + /* Check if player exists */ + if(!wrapper.playerExists(req.params.player)) { + return res.json({ + success: false, + response: "No such player!" + }) + } + const finishes = wrapper.unfinishedMaps(req.params.player) + + return res.json({ + success: true, + response: finishes, + }) + } +) + + export default finishApi \ No newline at end of file diff --git a/api/maps.js b/api/maps.js index 851ff06..d2ebceb 100644 --- a/api/maps.js +++ b/api/maps.js @@ -1,5 +1,6 @@ import { Router } from 'express' import { sqlite } from '../libs/database/init.js' +import wrapper, { map } from '../libs/database/wrapper.js' const mapApi = Router() @@ -18,37 +19,17 @@ mapApi.get( mapApi.get( '/get/:map', (req, res) => { - let map = req.params.map - /* Check if map exists */ - const check = sqlite.prepare(`SELECT map FROM maps WHERE map = ?`).get(map) - if (!check) { + if(!wrapper.mapExists(req.params.map)) { return res.json({ success: false, - response: "No map found!", + response: "No such map!" }) } - const info = sqlite.prepare(`SELECT * FROM maps WHERE map = ?`).get(map) - - /* TODO: Generate a table with this as a cache */ - const avgTime = sqlite.prepare(`SELECT avg(time) AS 'averageTime' FROM race WHERE map = ?`).get(map) - const total = sqlite.prepare(`SELECT COUNT(*) AS 'total' FROM race WHERE map = ?`).get(map) - const unique = sqlite.prepare(`SELECT COUNT(distinct(player)) AS 'unique' FROM race WHERE map = ?`).get(map) - const teams = sqlite.prepare(`SELECT COUNT(distinct(id)) AS 'teams' FROM teamrace WHERE map = ?`).get(map) return res.json({ success: true, - response: { - info, - - /* TODO Get median time*/ - averageTime: avgTime.averageTime, - finishes: { - unique: unique.unique, - total: total.total, - teams: teams.teams, - } - } + response: map(req.params.map) }) } ) @@ -56,11 +37,9 @@ mapApi.get( mapApi.get( '/getAll', (req, res) => { - const allMaps = sqlite.prepare(`SELECT * FROM maps`).all() - return res.json({ success: true, - response: allMaps, + response: wrapper.allMaps() }) } ) @@ -68,22 +47,53 @@ mapApi.get( mapApi.get( '/category/:category', (req, res) => { - let category = req.params.category - /* Check if category exists */ - const check = sqlite.prepare(`SELECT category FROM maps WHERE category = ? LIMIT 1`).get(category) - if (!check) { + if (!wrapper.categoryExists(req.params.category)) { return res.json({ success: false, response: "Invalid category name!", }) } - const allMaps = sqlite.prepare(`SELECT * FROM maps WHERE category = ?`).all(category) + return res.json({ + success: true, + response: wrapper.mapCategory(req.params.category) + }) + } +) + +mapApi.get( + '/leaderboard/race/:map', + (req, res) => { + /* Check if map exists */ + if (!wrapper.mapExists(req.params.map)) { + return res.json({ + success: false, + response: "No such map!", + }) + } return res.json({ success: true, - response: allMaps, + response: wrapper.leaderboardRace(req.params.map, 1, 20) + }) + } +) + +mapApi.get( + '/leaderboard/teamrace/:map', + (req, res) => { + /* Check if map exists */ + if (!wrapper.mapExists(req.params.map)) { + return res.json({ + success: false, + response: "No such map!", + }) + } + + return res.json({ + success: true, + response: wrapper.leaderboardTeamrace(req.params.map, 1, 20) }) } ) diff --git a/api/players.js b/api/players.js index d8d7f4d..6bf13cf 100644 --- a/api/players.js +++ b/api/players.js @@ -1,32 +1,24 @@ import { Router } from 'express' -import { sqlite } from '../libs/database/init.js' -import searcher from '../libs/database/searcher.js' +import wrapper from '../libs/database/wrapper.js' const playerApi = Router() - playerApi.get( '/get/:player', async (req, res) => { - searcher( - 'points', - 'player', - req.params.player, - undefined, - false, - "get", - 0 - ).then( - player => res.json({ - success: true, - response: player - }) - ).catch( - error => res.json({ + /* Check if player exists */ + if(!wrapper.playerExists(req.params.player)) { + return res.json({ success: false, - response: error + response: "No such player!" }) - ) + } + const data = wrapper.player(req.params.player) + + return res.json({ + success: true, + response: data + }) } ) diff --git a/index.js b/index.js index 3f6b3e2..4983417 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import api from './api/api.js' import { generateDB } from './libs/database/generate.js' import { dbInit } from './libs/database/init.js' import { downloadEssentialData } from './libs/download/dowload.js' - +import tasks from './libs/database/tasks.js' loadEnv() diff --git a/libs/database/generate.js b/libs/database/generate.js index b9c5f69..0293681 100644 --- a/libs/database/generate.js +++ b/libs/database/generate.js @@ -43,11 +43,6 @@ export function generateDB() { `ALTER TABLE maps RENAME COLUMN Timestamp TO release` ]) } - log("Generating map index...") - execMany([ - `CREATE INDEX IF NOT EXISTS "idx_maps_map" ON "maps" ("map")`, - `CREATE INDEX IF NOT EXISTS "idx_maps_category" ON "maps" ("category")` - ]) log("Generating race index...") execMany([ @@ -62,12 +57,15 @@ export function generateDB() { log("Creating rankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "rankings" ( + "category" varchar(32) NOT NULL, + "points" integer NOT NULL DEFAULT 0, "map" varchar(128) NOT NULL, "player" varchar(16) NOT NULL, "time" float NOT NULL DEFAULT 0, "date" timestamp NOT NULL DEFAULT current_timestamp, "server" char(4) NOT NULL DEFAULT '', - "rank" INTEGER NOT NULL + "rank" INTEGER NOT NULL, + "finishes" INTEGER NOT NULL DEFAULT 0 ) `) @@ -78,7 +76,9 @@ export function generateDB() { execMany([ `CREATE INDEX IF NOT EXISTS "idx_rankings_map" ON "rankings" ("map")`, `CREATE INDEX IF NOT EXISTS "idx_rankings_rank" ON "rankings" ("rank")`, - `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("player")` + `CREATE INDEX IF NOT EXISTS "idx_rankings_player" ON "rankings" ("player")`, + `CREATE INDEX IF NOT EXISTS "idx_rankings_finishes" ON "rankings" ("finishes")`, + `CREATE INDEX IF NOT EXISTS "idx_rankings_mapRank" ON "rankings" ("map", "rank")` ]) log("Generating teamrace index...") @@ -91,6 +91,8 @@ export function generateDB() { log("Creating teamrankings table...") sqlite.exec(` CREATE TABLE IF NOT EXISTS "teamrankings" ( + "category" varchar(32) NOT NULL, + "points" integer NOT NULL DEFAULT 0, "map" varchar(128) NOT NULL, "id" varbinary(16) NOT NULL, "player" varchar(16) NOT NULL, @@ -108,7 +110,9 @@ export function generateDB() { execMany([ `CREATE INDEX IF NOT EXISTS "idx_teamrankings_map" ON "teamrankings" ("map")`, `CREATE INDEX IF NOT EXISTS "idx_teamrankings_rank" ON "teamrankings" ("teamrank")`, - `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("player")` + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_player" ON "teamrankings" ("player")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_playerCategoryMap" ON "teamrankings" ("player", "category", "map")`, + `CREATE INDEX IF NOT EXISTS "idx_teamrankings_mapTeamrank" ON "teamrankings" ("map", "teamrank")` ]) log("Generating graphRecordCache...") @@ -131,6 +135,38 @@ export function generateDB() { log("Inserting points to DB...") tasks.processAllPoints() + log("Generating a new maps table...") + /* Rename the old one as we wanna use that name for the new one*/ + sqlite.exec(`ALTER TABLE maps RENAME TO oldmaps`) + + sqlite.exec(` + CREATE TABLE IF NOT EXISTS "maps" ( + "map" varchar(128) NOT NULL, + "category" varchar(32) NOT NULL, + "points" integer NOT NULL DEFAULT 0, + "stars" integer NOT NULL DEFAULT 0, + "mapper" char(128) NOT NULL, + "release" timestamp NOT NULL DEFAULT current_timestamp, + + "avgTime" FLOAT NOT NULL DEFAULT 0, + "medianTime" FLOAT NOT NULL DEFAULT 0, + "topTime" FLOAT NOT NULL DEFAULT 0, + "topTeamTime" FLOAT NOT NULL DEFAULT 0, + "finishesUnique" INTEGER NOT NULL DEFAULT 0, + "finishesTotal" INTEGER NOT NULL DEFAULT 0, + "finishesTeam" INTEGER NOT NULL DEFAULT 0 + ) + `) + tasks.processMaps() + + log("Generating map index...") + execMany([ + `CREATE INDEX IF NOT EXISTS "idx_maps_map" ON "maps" ("map")`, + `CREATE INDEX IF NOT EXISTS "idx_maps_category" ON "maps" ("category")` + `CREATE INDEX IF NOT EXISTS "idx_maps_categoryMap" ON "maps" ("category", "map")` + ]) + + skinDB.exec(` CREATE TABLE IF NOT EXISTS "skindata" ( "timestamp" INTEGER NOT NULL, diff --git a/libs/database/init.js b/libs/database/init.js index 281f7be..84f2098 100644 --- a/libs/database/init.js +++ b/libs/database/init.js @@ -24,6 +24,9 @@ export function dbInit() { /* Unsafe mode */ sqlite.unsafeMode() + /* Load external extensions */ + sqlite.loadExtension('./math-func.so') + log("Loaded in 'ddnet.sqlite'!") log("Loaded in 'skindata.sqlite'!") } diff --git a/libs/database/tasks.js b/libs/database/tasks.js index c91bc6a..71e6e2f 100644 --- a/libs/database/tasks.js +++ b/libs/database/tasks.js @@ -7,26 +7,28 @@ import { sqlite } from './init.js' * @module libs/database/processRankings */ export function processRankings() { - const maps = sqlite.prepare(`SELECT map FROM maps`) + const maps = sqlite.prepare(`SELECT * FROM maps`) for (const map of maps.iterate()) sqlite .prepare(` INSERT INTO rankings ( - map, player, time, date, rank, server + map, category, points, player, time, date, rank, server, finishes ) - SELECT map, player, time, date, rank, server + SELECT a.map, b.category, b.points, a.player, a.time, a.date, a.rank, a.server, a.finishes FROM ( SELECT rank() OVER w AS rank, map, date, player, min(time) AS time, - server - FROM race + server, + COUNT(*) AS finishes + FROM race as a WHERE map = ? GROUP BY player window w AS (ORDER BY time) ) AS a + JOIN maps as b ON a.map = b.map ORDER BY rank `) .run(map.map) @@ -44,12 +46,13 @@ export function processTeamRankings() { .prepare(` INSERT INTO teamrankings ( - player, map, id, time, date, server, teamrank + player, map, id, time, date, server, teamrank, category, points ) SELECT DISTINCT(r.player), r.map, r.id, r.time, r.date, Substring(n.server, 1, 3), - dense_rank() OVER w AS rank + dense_rank() OVER w AS rank, + a.category, a.points FROM (( SELECT DISTINCT id FROM teamrace @@ -61,7 +64,9 @@ export function processTeamRankings() { INNER JOIN race AS n ON r.map = n.map AND r.player = n.player - AND r.time = n.time window w AS (ORDER BY r.time) + AND r.time = n.time + JOIN maps as a + ON r.map = a.map window w AS (ORDER BY r.time) `) .run(map.map) } @@ -99,6 +104,45 @@ export function processTimeGraph() { } } +/** + * This generates a fancy map table containing more data such total finishes, median time. + * @module libs/database/processMaps + */ +export function processMaps() { + const maps = sqlite.prepare(`SELECT map FROM oldmaps`) + const finishes = sqlite.prepare(`SELECT * FROM race WHERE map = ? ORDER BY date`) + + for (const map of maps.iterate()) { + const info = sqlite.prepare(`SELECT * FROM oldmaps WHERE map = ?`).get(map.map) + + const avgTime = sqlite.prepare(`SELECT avg(time) AS avgTime FROM race WHERE map = ?`).get(map.map)?.avgTime ?? -1 + const medianTime = sqlite.prepare(`SELECT median(time) as medianTime FROM race WHERE map = ?`).get(map.map)?.medianTime ?? -1 + + const teams = sqlite.prepare(`SELECT COUNT(distinct(id)) AS 'teams' FROM teamrace WHERE map = ?`).get(map.map)?.teams ?? -1 + const topTeamTime = sqlite.prepare(`SELECT time as topTeamTime FROM teamrankings WHERE map = ? ORDER BY Time ASC LIMIT 1`).get(map.map)?.topTeamTime ?? -1 + + const topTime = sqlite.prepare(`SELECT time as topTime FROM rankings WHERE map = ? ORDER BY Time ASC LIMIT 1`).get(map.map)?.topTime ?? -1 + + + const total = sqlite.prepare(`SELECT COUNT(*) AS 'total' FROM race WHERE map = ?`).get(map.map)?.total ?? -1 + const unique = sqlite.prepare(`SELECT COUNT(distinct(player)) AS 'unique' FROM race WHERE map = ?`).get(map.map)?.unique ?? -1 + + + sqlite.prepare(` + INSERT INTO "maps" + ( + map, category, points, stars, mapper, release, + avgTime, medianTime, topTime, topTeamTime, + finishesUnique, finishesTotal, finishesTeam + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + info.map, info.category, info.points, info.stars, info.mapper, info.release, + avgTime, medianTime, topTime, topTeamTime, + unique, total, teams, + ) + } +} + /** * This inserts all types of points into a table... * @module db/processAllPoints @@ -160,5 +204,6 @@ export default { processAllPoints, processRankings, processTeamRankings, + processMaps, processTimeGraph } \ No newline at end of file diff --git a/libs/database/wrapper.js b/libs/database/wrapper.js new file mode 100644 index 0000000..06c2cce --- /dev/null +++ b/libs/database/wrapper.js @@ -0,0 +1,335 @@ +import { sqlite } from './init.js' + +/** + * This function checks if a player exists + * + * @param {string} player The player to check + + * @returns {boolean} Returns a boolean + */ +export function playerExists(player) { + const exists = sqlite.prepare(`SELECT * FROM points WHERE player = ? LIMIT 1`).get(player) + + if(exists) + return true + else + return false +} + +/** + * This function checks if a map exists + * + * @param {string} player The map to check + + * @returns {boolean} Returns a boolean + */ +export function mapExists(map) { + const exists = sqlite.prepare(`SELECT * FROM maps WHERE map = ? LIMIT 1`).get(map) + + if(exists) + return true + else + return false +} + +/** + * This function checks if a map category exists + * + * @param {string} player The category to check + + * @returns {boolean} Returns a boolean + */ + export function categoryExists(category) { + const exists = sqlite.prepare(`SELECT category FROM maps WHERE category = ? LIMIT 1`).get(category) + + if(exists) + return true + else + return false +} + +/** + * This function returns all data pertaining a certain player + * + * @param {string} player The player to fetch + + * @returns {object} An object containing the players data + */ +export function player(player) { + /* Misc */ + const firstFinish = sqlite.prepare(`SELECT map, time, date, server FROM race WHERE player = ? ORDER BY date ASC LIMIT 1`).get(player) + + /* Points */ + let points = {} + let rank = {} + + const pointsData = sqlite.prepare(`SELECT * FROM points WHERE player = ?`) + + for (const pointsType of pointsData.iterate(player)) { + rank[pointsType.type] = pointsType.rank + } + for (const pointsType of pointsData.iterate(player)) { + points[pointsType.type] = pointsType.points + } + + return { + player, + firstFinish, + points, + rank, + } +} + +/** + * This function returns all data pertaining a certain map + * + * @param {string} map The map to fetch + + * @returns {object} An object containing map data + */ +export function map(map) { + const a = sqlite.prepare(` + SELECT * FROM maps WHERE map = ? + `).get(map) + + return prettyifyMap(a) +} + +export function mapCategory(category) { + let output = [] + const maps = sqlite.prepare(` + SELECT * FROM maps WHERE category = ?`).all(category) + + for(const map of maps) { + output.push(prettyifyMap(map)) + } + return output +} + +/** + * This function returns all data pertaining to all maps + + * @returns {array} An array contaning all map objects + */ +export function allMaps() { + let output = [] + const maps = sqlite.prepare(` + SELECT * FROM maps`).all() + + for(const map of maps) { + output.push(prettyifyMap(map)) + } + return output +} + +/** + * This function returns all data pertaining a certain map + * + * @param {string} map The map to fetch + + * @returns {object} An object containing map data + */ + export function prettyifyMap(a) { + let output + + output = { + map: a.map, + category: a.category, + points: a.points, + stars: a.stars, + release: a.release, + mappers: a.mapper.split(" & "), + + times: { + average: a.avgTime, + median: a.medianTime, + topTime: a.topTime, + topTimeTeam: (a.topTeamTime != -1) ? a.topTeamTime : undefined, + }, + finishes: { + total: a.finishesTotal, + team: a.finishesTeam, + unique: a.finishesUnique, + } + } + + return output +} + +/** + * This function returns the race leaderboard for a map + * + * @param {string} map The map to check + * @param {number} start At which rank the leaderboard should begin + * @param {number} end At which rank the leaderboard should end + + * @returns {array} An array containing the leaderboard + */ +export function leaderboardRace(map, start, end) { + const leaderboard = sqlite.prepare(` + SELECT rank, time, date, player, server FROM rankings WHERE map = ? AND rank >= ? AND rank <= ?`) + .all(map, start, end) + + return leaderboard +} + +/** + * This function returns the teamrace leaderboard for a map + * + * @param {string} map The map to check + * @param {number} start At which rank the leaderboard should begin + * @param {number} end At which rank the leaderboard should end + + * @returns {array} An array containing the leaderboard + */ + export function leaderboardTeamrace(map, start, end) { + // TODO: Optimize array creation of players + let leaderboard = [] + + const a = sqlite.prepare(` + SELECT teamrank, time, date, player, server FROM teamrankings WHERE map = ? AND teamrank >= ? AND teamrank <= ? GROUP BY teamrank`) + + for(const teamrank of a.iterate(map, start, end)) { + let players = [] + const b = sqlite.prepare(`SELECT player FROM teamrankings WHERE map = ? AND teamrank = ?`) + + for(const player of b.iterate(map, teamrank.teamrank)) { + players.push(player.player) + } + leaderboard.push({ + teamrank: teamrank.teamrank, + time: teamrank.time, + date: teamrank.date, + server: teamrank.server, + players: players, + }) + } + return leaderboard +} + +/** + * This function returns the points leaderboard for a specific type + * (points, pointsRank, pointsTeam, pointsThisWeek, pointsThisMonth) + * + * @param {string} type Which type of points to fetch + * @param {number} start At which rank the leaderboard should begin + * @param {number} end At which rank the leaderboard should end + + * @returns {array} An array containing the leaderboard + */ +export function leaderboardPoints(map, start, end) { + const leaderboard = sqlite.prepare(` + SELECT rank, player, points FROM points WHERE type = ? AND rank >= ? AND rank <= ? ORDER BY rank`) + .all(type, start, end) + + return leaderboard +} + +/** + * This function returns all finished maps by a specific player + * togheter with their respective rank, teamrank, amount of finishes. + * Finishes are grouped by map category (Novice, Brutal) + * + * @param {string} player The player to check + + * @returns {object} An object containing all finishes grouped by category + */ +export function finishedMaps(player) { + const finishesStmt = sqlite.prepare( + ` + SELECT a.map, + a.category, + a.points, + a.rank, + b.teamrank, + a.finishes + FROM rankings AS a + LEFT OUTER JOIN teamrankings AS b + ON a.player = b.player + AND a.category = b.category + AND a.map = b.map + WHERE a.player = ? + `) + + let finishes = { + Novice: [], + Moderate: [], + Brutal: [], + Insane: [], + Dummy: [], + DDmaX: [], + Oldschool: [], + Solo: [], + Race: [], + Fun: [] + } + for (const finish of finishesStmt.iterate(player)) { + finishes[finish.category].push(finish) + } + + return finishes +} + +/** + * This function returns all unfinished maps by a specific player + * togheter with category, points, finishTotal and medianTime. + * Maps are grouped by the map category (Novice, Brutal) + * + * @param {string} player The player to check + + * @returns {object} An object containing all unfinished maps + */ + export function unfinishedMaps(player) { + const maps = sqlite.prepare( + ` + SELECT a.map, + a.category, + a.points, + b.finishesTotal, + b.medianTime + FROM (SELECT category, + map, + points + FROM maps + WHERE map NOT IN (SELECT map + FROM rankings + WHERE player = ? )) AS a + JOIN maps AS b + ON a.category = b.category + AND a.map = b.map + ORDER BY b.category ASC; + `) + + let unfinished = { + Novice: [], + Moderate: [], + Brutal: [], + Insane: [], + Dummy: [], + DDmaX: [], + Oldschool: [], + Solo: [], + Race: [], + Fun: [] + } + for (const map of maps.iterate(player)) { + unfinished[map.category].push(map) + } + + return unfinished +} + +export default { + playerExists, + finishedMaps, + unfinishedMaps, + player, + + map, + mapCategory, + allMaps, + mapExists, + leaderboardRace, + leaderboardTeamrace, + categoryExists, +} \ No newline at end of file diff --git a/math-func.so b/math-func.so new file mode 100755 index 0000000000000000000000000000000000000000..3a3fc0bc664b1814517cbe9422e90bbe0a5c4897 GIT binary patch literal 86392 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

hh*AkJ%O^m!oyrOv6Im_uup>;ui%JKYn`G8T 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 literal 0 HcmV?d00001