From 5e1deb5047d48d5676022c6ccead3c347ea07ffc Mon Sep 17 00:00:00 2001 From: Andrey <145541424+PlutoniumSoup@users.noreply.github.com> Date: Sat, 14 Sep 2024 21:11:20 +0300 Subject: [PATCH] Feat/game data to db (#31) ## How does this PR impact the user? ## Description Updated Prisma scheme: - Added `food_eaten` field in GameStats - Changed some fileds name - Changed `player_id` field type `Int` -> `String` Updated end game functuion: - Separated end game code and created new function `endGame` - Added function that writes game stats to the db ## Limitations ## Checklist - [x] my PR is focused and contains one wholistic change - [ ] I have added screenshots or screen recordings to show the changes --- .../migration.sql | 21 +++++++++ .../migration.sql | 30 +++++++++++++ .../migration.sql | 19 ++++++++ schema.prisma | 27 ++++++----- server/api/bot/submit.post.ts | 1 + server/plugins/engine.ts | 25 ++++++++++- utils/botCodeStore.ts | 17 ++++--- utils/prepareBotCode.spec.ts | 3 ++ utils/recordGamedb.ts | 45 +++++++++++++++++++ utils/world.ts | 7 +++ 10 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 migrations/20240908164726_update_game_fields_types/migration.sql create mode 100644 migrations/20240914132517_update_fields/migration.sql create mode 100644 migrations/20240914132735_update_fields/migration.sql create mode 100644 utils/recordGamedb.ts diff --git a/migrations/20240908164726_update_game_fields_types/migration.sql b/migrations/20240908164726_update_game_fields_types/migration.sql new file mode 100644 index 0000000..254460d --- /dev/null +++ b/migrations/20240908164726_update_game_fields_types/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to alter the column `end_time` on the `Game` table. The data in that column could be lost. The data in that column will be cast from `String` to `DateTime`. + - You are about to alter the column `start_time` on the `Game` table. The data in that column could be lost. The data in that column will be cast from `String` to `DateTime`. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Game" ( + "game_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "end_reason" TEXT NOT NULL +); +INSERT INTO "new_Game" ("end_reason", "end_time", "game_id", "start_time") SELECT "end_reason", "end_time", "game_id", "start_time" FROM "Game"; +DROP TABLE "Game"; +ALTER TABLE "new_Game" RENAME TO "Game"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/migrations/20240914132517_update_fields/migration.sql b/migrations/20240914132517_update_fields/migration.sql new file mode 100644 index 0000000..f6b3e4d --- /dev/null +++ b/migrations/20240914132517_update_fields/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `num_ate` on the `GameStats` table. All the data in the column will be lost. + - You are about to drop the column `num_eaten` on the `GameStats` table. All the data in the column will be lost. + - You are about to drop the column `player_id` on the `GameStats` table. All the data in the column will be lost. + - Added the required column `deaths` to the `GameStats` table without a default value. This is not possible if the table is not empty. + - Added the required column `food_eaten` to the `GameStats` table without a default value. This is not possible if the table is not empty. + - Added the required column `kills` to the `GameStats` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `GameStats` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_GameStats" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "game_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "size" REAL NOT NULL, + "food_eaten" INTEGER NOT NULL, + "kills" INTEGER NOT NULL, + "deaths" INTEGER NOT NULL, + CONSTRAINT "GameStats_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "Game" ("game_id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_GameStats" ("game_id", "id", "size") SELECT "game_id", "id", "size" FROM "GameStats"; +DROP TABLE "GameStats"; +ALTER TABLE "new_GameStats" RENAME TO "GameStats"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/migrations/20240914132735_update_fields/migration.sql b/migrations/20240914132735_update_fields/migration.sql new file mode 100644 index 0000000..12ae1e5 --- /dev/null +++ b/migrations/20240914132735_update_fields/migration.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_GameStats" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "game_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "size" REAL NOT NULL, + "food_eaten" INTEGER NOT NULL, + "kills" INTEGER NOT NULL, + "deaths" INTEGER NOT NULL, + CONSTRAINT "GameStats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "GameStats_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "Game" ("game_id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_GameStats" ("deaths", "food_eaten", "game_id", "id", "kills", "size", "user_id") SELECT "deaths", "food_eaten", "game_id", "id", "kills", "size", "user_id" FROM "GameStats"; +DROP TABLE "GameStats"; +ALTER TABLE "new_GameStats" RENAME TO "GameStats"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/schema.prisma b/schema.prisma index 9ef9f4a..2a7861f 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1,32 +1,35 @@ datasource db { provider = "sqlite" url = env("DATABASE_URL") -} +} generator client { provider = "prisma-client-js" } model User { - id Int @id @default(autoincrement()) - password String - username String @unique + id Int @id @default(autoincrement()) + password String + username String @unique + game_stats GameStats[] } model Game { - game_id Int @id @default(autoincrement()) - start_time String - end_time String + game_id Int @id @default(autoincrement()) + start_time DateTime + end_time DateTime end_reason String game_stats GameStats[] } model GameStats { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) game_id Int - player_id Int + user_id Int size Float - num_eaten Int - num_ate Int + food_eaten Int + kills Int + deaths Int + user User @relation(fields: [user_id], references: [id]) games Game @relation(fields: [game_id], references: [game_id]) -} \ No newline at end of file +} diff --git a/server/api/bot/submit.post.ts b/server/api/bot/submit.post.ts index b7fd18b..484c73a 100644 --- a/server/api/bot/submit.post.ts +++ b/server/api/bot/submit.post.ts @@ -13,6 +13,7 @@ export default defineEventHandler(async (event) => { botCodeStore.submitBotCode({ username: event.context.user.username, + userId: event.context.user.id, code: result.data.code, }); diff --git a/server/plugins/engine.ts b/server/plugins/engine.ts index e7af3c8..8cebad7 100644 --- a/server/plugins/engine.ts +++ b/server/plugins/engine.ts @@ -2,6 +2,7 @@ import ivm from "isolated-vm"; import prepareBotCode from "../../utils/prepareBotCode"; import * as botCodeStore from "~/utils/botCodeStore"; import World from "~/utils/world"; +import { recordGameEnd, type GameStat } from "~/utils/recordGamedb"; const MEMORY_LIMIT_MB = 64; const TIME_LIMIT_MS = 75; @@ -70,18 +71,38 @@ function startEngine({ botApi }: StartEngineArgs) { const worldState = WORLD_REF.world.getState(); for (const bot of worldState.bots.values()) { if (bot.radius > worldState.height / 2) { - WORLD_REF.world = new World ({ width: 600, height: 600 }); + endGame("Player overdominating"); console.debug(`The World was restarted cus ${bot.botId} was oversized`); } } }, 250); setInterval(() => { - WORLD_REF.world = new World ({ width: 600, height: 600 }); + endGame("Time's up"); console.debug(`The World was restarted after ${MAX_ROUND_TIME_MS} ms`); }, MAX_ROUND_TIME_MS); } +function endGame(reason: string) { + const endTime = new Date(); + const worldState = WORLD_REF.world.getState(); + recordGameEnd({ + startTime: worldState.startTime, + endTime: endTime, + endReason: reason, + stats: Array.from(worldState.stats.entries()).map(([botId, stat]) => { + return { + userId: botCodeStore.getBots()[botId]?.userId, + size: worldState.bots.get(WORLD_REF.world.getSpawnId(botId))?.radius, + foodEaten: stat.foodEaten, + kills: stat.kills, + deaths: stat.deaths, + }; + }).filter((entry): entry is GameStat => entry.userId !== undefined && entry.size !== undefined), + }); + WORLD_REF.world = new World ({ width: 600, height: 600 }); +} + export default defineNitroPlugin(async () => { const level01Api = await useStorage("assets:botApis").getItem("level01.js"); diff --git a/utils/botCodeStore.ts b/utils/botCodeStore.ts index 81f2ccc..6896c8f 100644 --- a/utils/botCodeStore.ts +++ b/utils/botCodeStore.ts @@ -7,6 +7,7 @@ export interface BotCode { id: string; code: string; username: string; + userId: number; } export type BotCodes = Record; @@ -18,12 +19,13 @@ const botEventEmitter = new EventEmitter(); type SubmitBotCodeArgs = { code: string; username: string; + userId: number; }; -export function submitBotCode({ code, username }: SubmitBotCodeArgs) { +export function submitBotCode({ code, username, userId }: SubmitBotCodeArgs) { // ensure each user has only one bot const id = Object.values(STORE).find(botCode => botCode.username === username)?.id || Math.random().toString(36).substring(5); - const botCode = { id, code, username }; + const botCode = { id, code, username, userId }; STORE[id] = botCode; saveBot(botCode); botEventEmitter.emit("update", STORE); @@ -52,19 +54,20 @@ function loadBots() { for (const file of files) { const code = fs.readFileSync(`${BOT_CODE_DIR}/${file}`, "utf8"); const username = file.split("-")[0]; - const id = file.split("-")[1]?.replace(".js", ""); - if (!id || !code || !username) { + const id = file.split("-")[1]; + const userId = file.split("-")[2]?.replace(".js", ""); + if (!id || !code || !username || !userId) { throw new Error(`Invalid bot code file: ${file}`); } - STORE[id] = { id, code, username }; + STORE[id] = { id, code, username, userId: +userId }; } } -function saveBot({ id, code, username }: BotCode) { +function saveBot({ id, code, username, userId }: BotCode) { if (!fs.existsSync(BOT_CODE_DIR)) { fs.mkdirSync(BOT_CODE_DIR); } - const botCodeFile = `${BOT_CODE_DIR}/${username}-${id}.js`; + const botCodeFile = `${BOT_CODE_DIR}/${username}-${id}-${userId}.js`; fs.writeFileSync(botCodeFile, code); } diff --git a/utils/prepareBotCode.spec.ts b/utils/prepareBotCode.spec.ts index 3f3857c..ac55685 100644 --- a/utils/prepareBotCode.spec.ts +++ b/utils/prepareBotCode.spec.ts @@ -6,6 +6,7 @@ it("prepares bot code correctly when there is one bot in the world", () => { id: "1", code: "console.log('hello world');", username: "yurij", + userId: 1, }; const state = { @@ -27,6 +28,7 @@ it("prepares bot code correctly when there are two bots in the world", () => { id: "1", code: "console.log('hello world');", username: "yurij", + userId: 1, }; const state = { @@ -51,6 +53,7 @@ it("prepares bot code correctly when there are three bots and some food in the w id: "1", code: "console.log('hello world');", username: "yurij", + userId: 1, }; const state = { diff --git a/utils/recordGamedb.ts b/utils/recordGamedb.ts new file mode 100644 index 0000000..fa3dccc --- /dev/null +++ b/utils/recordGamedb.ts @@ -0,0 +1,45 @@ +import prisma from "~/utils/db"; + +export type GameStat = { + userId: number; + size: number; + foodEaten: number; + kills: number; + deaths: number; +}; + +type RecordGameArgs = { + startTime: Date; + endTime: Date; + endReason: string; + stats: GameStat[]; +}; + +export async function recordGameEnd({ startTime, endTime, endReason, stats }: RecordGameArgs) { + try { + // Создание записи о завершении игры + const game = await prisma.game.create({ + data: { + start_time: startTime, + end_time: endTime, + end_reason: endReason, + game_stats: { + create: stats.map(stat => ({ + user_id: stat.userId, + size: stat.size, + food_eaten: stat.foodEaten, + kills: stat.kills, + deaths: stat.deaths, + })), + }, + }, + include: { + game_stats: true, + }, + }); + + console.log("Game record added:", game); + } catch (error) { + console.error("Error recording game end:", error); + } +} diff --git a/utils/world.ts b/utils/world.ts index 58ddf67..9316eee 100644 --- a/utils/world.ts +++ b/utils/world.ts @@ -23,6 +23,7 @@ export interface WorldState { width: number; height: number; stats: Map; + startTime: Date; } type BotSprites = Map; @@ -60,6 +61,7 @@ export default class World { private minSpawnDistance = 10; private maxMoveDistance = 2; private newBotRadius = 5; + private startTime = new Date(); constructor({ width, height }: WorldArgs) { this.width = width; @@ -163,6 +165,7 @@ export default class World { width: this.width, height: this.height, stats: this.stats, + startTime: this.startTime, }; } @@ -284,4 +287,8 @@ export default class World { return Boolean(botIdsToRemove.length || foodIdxToRemove.length); } + + getSpawnId(botId: string) { + return String(this.botIdToSpawnId.get(botId)); + } }