From e2c177190bedca5530b0a42cd9fa25c4f7ae0dd1 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 6 May 2026 08:07:42 +0000 Subject: [PATCH] po --- ...778055600000-osu-higher-lower-demo-data.js | 22 + backend/src/index.js | 3 +- backend/src/routes/gameplay.js | 54 + backend/src/services/gameplay.js | 896 +++++++++++++++ backend/src/services/gameplayDemoData.js | 467 ++++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 312 ++--- frontend/src/pages/play/[gameSessionId].tsx | 388 +++++++ frontend/src/pages/play/index.tsx | 1014 +++++++++++++++++ 11 files changed, 3019 insertions(+), 151 deletions(-) create mode 100644 backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js create mode 100644 backend/src/routes/gameplay.js create mode 100644 backend/src/services/gameplay.js create mode 100644 backend/src/services/gameplayDemoData.js create mode 100644 frontend/src/pages/play/[gameSessionId].tsx create mode 100644 frontend/src/pages/play/index.tsx diff --git a/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js b/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js new file mode 100644 index 0000000..00956ab --- /dev/null +++ b/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js @@ -0,0 +1,22 @@ +const { + mappers, + artists, + beatmapSets, + beatmaps, +} = require('../../services/gameplayDemoData'); + +module.exports = { + async up(queryInterface) { + await queryInterface.bulkInsert('mappers', mappers); + await queryInterface.bulkInsert('artists', artists); + await queryInterface.bulkInsert('beatmap_sets', beatmapSets); + await queryInterface.bulkInsert('beatmaps', beatmaps); + }, + + async down(queryInterface) { + await queryInterface.bulkDelete('beatmaps', { id: beatmaps.map((beatmap) => beatmap.id) }); + await queryInterface.bulkDelete('beatmap_sets', { id: beatmapSets.map((beatmapSet) => beatmapSet.id) }); + await queryInterface.bulkDelete('artists', { id: artists.map((artist) => artist.id) }); + await queryInterface.bulkDelete('mappers', { id: mappers.map((mapper) => mapper.id) }); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index ae87eaf..b99b32d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -36,6 +35,7 @@ const beatmap_setsRoutes = require('./routes/beatmap_sets'); const beatmapsRoutes = require('./routes/beatmaps'); const game_sessionsRoutes = require('./routes/game_sessions'); +const gameplayRoutes = require('./routes/gameplay'); const game_roundsRoutes = require('./routes/game_rounds'); @@ -118,6 +118,7 @@ app.use('/api/beatmap_sets', passport.authenticate('jwt', {session: false}), bea app.use('/api/beatmaps', passport.authenticate('jwt', {session: false}), beatmapsRoutes); app.use('/api/game_sessions', passport.authenticate('jwt', {session: false}), game_sessionsRoutes); +app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes); app.use('/api/game_rounds', passport.authenticate('jwt', {session: false}), game_roundsRoutes); diff --git a/backend/src/routes/gameplay.js b/backend/src/routes/gameplay.js new file mode 100644 index 0000000..c4fffa1 --- /dev/null +++ b/backend/src/routes/gameplay.js @@ -0,0 +1,54 @@ +const express = require('express'); + +const GameplayService = require('../services/gameplay'); +const Helpers = require('../helpers'); +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.get( + '/overview', + checkPermissions('READ_GAME_SESSIONS'), + Helpers.wrapAsync(async (req, res) => { + const payload = await GameplayService.getOverview(req.currentUser); + + res.status(200).send(payload); + }), +); + +router.post( + '/start', + checkPermissions('CREATE_GAME_SESSIONS'), + Helpers.wrapAsync(async (req, res) => { + const payload = await GameplayService.startSession(req.body, req.currentUser); + + res.status(200).send(payload); + }), +); + +router.post( + '/answer', + checkPermissions('CREATE_GAME_ROUNDS'), + Helpers.wrapAsync(async (req, res) => { + const payload = await GameplayService.answerRound(req.body, req.currentUser); + + res.status(200).send(payload); + }), +); + +router.get( + '/sessions/:id', + checkPermissions('READ_GAME_SESSIONS'), + Helpers.wrapAsync(async (req, res) => { + const payload = await GameplayService.getSessionDetails( + req.params.id, + req.currentUser, + ); + + res.status(200).send(payload); + }), +); + +router.use('/', Helpers.commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/gameplay.js b/backend/src/services/gameplay.js new file mode 100644 index 0000000..9b75e16 --- /dev/null +++ b/backend/src/services/gameplay.js @@ -0,0 +1,896 @@ +const crypto = require('crypto'); +const db = require('../db/models'); +const demoData = require('./gameplayDemoData'); + +const { Op } = db.Sequelize; + +const ROUND_MODE_OPTIONS = ['beatmap', 'mapper', 'artist']; +const SESSION_MODE_OPTIONS = [...ROUND_MODE_OPTIONS, 'mixed']; +const TIMER_PROFILE_SECONDS = { + normal: 8, + insane: 6, +}; +const SESSION_KEY_PREFIX = 'osu-hl-'; +const LEADERBOARD_SCOPE_KEY = 'osu-hl-global'; +const STARTING_LIVES = 2; +const RECENT_SESSION_LIMIT = 5; +const LEADERBOARD_LIMIT = 5; +const ALLOWED_STATUSES = ['ranked', 'loved']; +const demoMapperTotals = new Map( + demoData.mappers.map((mapper) => [mapper.id, mapper.total_ranked_loved_playcount]), +); +const demoArtistTotals = new Map( + demoData.artists.map((artist) => [artist.id, artist.total_ranked_loved_playcount]), +); + +function getBeatmapInclude() { + return [ + { model: db.mappers, as: 'mapper' }, + { model: db.artists, as: 'artist' }, + { model: db.beatmap_sets, as: 'beatmap_set' }, + ]; +} + +function createRequestError(message, code = 400) { + const error = new Error(message); + error.code = code; + + return error; +} + +function toNumber(value) { + const numericValue = Number(value || 0); + + return Number.isFinite(numericValue) ? numericValue : 0; +} + +function pickRandom(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function getResolvedMode(modeSetting) { + if (modeSetting === 'mixed') { + return pickRandom(ROUND_MODE_OPTIONS); + } + + return modeSetting; +} + +function getMetricLabel(resolvedMode) { + if (resolvedMode === 'mapper') { + return 'Mapper total plays'; + } + + if (resolvedMode === 'artist') { + return 'Artist total plays'; + } + + return 'Beatmap playcount'; +} + +function getComparisonValue(beatmap, resolvedMode) { + if (!beatmap) { + return 0; + } + + if (resolvedMode === 'mapper') { + return toNumber( + beatmap.mapper?.total_ranked_loved_playcount || demoMapperTotals.get(beatmap.mapperId), + ); + } + + if (resolvedMode === 'artist') { + return toNumber( + beatmap.artist?.total_ranked_loved_playcount || demoArtistTotals.get(beatmap.artistId), + ); + } + + return toNumber(beatmap.playcount); +} + +function isBeatmapPlayable(beatmap) { + return Boolean( + beatmap && + beatmap.background_image_url && + beatmap.mapper && + beatmap.artist && + ALLOWED_STATUSES.includes(beatmap.status) && + beatmap.mode === 'osu_standard' && + getComparisonValue(beatmap, 'beatmap') > 0 && + getComparisonValue(beatmap, 'mapper') > 0 && + getComparisonValue(beatmap, 'artist') > 0, + ); +} + +function serializeBeatmapCard(beatmap) { + return { + id: beatmap.id, + beatmapId: beatmap.osu_beatmap_numeric, + title: beatmap.title, + version: beatmap.version, + artistName: beatmap.artist?.name || beatmap.artist_name, + mapperName: beatmap.mapper?.osu_username || 'Unknown mapper', + mapperAvatarUrl: beatmap.mapper?.avatar_url || '', + backgroundImageUrl: + beatmap.background_image_url || beatmap.beatmap_set?.background_image_url || '', + status: beatmap.status, + }; +} + +function buildSessionSummary(session, rounds = []) { + const completedRounds = rounds.filter((round) => round.result !== 'pending'); + const correctAnswers = completedRounds.filter( + (round) => round.result === 'correct', + ).length; + const wrongAnswers = completedRounds.filter( + (round) => round.result === 'wrong', + ).length; + const timeoutCount = completedRounds.filter( + (round) => round.result === 'timeout', + ).length; + const suspiciousRounds = completedRounds.filter( + (round) => round.is_suspected_cheat, + ).length; + + return { + id: session.id, + sessionKey: session.session_key, + status: session.status, + modeSetting: session.mode_setting, + timerProfile: session.timer_profile, + startingLives: session.starting_lives, + livesRemaining: session.lives_remaining, + currentStreak: session.streak_current, + bestStreak: session.streak_best, + startedAt: session.started_at, + endedAt: session.ended_at, + totalRounds: completedRounds.length, + correctAnswers, + wrongAnswers, + timeoutCount, + suspiciousRounds, + }; +} + +function buildRoundResolution(round, beatmapA, beatmapB) { + return { + roundId: round.id, + roundIndex: round.round_index, + result: round.result, + playerChoice: round.player_choice || 'none', + correctChoice: round.correct_choice, + resolvedMode: round.resolved_mode, + comparedLabel: getMetricLabel(round.resolved_mode), + values: { + a: getComparisonValue(beatmapA, round.resolved_mode), + b: getComparisonValue(beatmapB, round.resolved_mode), + }, + cards: { + a: serializeBeatmapCard(beatmapA), + b: serializeBeatmapCard(beatmapB), + }, + }; +} + +function getUserDisplayName(user) { + const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ').trim(); + + return fullName || user?.email?.split('@')[0] || 'Player'; +} + +async function ensureDemoDataset(transaction) { + const activeBeatmapCount = await db.beatmaps.count({ + where: { + mode: 'osu_standard', + status: { + [Op.in]: ALLOWED_STATUSES, + }, + is_active: true, + }, + transaction, + }); + + if (activeBeatmapCount >= 2) { + return; + } + + for (const mapper of demoData.mappers) { + await db.mappers.upsert(mapper, { transaction }); + } + + for (const artist of demoData.artists) { + await db.artists.upsert(artist, { transaction }); + } + + for (const beatmapSet of demoData.beatmapSets) { + await db.beatmap_sets.upsert(beatmapSet, { transaction }); + } + + for (const beatmap of demoData.beatmaps) { + await db.beatmaps.upsert(beatmap, { transaction }); + } +} + +async function getEligibleBeatmaps(transaction) { + await ensureDemoDataset(transaction); + + const where = { + mode: 'osu_standard', + status: { + [Op.in]: ALLOWED_STATUSES, + }, + is_active: true, + }; + + let beatmaps = await db.beatmaps.findAll({ + where, + include: getBeatmapInclude(), + transaction, + }); + + beatmaps = beatmaps.filter(isBeatmapPlayable); + + if (beatmaps.length >= 2) { + return beatmaps; + } + + const fallbackBeatmaps = await db.beatmaps.findAll({ + where: { + mode: 'osu_standard', + status: { + [Op.in]: ALLOWED_STATUSES, + }, + }, + include: getBeatmapInclude(), + transaction, + }); + + return fallbackBeatmaps.filter(isBeatmapPlayable); +} + +function pickBeatmapPair(beatmaps, resolvedMode, excludeBeatmapIds = []) { + const pool = beatmaps.filter((beatmap) => !excludeBeatmapIds.includes(beatmap.id)); + const workingPool = pool.length >= 2 ? pool : beatmaps; + + if (workingPool.length < 2) { + throw new Error('At least two playable beatmaps are required to start Osu Higher Lower.'); + } + + const combinations = []; + + for (let index = 0; index < workingPool.length; index += 1) { + for (let nestedIndex = index + 1; nestedIndex < workingPool.length; nestedIndex += 1) { + const beatmapA = workingPool[index]; + const beatmapB = workingPool[nestedIndex]; + const valueA = getComparisonValue(beatmapA, resolvedMode); + const valueB = getComparisonValue(beatmapB, resolvedMode); + + if (valueA === valueB) { + continue; + } + + combinations.push({ + beatmapA, + beatmapB, + valueA, + valueB, + }); + } + } + + if (!combinations.length) { + throw new Error('The active beatmap dataset does not contain enough distinct comparison values.'); + } + + const selectedPair = pickRandom(combinations); + + return { + beatmapA: selectedPair.beatmapA, + beatmapB: selectedPair.beatmapB, + correctChoice: selectedPair.valueA >= selectedPair.valueB ? 'a' : 'b', + }; +} + +async function getPendingRound(sessionId, transaction) { + return db.game_rounds.findOne({ + where: { + game_sessionId: sessionId, + result: 'pending', + }, + include: [ + { + model: db.beatmaps, + as: 'beatmap_a', + include: getBeatmapInclude(), + }, + { + model: db.beatmaps, + as: 'beatmap_b', + include: getBeatmapInclude(), + }, + ], + order: [['round_index', 'DESC']], + transaction, + }); +} + +async function createRoundForSession(session, options = {}) { + const transaction = options.transaction; + const excludeBeatmapIds = options.excludeBeatmapIds || []; + const playableBeatmaps = await getEligibleBeatmaps(transaction); + const resolvedMode = getResolvedMode(session.mode_setting); + const pair = pickBeatmapPair(playableBeatmaps, resolvedMode, excludeBeatmapIds); + const roundCount = await db.game_rounds.count({ + where: { + game_sessionId: session.id, + }, + transaction, + }); + const timeLimitSeconds = TIMER_PROFILE_SECONDS[session.timer_profile] || TIMER_PROFILE_SECONDS.normal; + const presentedAt = new Date(); + + const round = await db.game_rounds.create( + { + game_sessionId: session.id, + round_index: roundCount + 1, + resolved_mode: resolvedMode, + beatmap_aId: pair.beatmapA.id, + beatmap_bId: pair.beatmapB.id, + correct_choice: pair.correctChoice, + result: 'pending', + time_limit_seconds: timeLimitSeconds, + presented_at: presentedAt, + createdById: session.userId, + updatedById: session.userId, + }, + { transaction }, + ); + + return { + round, + beatmapA: pair.beatmapA, + beatmapB: pair.beatmapB, + }; +} + +function buildActiveSessionPayload(session, round, beatmapA, beatmapB) { + const timeLimitSeconds = toNumber(round.time_limit_seconds); + + return { + session: { + id: session.id, + sessionKey: session.session_key, + status: session.status, + modeSetting: session.mode_setting, + timerProfile: session.timer_profile, + livesRemaining: session.lives_remaining, + startingLives: session.starting_lives, + currentStreak: session.streak_current, + bestStreak: session.streak_best, + startedAt: session.started_at, + }, + round: { + id: round.id, + index: round.round_index, + timeLimitSeconds, + presentedAt: round.presented_at, + endsAt: new Date( + new Date(round.presented_at).getTime() + timeLimitSeconds * 1000, + ).toISOString(), + cards: { + a: serializeBeatmapCard(beatmapA), + b: serializeBeatmapCard(beatmapB), + }, + }, + }; +} + +async function abandonActiveSessions(currentUser, transaction) { + const activeSessions = await db.game_sessions.findAll({ + where: { + userId: currentUser.id, + status: 'active', + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + transaction, + }); + + if (!activeSessions.length) { + return; + } + + const activeSessionIds = activeSessions.map((session) => session.id); + const endedAt = new Date(); + + await db.game_rounds.update( + { + result: 'timeout', + player_choice: 'none', + answered_at: endedAt, + updatedById: currentUser.id, + }, + { + where: { + game_sessionId: { + [Op.in]: activeSessionIds, + }, + result: 'pending', + }, + transaction, + }, + ); + + await db.game_sessions.update( + { + status: 'abandoned', + ended_at: endedAt, + updatedById: currentUser.id, + }, + { + where: { + id: { + [Op.in]: activeSessionIds, + }, + }, + transaction, + }, + ); +} + +async function recordLeaderboardEntry(session, currentUser, transaction) { + const rounds = await db.game_rounds.findAll({ + where: { + game_sessionId: session.id, + }, + transaction, + }); + const sessionSummary = buildSessionSummary(session, rounds); + const scoreValue = sessionSummary.correctAnswers; + + const existingEntry = await db.leaderboard_entries.findOne({ + where: { + userId: currentUser.id, + scope: 'global', + scope_key: LEADERBOARD_SCOPE_KEY, + mode_setting: session.mode_setting, + timer_profile: session.timer_profile, + }, + transaction, + }); + + if (existingEntry) { + await existingEntry.update( + { + score_value: Math.max(existingEntry.score_value || 0, scoreValue), + best_streak: Math.max(existingEntry.best_streak || 0, session.streak_best || 0), + games_played: (existingEntry.games_played || 0) + 1, + recorded_at: new Date(), + updatedById: currentUser.id, + }, + { transaction }, + ); + + return; + } + + await db.leaderboard_entries.create( + { + userId: currentUser.id, + scope: 'global', + scope_key: LEADERBOARD_SCOPE_KEY, + mode_setting: session.mode_setting, + timer_profile: session.timer_profile, + score_value: scoreValue, + best_streak: session.streak_best || 0, + games_played: 1, + recorded_at: new Date(), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); +} + +module.exports = class GameplayService { + static async startSession(payload, currentUser) { + const modeSetting = payload?.modeSetting || 'mixed'; + const timerProfile = payload?.timerProfile || 'normal'; + + if (!SESSION_MODE_OPTIONS.includes(modeSetting)) { + throw createRequestError('Mode must be beatmap, mapper, artist, or mixed.'); + } + + if (!Object.keys(TIMER_PROFILE_SECONDS).includes(timerProfile)) { + throw createRequestError('Timer profile must be normal or insane.'); + } + + const transaction = await db.sequelize.transaction(); + + try { + await abandonActiveSessions(currentUser, transaction); + + const session = await db.game_sessions.create( + { + session_key: `${SESSION_KEY_PREFIX}${crypto.randomUUID()}`, + userId: currentUser.id, + status: 'active', + mode_setting: modeSetting, + starting_lives: STARTING_LIVES, + lives_remaining: STARTING_LIVES, + streak_current: 0, + streak_best: 0, + timer_profile: timerProfile, + round_time_min_seconds: TIMER_PROFILE_SECONDS[timerProfile], + round_time_max_seconds: TIMER_PROFILE_SECONDS[timerProfile], + daily_seed_enabled: false, + started_at: new Date(), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const { round, beatmapA, beatmapB } = await createRoundForSession(session, { + transaction, + }); + + await transaction.commit(); + + return buildActiveSessionPayload(session, round, beatmapA, beatmapB); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async answerRound(payload, currentUser) { + const sessionId = payload?.sessionId; + const choice = payload?.choice || 'none'; + + if (!sessionId) { + throw createRequestError('A session id is required to answer a round.'); + } + + if (!['a', 'b', 'none'].includes(choice)) { + throw createRequestError('Choice must be a, b, or none.'); + } + + const transaction = await db.sequelize.transaction(); + + try { + const session = await db.game_sessions.findOne({ + where: { + id: sessionId, + userId: currentUser.id, + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + transaction, + }); + + if (!session) { + throw createRequestError('Game session not found.', 404); + } + + if (session.status !== 'active') { + throw createRequestError('This game session has already ended.'); + } + + const pendingRound = await getPendingRound(session.id, transaction); + + if (!pendingRound) { + throw createRequestError('There is no pending round to resolve.'); + } + + const now = new Date(); + const deadline = new Date( + new Date(pendingRound.presented_at).getTime() + + toNumber(pendingRound.time_limit_seconds) * 1000, + ); + const submittedLate = now.getTime() > deadline.getTime(); + const lateMs = Math.max(now.getTime() - deadline.getTime(), 0); + + let result = 'wrong'; + let storedChoice = choice; + let isSuspectedCheat = false; + let suspicionReason = null; + + if (choice === 'none' || submittedLate) { + result = 'timeout'; + storedChoice = choice === 'none' ? 'none' : choice; + + if (submittedLate && choice !== 'none' && lateMs > 1000) { + isSuspectedCheat = true; + suspicionReason = 'Answer submitted after the round timer had expired.'; + } + } else if (choice === pendingRound.correct_choice) { + result = 'correct'; + } + + await pendingRound.update( + { + player_choice: storedChoice, + result, + answered_at: now, + is_suspected_cheat: isSuspectedCheat, + suspicion_reason: suspicionReason, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const nextLivesRemaining = + result === 'correct' ? session.lives_remaining : Math.max((session.lives_remaining || 0) - 1, 0); + const nextCurrentStreak = result === 'correct' ? (session.streak_current || 0) + 1 : 0; + const nextBestStreak = Math.max(session.streak_best || 0, nextCurrentStreak); + const nextStatus = nextLivesRemaining > 0 ? 'active' : 'ended'; + + await session.update( + { + lives_remaining: nextLivesRemaining, + streak_current: nextCurrentStreak, + streak_best: nextBestStreak, + status: nextStatus, + ended_at: nextStatus === 'ended' ? now : null, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const resolution = buildRoundResolution( + pendingRound, + pendingRound.beatmap_a, + pendingRound.beatmap_b, + ); + const rounds = await db.game_rounds.findAll({ + where: { + game_sessionId: session.id, + }, + order: [['round_index', 'ASC']], + transaction, + }); + const sessionSummary = buildSessionSummary(session, rounds); + + let nextRoundPayload = null; + + if (nextStatus === 'active') { + const { round, beatmapA, beatmapB } = await createRoundForSession(session, { + transaction, + excludeBeatmapIds: [ + pendingRound.beatmap_a?.id, + pendingRound.beatmap_b?.id, + ].filter(Boolean), + }); + nextRoundPayload = buildActiveSessionPayload(session, round, beatmapA, beatmapB); + } else { + await recordLeaderboardEntry(session, currentUser, transaction); + } + + await transaction.commit(); + + return { + resolution, + sessionSummary, + nextRound: nextRoundPayload, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async syncExpiredActiveSession(currentUser) { + const activeSession = await db.game_sessions.findOne({ + where: { + userId: currentUser.id, + status: 'active', + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + order: [['started_at', 'DESC']], + }); + + if (!activeSession) { + return; + } + + const pendingRound = await getPendingRound(activeSession.id); + + if (!pendingRound) { + return; + } + + const deadline = new Date( + new Date(pendingRound.presented_at).getTime() + + toNumber(pendingRound.time_limit_seconds) * 1000, + ); + + if (Date.now() > deadline.getTime()) { + await this.answerRound( + { + sessionId: activeSession.id, + choice: 'none', + }, + currentUser, + ); + } + } + + static async getOverview(currentUser) { + await ensureDemoDataset(); + await this.syncExpiredActiveSession(currentUser); + + const [activeSession, recentSessions, leaderboardEntries, beatmapCount, lastSyncedAt] = + await Promise.all([ + db.game_sessions.findOne({ + where: { + userId: currentUser.id, + status: 'active', + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + order: [['started_at', 'DESC']], + }), + db.game_sessions.findAll({ + where: { + userId: currentUser.id, + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + include: [ + { + model: db.game_rounds, + as: 'game_rounds_game_session', + }, + ], + order: [['started_at', 'DESC']], + limit: RECENT_SESSION_LIMIT, + }), + db.leaderboard_entries.findAll({ + where: { + scope: 'global', + scope_key: LEADERBOARD_SCOPE_KEY, + }, + include: [ + { + model: db.users, + as: 'user', + }, + ], + order: [ + ['score_value', 'DESC'], + ['best_streak', 'DESC'], + ['recorded_at', 'ASC'], + ], + limit: LEADERBOARD_LIMIT, + }), + db.beatmaps.count({ + where: { + mode: 'osu_standard', + status: { + [Op.in]: ALLOWED_STATUSES, + }, + is_active: true, + }, + }), + db.beatmaps.max('last_synced_at', { + where: { + mode: 'osu_standard', + status: { + [Op.in]: ALLOWED_STATUSES, + }, + is_active: true, + }, + }), + ]); + + let activeSessionPayload = null; + + if (activeSession) { + const pendingRound = await getPendingRound(activeSession.id); + + if (pendingRound) { + activeSessionPayload = buildActiveSessionPayload( + activeSession, + pendingRound, + pendingRound.beatmap_a, + pendingRound.beatmap_b, + ); + } + } + + return { + activeSession: activeSessionPayload, + recentSessions: recentSessions.map((session) => + buildSessionSummary(session, session.game_rounds_game_session || []), + ), + leaderboard: leaderboardEntries.map((entry) => ({ + id: entry.id, + playerName: getUserDisplayName(entry.user), + scoreValue: entry.score_value, + bestStreak: entry.best_streak, + gamesPlayed: entry.games_played, + modeSetting: entry.mode_setting, + timerProfile: entry.timer_profile, + recordedAt: entry.recorded_at, + })), + dataset: { + beatmapCount, + lastSyncedAt, + }, + }; + } + + static async getSessionDetails(sessionId, currentUser) { + if (!sessionId) { + throw createRequestError('A session id is required.', 400); + } + + const session = await db.game_sessions.findOne({ + where: { + id: sessionId, + userId: currentUser.id, + session_key: { + [Op.like]: `${SESSION_KEY_PREFIX}%`, + }, + }, + include: [ + { + model: db.game_rounds, + as: 'game_rounds_game_session', + include: [ + { + model: db.beatmaps, + as: 'beatmap_a', + include: getBeatmapInclude(), + }, + { + model: db.beatmaps, + as: 'beatmap_b', + include: getBeatmapInclude(), + }, + ], + }, + ], + order: [[{ model: db.game_rounds, as: 'game_rounds_game_session' }, 'round_index', 'ASC']], + }); + + if (!session) { + throw createRequestError('Game session not found.', 404); + } + + const rounds = session.game_rounds_game_session || []; + + return { + session: buildSessionSummary(session, rounds), + rounds: rounds.map((round) => ({ + id: round.id, + roundIndex: round.round_index, + result: round.result, + playerChoice: round.player_choice || 'none', + correctChoice: round.correct_choice, + resolvedMode: round.resolved_mode, + comparedLabel: getMetricLabel(round.resolved_mode), + timeLimitSeconds: toNumber(round.time_limit_seconds), + presentedAt: round.presented_at, + answeredAt: round.answered_at, + isSuspectedCheat: round.is_suspected_cheat, + suspicionReason: round.suspicion_reason, + cards: { + a: serializeBeatmapCard(round.beatmap_a), + b: serializeBeatmapCard(round.beatmap_b), + }, + values: { + a: getComparisonValue(round.beatmap_a, round.resolved_mode), + b: getComparisonValue(round.beatmap_b, round.resolved_mode), + }, + })), + }; + } +}; diff --git a/backend/src/services/gameplayDemoData.js b/backend/src/services/gameplayDemoData.js new file mode 100644 index 0000000..ce050ff --- /dev/null +++ b/backend/src/services/gameplayDemoData.js @@ -0,0 +1,467 @@ +const createdAt = new Date('2026-05-06T08:00:00.000Z'); +const updatedAt = new Date('2026-05-06T08:00:00.000Z'); +const rankedOrLovedAt = new Date('2026-05-06T08:00:00.000Z'); +const syncedAt = new Date('2026-05-06T08:00:00.000Z'); + +const mapperIds = { + bebe: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b000', + hinae: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b001', + mattay: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b002', + ajmosca: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b003', + epicDisaster: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b004', + buddon: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b005', + sheepcraft: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b006', + dumii: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b007', +}; + +const artistIds = { + camellia: '98e177af-8f8b-46b0-8d0a-8bf9146b8100', + fantasticYouth: '98e177af-8f8b-46b0-8d0a-8bf9146b8101', + sei: '98e177af-8f8b-46b0-8d0a-8bf9146b8102', + blackY: '98e177af-8f8b-46b0-8d0a-8bf9146b8103', + passCode: '98e177af-8f8b-46b0-8d0a-8bf9146b8104', + imaiAsami: '98e177af-8f8b-46b0-8d0a-8bf9146b8105', + eisyoKobu: '98e177af-8f8b-46b0-8d0a-8bf9146b8106', +}; + +const beatmapSetIds = { + attackFromMandrake: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9200', + gedosanka: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9201', + blueAmber: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9202', + imitator: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9203', + sparkIgnition: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9204', + starmine: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9205', + machinegunPsystyle: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9206', + faithtival: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9207', +}; + +const beatmapIds = { + attackFromMandrake: '3a13d892-48d0-48de-a2a8-cc48ab73d100', + gedosanka: '3a13d892-48d0-48de-a2a8-cc48ab73d101', + blueAmber: '3a13d892-48d0-48de-a2a8-cc48ab73d102', + imitator: '3a13d892-48d0-48de-a2a8-cc48ab73d103', + sparkIgnition: '3a13d892-48d0-48de-a2a8-cc48ab73d104', + starmine: '3a13d892-48d0-48de-a2a8-cc48ab73d105', + machinegunPsystyle: '3a13d892-48d0-48de-a2a8-cc48ab73d106', + faithtival: '3a13d892-48d0-48de-a2a8-cc48ab73d107', +}; + +const mappers = [ + { + id: mapperIds.bebe, + osu_user_numeric: 5057420, + osu_username: 'bebe', + avatar_url: 'https://a.ppy.sh/5057420', + total_ranked_loved_playcount: 1146000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-bebe', + }, + { + id: mapperIds.hinae, + osu_user_numeric: 11864462, + osu_username: 'Hinae', + avatar_url: 'https://a.ppy.sh/11864462', + total_ranked_loved_playcount: 1982000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-hinae', + }, + { + id: mapperIds.mattay, + osu_user_numeric: 9748303, + osu_username: 'Mattay', + avatar_url: 'https://a.ppy.sh/9748303', + total_ranked_loved_playcount: 1564000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-mattay', + }, + { + id: mapperIds.ajmosca, + osu_user_numeric: 19884809, + osu_username: 'ajmosca', + avatar_url: 'https://a.ppy.sh/19884809', + total_ranked_loved_playcount: 5316000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-ajmosca', + }, + { + id: mapperIds.epicDisaster, + osu_user_numeric: 18480245, + osu_username: 'EpicDisaster', + avatar_url: 'https://a.ppy.sh/18480245', + total_ranked_loved_playcount: 2849000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-epic-disaster', + }, + { + id: mapperIds.buddon, + osu_user_numeric: 12330007, + osu_username: 'buddon', + avatar_url: 'https://a.ppy.sh/12330007', + total_ranked_loved_playcount: 3095000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-buddon', + }, + { + id: mapperIds.sheepcraft, + osu_user_numeric: 596806, + osu_username: 'Sheepcraft', + avatar_url: 'https://a.ppy.sh/596806', + total_ranked_loved_playcount: 7482000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-sheepcraft', + }, + { + id: mapperIds.dumii, + osu_user_numeric: 3068044, + osu_username: 'Dumii', + avatar_url: 'https://a.ppy.sh/3068044', + total_ranked_loved_playcount: 3827000, + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-mapper-dumii', + }, +].map((mapper) => ({ + ...mapper, + createdAt, + updatedAt, +})); + +const artists = [ + { + id: artistIds.camellia, + name: 'Camellia', + normalized_name: 'camellia', + total_ranked_loved_playcount: 5862000, + image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-camellia', + }, + { + id: artistIds.fantasticYouth, + name: 'FantasticYouth', + normalized_name: 'fantasticyouth', + total_ranked_loved_playcount: 1884000, + image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-fantasticyouth', + }, + { + id: artistIds.sei, + name: 'Sei', + normalized_name: 'sei', + total_ranked_loved_playcount: 1297000, + image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-sei', + }, + { + id: artistIds.blackY, + name: 'BlackY feat. Risa Yuzuki', + normalized_name: 'blacky-feat-risa-yuzuki', + total_ranked_loved_playcount: 4125000, + image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-blacky-risa-yuzuki', + }, + { + id: artistIds.passCode, + name: 'PassCode', + normalized_name: 'passcode', + total_ranked_loved_playcount: 2410000, + image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-passcode', + }, + { + id: artistIds.imaiAsami, + name: 'Imai Asami', + normalized_name: 'imai-asami', + total_ranked_loved_playcount: 2289000, + image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-imai-asami', + }, + { + id: artistIds.eisyoKobu, + name: 'Eisyo-kobu', + normalized_name: 'eisyo-kobu', + total_ranked_loved_playcount: 3643000, + image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476', + image_source: 'beatmap_background_fallback', + last_aggregated_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-artist-eisyo-kobu', + }, +].map((artist) => ({ + ...artist, + createdAt, + updatedAt, +})); + +const beatmapSets = [ + { + id: beatmapSetIds.attackFromMandrake, + osu_beatmapset_numeric: 2526285, + title: 'ATTACK FROM MANDRAKE', + artist_name: 'Camellia', + background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-attack-from-mandrake', + }, + { + id: beatmapSetIds.gedosanka, + osu_beatmapset_numeric: 2340531, + title: 'Gedosanka', + artist_name: 'FantasticYouth', + background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-gedosanka', + }, + { + id: beatmapSetIds.blueAmber, + osu_beatmapset_numeric: 2220326, + title: 'Blue Amber', + artist_name: 'Sei', + background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-blue-amber', + }, + { + id: beatmapSetIds.imitator, + osu_beatmapset_numeric: 2268000, + title: 'Imitator', + artist_name: 'BlackY feat. Risa Yuzuki', + background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-imitator', + }, + { + id: beatmapSetIds.sparkIgnition, + osu_beatmapset_numeric: 2461085, + title: 'SPARK IGNITION', + artist_name: 'PassCode', + background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-spark-ignition', + }, + { + id: beatmapSetIds.starmine, + osu_beatmapset_numeric: 1912968, + title: 'Asayake no Starmine (TV Size)', + artist_name: 'Imai Asami', + background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020', + status: 'ranked', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-starmine', + }, + { + id: beatmapSetIds.machinegunPsystyle, + osu_beatmapset_numeric: 1030023, + title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)', + artist_name: 'Camellia', + background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726', + status: 'loved', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-machinegun-psystyle', + }, + { + id: beatmapSetIds.faithtival, + osu_beatmapset_numeric: 1040472, + title: 'Faithtival', + artist_name: 'Eisyo-kobu', + background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476', + status: 'loved', + ranked_or_loved_at: rankedOrLovedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-set-faithtival', + }, +].map((beatmapSet) => ({ + ...beatmapSet, + createdAt, + updatedAt, +})); + +const beatmaps = [ + { + id: beatmapIds.attackFromMandrake, + osu_beatmap_numeric: 5580520, + title: 'ATTACK FROM MANDRAKE', + version: 'CHALLENGE', + artist_name: 'Camellia', + artistId: artistIds.camellia, + mapperId: mapperIds.bebe, + beatmap_setId: beatmapSetIds.attackFromMandrake, + mode: 'osu_standard', + status: 'ranked', + playcount: 244, + background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-attack-from-mandrake', + }, + { + id: beatmapIds.gedosanka, + osu_beatmap_numeric: 5028496, + title: 'Gedosanka', + version: 'Illusion', + artist_name: 'FantasticYouth', + artistId: artistIds.fantasticYouth, + mapperId: mapperIds.hinae, + beatmap_setId: beatmapSetIds.gedosanka, + mode: 'osu_standard', + status: 'ranked', + playcount: 317, + background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-gedosanka', + }, + { + id: beatmapIds.blueAmber, + osu_beatmap_numeric: 4706536, + title: 'Blue Amber', + version: 'Extra', + artist_name: 'Sei', + artistId: artistIds.sei, + mapperId: mapperIds.mattay, + beatmap_setId: beatmapSetIds.blueAmber, + mode: 'osu_standard', + status: 'ranked', + playcount: 163, + background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-blue-amber', + }, + { + id: beatmapIds.imitator, + osu_beatmap_numeric: 4829849, + title: 'Imitator', + version: 'Mirror Image', + artist_name: 'BlackY feat. Risa Yuzuki', + artistId: artistIds.blackY, + mapperId: mapperIds.ajmosca, + beatmap_setId: beatmapSetIds.imitator, + mode: 'osu_standard', + status: 'ranked', + playcount: 680, + background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-imitator', + }, + { + id: beatmapIds.sparkIgnition, + osu_beatmap_numeric: 5561376, + title: 'SPARK IGNITION', + version: 'Forget All Your Memories, Take My Hand and Fly Away', + artist_name: 'PassCode', + artistId: artistIds.passCode, + mapperId: mapperIds.epicDisaster, + beatmap_setId: beatmapSetIds.sparkIgnition, + mode: 'osu_standard', + status: 'ranked', + playcount: 717, + background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-spark-ignition', + }, + { + id: beatmapIds.starmine, + osu_beatmap_numeric: 5473215, + title: 'Asayake no Starmine (TV Size)', + version: 'Katami', + artist_name: 'Imai Asami', + artistId: artistIds.imaiAsami, + mapperId: mapperIds.buddon, + beatmap_setId: beatmapSetIds.starmine, + mode: 'osu_standard', + status: 'ranked', + playcount: 746, + background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-starmine', + }, + { + id: beatmapIds.machinegunPsystyle, + osu_beatmap_numeric: 2153775, + title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)', + version: 'Frick', + artist_name: 'Camellia', + artistId: artistIds.camellia, + mapperId: mapperIds.sheepcraft, + beatmap_setId: beatmapSetIds.machinegunPsystyle, + mode: 'osu_standard', + status: 'loved', + playcount: 57088, + background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-machinegun-psystyle', + }, + { + id: beatmapIds.faithtival, + osu_beatmap_numeric: 2216966, + title: 'Faithtival', + version: 'Petal', + artist_name: 'Eisyo-kobu', + artistId: artistIds.eisyoKobu, + mapperId: mapperIds.dumii, + beatmap_setId: beatmapSetIds.faithtival, + mode: 'osu_standard', + status: 'loved', + playcount: 701, + background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476', + last_synced_at: syncedAt, + is_active: true, + importHash: 'osu-hl-demo-beatmap-faithtival', + }, +].map((beatmap) => ({ + ...beatmap, + createdAt, + updatedAt, +})); + +module.exports = { + mapperIds, + artistIds, + beatmapSetIds, + beatmapIds, + mappers, + artists, + beatmapSets, + beatmaps, +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 89a7ff1..3d2ec54 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/play', + label: 'Play', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiControllerClassic' in icon ? icon['mdiControllerClassic' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_GAME_SESSIONS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ed3bfe5..8edafa0 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,186 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import type { ReactElement } from 'react'; +import React from 'react'; + import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const heroCovers = [ + 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726', + 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210', + 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476', +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); +const modes = [ + { + name: 'Beatmap', + description: 'Guess which beatmap has the higher hidden playcount.', + }, + { + name: 'Mapper', + description: 'Guess using total ranked + loved plays across a mapper catalogue.', + }, + { + name: 'Artist', + description: 'Guess using total ranked + loved plays aggregated by artist.', + }, + { + name: 'Mixed', + description: 'The server hides the metric until after you lock in your answer.', + }, +]; - const title = 'App Preview' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const rules = [ + 'Two large cards. One guess. Server-side answer only.', + '2 lives total. Wrong answer or timeout costs 1 life.', + '5–8 second rounds. No waiting room between reveals.', + 'osu!standard only. Ranked and Loved data only.', +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Osu Higher Lower')} + - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+

osu! higher lower

+

Minimal, fast, anti-cheat by default.

- - - +
+ + +
+
- - +
+
+
+ 2 lives • hidden mixed mode • osu! assets only +
+

+ A competitive browser game built around osu! beatmap popularity. +

+

+ Each round shows two beatmap cards. The backend picks the metric, + keeps every number hidden until you answer, and resolves the guess + instantly so the pace never drops. +

+ +
+ + +
+ +
+ {rules.map((rule) => ( +
+ {rule} +
+ ))} +
+
+ +
+
+
+ {heroCovers.map((cover, index) => ( +
+
+
+ {index === 0 ? 'live round' : 'osu! cover'} +
+
+
+

+ {index === 0 ? 'Hidden metric' : 'Minimal card surface'} +

+

+ {index === 0 ? 'Mode stays server-side until you commit.' : 'Beatmap art does the heavy lifting.'} +

+
+
+

Timer

+

8s

+
+
+
+
+ ))} +
+
+
+ +
+
+

How it plays

+

A thin, honest game loop.

+

+ Start a run, survive as many hidden comparisons as you can, review + the reveal, then jump straight into the next round. The admin UI is + still available for inspecting beatmaps, mappers, artists, sessions, + and logs without rebuilding any generic CRUD. +

+
+
+ {modes.map((mode) => ( +
+

+ {mode.name} +

+

{mode.description}

+
+ ))} +
+
+ +
+

Built for instant rounds, clean visuals, and server-side fairness.

+
+ + Login + + + Admin interface + + + Play + +
+
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ + ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/play/[gameSessionId].tsx b/frontend/src/pages/play/[gameSessionId].tsx new file mode 100644 index 0000000..ce3edaa --- /dev/null +++ b/frontend/src/pages/play/[gameSessionId].tsx @@ -0,0 +1,388 @@ +import axios from 'axios'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; + +import BaseButton from '../../components/BaseButton'; +import LoadingSpinner from '../../components/LoadingSpinner'; +import SectionMain from '../../components/SectionMain'; +import { getPageTitle } from '../../config'; +import LayoutAuthenticated from '../../layouts/Authenticated'; + +type ModeSetting = 'beatmap' | 'mapper' | 'artist' | 'mixed'; +type TimerProfile = 'normal' | 'insane'; +type RoundResult = 'pending' | 'correct' | 'wrong' | 'timeout'; + +type SessionSummary = { + id: string; + sessionKey: string; + status: string; + modeSetting: ModeSetting; + timerProfile: TimerProfile; + startingLives: number; + livesRemaining: number; + currentStreak: number; + bestStreak: number; + startedAt: string; + endedAt: string | null; + totalRounds: number; + correctAnswers: number; + wrongAnswers: number; + timeoutCount: number; + suspiciousRounds: number; +}; + +type RoundCard = { + id: string; + beatmapId: number; + title: string; + version: string; + artistName: string; + mapperName: string; + mapperAvatarUrl: string; + backgroundImageUrl: string; + status: string; +}; + +type RoundDetail = { + id: string; + roundIndex: number; + result: RoundResult; + playerChoice: 'a' | 'b' | 'none'; + correctChoice: 'a' | 'b'; + resolvedMode: 'beatmap' | 'mapper' | 'artist'; + comparedLabel: string; + timeLimitSeconds: number; + presentedAt: string; + answeredAt: string | null; + isSuspectedCheat: boolean; + suspicionReason: string | null; + cards: { + a: RoundCard; + b: RoundCard; + }; + values: { + a: number; + b: number; + }; +}; + +type SessionDetailPayload = { + session: SessionSummary; + rounds: RoundDetail[]; +}; + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}); + +const numberFormatter = new Intl.NumberFormat('en-US'); + +function formatModeLabel(value: ModeSetting) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatTimerLabel(value: TimerProfile) { + return value === 'insane' ? 'Insane · 6s' : 'Normal · 8s'; +} + +function extractErrorMessage(error: unknown) { + if (axios.isAxiosError(error)) { + if (typeof error.response?.data === 'string') { + return error.response.data; + } + } + + if (error instanceof Error) { + return error.message; + } + + return 'Unable to load the session details.'; +} + +function getResultBadge(result: RoundResult) { + if (result === 'correct') { + return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200'; + } + + if (result === 'timeout') { + return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200'; + } + + if (result === 'wrong') { + return 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200'; + } + + return 'border-slate-200 bg-slate-50 text-slate-800 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'; +} + +export default function SessionDetailPage() { + const router = useRouter(); + const sessionId = useMemo(() => { + if (typeof router.query.gameSessionId === 'string') { + return router.query.gameSessionId; + } + + return ''; + }, [router.query.gameSessionId]); + const [payload, setPayload] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + + const fetchSessionDetails = useCallback(async () => { + if (!sessionId) { + return; + } + + setIsLoading(true); + + try { + const { data } = await axios.get(`/gameplay/sessions/${sessionId}`); + + setPayload(data); + setErrorMessage(''); + } catch (error: unknown) { + console.error('Failed to load Osu Higher Lower session details:', error); + setErrorMessage(extractErrorMessage(error)); + } finally { + setIsLoading(false); + } + }, [sessionId]); + + useEffect(() => { + void fetchSessionDetails(); + }, [fetchSessionDetails]); + + return ( + <> + + {getPageTitle('Session details')} + + +
+
+
+
+

Round review

+

+ Session details +

+

+ Inspect every reveal from the run: the resolved metric, the hidden values, + your guess, and any suspicious-late answers flagged by the backend. +

+
+
+ + +
+
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isLoading ? ( +
+ +
+ ) : payload ? ( + <> +
+
+

Mode

+

+ {formatModeLabel(payload.session.modeSetting)} +

+

+ {formatTimerLabel(payload.session.timerProfile)} +

+
+
+

Score

+

+ {payload.session.correctAnswers} +

+

+ correct rounds +

+
+
+

Best streak

+

+ {payload.session.bestStreak} +

+

+ highest uninterrupted run +

+
+
+

Rounds

+

+ {payload.session.totalRounds} +

+

+ finished comparisons +

+
+
+

Started

+

+ {dateFormatter.format(new Date(payload.session.startedAt))} +

+

+ {payload.session.endedAt + ? `ended ${dateFormatter.format(new Date(payload.session.endedAt))}` + : 'still in progress'} +

+
+
+ +
+
+
+

Round log

+

+ {payload.rounds.length} revealed rounds +

+
+ {payload.session.suspiciousRounds ? ( +
+ {payload.session.suspiciousRounds} suspicious answer + {payload.session.suspiciousRounds === 1 ? '' : 's'} +
+ ) : null} +
+ +
+ {payload.rounds.map((round) => ( +
+
+
+

+ Round {round.roundIndex} +

+

+ {round.comparedLabel} +

+

+ Presented {dateFormatter.format(new Date(round.presentedAt))} + {round.answeredAt + ? ` · answered ${dateFormatter.format(new Date(round.answeredAt))}` + : ''} +

+
+
+ + {round.result} + + + picked {round.playerChoice.toUpperCase()} + + + correct {round.correctChoice.toUpperCase()} + +
+
+ +
+ {(['a', 'b'] as const).map((choice) => { + const card = round.cards[choice]; + const value = round.values[choice]; + const isWinner = round.correctChoice === choice; + + return ( +
+
+
+
+ + {choice} + + + {isWinner ? 'higher value' : card.status} + +
+
+

Artist

+

{card.artistName}

+

{card.title}

+

{card.version}

+
+
+ {`${card.mapperName} +
+
+

Mapper

+

{card.mapperName}

+
+
+
+

+ {round.comparedLabel} +

+

+ {numberFormatter.format(value)} +

+
+
+
+
+
+ ); + })} +
+ + {round.isSuspectedCheat && round.suspicionReason ? ( +
+ {round.suspicionReason} +
+ ) : null} +
+ ))} +
+
+ + ) : ( +
+ No session detail available yet. +
+ )} +
+
+ + ); +} + +SessionDetailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/play/index.tsx b/frontend/src/pages/play/index.tsx new file mode 100644 index 0000000..2b383e9 --- /dev/null +++ b/frontend/src/pages/play/index.tsx @@ -0,0 +1,1014 @@ +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import BaseButton from '../../components/BaseButton'; +import LoadingSpinner from '../../components/LoadingSpinner'; +import SectionMain from '../../components/SectionMain'; +import { getPageTitle } from '../../config'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { useAppSelector } from '../../stores/hooks'; + +type ModeSetting = 'beatmap' | 'mapper' | 'artist' | 'mixed'; +type TimerProfile = 'normal' | 'insane'; +type PlayerChoice = 'a' | 'b' | 'none'; +type RoundResult = 'correct' | 'wrong' | 'timeout'; + +type BeatmapCard = { + id: string; + beatmapId: number; + title: string; + version: string; + artistName: string; + mapperName: string; + mapperAvatarUrl: string; + backgroundImageUrl: string; + status: string; +}; + +type ActiveGamePayload = { + session: { + id: string; + sessionKey: string; + status: string; + modeSetting: ModeSetting; + timerProfile: TimerProfile; + livesRemaining: number; + startingLives: number; + currentStreak: number; + bestStreak: number; + startedAt: string; + }; + round: { + id: string; + index: number; + timeLimitSeconds: number; + presentedAt: string; + endsAt: string; + cards: { + a: BeatmapCard; + b: BeatmapCard; + }; + }; +}; + +type SessionSummary = { + id: string; + sessionKey: string; + status: string; + modeSetting: ModeSetting; + timerProfile: TimerProfile; + startingLives: number; + livesRemaining: number; + currentStreak: number; + bestStreak: number; + startedAt: string; + endedAt: string | null; + totalRounds: number; + correctAnswers: number; + wrongAnswers: number; + timeoutCount: number; + suspiciousRounds: number; +}; + +type LeaderboardRow = { + id: string; + playerName: string; + scoreValue: number; + bestStreak: number; + gamesPlayed: number; + modeSetting: ModeSetting; + timerProfile: TimerProfile; + recordedAt: string; +}; + +type OverviewPayload = { + activeSession: ActiveGamePayload | null; + recentSessions: SessionSummary[]; + leaderboard: LeaderboardRow[]; + dataset: { + beatmapCount: number; + lastSyncedAt: string | null; + }; +}; + +type ResolutionPayload = { + roundId: string; + roundIndex: number; + result: RoundResult; + playerChoice: PlayerChoice; + correctChoice: 'a' | 'b'; + resolvedMode: 'beatmap' | 'mapper' | 'artist'; + comparedLabel: string; + values: { + a: number; + b: number; + }; + cards: { + a: BeatmapCard; + b: BeatmapCard; + }; +}; + +type AnswerPayload = { + resolution: ResolutionPayload; + sessionSummary: SessionSummary; + nextRound: ActiveGamePayload | null; +}; + +const modeOptions: { value: ModeSetting; label: string; description: string }[] = [ + { + value: 'mixed', + label: 'Mixed', + description: 'Backend chooses the metric per round and hides it until after you answer.', + }, + { + value: 'beatmap', + label: 'Beatmap', + description: 'Pure beatmap playcount comparisons only.', + }, + { + value: 'mapper', + label: 'Mapper', + description: 'Compare total ranked + loved plays by mapper.', + }, + { + value: 'artist', + label: 'Artist', + description: 'Compare total ranked + loved plays by artist.', + }, +]; + +const timerOptions: { value: TimerProfile; label: string; description: string }[] = [ + { + value: 'normal', + label: 'Normal · 8s', + description: 'Gives you a fair read on the art, title, artist, and mapper before the guess.', + }, + { + value: 'insane', + label: 'Insane · 6s', + description: 'Still fast, but less punishing when the cards need a split-second longer.', + }, +]; + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}); + +const numberFormatter = new Intl.NumberFormat('en-US'); +const CARD_VALUE_COUNTUP_MS = 900; +const NEXT_ROUND_DELAY_MS = 1150; + +function formatModeLabel(value: ModeSetting) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatTimerLabel(value: TimerProfile) { + return value === 'insane' ? 'Insane · 6s' : 'Normal · 8s'; +} + +function extractErrorMessage(error: unknown) { + if (axios.isAxiosError(error)) { + if (typeof error.response?.data === 'string') { + return error.response.data; + } + + if ( + error.response?.data && + typeof error.response.data === 'object' && + 'message' in error.response.data && + typeof error.response.data.message === 'string' + ) { + return error.response.data.message; + } + } + + if (error instanceof Error) { + return error.message; + } + + return 'Something went wrong while talking to the gameplay API.'; +} + +function getResolutionCopy(resolution: ResolutionPayload) { + if (resolution.result === 'correct') { + return 'Correct'; + } + + if (resolution.result === 'timeout') { + return 'Timeout'; + } + + return 'Wrong'; +} + +function getResolutionDetailCopy(resolution: ResolutionPayload) { + if (resolution.result === 'correct') { + return 'The higher total is highlighted in green on the winning card.'; + } + + if (resolution.result === 'timeout') { + return 'Time ran out. The winning card is still revealed in green.'; + } + + return 'Your miss is marked in red while the higher total stays green.'; +} + +function getResultStyles(result: RoundResult) { + if (result === 'correct') { + return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200'; + } + + if (result === 'timeout') { + return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200'; + } + + return 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200'; +} + +function buildCardButtonClasses( + disabled: boolean, + tone: 'neutral' | 'winner' | 'wrong', + revealActive: boolean, +) { + const toneClasses = + tone === 'winner' + ? 'border-emerald-400/80 shadow-[0_20px_60px_rgba(16,185,129,0.16)]' + : tone === 'wrong' + ? 'border-rose-400/80 shadow-[0_20px_60px_rgba(244,63,94,0.14)]' + : 'border-slate-200 dark:border-dark-700'; + + return [ + 'group relative overflow-hidden rounded-[1.75rem] border bg-slate-950 text-left transition duration-150', + toneClasses, + disabled + ? revealActive + ? 'cursor-not-allowed' + : 'cursor-not-allowed opacity-75' + : 'hover:-translate-y-0.5 hover:shadow-[0_18px_60px_rgba(15,23,42,0.22)] focus:outline-none focus:ring-2 focus:ring-[#ff66aa] focus:ring-offset-2 dark:focus:ring-offset-dark-900', + ].join(' '); +} + +export default function PlayPage() { + const { currentUser } = useAppSelector((state) => state.auth); + const [overview, setOverview] = useState(null); + const [activeGame, setActiveGame] = useState(null); + const [lastCompletedSession, setLastCompletedSession] = useState(null); + const [resolution, setResolution] = useState(null); + const [modeSetting, setModeSetting] = useState('mixed'); + const [timerProfile, setTimerProfile] = useState('normal'); + const [isLoadingOverview, setIsLoadingOverview] = useState(true); + const [isStarting, setIsStarting] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [timeRemainingMs, setTimeRemainingMs] = useState(0); + const [animatedValues, setAnimatedValues] = useState<{ a: number; b: number }>({ + a: 0, + b: 0, + }); + const revealTimerRef = useRef | null>(null); + const valueAnimationFrameRef = useRef(null); + const submissionLockRef = useRef(false); + + const fetchOverview = useCallback(async () => { + setIsLoadingOverview(true); + + try { + const { data } = await axios.get('/gameplay/overview'); + + setOverview(data); + setActiveGame(data.activeSession); + setErrorMessage(''); + } catch (error: unknown) { + console.error('Failed to load Osu Higher Lower overview:', error); + setErrorMessage(extractErrorMessage(error)); + } finally { + setIsLoadingOverview(false); + } + }, []); + + useEffect(() => { + void fetchOverview(); + }, [fetchOverview]); + + useEffect(() => { + return () => { + if (revealTimerRef.current) { + window.clearTimeout(revealTimerRef.current); + } + + if (valueAnimationFrameRef.current) { + window.cancelAnimationFrame(valueAnimationFrameRef.current); + } + }; + }, []); + + useEffect(() => { + if (valueAnimationFrameRef.current) { + window.cancelAnimationFrame(valueAnimationFrameRef.current); + valueAnimationFrameRef.current = null; + } + + if (!resolution) { + setAnimatedValues({ a: 0, b: 0 }); + return undefined; + } + + const startedAt = window.performance.now(); + const targetValues = resolution.values; + + setAnimatedValues({ a: 0, b: 0 }); + + const animateValues = (timestamp: number) => { + const progress = Math.min((timestamp - startedAt) / CARD_VALUE_COUNTUP_MS, 1); + const easedProgress = 1 - (1 - progress) ** 3; + + setAnimatedValues({ + a: Math.round(targetValues.a * easedProgress), + b: Math.round(targetValues.b * easedProgress), + }); + + if (progress < 1) { + valueAnimationFrameRef.current = window.requestAnimationFrame(animateValues); + return; + } + + valueAnimationFrameRef.current = null; + }; + + valueAnimationFrameRef.current = window.requestAnimationFrame(animateValues); + + return () => { + if (valueAnimationFrameRef.current) { + window.cancelAnimationFrame(valueAnimationFrameRef.current); + valueAnimationFrameRef.current = null; + } + }; + }, [resolution]); + + const handleRoundAnswer = useCallback( + async (choice: PlayerChoice) => { + if (!activeGame || submissionLockRef.current || isSubmitting) { + return; + } + + submissionLockRef.current = true; + setIsSubmitting(true); + + try { + const { data } = await axios.post('/gameplay/answer', { + sessionId: activeGame.session.id, + choice, + }); + + setResolution(data.resolution); + setLastCompletedSession(data.sessionSummary); + + if (data.nextRound) { + if (revealTimerRef.current) { + window.clearTimeout(revealTimerRef.current); + } + + revealTimerRef.current = window.setTimeout(() => { + setActiveGame(data.nextRound); + setResolution(null); + setIsSubmitting(false); + submissionLockRef.current = false; + }, NEXT_ROUND_DELAY_MS); + } else { + setActiveGame(null); + setIsSubmitting(false); + submissionLockRef.current = false; + await fetchOverview(); + } + } catch (error: unknown) { + console.error('Failed to submit Osu Higher Lower answer:', error); + setErrorMessage(extractErrorMessage(error)); + setIsSubmitting(false); + submissionLockRef.current = false; + } + }, + [activeGame, fetchOverview, isSubmitting], + ); + + useEffect(() => { + if (!activeGame) { + setTimeRemainingMs(0); + return undefined; + } + + const syncRemainingTime = () => { + const remaining = Math.max( + new Date(activeGame.round.endsAt).getTime() - Date.now(), + 0, + ); + + setTimeRemainingMs(remaining); + + if (remaining === 0 && !submissionLockRef.current && !isSubmitting) { + void handleRoundAnswer('none'); + } + }; + + syncRemainingTime(); + + const interval = window.setInterval(syncRemainingTime, 100); + + return () => { + window.clearInterval(interval); + }; + }, [activeGame, handleRoundAnswer, isSubmitting]); + + const handleStartSession = useCallback(async () => { + setIsStarting(true); + setResolution(null); + setLastCompletedSession(null); + setErrorMessage(''); + + try { + const { data } = await axios.post('/gameplay/start', { + modeSetting, + timerProfile, + }); + + setActiveGame(data); + setOverview((currentOverview) => + currentOverview + ? { + ...currentOverview, + activeSession: data, + } + : currentOverview, + ); + } catch (error: unknown) { + console.error('Failed to start Osu Higher Lower session:', error); + setErrorMessage(extractErrorMessage(error)); + } finally { + submissionLockRef.current = false; + setIsStarting(false); + } + }, [modeSetting, timerProfile]); + + const timerProgress = useMemo(() => { + if (!activeGame) { + return 0; + } + + const fullDuration = activeGame.round.timeLimitSeconds * 1000; + + if (!fullDuration) { + return 0; + } + + return Math.max(Math.min((timeRemainingMs / fullDuration) * 100, 100), 0); + }, [activeGame, timeRemainingMs]); + + const playerName = useMemo(() => { + const fullName = [currentUser?.firstName, currentUser?.lastName] + .filter(Boolean) + .join(' ') + .trim(); + + return fullName || currentUser?.email?.split('@')[0] || 'Player'; + }, [currentUser]); + + return ( + <> + + {getPageTitle('Play Osu Higher Lower')} + + +
+
+
+
+

Playable MVP slice

+

+ Osu Higher Lower +

+

+ Pick the higher hidden stat, protect your two lives, and keep the + streak alive. Mixed mode keeps the metric server-side until your + answer lands so the frontend never sees the truth early. +

+
+ + {overview?.dataset.beatmapCount ?? 0} active beatmaps + + + osu!standard only + + + Ranked + Loved only + + + 2 lives per run + +
+
+
+
+

Player

+

{playerName}

+

+ Recent runs and round reveals are attached to your signed-in session. +

+
+
+

Dataset sync

+

+ {overview?.dataset.lastSyncedAt + ? dateFormatter.format(new Date(overview.dataset.lastSyncedAt)) + : 'Waiting'} +

+

+ Precomputed mapper and artist totals are stored ahead of gameplay. +

+
+
+
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isLoadingOverview && !overview ? ( +
+ +
+ ) : ( +
+
+
+
+
+

+ {activeGame ? 'Run in progress' : 'Start a run'} +

+

+ {activeGame ? 'Continue from your current round.' : 'Choose a mode and timer.'} +

+
+
+ {activeGame + ? `${activeGame.session.livesRemaining}/${activeGame.session.startingLives} lives` + : '2 lives'} +
+
+ +
+
+

Mode

+
+ {modeOptions.map((option) => { + const isSelected = modeSetting === option.value; + + return ( + + ); + })} +
+
+ +
+

Timer

+
+ {timerOptions.map((option) => { + const isSelected = timerProfile === option.value; + + return ( + + ); + })} +
+
+
+ +
+ void handleStartSession()} + /> + {activeGame ? ( + + Starting over abandons the current run and deals a new round instantly. + + ) : null} +
+
+ +
+
+
+

Leaderboard

+

Best runs so far

+
+
+ +
+ {overview?.leaderboard.length ? ( + overview.leaderboard.map((entry, index) => ( +
+
+

+ {index + 1}. {entry.playerName} +

+

+ {formatModeLabel(entry.modeSetting)} · {formatTimerLabel(entry.timerProfile)} +

+
+
+

+ {entry.scoreValue} +

+

+ best streak {entry.bestStreak} · {entry.gamesPlayed} games +

+
+
+ )) + ) : ( +
+ No leaderboard entries yet. Finish a run and the board will populate. +
+ )} +
+
+
+ +
+
+
+
+

Live board

+

+ {activeGame ? `Round ${activeGame.round.index}` : 'Ready when you are'} +

+

+ {activeGame + ? activeGame.session.modeSetting === 'mixed' + ? 'Mixed mode keeps the current metric hidden until you answer.' + : `${formatModeLabel(activeGame.session.modeSetting)} mode is active for this run.` + : 'Start a run to deal two beatmaps and begin the timer.'} +

+
+ {activeGame ? ( +
+ + {activeGame.session.livesRemaining}/{activeGame.session.startingLives} lives + + + streak {activeGame.session.currentStreak} + + + {formatTimerLabel(activeGame.session.timerProfile)} + +
+ ) : null} +
+ + {resolution ? ( +
+
+
+

Reveal

+

+ {getResolutionCopy(resolution)} · {resolution.comparedLabel} +

+
+
{getResolutionDetailCopy(resolution)}
+
+
+ ) : null} + + {activeGame ? ( + <> +
+
+
+
+ {resolution ? 'Locking next round…' : 'Timer'} + {(timeRemainingMs / 1000).toFixed(1)}s +
+ +
+
+
+ VS +
+
+
+ {(['a', 'b'] as const).map((choice, index) => { + const card = activeGame.round.cards[choice]; + const isDisabled = isSubmitting || isStarting; + const isWinningCard = resolution?.correctChoice === choice; + const isWrongSelection = + resolution?.result === 'wrong' && resolution.playerChoice === choice; + const cardTone = isWrongSelection + ? 'wrong' + : isWinningCard + ? 'winner' + : 'neutral'; + const statsBoxClasses = isWinningCard + ? 'border-emerald-300/50 bg-emerald-500/20 text-emerald-50' + : isWrongSelection + ? 'border-rose-300/45 bg-rose-500/18 text-rose-50' + : 'border-white/15 bg-black/40 text-white'; + const revealOverlayClasses = isWinningCard + ? 'bg-emerald-500/16' + : isWrongSelection + ? 'bg-rose-500/14' + : 'bg-transparent'; + const revealPill = resolution + ? isWinningCard + ? 'Higher' + : 'Lower' + : 'Hidden'; + const revealHint = resolution ? 'Running total' : 'Choose the higher'; + + return ( + + {index === 1 ? ( +
+
+ VS +
+
+ ) : null} + +
+ ); + })} +
+
+ + ) : ( +
+ Choose a mode, hit start, and the backend will deal a hidden comparison round immediately. +
+ )} +
+ + {lastCompletedSession && !activeGame ? ( +
+

Latest result

+

Run finished

+
+
+

Score

+

+ {lastCompletedSession.correctAnswers} +

+
+
+

Best streak

+

+ {lastCompletedSession.bestStreak} +

+
+
+

Mode

+

+ {formatModeLabel(lastCompletedSession.modeSetting)} +

+
+
+

Timer

+

+ {formatTimerLabel(lastCompletedSession.timerProfile)} +

+
+
+
+ void handleStartSession()} + /> + +
+
+ ) : null} + +
+
+
+

Recent sessions

+

Your run history

+
+ {overview?.recentSessions.length ? ( + + {overview.recentSessions.length} recent runs + + ) : null} +
+
+ {overview?.recentSessions.length ? ( + overview.recentSessions.map((session) => ( + +
+

+ {formatModeLabel(session.modeSetting)} · {formatTimerLabel(session.timerProfile)} +

+

+ {session.endedAt + ? dateFormatter.format(new Date(session.endedAt)) + : dateFormatter.format(new Date(session.startedAt))} +

+
+
+
+

Score

+

+ {session.correctAnswers} +

+
+
+

Best streak

+

+ {session.bestStreak} +

+
+
+

Rounds

+

+ {session.totalRounds} +

+
+
+

Review

+

+ Open details +

+
+
+ + )) + ) : ( +
+ You have not finished any Osu Higher Lower sessions yet. +
+ )} +
+
+
+
+ )} +
+
+ + ); +} + +PlayPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +};