This commit is contained in:
Flatlogic Bot 2026-05-06 08:07:42 +00:00
parent 8bfa911b12
commit e2c177190b
11 changed files with 3019 additions and 151 deletions

View File

@ -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) });
},
};

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -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);

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

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

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

View File

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

View File

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

View File

@ -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',

View File

@ -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.',
'58 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>;
};

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

File diff suppressed because it is too large Load Diff