fix: improve ongeki ux, improve ranking backend

This commit is contained in:
Raymond
2026-06-13 23:04:22 -04:00
parent 08bf69c723
commit 32c0a8745c
11 changed files with 89 additions and 95 deletions

View File

@@ -81,8 +81,7 @@ export interface GenericRanking {
rating: number
fullCombo: number
allPerfect: number
lastSeen: string,
modernRating: number
lastSeen: string
}
export interface RankCount {
@@ -97,7 +96,8 @@ export interface GenericGameSummary {
serverRank: number
accuracy: number
rating: number
ratingHighest: number
ratingHighest: number,
ratingNotGeneric: boolean,
ranks: RankCount[]
detailedRanks: { [key: number]: { [key: string]: number } }
maxCombo: number

View File

@@ -2,8 +2,8 @@ export const EN_REF_USER = {
'UserHome.ServerRank': 'Server Rank',
'UserHome.DXRating': 'DX Rating',
'UserHome.Rating': 'Rating',
'UserHome.LegacyRating': 'Legacy Rating',
'UserHome.ModernRating': 'Modern Rating',
'UserHome.HighestDXRating': 'Peak DX Rating',
'UserHome.HighestRating': 'Peak Rating',
'UserHome.Statistics': 'Statistics',
'UserHome.Accuracy': 'Accuracy',
'UserHome.MaxCombo': 'Max Combo',

View File

@@ -14,9 +14,9 @@ import {
const zhUser: typeof EN_REF_USER = {
'UserHome.ServerRank': '服务器排名',
'UserHome.DXRating': 'DX B50',
'UserHome.Rating': '评分',
'UserHome.LegacyRating': '旧评分',
'UserHome.ModernRating': '评分',
'UserHome.Rating': '最高评分',
'UserHome.HighestDXRating': 'DX B50',
'UserHome.HighestRating': '最高评分',
'UserHome.Statistics': '统计数据',
'UserHome.Accuracy': '准确率',
'UserHome.MaxCombo': '最大连击',

View File

@@ -22,8 +22,3 @@ export interface OngekiUserSummaryEntry {
recent10: string
recent: GenericGamePlaylog[]
}
export interface OngekiRefreshData {
playerRating: number,
highestRating: number
}

View File

@@ -12,7 +12,6 @@ import type {
Dict,
GameUserOption
} from './generalTypes'
import type { OngekiRefreshData } from './ongekiTypes'
import type { GameName } from './scoring'
interface ExtReqInit extends RequestInit {
@@ -245,8 +244,6 @@ export const GAME = {
post(`/api/v2/game/${game}/import-music-detail`, {}, { json: data }),
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
refreshData: (username: string): Promise<OngekiRefreshData> =>
post(`/api/v2/game/ongeki/refresh-data`, { username })
}
export const DATA = {
@@ -263,9 +260,9 @@ export const SETTING = {
post('/api/v2/settings/set', { key, value: `${value}` }),
detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
optionGet: (game: string): Promise<GameUserOption> =>
optionGet: (game: string): Promise<GameUserOption> =>
post(`/api/v2/game/${game}/user-option`),
optionSet: (game: string, field: string, value: number): Promise<void> =>
optionSet: (game: string, field: string, value: number): Promise<void> =>
post(`/api/v2/game/${game}/user-option-set`, { field, value }),
}

View File

@@ -52,12 +52,7 @@
<div class="lb-user" on:mouseenter={() => hoveringUser = ""} role="heading" aria-level="2">
<span class="rank">{t("Leaderboard.Rank")}</span>
<span class="name"></span>
{#if game == "ongeki"}
<span class="rating modern-rating">{t("Leaderboard.ModernRating")}</span>
<span class="rating legacy-rating">{t("Leaderboard.LegacyRating")}</span>
{:else}
<span class="rating">{t("Leaderboard.Rating")}</span>
{/if}
<span class="rating">{t("Leaderboard.Rating")}</span>
<span class="accuracy">{t("Leaderboard.Accuracy")}</span>
<span class="fc">{t("Leaderboard.FC")}</span>
<span class="ap">{t("Leaderboard.AP")}</span>
@@ -76,13 +71,8 @@
{/if}
</span>
{#if game == 'ongeki'}
{#if user.modernRating > 0}
<span class="rating modern-rating">{
(user.modernRating / 1000).toFixed(3)
}</span>
{/if}
<span class="rating legacy-rating">{
(user.rating / 100).toFixed(2)
<span class="rating">{
(user.rating / 1000).toFixed(3)
}</span>
{:else}
<span class="rating">{

View File

@@ -24,7 +24,6 @@
import useLocalStorage from "../libs/hooks/useLocalStorage.svelte";
import Line from "../components/chart/Line.svelte";
import ChuniUserboxDisplay from "../components/settings/userbox/ChuniUserboxDisplay.svelte";
import type { OngekiRefreshData } from "../libs/ongekiTypes";
const TREND_DAYS = 60
@@ -35,7 +34,6 @@
let calElement: HTMLElement
let error: string;
let me: AquaNetUser
title(`User ${username}`)
const rounding = useLocalStorage("rounding", true);
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
@@ -52,7 +50,6 @@
let isLoading = false
let showMoreRecent = false
let ongekiData: OngekiRefreshData
function init() {
USER.isLoggedIn() && USER.me().then(u => me = u)
@@ -85,6 +82,8 @@
console.log(trend)
console.log(games)
title(` ${user.name} (@${username}) ・ ${GAME_TITLE[game]}`)
// If game is wacca, divide all ratings by 10
if (game === 'wacca') {
user.rating /= 10
@@ -95,11 +94,6 @@
})
}
if (game == 'ongeki') {
let ogkData = await GAME.refreshData(username).catch(err => {});
if(ogkData) ongekiData = ogkData
}
// Set beforeRating in recent to the last play's afterRating
user.recent.forEach((it, i) => {
if (i < user.recent.length - 1) {
@@ -195,23 +189,19 @@
<div class="scoring-info">
<div class="chart">
<div class="info-top">
{#if game == "ongeki" && ongekiData}
<!-- display modern (refresh) and legacy rating -->
{#if game == "ongeki" && d.user.ratingNotGeneric}
<div class="rating">
<span>{t("UserHome.ModernRating")}</span>
<span>{(ongekiData.playerRating / 1000).toFixed(3)}</span>
</div>
<div class="rating">
<span>{t("UserHome.LegacyRating")}</span>
<span>{(d.user.rating / 100).toFixed(2)}</span>
<span>{t("UserHome.Rating")}</span>
<span>{(d.user.rating / 1000).toFixed(3)}</span>
</div>
{:else}
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
<span>{
game === 'chu3' ?
game === 'chu3' || game === 'ongeki' ?
(d.user.rating / 100).toFixed(2) :
d.user.rating.toLocaleString()
}</span>
@@ -220,7 +210,7 @@
<div class="rank">
<span>{t('UserHome.ServerRank')}</span>
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
<span>#{(d.user.serverRank).toLocaleString()}</span>
</div>
</div>
@@ -269,6 +259,23 @@
<span>{(d.user.accuracy).toFixed(2)}%</span>
</div>
{#if game == "ongeki" && d.user.ratingNotGeneric}
<div class="rating">
<span>{t("UserHome.HighestRating")}</span>
<span>{(d.user.ratingHighest / 1000).toFixed(3)}</span>
</div>
{:else}
<div class="rating">
<span>{game === 'mai2' ? t("UserHome.HighestDXRating"): t("UserHome.HighestRating")}</span>
<!-- NOTE: this is for legacy ongeki display, so 1.45 profiles are at least usable -->
<span>{
game === 'chu3' || game === 'ongeki' ?
(d.user.ratingHighest / 100).toFixed(2) :
d.user.ratingHighest.toLocaleString()
}</span>
</div>
{/if}
<div class="max-combo">
<span>{t("UserHome.MaxCombo")}</span>
<span>{d.user.maxCombo}</span>
@@ -335,7 +342,7 @@
<RatingComposition title={t("UserHome.RatingComposition.New", {n : 15})} comp={d.user.ratingComposition.new15} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.New", {n: 10})} comp={d.user.ratingComposition.new10} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.Platinum", {n: 50})} comp={d.user.ratingComposition.pscore50} {allMusics} {game}/>
{#if me && me.displayCandidates && d.user.aquaUser && me.username == d.user.aquaUser.username}
<RatingComposition title={t("UserHome.RatingComposition.BestCandidates", {n: 20})} comp={d.user.ratingComposition.best20_candidates} {allMusics} {game}/>
<RatingComposition title={t("UserHome.RatingComposition.NewCandidates", {n: 10})} comp={d.user.ratingComposition.new10_candidates} {allMusics} {game}/>
@@ -523,7 +530,7 @@
.scoring-info
display: flex
gap: vars.$gap
max-height: 250px
max-height: 300px
.chart
flex: 0 1 790px
@@ -604,7 +611,7 @@
display: flex
align-items: center
gap: 20px
background-color: rgba(white, 0.03)
border-radius: vars.$border-radius

View File

@@ -46,10 +46,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
}
// List<Pair<should_hide, player>>>
private var rankingCache: List<Pair<Bool, GenericRankingPlayer>> = emptyList()
private var rankingCache: List<GenericRankingPlayer> = emptyList()
private var rankingCacheLock = ReentrantLock()
// Sorted index List<Rating> = Rank
private var rankingSortedIndex: List<Int> = emptyList()
private var rankingLookupCache: Map<Long, GenericRankingPlayer> = emptyMap()
private val pageSize = 100
@API("ranking")
@@ -58,18 +58,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
// Check cache validity
if (rankingCache.isEmpty()) (500 - "Rank is empty or is currently computing.")
val reqUser = token?.let { us.jwt.auth(it) }?.let { u ->
// Optimization: If the user is not banned, we don't need to process user information
if (!u.ghostCard.rankingBanned && !u.cards.any { it.rankingBanned } && u.ghostCard.status.isNormal) null
else u
}
// Read from cache if we just computed it less than duration ago
// Shadow-ban: Do not show banned cards in the ranking except for the user who owns the card
val v = rankingCache.filter { !it.l || it.r.username == reqUser?.username }
.mapIndexed { i, it -> it.r.apply { rank = i + 1 } }
.also { logger.info("Ranking returned in ${millis() - time}ms") }
val v = rankingCache.also { logger.info("Ranking returned in ${millis() - time}ms") }
return page?.let {
if (it < 0) (400 - "Invalid page number")
@@ -78,7 +67,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
}
@PostConstruct
fun rakingCacheInit() = thread { rankingCacheRun() }
fun rankingCacheInit() = thread { rankingCacheRun() }
// Every 20 minutes
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.MINUTES)
@@ -92,34 +81,35 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
SELECT
c.id,
u.user_name,
u.player_rating,
${if (name == "ongeki") "u.new_player_rating" else "u.player_rating"} AS rating,
u.last_play_date,
AVG(p.achievement) / 10000.0 AS acc,
SUM(p.is_full_combo) AS fc,
SUM(p.is_all_perfect) AS ap,
c.ranking_banned or a.opt_out_of_leaderboard or c.status = 12 AS hide,
a.username ${if (name == "ongeki") ", u.new_player_rating" else ""}
a.username
FROM ${tableName}_user_playlog_view p
JOIN ${tableName}_user_data_view u ON p.user_id = u.id
JOIN sega_card c ON u.aime_card_id = c.id
LEFT JOIN aqua_net_user a ON c.net_user_id = a.au_id
GROUP BY p.user_id, u.player_rating
HAVING NOT hide ${if (name == "ongeki") "AND rating > 0" else "" /* Hide users on Ongeki 1.45 and below */}
ORDER BY u.player_rating DESC;
"""
).exec.mapIndexed { i, it ->
it[7].truthy to GenericRankingPlayer(
GenericRankingPlayer(
rank = i + 1,
id = it[0]!!.long,
name = it[1].toString(),
rating = it[2]!!.int,
lastSeen = it[3].toString(),
accuracy = it[4]!!.double,
fullCombo = it[5]!!.int,
allPerfect = it[6]!!.int,
username = it[8]?.toString() ?: "user${it[0]}",
modernRating = if (name == "ongeki") it[9]?.int() ?: 0 else 0
username = it[8]?.toString() ?: "user${it[0]}"
)
}
rankingSortedIndex = rankingCache.filter { !it.l }.map { it.r.rating }.reversed()
rankingLookupCache = rankingCache.associateBy { it.id }
logger.info("Ranking for $name computed in ${millis() - time}ms")
}
@@ -158,6 +148,10 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
userMusicRepo.findByUser_Card_ExtIdAndMusicIdIn(card.extId, musicList)
}
open fun getRating(user: T, isHighest: Bool): Int {
return if (isHighest) user.highestRating else user.playerRating;
}
fun genericUserSummary(card: Card, ratingComp: Map<String, String>, rival: Boolean? = null, favorites: List<Int>? = null): GenericGameSummary {
// Summary values: total plays, player rating, server-wide ranking
// number of each rank, max combo, number of full combo, number of all perfect
@@ -183,18 +177,14 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
}
}
// Find serverRank by binary-searching in the rankingSortedIndex to find the minimal index that
// is greater than or equal to the user's rating
var serverRank = rankingSortedIndex.binarySearch(user.playerRating).let { if (it < 0) -it - 1 else it + 1 }
serverRank = rankingSortedIndex.size - serverRank
return GenericGameSummary(
name = user.userName,
aquaUser = card.aquaUser?.publicFields,
serverRank = serverRank.long,
serverRank = rankingLookupCache[user.card!!.id]?.rank?.str ?: "-",
accuracy = plays.acc(),
rating = user.playerRating,
ratingHighest = user.highestRating,
rating = getRating(user, false),
ratingHighest = getRating(user, true),
ratingNotGeneric = getRating(user, false) != user.playerRating,
ranks = ranks.map { (k, v) -> RankCount(k, v) },
detailedRanks = detailedRanks,
maxCombo = plays.maxOfOrNull { it.maxCombo } ?: 0,

View File

@@ -1,6 +1,7 @@
package icu.samnyan.aqua.net.games
import com.fasterxml.jackson.annotation.JsonIgnore
import ext.Bool
import ext.JACKSON
import ext.JavaSerializable
import icu.samnyan.aqua.sega.general.model.Card
@@ -25,10 +26,11 @@ data class GenericGameSummary(
val aquaUser: Map<String, Any?>?,
val serverRank: Long,
val serverRank: String,
val accuracy: Double,
val rating: Int,
val ratingHighest: Int,
val ratingNotGeneric: Bool,
val ranks: List<RankCount>,
val detailedRanks: Map<Int, Map<String, Int>>,
val maxCombo: Int,
@@ -60,7 +62,9 @@ data class GenericRankingPlayer(
val allPerfect: Int,
val fullCombo: Int,
val lastSeen: String,
val modernRating: Int = 0
@JsonIgnore
val id: Long
)
@Serializable

View File

@@ -14,6 +14,7 @@ import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserGeneralData
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserLoginBonus
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserOption
import jakarta.annotation.PostConstruct
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
@@ -214,6 +215,21 @@ class Maimai2(
// creating a ton of SHA256 hashes every launch *probably* isn't ideal but it's better than exposing token AND extid...
@OptIn(ExperimentalStdlibApi::class)
fun myPhotoGetHash(value: Str): Str {
return MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(UTF_8)).toHexString()
}
@PostConstruct
fun myPhotoInit() = thread {
photoDir.listFiles()
?.map { it.name }
?.map {
// generate hash of photo filename as to not expose details
photoHashMap[it] = myPhotoGetHash(it)
}
}
@API("my-photo")
suspend fun myPhoto(@RP token: Str) = us.jwt.auth(token) { u ->
val find = "${u.ghostCard.extId}-"
@@ -224,8 +240,7 @@ class Maimai2(
?.map {
// generate hash of photo filename as to not expose details
if (!photoHashMap.containsKey(it))
photoHashMap[it] = MessageDigest.getInstance("SHA-256")
.digest(it.toByteArray(UTF_8)).toHexString()
photoHashMap[it] = myPhotoGetHash(it)
photoHashMap[it]
}
?: emptyList()

View File

@@ -1,6 +1,7 @@
package icu.samnyan.aqua.net.games.ongeki
import ext.API
import ext.Bool
import ext.RP
import ext.minus
import icu.samnyan.aqua.net.db.AquaUserServices
@@ -64,15 +65,10 @@ class Ongeki(
))
}
@API("refresh-data")
suspend fun refreshData(@RP username: String) = us.cardByName(username) { card ->
val user = userDataRepo.findByCard_ExtId(card.extId) ?: (404 - "User not found")
if (user.newHighestRating > 0)
mapOf(
"playerRating" to user.newPlayerRating,
"highestRating" to user.newHighestRating
)
else (400 - "User has not played Refresh")
override fun getRating(user: UserData, isHighest: Bool): Int {
return if (user.newHighestRating > 0) {
if (isHighest) user.newHighestRating else user.newPlayerRating
} else if (isHighest) user.highestRating else user.playerRating
}
@API("user-option")