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 fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -36,6 +35,7 @@ const beatmap_setsRoutes = require('./routes/beatmap_sets');
|
||||
const beatmapsRoutes = require('./routes/beatmaps');
|
||||
|
||||
const game_sessionsRoutes = require('./routes/game_sessions');
|
||||
const gameplayRoutes = require('./routes/gameplay');
|
||||
|
||||
const game_roundsRoutes = require('./routes/game_rounds');
|
||||
|
||||
@ -118,6 +118,7 @@ app.use('/api/beatmap_sets', passport.authenticate('jwt', {session: false}), bea
|
||||
app.use('/api/beatmaps', passport.authenticate('jwt', {session: false}), beatmapsRoutes);
|
||||
|
||||
app.use('/api/game_sessions', passport.authenticate('jwt', {session: false}), game_sessionsRoutes);
|
||||
app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
|
||||
|
||||
app.use('/api/game_rounds', passport.authenticate('jwt', {session: false}), game_roundsRoutes);
|
||||
|
||||
|
||||
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 { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/play',
|
||||
label: 'Play',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiControllerClassic' in icon ? icon['mdiControllerClassic' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_GAME_SESSIONS'
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
@ -1,166 +1,186 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const heroCovers = [
|
||||
'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
|
||||
'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
|
||||
'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const modes = [
|
||||
{
|
||||
name: 'Beatmap',
|
||||
description: 'Guess which beatmap has the higher hidden playcount.',
|
||||
},
|
||||
{
|
||||
name: 'Mapper',
|
||||
description: 'Guess using total ranked + loved plays across a mapper catalogue.',
|
||||
},
|
||||
{
|
||||
name: 'Artist',
|
||||
description: 'Guess using total ranked + loved plays aggregated by artist.',
|
||||
},
|
||||
{
|
||||
name: 'Mixed',
|
||||
description: 'The server hides the metric until after you lock in your answer.',
|
||||
},
|
||||
];
|
||||
|
||||
const title = 'App Preview'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<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>)
|
||||
}
|
||||
};
|
||||
const rules = [
|
||||
'Two large cards. One guess. Server-side answer only.',
|
||||
'2 lives total. Wrong answer or timeout costs 1 life.',
|
||||
'5–8 second rounds. No waiting room between reveals.',
|
||||
'osu!standard only. Ranked and Loved data only.',
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<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>
|
||||
<meta
|
||||
name="description"
|
||||
content="Fast-paced competitive higher-lower game using osu! beatmap stats with anti-cheat, instant rounds, and minimal UI."
|
||||
/>
|
||||
</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 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>
|
||||
<main className="min-h-screen bg-[#0b0d11] text-slate-100">
|
||||
<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">
|
||||
<header className="flex items-center justify-between border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">osu! higher lower</p>
|
||||
<h1 className="mt-2 text-lg font-semibold text-white">Minimal, fast, anti-cheat by default.</h1>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||
<BaseButton href="/dashboard" label="Admin" color="info" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<section className="grid flex-1 items-center gap-12 py-14 lg:grid-cols-[1.05fr_0.95fr] lg:py-20">
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
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