From 7fdd87a6c74a9f5e1968c6715ce24e6211093186 Mon Sep 17 00:00:00 2001 From: Sarah Ohlin Date: Mon, 9 Jan 2023 16:57:07 -0500 Subject: [PATCH] feat: added the hiding of replays --- ' | 244 ++++++++++++++++++ assets/gui/eye_open.png | Bin 0 -> 384 bytes assets/gui/minus.png | Bin 0 -> 114 bytes backend/controllers/Auth.ts | 5 +- backend/controllers/Level.ts | 26 +- backend/controllers/Replay.ts | 49 +++- backend/controllers/User.ts | 8 +- backend/index.ts | 3 +- backend/util/tools.ts | 1 + game/api.cpp | 43 ++- game/api.hpp | 12 +- game/gui/leaderboard_modal.cpp | 42 ++- game/gui/leaderboard_modal.hpp | 1 + game/gui/level_card.cpp | 10 +- game/gui/level_card.hpp | 7 +- game/gui/menu_bar.cpp | 17 +- game/gui/menu_bar.hpp | 8 +- game/gui/user_modal.cpp | 4 +- game/gui/user_modal.hpp | 4 +- game/states/edit.cpp | 5 +- game/states/edit.hpp | 2 +- game/states/search.cpp | 11 +- game/states/search.hpp | 6 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + prisma/seed.ts | 1 + 26 files changed, 448 insertions(+), 64 deletions(-) create mode 100644 ' create mode 100644 assets/gui/eye_open.png create mode 100644 assets/gui/minus.png create mode 100644 prisma/migrations/20230109203606_hideable_replays/migration.sql diff --git a/' b/' new file mode 100644 index 0000000..1979f2e --- /dev/null +++ b/' @@ -0,0 +1,244 @@ +import { Request, Response } from 'express'; +import validator from 'validator'; + +import * as tools from '@util/tools'; + +import { stringify } from 'flatted'; + +import { prisma } from '@db/index'; +import log from '@/log'; +import { UserLevelScore } from '@prisma/client'; + +import { IsIn, IsInt, IsNumber, IsOptional, Max, Min, validate } from 'class-validator'; + +const SortableFields = ['time', 'author', 'createdAt', 'updatedAt'] as const; +const SortDirections = ['asc', 'desc'] as const; + +export class IReplaySearchOptions { + @IsInt({ message: 'Malformed levelId' }) + @Min(0) + levelId!: number; + + @IsOptional() + @IsNumber(undefined, { message: 'Malformed cursor' }) + cursor?: number; + + @IsInt({ message: 'Malformed limit' }) + @Min(1) + @Max(20) + limit!: number; + + @IsIn(SortableFields) + sortBy!: typeof SortableFields[number]; + + @IsIn(SortDirections) + order!: typeof SortDirections[number]; +} + +export interface IReplayResponse { + id: number; + user: string; + levelId: number; + time: number; + version: string; + raw: string; + createdAt: number; + updatedAt: number; + alt: boolean; + levelVersion: number; +} + +export const ScoreQueryInclude = { + include: { + user: true, + }, +}; + +export const ScoreQueryHide = (uid?: number) => ({ + OR: [ + { + hidden: false, + }, + ...(uid != undefined + ? [ + { + userId: uid, + }, + ] + : []), + ], +}); +var ScoreQueryHide = (uid) => ({ + OR: [ + { + hidden: false, + }, + ...(uid != undefined + ? [ + { + userId: uid, + }, + ] + : []), + ], +}); + +export async function uploadParsedReplay( + data: tools.IReplayData, + userId: number, + levelId?: number +): Promise { + try { + let score: tools.UserLevelScoreRunner | undefined = await prisma.userLevelScore.findFirst({ + where: { + userId: userId, + levelId: levelId ?? data.header.levelId, + }, + ...ScoreQueryInclude, + }); + const lvl = await prisma.level.findUnique({ where: { id: levelId ?? data.header.levelId } }); + if (lvl == null) throw '(NO_FIND_LVL)'; + if (score == undefined || data.header.time < score.time || score.levelVersion != lvl.version) { + score = await prisma.userLevelScore.create({ + data: { + user: { connect: { id: userId } }, + level: { connect: { id: levelId ?? data.header.levelId } }, + replay: data.raw, + time: data.header.time, + version: data.header.version, + alt: data.header.alt, + levelVersion: lvl.version, + }, + ...ScoreQueryInclude, + }); + } + return score; + } catch (e) { + log.warn(`Failure to upload level score: ${stringify(e)}`); + return undefined; + } +} + +export async function uploadReplay( + b64replay: string, + userId: number, + levelId?: number +): Promise { + const buf = Buffer.from(b64replay, 'base64'); + const data: tools.IReplayData | undefined = tools.decodeRawReplay(buf); + if (!data) return undefined; + return await uploadParsedReplay(data, userId, levelId); +} + +// replay controller +export default class Replay { + // upload a new replay file + static async upload(req: Request, res: Response) { + const token: tools.IAuthToken = res.locals.token; + const b64replay = req.body.replay; + + const replay: tools.UserLevelScoreRunner | undefined = await uploadReplay(b64replay, token.id); + if (replay == null) { + return res.status(400).send({ error: 'Invalid replay base64 data.' }); + } + return res.status(200).send({ newBest: replay.time }); + } + + // hide your own replay file + static async hide(req: Request, res: Response) { + const token: tools.IAuthToken = res.locals.token; + const id = parseInt(req.params.id ?? 'nan'); + const hidden: boolean = req.params.hide === 'hide'; + if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' }); + try { + const replay = await prisma.userLevelScore.findUnique({ + where: { id }, + ...ScoreQueryInclude, + }); + if (replay == null) + return res.status(400).send({ error: 'A replay with that ID was not found.' }); + if (replay.user.id != token.id) + return res.status(403).send({ error: 'You do not have permission to hide this replay.' }); + await prisma.userLevelScore.update({ + where: { id }, + data: { hidden }, + }); + return res.status(200).send(); + } catch (e) { + return res.status(500).send({ error: 'Internal server error (NO_HIDE_RPL)' }); + } + } + + static async get(req: Request, res: Response) { + const token: tools.IAuthToken | undefined = res.locals.token; + const id = parseInt(req.params.id ?? 'nan'); + if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' }); + try { + const replay = await prisma.userLevelScore.findFirst({ + where: { + id, + ...ScoreQueryHide(token?.id), + }, + ...ScoreQueryInclude, + }); + if (replay == null) + return res.status(400).send({ error: 'A replay with that ID was not found.' }); + return res.status(200).send({ replay: tools.toReplayResponse(replay) }); + } catch (e) { + return res.status(500).send({ error: 'Internal server error (NO_FETCH_RPL)' }); + } + } + + // search for replays + static async search(req: Request, res: Response) { + const token: tools.IAuthToken | undefined = res.locals.token; + const levelId = parseInt(req.params.levelId ?? 'nan'); + if (isNaN(levelId)) { + return res.status(400).send({ error: 'Invalid levelId parameter.' }); + } + let opts: IReplaySearchOptions = new IReplaySearchOptions(); + try { + opts.cursor = req.body.cursor ?? -1; + opts.limit = req.body.limit ?? 20; + opts.levelId = levelId; + opts.order = req.body.order ?? 'asc'; + opts.sortBy = req.body.sortBy ?? 'time'; + const errors = await validate(opts); + if (errors?.length > 0) { + return res.status(400).send({ + error: Object.entries(errors[0].constraints ?? [[0, 'Malformed Request Body']])[0][1], + }); + } + } catch (e) { + return res.status(400).send({ error: 'Malformed Request Body' }); + } + + const scores = await prisma.userLevelScore.findMany({ + take: opts.limit, + ...(opts.cursor > 1 && { + skip: 1, + cursor: { + id: opts.cursor, + }, + }), + where: { + levelId, + }, + orderBy: [{ levelVersion: 'desc' }, { [opts.sortBy]: opts.order }], + ...ScoreQueryInclude, + }); + + if (scores.length === 0) { + return res.status(200).send({ + scores: [], + cursor: -1, + }); + } + const lastScore = scores[scores.length - 1]; + + return res.status(200).send({ + scores: scores.map((score) => tools.toReplayResponse(score)), + cursor: lastScore?.id && scores.length >= opts.limit ? lastScore.id : -1, + }); + } +} diff --git a/assets/gui/eye_open.png b/assets/gui/eye_open.png new file mode 100644 index 0000000000000000000000000000000000000000..51d0e93419be0ea47df15b69f191f2f2fec11450 GIT binary patch literal 384 zcmV-`0e}99P)Px$IY~r8R5(v#WS|f*QmCJSCI2DmNbL1&oYJ|9IOn%J)R7c6?x%?zzz8S zAL4bF!dqba`l&SxZ=YXhnBJbuU~j3*;E=YR;oZld3<)bD!17ZSct8{*K?6XlgvAvZ zJ~GIIHR3b??1jI78Irmlp?KlLhYt+@n7J94xMUdkrTG|wdpsB>EAnA@0jwWeAYQq0 zk>Sgi&tMI@{+c)g5oRLTkm;o>z^1MJZ3vFWB;osD`Sz6+c%u=;5Il(qZ$iSO54#ji e@&ZY&0028Ffe{pr@#Fvi002ovPDHLkV1fXY ({ include: { author: true, votes: true, scores: { ...ScoreQueryInclude, + where: { + ...ScoreQueryHide(uid), + }, }, _count: { select: { @@ -48,7 +50,7 @@ export const LevelQueryInclude = { }, }, }, -}; +}); export interface ILevelResponse { id: number; @@ -143,7 +145,7 @@ export default class Level { update: { vote: vote === 'like' ? 1 : -1, }, - include: { level: { ...LevelQueryInclude } }, + include: { level: { ...LevelQueryInclude(token.id) } }, }); return res.status(200).send({ level: tools.toLevelResponse(voteModel.level, token.id), @@ -238,7 +240,7 @@ export default class Level { orderBy: { [opts.sortBy]: opts.order, }, - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }); if (levels.length === 0) { @@ -265,6 +267,7 @@ export default class Level { * @param {int} req.params.id */ static async getById(req: Request, res: Response) { + const token: tools.IAuthToken | undefined = res.locals.token; const id: string | undefined = req.params.id; if (!id) { return res.status(400).send({ error: 'No id specified' }); @@ -284,7 +287,7 @@ export default class Level { increment: 1, }, }, - ...LevelQueryInclude, + ...LevelQueryInclude(token.id), }); if (!level) { return res.status(404).send({ error: `Level id "${id}" not found` }); @@ -301,6 +304,7 @@ export default class Level { static async getQuickplay(req: Request, res: Response) { try { + const token: tools.IAuthToken | undefined = res.locals.token; const levelIds = await prisma.level.findMany({ select: { id: true, @@ -309,7 +313,7 @@ export default class Level { const { id } = levelIds[Math.floor(Math.random() * levelIds.length)]; const level = await prisma.level.findUnique({ where: { id }, - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }); if (!level) { return res.status(500).send({ error: `Quickplay fetch failed.` }); @@ -433,7 +437,7 @@ export default class Level { data: { verification: { connect: { id: updatedLevelPreVerify.scores[0].id } }, }, - ...LevelQueryInclude, + ...LevelQueryInclude(token.id), }) : null; @@ -463,7 +467,7 @@ export default class Level { }, }, }, - ...LevelQueryInclude, + ...LevelQueryInclude(token.id), }); newLevel = newLevel != null @@ -472,7 +476,7 @@ export default class Level { data: { verification: { connect: { id: newLevel.scores[0].id } }, }, - ...LevelQueryInclude, + ...LevelQueryInclude(token.id), }) : newLevel; diff --git a/backend/controllers/Replay.ts b/backend/controllers/Replay.ts index a09a6fc..50f9ada 100644 --- a/backend/controllers/Replay.ts +++ b/backend/controllers/Replay.ts @@ -46,6 +46,7 @@ export interface IReplayResponse { updatedAt: number; alt: boolean; levelVersion: number; + hidden: boolean; } export const ScoreQueryInclude = { @@ -54,6 +55,21 @@ export const ScoreQueryInclude = { }, }; +export const ScoreQueryHide = (uid?: number) => ({ + OR: [ + { + hidden: false, + }, + ...(uid != undefined + ? [ + { + userId: uid, + }, + ] + : []), + ], +}); + export async function uploadParsedReplay( data: tools.IReplayData, userId: number, @@ -115,15 +131,43 @@ export default class Replay { return res.status(200).send({ newBest: replay.time }); } - static async get(req: Request, res: Response) { - const token: tools.IAuthToken | undefined = res.locals.token; + // hide your own replay file + static async hide(req: Request, res: Response) { + const token: tools.IAuthToken = res.locals.token; const id = parseInt(req.params.id ?? 'nan'); + const hidden: boolean = req.params.hide === 'hide'; if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' }); try { const replay = await prisma.userLevelScore.findUnique({ where: { id }, ...ScoreQueryInclude, }); + if (replay == null) + return res.status(400).send({ error: 'A replay with that ID was not found.' }); + if (replay.user.id != token.id) + return res.status(403).send({ error: 'You do not have permission to hide this replay.' }); + await prisma.userLevelScore.update({ + where: { id }, + data: { hidden }, + }); + return res.status(200).send(); + } catch (e) { + return res.status(500).send({ error: 'Internal server error (NO_HIDE_RPL)' }); + } + } + + static async get(req: Request, res: Response) { + const token: tools.IAuthToken | undefined = res.locals.token; + const id = parseInt(req.params.id ?? 'nan'); + if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' }); + try { + const replay = await prisma.userLevelScore.findFirst({ + where: { + id, + ...ScoreQueryHide(token?.id), + }, + ...ScoreQueryInclude, + }); if (replay == null) return res.status(400).send({ error: 'A replay with that ID was not found.' }); return res.status(200).send({ replay: tools.toReplayResponse(replay) }); @@ -166,6 +210,7 @@ export default class Replay { }), where: { levelId, + ...ScoreQueryHide(token?.id), }, orderBy: [{ levelVersion: 'desc' }, { [opts.sortBy]: opts.order }], ...ScoreQueryInclude, diff --git a/backend/controllers/User.ts b/backend/controllers/User.ts index 9900391..efb3d29 100644 --- a/backend/controllers/User.ts +++ b/backend/controllers/User.ts @@ -37,14 +37,14 @@ export default class User { levels: { orderBy: { createdAt: 'desc' }, take: 1, - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }, scores: { orderBy: { createdAt: 'desc' }, include: { user: true, level: { - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }, }, }, @@ -114,14 +114,14 @@ export default class User { levels: { orderBy: { createdAt: 'desc' }, take: 1, - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }, scores: { orderBy: { createdAt: 'desc' }, include: { user: true, level: { - ...LevelQueryInclude, + ...LevelQueryInclude(token?.id), }, }, }, diff --git a/backend/index.ts b/backend/index.ts index 9681270..b044667 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -31,7 +31,7 @@ app.post('/reset-password', Auth.ResetPassword); app.post('/level/upload/:confirm?', requireAuth(0), Level.upload); app.post('/level/search', checkAuth(), Level.search); -app.get('/level/quickplay', Level.getQuickplay); +app.get('/level/quickplay', checkAuth(), Level.getQuickplay); app.get('/level/:id/ping-download', Level.downloadPing); app.post('/level/:id(\\d+)/:vote(like|dislike)', requireAuth(0), Level.vote); app.post('/level/:id', checkAuth(), Level.getById); @@ -39,6 +39,7 @@ app.post('/level/:id', checkAuth(), Level.getById); app.post('/replay/upload', requireAuth(0), Replay.upload); app.post('/replay/search/:levelId(\\d+)', checkAuth(), Replay.search); app.post('/replay/:id(\\d+)', checkAuth(), Replay.get); +app.post('/replay/:id(\\d+)/:hide(hide|unhide)', requireAuth(0), Replay.hide); app.post('/comments/level/:levelId(\\d+)', Comment.get); app.post('/comments/new/:levelId(\\d+)', requireAuth(0), Comment.post); diff --git a/backend/util/tools.ts b/backend/util/tools.ts index 1861335..2015743 100644 --- a/backend/util/tools.ts +++ b/backend/util/tools.ts @@ -234,6 +234,7 @@ export function toReplayResponse(replay: UserLevelScoreRunner): IReplayResponse updatedAt: replay.updatedAt.getTime() / 1000, alt: replay.alt, levelVersion: replay.levelVersion, + hidden: replay.hidden, }; } diff --git a/game/api.cpp b/game/api.cpp index ee0f2d2..80c76d1 100644 --- a/game/api.cpp +++ b/game/api.cpp @@ -25,6 +25,46 @@ api &api::get() { return instance; } +std::future api::set_replay_visibility(int rid, bool visible) { + return std::async([this, rid, visible]() -> api::response { + try { + nlohmann::json body = nlohmann::json::object(); + auth::get().add_jwt_to_body(body); + if (auto res = m_cli.Post("/replay/" + std::to_string(rid) + (visible ? "/unhide" : "/hide"), body.dump(), "application/json")) { + if (res->status == 200) { + return { .success = true }; + } else { + nlohmann::json result = nlohmann::json::parse(res->body); + if (result.contains("error")) { + std::cout << result.dump() << std::endl; + throw std::runtime_error(result["error"]); + } else { + throw "Unknown server error"; + } + } + } else { + debug::log() << httplib::to_string(res.error()) << "\n"; + throw "Could not connect to server"; + } + } catch (const char *e) { + return { + .success = false, + .error = e + }; + } catch (std::exception &e) { + return { + .success = false, + .error = e.what() + }; + } catch (...) { + return { + .success = false, + .error = "Unknown error." + }; + } + }); +} + std::future api::fetch_user_stats(int id) { return std::async([this, id]() -> api::user_stats_response { try { @@ -617,7 +657,8 @@ bool api::replay::operator==(const api::replay &other) const { other.time == time && other.user == user && other.version == version && - other.alt == alt; + other.alt == alt && + other.hidden == hidden; } bool api::replay::operator!=(const api::replay &other) const { diff --git a/game/api.hpp b/game/api.hpp index 7c742f4..c50f052 100644 --- a/game/api.hpp +++ b/game/api.hpp @@ -16,6 +16,13 @@ class api { public: static api& get(); + // this is not inherited by other response types because that would make designation initialization in the api implementations + // less pretty + struct response { + bool success; + std::optional error; + }; + struct level_record { std::string user; float time; @@ -57,7 +64,8 @@ class api { std::time_t updatedAt; bool alt; int levelVersion; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(api::replay, id, user, levelId, time, version, raw, createdAt, updatedAt, alt, levelVersion); + bool hidden; + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(api::replay, id, user, levelId, time, version, raw, createdAt, updatedAt, alt, levelVersion, hidden); bool operator==(const replay& other) const; bool operator!=(const replay& other) const; }; @@ -216,6 +224,8 @@ class api { std::future search_replays(int levelId, api::replay_search_query q); std::future upload_replay(::replay rp); + std::future set_replay_visibility(int rid, bool visible); + std::future fetch_user_stats(int id); std::future fetch_user_stats(std::string name); diff --git a/game/gui/leaderboard_modal.cpp b/game/gui/leaderboard_modal.cpp index 1b8bde4..9efb9df 100644 --- a/game/gui/leaderboard_modal.cpp +++ b/game/gui/leaderboard_modal.cpp @@ -126,7 +126,7 @@ void leaderboard_modal::imdraw(fsm* sm) { ImGui::TextWrapped("%s", res.error->c_str()); ImGui::PopStyleColor(); } else { - if (ImGui::BeginTable("###Scores", 6, ImGuiTableFlags_Borders)) { + if (ImGui::BeginTable("###Scores", 7, ImGuiTableFlags_Borders)) { ImGui::TableNextRow(); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(sf::Color(0xC8AD7FFF))); ImGui::TableNextColumn(); @@ -141,6 +141,8 @@ void leaderboard_modal::imdraw(fsm* sm) { ImGui::Text("Game Version"); ImGui::TableNextColumn(); ImGui::Text("Replay"); + ImGui::TableNextColumn(); + ImGui::Text("Visibility"); ImGui::PopStyleColor(); if (res.scores.size()) { api::replay wr = res.scores[0]; @@ -203,6 +205,44 @@ void leaderboard_modal::imdraw(fsm* sm) { if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/download.png"), "Replay")) { sm->swap_state(m_lvl, replay(score)); } + ImGui::TableNextColumn(); + // if we own this score + bool can_hide = auth::get().authed() && auth::get().username() == score.user; + ImGui::BeginDisabled(!can_hide); + ImTextureID hide_tex = resource::get().imtex(!score.hidden ? "assets/gui/eye_open.png" : "assets/gui/minus.png"); + if (ImGui::ImageButton(hide_tex, ImVec2(16, 16))) { + if (!m_hide_handle.fetching()) + m_hide_handle.reset(api::get().set_replay_visibility(score.id, score.hidden)); + } + ImGui::EndDisabled(); + // hide button + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + if (!can_hide) { + ImGui::SetTooltip("You must be logged in as the player who ran this replay to hide it."); + } else if (score.hidden) { + ImGui::SetTooltip("Make this replay private"); + } else { + ImGui::SetTooltip("Hide this replay from public view"); + } + } + ImGui::SameLine(); + // hide api handling + m_hide_handle.poll(); + if (m_hide_handle.ready()) { + auto res = m_hide_handle.get(); + if (res.success) { + m_update_query(); + m_hide_handle.reset(); + } else if (res.error.has_value()) { + ImGui::TextColored(sf::Color::Red, "[!]"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Failed to %shide replay: %s", score.hidden ? "un" : "", res.error->c_str()); + if (ImGui::IsItemClicked()) { + m_hide_handle.reset(); + } + } + } + } ImGui::PopID(); } } diff --git a/game/gui/leaderboard_modal.hpp b/game/gui/leaderboard_modal.hpp index 0952ab4..444a479 100644 --- a/game/gui/leaderboard_modal.hpp +++ b/game/gui/leaderboard_modal.hpp @@ -24,6 +24,7 @@ class leaderboard_modal { int m_ex_id; // for imgui id system api_handle m_api_handle; + api_handle m_hide_handle; const char* m_sort_opts[4]; // api sortBy options int m_sort_selection; diff --git a/game/gui/level_card.cpp b/game/gui/level_card.cpp index 8ddd0c6..44cc741 100644 --- a/game/gui/level_card.cpp +++ b/game/gui/level_card.cpp @@ -16,11 +16,9 @@ #include #include -namespace ImGui { +int level_card::m_next_id = 0; -int ApiLevelTile::m_next_id = 0; - -ApiLevelTile::ApiLevelTile(api::level& lvl, sf::Color bg) +level_card::level_card(api::level& lvl, sf::Color bg) : m_bg(bg), m_lvl(lvl), m_tmap(resource::get().tex("assets/tiles.png"), 32, 32, 16), @@ -38,7 +36,7 @@ ApiLevelTile::ApiLevelTile(api::level& lvl, sf::Color bg) m_ex_id = m_next_id++; } -void ApiLevelTile::imdraw(fsm* sm) { +void level_card::imdraw(fsm* sm) { // title / auth ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(0xFB8CABFF), "%s (v%d)", m_lvl.title.c_str(), m_lvl.version); ImGui::SameLine(); @@ -178,5 +176,3 @@ void ApiLevelTile::imdraw(fsm* sm) { ImGui::TextWrapped("%s", m_lvl.description.c_str()); ImGui::PopStyleColor(); } - -} diff --git a/game/gui/level_card.hpp b/game/gui/level_card.hpp index 00e331f..688390b 100644 --- a/game/gui/level_card.hpp +++ b/game/gui/level_card.hpp @@ -17,12 +17,10 @@ class fsm; class user_modal; -namespace ImGui { - /* a frame that stores a small preview of an api-fetched level */ -class ApiLevelTile { +class level_card { public: - ApiLevelTile(api::level& lvl, sf::Color bg = sf::Color(0xC8AD7FFF)); + level_card(api::level& lvl, sf::Color bg = sf::Color(0xC8AD7FFF)); void imdraw(fsm* sm); @@ -44,4 +42,3 @@ class ApiLevelTile { api_handle m_vote_handle; }; -} diff --git a/game/gui/menu_bar.cpp b/game/gui/menu_bar.cpp index d3d2e11..57a10d1 100644 --- a/game/gui/menu_bar.cpp +++ b/game/gui/menu_bar.cpp @@ -11,9 +11,7 @@ #include "tilemap.hpp" #include "util.hpp" -namespace ImGui { - -AppMenuBar::AppMenuBar() +menu_bar::menu_bar() : m_listening_key(), m_rules_gifs({ std::make_pair(ImGui::Gif(resource::get().tex("assets/gifs/run.png"), 33, { 240, 240 }, 20), "Use left & right to run"), @@ -33,7 +31,7 @@ AppMenuBar::AppMenuBar() m_pword_just_reset = false; } -void AppMenuBar::process_event(sf::Event e) { +void menu_bar::process_event(sf::Event e) { switch (e.type) { case sf::Event::KeyPressed: if (m_listening_key) { @@ -47,7 +45,7 @@ void AppMenuBar::process_event(sf::Event e) { } } -void AppMenuBar::m_open_verify_popup() { +void menu_bar::m_open_verify_popup() { m_v_code = 0; m_verify_handle.reset(); m_reverify_handle.reset(); @@ -56,7 +54,7 @@ void AppMenuBar::m_open_verify_popup() { ImGui::OpenPopup("Verify###Verify"); } -void AppMenuBar::m_gui_verify_popup() { +void menu_bar::m_gui_verify_popup() { ImGuiWindowFlags modal_flags = ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; bool dummy = true; // VERIFICATION @@ -99,7 +97,7 @@ void AppMenuBar::m_gui_verify_popup() { } } -void AppMenuBar::imdraw(std::string& info_msg, fsm* sm) { +void menu_bar::imdraw(std::string& info_msg, fsm* sm) { const ImTextureID tiles = resource::get().imtex("assets/tiles.png"); sf::Texture& tiles_tex = resource::get().tex("assets/tiles.png"); @@ -377,7 +375,7 @@ void AppMenuBar::imdraw(std::string& info_msg, fsm* sm) { ImGui::EndMainMenuBar(); } -bool AppMenuBar::m_auth_unresolved() const { +bool menu_bar::m_auth_unresolved() const { return m_login_handle.fetching() || m_signup_handle.fetching() || m_login_handle.ready() || @@ -388,7 +386,7 @@ bool AppMenuBar::m_auth_unresolved() const { m_reverify_handle.ready(); } -void AppMenuBar::m_close_auth_popup() { +void menu_bar::m_close_auth_popup() { m_login_handle.reset(); m_signup_handle.reset(); m_verify_handle.reset(); @@ -401,4 +399,3 @@ void AppMenuBar::m_close_auth_popup() { ImGui::CloseCurrentPopup(); } -} diff --git a/game/gui/menu_bar.hpp b/game/gui/menu_bar.hpp index 549deac..a6da5d5 100644 --- a/game/gui/menu_bar.hpp +++ b/game/gui/menu_bar.hpp @@ -18,11 +18,9 @@ #include "gui/forgot_password_modal.hpp" #include "gui/user_modal.hpp" -namespace ImGui { - -class AppMenuBar { +class menu_bar { public: - AppMenuBar(); + menu_bar(); void imdraw(std::string& info_msg, fsm* sm); void process_event(sf::Event e); @@ -55,5 +53,3 @@ class AppMenuBar { void m_close_auth_popup(); // close the auth popup and reset all values }; - -} diff --git a/game/gui/user_modal.cpp b/game/gui/user_modal.cpp index 88154e8..fce7e30 100644 --- a/game/gui/user_modal.cpp +++ b/game/gui/user_modal.cpp @@ -44,10 +44,10 @@ void user_modal::imdraw(fsm* sm) { if (rsp.success) { modal_title = rsp.stats->username + " #" + std::to_string(rsp.stats->id) + " " + modal_title; if (rsp.stats->recentLevel && !m_recent_level_tile) { - m_recent_level_tile.reset(new ImGui::ApiLevelTile(*rsp.stats->recentLevel)); + m_recent_level_tile.reset(new level_card(*rsp.stats->recentLevel)); } if (rsp.stats->recentScore && rsp.stats->recentScoreLevel && !m_recent_score_tile) { - m_recent_score_tile.reset(new ImGui::ApiLevelTile(*rsp.stats->recentScoreLevel)); + m_recent_score_tile.reset(new level_card(*rsp.stats->recentScoreLevel)); } } } diff --git a/game/gui/user_modal.hpp b/game/gui/user_modal.hpp index c7b4b62..c11c324 100644 --- a/game/gui/user_modal.hpp +++ b/game/gui/user_modal.hpp @@ -27,7 +27,7 @@ class user_modal { std::string m_name; // username to fetch api_handle m_stats_handle; - std::unique_ptr m_recent_level_tile; - std::unique_ptr m_recent_score_tile; + std::unique_ptr m_recent_level_tile; + std::unique_ptr m_recent_score_tile; void m_fetch(); }; diff --git a/game/states/edit.cpp b/game/states/edit.cpp index 56adab4..3c72560 100644 --- a/game/states/edit.cpp +++ b/game/states/edit.cpp @@ -840,8 +840,11 @@ void edit::m_gui_controls(fsm* sm) { ImGui::SetKeyboardFocusHere(0); } if (ImGui::InputText("Description", m_description_buffer, 256, ImGuiInputTextFlags_EnterReturnsTrue)) { - if (!m_upload_handle.fetching()) + if (!m_upload_handle.fetching()) { + m_verification->set_created_now(); + m_verification->set_user(auth::get().username().c_str()); m_upload_handle.reset(api::get().upload_level(m_level(), *m_verification, m_title_buffer, m_description_buffer)); + } } ImGui::BeginDisabled(m_upload_handle.fetching()); const char* upload_label = m_upload_handle.fetching() ? "Uploading...###UploadForReal" : "Upload###UploadForReal"; diff --git a/game/states/edit.hpp b/game/states/edit.hpp index 4f81a9b..e1e0f9c 100644 --- a/game/states/edit.hpp +++ b/game/states/edit.hpp @@ -36,7 +36,7 @@ class edit : public state { sf::Sprite m_bg; - ImGui::AppMenuBar m_menu_bar; + menu_bar m_menu_bar; void draw(sf::RenderTarget&, sf::RenderStates) const; diff --git a/game/states/search.cpp b/game/states/search.cpp index ad6bc4c..a00d732 100644 --- a/game/states/search.cpp +++ b/game/states/search.cpp @@ -39,10 +39,13 @@ void search::update(fsm* sm, sf::Time dt) { m_loading_gif.update(); // check if a pending query is ready, and update status accordingly m_query_handle.poll(); - if (!m_authed_last_frame && auth::get().authed()) { + bool authed = auth::get().authed(); + if (!m_authed_last_frame && authed) { + m_update_query(); + } else if (m_authed_last_frame && !authed) { m_update_query(); } - m_authed_last_frame = auth::get().authed(); + m_authed_last_frame = authed; } void search::process_event(sf::Event e) { @@ -215,9 +218,9 @@ api::level_search_query& search::query() { return context::get().level_search_query(); } -ImGui::ApiLevelTile& search::m_gui_level_tile(api::level& lvl) { +level_card& search::m_gui_level_tile(api::level& lvl) { if (!m_api_level_tile.contains(lvl.id)) { - m_api_level_tile[lvl.id] = std::make_shared(lvl); + m_api_level_tile[lvl.id] = std::make_shared(lvl); } return *m_api_level_tile[lvl.id].get(); } diff --git a/game/states/search.hpp b/game/states/search.hpp index 5cfe359..d35f1dd 100644 --- a/game/states/search.hpp +++ b/game/states/search.hpp @@ -26,7 +26,7 @@ class search : public state { void process_event(sf::Event e); private: - ImGui::AppMenuBar m_menu_bar; // app menu bar + menu_bar m_menu_bar; // app menu bar // QUERY STUFF api::level_search_query& query(); // just fetches the query from context @@ -51,8 +51,8 @@ class search : public state { api_handle m_quickplay_handle; // handles automatic loading & caching to prevent unnecessary draws - std::unordered_map> m_api_level_tile; - ImGui::ApiLevelTile& m_gui_level_tile(api::level& lvl); + std::unordered_map> m_api_level_tile; + level_card& m_gui_level_tile(api::level& lvl); void m_update_query(); // sends the query to the api void m_next_page(); diff --git a/prisma/migrations/20230109203606_hideable_replays/migration.sql b/prisma/migrations/20230109203606_hideable_replays/migration.sql new file mode 100644 index 0000000..6d0e5c8 --- /dev/null +++ b/prisma/migrations/20230109203606_hideable_replays/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserLevelScore" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 73f6dfa..c81e6ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,6 +94,7 @@ model UserLevelScore { alt Boolean @default(false) verifies Level? @relation("verify") levelVersion Int @default(1) + hidden Boolean @default(false) @@id([id, userId, levelId]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index d215300..b3412dd 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -86,6 +86,7 @@ async function fakeScore(user: User, level: Level) { time: sampleScore.time, replay: Buffer.from(sampleScore.replay, 'base64'), version: 'vSeeded', + hidden: Math.random() > 0.7, }, }); return score;