diff --git a/src/main/java/ext/Ext.kt b/src/main/java/ext/Ext.kt index dde6f995..3f008bc5 100644 --- a/src/main/java/ext/Ext.kt +++ b/src/main/java/ext/Ext.kt @@ -5,6 +5,7 @@ import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* +import jakarta.persistence.Query import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.tika.Tika @@ -138,7 +139,26 @@ fun Any.long() = when (this) { } fun Any.uint32() = long() and 0xFFFFFFFF fun Any.int() = long().toInt() +val Any.long get() = long() +val Any.int get() = int() +val Any.double get() = when (this) { + is Boolean -> if (this) 1.0 else 0.0 + is Number -> toDouble() + is String -> toDouble() + else -> 400 - "Invalid number: $this" +} operator fun Bool.unaryPlus() = if (this) 1 else 0 +val Any?.truthy get() = when (this) { + null -> false + is Bool -> this + is Float -> this != 0f && !isNaN() + is Double -> this != 0.0 && !isNaN() + is Number -> this != 0 + is String -> this.isNotBlank() + is Collection<*> -> isNotEmpty() + is Map<*, *> -> isNotEmpty() + else -> true +} // Collections fun ls(vararg args: T) = args.toList() @@ -176,7 +196,7 @@ fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/" fun T.logger() = LoggerFactory.getLogger(this::class.java) -// I hate this ;-; +// I hate this ;-; (list destructuring) operator fun List.component6(): E = get(5) operator fun List.component7(): E = get(6) operator fun List.component8(): E = get(7) @@ -185,3 +205,11 @@ operator fun List.component10(): E = get(9) operator fun List.component11(): E = get(10) operator fun List.component12(): E = get(11) operator fun List.component13(): E = get(12) + +inline operator fun List.invoke(i: Int) = get(i) as E + +val Pair.l get() = component1() +val Pair<*, S>.r get() = component2() + +// Database +val Query.exec get() = resultList.map { (it as Array<*>).toList() } diff --git a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt index a325c9ae..c8460fc9 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt @@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory import kotlin.jvm.optionals.getOrNull import kotlin.reflect.KClass -abstract class GameApiController(name: String, userDataClass: KClass) { +abstract class GameApiController(val name: String, userDataClass: KClass) { val musicMapping = resJson>("/meta/$name/music.json") ?.mapKeys { it.key.toInt() } ?: emptyMap() val logger = LoggerFactory.getLogger(javaClass) @@ -31,41 +31,61 @@ abstract class GameApiController(name: String, userDataClass: KCl playlogRepo.findByUserCardExtId(card.extId) } - private var rankingCache: MutableMap>> = mutableMapOf() + // Pair>> + private var rankingCache: Pair>> = 0L to emptyList() private val rankingCacheDuration = 240_000 @API("ranking") fun ranking(@RP token: String?): List { - val reqUser = token?.let { us.jwt.auth(it) { u -> + val time = millis() + val tableName = when (name) { "mai2" -> "maimai2"; "chu3" -> "chusan"; else -> name } + + // Check if ranking cache needs to be updated + // TODO: pagination + if (time - rankingCache.first > rankingCacheDuration) { + rankingCache = time to us.em.createNativeQuery( + """ + SELECT + u.id, + u.user_name, + u.player_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 AS hide, + 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 + ORDER BY u.player_rating DESC; + """ + ).exec.mapIndexed { i, it -> + it[7].truthy to GenericRankingPlayer( + rank = i + 1, + 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]}" + ) + } + } + + 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 }) null else u - } } - val cacheKey = reqUser?.auId ?: -1 - - // Read from cache if we just computed it less than duration ago - rankingCache[cacheKey]?.let { (t, r) -> - if (millis() - t < rankingCacheDuration) return r } - // TODO: pagination + // 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 players = userDataRepo.findAll().sortedByDescending { it.playerRating } - .filter { (it.card?.rankingBanned != true && it.card?.aquaUser?.optOutOfLeaderboard != true) || it.card?.aquaUser?.let { it == reqUser } ?: false } - return players.filter { it.card != null }.mapIndexed { i, user -> - val card = user.card!! - val plays = playlogRepo.findByUserCardExtId(card.extId) - - GenericRankingPlayer( - rank = i + 1, - name = user.userName, - accuracy = plays.acc(), - rating = user.playerRating, - allPerfect = plays.count { it.isAllPerfect }, - fullCombo = plays.count { it.isFullCombo }, - lastSeen = user.lastPlayDate.toString(), - username = (if (card.isGhost) user.card!!.aquaUser?.username else null) ?: "user${user.card!!.id}" - ) - }.also { rankingCache[cacheKey] = millis() to it } // Update cache + return rankingCache.r.filter { !it.l || it.r.username == reqUser?.username }.map { it.r }.also { + logger.info("Ranking computed in ${millis() - time}ms") + } } @API("playlog")