Autosave: 20260506-160338
This commit is contained in:
parent
94972c3028
commit
47a2f113a1
BIN
assets/pasted-20260506-160112-7c934626.png
Normal file
BIN
assets/pasted-20260506-160112-7c934626.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -54,6 +53,7 @@ const leaderboard_entriesRoutes = require('./routes/leaderboard_entries');
|
|||||||
const daily_seedsRoutes = require('./routes/daily_seeds');
|
const daily_seedsRoutes = require('./routes/daily_seeds');
|
||||||
|
|
||||||
const server_cachesRoutes = require('./routes/server_caches');
|
const server_cachesRoutes = require('./routes/server_caches');
|
||||||
|
const gameplayRoutes = require('./routes/gameplay');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/daily_seeds', passport.authenticate('jwt', {session: false}), daily_seedsRoutes);
|
||||||
|
|
||||||
app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
|
app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
|
||||||
|
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
|
|||||||
58
backend/src/routes/gameplay.js
Normal file
58
backend/src/routes/gameplay.js
Normal file
@ -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;
|
||||||
1108
backend/src/services/gameplay.js
Normal file
1108
backend/src/services/gameplay.js
Normal file
File diff suppressed because it is too large
Load Diff
773
backend/src/services/osuCatalogSync.js
Normal file
773
backend/src/services/osuCatalogSync.js
Normal file
@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,36 +1,107 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Sync_jobsDBApi = require('../db/api/sync_jobs');
|
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 ValidationError = require('./notifications/errors/validation');
|
||||||
|
const OsuCatalogSyncService = require('./osuCatalogSync');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
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 {
|
module.exports = class Sync_jobsService {
|
||||||
static async create(data, currentUser) {
|
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 {
|
try {
|
||||||
await Sync_jobsDBApi.create(
|
const result = await OsuCatalogSyncService.run(syncJob, currentUser);
|
||||||
data,
|
|
||||||
|
await db.sync_jobs.update(
|
||||||
{
|
{
|
||||||
currentUser,
|
status: 'succeeded',
|
||||||
transaction,
|
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) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
console.error('Sync job failed:', error);
|
||||||
|
await markJobFailed(syncJob.id, currentUser, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -38,24 +109,24 @@ module.exports = class Sync_jobsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
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) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (row) => results.push(row))
|
||||||
.on('end', async () => {
|
.on('end', () => {
|
||||||
console.log('CSV results', results);
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
});
|
||||||
|
|
||||||
await Sync_jobsDBApi.bulkImport(results, {
|
await Sync_jobsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -67,16 +138,15 @@ module.exports = class Sync_jobsService {
|
|||||||
|
|
||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let sync_jobs = await Sync_jobsDBApi.findBy(
|
const sync_jobs = await Sync_jobsDBApi.findBy(
|
||||||
{id},
|
{ id },
|
||||||
{transaction},
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sync_jobs) {
|
if (!sync_jobs) {
|
||||||
throw new ValidationError(
|
throw new ValidationError('sync_jobsNotFound');
|
||||||
'sync_jobsNotFound',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSync_jobs = await Sync_jobsDBApi.update(
|
const updatedSync_jobs = await Sync_jobsDBApi.update(
|
||||||
@ -90,12 +160,11 @@ module.exports = class Sync_jobsService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedSync_jobs;
|
return updatedSync_jobs;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -131,8 +200,4 @@ module.exports = class Sync_jobsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
32
frontend/src/components/OsuHigherLower/AnimatedCount.tsx
Normal file
32
frontend/src/components/OsuHigherLower/AnimatedCount.tsx
Normal file
@ -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)}</>;
|
||||||
|
}
|
||||||
212
frontend/src/components/OsuHigherLower/GuessCard.tsx
Normal file
212
frontend/src/components/OsuHigherLower/GuessCard.tsx
Normal file
@ -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<Props, 'selected' | 'reveal'>) => {
|
||||||
|
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 }) => (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-white/20 bg-white/10 text-lg font-semibold text-white shadow-sm shadow-black/30">
|
||||||
|
{label.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AvatarImage = ({ src, alt }: { src?: string | null; alt: string }) => {
|
||||||
|
if (!src) {
|
||||||
|
return <AvatarFallback label={alt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={alt}
|
||||||
|
className="h-16 w-16 rounded-full border border-white/30 bg-cover bg-center shadow-sm shadow-black/30"
|
||||||
|
role="img"
|
||||||
|
style={{ backgroundImage: `url(${src})` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={
|
||||||
|
backgroundImage
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${backgroundImage})`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/25 via-black/45 to-black/90" />
|
||||||
|
<div className="absolute left-4 top-4 z-10 rounded-full border border-white/20 bg-black/40 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-white/85 backdrop-blur-sm">
|
||||||
|
{slot}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-between p-5 md:p-6">
|
||||||
|
<div />
|
||||||
|
|
||||||
|
{isBeatmapCard(card) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full bg-white/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/75">
|
||||||
|
{card.status}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-black/30 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/65">
|
||||||
|
{card.difficultyName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="max-w-[18ch] text-2xl font-semibold leading-tight md:text-4xl">
|
||||||
|
{card.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-white/78 md:text-base">
|
||||||
|
{card.artistName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3 backdrop-blur-sm">
|
||||||
|
<AvatarImage alt={card.mapperName} src={card.mapperAvatarUrl} />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
||||||
|
Mapped by
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium text-white">{card.mapperName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMapperCard(card) && (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-end gap-5 pb-3 text-center md:justify-center md:pb-0">
|
||||||
|
<AvatarImage alt={card.name} src={card.avatarUrl} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
||||||
|
Featured mapper
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-semibold leading-tight md:text-5xl">
|
||||||
|
{card.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isArtistCard(card) && (
|
||||||
|
<div className="flex flex-1 items-end pb-3 md:items-center md:justify-center md:pb-0">
|
||||||
|
<div className="w-full rounded-[28px] border border-white/10 bg-black/25 p-5 text-center backdrop-blur-sm">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
||||||
|
Featured artist
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold leading-tight md:text-5xl">
|
||||||
|
{card.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
{reveal?.show ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-sm">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55">
|
||||||
|
Revealed
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-3xl font-semibold md:text-4xl">
|
||||||
|
<AnimatedCount value={reveal.value} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/70">{reveal.valueSuffix}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-white/78 backdrop-blur-sm">
|
||||||
|
Pick the card you think hides the higher value.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onPick) {
|
||||||
|
return <div className={wrapperClasses}>{body}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={`Choose ${slot.toUpperCase()}`}
|
||||||
|
className={wrapperClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onPick(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/components/OsuHigherLower/types.ts
Normal file
111
frontend/src/components/OsuHigherLower/types.ts
Normal file
@ -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);
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -2,6 +2,18 @@ import * as icon from '@mdi/js';
|
|||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
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',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
|
|||||||
@ -1,166 +1,264 @@
|
|||||||
|
import {
|
||||||
import React, { useEffect, useState } from 'react';
|
mdiDatabaseOutline,
|
||||||
import type { ReactElement } from 'react';
|
mdiGamepadSquareOutline,
|
||||||
|
mdiPlayCircleOutline,
|
||||||
|
mdiShieldCheckOutline,
|
||||||
|
mdiTimerOutline,
|
||||||
|
mdiViewDashboardOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import CardBox from '../components/CardBox';
|
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 { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
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() {
|
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) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Osu Higher Lower')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-[#0b0d12] text-white">
|
||||||
<div
|
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-6 lg:px-10">
|
||||||
className={`flex ${
|
<header className="flex items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<Link className="flex items-center gap-3" href="/">
|
||||||
} min-h-screen w-full`}
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
||||||
>
|
<BaseIcon className="text-[#ff66aa]" path={mdiGamepadSquareOutline} size={20} />
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
</span>
|
||||||
? imageBlock(illustrationImage)
|
<div>
|
||||||
: null}
|
<p className="text-xs uppercase tracking-[0.22em] text-white/45">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
Osu Higher Lower
|
||||||
? videoBlock(illustrationVideo)
|
</p>
|
||||||
: null}
|
<p className="text-sm text-white/70">Fast browser guessing game</p>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
</div>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
</Link>
|
||||||
<CardBoxComponentTitle title="Welcome to your Osu Higher Lower app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
<BaseButton color="whiteDark" href="/login" label="Login" />
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<BaseButton
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
color="whiteDark"
|
||||||
|
href="/dashboard"
|
||||||
|
icon={mdiViewDashboardOutline}
|
||||||
|
label="Admin interface"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<BaseButtons>
|
<main className="flex-1 py-10 lg:py-14">
|
||||||
<BaseButton
|
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
href='/login'
|
<div>
|
||||||
label='Login'
|
<p className="text-sm uppercase tracking-[0.28em] text-white/45">
|
||||||
color='info'
|
osu!standard · ranked + loved
|
||||||
className='w-full'
|
</p>
|
||||||
/>
|
<h1 className="mt-4 max-w-3xl text-5xl font-semibold tracking-tight text-white md:text-6xl">
|
||||||
|
Guess the higher hidden osu! stat and survive the run.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-2xl text-lg leading-8 text-white/68">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
</BaseButtons>
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
</CardBox>
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
href="/play"
|
||||||
|
icon={mdiPlayCircleOutline}
|
||||||
|
label="Open the arena"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="whiteDark"
|
||||||
|
href="/dashboard"
|
||||||
|
icon={mdiViewDashboardOutline}
|
||||||
|
label="Admin interface"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<p className="text-3xl font-semibold text-white">2</p>
|
||||||
|
<p className="mt-1 text-sm text-white/55">lives per run</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<p className="text-3xl font-semibold text-white">8s / 5s</p>
|
||||||
|
<p className="mt-1 text-sm text-white/55">normal and insane timers</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<p className="text-3xl font-semibold text-white">O(1)</p>
|
||||||
|
<p className="mt-1 text-sm text-white/55">gameplay queries from precomputed totals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{heroCards.map((card) => (
|
||||||
|
<div
|
||||||
|
className="relative min-h-[360px] overflow-hidden rounded-[32px] border border-white/10 bg-[#131722]"
|
||||||
|
key={card.slot}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${card.image})` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/25 via-black/40 to-black/90" />
|
||||||
|
<div className="relative flex h-full flex-col justify-between p-5">
|
||||||
|
<div className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-white/80">
|
||||||
|
{card.slot}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="max-w-[12ch] text-3xl font-semibold leading-tight text-white">
|
||||||
|
{card.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-white/72">
|
||||||
|
{card.subtitle}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-white/68 backdrop-blur-sm">
|
||||||
|
Hidden values stay off the client until the guess is submitted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-12 grid gap-4 lg:grid-cols-3">
|
||||||
|
{featureCards.map((feature) => (
|
||||||
|
<CardBox
|
||||||
|
className="border border-white/10 bg-[#10141d] text-white"
|
||||||
|
key={feature.title}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5">
|
||||||
|
<BaseIcon className="text-white/75" path={feature.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-white">{feature.title}</p>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-white/62">{feature.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-12 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Round formats
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
{modeCards.map((mode) => (
|
||||||
|
<div
|
||||||
|
className="rounded-[28px] border border-white/10 bg-white/5 px-4 py-4"
|
||||||
|
key={mode.label}
|
||||||
|
>
|
||||||
|
<p className="text-xl font-semibold text-white">{mode.label}</p>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-white/62">{mode.helper}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Included admin shell
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-2xl font-semibold text-white">
|
||||||
|
Manage beatmaps, aggregates, sync jobs, sessions, and leaderboards from the built-in admin side.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-white/62">
|
||||||
|
The public page keeps the login link intact and gives you a direct path to the admin interface for data ops and QA.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="whiteDark"
|
||||||
|
href="/login"
|
||||||
|
label="Login"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
href="/dashboard"
|
||||||
|
icon={mdiViewDashboardOutline}
|
||||||
|
label="Admin interface"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="flex flex-col gap-4 border-t border-white/10 pt-5 text-sm text-white/45 md:flex-row md:items-center md:justify-between">
|
||||||
|
<p>© 2026 Osu Higher Lower. Clean rounds, hidden answers, no extra fluff.</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<Link href="/privacy-policy">Privacy Policy</Link>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
<Link href="/dashboard">Admin interface</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
298
frontend/src/pages/play/history.tsx
Normal file
298
frontend/src/pages/play/history.tsx
Normal file
@ -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<HistorySession['status'], string> = {
|
||||||
|
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<HistorySession[]>([]);
|
||||||
|
const [leaderboard, setLeaderboard] = React.useState<GameplayLeaderboardEntry[]>([]);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Run history')}</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiHistory} main title="Run history">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
href="/play"
|
||||||
|
icon={mdiPlayCircleOutline}
|
||||||
|
label="Back to play"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="mb-6 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.65fr_1fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Your recent sessions
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">
|
||||||
|
Review finished runs or resume the active one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-8 text-center text-sm text-white/55">
|
||||||
|
Loading your sessions...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !sessions.length ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-10 text-center text-white/55">
|
||||||
|
You have not started a run yet. Head back to the arena and launch your
|
||||||
|
first session.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading
|
||||||
|
? sessions.map((session) => (
|
||||||
|
<Link
|
||||||
|
className="block rounded-[28px] border border-white/10 bg-white/5 px-5 py-5 transition hover:border-white/20"
|
||||||
|
href={
|
||||||
|
session.status === 'active'
|
||||||
|
? '/play'
|
||||||
|
: `/play/sessions/${session.id}`
|
||||||
|
}
|
||||||
|
key={session.id}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${statusTone[session.status]}`}
|
||||||
|
>
|
||||||
|
{session.status === 'active' ? 'In progress' : session.status}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
|
||||||
|
{formatModeSelection(session.modeSelection)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
|
||||||
|
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold text-white">
|
||||||
|
{session.correctGuesses} correct guesses
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-white/55">
|
||||||
|
{formatSessionDate(session.endedAt || session.startedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 lg:w-[390px]">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiShieldCheckOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Best streak
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{session.bestStreak}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiTimerOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Rounds played
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{session.roundsPlayed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiChartBoxOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Score
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{session.leaderboardEntry?.score ?? session.correctGuesses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Top all-time scores
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{leaderboard.length ? (
|
||||||
|
leaderboard.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4"
|
||||||
|
key={entry.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/55">#{index + 1}</p>
|
||||||
|
<p className="mt-1 text-base font-medium text-white">
|
||||||
|
{entry.user.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-white/45">
|
||||||
|
{formatSessionDate(entry.achievedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-semibold text-white">{entry.score}</p>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-white/45">
|
||||||
|
correct
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
||||||
|
No leaderboard entries yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Need another run?
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-base leading-7 text-white/65">
|
||||||
|
Start a fresh session from the play screen. Active runs are preserved on the
|
||||||
|
server until you finish or replace them.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
href="/play"
|
||||||
|
icon={mdiGamepadSquareOutline}
|
||||||
|
label="Open arena"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayHistoryPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
768
frontend/src/pages/play/index.tsx
Normal file
768
frontend/src/pages/play/index.tsx
Normal file
@ -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<ModeSelection>('mixed');
|
||||||
|
const [timerProfile, setTimerProfile] = React.useState<TimerProfile>('normal');
|
||||||
|
const [session, setSession] = React.useState<GameplaySession | null>(null);
|
||||||
|
const [currentRound, setCurrentRound] = React.useState<GameplayRound | null>(null);
|
||||||
|
const [revealedRound, setRevealedRound] = React.useState<GameplayRound | null>(null);
|
||||||
|
const [history, setHistory] = React.useState<HistorySession[]>([]);
|
||||||
|
const [leaderboard, setLeaderboard] = React.useState<GameplayLeaderboardEntry[]>([]);
|
||||||
|
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<number | null>(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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Play')}</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton
|
||||||
|
icon={mdiGamepadSquareOutline}
|
||||||
|
main
|
||||||
|
title="Play"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="whiteDark"
|
||||||
|
href="/play/history"
|
||||||
|
icon={mdiHistory}
|
||||||
|
label="Run history"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="mb-6 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoadingState ? (
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="space-y-2 py-10 text-center">
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
Loading arena
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-white/80">
|
||||||
|
Restoring your current run and recent leaderboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingState && session ? (
|
||||||
|
<div className="mb-6 grid gap-4 lg:grid-cols-[1.6fr_1fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
||||||
|
{formatModeSelection(session.modeSelection)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
||||||
|
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium uppercase tracking-[0.2em] text-white/60">
|
||||||
|
Round {activeRound?.roundNumber || session.roundsPlayed + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseIcon className="text-white/70" path={mdiHeart} size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Lives
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
{Array.from({ length: lives }).map((_, index) => (
|
||||||
|
<BaseIcon
|
||||||
|
key={`life-${index}`}
|
||||||
|
className={
|
||||||
|
index < (session.livesRemaining || 0)
|
||||||
|
? 'text-[#ff668f]'
|
||||||
|
: 'text-white/20'
|
||||||
|
}
|
||||||
|
path={
|
||||||
|
index < (session.livesRemaining || 0)
|
||||||
|
? mdiHeart
|
||||||
|
: mdiHeartOutline
|
||||||
|
}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseIcon className="text-white/70" path={mdiShieldCheckOutline} size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Current streak
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-white">
|
||||||
|
{session.streak}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseIcon className="text-white/70" path={mdiChartBoxOutline} size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Best streak
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-white">
|
||||||
|
{session.bestStreak}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Round timer
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{currentRound
|
||||||
|
? `${Math.max(remainingMs / 1000, 0).toFixed(1)}s left`
|
||||||
|
: 'Resolving round'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BaseIcon className="text-white/60" path={mdiTimerOutline} size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-[width] duration-100 ${
|
||||||
|
timerProgress > 45
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: timerProgress > 15
|
||||||
|
? 'bg-yellow-400'
|
||||||
|
: 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${timerProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-white/62">{getResultMessage(revealedRound)}</p>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingState && lobbyVisible ? (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-white/45">
|
||||||
|
Quick start
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white">
|
||||||
|
Minimal, fast, and anti-cheat.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-base leading-7 text-white/68">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Choose your mode
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{modeOptions.map((option) => {
|
||||||
|
const isActive = option.value === modeSelection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
|
||||||
|
isActive
|
||||||
|
? 'border-[#ff66aa]/70 bg-[#ff66aa]/10 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-white/78 hover:border-white/25'
|
||||||
|
}`}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setModeSelection(option.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-medium text-white">{option.label}</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-white/60">
|
||||||
|
{option.helper}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Choose your timer
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{timerOptions.map((option) => {
|
||||||
|
const isActive = option.value === timerProfile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
|
||||||
|
isActive
|
||||||
|
? 'border-[#ff66aa]/70 bg-[#ff66aa]/10 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-white/78 hover:border-white/25'
|
||||||
|
}`}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setTimerProfile(option.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BaseIcon
|
||||||
|
className={isActive ? 'text-[#ff99c6]' : 'text-white/55'}
|
||||||
|
path={option.icon}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
<p className="text-lg font-medium text-white">{option.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-white/60">
|
||||||
|
{option.helper}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
disabled={isStarting}
|
||||||
|
icon={mdiGamepadSquareOutline}
|
||||||
|
label={isStarting ? 'Starting...' : 'Start run'}
|
||||||
|
onClick={handleStartSession}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="whiteDark"
|
||||||
|
href="/play/history"
|
||||||
|
icon={mdiHistory}
|
||||||
|
label="View run history"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
All-time ladder
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold text-white">
|
||||||
|
Top current streaks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{leaderboard.length ? (
|
||||||
|
leaderboard.slice(0, 5).map((entry, index) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3"
|
||||||
|
key={entry.id}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/55">#{index + 1}</p>
|
||||||
|
<p className="mt-1 text-base font-medium text-white">
|
||||||
|
{entry.user.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-semibold text-white">{entry.score}</p>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-white/45">
|
||||||
|
correct guesses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
||||||
|
Finish your first run to create a leaderboard entry.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-white/45">
|
||||||
|
Recent runs
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{history.length ? (
|
||||||
|
history.slice(0, 3).map((item) => (
|
||||||
|
<Link
|
||||||
|
className="block rounded-2xl border border-white/10 bg-white/5 px-4 py-3 transition hover:border-white/20"
|
||||||
|
href={`/play/sessions/${item.id}`}
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{formatModeSelection(item.modeSelection)} · {item.correctGuesses} correct
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/55">
|
||||||
|
{formatSessionDate(item.endedAt || item.startedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/65">
|
||||||
|
Best {item.bestStreak}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-6 text-sm text-white/55">
|
||||||
|
No runs yet. Start a session to build your history.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingState && activeRound ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-[#10141d] px-4 py-3 text-sm text-white/70">
|
||||||
|
{currentRound ? (
|
||||||
|
<span>
|
||||||
|
Hidden values stay server-side until you answer. Pick A or B before the
|
||||||
|
timer expires.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{getResultMessage(revealedRound)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<GuessCard
|
||||||
|
card={activeRound.cards.a}
|
||||||
|
disabled={Boolean(revealedRound) || isSubmittingChoice}
|
||||||
|
onPick={currentRound ? submitChoice : undefined}
|
||||||
|
reveal={
|
||||||
|
revealedRound?.reveal
|
||||||
|
? {
|
||||||
|
show: true,
|
||||||
|
value: revealedRound.reveal.values.a,
|
||||||
|
valueSuffix: revealedRound.reveal.valueSuffix,
|
||||||
|
isWinner: revealedRound.reveal.winningChoice === 'a',
|
||||||
|
isWrongSelection:
|
||||||
|
!revealedRound.reveal.isCorrect &&
|
||||||
|
revealedRound.reveal.playerChoice === 'a',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
slot="a"
|
||||||
|
/>
|
||||||
|
<GuessCard
|
||||||
|
card={activeRound.cards.b}
|
||||||
|
disabled={Boolean(revealedRound) || isSubmittingChoice}
|
||||||
|
onPick={currentRound ? submitChoice : undefined}
|
||||||
|
reveal={
|
||||||
|
revealedRound?.reveal
|
||||||
|
? {
|
||||||
|
show: true,
|
||||||
|
value: revealedRound.reveal.values.b,
|
||||||
|
valueSuffix: revealedRound.reveal.valueSuffix,
|
||||||
|
isWinner: revealedRound.reveal.winningChoice === 'b',
|
||||||
|
isWrongSelection:
|
||||||
|
!revealedRound.reveal.isCorrect &&
|
||||||
|
revealedRound.reveal.playerChoice === 'b',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
slot="b"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingState && gameOver && session ? (
|
||||||
|
<CardBox className="mt-6 border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-white/45">
|
||||||
|
Run complete
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-white">
|
||||||
|
{session.correctGuesses} correct guesses with a best streak of {session.bestStreak}.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-base leading-7 text-white/65">
|
||||||
|
Review every reveal in the run detail page, then spin up another instant
|
||||||
|
session when you are ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
icon={mdiRefresh}
|
||||||
|
label="Start another run"
|
||||||
|
onClick={handleStartSession}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="whiteDark"
|
||||||
|
href={`/play/sessions/${session.id}`}
|
||||||
|
label="View run details"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
294
frontend/src/pages/play/sessions/[sessionId].tsx
Normal file
294
frontend/src/pages/play/sessions/[sessionId].tsx
Normal file
@ -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<GameplayRound[]>([]);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Run detail')}</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiHistory} main title="Run detail">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<BaseButton color="whiteDark" href="/play/history" label="Back to history" />
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
href="/play"
|
||||||
|
icon={mdiGamepadSquareOutline}
|
||||||
|
label="Open arena"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="mb-6 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="py-10 text-center text-sm text-white/55">
|
||||||
|
Loading session detail...
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && session ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.22em] text-white/45">
|
||||||
|
{formatModeSelection(session.modeSelection)} ·{' '}
|
||||||
|
{session.timerProfile === 'insane' ? 'Insane · 5s' : 'Normal · 8s'}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-white">
|
||||||
|
{session.correctGuesses} correct guesses across {session.roundsPlayed} rounds.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-base leading-7 text-white/65">
|
||||||
|
Started {formatSessionDate(session.startedAt)} · Ended{' '}
|
||||||
|
{formatSessionDate(session.endedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 lg:w-[460px]">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiShieldCheckOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Best streak
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">
|
||||||
|
{session.bestStreak}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiChartBoxOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Score
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">
|
||||||
|
{session.leaderboardEntry?.score ?? session.correctGuesses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BaseIcon className="text-white/55" path={mdiTimerOutline} size={16} />
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-white/45">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-semibold capitalize text-white">
|
||||||
|
{session.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{!rounds.length ? (
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="py-10 text-center text-sm text-white/55">
|
||||||
|
No rounds were recorded for this session.
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white" key={round.id}>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
|
||||||
|
Round {round.roundNumber}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/60">
|
||||||
|
{round.reveal?.modeLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${resultTone(round)}`}
|
||||||
|
>
|
||||||
|
{round.reveal?.loseReason === 'timeout'
|
||||||
|
? 'Timed out'
|
||||||
|
: round.reveal?.isCorrect
|
||||||
|
? 'Correct'
|
||||||
|
: 'Wrong'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-white/62">
|
||||||
|
{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()}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/65">
|
||||||
|
Answered {formatSessionDate(round.reveal?.answeredAt || round.presentedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||||
|
<GuessCard
|
||||||
|
card={round.cards.a}
|
||||||
|
compact
|
||||||
|
reveal={{
|
||||||
|
show: true,
|
||||||
|
value: round.reveal?.values.a || 0,
|
||||||
|
valueSuffix: round.reveal?.valueSuffix || 'plays',
|
||||||
|
isWinner: round.reveal?.winningChoice === 'a',
|
||||||
|
isWrongSelection:
|
||||||
|
!round.reveal?.isCorrect && round.reveal?.playerChoice === 'a',
|
||||||
|
}}
|
||||||
|
slot="a"
|
||||||
|
/>
|
||||||
|
<GuessCard
|
||||||
|
card={round.cards.b}
|
||||||
|
compact
|
||||||
|
reveal={{
|
||||||
|
show: true,
|
||||||
|
value: round.reveal?.values.b || 0,
|
||||||
|
valueSuffix: round.reveal?.valueSuffix || 'plays',
|
||||||
|
isWinner: round.reveal?.winningChoice === 'b',
|
||||||
|
isWrongSelection:
|
||||||
|
!round.reveal?.isCorrect && round.reveal?.playerChoice === 'b',
|
||||||
|
}}
|
||||||
|
slot="b"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !session && !errorMessage ? (
|
||||||
|
<CardBox className="border border-white/10 bg-[#10141d] text-white">
|
||||||
|
<div className="py-10 text-center text-sm text-white/55">
|
||||||
|
This session could not be found.
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionDetailPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_GAME_SESSIONS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
@ -9,31 +9,27 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
|||||||
import { getPageTitle } from '../../config'
|
import { getPageTitle } from '../../config'
|
||||||
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
|
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import axios from "axios";
|
import axios from 'axios';
|
||||||
import Link from "next/link";
|
import Link from 'next/link';
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import CardBoxModal from '../../components/CardBoxModal';
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
||||||
import {setRefetch, uploadCsv} from '../../stores/sync_jobs/sync_jobsSlice';
|
import {
|
||||||
|
create as createSyncJob,
|
||||||
|
setRefetch,
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
uploadCsv,
|
||||||
|
} from '../../stores/sync_jobs/sync_jobsSlice';
|
||||||
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
|
|
||||||
const Sync_jobsTablesPage = () => {
|
const Sync_jobsTablesPage = () => {
|
||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
const [isCatalogSyncing, setIsCatalogSyncing] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'ErrorSummary', title: 'error_summary'},{label: 'JobPayloadJson', title: 'job_payload_json'},
|
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: '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'},
|
||||||
|
|
||||||
@ -43,49 +39,63 @@ const Sync_jobsTablesPage = () => {
|
|||||||
{label: 'TriggeredBy', title: 'triggered_by'},
|
{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']},
|
{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 hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
|
||||||
|
|
||||||
|
const addFilter = () => {
|
||||||
const addFilter = () => {
|
const newItem = {
|
||||||
const newItem = {
|
id: uniqueId(),
|
||||||
id: uniqueId(),
|
fields: {
|
||||||
fields: {
|
filterValue: '',
|
||||||
filterValue: '',
|
filterValueFrom: '',
|
||||||
filterValueFrom: '',
|
filterValueTo: '',
|
||||||
filterValueTo: '',
|
selectedField: '',
|
||||||
selectedField: '',
|
},
|
||||||
},
|
|
||||||
};
|
|
||||||
newItem.fields.selectedField = filters[0].title;
|
|
||||||
setFilterItems([...filterItems, newItem]);
|
|
||||||
};
|
};
|
||||||
|
newItem.fields.selectedField = filters[0].title;
|
||||||
|
setFilterItems([...filterItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
const getSync_jobsCSV = async () => {
|
const getSync_jobsCSV = async () => {
|
||||||
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||||
const type = response.headers['content-type']
|
const type = response.headers['content-type']
|
||||||
const blob = new Blob([response.data], { type: type })
|
const blob = new Blob([response.data], { type: type })
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = window.URL.createObjectURL(blob)
|
link.href = window.URL.createObjectURL(blob)
|
||||||
link.download = 'sync_jobsCSV.csv'
|
link.download = 'sync_jobsCSV.csv'
|
||||||
link.click()
|
link.click()
|
||||||
};
|
};
|
||||||
|
|
||||||
const onModalConfirm = async () => {
|
const runCatalogSync = async () => {
|
||||||
if (!csvFile) return;
|
try {
|
||||||
await dispatch(uploadCsv(csvFile));
|
setIsCatalogSyncing(true);
|
||||||
dispatch(setRefetch(true));
|
await dispatch(
|
||||||
setCsvFile(null);
|
createSyncJob({
|
||||||
setIsModalActive(false);
|
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 onModalCancel = () => {
|
const onModalConfirm = async () => {
|
||||||
setCsvFile(null);
|
if (!csvFile) return;
|
||||||
setIsModalActive(false);
|
await dispatch(uploadCsv(csvFile));
|
||||||
};
|
dispatch(setRefetch(true));
|
||||||
|
setCsvFile(null);
|
||||||
|
setIsModalActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModalCancel = () => {
|
||||||
|
setCsvFile(null);
|
||||||
|
setIsModalActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -94,11 +104,20 @@ const Sync_jobsTablesPage = () => {
|
|||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sync_jobs/sync_jobs-new'} color='info' label='New Item'/>}
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sync_jobs/sync_jobs-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && (
|
||||||
|
<BaseButton
|
||||||
|
className={'mr-3'}
|
||||||
|
color='success'
|
||||||
|
label={isCatalogSyncing ? 'Syncing osu catalog…' : 'Sync / continue osu catalog'}
|
||||||
|
disabled={isCatalogSyncing}
|
||||||
|
onClick={runCatalogSync}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
@ -108,31 +127,37 @@ const Sync_jobsTablesPage = () => {
|
|||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Upload CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<TableSync_jobs
|
{hasCreatePermission && (
|
||||||
filterItems={filterItems}
|
<CardBox className='mb-6'>
|
||||||
setFilterItems={setFilterItems}
|
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
||||||
filters={filters}
|
Use <strong>Sync / continue osu catalog</strong> before testing the game. Each run upserts into the existing catalog, refreshes mapper and artist totals, and if you leave <code>startPage</code> out it automatically continues from the next untouched page so your local pool keeps growing.
|
||||||
showGrid={false}
|
</p>
|
||||||
/>
|
</CardBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableSync_jobs
|
||||||
|
filterItems={filterItems}
|
||||||
|
setFilterItems={setFilterItems}
|
||||||
|
filters={filters}
|
||||||
|
showGrid={false}
|
||||||
|
/>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<CardBoxModal
|
<CardBoxModal
|
||||||
title='Upload CSV'
|
title='Upload CSV'
|
||||||
|
|||||||
@ -193,7 +193,7 @@ const initialValues = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
job_payload_json: '',
|
job_payload_json: '{"pages":12,"sort":"plays_desc"}',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user