po
This commit is contained in:
parent
8bfa911b12
commit
e2c177190b
@ -0,0 +1,22 @@
|
|||||||
|
const {
|
||||||
|
mappers,
|
||||||
|
artists,
|
||||||
|
beatmapSets,
|
||||||
|
beatmaps,
|
||||||
|
} = require('../../services/gameplayDemoData');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.bulkInsert('mappers', mappers);
|
||||||
|
await queryInterface.bulkInsert('artists', artists);
|
||||||
|
await queryInterface.bulkInsert('beatmap_sets', beatmapSets);
|
||||||
|
await queryInterface.bulkInsert('beatmaps', beatmaps);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.bulkDelete('beatmaps', { id: beatmaps.map((beatmap) => beatmap.id) });
|
||||||
|
await queryInterface.bulkDelete('beatmap_sets', { id: beatmapSets.map((beatmapSet) => beatmapSet.id) });
|
||||||
|
await queryInterface.bulkDelete('artists', { id: artists.map((artist) => artist.id) });
|
||||||
|
await queryInterface.bulkDelete('mappers', { id: mappers.map((mapper) => mapper.id) });
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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');
|
||||||
@ -36,6 +35,7 @@ const beatmap_setsRoutes = require('./routes/beatmap_sets');
|
|||||||
const beatmapsRoutes = require('./routes/beatmaps');
|
const beatmapsRoutes = require('./routes/beatmaps');
|
||||||
|
|
||||||
const game_sessionsRoutes = require('./routes/game_sessions');
|
const game_sessionsRoutes = require('./routes/game_sessions');
|
||||||
|
const gameplayRoutes = require('./routes/gameplay');
|
||||||
|
|
||||||
const game_roundsRoutes = require('./routes/game_rounds');
|
const game_roundsRoutes = require('./routes/game_rounds');
|
||||||
|
|
||||||
@ -118,6 +118,7 @@ app.use('/api/beatmap_sets', passport.authenticate('jwt', {session: false}), bea
|
|||||||
app.use('/api/beatmaps', passport.authenticate('jwt', {session: false}), beatmapsRoutes);
|
app.use('/api/beatmaps', passport.authenticate('jwt', {session: false}), beatmapsRoutes);
|
||||||
|
|
||||||
app.use('/api/game_sessions', passport.authenticate('jwt', {session: false}), game_sessionsRoutes);
|
app.use('/api/game_sessions', passport.authenticate('jwt', {session: false}), game_sessionsRoutes);
|
||||||
|
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
|
||||||
|
|
||||||
app.use('/api/game_rounds', passport.authenticate('jwt', {session: false}), game_roundsRoutes);
|
app.use('/api/game_rounds', passport.authenticate('jwt', {session: false}), game_roundsRoutes);
|
||||||
|
|
||||||
|
|||||||
54
backend/src/routes/gameplay.js
Normal file
54
backend/src/routes/gameplay.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const GameplayService = require('../services/gameplay');
|
||||||
|
const Helpers = require('../helpers');
|
||||||
|
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/overview',
|
||||||
|
checkPermissions('READ_GAME_SESSIONS'),
|
||||||
|
Helpers.wrapAsync(async (req, res) => {
|
||||||
|
const payload = await GameplayService.getOverview(req.currentUser);
|
||||||
|
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/start',
|
||||||
|
checkPermissions('CREATE_GAME_SESSIONS'),
|
||||||
|
Helpers.wrapAsync(async (req, res) => {
|
||||||
|
const payload = await GameplayService.startSession(req.body, req.currentUser);
|
||||||
|
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/answer',
|
||||||
|
checkPermissions('CREATE_GAME_ROUNDS'),
|
||||||
|
Helpers.wrapAsync(async (req, res) => {
|
||||||
|
const payload = await GameplayService.answerRound(req.body, req.currentUser);
|
||||||
|
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/sessions/:id',
|
||||||
|
checkPermissions('READ_GAME_SESSIONS'),
|
||||||
|
Helpers.wrapAsync(async (req, res) => {
|
||||||
|
const payload = await GameplayService.getSessionDetails(
|
||||||
|
req.params.id,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', Helpers.commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
896
backend/src/services/gameplay.js
Normal file
896
backend/src/services/gameplay.js
Normal file
@ -0,0 +1,896 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const demoData = require('./gameplayDemoData');
|
||||||
|
|
||||||
|
const { Op } = db.Sequelize;
|
||||||
|
|
||||||
|
const ROUND_MODE_OPTIONS = ['beatmap', 'mapper', 'artist'];
|
||||||
|
const SESSION_MODE_OPTIONS = [...ROUND_MODE_OPTIONS, 'mixed'];
|
||||||
|
const TIMER_PROFILE_SECONDS = {
|
||||||
|
normal: 8,
|
||||||
|
insane: 6,
|
||||||
|
};
|
||||||
|
const SESSION_KEY_PREFIX = 'osu-hl-';
|
||||||
|
const LEADERBOARD_SCOPE_KEY = 'osu-hl-global';
|
||||||
|
const STARTING_LIVES = 2;
|
||||||
|
const RECENT_SESSION_LIMIT = 5;
|
||||||
|
const LEADERBOARD_LIMIT = 5;
|
||||||
|
const ALLOWED_STATUSES = ['ranked', 'loved'];
|
||||||
|
const demoMapperTotals = new Map(
|
||||||
|
demoData.mappers.map((mapper) => [mapper.id, mapper.total_ranked_loved_playcount]),
|
||||||
|
);
|
||||||
|
const demoArtistTotals = new Map(
|
||||||
|
demoData.artists.map((artist) => [artist.id, artist.total_ranked_loved_playcount]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getBeatmapInclude() {
|
||||||
|
return [
|
||||||
|
{ model: db.mappers, as: 'mapper' },
|
||||||
|
{ model: db.artists, as: 'artist' },
|
||||||
|
{ model: db.beatmap_sets, as: 'beatmap_set' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestError(message, code = 400) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const numericValue = Number(value || 0);
|
||||||
|
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom(array) {
|
||||||
|
return array[Math.floor(Math.random() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolvedMode(modeSetting) {
|
||||||
|
if (modeSetting === 'mixed') {
|
||||||
|
return pickRandom(ROUND_MODE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modeSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetricLabel(resolvedMode) {
|
||||||
|
if (resolvedMode === 'mapper') {
|
||||||
|
return 'Mapper total plays';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMode === 'artist') {
|
||||||
|
return 'Artist total plays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Beatmap playcount';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComparisonValue(beatmap, resolvedMode) {
|
||||||
|
if (!beatmap) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMode === 'mapper') {
|
||||||
|
return toNumber(
|
||||||
|
beatmap.mapper?.total_ranked_loved_playcount || demoMapperTotals.get(beatmap.mapperId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMode === 'artist') {
|
||||||
|
return toNumber(
|
||||||
|
beatmap.artist?.total_ranked_loved_playcount || demoArtistTotals.get(beatmap.artistId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toNumber(beatmap.playcount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeatmapPlayable(beatmap) {
|
||||||
|
return Boolean(
|
||||||
|
beatmap &&
|
||||||
|
beatmap.background_image_url &&
|
||||||
|
beatmap.mapper &&
|
||||||
|
beatmap.artist &&
|
||||||
|
ALLOWED_STATUSES.includes(beatmap.status) &&
|
||||||
|
beatmap.mode === 'osu_standard' &&
|
||||||
|
getComparisonValue(beatmap, 'beatmap') > 0 &&
|
||||||
|
getComparisonValue(beatmap, 'mapper') > 0 &&
|
||||||
|
getComparisonValue(beatmap, 'artist') > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeBeatmapCard(beatmap) {
|
||||||
|
return {
|
||||||
|
id: beatmap.id,
|
||||||
|
beatmapId: beatmap.osu_beatmap_numeric,
|
||||||
|
title: beatmap.title,
|
||||||
|
version: beatmap.version,
|
||||||
|
artistName: beatmap.artist?.name || beatmap.artist_name,
|
||||||
|
mapperName: beatmap.mapper?.osu_username || 'Unknown mapper',
|
||||||
|
mapperAvatarUrl: beatmap.mapper?.avatar_url || '',
|
||||||
|
backgroundImageUrl:
|
||||||
|
beatmap.background_image_url || beatmap.beatmap_set?.background_image_url || '',
|
||||||
|
status: beatmap.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionSummary(session, rounds = []) {
|
||||||
|
const completedRounds = rounds.filter((round) => round.result !== 'pending');
|
||||||
|
const correctAnswers = completedRounds.filter(
|
||||||
|
(round) => round.result === 'correct',
|
||||||
|
).length;
|
||||||
|
const wrongAnswers = completedRounds.filter(
|
||||||
|
(round) => round.result === 'wrong',
|
||||||
|
).length;
|
||||||
|
const timeoutCount = completedRounds.filter(
|
||||||
|
(round) => round.result === 'timeout',
|
||||||
|
).length;
|
||||||
|
const suspiciousRounds = completedRounds.filter(
|
||||||
|
(round) => round.is_suspected_cheat,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
sessionKey: session.session_key,
|
||||||
|
status: session.status,
|
||||||
|
modeSetting: session.mode_setting,
|
||||||
|
timerProfile: session.timer_profile,
|
||||||
|
startingLives: session.starting_lives,
|
||||||
|
livesRemaining: session.lives_remaining,
|
||||||
|
currentStreak: session.streak_current,
|
||||||
|
bestStreak: session.streak_best,
|
||||||
|
startedAt: session.started_at,
|
||||||
|
endedAt: session.ended_at,
|
||||||
|
totalRounds: completedRounds.length,
|
||||||
|
correctAnswers,
|
||||||
|
wrongAnswers,
|
||||||
|
timeoutCount,
|
||||||
|
suspiciousRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoundResolution(round, beatmapA, beatmapB) {
|
||||||
|
return {
|
||||||
|
roundId: round.id,
|
||||||
|
roundIndex: round.round_index,
|
||||||
|
result: round.result,
|
||||||
|
playerChoice: round.player_choice || 'none',
|
||||||
|
correctChoice: round.correct_choice,
|
||||||
|
resolvedMode: round.resolved_mode,
|
||||||
|
comparedLabel: getMetricLabel(round.resolved_mode),
|
||||||
|
values: {
|
||||||
|
a: getComparisonValue(beatmapA, round.resolved_mode),
|
||||||
|
b: getComparisonValue(beatmapB, round.resolved_mode),
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
a: serializeBeatmapCard(beatmapA),
|
||||||
|
b: serializeBeatmapCard(beatmapB),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserDisplayName(user) {
|
||||||
|
const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
return fullName || user?.email?.split('@')[0] || 'Player';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDemoDataset(transaction) {
|
||||||
|
const activeBeatmapCount = await db.beatmaps.count({
|
||||||
|
where: {
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: {
|
||||||
|
[Op.in]: ALLOWED_STATUSES,
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeBeatmapCount >= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mapper of demoData.mappers) {
|
||||||
|
await db.mappers.upsert(mapper, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const artist of demoData.artists) {
|
||||||
|
await db.artists.upsert(artist, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const beatmapSet of demoData.beatmapSets) {
|
||||||
|
await db.beatmap_sets.upsert(beatmapSet, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const beatmap of demoData.beatmaps) {
|
||||||
|
await db.beatmaps.upsert(beatmap, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEligibleBeatmaps(transaction) {
|
||||||
|
await ensureDemoDataset(transaction);
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: {
|
||||||
|
[Op.in]: ALLOWED_STATUSES,
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let beatmaps = await db.beatmaps.findAll({
|
||||||
|
where,
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
beatmaps = beatmaps.filter(isBeatmapPlayable);
|
||||||
|
|
||||||
|
if (beatmaps.length >= 2) {
|
||||||
|
return beatmaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackBeatmaps = await db.beatmaps.findAll({
|
||||||
|
where: {
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: {
|
||||||
|
[Op.in]: ALLOWED_STATUSES,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fallbackBeatmaps.filter(isBeatmapPlayable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBeatmapPair(beatmaps, resolvedMode, excludeBeatmapIds = []) {
|
||||||
|
const pool = beatmaps.filter((beatmap) => !excludeBeatmapIds.includes(beatmap.id));
|
||||||
|
const workingPool = pool.length >= 2 ? pool : beatmaps;
|
||||||
|
|
||||||
|
if (workingPool.length < 2) {
|
||||||
|
throw new Error('At least two playable beatmaps are required to start Osu Higher Lower.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinations = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < workingPool.length; index += 1) {
|
||||||
|
for (let nestedIndex = index + 1; nestedIndex < workingPool.length; nestedIndex += 1) {
|
||||||
|
const beatmapA = workingPool[index];
|
||||||
|
const beatmapB = workingPool[nestedIndex];
|
||||||
|
const valueA = getComparisonValue(beatmapA, resolvedMode);
|
||||||
|
const valueB = getComparisonValue(beatmapB, resolvedMode);
|
||||||
|
|
||||||
|
if (valueA === valueB) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
combinations.push({
|
||||||
|
beatmapA,
|
||||||
|
beatmapB,
|
||||||
|
valueA,
|
||||||
|
valueB,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!combinations.length) {
|
||||||
|
throw new Error('The active beatmap dataset does not contain enough distinct comparison values.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPair = pickRandom(combinations);
|
||||||
|
|
||||||
|
return {
|
||||||
|
beatmapA: selectedPair.beatmapA,
|
||||||
|
beatmapB: selectedPair.beatmapB,
|
||||||
|
correctChoice: selectedPair.valueA >= selectedPair.valueB ? 'a' : 'b',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingRound(sessionId, transaction) {
|
||||||
|
return db.game_rounds.findOne({
|
||||||
|
where: {
|
||||||
|
game_sessionId: sessionId,
|
||||||
|
result: 'pending',
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.beatmaps,
|
||||||
|
as: 'beatmap_a',
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.beatmaps,
|
||||||
|
as: 'beatmap_b',
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['round_index', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoundForSession(session, options = {}) {
|
||||||
|
const transaction = options.transaction;
|
||||||
|
const excludeBeatmapIds = options.excludeBeatmapIds || [];
|
||||||
|
const playableBeatmaps = await getEligibleBeatmaps(transaction);
|
||||||
|
const resolvedMode = getResolvedMode(session.mode_setting);
|
||||||
|
const pair = pickBeatmapPair(playableBeatmaps, resolvedMode, excludeBeatmapIds);
|
||||||
|
const roundCount = await db.game_rounds.count({
|
||||||
|
where: {
|
||||||
|
game_sessionId: session.id,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const timeLimitSeconds = TIMER_PROFILE_SECONDS[session.timer_profile] || TIMER_PROFILE_SECONDS.normal;
|
||||||
|
const presentedAt = new Date();
|
||||||
|
|
||||||
|
const round = await db.game_rounds.create(
|
||||||
|
{
|
||||||
|
game_sessionId: session.id,
|
||||||
|
round_index: roundCount + 1,
|
||||||
|
resolved_mode: resolvedMode,
|
||||||
|
beatmap_aId: pair.beatmapA.id,
|
||||||
|
beatmap_bId: pair.beatmapB.id,
|
||||||
|
correct_choice: pair.correctChoice,
|
||||||
|
result: 'pending',
|
||||||
|
time_limit_seconds: timeLimitSeconds,
|
||||||
|
presented_at: presentedAt,
|
||||||
|
createdById: session.userId,
|
||||||
|
updatedById: session.userId,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
round,
|
||||||
|
beatmapA: pair.beatmapA,
|
||||||
|
beatmapB: pair.beatmapB,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveSessionPayload(session, round, beatmapA, beatmapB) {
|
||||||
|
const timeLimitSeconds = toNumber(round.time_limit_seconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
id: session.id,
|
||||||
|
sessionKey: session.session_key,
|
||||||
|
status: session.status,
|
||||||
|
modeSetting: session.mode_setting,
|
||||||
|
timerProfile: session.timer_profile,
|
||||||
|
livesRemaining: session.lives_remaining,
|
||||||
|
startingLives: session.starting_lives,
|
||||||
|
currentStreak: session.streak_current,
|
||||||
|
bestStreak: session.streak_best,
|
||||||
|
startedAt: session.started_at,
|
||||||
|
},
|
||||||
|
round: {
|
||||||
|
id: round.id,
|
||||||
|
index: round.round_index,
|
||||||
|
timeLimitSeconds,
|
||||||
|
presentedAt: round.presented_at,
|
||||||
|
endsAt: new Date(
|
||||||
|
new Date(round.presented_at).getTime() + timeLimitSeconds * 1000,
|
||||||
|
).toISOString(),
|
||||||
|
cards: {
|
||||||
|
a: serializeBeatmapCard(beatmapA),
|
||||||
|
b: serializeBeatmapCard(beatmapB),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abandonActiveSessions(currentUser, transaction) {
|
||||||
|
const activeSessions = await db.game_sessions.findAll({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'active',
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeSessions.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSessionIds = activeSessions.map((session) => session.id);
|
||||||
|
const endedAt = new Date();
|
||||||
|
|
||||||
|
await db.game_rounds.update(
|
||||||
|
{
|
||||||
|
result: 'timeout',
|
||||||
|
player_choice: 'none',
|
||||||
|
answered_at: endedAt,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
game_sessionId: {
|
||||||
|
[Op.in]: activeSessionIds,
|
||||||
|
},
|
||||||
|
result: 'pending',
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.game_sessions.update(
|
||||||
|
{
|
||||||
|
status: 'abandoned',
|
||||||
|
ended_at: endedAt,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.in]: activeSessionIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordLeaderboardEntry(session, currentUser, transaction) {
|
||||||
|
const rounds = await db.game_rounds.findAll({
|
||||||
|
where: {
|
||||||
|
game_sessionId: session.id,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const sessionSummary = buildSessionSummary(session, rounds);
|
||||||
|
const scoreValue = sessionSummary.correctAnswers;
|
||||||
|
|
||||||
|
const existingEntry = await db.leaderboard_entries.findOne({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
scope: 'global',
|
||||||
|
scope_key: LEADERBOARD_SCOPE_KEY,
|
||||||
|
mode_setting: session.mode_setting,
|
||||||
|
timer_profile: session.timer_profile,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEntry) {
|
||||||
|
await existingEntry.update(
|
||||||
|
{
|
||||||
|
score_value: Math.max(existingEntry.score_value || 0, scoreValue),
|
||||||
|
best_streak: Math.max(existingEntry.best_streak || 0, session.streak_best || 0),
|
||||||
|
games_played: (existingEntry.games_played || 0) + 1,
|
||||||
|
recorded_at: new Date(),
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.leaderboard_entries.create(
|
||||||
|
{
|
||||||
|
userId: currentUser.id,
|
||||||
|
scope: 'global',
|
||||||
|
scope_key: LEADERBOARD_SCOPE_KEY,
|
||||||
|
mode_setting: session.mode_setting,
|
||||||
|
timer_profile: session.timer_profile,
|
||||||
|
score_value: scoreValue,
|
||||||
|
best_streak: session.streak_best || 0,
|
||||||
|
games_played: 1,
|
||||||
|
recorded_at: new Date(),
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = class GameplayService {
|
||||||
|
static async startSession(payload, currentUser) {
|
||||||
|
const modeSetting = payload?.modeSetting || 'mixed';
|
||||||
|
const timerProfile = payload?.timerProfile || 'normal';
|
||||||
|
|
||||||
|
if (!SESSION_MODE_OPTIONS.includes(modeSetting)) {
|
||||||
|
throw createRequestError('Mode must be beatmap, mapper, artist, or mixed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(TIMER_PROFILE_SECONDS).includes(timerProfile)) {
|
||||||
|
throw createRequestError('Timer profile must be normal or insane.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await abandonActiveSessions(currentUser, transaction);
|
||||||
|
|
||||||
|
const session = await db.game_sessions.create(
|
||||||
|
{
|
||||||
|
session_key: `${SESSION_KEY_PREFIX}${crypto.randomUUID()}`,
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'active',
|
||||||
|
mode_setting: modeSetting,
|
||||||
|
starting_lives: STARTING_LIVES,
|
||||||
|
lives_remaining: STARTING_LIVES,
|
||||||
|
streak_current: 0,
|
||||||
|
streak_best: 0,
|
||||||
|
timer_profile: timerProfile,
|
||||||
|
round_time_min_seconds: TIMER_PROFILE_SECONDS[timerProfile],
|
||||||
|
round_time_max_seconds: TIMER_PROFILE_SECONDS[timerProfile],
|
||||||
|
daily_seed_enabled: false,
|
||||||
|
started_at: new Date(),
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { round, beatmapA, beatmapB } = await createRoundForSession(session, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return buildActiveSessionPayload(session, round, beatmapA, beatmapB);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async answerRound(payload, currentUser) {
|
||||||
|
const sessionId = payload?.sessionId;
|
||||||
|
const choice = payload?.choice || 'none';
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
throw createRequestError('A session id is required to answer a round.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['a', 'b', 'none'].includes(choice)) {
|
||||||
|
throw createRequestError('Choice must be a, b, or none.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await db.game_sessions.findOne({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw createRequestError('Game session not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== 'active') {
|
||||||
|
throw createRequestError('This game session has already ended.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRound = await getPendingRound(session.id, transaction);
|
||||||
|
|
||||||
|
if (!pendingRound) {
|
||||||
|
throw createRequestError('There is no pending round to resolve.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const deadline = new Date(
|
||||||
|
new Date(pendingRound.presented_at).getTime() +
|
||||||
|
toNumber(pendingRound.time_limit_seconds) * 1000,
|
||||||
|
);
|
||||||
|
const submittedLate = now.getTime() > deadline.getTime();
|
||||||
|
const lateMs = Math.max(now.getTime() - deadline.getTime(), 0);
|
||||||
|
|
||||||
|
let result = 'wrong';
|
||||||
|
let storedChoice = choice;
|
||||||
|
let isSuspectedCheat = false;
|
||||||
|
let suspicionReason = null;
|
||||||
|
|
||||||
|
if (choice === 'none' || submittedLate) {
|
||||||
|
result = 'timeout';
|
||||||
|
storedChoice = choice === 'none' ? 'none' : choice;
|
||||||
|
|
||||||
|
if (submittedLate && choice !== 'none' && lateMs > 1000) {
|
||||||
|
isSuspectedCheat = true;
|
||||||
|
suspicionReason = 'Answer submitted after the round timer had expired.';
|
||||||
|
}
|
||||||
|
} else if (choice === pendingRound.correct_choice) {
|
||||||
|
result = 'correct';
|
||||||
|
}
|
||||||
|
|
||||||
|
await pendingRound.update(
|
||||||
|
{
|
||||||
|
player_choice: storedChoice,
|
||||||
|
result,
|
||||||
|
answered_at: now,
|
||||||
|
is_suspected_cheat: isSuspectedCheat,
|
||||||
|
suspicion_reason: suspicionReason,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextLivesRemaining =
|
||||||
|
result === 'correct' ? session.lives_remaining : Math.max((session.lives_remaining || 0) - 1, 0);
|
||||||
|
const nextCurrentStreak = result === 'correct' ? (session.streak_current || 0) + 1 : 0;
|
||||||
|
const nextBestStreak = Math.max(session.streak_best || 0, nextCurrentStreak);
|
||||||
|
const nextStatus = nextLivesRemaining > 0 ? 'active' : 'ended';
|
||||||
|
|
||||||
|
await session.update(
|
||||||
|
{
|
||||||
|
lives_remaining: nextLivesRemaining,
|
||||||
|
streak_current: nextCurrentStreak,
|
||||||
|
streak_best: nextBestStreak,
|
||||||
|
status: nextStatus,
|
||||||
|
ended_at: nextStatus === 'ended' ? now : null,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolution = buildRoundResolution(
|
||||||
|
pendingRound,
|
||||||
|
pendingRound.beatmap_a,
|
||||||
|
pendingRound.beatmap_b,
|
||||||
|
);
|
||||||
|
const rounds = await db.game_rounds.findAll({
|
||||||
|
where: {
|
||||||
|
game_sessionId: session.id,
|
||||||
|
},
|
||||||
|
order: [['round_index', 'ASC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const sessionSummary = buildSessionSummary(session, rounds);
|
||||||
|
|
||||||
|
let nextRoundPayload = null;
|
||||||
|
|
||||||
|
if (nextStatus === 'active') {
|
||||||
|
const { round, beatmapA, beatmapB } = await createRoundForSession(session, {
|
||||||
|
transaction,
|
||||||
|
excludeBeatmapIds: [
|
||||||
|
pendingRound.beatmap_a?.id,
|
||||||
|
pendingRound.beatmap_b?.id,
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
nextRoundPayload = buildActiveSessionPayload(session, round, beatmapA, beatmapB);
|
||||||
|
} else {
|
||||||
|
await recordLeaderboardEntry(session, currentUser, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolution,
|
||||||
|
sessionSummary,
|
||||||
|
nextRound: nextRoundPayload,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async syncExpiredActiveSession(currentUser) {
|
||||||
|
const activeSession = await db.game_sessions.findOne({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'active',
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [['started_at', 'DESC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRound = await getPendingRound(activeSession.id);
|
||||||
|
|
||||||
|
if (!pendingRound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = new Date(
|
||||||
|
new Date(pendingRound.presented_at).getTime() +
|
||||||
|
toNumber(pendingRound.time_limit_seconds) * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Date.now() > deadline.getTime()) {
|
||||||
|
await this.answerRound(
|
||||||
|
{
|
||||||
|
sessionId: activeSession.id,
|
||||||
|
choice: 'none',
|
||||||
|
},
|
||||||
|
currentUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getOverview(currentUser) {
|
||||||
|
await ensureDemoDataset();
|
||||||
|
await this.syncExpiredActiveSession(currentUser);
|
||||||
|
|
||||||
|
const [activeSession, recentSessions, leaderboardEntries, beatmapCount, lastSyncedAt] =
|
||||||
|
await Promise.all([
|
||||||
|
db.game_sessions.findOne({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'active',
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [['started_at', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.game_sessions.findAll({
|
||||||
|
where: {
|
||||||
|
userId: currentUser.id,
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.game_rounds,
|
||||||
|
as: 'game_rounds_game_session',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['started_at', 'DESC']],
|
||||||
|
limit: RECENT_SESSION_LIMIT,
|
||||||
|
}),
|
||||||
|
db.leaderboard_entries.findAll({
|
||||||
|
where: {
|
||||||
|
scope: 'global',
|
||||||
|
scope_key: LEADERBOARD_SCOPE_KEY,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'user',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
['score_value', 'DESC'],
|
||||||
|
['best_streak', 'DESC'],
|
||||||
|
['recorded_at', 'ASC'],
|
||||||
|
],
|
||||||
|
limit: LEADERBOARD_LIMIT,
|
||||||
|
}),
|
||||||
|
db.beatmaps.count({
|
||||||
|
where: {
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: {
|
||||||
|
[Op.in]: ALLOWED_STATUSES,
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.beatmaps.max('last_synced_at', {
|
||||||
|
where: {
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: {
|
||||||
|
[Op.in]: ALLOWED_STATUSES,
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let activeSessionPayload = null;
|
||||||
|
|
||||||
|
if (activeSession) {
|
||||||
|
const pendingRound = await getPendingRound(activeSession.id);
|
||||||
|
|
||||||
|
if (pendingRound) {
|
||||||
|
activeSessionPayload = buildActiveSessionPayload(
|
||||||
|
activeSession,
|
||||||
|
pendingRound,
|
||||||
|
pendingRound.beatmap_a,
|
||||||
|
pendingRound.beatmap_b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSession: activeSessionPayload,
|
||||||
|
recentSessions: recentSessions.map((session) =>
|
||||||
|
buildSessionSummary(session, session.game_rounds_game_session || []),
|
||||||
|
),
|
||||||
|
leaderboard: leaderboardEntries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
playerName: getUserDisplayName(entry.user),
|
||||||
|
scoreValue: entry.score_value,
|
||||||
|
bestStreak: entry.best_streak,
|
||||||
|
gamesPlayed: entry.games_played,
|
||||||
|
modeSetting: entry.mode_setting,
|
||||||
|
timerProfile: entry.timer_profile,
|
||||||
|
recordedAt: entry.recorded_at,
|
||||||
|
})),
|
||||||
|
dataset: {
|
||||||
|
beatmapCount,
|
||||||
|
lastSyncedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSessionDetails(sessionId, currentUser) {
|
||||||
|
if (!sessionId) {
|
||||||
|
throw createRequestError('A session id is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await db.game_sessions.findOne({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
session_key: {
|
||||||
|
[Op.like]: `${SESSION_KEY_PREFIX}%`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.game_rounds,
|
||||||
|
as: 'game_rounds_game_session',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.beatmaps,
|
||||||
|
as: 'beatmap_a',
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.beatmaps,
|
||||||
|
as: 'beatmap_b',
|
||||||
|
include: getBeatmapInclude(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [[{ model: db.game_rounds, as: 'game_rounds_game_session' }, 'round_index', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw createRequestError('Game session not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounds = session.game_rounds_game_session || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: buildSessionSummary(session, rounds),
|
||||||
|
rounds: rounds.map((round) => ({
|
||||||
|
id: round.id,
|
||||||
|
roundIndex: round.round_index,
|
||||||
|
result: round.result,
|
||||||
|
playerChoice: round.player_choice || 'none',
|
||||||
|
correctChoice: round.correct_choice,
|
||||||
|
resolvedMode: round.resolved_mode,
|
||||||
|
comparedLabel: getMetricLabel(round.resolved_mode),
|
||||||
|
timeLimitSeconds: toNumber(round.time_limit_seconds),
|
||||||
|
presentedAt: round.presented_at,
|
||||||
|
answeredAt: round.answered_at,
|
||||||
|
isSuspectedCheat: round.is_suspected_cheat,
|
||||||
|
suspicionReason: round.suspicion_reason,
|
||||||
|
cards: {
|
||||||
|
a: serializeBeatmapCard(round.beatmap_a),
|
||||||
|
b: serializeBeatmapCard(round.beatmap_b),
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
a: getComparisonValue(round.beatmap_a, round.resolved_mode),
|
||||||
|
b: getComparisonValue(round.beatmap_b, round.resolved_mode),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
467
backend/src/services/gameplayDemoData.js
Normal file
467
backend/src/services/gameplayDemoData.js
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
const createdAt = new Date('2026-05-06T08:00:00.000Z');
|
||||||
|
const updatedAt = new Date('2026-05-06T08:00:00.000Z');
|
||||||
|
const rankedOrLovedAt = new Date('2026-05-06T08:00:00.000Z');
|
||||||
|
const syncedAt = new Date('2026-05-06T08:00:00.000Z');
|
||||||
|
|
||||||
|
const mapperIds = {
|
||||||
|
bebe: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b000',
|
||||||
|
hinae: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b001',
|
||||||
|
mattay: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b002',
|
||||||
|
ajmosca: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b003',
|
||||||
|
epicDisaster: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b004',
|
||||||
|
buddon: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b005',
|
||||||
|
sheepcraft: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b006',
|
||||||
|
dumii: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b007',
|
||||||
|
};
|
||||||
|
|
||||||
|
const artistIds = {
|
||||||
|
camellia: '98e177af-8f8b-46b0-8d0a-8bf9146b8100',
|
||||||
|
fantasticYouth: '98e177af-8f8b-46b0-8d0a-8bf9146b8101',
|
||||||
|
sei: '98e177af-8f8b-46b0-8d0a-8bf9146b8102',
|
||||||
|
blackY: '98e177af-8f8b-46b0-8d0a-8bf9146b8103',
|
||||||
|
passCode: '98e177af-8f8b-46b0-8d0a-8bf9146b8104',
|
||||||
|
imaiAsami: '98e177af-8f8b-46b0-8d0a-8bf9146b8105',
|
||||||
|
eisyoKobu: '98e177af-8f8b-46b0-8d0a-8bf9146b8106',
|
||||||
|
};
|
||||||
|
|
||||||
|
const beatmapSetIds = {
|
||||||
|
attackFromMandrake: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9200',
|
||||||
|
gedosanka: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9201',
|
||||||
|
blueAmber: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9202',
|
||||||
|
imitator: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9203',
|
||||||
|
sparkIgnition: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9204',
|
||||||
|
starmine: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9205',
|
||||||
|
machinegunPsystyle: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9206',
|
||||||
|
faithtival: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9207',
|
||||||
|
};
|
||||||
|
|
||||||
|
const beatmapIds = {
|
||||||
|
attackFromMandrake: '3a13d892-48d0-48de-a2a8-cc48ab73d100',
|
||||||
|
gedosanka: '3a13d892-48d0-48de-a2a8-cc48ab73d101',
|
||||||
|
blueAmber: '3a13d892-48d0-48de-a2a8-cc48ab73d102',
|
||||||
|
imitator: '3a13d892-48d0-48de-a2a8-cc48ab73d103',
|
||||||
|
sparkIgnition: '3a13d892-48d0-48de-a2a8-cc48ab73d104',
|
||||||
|
starmine: '3a13d892-48d0-48de-a2a8-cc48ab73d105',
|
||||||
|
machinegunPsystyle: '3a13d892-48d0-48de-a2a8-cc48ab73d106',
|
||||||
|
faithtival: '3a13d892-48d0-48de-a2a8-cc48ab73d107',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappers = [
|
||||||
|
{
|
||||||
|
id: mapperIds.bebe,
|
||||||
|
osu_user_numeric: 5057420,
|
||||||
|
osu_username: 'bebe',
|
||||||
|
avatar_url: 'https://a.ppy.sh/5057420',
|
||||||
|
total_ranked_loved_playcount: 1146000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-bebe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.hinae,
|
||||||
|
osu_user_numeric: 11864462,
|
||||||
|
osu_username: 'Hinae',
|
||||||
|
avatar_url: 'https://a.ppy.sh/11864462',
|
||||||
|
total_ranked_loved_playcount: 1982000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-hinae',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.mattay,
|
||||||
|
osu_user_numeric: 9748303,
|
||||||
|
osu_username: 'Mattay',
|
||||||
|
avatar_url: 'https://a.ppy.sh/9748303',
|
||||||
|
total_ranked_loved_playcount: 1564000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-mattay',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.ajmosca,
|
||||||
|
osu_user_numeric: 19884809,
|
||||||
|
osu_username: 'ajmosca',
|
||||||
|
avatar_url: 'https://a.ppy.sh/19884809',
|
||||||
|
total_ranked_loved_playcount: 5316000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-ajmosca',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.epicDisaster,
|
||||||
|
osu_user_numeric: 18480245,
|
||||||
|
osu_username: 'EpicDisaster',
|
||||||
|
avatar_url: 'https://a.ppy.sh/18480245',
|
||||||
|
total_ranked_loved_playcount: 2849000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-epic-disaster',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.buddon,
|
||||||
|
osu_user_numeric: 12330007,
|
||||||
|
osu_username: 'buddon',
|
||||||
|
avatar_url: 'https://a.ppy.sh/12330007',
|
||||||
|
total_ranked_loved_playcount: 3095000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-buddon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.sheepcraft,
|
||||||
|
osu_user_numeric: 596806,
|
||||||
|
osu_username: 'Sheepcraft',
|
||||||
|
avatar_url: 'https://a.ppy.sh/596806',
|
||||||
|
total_ranked_loved_playcount: 7482000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-sheepcraft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mapperIds.dumii,
|
||||||
|
osu_user_numeric: 3068044,
|
||||||
|
osu_username: 'Dumii',
|
||||||
|
avatar_url: 'https://a.ppy.sh/3068044',
|
||||||
|
total_ranked_loved_playcount: 3827000,
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-mapper-dumii',
|
||||||
|
},
|
||||||
|
].map((mapper) => ({
|
||||||
|
...mapper,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const artists = [
|
||||||
|
{
|
||||||
|
id: artistIds.camellia,
|
||||||
|
name: 'Camellia',
|
||||||
|
normalized_name: 'camellia',
|
||||||
|
total_ranked_loved_playcount: 5862000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-camellia',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.fantasticYouth,
|
||||||
|
name: 'FantasticYouth',
|
||||||
|
normalized_name: 'fantasticyouth',
|
||||||
|
total_ranked_loved_playcount: 1884000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-fantasticyouth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.sei,
|
||||||
|
name: 'Sei',
|
||||||
|
normalized_name: 'sei',
|
||||||
|
total_ranked_loved_playcount: 1297000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-sei',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.blackY,
|
||||||
|
name: 'BlackY feat. Risa Yuzuki',
|
||||||
|
normalized_name: 'blacky-feat-risa-yuzuki',
|
||||||
|
total_ranked_loved_playcount: 4125000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-blacky-risa-yuzuki',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.passCode,
|
||||||
|
name: 'PassCode',
|
||||||
|
normalized_name: 'passcode',
|
||||||
|
total_ranked_loved_playcount: 2410000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-passcode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.imaiAsami,
|
||||||
|
name: 'Imai Asami',
|
||||||
|
normalized_name: 'imai-asami',
|
||||||
|
total_ranked_loved_playcount: 2289000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-imai-asami',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: artistIds.eisyoKobu,
|
||||||
|
name: 'Eisyo-kobu',
|
||||||
|
normalized_name: 'eisyo-kobu',
|
||||||
|
total_ranked_loved_playcount: 3643000,
|
||||||
|
image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
|
||||||
|
image_source: 'beatmap_background_fallback',
|
||||||
|
last_aggregated_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-artist-eisyo-kobu',
|
||||||
|
},
|
||||||
|
].map((artist) => ({
|
||||||
|
...artist,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const beatmapSets = [
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.attackFromMandrake,
|
||||||
|
osu_beatmapset_numeric: 2526285,
|
||||||
|
title: 'ATTACK FROM MANDRAKE',
|
||||||
|
artist_name: 'Camellia',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-attack-from-mandrake',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.gedosanka,
|
||||||
|
osu_beatmapset_numeric: 2340531,
|
||||||
|
title: 'Gedosanka',
|
||||||
|
artist_name: 'FantasticYouth',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-gedosanka',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.blueAmber,
|
||||||
|
osu_beatmapset_numeric: 2220326,
|
||||||
|
title: 'Blue Amber',
|
||||||
|
artist_name: 'Sei',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-blue-amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.imitator,
|
||||||
|
osu_beatmapset_numeric: 2268000,
|
||||||
|
title: 'Imitator',
|
||||||
|
artist_name: 'BlackY feat. Risa Yuzuki',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-imitator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.sparkIgnition,
|
||||||
|
osu_beatmapset_numeric: 2461085,
|
||||||
|
title: 'SPARK IGNITION',
|
||||||
|
artist_name: 'PassCode',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-spark-ignition',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.starmine,
|
||||||
|
osu_beatmapset_numeric: 1912968,
|
||||||
|
title: 'Asayake no Starmine (TV Size)',
|
||||||
|
artist_name: 'Imai Asami',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
|
||||||
|
status: 'ranked',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-starmine',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.machinegunPsystyle,
|
||||||
|
osu_beatmapset_numeric: 1030023,
|
||||||
|
title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)',
|
||||||
|
artist_name: 'Camellia',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
|
||||||
|
status: 'loved',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-machinegun-psystyle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapSetIds.faithtival,
|
||||||
|
osu_beatmapset_numeric: 1040472,
|
||||||
|
title: 'Faithtival',
|
||||||
|
artist_name: 'Eisyo-kobu',
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
|
||||||
|
status: 'loved',
|
||||||
|
ranked_or_loved_at: rankedOrLovedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-set-faithtival',
|
||||||
|
},
|
||||||
|
].map((beatmapSet) => ({
|
||||||
|
...beatmapSet,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const beatmaps = [
|
||||||
|
{
|
||||||
|
id: beatmapIds.attackFromMandrake,
|
||||||
|
osu_beatmap_numeric: 5580520,
|
||||||
|
title: 'ATTACK FROM MANDRAKE',
|
||||||
|
version: 'CHALLENGE',
|
||||||
|
artist_name: 'Camellia',
|
||||||
|
artistId: artistIds.camellia,
|
||||||
|
mapperId: mapperIds.bebe,
|
||||||
|
beatmap_setId: beatmapSetIds.attackFromMandrake,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 244,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-attack-from-mandrake',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.gedosanka,
|
||||||
|
osu_beatmap_numeric: 5028496,
|
||||||
|
title: 'Gedosanka',
|
||||||
|
version: 'Illusion',
|
||||||
|
artist_name: 'FantasticYouth',
|
||||||
|
artistId: artistIds.fantasticYouth,
|
||||||
|
mapperId: mapperIds.hinae,
|
||||||
|
beatmap_setId: beatmapSetIds.gedosanka,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 317,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-gedosanka',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.blueAmber,
|
||||||
|
osu_beatmap_numeric: 4706536,
|
||||||
|
title: 'Blue Amber',
|
||||||
|
version: 'Extra',
|
||||||
|
artist_name: 'Sei',
|
||||||
|
artistId: artistIds.sei,
|
||||||
|
mapperId: mapperIds.mattay,
|
||||||
|
beatmap_setId: beatmapSetIds.blueAmber,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 163,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-blue-amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.imitator,
|
||||||
|
osu_beatmap_numeric: 4829849,
|
||||||
|
title: 'Imitator',
|
||||||
|
version: 'Mirror Image',
|
||||||
|
artist_name: 'BlackY feat. Risa Yuzuki',
|
||||||
|
artistId: artistIds.blackY,
|
||||||
|
mapperId: mapperIds.ajmosca,
|
||||||
|
beatmap_setId: beatmapSetIds.imitator,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 680,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-imitator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.sparkIgnition,
|
||||||
|
osu_beatmap_numeric: 5561376,
|
||||||
|
title: 'SPARK IGNITION',
|
||||||
|
version: 'Forget All Your Memories, Take My Hand and Fly Away',
|
||||||
|
artist_name: 'PassCode',
|
||||||
|
artistId: artistIds.passCode,
|
||||||
|
mapperId: mapperIds.epicDisaster,
|
||||||
|
beatmap_setId: beatmapSetIds.sparkIgnition,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 717,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-spark-ignition',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.starmine,
|
||||||
|
osu_beatmap_numeric: 5473215,
|
||||||
|
title: 'Asayake no Starmine (TV Size)',
|
||||||
|
version: 'Katami',
|
||||||
|
artist_name: 'Imai Asami',
|
||||||
|
artistId: artistIds.imaiAsami,
|
||||||
|
mapperId: mapperIds.buddon,
|
||||||
|
beatmap_setId: beatmapSetIds.starmine,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'ranked',
|
||||||
|
playcount: 746,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-starmine',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.machinegunPsystyle,
|
||||||
|
osu_beatmap_numeric: 2153775,
|
||||||
|
title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)',
|
||||||
|
version: 'Frick',
|
||||||
|
artist_name: 'Camellia',
|
||||||
|
artistId: artistIds.camellia,
|
||||||
|
mapperId: mapperIds.sheepcraft,
|
||||||
|
beatmap_setId: beatmapSetIds.machinegunPsystyle,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'loved',
|
||||||
|
playcount: 57088,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-machinegun-psystyle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: beatmapIds.faithtival,
|
||||||
|
osu_beatmap_numeric: 2216966,
|
||||||
|
title: 'Faithtival',
|
||||||
|
version: 'Petal',
|
||||||
|
artist_name: 'Eisyo-kobu',
|
||||||
|
artistId: artistIds.eisyoKobu,
|
||||||
|
mapperId: mapperIds.dumii,
|
||||||
|
beatmap_setId: beatmapSetIds.faithtival,
|
||||||
|
mode: 'osu_standard',
|
||||||
|
status: 'loved',
|
||||||
|
playcount: 701,
|
||||||
|
background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
|
||||||
|
last_synced_at: syncedAt,
|
||||||
|
is_active: true,
|
||||||
|
importHash: 'osu-hl-demo-beatmap-faithtival',
|
||||||
|
},
|
||||||
|
].map((beatmap) => ({
|
||||||
|
...beatmap,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapperIds,
|
||||||
|
artistIds,
|
||||||
|
beatmapSetIds,
|
||||||
|
beatmapIds,
|
||||||
|
mappers,
|
||||||
|
artists,
|
||||||
|
beatmapSets,
|
||||||
|
beatmaps,
|
||||||
|
};
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/play',
|
||||||
|
label: 'Play',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiControllerClassic' in icon ? icon['mdiControllerClassic' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_GAME_SESSIONS'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
@ -1,166 +1,186 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
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 CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const heroCovers = [
|
||||||
|
'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
|
||||||
|
'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
|
||||||
|
'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
const modes = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
{
|
||||||
src: undefined,
|
name: 'Beatmap',
|
||||||
photographer: undefined,
|
description: 'Guess which beatmap has the higher hidden playcount.',
|
||||||
photographer_url: undefined,
|
},
|
||||||
})
|
{
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
name: 'Mapper',
|
||||||
const [contentType, setContentType] = useState('image');
|
description: 'Guess using total ranked + loved plays across a mapper catalogue.',
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
},
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
{
|
||||||
|
name: 'Artist',
|
||||||
|
description: 'Guess using total ranked + loved plays aggregated by artist.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mixed',
|
||||||
|
description: 'The server hides the metric until after you lock in your answer.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const title = 'App Preview'
|
const rules = [
|
||||||
|
'Two large cards. One guess. Server-side answer only.',
|
||||||
// Fetch Pexels image/video
|
'2 lives total. Wrong answer or timeout costs 1 life.',
|
||||||
useEffect(() => {
|
'5–8 second rounds. No waiting room between reveals.',
|
||||||
async function fetchData() {
|
'osu!standard only. Ranked and Loved data only.',
|
||||||
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
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>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Fast-paced competitive higher-lower game using osu! beatmap stats with anti-cheat, instant rounds, and minimal UI."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
<main className="min-h-screen bg-[#0b0d11] text-slate-100">
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-6 py-6 sm:px-8 lg:px-10">
|
||||||
<div
|
<header className="flex items-center justify-between border-b border-white/10 pb-5">
|
||||||
className={`flex ${
|
<div>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">osu! higher lower</p>
|
||||||
} min-h-screen w-full`}
|
<h1 className="mt-2 text-lg font-semibold text-white">Minimal, fast, anti-cheat by default.</h1>
|
||||||
>
|
|
||||||
{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 App Preview app!"/>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<BaseButtons>
|
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||||
<BaseButton
|
<BaseButton href="/dashboard" label="Admin" color="info" />
|
||||||
href='/login'
|
</div>
|
||||||
label='Login'
|
</header>
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<section className="grid flex-1 items-center gap-12 py-14 lg:grid-cols-[1.05fr_0.95fr] lg:py-20">
|
||||||
</CardBox>
|
<div className="max-w-3xl">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300">
|
||||||
|
2 lives • hidden mixed mode • osu! assets only
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-8 text-4xl font-semibold leading-tight text-white sm:text-5xl lg:text-6xl">
|
||||||
|
A competitive browser game built around osu! beatmap popularity.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
|
||||||
|
Each round shows two beatmap cards. The backend picks the metric,
|
||||||
|
keeps every number hidden until you answer, and resolves the guess
|
||||||
|
instantly so the pace never drops.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<BaseButton href="/play" label="Play now" color="info" className="justify-center px-6" />
|
||||||
|
<BaseButton href="/login" label="Login to compete" color="whiteDark" className="justify-center px-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid gap-3 sm:grid-cols-2">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm leading-6 text-slate-200"
|
||||||
|
>
|
||||||
|
{rule}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 -z-10 rounded-[2rem] bg-[radial-gradient(circle_at_top,_rgba(255,102,170,0.18),_transparent_55%)]" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||||
|
{heroCovers.map((cover, index) => (
|
||||||
|
<div
|
||||||
|
key={cover}
|
||||||
|
className={`overflow-hidden rounded-[1.75rem] border border-white/10 bg-[#151923] shadow-[0_24px_80px_rgba(0,0,0,0.35)] ${
|
||||||
|
index === 0 ? 'sm:col-span-2 xl:col-span-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative bg-slate-900 ${index === 0 ? 'h-64 sm:h-72' : 'h-52'}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(180deg, rgba(9,11,16,0.12), rgba(9,11,16,0.78)), url(${cover})`,
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute left-5 top-5 rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-slate-100">
|
||||||
|
{index === 0 ? 'live round' : 'osu! cover'}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between gap-4 p-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-300">
|
||||||
|
{index === 0 ? 'Hidden metric' : 'Minimal card surface'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{index === 0 ? 'Mode stays server-side until you commit.' : 'Beatmap art does the heavy lifting.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/35 px-4 py-3 text-right">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Timer</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-white">8s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 border-t border-white/10 py-10 lg:grid-cols-[1fr_1.1fr] lg:py-14">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">How it plays</p>
|
||||||
|
<h3 className="mt-4 text-3xl font-semibold text-white">A thin, honest game loop.</h3>
|
||||||
|
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-300">
|
||||||
|
Start a run, survive as many hidden comparisons as you can, review
|
||||||
|
the reveal, then jump straight into the next round. The admin UI is
|
||||||
|
still available for inspecting beatmaps, mappers, artists, sessions,
|
||||||
|
and logs without rebuilding any generic CRUD.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<div
|
||||||
|
key={mode.name}
|
||||||
|
className="rounded-[1.5rem] border border-white/10 bg-white/5 p-5"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-[#ff66aa]">
|
||||||
|
{mode.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-300">{mode.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="flex flex-col gap-4 border-t border-white/10 py-6 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p>Built for instant rounds, clean visuals, and server-side fairness.</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link className="hover:text-white" href="/login">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link className="hover:text-white" href="/dashboard">
|
||||||
|
Admin interface
|
||||||
|
</Link>
|
||||||
|
<Link className="hover:text-white" href="/play">
|
||||||
|
Play
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</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) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
388
frontend/src/pages/play/[gameSessionId].tsx
Normal file
388
frontend/src/pages/play/[gameSessionId].tsx
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
|
||||||
|
type ModeSetting = 'beatmap' | 'mapper' | 'artist' | 'mixed';
|
||||||
|
type TimerProfile = 'normal' | 'insane';
|
||||||
|
type RoundResult = 'pending' | 'correct' | 'wrong' | 'timeout';
|
||||||
|
|
||||||
|
type SessionSummary = {
|
||||||
|
id: string;
|
||||||
|
sessionKey: string;
|
||||||
|
status: string;
|
||||||
|
modeSetting: ModeSetting;
|
||||||
|
timerProfile: TimerProfile;
|
||||||
|
startingLives: number;
|
||||||
|
livesRemaining: number;
|
||||||
|
currentStreak: number;
|
||||||
|
bestStreak: number;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
totalRounds: number;
|
||||||
|
correctAnswers: number;
|
||||||
|
wrongAnswers: number;
|
||||||
|
timeoutCount: number;
|
||||||
|
suspiciousRounds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoundCard = {
|
||||||
|
id: string;
|
||||||
|
beatmapId: number;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
artistName: string;
|
||||||
|
mapperName: string;
|
||||||
|
mapperAvatarUrl: string;
|
||||||
|
backgroundImageUrl: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoundDetail = {
|
||||||
|
id: string;
|
||||||
|
roundIndex: number;
|
||||||
|
result: RoundResult;
|
||||||
|
playerChoice: 'a' | 'b' | 'none';
|
||||||
|
correctChoice: 'a' | 'b';
|
||||||
|
resolvedMode: 'beatmap' | 'mapper' | 'artist';
|
||||||
|
comparedLabel: string;
|
||||||
|
timeLimitSeconds: number;
|
||||||
|
presentedAt: string;
|
||||||
|
answeredAt: string | null;
|
||||||
|
isSuspectedCheat: boolean;
|
||||||
|
suspicionReason: string | null;
|
||||||
|
cards: {
|
||||||
|
a: RoundCard;
|
||||||
|
b: RoundCard;
|
||||||
|
};
|
||||||
|
values: {
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionDetailPayload = {
|
||||||
|
session: SessionSummary;
|
||||||
|
rounds: RoundDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||||
|
|
||||||
|
function formatModeLabel(value: ModeSetting) {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimerLabel(value: TimerProfile) {
|
||||||
|
return value === 'insane' ? 'Insane · 6s' : 'Normal · 8s';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (typeof error.response?.data === 'string') {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load the session details.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultBadge(result: RoundResult) {
|
||||||
|
if (result === 'correct') {
|
||||||
|
return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'timeout') {
|
||||||
|
return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'wrong') {
|
||||||
|
return 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-slate-200 bg-slate-50 text-slate-800 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const sessionId = useMemo(() => {
|
||||||
|
if (typeof router.query.gameSessionId === 'string') {
|
||||||
|
return router.query.gameSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}, [router.query.gameSessionId]);
|
||||||
|
const [payload, setPayload] = useState<SessionDetailPayload | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const fetchSessionDetails = useCallback(async () => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<SessionDetailPayload>(`/gameplay/sessions/${sessionId}`);
|
||||||
|
|
||||||
|
setPayload(data);
|
||||||
|
setErrorMessage('');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to load Osu Higher Lower session details:', error);
|
||||||
|
setErrorMessage(extractErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchSessionDetails();
|
||||||
|
}, [fetchSessionDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Session details')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||||
|
<section className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-dark-700 dark:bg-dark-900 sm:p-8">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Round review</p>
|
||||||
|
<h1 className="mt-3 text-3xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
Session details
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-500 dark:text-slate-300">
|
||||||
|
Inspect every reveal from the run: the resolved metric, the hidden values,
|
||||||
|
your guess, and any suspicious-late answers flagged by the backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<BaseButton href="/play" label="Back to play" color="whiteDark" />
|
||||||
|
<BaseButton href="/game_sessions/game_sessions-list" label="Open admin sessions" color="info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="rounded-[1.5rem] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-900 dark:border-rose-900/60 dark:bg-rose-950/30 dark:text-rose-200">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-[2rem] border border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : payload ? (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Mode</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{formatModeLabel(payload.session.modeSetting)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
{formatTimerLabel(payload.session.timerProfile)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Score</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{payload.session.correctAnswers}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
correct rounds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Best streak</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{payload.session.bestStreak}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
highest uninterrupted run
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Rounds</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{payload.session.totalRounds}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
finished comparisons
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Started</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{dateFormatter.format(new Date(payload.session.startedAt))}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
{payload.session.endedAt
|
||||||
|
? `ended ${dateFormatter.format(new Date(payload.session.endedAt))}`
|
||||||
|
: 'still in progress'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-dark-700 dark:bg-dark-900">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Round log</p>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{payload.rounds.length} revealed rounds
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{payload.session.suspiciousRounds ? (
|
||||||
|
<div className="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{payload.session.suspiciousRounds} suspicious answer
|
||||||
|
{payload.session.suspiciousRounds === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-5">
|
||||||
|
{payload.rounds.map((round) => (
|
||||||
|
<article
|
||||||
|
key={round.id}
|
||||||
|
className="rounded-[1.75rem] border border-slate-200 bg-slate-50 p-5 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">
|
||||||
|
Round {round.roundIndex}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
{round.comparedLabel}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-300">
|
||||||
|
Presented {dateFormatter.format(new Date(round.presentedAt))}
|
||||||
|
{round.answeredAt
|
||||||
|
? ` · answered ${dateFormatter.format(new Date(round.answeredAt))}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
<span className={`rounded-full border px-3 py-1 ${getResultBadge(round.result)}`}>
|
||||||
|
{round.result}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-slate-200 px-3 py-1 text-slate-600 dark:border-dark-700 dark:text-slate-300">
|
||||||
|
picked {round.playerChoice.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-slate-200 px-3 py-1 text-slate-600 dark:border-dark-700 dark:text-slate-300">
|
||||||
|
correct {round.correctChoice.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||||
|
{(['a', 'b'] as const).map((choice) => {
|
||||||
|
const card = round.cards[choice];
|
||||||
|
const value = round.values[choice];
|
||||||
|
const isWinner = round.correctChoice === choice;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${round.id}-${choice}`}
|
||||||
|
className="overflow-hidden rounded-[1.5rem] border border-slate-200 bg-slate-950 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative bg-slate-950"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(180deg, rgba(8,10,14,0.10), rgba(8,10,14,0.82)), url(${card.backgroundImageUrl})`,
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-5 text-white">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white">
|
||||||
|
{choice}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs uppercase tracking-[0.24em] ${
|
||||||
|
isWinner
|
||||||
|
? 'border-emerald-200/40 bg-emerald-500/20 text-emerald-100'
|
||||||
|
: 'border-white/15 bg-black/35 text-white/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isWinner ? 'higher value' : card.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-24">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-300">Artist</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-100">{card.artistName}</p>
|
||||||
|
<h4 className="mt-4 text-2xl font-semibold text-white">{card.title}</h4>
|
||||||
|
<p className="mt-2 text-sm text-slate-200">{card.version}</p>
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 overflow-hidden rounded-full border border-white/20 bg-black/30">
|
||||||
|
<img
|
||||||
|
src={card.mapperAvatarUrl}
|
||||||
|
alt={`${card.mapperName} avatar`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-300">Mapper</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-white">{card.mapperName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 rounded-[1.25rem] border border-white/15 bg-black/35 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-300">
|
||||||
|
{round.comparedLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">
|
||||||
|
{numberFormatter.format(value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.isSuspectedCheat && round.suspicionReason ? (
|
||||||
|
<div className="mt-4 rounded-[1.25rem] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{round.suspicionReason}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[2rem] border border-dashed border-slate-300 bg-white px-6 py-12 text-center text-sm text-slate-500 dark:border-dark-700 dark:bg-dark-900 dark:text-slate-300">
|
||||||
|
No session detail available yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionDetailPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
1014
frontend/src/pages/play/index.tsx
Normal file
1014
frontend/src/pages/play/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user