diff --git a/assets/pasted-20260506-160112-7c934626.png b/assets/pasted-20260506-160112-7c934626.png new file mode 100644 index 0000000..bda6232 Binary files /dev/null and b/assets/pasted-20260506-160112-7c934626.png differ diff --git a/backend/src/index.js b/backend/src/index.js index 5d1f556..1f2f1b3 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'); @@ -54,6 +53,7 @@ const leaderboard_entriesRoutes = require('./routes/leaderboard_entries'); const daily_seedsRoutes = require('./routes/daily_seeds'); const server_cachesRoutes = require('./routes/server_caches'); +const gameplayRoutes = require('./routes/gameplay'); const getBaseUrl = (url) => { @@ -146,6 +146,7 @@ app.use('/api/leaderboard_entries', passport.authenticate('jwt', {session: false app.use('/api/daily_seeds', passport.authenticate('jwt', {session: false}), daily_seedsRoutes); app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes); +app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/gameplay.js b/backend/src/routes/gameplay.js new file mode 100644 index 0000000..0f222c9 --- /dev/null +++ b/backend/src/routes/gameplay.js @@ -0,0 +1,58 @@ +const express = require('express'); + +const GameplayService = require('../services/gameplay'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get( + '/active', + wrapAsync(async (req, res) => { + const payload = await GameplayService.getActiveSession(req.currentUser); + res.status(200).send(payload); + }), +); + +router.get( + '/sessions', + wrapAsync(async (req, res) => { + const payload = await GameplayService.listSessions(req.currentUser); + res.status(200).send(payload); + }), +); + +router.get( + '/sessions/:id', + wrapAsync(async (req, res) => { + const payload = await GameplayService.getSessionDetail( + req.params.id, + req.currentUser, + ); + res.status(200).send(payload); + }), +); + +router.post( + '/sessions/start', + wrapAsync(async (req, res) => { + const payload = await GameplayService.startSession(req.body, req.currentUser); + res.status(200).send(payload); + }), +); + +router.post( + '/sessions/:sessionId/rounds/:roundId/answer', + wrapAsync(async (req, res) => { + const payload = await GameplayService.answerRound( + req.params.sessionId, + req.params.roundId, + req.body, + req.currentUser, + ); + res.status(200).send(payload); + }), +); + +router.use('/', require('../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..69937d7 --- /dev/null +++ b/backend/src/services/gameplay.js @@ -0,0 +1,1108 @@ +const crypto = require('crypto'); +const db = require('../db/models'); + +const { Op } = db.Sequelize; + +const TIMER_PROFILES = { + normal: 8000, + insane: 5000, +}; + +const MODE_SELECTIONS = { + beatmap_only: 'beatmap', + mapper_only: 'mapper', + artist_only: 'artist', + mixed: 'mixed', +}; + +const ROUND_MODES = ['beatmap', 'mapper', 'artist']; +const RECENT_ENTITY_COOLDOWN = 20; +const CANDIDATE_SEARCH_PLANS = [ + { + limit: 120, + strategy: 'strict_session', + }, + { + limit: 180, + strategy: 'recent_cooldown', + }, + { + limit: 260, + strategy: 'recent_beatmap_cooldown', + }, + { + limit: 320, + strategy: 'open_pool', + }, +]; + +const CANDIDATE_INCLUDE = [ + { + model: db.beatmap_sets, + as: 'beatmap_set', + }, + { + model: db.mappers, + as: 'mapper', + include: [ + { + model: db.mapper_stats, + as: 'mapper_stats_mapper', + required: false, + }, + ], + }, + { + model: db.artists, + as: 'artist', + include: [ + { + model: db.artist_stats, + as: 'artist_stats_artist', + required: false, + }, + ], + }, +]; + +const ROUND_VISUAL_INCLUDE = [ + { + model: db.beatmap_sets, + as: 'beatmap_set', + }, + { + model: db.mappers, + as: 'mapper', + }, + { + model: db.artists, + as: 'artist', + }, +]; + +function gameplayError(message, code = 400) { + const error = new Error(message); + error.code = code; + return error; +} + +function normalizeNumber(value) { + if (value === null || value === undefined) { + return 0; + } + + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : 0; +} + +function isProbablyPlaceholderImage(url) { + return !url || url.includes('cdn.example.com'); +} + +function getBeatmapVisual(beatmap) { + return ( + beatmap?.beatmap_set?.background_image_url || + beatmap?.beatmap_set?.cover_image_url || + null + ); +} + +function getMaxStatTotal(statRows = []) { + if (!Array.isArray(statRows) || !statRows.length) { + return 0; + } + + return statRows.reduce((highestValue, statRow) => { + const totalPlays = normalizeNumber(statRow?.total_plays); + return totalPlays > highestValue ? totalPlays : highestValue; + }, 0); +} + +function getMapperTotalPlays(beatmap) { + return getMaxStatTotal(beatmap?.mapper?.mapper_stats_mapper); +} + +function getArtistTotalPlays(beatmap) { + return getMaxStatTotal(beatmap?.artist?.artist_stats_artist); +} + +function getRoundValue(beatmap, roundMode) { + if (roundMode === 'mapper') { + return getMapperTotalPlays(beatmap); + } + + if (roundMode === 'artist') { + return getArtistTotalPlays(beatmap); + } + + return normalizeNumber(beatmap?.playcount); +} + +function getModeLabel(roundMode) { + if (roundMode === 'mapper') { + return 'Mapper total plays'; + } + + if (roundMode === 'artist') { + return 'Artist total plays'; + } + + return 'Beatmap playcount'; +} + +function getValueSuffix(roundMode) { + if (roundMode === 'beatmap') { + return 'plays'; + } + + return 'total plays'; +} + +function pickRoundMode(modeSelection) { + if (!MODE_SELECTIONS[modeSelection]) { + throw gameplayError('Unsupported mode selection.'); + } + + if (modeSelection === 'mixed') { + return ROUND_MODES[Math.floor(Math.random() * ROUND_MODES.length)]; + } + + return MODE_SELECTIONS[modeSelection]; +} + +function shuffle(items) { + const cloned = [...items]; + + for (let index = cloned.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [cloned[index], cloned[swapIndex]] = [cloned[swapIndex], cloned[index]]; + } + + return cloned; +} + +function serializeCard(beatmap, roundMode) { + const fallbackVisual = getBeatmapVisual(beatmap); + + if (roundMode === 'mapper') { + return { + name: + beatmap?.mapper?.osu_username || + beatmap?.beatmap_set?.creator_username || + 'Unknown mapper', + avatarUrl: beatmap?.mapper?.avatar_url || null, + bannerUrl: beatmap?.mapper?.banner_url || fallbackVisual, + fallbackVisual, + }; + } + + if (roundMode === 'artist') { + const artistImage = isProbablyPlaceholderImage(beatmap?.artist?.image_url) + ? fallbackVisual + : beatmap?.artist?.image_url; + + return { + name: beatmap?.artist?.name || beatmap?.beatmap_set?.artist_name || 'Unknown artist', + imageUrl: artistImage || fallbackVisual, + fallbackVisual, + }; + } + + return { + title: beatmap?.beatmap_set?.title || 'Unknown beatmap', + artistName: beatmap?.artist?.name || beatmap?.beatmap_set?.artist_name || 'Unknown artist', + mapperName: + beatmap?.mapper?.osu_username || + beatmap?.beatmap_set?.creator_username || + 'Unknown mapper', + mapperAvatarUrl: beatmap?.mapper?.avatar_url || null, + difficultyName: beatmap?.difficulty_name || 'Unknown difficulty', + status: beatmap?.status || 'ranked', + backgroundImageUrl: fallbackVisual, + }; +} + +function serializePendingRound(roundRecord) { + return { + id: roundRecord.id, + roundNumber: roundRecord.round_number, + timeLimitMs: roundRecord.time_limit_ms, + presentedAt: roundRecord.presented_at, + expiresAt: new Date( + new Date(roundRecord.presented_at).getTime() + roundRecord.time_limit_ms, + ).toISOString(), + cards: { + a: serializeCard(roundRecord.option_a_beatmap, roundRecord.round_mode), + b: serializeCard(roundRecord.option_b_beatmap, roundRecord.round_mode), + }, + }; +} + +function serializeResolvedRound(roundRecord) { + const winningChoice = normalizeNumber(roundRecord.value_a) > normalizeNumber(roundRecord.value_b) + ? 'a' + : 'b'; + + return { + ...serializePendingRound(roundRecord), + reveal: { + roundMode: roundRecord.round_mode, + modeLabel: getModeLabel(roundRecord.round_mode), + valueSuffix: getValueSuffix(roundRecord.round_mode), + correctChoice: roundRecord.correct_choice, + winningChoice, + playerChoice: roundRecord.player_choice, + isCorrect: Boolean(roundRecord.is_correct), + loseReason: roundRecord.lose_reason, + values: { + a: normalizeNumber(roundRecord.value_a), + b: normalizeNumber(roundRecord.value_b), + }, + answeredAt: roundRecord.answered_at, + }, + }; +} + +async function loadBeatmapVisual(beatmapId, transaction) { + return db.beatmaps.findOne({ + where: { id: beatmapId }, + include: ROUND_VISUAL_INCLUDE, + transaction, + }); +} + +async function hydrateRound(roundRecord, transaction) { + if (!roundRecord) { + return null; + } + + const plainRound = roundRecord.toJSON ? roundRecord.toJSON() : roundRecord; + const [optionABeatmap, optionBBeatmap] = await Promise.all([ + loadBeatmapVisual(plainRound.option_a_beatmapId, transaction), + loadBeatmapVisual(plainRound.option_b_beatmapId, transaction), + ]); + + return { + ...plainRound, + option_a_beatmap: optionABeatmap, + option_b_beatmap: optionBBeatmap, + }; +} + +async function loadRound(roundId, transaction) { + const roundRecord = await db.game_rounds.findOne({ + where: { id: roundId }, + transaction, + }); + + return hydrateRound(roundRecord, transaction); +} + +async function getPendingRound(sessionId, transaction) { + const roundRecord = await db.game_rounds.findOne({ + where: { + sessionId, + state: 'pending', + }, + order: [['round_number', 'DESC']], + transaction, + }); + + return hydrateRound(roundRecord, transaction); +} + +async function getSessionMetrics(sessionId, transaction) { + const [roundsPlayed, correctGuesses] = await Promise.all([ + db.game_rounds.count({ + where: { + sessionId, + state: { + [Op.not]: 'pending', + }, + }, + transaction, + }), + db.game_rounds.count({ + where: { + sessionId, + is_correct: true, + }, + transaction, + }), + ]); + + return { + roundsPlayed, + correctGuesses, + }; +} + +function serializeSession(sessionRecord, metrics = { roundsPlayed: 0, correctGuesses: 0 }) { + return { + id: sessionRecord.id, + status: sessionRecord.status, + modeSelection: sessionRecord.mode_selection, + timerProfile: sessionRecord.timer_profile, + sessionToken: sessionRecord.session_token, + startingLives: sessionRecord.starting_lives, + livesRemaining: sessionRecord.lives_remaining, + streak: sessionRecord.streak, + bestStreak: sessionRecord.best_streak, + startedAt: sessionRecord.started_at, + endedAt: sessionRecord.ended_at, + roundsPlayed: metrics.roundsPlayed, + correctGuesses: metrics.correctGuesses, + }; +} + +function limitedSetAdd(set, value, limit = null) { + if (!value) { + return; + } + + if (limit !== null && set.size >= limit) { + return; + } + + set.add(value); +} + +function getRoundEntityId(beatmap, roundMode) { + if (roundMode === 'mapper') { + return beatmap?.mapperId || beatmap?.mapper?.id || null; + } + + if (roundMode === 'artist') { + return beatmap?.artistId || beatmap?.artist?.id || null; + } + + return beatmap?.beatmap_setId || beatmap?.beatmap_set?.id || beatmap?.id || null; +} + +function dedupeCandidatesByRoundEntity(candidates, roundMode) { + const seenEntityIds = new Set(); + + return candidates.filter((beatmap) => { + const entityId = getRoundEntityId(beatmap, roundMode) || beatmap?.id; + + if (!entityId || seenEntityIds.has(entityId)) { + return false; + } + + seenEntityIds.add(entityId); + return true; + }); +} + +async function getSessionUsage(sessionId, transaction) { + const rounds = await db.game_rounds.findAll({ + where: { sessionId }, + attributes: ['id', 'round_number'], + include: [ + { + model: db.beatmaps, + as: 'option_a_beatmap', + attributes: ['id', 'beatmap_setId', 'mapperId', 'artistId'], + required: false, + }, + { + model: db.beatmaps, + as: 'option_b_beatmap', + attributes: ['id', 'beatmap_setId', 'mapperId', 'artistId'], + required: false, + }, + ], + order: [['round_number', 'DESC']], + transaction, + }); + + const usage = { + beatmapIds: new Set(), + beatmapSetIds: new Set(), + mapperIds: new Set(), + artistIds: new Set(), + recentBeatmapIds: new Set(), + recentBeatmapSetIds: new Set(), + recentMapperIds: new Set(), + recentArtistIds: new Set(), + }; + + rounds.forEach((roundRecord) => { + [roundRecord.option_a_beatmap, roundRecord.option_b_beatmap].forEach((beatmap) => { + if (!beatmap) { + return; + } + + limitedSetAdd(usage.beatmapIds, beatmap.id); + limitedSetAdd(usage.beatmapSetIds, beatmap.beatmap_setId); + limitedSetAdd(usage.mapperIds, beatmap.mapperId); + limitedSetAdd(usage.artistIds, beatmap.artistId); + limitedSetAdd(usage.recentBeatmapIds, beatmap.id, RECENT_ENTITY_COOLDOWN); + limitedSetAdd( + usage.recentBeatmapSetIds, + beatmap.beatmap_setId, + RECENT_ENTITY_COOLDOWN, + ); + limitedSetAdd(usage.recentMapperIds, beatmap.mapperId, RECENT_ENTITY_COOLDOWN); + limitedSetAdd(usage.recentArtistIds, beatmap.artistId, RECENT_ENTITY_COOLDOWN); + }); + }); + + return usage; +} + +function getCandidateExclusions(roundMode, sessionUsage, strategy) { + if (strategy === 'strict_session') { + return { + excludedBeatmapIds: Array.from(sessionUsage.beatmapIds), + excludedBeatmapSetIds: + roundMode === 'beatmap' ? Array.from(sessionUsage.beatmapSetIds) : [], + excludedMapperIds: + roundMode === 'mapper' ? Array.from(sessionUsage.mapperIds) : [], + excludedArtistIds: + roundMode === 'artist' ? Array.from(sessionUsage.artistIds) : [], + }; + } + + if (strategy === 'recent_cooldown') { + return { + excludedBeatmapIds: Array.from(sessionUsage.recentBeatmapIds), + excludedBeatmapSetIds: + roundMode === 'beatmap' ? Array.from(sessionUsage.recentBeatmapSetIds) : [], + excludedMapperIds: + roundMode === 'mapper' ? Array.from(sessionUsage.recentMapperIds) : [], + excludedArtistIds: + roundMode === 'artist' ? Array.from(sessionUsage.recentArtistIds) : [], + }; + } + + if (strategy === 'recent_beatmap_cooldown') { + return { + excludedBeatmapIds: Array.from(sessionUsage.recentBeatmapIds), + excludedBeatmapSetIds: + roundMode === 'beatmap' ? Array.from(sessionUsage.recentBeatmapSetIds) : [], + excludedMapperIds: [], + excludedArtistIds: [], + }; + } + + return { + excludedBeatmapIds: [], + excludedBeatmapSetIds: [], + excludedMapperIds: [], + excludedArtistIds: [], + }; +} + +async function findCandidates({ + roundMode, + limit = 30, + excludedBeatmapIds = [], + excludedBeatmapSetIds = [], + excludedMapperIds = [], + excludedArtistIds = [], + transaction, +}) { + const where = { + mode: 'osu', + status: { + [Op.in]: ['ranked', 'loved'], + }, + }; + + if (excludedBeatmapIds.length) { + where.id = { + [Op.notIn]: excludedBeatmapIds, + }; + } + + if (roundMode === 'beatmap' && excludedBeatmapSetIds.length) { + where.beatmap_setId = { + [Op.notIn]: excludedBeatmapSetIds, + }; + } + + if (roundMode === 'mapper' && excludedMapperIds.length) { + where.mapperId = { + [Op.notIn]: excludedMapperIds, + }; + } + + if (roundMode === 'artist' && excludedArtistIds.length) { + where.artistId = { + [Op.notIn]: excludedArtistIds, + }; + } + + const candidates = await db.beatmaps.findAll({ + where, + include: CANDIDATE_INCLUDE, + limit, + order: db.sequelize.random(), + transaction, + }); + + return dedupeCandidatesByRoundEntity( + candidates.filter((beatmap) => beatmap?.beatmap_set && beatmap?.mapper && beatmap?.artist), + roundMode, + ); +} + +function canComparePair(firstBeatmap, secondBeatmap, roundMode) { + if (!firstBeatmap || !secondBeatmap || firstBeatmap.id === secondBeatmap.id) { + return false; + } + + if (roundMode === 'mapper' && firstBeatmap.mapperId === secondBeatmap.mapperId) { + return false; + } + + if (roundMode === 'artist' && firstBeatmap.artistId === secondBeatmap.artistId) { + return false; + } + + const firstValue = getRoundValue(firstBeatmap, roundMode); + const secondValue = getRoundValue(secondBeatmap, roundMode); + + return firstValue > 0 && secondValue > 0 && firstValue !== secondValue; +} + +function pickComparablePair(candidates, roundMode) { + const shuffled = shuffle(candidates); + + for (let outerIndex = 0; outerIndex < shuffled.length; outerIndex += 1) { + for ( + let innerIndex = outerIndex + 1; + innerIndex < shuffled.length; + innerIndex += 1 + ) { + const firstBeatmap = shuffled[outerIndex]; + const secondBeatmap = shuffled[innerIndex]; + + if (!canComparePair(firstBeatmap, secondBeatmap, roundMode)) { + continue; + } + + const valueA = getRoundValue(firstBeatmap, roundMode); + const valueB = getRoundValue(secondBeatmap, roundMode); + + return { + firstBeatmap, + secondBeatmap, + valueA, + valueB, + }; + } + } + + throw gameplayError( + 'No comparable beatmap pair is available for the selected mode. Please sync more osu! data.', + 503, + ); +} + +function generateSessionToken() { + return `sess_${crypto.randomBytes(8).toString('hex')}`; +} + +async function selectComparablePairForSession(sessionRecord, transaction) { + const roundMode = pickRoundMode(sessionRecord.mode_selection); + const sessionUsage = await getSessionUsage(sessionRecord.id, transaction); + let lastError = null; + + for (const searchPlan of CANDIDATE_SEARCH_PLANS) { + const candidates = await findCandidates({ + roundMode, + limit: searchPlan.limit, + transaction, + ...getCandidateExclusions(roundMode, sessionUsage, searchPlan.strategy), + }); + + try { + return { + roundMode, + ...pickComparablePair(candidates, roundMode), + }; + } catch (error) { + lastError = error; + } + } + + throw ( + lastError || + gameplayError( + 'No comparable beatmap pair is available for the selected mode. Please sync more osu! data.', + 503, + ) + ); +} + +async function createRoundForSession(sessionRecord, currentUser, transaction) { + const { roundMode, firstBeatmap, secondBeatmap, valueA, valueB } = + await selectComparablePairForSession(sessionRecord, transaction); + + const nextRoundNumber = + (await db.game_rounds.count({ + where: { sessionId: sessionRecord.id }, + transaction, + })) + 1; + + const round = await db.game_rounds.create( + { + sessionId: sessionRecord.id, + round_number: nextRoundNumber, + round_mode: roundMode, + state: 'pending', + time_limit_ms: TIMER_PROFILES[sessionRecord.timer_profile], + presented_at: new Date(), + player_choice: 'none', + correct_choice: valueA > valueB ? 'a' : 'b', + is_correct: false, + lose_reason: 'none', + value_a: valueA, + value_b: valueB, + option_a_beatmapId: firstBeatmap.id, + option_b_beatmapId: secondBeatmap.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const roundRecord = await loadRound(round.id, transaction); + return serializePendingRound(roundRecord); +} + +async function createLeaderboardEntryIfNeeded(sessionRecord, currentUser, transaction) { + const existingEntry = await db.leaderboard_entries.findOne({ + where: { + sessionId: sessionRecord.id, + board_type: 'all_time', + }, + transaction, + }); + + if (existingEntry) { + return existingEntry; + } + + const metrics = await getSessionMetrics(sessionRecord.id, transaction); + + return db.leaderboard_entries.create( + { + board_type: 'all_time', + seed_key: 'all_time', + score: metrics.correctGuesses, + rounds_survived: metrics.roundsPlayed, + best_streak: sessionRecord.best_streak, + achieved_at: sessionRecord.ended_at || new Date(), + sessionId: sessionRecord.id, + userId: currentUser.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); +} + +function isRoundExpired(roundRecord) { + const presentedAt = new Date(roundRecord.presented_at).getTime(); + return Date.now() > presentedAt + normalizeNumber(roundRecord.time_limit_ms); +} + +module.exports = class GameplayService { + static async startSession(payload, currentUser) { + const modeSelection = payload?.modeSelection || 'mixed'; + const timerProfile = payload?.timerProfile || 'normal'; + + if (!MODE_SELECTIONS[modeSelection]) { + throw gameplayError('Mode selection must be beatmap_only, mapper_only, artist_only, or mixed.'); + } + + if (!TIMER_PROFILES[timerProfile]) { + throw gameplayError('Timer profile must be normal or insane.'); + } + + const transaction = await db.sequelize.transaction(); + + try { + const now = new Date(); + + await db.game_sessions.update( + { + status: 'abandoned', + ended_at: now, + updatedById: currentUser.id, + }, + { + where: { + userId: currentUser.id, + status: 'active', + }, + transaction, + }, + ); + + const sessionRecord = await db.game_sessions.create( + { + session_token: generateSessionToken(), + status: 'active', + starting_lives: 2, + lives_remaining: 2, + streak: 0, + best_streak: 0, + timer_profile: timerProfile, + mode_selection: modeSelection, + started_at: now, + userId: currentUser.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const currentRound = await createRoundForSession( + sessionRecord, + currentUser, + transaction, + ); + + const metrics = await getSessionMetrics(sessionRecord.id, transaction); + + await transaction.commit(); + + return { + session: serializeSession(sessionRecord, metrics), + currentRound, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async answerRound(sessionId, roundId, payload, currentUser) { + const submittedChoice = payload?.choice; + + if (!['a', 'b', 'none'].includes(submittedChoice)) { + throw gameplayError('Choice must be a, b, or none.'); + } + + const transaction = await db.sequelize.transaction(); + + try { + const sessionRecord = await db.game_sessions.findOne({ + where: { + id: sessionId, + userId: currentUser.id, + }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + + if (!sessionRecord) { + throw gameplayError('Game session not found.', 404); + } + + if (sessionRecord.status !== 'active') { + throw gameplayError('This session is no longer active.'); + } + + const roundRecord = await db.game_rounds.findOne({ + where: { + id: roundId, + sessionId, + }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + + if (!roundRecord) { + throw gameplayError('Round not found.', 404); + } + + if (roundRecord.state !== 'pending') { + throw gameplayError('This round has already been resolved.'); + } + + const answeredAt = new Date(); + const timedOut = + submittedChoice === 'none' || + answeredAt.getTime() > + new Date(roundRecord.presented_at).getTime() + roundRecord.time_limit_ms; + const playerChoice = timedOut ? 'none' : submittedChoice; + const isCorrect = !timedOut && playerChoice === roundRecord.correct_choice; + const nextLivesRemaining = isCorrect + ? sessionRecord.lives_remaining + : Math.max(sessionRecord.lives_remaining - 1, 0); + const streak = isCorrect ? sessionRecord.streak + 1 : 0; + const bestStreak = Math.max(sessionRecord.best_streak || 0, streak); + const nextStatus = nextLivesRemaining > 0 ? 'active' : 'finished'; + + await roundRecord.update( + { + player_choice: playerChoice, + answered_at: answeredAt, + is_correct: isCorrect, + lose_reason: timedOut ? 'timeout' : isCorrect ? 'none' : 'wrong', + state: timedOut ? 'timed_out' : 'answered', + updatedById: currentUser.id, + }, + { transaction }, + ); + + await sessionRecord.update( + { + lives_remaining: nextLivesRemaining, + streak, + best_streak: bestStreak, + status: nextStatus, + ended_at: nextStatus === 'finished' ? answeredAt : sessionRecord.ended_at, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const resolvedRound = await loadRound(roundRecord.id, transaction); + let nextRound = null; + + if (nextStatus === 'active') { + nextRound = await createRoundForSession(sessionRecord, currentUser, transaction); + } else { + await createLeaderboardEntryIfNeeded(sessionRecord, currentUser, transaction); + } + + const metrics = await getSessionMetrics(sessionRecord.id, transaction); + + await transaction.commit(); + + return { + session: serializeSession(sessionRecord, metrics), + result: serializeResolvedRound(resolvedRound), + nextRound, + gameOver: nextStatus !== 'active', + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async getActiveSession(currentUser) { + const sessionRecord = await db.game_sessions.findOne({ + where: { + userId: currentUser.id, + status: 'active', + }, + order: [['started_at', 'DESC']], + }); + + if (!sessionRecord) { + return { + activeSession: false, + }; + } + + const pendingRound = await getPendingRound(sessionRecord.id); + + if (!pendingRound) { + const transaction = await db.sequelize.transaction(); + + try { + const lockedSession = await db.game_sessions.findOne({ + where: { id: sessionRecord.id }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + const nextRound = await createRoundForSession( + lockedSession, + currentUser, + transaction, + ); + const metrics = await getSessionMetrics(sessionRecord.id, transaction); + await transaction.commit(); + + return { + activeSession: true, + session: serializeSession(lockedSession, metrics), + currentRound: nextRound, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + if (isRoundExpired(pendingRound)) { + const resolvedSession = await this.answerRound( + sessionRecord.id, + pendingRound.id, + { choice: 'none' }, + currentUser, + ); + + return { + activeSession: true, + autoResolved: true, + ...resolvedSession, + }; + } + + const metrics = await getSessionMetrics(sessionRecord.id); + + return { + activeSession: true, + session: serializeSession(sessionRecord, metrics), + currentRound: serializePendingRound(pendingRound), + }; + } + + static async listSessions(currentUser) { + const sessions = await db.game_sessions.findAll({ + where: { + userId: currentUser.id, + }, + order: [['started_at', 'DESC']], + limit: 12, + }); + + const sessionIds = sessions.map((session) => session.id); + + const [rounds, entries, leaderboard] = await Promise.all([ + sessionIds.length + ? db.game_rounds.findAll({ + where: { + sessionId: { + [Op.in]: sessionIds, + }, + }, + attributes: ['sessionId', 'id', 'is_correct', 'state'], + }) + : [], + sessionIds.length + ? db.leaderboard_entries.findAll({ + where: { + sessionId: { + [Op.in]: sessionIds, + }, + board_type: 'all_time', + }, + }) + : [], + db.leaderboard_entries.findAll({ + where: { + board_type: 'all_time', + }, + include: [ + { + model: db.users, + as: 'user', + attributes: ['id', 'firstName', 'email'], + }, + ], + order: [ + ['score', 'DESC'], + ['best_streak', 'DESC'], + ['achieved_at', 'ASC'], + ], + limit: 10, + }), + ]); + + const metricsBySession = rounds.reduce((accumulator, round) => { + if (!accumulator[round.sessionId]) { + accumulator[round.sessionId] = { + roundsPlayed: 0, + correctGuesses: 0, + }; + } + + if (round.state !== 'pending') { + accumulator[round.sessionId].roundsPlayed += 1; + } + + if (round.is_correct) { + accumulator[round.sessionId].correctGuesses += 1; + } + + return accumulator; + }, {}); + + const entryBySession = entries.reduce((accumulator, entry) => { + accumulator[entry.sessionId] = entry; + return accumulator; + }, {}); + + return { + sessions: sessions.map((session) => ({ + ...serializeSession(session, metricsBySession[session.id]), + leaderboardEntry: entryBySession[session.id] + ? { + score: entryBySession[session.id].score, + bestStreak: entryBySession[session.id].best_streak, + } + : null, + })), + leaderboard: leaderboard.map((entry) => ({ + id: entry.id, + score: entry.score, + bestStreak: entry.best_streak, + roundsSurvived: entry.rounds_survived, + achievedAt: entry.achieved_at, + user: { + id: entry.user?.id, + name: entry.user?.firstName || entry.user?.email || 'Unknown player', + }, + })), + }; + } + + static async getSessionDetail(sessionId, currentUser) { + const sessionRecord = await db.game_sessions.findOne({ + where: { + id: sessionId, + userId: currentUser.id, + }, + }); + + if (!sessionRecord) { + throw gameplayError('Game session not found.', 404); + } + + const [metrics, rounds, leaderboardEntry] = await Promise.all([ + getSessionMetrics(sessionId), + db.game_rounds.findAll({ + where: { + sessionId, + state: { + [Op.not]: 'pending', + }, + }, + order: [['round_number', 'ASC']], + }), + db.leaderboard_entries.findOne({ + where: { + sessionId, + board_type: 'all_time', + }, + }), + ]); + + return { + session: { + ...serializeSession(sessionRecord, metrics), + leaderboardEntry: leaderboardEntry + ? { + score: leaderboardEntry.score, + bestStreak: leaderboardEntry.best_streak, + roundsSurvived: leaderboardEntry.rounds_survived, + achievedAt: leaderboardEntry.achieved_at, + } + : null, + }, + rounds: await Promise.all( + rounds.map(async (roundRecord) => serializeResolvedRound(await hydrateRound(roundRecord))), + ), + }; + } +}; diff --git a/backend/src/services/osuCatalogSync.js b/backend/src/services/osuCatalogSync.js new file mode 100644 index 0000000..3ad09f6 --- /dev/null +++ b/backend/src/services/osuCatalogSync.js @@ -0,0 +1,773 @@ +const axios = require('axios'); + +const db = require('../db/models'); + +const { Op } = db.Sequelize; + +const OSU_PUBLIC_BEATMAPSET_SEARCH_URL = 'https://osu.ppy.sh/beatmapsets/search'; +const SUPPORTED_SET_STATUSES = new Set(['ranked', 'loved']); +const DEFAULT_SEED_PAGES = 12; +const DEFAULT_INCREMENTAL_PAGES = 3; +const MAX_PAGES_PER_JOB = 40; +const DEFAULT_START_PAGE = 1; +const REQUEST_TIMEOUT_MS = 30000; +const IMPORT_JOB_TYPES = ['osu_sync_seed', 'osu_sync_incremental']; + +function syncError(message, statusCode = 400) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} + +function normalizeInteger(value, fallback = 0) { + const normalized = Number(value); + return Number.isFinite(normalized) ? Math.trunc(normalized) : fallback; +} + +function normalizeDecimal(value, fallback = 0) { + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : fallback; +} + +function normalizeString(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +} + +function normalizeSetStatus(status) { + const normalized = normalizeString(status).toLowerCase(); + return SUPPORTED_SET_STATUSES.has(normalized) ? normalized : null; +} + +function getMapperAvatarUrl(osuUserNumeric) { + if (!osuUserNumeric) { + return null; + } + + return `https://a.ppy.sh/${osuUserNumeric}`; +} + +function getMapperProfileUrl(osuUserNumeric) { + if (!osuUserNumeric) { + return null; + } + + return `https://osu.ppy.sh/users/${osuUserNumeric}`; +} + +function getBestCoverUrl(beatmapSet) { + return ( + beatmapSet?.covers?.card || + beatmapSet?.covers?.['card@2x'] || + beatmapSet?.covers?.cover || + beatmapSet?.covers?.['cover@2x'] || + beatmapSet?.covers?.list || + beatmapSet?.covers?.['list@2x'] || + null + ); +} + +function parseDateOrNull(value) { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function parseJobPayload(rawPayload) { + if (!rawPayload) { + return {}; + } + + if (typeof rawPayload === 'object' && !Array.isArray(rawPayload)) { + return rawPayload; + } + + if (typeof rawPayload !== 'string') { + throw syncError('job_payload_json must be empty or valid JSON.'); + } + + try { + const parsed = JSON.parse(rawPayload); + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw syncError('job_payload_json must decode to a JSON object.'); + } + + return parsed; + } catch (error) { + if (error.statusCode) { + throw error; + } + + throw syncError('job_payload_json must contain valid JSON.'); + } +} + +function getDefaultPageCount(jobType) { + return jobType === 'osu_sync_incremental' + ? DEFAULT_INCREMENTAL_PAGES + : DEFAULT_SEED_PAGES; +} + +function getNormalizedPageCount(payload, jobType) { + return Math.min( + Math.max(normalizeInteger(payload?.pages, getDefaultPageCount(jobType)), 1), + MAX_PAGES_PER_JOB, + ); +} + +function getNormalizedSort(payload) { + return normalizeString(payload?.sort || 'plays_desc') || 'plays_desc'; +} + +function hasExplicitStartPage(payload) { + const rawStartPage = payload?.startPage ?? payload?.page; + + if (rawStartPage === null || rawStartPage === undefined) { + return false; + } + + return `${rawStartPage}`.trim() !== ''; +} + +function getNormalizedStartPage(payload) { + return Math.max( + normalizeInteger(payload?.startPage ?? payload?.page, DEFAULT_START_PAGE), + DEFAULT_START_PAGE, + ); +} + +async function getNextAutomaticStartPage({ currentJobId, sort }) { + const previousSuccessfulJobs = await db.sync_jobs.findAll({ + attributes: ['id', 'job_type', 'job_payload_json'], + where: { + id: { + [Op.ne]: currentJobId, + }, + job_type: { + [Op.in]: IMPORT_JOB_TYPES, + }, + status: 'succeeded', + }, + order: [['createdAt', 'ASC']], + }); + + return previousSuccessfulJobs.reduce((nextStartPage, previousJob) => { + let previousPayload; + + try { + previousPayload = parseJobPayload(previousJob.job_payload_json); + } catch (error) { + return nextStartPage; + } + + if (getNormalizedSort(previousPayload) !== sort) { + return nextStartPage; + } + + const previousStartPage = hasExplicitStartPage(previousPayload) + ? getNormalizedStartPage(previousPayload) + : DEFAULT_START_PAGE; + const previousPages = getNormalizedPageCount(previousPayload, previousJob.job_type); + const previousNextPage = previousStartPage + previousPages; + + return previousNextPage > nextStartPage ? previousNextPage : nextStartPage; + }, DEFAULT_START_PAGE); +} + +async function getImportOptions(jobRecord) { + const payload = parseJobPayload(jobRecord?.job_payload_json); + const pages = getNormalizedPageCount(payload, jobRecord?.job_type); + const sort = getNormalizedSort(payload); + const explicitStartPage = hasExplicitStartPage(payload); + const startPage = explicitStartPage + ? getNormalizedStartPage(payload) + : await getNextAutomaticStartPage({ + currentJobId: jobRecord?.id, + sort, + }); + + return { + payload, + pages, + sort, + startPage, + explicitStartPage, + }; +} + +function shouldKeepBeatmapSet(beatmapSet) { + if (!beatmapSet || !normalizeInteger(beatmapSet.id, 0)) { + return false; + } + + if (!normalizeSetStatus(beatmapSet.status)) { + return false; + } + + if (!normalizeString(beatmapSet.title) || !normalizeString(beatmapSet.artist)) { + return false; + } + + return true; +} + +function getUsableBeatmaps(beatmapSet) { + const setStatus = normalizeSetStatus(beatmapSet?.status); + + return (Array.isArray(beatmapSet?.beatmaps) ? beatmapSet.beatmaps : []).filter( + (beatmap) => { + if (!beatmap || !normalizeInteger(beatmap.id, 0)) { + return false; + } + + const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus; + + return ( + beatmap.mode === 'osu' && + Boolean(beatmap.is_scoreable) && + Boolean(beatmapStatus) + ); + }, + ); +} + +async function updateJob(jobId, currentUser, patch) { + await db.sync_jobs.update( + { + ...patch, + updatedById: currentUser?.id || null, + }, + { + where: { id: jobId }, + }, + ); +} + +function toRecordMap(records, getKey) { + return records.reduce((accumulator, record) => { + const key = getKey(record); + + if (key !== null && key !== undefined && key !== '') { + accumulator.set(key, record); + } + + return accumulator; + }, new Map()); +} + +async function fetchBeatmapSearchPage({ page, sort }) { + let response; + + try { + response = await axios.get(OSU_PUBLIC_BEATMAPSET_SEARCH_URL, { + params: { + m: 0, + nsfw: false, + sort, + page, + }, + headers: { + 'User-Agent': 'Flatlogic Osu Higher Lower Catalog Sync', + Accept: 'application/json', + }, + timeout: REQUEST_TIMEOUT_MS, + }); + } catch (error) { + const remoteMessage = error?.response?.data?.error; + + throw syncError( + remoteMessage || 'Unable to download beatmap catalog data from osu! right now.', + error?.response?.status || 502, + ); + } + + const beatmapSets = Array.isArray(response?.data?.beatmapsets) + ? response.data.beatmapsets + : []; + + return beatmapSets.filter(shouldKeepBeatmapSet); +} + +async function upsertBeatmapSetsPage({ beatmapSets, currentUser }) { + const transaction = await db.sequelize.transaction(); + const now = new Date(); + const counts = { + processed: 0, + inserted: 0, + updated: 0, + skipped: 0, + }; + + try { + const osuBeatmapSetNumerics = beatmapSets + .map((beatmapSet) => normalizeInteger(beatmapSet.id, 0)) + .filter(Boolean); + const creatorUserNumerics = beatmapSets + .map((beatmapSet) => normalizeInteger(beatmapSet.user_id, 0)) + .filter(Boolean); + const artistNames = beatmapSets + .map((beatmapSet) => normalizeString(beatmapSet.artist)) + .filter(Boolean); + const osuBeatmapNumerics = beatmapSets.flatMap((beatmapSet) => + getUsableBeatmaps(beatmapSet) + .map((beatmap) => normalizeInteger(beatmap.id, 0)) + .filter(Boolean), + ); + + const [existingSets, existingMappers, existingArtists, existingBeatmaps] = + await Promise.all([ + db.beatmap_sets.findAll({ + where: { + osu_beatmapset_numeric: { + [Op.in]: osuBeatmapSetNumerics, + }, + }, + transaction, + }), + creatorUserNumerics.length + ? db.mappers.findAll({ + where: { + osu_user_numeric: { + [Op.in]: creatorUserNumerics, + }, + }, + transaction, + }) + : [], + artistNames.length + ? db.artists.findAll({ + where: { + name: { + [Op.in]: artistNames, + }, + }, + transaction, + }) + : [], + osuBeatmapNumerics.length + ? db.beatmaps.findAll({ + where: { + osu_beatmap_numeric: { + [Op.in]: osuBeatmapNumerics, + }, + }, + transaction, + }) + : [], + ]); + + const beatmapSetMap = toRecordMap( + existingSets, + (record) => normalizeInteger(record.osu_beatmapset_numeric, 0), + ); + const mapperMap = toRecordMap( + existingMappers, + (record) => normalizeInteger(record.osu_user_numeric, 0), + ); + const artistMap = toRecordMap(existingArtists, (record) => normalizeString(record.name)); + const beatmapMap = toRecordMap( + existingBeatmaps, + (record) => normalizeInteger(record.osu_beatmap_numeric, 0), + ); + + for (const beatmapSet of beatmapSets) { + const osuBeatmapSetNumeric = normalizeInteger(beatmapSet.id, 0); + const creatorUserNumeric = normalizeInteger(beatmapSet.user_id, 0); + const artistName = normalizeString(beatmapSet.artist); + const setStatus = normalizeSetStatus(beatmapSet.status); + const usableBeatmaps = getUsableBeatmaps(beatmapSet); + const coverUrl = getBestCoverUrl(beatmapSet); + + if ( + !osuBeatmapSetNumeric || + !creatorUserNumeric || + !artistName || + !setStatus || + !usableBeatmaps.length + ) { + counts.skipped += 1; + continue; + } + + counts.processed += 1; + + const beatmapSetPayload = { + osu_beatmapset_numeric: osuBeatmapSetNumeric, + title: normalizeString(beatmapSet.title) || 'Unknown beatmap set', + artist_name: artistName, + creator_username: normalizeString(beatmapSet.creator) || 'Unknown mapper', + creator_user_numeric: creatorUserNumeric, + status: setStatus, + total_playcount: normalizeInteger(beatmapSet.play_count, 0), + background_image_url: coverUrl, + cover_image_url: coverUrl, + ranked_at: setStatus === 'ranked' ? parseDateOrNull(beatmapSet.ranked_date) : null, + loved_at: setStatus === 'loved' ? parseDateOrNull(beatmapSet.ranked_date) : null, + last_synced_at: now, + updatedById: currentUser.id, + }; + + let beatmapSetRecord = beatmapSetMap.get(osuBeatmapSetNumeric); + + if (beatmapSetRecord) { + await beatmapSetRecord.update(beatmapSetPayload, { transaction }); + counts.updated += 1; + } else { + beatmapSetRecord = await db.beatmap_sets.create( + { + ...beatmapSetPayload, + createdById: currentUser.id, + }, + { transaction }, + ); + beatmapSetMap.set(osuBeatmapSetNumeric, beatmapSetRecord); + counts.inserted += 1; + } + + const mapperPayload = { + osu_user_numeric: creatorUserNumeric, + osu_username: normalizeString(beatmapSet.creator) || 'Unknown mapper', + avatar_url: getMapperAvatarUrl(creatorUserNumeric), + profile_url: getMapperProfileUrl(creatorUserNumeric), + banner_url: coverUrl, + status: 'active', + last_synced_at: now, + updatedById: currentUser.id, + }; + + let mapperRecord = mapperMap.get(creatorUserNumeric); + + if (mapperRecord) { + await mapperRecord.update(mapperPayload, { transaction }); + counts.updated += 1; + } else { + mapperRecord = await db.mappers.create( + { + ...mapperPayload, + createdById: currentUser.id, + }, + { transaction }, + ); + mapperMap.set(creatorUserNumeric, mapperRecord); + counts.inserted += 1; + } + + const artistPayload = { + name: artistName, + image_source: coverUrl ? 'beatmap_background_fallback' : 'none', + image_url: coverUrl, + external_url: null, + status: 'active', + last_synced_at: now, + updatedById: currentUser.id, + }; + + let artistRecord = artistMap.get(artistName); + + if (artistRecord) { + await artistRecord.update(artistPayload, { transaction }); + counts.updated += 1; + } else { + artistRecord = await db.artists.create( + { + ...artistPayload, + createdById: currentUser.id, + }, + { transaction }, + ); + artistMap.set(artistName, artistRecord); + counts.inserted += 1; + } + + for (const beatmap of usableBeatmaps) { + const osuBeatmapNumeric = normalizeInteger(beatmap.id, 0); + const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus; + + if (!osuBeatmapNumeric || !beatmapStatus) { + counts.skipped += 1; + continue; + } + + const beatmapPayload = { + osu_beatmap_numeric: osuBeatmapNumeric, + difficulty_name: normalizeString(beatmap.version) || 'Unknown difficulty', + mode: 'osu', + status: beatmapStatus, + playcount: normalizeInteger(beatmap.playcount, 0), + passcount: normalizeInteger(beatmap.passcount, 0), + star_rating: normalizeDecimal(beatmap.difficulty_rating, 0), + bpm: normalizeDecimal(beatmap.bpm || beatmapSet.bpm, 0), + length_seconds: normalizeInteger( + beatmap.total_length || beatmap.hit_length, + 0, + ), + cs: normalizeDecimal(beatmap.cs, 0), + ar: normalizeDecimal(beatmap.ar, 0), + od: normalizeDecimal(beatmap.accuracy, 0), + hp: normalizeDecimal(beatmap.drain, 0), + last_synced_at: now, + beatmap_setId: beatmapSetRecord.id, + mapperId: mapperRecord.id, + artistId: artistRecord.id, + updatedById: currentUser.id, + }; + + let beatmapRecord = beatmapMap.get(osuBeatmapNumeric); + + if (beatmapRecord) { + await beatmapRecord.update(beatmapPayload, { transaction }); + counts.updated += 1; + } else { + beatmapRecord = await db.beatmaps.create( + { + ...beatmapPayload, + createdById: currentUser.id, + }, + { transaction }, + ); + beatmapMap.set(osuBeatmapNumeric, beatmapRecord); + counts.inserted += 1; + } + } + } + + await transaction.commit(); + return counts; + } catch (error) { + await transaction.rollback(); + throw error; + } +} + +async function recomputeEntityStats({ + entity = 'mapper', + currentUser, +}) { + const foreignKey = entity === 'artist' ? 'artistId' : 'mapperId'; + const StatsModel = entity === 'artist' ? db.artist_stats : db.mapper_stats; + const transaction = await db.sequelize.transaction(); + const now = new Date(); + + try { + const aggregates = await db.beatmaps.findAll({ + attributes: [ + foreignKey, + [db.sequelize.fn('SUM', db.sequelize.col('playcount')), 'total_plays'], + [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'ranked_loved_maps_count'], + ], + where: { + [foreignKey]: { + [Op.ne]: null, + }, + mode: 'osu', + status: { + [Op.in]: ['ranked', 'loved'], + }, + }, + group: [foreignKey], + raw: true, + transaction, + }); + + const entityIds = aggregates + .map((row) => row[foreignKey]) + .filter(Boolean); + + const existingStats = entityIds.length + ? await StatsModel.findAll({ + where: { + [foreignKey]: { + [Op.in]: entityIds, + }, + }, + order: [['createdAt', 'ASC'], ['id', 'ASC']], + transaction, + }) + : []; + + const existingStatsMap = existingStats.reduce((accumulator, statRecord) => { + const relatedId = statRecord[foreignKey]; + + if (!accumulator.has(relatedId)) { + accumulator.set(relatedId, []); + } + + accumulator.get(relatedId).push(statRecord); + return accumulator; + }, new Map()); + + let inserted = 0; + let updated = 0; + let removedDuplicates = 0; + + for (const aggregate of aggregates) { + const relatedId = aggregate[foreignKey]; + const relatedStats = existingStatsMap.get(relatedId) || []; + const [primaryRecord, ...duplicateRecords] = relatedStats; + const payload = { + total_plays: normalizeInteger(aggregate.total_plays, 0), + ranked_loved_maps_count: normalizeInteger( + aggregate.ranked_loved_maps_count, + 0, + ), + computed_at: now, + updatedById: currentUser.id, + }; + + if (primaryRecord) { + await primaryRecord.update(payload, { transaction }); + updated += 1; + } else { + await StatsModel.create( + { + ...payload, + [foreignKey]: relatedId, + createdById: currentUser.id, + }, + { transaction }, + ); + inserted += 1; + } + + for (const duplicateRecord of duplicateRecords) { + await duplicateRecord.destroy({ transaction }); + removedDuplicates += 1; + } + } + + await transaction.commit(); + + return { + inserted, + updated, + removedDuplicates, + processed: aggregates.length, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } +} + +module.exports = class OsuCatalogSyncService { + static isRunnableJobType(jobType) { + return [ + 'osu_sync_seed', + 'osu_sync_incremental', + 'aggregate_mapper_totals', + 'aggregate_artist_totals', + ].includes(jobType); + } + + static async run(jobRecord, currentUser) { + if (!jobRecord?.id) { + throw syncError('Sync job record is required.'); + } + + if (jobRecord.job_type === 'aggregate_mapper_totals') { + const mapperTotals = await recomputeEntityStats({ + entity: 'mapper', + currentUser, + }); + + return { + processedCount: mapperTotals.processed, + insertedCount: mapperTotals.inserted, + updatedCount: mapperTotals.updated, + skippedCount: 0, + summary: `Recomputed mapper totals for ${mapperTotals.processed} mappers.`, + }; + } + + if (jobRecord.job_type === 'aggregate_artist_totals') { + const artistTotals = await recomputeEntityStats({ + entity: 'artist', + currentUser, + }); + + return { + processedCount: artistTotals.processed, + insertedCount: artistTotals.inserted, + updatedCount: artistTotals.updated, + skippedCount: 0, + summary: `Recomputed artist totals for ${artistTotals.processed} artists.`, + }; + } + + const importOptions = await getImportOptions(jobRecord); + const normalizedPayload = { + ...importOptions.payload, + pages: importOptions.pages, + sort: importOptions.sort, + startPage: importOptions.startPage, + }; + + delete normalizedPayload.page; + + await updateJob(jobRecord.id, currentUser, { + job_payload_json: JSON.stringify(normalizedPayload), + }); + + const totals = { + processedCount: 0, + insertedCount: 0, + updatedCount: 0, + skippedCount: 0, + errorCount: 0, + }; + + for (let pageOffset = 0; pageOffset < importOptions.pages; pageOffset += 1) { + const page = importOptions.startPage + pageOffset; + const beatmapSets = await fetchBeatmapSearchPage({ + page, + sort: importOptions.sort, + }); + const pageCounts = await upsertBeatmapSetsPage({ + beatmapSets, + currentUser, + }); + + totals.processedCount += pageCounts.processed; + totals.insertedCount += pageCounts.inserted; + totals.updatedCount += pageCounts.updated; + totals.skippedCount += pageCounts.skipped; + + await updateJob(jobRecord.id, currentUser, { + processed_count: totals.processedCount, + inserted_count: totals.insertedCount, + updated_count: totals.updatedCount, + skipped_count: totals.skippedCount, + error_count: totals.errorCount, + }); + } + + const [mapperTotals, artistTotals] = await Promise.all([ + recomputeEntityStats({ entity: 'mapper', currentUser }), + recomputeEntityStats({ entity: 'artist', currentUser }), + ]); + + totals.insertedCount += mapperTotals.inserted + artistTotals.inserted; + totals.updatedCount += mapperTotals.updated + artistTotals.updated; + + const endPage = importOptions.startPage + importOptions.pages - 1; + const pageRangeLabel = + importOptions.pages === 1 + ? `page ${importOptions.startPage}` + : `pages ${importOptions.startPage}-${endPage}`; + const nextSyncHint = importOptions.explicitStartPage + ? '' + : ` Next automatic sync will continue from page ${endPage + 1}.`; + + return { + ...totals, + summary: `Imported ${totals.processedCount} beatmap sets across ${pageRangeLabel} (${importOptions.sort}) and refreshed mapper/artist totals.${nextSyncHint}`, + }; + } +}; diff --git a/backend/src/services/sync_jobs.js b/backend/src/services/sync_jobs.js index 0b04fef..eb62ab5 100644 --- a/backend/src/services/sync_jobs.js +++ b/backend/src/services/sync_jobs.js @@ -1,36 +1,107 @@ const db = require('../db/models'); const Sync_jobsDBApi = require('../db/api/sync_jobs'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); +const OsuCatalogSyncService = require('./osuCatalogSync'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +async function createStoredJob(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const syncJob = await Sync_jobsDBApi.create( + data, + { + currentUser, + transaction, + }, + ); + await transaction.commit(); + return syncJob; + } catch (error) { + await transaction.rollback(); + throw error; + } +} +async function markJobFailed(jobId, currentUser, error) { + try { + await db.sync_jobs.update( + { + status: 'failed', + finished_at: new Date(), + error_count: 1, + error_summary: error.message, + updatedById: currentUser?.id || null, + }, + { + where: { id: jobId }, + }, + ); + } catch (updateError) { + console.error('Failed to update sync job failure state:', updateError); + } +} module.exports = class Sync_jobsService { static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); + const shouldRunJob = + OsuCatalogSyncService.isRunnableJobType(data?.job_type) && + (!data?.status || ['queued', 'running'].includes(data.status)); + + const initialPayload = shouldRunJob + ? { + ...data, + status: 'running', + started_at: new Date(), + finished_at: null, + processed_count: 0, + inserted_count: 0, + updated_count: 0, + skipped_count: 0, + error_count: 0, + error_summary: null, + triggered_by: currentUser?.id || null, + } + : data; + + const syncJob = await createStoredJob(initialPayload, currentUser); + + if (!shouldRunJob) { + return syncJob; + } + try { - await Sync_jobsDBApi.create( - data, + const result = await OsuCatalogSyncService.run(syncJob, currentUser); + + await db.sync_jobs.update( { - currentUser, - transaction, + status: 'succeeded', + finished_at: new Date(), + processed_count: result.processedCount, + inserted_count: result.insertedCount, + updated_count: result.updatedCount, + skipped_count: result.skippedCount, + error_count: result.errorCount || 0, + error_summary: result.summary, + updatedById: currentUser.id, + }, + { + where: { id: syncJob.id }, }, ); - await transaction.commit(); + return db.sync_jobs.findByPk(syncJob.id); } catch (error) { - await transaction.rollback(); + console.error('Sync job failed:', error); + await markJobFailed(syncJob.id, currentUser, error); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,24 +109,24 @@ module.exports = class Sync_jobsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { + .on('data', (row) => results.push(row)) + .on('end', () => { console.log('CSV results', results); resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Sync_jobsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -67,16 +138,15 @@ module.exports = class Sync_jobsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); + try { - let sync_jobs = await Sync_jobsDBApi.findBy( - {id}, - {transaction}, + const sync_jobs = await Sync_jobsDBApi.findBy( + { id }, + { transaction }, ); if (!sync_jobs) { - throw new ValidationError( - 'sync_jobsNotFound', - ); + throw new ValidationError('sync_jobsNotFound'); } const updatedSync_jobs = await Sync_jobsDBApi.update( @@ -90,12 +160,11 @@ module.exports = class Sync_jobsService { await transaction.commit(); return updatedSync_jobs; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -131,8 +200,4 @@ module.exports = class Sync_jobsService { throw error; } } - - }; - - 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/components/OsuHigherLower/AnimatedCount.tsx b/frontend/src/components/OsuHigherLower/AnimatedCount.tsx new file mode 100644 index 0000000..8298a6a --- /dev/null +++ b/frontend/src/components/OsuHigherLower/AnimatedCount.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +type Props = { + value: number; + duration?: number; +}; + +export default function AnimatedCount({ value, duration = 700 }: Props) { + const [displayValue, setDisplayValue] = React.useState(0); + + React.useEffect(() => { + let frameId = 0; + const startTime = performance.now(); + + const tick = (currentTime: number) => { + const progress = Math.min((currentTime - startTime) / duration, 1); + const easedProgress = 1 - Math.pow(1 - progress, 3); + setDisplayValue(Math.round(value * easedProgress)); + + if (progress < 1) { + frameId = requestAnimationFrame(tick); + } + }; + + setDisplayValue(0); + frameId = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(frameId); + }, [duration, value]); + + return <>{new Intl.NumberFormat().format(displayValue)}; +} diff --git a/frontend/src/components/OsuHigherLower/GuessCard.tsx b/frontend/src/components/OsuHigherLower/GuessCard.tsx new file mode 100644 index 0000000..65056b8 --- /dev/null +++ b/frontend/src/components/OsuHigherLower/GuessCard.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import AnimatedCount from './AnimatedCount'; +import { + GameplayRoundCard, + isArtistCard, + isBeatmapCard, + isMapperCard, +} from './types'; + +type Props = { + slot: 'a' | 'b'; + card: GameplayRoundCard; + onPick?: (slot: 'a' | 'b') => void; + disabled?: boolean; + selected?: boolean; + compact?: boolean; + reveal?: { + show: boolean; + value: number; + valueSuffix: string; + isWinner: boolean; + isWrongSelection: boolean; + }; +}; + +const getBackgroundImage = (card: GameplayRoundCard) => { + if (isBeatmapCard(card)) { + return card.backgroundImageUrl; + } + + if (isMapperCard(card)) { + return card.bannerUrl || card.fallbackVisual; + } + + if (isArtistCard(card)) { + return card.imageUrl || card.fallbackVisual; + } + + return null; +}; + +const getStatusClasses = ({ + selected, + reveal, +}: Pick) => { + if (reveal?.show && reveal.isWinner) { + return 'border-emerald-500/80 ring-2 ring-emerald-500/60'; + } + + if (reveal?.show && reveal.isWrongSelection) { + return 'border-red-500/80 ring-2 ring-red-500/60'; + } + + if (selected) { + return 'border-[#ff66aa]/80 ring-2 ring-[#ff66aa]/50'; + } + + return 'border-white/10 hover:border-white/30'; +}; + +const AvatarFallback = ({ label }: { label: string }) => ( +
+ {label.charAt(0).toUpperCase()} +
+); + +const AvatarImage = ({ src, alt }: { src?: string | null; alt: string }) => { + if (!src) { + return ; + } + + return ( +
+ ); +}; + +export default function GuessCard({ + slot, + card, + onPick, + disabled = false, + selected = false, + compact = false, + reveal, +}: Props) { + const backgroundImage = getBackgroundImage(card); + const wrapperClasses = [ + 'group relative w-full overflow-hidden rounded-3xl border bg-[#131722] text-left text-white transition duration-150 ease-out', + compact ? 'min-h-[280px]' : 'min-h-[360px] md:min-h-[520px]', + disabled || !onPick ? 'cursor-default' : 'cursor-pointer', + getStatusClasses({ selected, reveal }), + ].join(' '); + + const body = ( + <> +
+
+
+ {slot} +
+ +
+
+ + {isBeatmapCard(card) && ( +
+
+ + {card.status} + + + {card.difficultyName} + +
+
+

+ {card.title} +

+

+ {card.artistName} +

+
+
+ +
+

+ Mapped by +

+

{card.mapperName}

+
+
+
+ )} + + {isMapperCard(card) && ( +
+ +
+

+ Featured mapper +

+

+ {card.name} +

+
+
+ )} + + {isArtistCard(card) && ( +
+
+

+ Featured artist +

+

+ {card.name} +

+
+
+ )} + +
+ {reveal?.show ? ( +
+

+ Revealed +

+

+ +

+

{reveal.valueSuffix}

+
+ ) : ( +
+ Pick the card you think hides the higher value. +
+ )} +
+
+ + ); + + if (!onPick) { + return
{body}
; + } + + return ( + + ); +} diff --git a/frontend/src/components/OsuHigherLower/types.ts b/frontend/src/components/OsuHigherLower/types.ts new file mode 100644 index 0000000..f4326f2 --- /dev/null +++ b/frontend/src/components/OsuHigherLower/types.ts @@ -0,0 +1,111 @@ +export type ModeSelection = + | 'beatmap_only' + | 'mapper_only' + | 'artist_only' + | 'mixed'; + +export type TimerProfile = 'normal' | 'insane'; + +export type GameplayBeatmapCard = { + title: string; + artistName: string; + mapperName: string; + mapperAvatarUrl?: string | null; + difficultyName: string; + status: string; + backgroundImageUrl?: string | null; +}; + +export type GameplayMapperCard = { + name: string; + avatarUrl?: string | null; + bannerUrl?: string | null; + fallbackVisual?: string | null; +}; + +export type GameplayArtistCard = { + name: string; + imageUrl?: string | null; + fallbackVisual?: string | null; +}; + +export type GameplayRoundCard = + | GameplayBeatmapCard + | GameplayMapperCard + | GameplayArtistCard; + +export type GameplayReveal = { + roundMode: 'beatmap' | 'mapper' | 'artist'; + modeLabel: string; + valueSuffix: string; + correctChoice: 'a' | 'b'; + winningChoice: 'a' | 'b'; + playerChoice: 'a' | 'b' | 'none'; + isCorrect: boolean; + loseReason: 'wrong' | 'timeout' | 'none'; + values: { + a: number; + b: number; + }; + answeredAt?: string; +}; + +export type GameplayRound = { + id: string; + roundNumber: number; + timeLimitMs: number; + presentedAt: string; + expiresAt: string; + cards: { + a: GameplayRoundCard; + b: GameplayRoundCard; + }; + reveal?: GameplayReveal; +}; + +export type GameplaySession = { + id: string; + status: 'active' | 'finished' | 'abandoned'; + modeSelection: ModeSelection; + timerProfile: TimerProfile; + sessionToken: string; + startingLives: number; + livesRemaining: number; + streak: number; + bestStreak: number; + startedAt: string; + endedAt?: string | null; + roundsPlayed: number; + correctGuesses: number; +}; + +export type HistorySession = GameplaySession & { + leaderboardEntry?: { + score: number; + bestStreak: number; + } | null; +}; + +export type GameplayLeaderboardEntry = { + id: string; + score: number; + bestStreak: number; + roundsSurvived: number; + achievedAt: string; + user: { + id?: string; + name: string; + }; +}; + +export const isBeatmapCard = ( + card: GameplayRoundCard, +): card is GameplayBeatmapCard => 'title' in card; + +export const isMapperCard = ( + card: GameplayRoundCard, +): card is GameplayMapperCard => 'bannerUrl' in card; + +export const isArtistCard = ( + card: GameplayRoundCard, +): card is GameplayArtistCard => !isBeatmapCard(card) && !isMapperCard(card); 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 49b1c1a..e35824c 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -2,6 +2,18 @@ import * as icon from '@mdi/js'; import { MenuAsideItem } from './interfaces' const menuAside: MenuAsideItem[] = [ + { + href: '/play', + icon: 'mdiGamepadSquareOutline' in icon ? icon['mdiGamepadSquareOutline' as keyof typeof icon] : icon.mdiTable, + label: 'Play', + permissions: 'READ_GAME_SESSIONS' + }, + { + href: '/play/history', + icon: icon.mdiHistory ?? icon.mdiTable, + label: 'Run history', + permissions: 'READ_GAME_SESSIONS' + }, { href: '/dashboard', icon: icon.mdiViewDashboardOutline, diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index f467fae..8bacf29 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,264 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiDatabaseOutline, + mdiGamepadSquareOutline, + mdiPlayCircleOutline, + mdiShieldCheckOutline, + mdiTimerOutline, + mdiViewDashboardOutline, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; 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'; +import LayoutGuest from '../layouts/Guest'; +const heroCards = [ + { + slot: 'A', + title: 'Blue Zenith', + subtitle: 'xi · mapped by Monstrata', + image: + 'https://assets.ppy.sh/beatmaps/874923/covers/raw.jpg', + }, + { + slot: 'B', + title: 'Kyouran Hey Kids!!', + subtitle: 'THE ORAL CIGARETTES · mapped by Sotarks', + image: + 'https://assets.ppy.sh/beatmaps/102120/covers/raw.jpg', + }, +]; + +const featureCards = [ + { + icon: mdiShieldCheckOutline, + title: 'Server-hidden answers', + body: 'Playcounts, mapper totals, artist totals, and the mixed-mode selection stay hidden until the answer is locked in.', + }, + { + icon: mdiTimerOutline, + title: 'Five to eight second rounds', + body: 'Each guess resolves immediately. A timeout costs one life and the next card pair rolls in without loading screens.', + }, + { + icon: mdiDatabaseOutline, + title: 'Ranked + loved only', + body: 'The pool is scoped to osu!standard with precomputed mapper and artist aggregates for fast constant-time round evaluation.', + }, +]; + +const modeCards = [ + { + label: 'Beatmap', + helper: 'Compare raw beatmap playcount.', + }, + { + label: 'Mapper', + helper: 'Show mapper avatar + banner only, then reveal total mapper plays.', + }, + { + label: 'Artist', + helper: 'Show artist image + name only, then reveal total artist plays.', + }, + { + label: 'Mixed', + helper: 'The server chooses the round type each time and reveals it only after the guess.', + }, +]; 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('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Osu Higher Lower' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - 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 +

+

Fast browser guessing game

+
+ - - +
+ + +
+
+ +
+
+
+

+ osu!standard · ranked + loved +

+

+ Guess the higher hidden osu! stat and survive the run. +

+

+ Two cards. Two lives. Five to eight seconds. Beatmap playcount, mapper + total plays, artist total plays — all decided server-side for a fast, + minimal, anti-cheat-first competitive loop. +

+ +
+ + +
+ +
+
+

2

+

lives per run

+
+
+

8s / 5s

+

normal and insane timers

+
+
+

O(1)

+

gameplay queries from precomputed totals

+
+
+
+ +
+ {heroCards.map((card) => ( +
+
+
+
+
+ {card.slot} +
+
+

+ {card.title} +

+

+ {card.subtitle} +

+
+ Hidden values stay off the client until the guess is submitted. +
+
+
+
+ ))} +
+
+ +
+ {featureCards.map((feature) => ( + +
+ + + +
+

{feature.title}

+

{feature.body}

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

+ Round formats +

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

{mode.label}

+

{mode.helper}

+
+ ))} +
+
+ + +

+ Included admin shell +

+

+ Manage beatmaps, aggregates, sync jobs, sessions, and leaderboards from the built-in admin side. +

+

+ The public page keeps the login link intact and gives you a direct path to the admin interface for data ops and QA. +

+
+ + +
+
+
+
+ +
+

© 2026 Osu Higher Lower. Clean rounds, hidden answers, no extra fluff.

+
+ Privacy Policy + Login + Admin interface +
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/play/history.tsx b/frontend/src/pages/play/history.tsx new file mode 100644 index 0000000..aac6e67 --- /dev/null +++ b/frontend/src/pages/play/history.tsx @@ -0,0 +1,298 @@ +import { + mdiChartBoxOutline, + mdiGamepadSquareOutline, + mdiHistory, + mdiPlayCircleOutline, + mdiShieldCheckOutline, + mdiTimerOutline, +} from '@mdi/js'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement } from 'react'; +import axios from 'axios'; +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import { + GameplayLeaderboardEntry, + HistorySession, +} from '../../components/OsuHigherLower/types'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import LayoutAuthenticated from '../../layouts/Authenticated'; + +const formatModeSelection = (modeSelection: HistorySession['modeSelection']) => { + if (modeSelection === 'beatmap_only') { + return 'Beatmap'; + } + + if (modeSelection === 'mapper_only') { + return 'Mapper'; + } + + if (modeSelection === 'artist_only') { + return 'Artist'; + } + + return 'Mixed'; +}; + +const formatSessionDate = (date?: string | null) => { + if (!date) { + return 'Still running'; + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(date)); +}; + +const statusTone: Record = { + active: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100', + finished: 'border-white/10 bg-white/5 text-white/70', + abandoned: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-100', +}; + +export default function PlayHistoryPage() { + const [sessions, setSessions] = React.useState([]); + const [leaderboard, setLeaderboard] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [errorMessage, setErrorMessage] = React.useState(''); + + React.useEffect(() => { + let isMounted = true; + + const loadHistory = async () => { + try { + setIsLoading(true); + setErrorMessage(''); + const { data } = await axios.get('/gameplay/sessions'); + + if (!isMounted) { + return; + } + + setSessions(Array.isArray(data?.sessions) ? data.sessions : []); + setLeaderboard(Array.isArray(data?.leaderboard) ? data.leaderboard : []); + } catch (error: any) { + if (!isMounted) { + return; + } + + setErrorMessage(error?.response?.data || 'Could not load your run history.'); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void loadHistory(); + + return () => { + isMounted = false; + }; + }, []); + + return ( + <> + + {getPageTitle('Run history')} + + + + +
+ +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ +
+
+

+ Your recent sessions +

+

+ Review finished runs or resume the active one. +

+
+
+ +
+ {isLoading ? ( +
+ Loading your sessions... +
+ ) : null} + + {!isLoading && !sessions.length ? ( +
+ You have not started a run yet. Head back to the arena and launch your + first session. +
+ ) : null} + + {!isLoading + ? sessions.map((session) => ( + +
+
+
+ + {session.status === 'active' ? 'In progress' : session.status} + + + {formatModeSelection(session.modeSelection)} + + + {session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'} + +
+ +
+

+ {session.correctGuesses} correct guesses +

+

+ {formatSessionDate(session.endedAt || session.startedAt)} +

+
+
+ +
+
+
+ +

+ Best streak +

+
+

+ {session.bestStreak} +

+
+ +
+
+ +

+ Rounds played +

+
+

+ {session.roundsPlayed} +

+
+ +
+
+ +

+ Score +

+
+

+ {session.leaderboardEntry?.score ?? session.correctGuesses} +

+
+
+
+ + )) + : null} +
+
+ +
+ +

+ Top all-time scores +

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

#{index + 1}

+

+ {entry.user.name} +

+

+ {formatSessionDate(entry.achievedAt)} +

+
+
+

{entry.score}

+

+ correct +

+
+
+
+ )) + ) : ( +
+ No leaderboard entries yet. +
+ )} +
+
+ + +

+ Need another run? +

+

+ Start a fresh session from the play screen. Active runs are preserved on the + server until you finish or replace them. +

+
+ +
+
+
+
+
+ + ); +} + +PlayHistoryPage.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..853b08b --- /dev/null +++ b/frontend/src/pages/play/index.tsx @@ -0,0 +1,768 @@ +import { + mdiChartBoxOutline, + mdiFlash, + mdiGamepadSquareOutline, + mdiHeart, + mdiHeartOutline, + mdiHistory, + mdiRefresh, + mdiShieldCheckOutline, + mdiTimerOutline, +} from '@mdi/js'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement } from 'react'; +import axios from 'axios'; +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import GuessCard from '../../components/OsuHigherLower/GuessCard'; +import { + GameplayLeaderboardEntry, + GameplayRound, + GameplaySession, + HistorySession, + ModeSelection, + TimerProfile, +} from '../../components/OsuHigherLower/types'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import LayoutAuthenticated from '../../layouts/Authenticated'; + +const ADVANCE_DELAY_MS = 1050; + +const modeOptions: Array<{ + value: ModeSelection; + label: string; + helper: string; +}> = [ + { + value: 'mixed', + label: 'Mixed', + helper: 'Server shuffles beatmap, mapper, and artist rounds. The label stays hidden until reveal.', + }, + { + value: 'beatmap_only', + label: 'Beatmap', + helper: 'Classic higher-lower using ranked or loved osu!standard beatmap playcount.', + }, + { + value: 'mapper_only', + label: 'Mapper', + helper: 'Compare total plays accumulated across each mapper’s ranked and loved maps.', + }, + { + value: 'artist_only', + label: 'Artist', + helper: 'Compare total plays accumulated across each artist’s ranked and loved maps.', + }, +]; + +const timerOptions: Array<{ + value: TimerProfile; + label: string; + helper: string; + icon: string; +}> = [ + { + value: 'normal', + label: 'Normal · 8s', + helper: 'A quick pace that still gives you time to read each card carefully.', + icon: mdiTimerOutline, + }, + { + value: 'insane', + label: 'Insane · 5s', + helper: 'Very fast rounds with no breathing room between reveals.', + icon: mdiFlash, + }, +]; + +const formatModeSelection = (modeSelection: GameplaySession['modeSelection']) => { + if (modeSelection === 'beatmap_only') { + return 'Beatmap'; + } + + if (modeSelection === 'mapper_only') { + return 'Mapper'; + } + + if (modeSelection === 'artist_only') { + return 'Artist'; + } + + return 'Mixed'; +}; + +const formatSessionDate = (date?: string | null) => { + if (!date) { + return 'Still running'; + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(date)); +}; + +const getResultMessage = (round?: GameplayRound | null) => { + if (!round?.reveal) { + return 'Mode hidden until you answer.'; + } + + if (round.reveal.loseReason === 'timeout') { + return `${round.reveal.modeLabel} revealed — time ran out.`; + } + + return round.reveal.isCorrect + ? `${round.reveal.modeLabel} revealed — correct guess.` + : `${round.reveal.modeLabel} revealed — wrong guess.`; +}; + +export default function PlayPage() { + const [modeSelection, setModeSelection] = React.useState('mixed'); + const [timerProfile, setTimerProfile] = React.useState('normal'); + const [session, setSession] = React.useState(null); + const [currentRound, setCurrentRound] = React.useState(null); + const [revealedRound, setRevealedRound] = React.useState(null); + const [history, setHistory] = React.useState([]); + const [leaderboard, setLeaderboard] = React.useState([]); + const [isLoadingState, setIsLoadingState] = React.useState(true); + const [isStarting, setIsStarting] = React.useState(false); + const [isSubmittingChoice, setIsSubmittingChoice] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + const [remainingMs, setRemainingMs] = React.useState(0); + const advanceTimerRef = React.useRef(null); + const timeoutTriggeredRef = React.useRef(false); + + const loadHistory = React.useCallback(async () => { + const { data } = await axios.get('/gameplay/sessions'); + setHistory(Array.isArray(data?.sessions) ? data.sessions : []); + setLeaderboard(Array.isArray(data?.leaderboard) ? data.leaderboard : []); + }, []); + + const queueNextRound = React.useCallback( + (nextRound: GameplayRound | null, nextSession: GameplaySession) => { + if (advanceTimerRef.current) { + window.clearTimeout(advanceTimerRef.current); + } + + if (!nextRound) { + void loadHistory(); + return; + } + + advanceTimerRef.current = window.setTimeout(() => { + setSession(nextSession); + setRevealedRound(null); + setCurrentRound(nextRound); + setRemainingMs(nextRound.timeLimitMs); + timeoutTriggeredRef.current = false; + }, ADVANCE_DELAY_MS); + }, + [loadHistory], + ); + + const applyResolution = React.useCallback( + (payload: { + session: GameplaySession; + result: GameplayRound; + nextRound: GameplayRound | null; + }) => { + setSession(payload.session); + setCurrentRound(null); + setRevealedRound(payload.result); + queueNextRound(payload.nextRound, payload.session); + }, + [queueNextRound], + ); + + const loadActiveSession = React.useCallback(async () => { + const { data } = await axios.get('/gameplay/active'); + + if (!data?.activeSession) { + setSession(null); + setCurrentRound(null); + setRevealedRound(null); + return; + } + + if (data.currentRound) { + setSession(data.session); + setCurrentRound(data.currentRound); + setRevealedRound(null); + setRemainingMs(data.currentRound.timeLimitMs); + timeoutTriggeredRef.current = false; + return; + } + + if (data.result) { + applyResolution({ + session: data.session, + result: data.result, + nextRound: data.nextRound ?? null, + }); + } + }, [applyResolution]); + + React.useEffect(() => { + let isMounted = true; + + const loadPage = async () => { + try { + setIsLoadingState(true); + setErrorMessage(''); + await Promise.all([loadHistory(), loadActiveSession()]); + } catch (error: any) { + if (!isMounted) { + return; + } + + setErrorMessage( + error?.response?.data || 'Unable to load the game state right now.', + ); + } finally { + if (isMounted) { + setIsLoadingState(false); + } + } + }; + + void loadPage(); + + return () => { + isMounted = false; + if (advanceTimerRef.current) { + window.clearTimeout(advanceTimerRef.current); + } + }; + }, [loadActiveSession, loadHistory]); + + const submitChoice = React.useCallback( + async (choice: 'a' | 'b' | 'none') => { + if (!session || !currentRound || isSubmittingChoice) { + return; + } + + try { + setIsSubmittingChoice(true); + setErrorMessage(''); + timeoutTriggeredRef.current = choice === 'none'; + + const { data } = await axios.post( + `/gameplay/sessions/${session.id}/rounds/${currentRound.id}/answer`, + { choice }, + ); + + applyResolution({ + session: data.session, + result: data.result, + nextRound: data.nextRound ?? null, + }); + } catch (error: any) { + setErrorMessage( + error?.response?.data || 'Could not submit your guess. Please try again.', + ); + await loadActiveSession(); + } finally { + setIsSubmittingChoice(false); + } + }, + [applyResolution, currentRound, isSubmittingChoice, loadActiveSession, session], + ); + + React.useEffect(() => { + if (!currentRound) { + return undefined; + } + + const expiresAt = new Date(currentRound.expiresAt).getTime(); + + const tick = () => { + const nextRemainingMs = Math.max(expiresAt - Date.now(), 0); + setRemainingMs(nextRemainingMs); + + if (nextRemainingMs === 0 && !timeoutTriggeredRef.current) { + timeoutTriggeredRef.current = true; + void submitChoice('none'); + } + }; + + tick(); + const timerId = window.setInterval(tick, 100); + + return () => window.clearInterval(timerId); + }, [currentRound, submitChoice]); + + const handleStartSession = async () => { + try { + setIsStarting(true); + setErrorMessage(''); + setRevealedRound(null); + setCurrentRound(null); + + const { data } = await axios.post('/gameplay/sessions/start', { + modeSelection, + timerProfile, + }); + + setSession(data.session); + setCurrentRound(data.currentRound); + setRemainingMs(data.currentRound?.timeLimitMs || 0); + timeoutTriggeredRef.current = false; + await loadHistory(); + } catch (error: any) { + setErrorMessage( + error?.response?.data || 'Could not start a new session right now.', + ); + } finally { + setIsStarting(false); + } + }; + + const activeRound = currentRound || revealedRound; + const timerProgress = React.useMemo(() => { + if (!currentRound) { + return 0; + } + + return Math.max( + 0, + Math.min((remainingMs / currentRound.timeLimitMs) * 100, 100), + ); + }, [currentRound, remainingMs]); + + const lives = session?.startingLives ?? 2; + const lobbyVisible = !session || (!currentRound && !revealedRound); + const gameOver = session?.status === 'finished' && Boolean(revealedRound); + + return ( + <> + + {getPageTitle('Play')} + + + + +
+ +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isLoadingState ? ( + +
+

+ Loading arena +

+

+ Restoring your current run and recent leaderboard. +

+
+
+ ) : null} + + {!isLoadingState && session ? ( +
+ +
+
+ {formatModeSelection(session.modeSelection)} +
+
+ {session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'} +
+
+ Round {activeRound?.roundNumber || session.roundsPlayed + 1} +
+
+ +
+
+
+ +
+

+ Lives +

+
+ {Array.from({ length: lives }).map((_, index) => ( + + ))} +
+
+
+
+ +
+
+ +
+

+ Current streak +

+

+ {session.streak} +

+
+
+
+ +
+
+ +
+

+ Best streak +

+

+ {session.bestStreak} +

+
+
+
+
+
+ + +
+
+
+

+ Round timer +

+

+ {currentRound + ? `${Math.max(remainingMs / 1000, 0).toFixed(1)}s left` + : 'Resolving round'} +

+
+ +
+ +
+
45 + ? 'bg-emerald-400' + : timerProgress > 15 + ? 'bg-yellow-400' + : 'bg-red-400' + }`} + style={{ width: `${timerProgress}%` }} + /> +
+ +

{getResultMessage(revealedRound)}

+
+ +
+ ) : null} + + {!isLoadingState && lobbyVisible ? ( +
+ +
+

+ Quick start +

+

+ Minimal, fast, and anti-cheat. +

+

+ Start a run with two lives, guess the higher hidden stat, and let the + server reveal beatmap playcount, mapper totals, or artist totals only + after you answer. +

+
+ +
+
+

+ Choose your mode +

+
+ {modeOptions.map((option) => { + const isActive = option.value === modeSelection; + + return ( + + ); + })} +
+
+ +
+

+ Choose your timer +

+
+ {timerOptions.map((option) => { + const isActive = option.value === timerProfile; + + return ( + + ); + })} +
+
+
+ +
+ + +
+
+ +
+ +
+
+

+ All-time ladder +

+

+ Top current streaks +

+
+
+ +
+ {leaderboard.length ? ( + leaderboard.slice(0, 5).map((entry, index) => ( +
+
+

#{index + 1}

+

+ {entry.user.name} +

+
+
+

{entry.score}

+

+ correct guesses +

+
+
+ )) + ) : ( +
+ Finish your first run to create a leaderboard entry. +
+ )} +
+
+ + +

+ Recent runs +

+
+ {history.length ? ( + history.slice(0, 3).map((item) => ( + +
+
+

+ {formatModeSelection(item.modeSelection)} · {item.correctGuesses} correct +

+

+ {formatSessionDate(item.endedAt || item.startedAt)} +

+
+

+ Best {item.bestStreak} +

+
+ + )) + ) : ( +
+ No runs yet. Start a session to build your history. +
+ )} +
+
+
+
+ ) : null} + + {!isLoadingState && activeRound ? ( +
+
+ {currentRound ? ( + + Hidden values stay server-side until you answer. Pick A or B before the + timer expires. + + ) : ( + {getResultMessage(revealedRound)} + )} +
+ +
+ + +
+
+ ) : null} + + {!isLoadingState && gameOver && session ? ( + +
+
+

+ Run complete +

+

+ {session.correctGuesses} correct guesses with a best streak of {session.bestStreak}. +

+

+ Review every reveal in the run detail page, then spin up another instant + session when you are ready. +

+
+
+ + +
+
+
+ ) : null} + + + ); +} + +PlayPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/play/sessions/[sessionId].tsx b/frontend/src/pages/play/sessions/[sessionId].tsx new file mode 100644 index 0000000..442a8b2 --- /dev/null +++ b/frontend/src/pages/play/sessions/[sessionId].tsx @@ -0,0 +1,294 @@ +import { + mdiChartBoxOutline, + mdiCloseCircleOutline, + mdiGamepadSquareOutline, + mdiHistory, + mdiShieldCheckOutline, + mdiTimerOutline, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import axios from 'axios'; +import { useRouter } from 'next/router'; +import BaseButton from '../../../components/BaseButton'; +import BaseIcon from '../../../components/BaseIcon'; +import CardBox from '../../../components/CardBox'; +import GuessCard from '../../../components/OsuHigherLower/GuessCard'; +import { GameplayRound, GameplaySession } from '../../../components/OsuHigherLower/types'; +import SectionMain from '../../../components/SectionMain'; +import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../../config'; +import LayoutAuthenticated from '../../../layouts/Authenticated'; + +const formatModeSelection = (modeSelection: GameplaySession['modeSelection']) => { + if (modeSelection === 'beatmap_only') { + return 'Beatmap'; + } + + if (modeSelection === 'mapper_only') { + return 'Mapper'; + } + + if (modeSelection === 'artist_only') { + return 'Artist'; + } + + return 'Mixed'; +}; + +const formatSessionDate = (date?: string | null) => { + if (!date) { + return 'Still running'; + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(date)); +}; + +const resultTone = (round: GameplayRound) => { + if (round.reveal?.loseReason === 'timeout') { + return 'border-yellow-500/30 bg-yellow-500/10 text-yellow-100'; + } + + return round.reveal?.isCorrect + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100' + : 'border-red-500/30 bg-red-500/10 text-red-100'; +}; + +export default function SessionDetailPage() { + const router = useRouter(); + const { sessionId } = router.query; + const [session, setSession] = React.useState< + (GameplaySession & { + leaderboardEntry?: { + score: number; + bestStreak: number; + roundsSurvived: number; + achievedAt: string; + } | null; + }) | null + >(null); + const [rounds, setRounds] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [errorMessage, setErrorMessage] = React.useState(''); + + React.useEffect(() => { + if (!sessionId || typeof sessionId !== 'string') { + return; + } + + let isMounted = true; + + const loadDetail = async () => { + try { + setIsLoading(true); + setErrorMessage(''); + const { data } = await axios.get(`/gameplay/sessions/${sessionId}`); + + if (!isMounted) { + return; + } + + setSession(data.session); + setRounds(Array.isArray(data.rounds) ? data.rounds : []); + } catch (error: any) { + if (!isMounted) { + return; + } + + setErrorMessage(error?.response?.data || 'Could not load this session.'); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void loadDetail(); + + return () => { + isMounted = false; + }; + }, [sessionId]); + + return ( + <> + + {getPageTitle('Run detail')} + + + + +
+ + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isLoading ? ( + +
+ Loading session detail... +
+
+ ) : null} + + {!isLoading && session ? ( +
+ +
+
+

+ {formatModeSelection(session.modeSelection)} ·{' '} + {session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'} +

+

+ {session.correctGuesses} correct guesses across {session.roundsPlayed} rounds. +

+

+ Started {formatSessionDate(session.startedAt)} · Ended{' '} + {formatSessionDate(session.endedAt)} +

+
+ +
+
+
+ +

+ Best streak +

+
+

+ {session.bestStreak} +

+
+
+
+ +

+ Score +

+
+

+ {session.leaderboardEntry?.score ?? session.correctGuesses} +

+
+
+
+ +

+ Status +

+
+

+ {session.status} +

+
+
+
+
+ + {!rounds.length ? ( + +
+ No rounds were recorded for this session. +
+
+ ) : null} + + {rounds.map((round) => ( + +
+
+
+ + Round {round.roundNumber} + + + {round.reveal?.modeLabel} + + + {round.reveal?.loseReason === 'timeout' + ? 'Timed out' + : round.reveal?.isCorrect + ? 'Correct' + : 'Wrong'} + +
+

+ {round.reveal?.loseReason === 'timeout' + ? 'The timer expired before an answer reached the server.' + : `You chose ${String(round.reveal?.playerChoice).toUpperCase()} and the correct answer was ${String(round.reveal?.correctChoice).toUpperCase()}.`} +

+
+
+ Answered {formatSessionDate(round.reveal?.answeredAt || round.presentedAt)} +
+
+ +
+ + +
+
+ ))} +
+ ) : null} + + {!isLoading && !session && !errorMessage ? ( + +
+ This session could not be found. +
+
+ ) : null} +
+ + ); +} + +SessionDetailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/sync_jobs/sync_jobs-list.tsx b/frontend/src/pages/sync_jobs/sync_jobs-list.tsx index 799bd18..d673a1e 100644 --- a/frontend/src/pages/sync_jobs/sync_jobs-list.tsx +++ b/frontend/src/pages/sync_jobs/sync_jobs-list.tsx @@ -9,83 +9,93 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut import { getPageTitle } from '../../config' import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs' import BaseButton from '../../components/BaseButton' -import axios from "axios"; -import Link from "next/link"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import CardBoxModal from "../../components/CardBoxModal"; -import DragDropFilePicker from "../../components/DragDropFilePicker"; -import {setRefetch, uploadCsv} from '../../stores/sync_jobs/sync_jobsSlice'; - - -import {hasPermission} from "../../helpers/userPermissions"; - - +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + create as createSyncJob, + setRefetch, + uploadCsv, +} from '../../stores/sync_jobs/sync_jobsSlice'; +import { hasPermission } from '../../helpers/userPermissions'; const Sync_jobsTablesPage = () => { const [filterItems, setFilterItems] = useState([]); const [csvFile, setCsvFile] = useState(null); const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); + const [isCatalogSyncing, setIsCatalogSyncing] = useState(false); - const { currentUser } = useAppSelector((state) => state.auth); - - const dispatch = useAppDispatch(); - const [filters] = useState([{label: 'ErrorSummary', title: 'error_summary'},{label: 'JobPayloadJson', title: 'job_payload_json'}, {label: 'ProcessedCount', title: 'processed_count', number: 'true'},{label: 'InsertedCount', title: 'inserted_count', number: 'true'},{label: 'UpdatedCount', title: 'updated_count', number: 'true'},{label: 'SkippedCount', title: 'skipped_count', number: 'true'},{label: 'ErrorCount', title: 'error_count', number: 'true'}, - + {label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'}, - - + + {label: 'TriggeredBy', title: 'triggered_by'}, - - - + + {label: 'JobType', title: 'job_type', type: 'enum', options: ['osu_sync_seed','osu_sync_incremental','osu_refresh_mappers','osu_refresh_artists','aggregate_mapper_totals','aggregate_artist_totals','cache_warmup']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','running','succeeded','failed','canceled']}, ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS'); - - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS'); - const getSync_jobsCSV = async () => { - const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'}); - const type = response.headers['content-type'] - const blob = new Blob([response.data], { type: type }) - const link = document.createElement('a') - link.href = window.URL.createObjectURL(blob) - link.download = 'sync_jobsCSV.csv' - link.click() + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; - const onModalConfirm = async () => { - if (!csvFile) return; - await dispatch(uploadCsv(csvFile)); - dispatch(setRefetch(true)); - setCsvFile(null); - setIsModalActive(false); - }; + const getSync_jobsCSV = async () => { + const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'}); + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type: type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'sync_jobsCSV.csv' + link.click() + }; - const onModalCancel = () => { - setCsvFile(null); - setIsModalActive(false); - }; + const runCatalogSync = async () => { + try { + setIsCatalogSyncing(true); + await dispatch( + createSyncJob({ + job_type: 'osu_sync_seed', + status: 'queued', + job_payload_json: JSON.stringify({ pages: 12, sort: 'plays_desc' }), + }), + ).unwrap(); + dispatch(setRefetch(true)); + } finally { + setIsCatalogSyncing(false); + } + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; return ( <> @@ -94,12 +104,21 @@ const Sync_jobsTablesPage = () => { - {''} + {''} - - - {hasCreatePermission && } - + + {hasCreatePermission && } + + {hasCreatePermission && ( + + )} + { onClick={addFilter} /> - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} +
-
- -
- Switch to Table -
- +
+ +
+ Switch to Table +
- - - + + {hasCreatePermission && ( + +

+ Use Sync / continue osu catalog before testing the game. Each run upserts into the existing catalog, refreshes mapper and artist totals, and if you leave startPage out it automatically continues from the next untouched page so your local pool keeps growing. +

+
+ )} + +
{ Sync_jobsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( {page} diff --git a/frontend/src/pages/sync_jobs/sync_jobs-new.tsx b/frontend/src/pages/sync_jobs/sync_jobs-new.tsx index 46a6b38..b1a7769 100644 --- a/frontend/src/pages/sync_jobs/sync_jobs-new.tsx +++ b/frontend/src/pages/sync_jobs/sync_jobs-new.tsx @@ -193,7 +193,7 @@ const initialValues = { - job_payload_json: '', + job_payload_json: '{"pages":12,"sort":"plays_desc"}',