Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
47a2f113a1 Autosave: 20260506-160338 2026-05-06 16:03:37 +00:00
18 changed files with 4123 additions and 270 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -54,6 +53,7 @@ const leaderboard_entriesRoutes = require('./routes/leaderboard_entries');
const daily_seedsRoutes = require('./routes/daily_seeds');
const server_cachesRoutes = require('./routes/server_caches');
const gameplayRoutes = require('./routes/gameplay');
const getBaseUrl = (url) => {
@ -146,6 +146,7 @@ app.use('/api/leaderboard_entries', passport.authenticate('jwt', {session: false
app.use('/api/daily_seeds', passport.authenticate('jwt', {session: false}), daily_seedsRoutes);
app.use('/api/server_caches', passport.authenticate('jwt', {session: false}), server_cachesRoutes);
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
app.use(
'/api/openai',

View 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;

File diff suppressed because it is too large Load Diff

View 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}`,
};
}
};

View File

@ -1,36 +1,107 @@
const db = require('../db/models');
const Sync_jobsDBApi = require('../db/api/sync_jobs');
const processFile = require("../middlewares/upload");
const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const OsuCatalogSyncService = require('./osuCatalogSync');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
async function createStoredJob(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const syncJob = await Sync_jobsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return syncJob;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async function markJobFailed(jobId, currentUser, error) {
try {
await db.sync_jobs.update(
{
status: 'failed',
finished_at: new Date(),
error_count: 1,
error_summary: error.message,
updatedById: currentUser?.id || null,
},
{
where: { id: jobId },
},
);
} catch (updateError) {
console.error('Failed to update sync job failure state:', updateError);
}
}
module.exports = class Sync_jobsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
const shouldRunJob =
OsuCatalogSyncService.isRunnableJobType(data?.job_type) &&
(!data?.status || ['queued', 'running'].includes(data.status));
const initialPayload = shouldRunJob
? {
...data,
status: 'running',
started_at: new Date(),
finished_at: null,
processed_count: 0,
inserted_count: 0,
updated_count: 0,
skipped_count: 0,
error_count: 0,
error_summary: null,
triggered_by: currentUser?.id || null,
}
: data;
const syncJob = await createStoredJob(initialPayload, currentUser);
if (!shouldRunJob) {
return syncJob;
}
try {
await Sync_jobsDBApi.create(
data,
const result = await OsuCatalogSyncService.run(syncJob, currentUser);
await db.sync_jobs.update(
{
currentUser,
transaction,
status: 'succeeded',
finished_at: new Date(),
processed_count: result.processedCount,
inserted_count: result.insertedCount,
updated_count: result.updatedCount,
skipped_count: result.skippedCount,
error_count: result.errorCount || 0,
error_summary: result.summary,
updatedById: currentUser.id,
},
{
where: { id: syncJob.id },
},
);
await transaction.commit();
return db.sync_jobs.findByPk(syncJob.id);
} catch (error) {
await transaction.rollback();
console.error('Sync job failed:', error);
await markJobFailed(syncJob.id, currentUser, error);
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -38,24 +109,24 @@ module.exports = class Sync_jobsService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
.on('data', (row) => results.push(row))
.on('end', () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
});
await Sync_jobsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
@ -67,16 +138,15 @@ module.exports = class Sync_jobsService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let sync_jobs = await Sync_jobsDBApi.findBy(
{id},
{transaction},
const sync_jobs = await Sync_jobsDBApi.findBy(
{ id },
{ transaction },
);
if (!sync_jobs) {
throw new ValidationError(
'sync_jobsNotFound',
);
throw new ValidationError('sync_jobsNotFound');
}
const updatedSync_jobs = await Sync_jobsDBApi.update(
@ -90,12 +160,11 @@ module.exports = class Sync_jobsService {
await transaction.commit();
return updatedSync_jobs;
} catch (error) {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -131,8 +200,4 @@ module.exports = class Sync_jobsService {
throw error;
}
}
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View 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)}</>;
}

View 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>
);
}

View 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);

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -2,6 +2,18 @@ import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [
{
href: '/play',
icon: 'mdiGamepadSquareOutline' in icon ? icon['mdiGamepadSquareOutline' as keyof typeof icon] : icon.mdiTable,
label: 'Play',
permissions: 'READ_GAME_SESSIONS'
},
{
href: '/play/history',
icon: icon.mdiHistory ?? icon.mdiTable,
label: 'Run history',
permissions: 'READ_GAME_SESSIONS'
},
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,

View File

@ -1,166 +1,264 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiDatabaseOutline,
mdiGamepadSquareOutline,
mdiPlayCircleOutline,
mdiShieldCheckOutline,
mdiTimerOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import LayoutGuest from '../layouts/Guest';
const heroCards = [
{
slot: 'A',
title: 'Blue Zenith',
subtitle: 'xi · mapped by Monstrata',
image:
'https://assets.ppy.sh/beatmaps/874923/covers/raw.jpg',
},
{
slot: 'B',
title: 'Kyouran Hey Kids!!',
subtitle: 'THE ORAL CIGARETTES · mapped by Sotarks',
image:
'https://assets.ppy.sh/beatmaps/102120/covers/raw.jpg',
},
];
const featureCards = [
{
icon: mdiShieldCheckOutline,
title: 'Server-hidden answers',
body: 'Playcounts, mapper totals, artist totals, and the mixed-mode selection stay hidden until the answer is locked in.',
},
{
icon: mdiTimerOutline,
title: 'Five to eight second rounds',
body: 'Each guess resolves immediately. A timeout costs one life and the next card pair rolls in without loading screens.',
},
{
icon: mdiDatabaseOutline,
title: 'Ranked + loved only',
body: 'The pool is scoped to osu!standard with precomputed mapper and artist aggregates for fast constant-time round evaluation.',
},
];
const modeCards = [
{
label: 'Beatmap',
helper: 'Compare raw beatmap playcount.',
},
{
label: 'Mapper',
helper: 'Show mapper avatar + banner only, then reveal total mapper plays.',
},
{
label: 'Artist',
helper: 'Show artist image + name only, then reveal total artist plays.',
},
{
label: 'Mixed',
helper: 'The server chooses the round type each time and reveals it only after the guess.',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Osu Higher Lower'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<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 (
<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>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Osu Higher Lower')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Osu Higher Lower app!"/>
<div className="min-h-screen bg-[#0b0d12] text-white">
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-6 lg:px-10">
<header className="flex items-center justify-between gap-4 border-b border-white/10 pb-5">
<Link className="flex items-center gap-3" href="/">
<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} />
</span>
<div>
<p className="text-xs uppercase tracking-[0.22em] text-white/45">
Osu Higher Lower
</p>
<p className="text-sm text-white/70">Fast browser guessing game</p>
</div>
</Link>
<div className="space-y-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>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="flex flex-wrap items-center gap-3">
<BaseButton color="whiteDark" href="/login" label="Login" />
<BaseButton
color="whiteDark"
href="/dashboard"
icon={mdiViewDashboardOutline}
label="Admin interface"
/>
</div>
</header>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<main className="flex-1 py-10 lg:py-14">
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
<div>
<p className="text-sm uppercase tracking-[0.28em] text-white/45">
osu!standard · ranked + loved
</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>
</CardBox>
<div className="mt-8 flex flex-wrap items-center gap-3">
<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>
</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) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View 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>;
};

View 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 mappers ranked and loved maps.',
},
{
value: 'artist_only',
label: 'Artist',
helper: 'Compare total plays accumulated across each artists 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>;
};

View 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>;
};

View File

@ -9,31 +9,27 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
import { getPageTitle } from '../../config'
import TableSync_jobs from '../../components/Sync_jobs/TableSync_jobs'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/sync_jobs/sync_jobsSlice';
import {hasPermission} from "../../helpers/userPermissions";
import axios from 'axios';
import Link from 'next/link';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import {
create as createSyncJob,
setRefetch,
uploadCsv,
} from '../../stores/sync_jobs/sync_jobsSlice';
import { hasPermission } from '../../helpers/userPermissions';
const Sync_jobsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const [isCatalogSyncing, setIsCatalogSyncing] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch();
const [filters] = useState([{label: 'ErrorSummary', title: 'error_summary'},{label: 'JobPayloadJson', title: 'job_payload_json'},
{label: 'ProcessedCount', title: 'processed_count', number: 'true'},{label: 'InsertedCount', title: 'inserted_count', number: 'true'},{label: 'UpdatedCount', title: 'updated_count', number: 'true'},{label: 'SkippedCount', title: 'skipped_count', number: 'true'},{label: 'ErrorCount', title: 'error_count', number: 'true'},
@ -43,49 +39,63 @@ const Sync_jobsTablesPage = () => {
{label: 'TriggeredBy', title: 'triggered_by'},
{label: 'JobType', title: 'job_type', type: 'enum', options: ['osu_sync_seed','osu_sync_incremental','osu_refresh_mappers','osu_refresh_artists','aggregate_mapper_totals','aggregate_artist_totals','cache_warmup']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','running','succeeded','failed','canceled']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SYNC_JOBS');
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
};
const getSync_jobsCSV = async () => {
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'sync_jobsCSV.csv'
link.click()
};
const getSync_jobsCSV = async () => {
const response = await axios({url: '/sync_jobs?filetype=csv', method: 'GET',responseType: 'blob'});
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'sync_jobsCSV.csv'
link.click()
};
const onModalConfirm = async () => {
if (!csvFile) return;
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const runCatalogSync = async () => {
try {
setIsCatalogSyncing(true);
await dispatch(
createSyncJob({
job_type: 'osu_sync_seed',
status: 'queued',
job_payload_json: JSON.stringify({ pages: 12, sort: 'plays_desc' }),
}),
).unwrap();
dispatch(setRefetch(true));
} finally {
setIsCatalogSyncing(false);
}
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
const onModalConfirm = async () => {
if (!csvFile) return;
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return (
<>
@ -94,11 +104,20 @@ const Sync_jobsTablesPage = () => {
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sync_jobs" main>
{''}
{''}
</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
className={'mr-3'}
@ -108,31 +127,37 @@ const Sync_jobsTablesPage = () => {
/>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSync_jobsCSV} />
{hasCreatePermission && (
<BaseButton
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
{hasCreatePermission && (
<BaseButton
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
</div>
<div className='md:inline-flex items-center ms-auto'>
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
</div>
<div className='md:inline-flex items-center ms-auto'>
<Link href={'/sync_jobs/sync_jobs-table'}>Switch to Table</Link>
</div>
</CardBox>
<TableSync_jobs
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={false}
/>
{hasCreatePermission && (
<CardBox className='mb-6'>
<p className='text-sm text-slate-500 dark:text-slate-300'>
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.
</p>
</CardBox>
)}
<TableSync_jobs
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={false}
/>
</SectionMain>
<CardBoxModal
title='Upload CSV'

View File

@ -193,7 +193,7 @@ const initialValues = {
job_payload_json: '',
job_payload_json: '{"pages":12,"sort":"plays_desc"}',