From a92f1ca892d09cb523df49fdf29f2e9a7be01142 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:40:07 +0800 Subject: [PATCH 01/35] feat(shared): add ArcaeaScoringMode and clear-type bonus to play rating Arcaea v7.0 reworked the potential system: B30 + R10 is replaced by B50 (top 10 entries counted twice), and single-play potential now adds a +0.2 clear bonus for any state other than TRACK_LOST. ArcaeaScoringMode is keyed by the date each rule took effect so the key can be persisted as-is and stays chronologically ordered. A missing clear type counts as TRACK_LOST: historical records without a reliable clear state must not gain the bonus, so the default keeps B50 values conservative rather than inflating them. --- .../arcaeaoffline/core/calculators/Common.kt | 32 +++++++++++++++++++ .../core/constants/ArcaeaScoringMode.kt | 20 ++++++++++++ .../core/calculators/CommonCalculatorsTest.kt | 27 ++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaScoringMode.kt diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt index 84741998..ad5fdc4c 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.core.calculators +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max @@ -29,6 +30,37 @@ fun calculatePlayRating( } } +/** Bonus added to single-play potential by the v7.0 rules (B50 scoring). */ +const val PLAY_RATING_CLEAR_BONUS = 0.2 + +/** + * Clear-type bonus of a single play. Any state other than TRACK_LOST earns the + * bonus; a missing clear type is treated as TRACK_LOST, i.e. no bonus. + */ +fun calculateClearBonus(clearType: ArcaeaPlayResultClearType?): Double = + if (clearType == null || clearType == ArcaeaPlayResultClearType.TRACK_LOST) 0.0 else PLAY_RATING_CLEAR_BONUS + +/** + * Single-play potential under the v7.0 rules: the score-based value plus the + * clear-type bonus, with the whole sum floored at zero. + */ +fun calculatePlayRating( + score: Int, + constant: Int, + clearType: ArcaeaPlayResultClearType?, +): Double { + if (constant < 0) return 0.0 + + val bonus = calculateClearBonus(clearType) + return if (score >= 10_000_000) { + constant / 10.0 + 2 + bonus + } else if (score >= 9_800_000) { + constant / 10.0 + 1 + (score - 9_800_000) / 200_000.0 + bonus + } else { + max(0.0, constant / 10.0 + (score - 9_500_000) / 300_000.0 + bonus) + } +} + /** * Calculate a possible score [IntRange] from specified [targetPlayRating] and [constant]. * diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaScoringMode.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaScoringMode.kt new file mode 100644 index 00000000..6ca31423 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaScoringMode.kt @@ -0,0 +1,20 @@ +package xyz.sevive.arcaeaoffline.core.constants + +/** + * Potential scoring rules, keyed by the date (yyyyMMdd) the rule took effect. + * + * The key is the persisted form (see the `scoring_mode` property) and orders + * modes chronologically: append new modes with a larger key, never renumber. + */ +enum class ArcaeaScoringMode( + val key: Int, +) { + B30_R10(20170602), + B50(20260827), + ; + + companion object { + /** Returns null when the key belongs to a newer app version or is corrupted. */ + fun fromKey(key: Int): ArcaeaScoringMode? = entries.find { it.key == key } + } +} diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index 493131da..ca6f191d 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType class CommonCalculatorsTest { @Test @@ -57,4 +58,30 @@ class CommonCalculatorsTest { assertNull(calculateInvertScoreRange(targetPlayRating = 14.0, constant = 80)) } + + @Test + fun testPlayRatingClearBonus() { + val score = 10_000_000 + val constant = 100 + + // A missing clear type counts as TRACK_LOST: no bonus + assertEquals(calculatePlayRating(score, constant, null), 12.0, 0.0) + assertEquals(calculatePlayRating(score, constant, ArcaeaPlayResultClearType.TRACK_LOST), 12.0, 0.0) + + for (clearType in + listOf( + ArcaeaPlayResultClearType.NORMAL_CLEAR, + ArcaeaPlayResultClearType.FULL_RECALL, + ArcaeaPlayResultClearType.PURE_MEMORY, + ArcaeaPlayResultClearType.EASY_CLEAR, + ArcaeaPlayResultClearType.HARD_CLEAR, + )) { + assertEquals(calculatePlayRating(score, constant, clearType), 12.2, 0.0) + } + + // The bonus participates in the floor at zero: a negative base plus + // bonus stays above zero, while TRACK_LOST floors at zero + assertEquals(calculatePlayRating(8_000_000, 50, null), 0.0, 0.0) + assertEquals(calculatePlayRating(8_000_000, 50, ArcaeaPlayResultClearType.NORMAL_CLEAR), 0.2, 0.0) + } } From a8f8c086f100dc40525668e9a86fd786a64344b6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:40:35 +0800 Subject: [PATCH 02/35] feat(core): store scoring mode in database properties The scoring mode describes how a database's play results are interpreted (official servers use B50 while private servers may stay on B30 + R10), so it belongs to the database itself rather than app preferences. Storing it in the properties table keeps it traveling with the database file and makes a per-database scope automatic. Unset or unknown keys fall back to the latest mode (B50). --- .../core/database/entities/Property.kt | 1 + .../database/repositories/PropertyRepository.kt | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt index 6fdc024e..6c5264da 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt @@ -12,5 +12,6 @@ data class Property( const val KEY_VERSION = "version" const val KEY_R30_LAST_UPDATED_AT = "r30_last_updated_at" const val KEY_R30_LAST_UPDATED_PLAY_RESULT_UUID = "r30_last_updated_uuid" + const val KEY_SCORING_MODE = "scoring_mode" } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt index 972ca772..c40961e7 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.daos.PropertyDao import xyz.sevive.arcaeaoffline.core.database.entities.Property import kotlin.time.Instant @@ -20,6 +21,10 @@ interface PropertyRepository { suspend fun setDatabaseVersion(ver: Int) + fun scoringMode(): Flow + + suspend fun setScoringMode(mode: ArcaeaScoringMode) + suspend fun r30LastUpdatedAt(): Instant? suspend fun setR30LastUpdatedAt(instant: Instant) @@ -48,6 +53,18 @@ class PropertyRepositoryImpl( this.upsert(Property(Property.KEY_VERSION, ver.toString())) } + // The scoring mode describes how this database's play results are + // interpreted, so it lives in the database itself and travels with the + // database file. Falls back to the latest mode when unset or unknown. + override fun scoringMode(): Flow = + this.find(Property.KEY_SCORING_MODE).map { property -> + property?.value?.toIntOrNull()?.let { ArcaeaScoringMode.fromKey(it) } ?: ArcaeaScoringMode.B50 + } + + override suspend fun setScoringMode(mode: ArcaeaScoringMode) { + this.upsert(Property(Property.KEY_SCORING_MODE, mode.key.toString())) + } + override suspend fun r30LastUpdatedAt(): Instant? { val property = this.find(Property.KEY_R30_LAST_UPDATED_AT).firstOrNull() ?: return null return Instant.fromEpochMilliseconds(property.value.toLong()) From 60385e39e001361734e17daaaba23cb61640566f Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:43:51 +0800 Subject: [PATCH 03/35] feat(core): add B50 potential calculation PotentialRepository gains b50(), b10() and a scoring-mode-aware potential(). B50 follows the official v7.0 formula (best50 sum plus best10 sum, divided by 60, so the top 10 count twice) over the clear-bonus-inclusive play rating; B30_R10 keeps (b30 + r10) weighting. Best-per-chart ranking becomes scoring-mode aware: under B50 a cleared play can outrank a higher-scoring TRACK_LOST play of the same chart, so the per-chart best must be selected by the bonus-inclusive rating. The minimum-fields query now carries clear_type for that comparison. The B30 list call site passes B30_R10 explicitly; behavior is unchanged. --- .../b30list/DatabaseB30ListViewModel.kt | 3 +- .../core/database/daos/PlayResultBestDao.kt | 6 +- .../MinimumPlayResultPotentialFields.kt | 2 + .../database/entities/PlayResultCalculated.kt | 4 ++ .../repositories/PlayResultBestRepository.kt | 32 ++++++++-- .../repositories/PotentialRepository.kt | 63 +++++++++++++++++-- 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt index cd8be6ca..0b54e10c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated @@ -46,7 +47,7 @@ class DatabaseB30ListViewModel( emit(UiState(isLoading = true, limit = limit)) playResultBestRepo - .orderDescWithLimit(limit) + .orderDescWithLimit(limit, ArcaeaScoringMode.B30_R10) .collectLatest { dbItems -> val chartCacheHolder = UiDisplayChartCacheHolder() chartCacheHolder.updateCache( diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultBestDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultBestDao.kt index a545c70f..0379589f 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultBestDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultBestDao.kt @@ -9,14 +9,16 @@ import xyz.sevive.arcaeaoffline.core.database.entities.MinimumPlayResultPotentia interface PlayResultBestDao { // Minimum fields to compute play rating per play result; full // PlayResultCalculated rows are fetched afterwards for the best - // uuids only (see PlayResultBestRepository). + // uuids only (see PlayResultBestRepository). clear_type is needed for + // the clear bonus of the B50 rules. @Query( """SELECT pr.uuid, pr.song_id, pr.rating_class, pr.score, - ci.constant + ci.constant, + pr.clear_type FROM play_results AS pr LEFT JOIN charts_info AS ci ON pr.song_id = ci.song_id diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt index a9c6e7e5..c6946ee4 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt @@ -1,6 +1,7 @@ package xyz.sevive.arcaeaoffline.core.database.entities import androidx.room.ColumnInfo +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import kotlin.uuid.Uuid @@ -10,4 +11,5 @@ data class MinimumPlayResultPotentialFields( @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, val score: Int, val constant: Int, + val clearType: ArcaeaPlayResultClearType?, ) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt index 67489227..02143604 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt @@ -22,6 +22,10 @@ data class PlayResultCalculated( val comment = playResult.comment val playRating = calculatePlayRating(score = score, constant = chartInfo.constant) + + /** Single-play potential under the v7.0 (B50) rules. */ + val playRatingWithClearBonus = + calculatePlayRating(score = score, constant = chartInfo.constant, clearType = clearType) val shinyPure = if (chartInfo.notes == null || chartInfo.notes == 0 || pure == null || far == null || lost == null) { null diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt index 735cc12d..56364d52 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultBestDao +import xyz.sevive.arcaeaoffline.core.database.entities.MinimumPlayResultPotentialFields import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated interface PlayResultBestRepository { @@ -16,7 +18,10 @@ interface PlayResultBestRepository { ratingClass: ArcaeaRatingClass, ): Flow - fun orderDescWithLimit(limit: Int): Flow> + fun orderDescWithLimit( + limit: Int, + scoringMode: ArcaeaScoringMode, + ): Flow> } @OptIn(ExperimentalCoroutinesApi::class) @@ -34,7 +39,10 @@ class PlayResultBestRepositoryImpl( list.maxByOrNull { it.playRating } } - override fun orderDescWithLimit(limit: Int): Flow> = + override fun orderDescWithLimit( + limit: Int, + scoringMode: ArcaeaScoringMode, + ): Flow> = playResultBestDao.minimumPlayResultPotentialFields().flatMapLatest { originalList -> val topUuids = originalList @@ -43,7 +51,7 @@ class PlayResultBestRepositoryImpl( .map { group -> // Calculate play rating once and find the best group - .map { it to calculatePlayRating(it.score, it.constant) } + .map { it to it.playRating(scoringMode) } .maxBy { it.second } } // Sort all best results by play rating @@ -53,7 +61,23 @@ class PlayResultBestRepositoryImpl( playResultCalculatedRepo.findAllByUuid(topUuids).map { list -> // Re-sort because the DB might return them in a different order - list.sortedByDescending { it.playRating } + list.sortedByDescending { it.playRating(scoringMode) } } } + + /** + * B50 ranks by the clear-bonus-inclusive play rating, so a cleared play can + * outrank a higher-scoring TRACK_LOST play of the same chart. + */ + private fun MinimumPlayResultPotentialFields.playRating(scoringMode: ArcaeaScoringMode): Double = + when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> calculatePlayRating(score, constant) + ArcaeaScoringMode.B50 -> calculatePlayRating(score, constant, clearType) + } + + private fun PlayResultCalculated.playRating(scoringMode: ArcaeaScoringMode): Double = + when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> playRating + ArcaeaScoringMode.B50 -> playRatingWithClearBonus + } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt index a1328b10..af3e5b9e 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt @@ -2,28 +2,46 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated interface PotentialRepository { + /** Average single-play potential of the best 30 entries (legacy display value). */ fun b30(): Flow + /** Average single-play potential of the recent top 10 entries (legacy display value). */ fun r10(): Flow + + /** Average single-play potential of the best 50 entries (B50 display value). */ + fun b50(): Flow + + /** Average single-play potential of the top 10 of the best 50 entries (B50 display value). */ + fun b10(): Flow + + /** Overall potential, following the scoring mode stored in the database properties. */ + fun potential(): Flow } +@OptIn(ExperimentalCoroutinesApi::class) class PotentialRepositoryImpl( private val playResultBestRepo: PlayResultBestRepository, private val r30EntryRepo: R30EntryRepository, + private val propertyRepo: PropertyRepository, ) : PotentialRepository { - private fun b30Entries(): Flow> = playResultBestRepo.orderDescWithLimit(30) + private fun b30Entries(): Flow> = + playResultBestRepo.orderDescWithLimit(30, ArcaeaScoringMode.B30_R10) + + private fun b50Entries(): Flow> = + playResultBestRepo.orderDescWithLimit(50, ArcaeaScoringMode.B50) - @OptIn(ExperimentalCoroutinesApi::class) private fun r10Entries(): Flow> = r30EntryRepo.findAllCombined().mapLatest { it.sortedByDescending { it.playRating() ?: -1.0 }.take(10) } - @OptIn(ExperimentalCoroutinesApi::class) override fun b30() = this.b30Entries().mapLatest { entries -> if (entries.isEmpty()) { @@ -33,7 +51,6 @@ class PotentialRepositoryImpl( } } - @OptIn(ExperimentalCoroutinesApi::class) override fun r10() = this.r10Entries().mapLatest { entries -> if (entries.isEmpty()) { @@ -42,4 +59,42 @@ class PotentialRepositoryImpl( entries.sumOf { it.playRating() ?: 0.0 } / entries.size } } + + override fun b50() = + this.b50Entries().mapLatest { entries -> + if (entries.isEmpty()) { + 0.0 + } else { + entries.sumOf { it.playRatingWithClearBonus } / entries.size + } + } + + override fun b10() = + this.b50Entries().mapLatest { entries -> + val top = entries.take(10) + if (top.isEmpty()) { + 0.0 + } else { + top.sumOf { it.playRatingWithClearBonus } / top.size + } + } + + override fun potential() = + propertyRepo.scoringMode().flatMapLatest { mode -> + when (mode) { + ArcaeaScoringMode.B30_R10 -> + combine(b30(), r10()) { b30, r10 -> + b30 * 0.75 + r10 * 0.25 + } + + ArcaeaScoringMode.B50 -> + // Official v7.0 formula: the best 10 entries count twice, + // i.e. (best50 sum + best10 sum) / 60 + b50Entries().mapLatest { entries -> + val b50Sum = entries.sumOf { it.playRatingWithClearBonus } + val b10Sum = entries.take(10).sumOf { it.playRatingWithClearBonus } + (b50Sum + b10Sum) / 60 + } + } + } } From a488b96a5bdec5fb430f15d3a2feada613ddcfcf Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:48:13 +0800 Subject: [PATCH 04/35] refactor(app.ui): unify potential display precision at 3 decimals potentialToText now takes the decimal scale directly (default 3) instead of a DecimalMode, which no caller customized. There is no community consensus on potential display precision and some tools show the raw value, so 3 decimals is used everywhere by default; the overview main potential switches per scoring mode separately. The chart recommend result row used a hardcoded "%.2f" format; it now goes through potentialToText like every other ptt display. --- .../ui/helpers/ArcaeaFormatters.kt | 13 ++++++------- .../UtilitiesChartRecommendScreen.kt | 4 ++-- .../ui/helpers/ArcaeaFormattersTest.kt | 19 +++++++++++-------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index c5493b3c..a1749a32 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -1,6 +1,5 @@ package xyz.sevive.arcaeaoffline.ui.helpers -import com.ionspin.kotlin.bignum.decimal.DecimalMode import com.ionspin.kotlin.bignum.decimal.RoundingMode import com.ionspin.kotlin.bignum.decimal.toBigDecimal import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass @@ -22,18 +21,18 @@ object ArcaeaFormatters { } /** - * Format the given potential to text. - * If the potential is null, return "-.--" instead. + * Format the given potential to text, truncated (towards zero) to [scale] decimal places. + * If the potential is null, return a placeholder of the same width. */ fun potentialToText( potential: Double?, - decimalMode: DecimalMode = DecimalMode(roundingMode = RoundingMode.TOWARDS_ZERO, scale = 2), + scale: Int = 3, ): String = potential ?.toBigDecimal() - ?.roundToDigitPositionAfterDecimalPoint(decimalMode.scale, decimalMode.roundingMode) - ?.scale(decimalMode.scale) - ?.toPlainString() ?: "-.--" + ?.roundToDigitPositionAfterDecimalPoint(scale.toLong(), RoundingMode.TOWARDS_ZERO) + ?.scale(scale.toLong()) + ?.toPlainString() ?: "-." + "-".repeat(scale) /** * Format the given playResult to a level text. diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index fae267d3..cf4bd405 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -57,6 +57,7 @@ import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.ui.SubScreenContainer +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField @@ -65,7 +66,6 @@ import xyz.sevive.arcaeaoffline.ui.components.PlayRatingCalculator import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.rememberDecimalStepperTextFieldState -import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.UtilitiesSubScreen import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import kotlin.math.round @@ -223,7 +223,7 @@ private fun ResultsListItem( Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null) - Text("%.2f".format(state.actualPlayRating), fontWeight = FontWeight.Bold) + Text(ArcaeaFormatters.potentialToText(state.actualPlayRating), fontWeight = FontWeight.Bold) Spacer(Modifier.weight(1f)) diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt index b714957a..5d4c09d0 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt @@ -17,14 +17,17 @@ class ArcaeaFormattersTest { @Test fun potentialToTextTest() { - assertEquals("0.00", ArcaeaFormatters.potentialToText(0.0)) - assertEquals("12.00", ArcaeaFormatters.potentialToText(12.0)) - assertEquals("12.05", ArcaeaFormatters.potentialToText(12.05)) - assertEquals("12.05", ArcaeaFormatters.potentialToText(12.054)) - assertEquals("12.05", ArcaeaFormatters.potentialToText(12.055)) - assertEquals("12.05", ArcaeaFormatters.potentialToText(12.056)) - assertEquals("12.05", ArcaeaFormatters.potentialToText(12.057)) - assertEquals("-.--", ArcaeaFormatters.potentialToText(null)) + assertEquals("0.000", ArcaeaFormatters.potentialToText(0.0)) + assertEquals("12.000", ArcaeaFormatters.potentialToText(12.0)) + assertEquals("12.050", ArcaeaFormatters.potentialToText(12.05)) + assertEquals("12.054", ArcaeaFormatters.potentialToText(12.054)) + assertEquals("12.055", ArcaeaFormatters.potentialToText(12.055)) + assertEquals("12.055", ArcaeaFormatters.potentialToText(12.0559)) + assertEquals("-.---", ArcaeaFormatters.potentialToText(null)) + + // Truncation towards zero at the requested scale + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.0559, scale = 2)) + assertEquals("-.--", ArcaeaFormatters.potentialToText(null, scale = 2)) } @Test From 2cb98aa48adb7378b79302432e87f97b7a68f568 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:50:04 +0800 Subject: [PATCH 05/35] feat(app.ui): scoring-mode-aware overview potential card The overview card follows the scoring mode stored in the database: B30 + R10 shows the legacy averages with the official 0.01 precision, B50 shows the B50/B10 averages with 0.001. The overall potential value comes from PotentialRepository.potential() which already switches on the mode. Also annotate MinimumPlayResultPotentialFields.clearType with @ColumnInfo so Room maps the new query column. --- .../screens/overview/OverviewPotentialCard.kt | 48 ++++++++++++++----- .../ui/screens/overview/OverviewViewModel.kt | 39 +++++++++++---- .../MinimumPlayResultPotentialFields.kt | 2 +- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt index f1ac73bf..32429ab8 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -28,6 +29,7 @@ private fun PotentialLabel( modifier: Modifier = Modifier, titleTextStyle: TextStyle = MaterialTheme.typography.titleLarge, potentialTextStyle: TextStyle = MaterialTheme.typography.headlineLarge, + scale: Int = 3, ) { Column(modifier) { Text( @@ -36,7 +38,7 @@ private fun PotentialLabel( fontWeight = FontWeight.Light, ) Text( - ArcaeaFormatters.potentialToText(potential), + ArcaeaFormatters.potentialToText(potential, scale), style = potentialTextStyle, ) } @@ -47,6 +49,12 @@ internal fun OverviewPotentialCard( uiState: OverviewViewModel.UiState, modifier: Modifier = Modifier, ) { + // The official display precision is 0.01 for B30 + R10 and 0.001 for B50 + val mainScale = when (uiState.scoringMode) { + ArcaeaScoringMode.B30_R10 -> 2 + ArcaeaScoringMode.B50 -> 3 + } + Card(modifier) { Row(Modifier.padding(dimensionResource(R.dimen.page_padding))) { PotentialLabel( @@ -54,6 +62,7 @@ internal fun OverviewPotentialCard( potential = uiState.potential, titleTextStyle = MaterialTheme.typography.headlineSmall, potentialTextStyle = MaterialTheme.typography.displayLarge, + scale = mainScale, modifier = Modifier .align(Alignment.Bottom) @@ -63,15 +72,31 @@ internal fun OverviewPotentialCard( Column( verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), ) { - PotentialLabel( - title = "B30", - potential = uiState.b30, - ) + when (uiState.scoringMode) { + ArcaeaScoringMode.B30_R10 -> { + PotentialLabel( + title = "B30", + potential = uiState.b30, + ) + + PotentialLabel( + title = "R10", + potential = uiState.r10, + ) + } + + ArcaeaScoringMode.B50 -> { + PotentialLabel( + title = "B50", + potential = uiState.b50, + ) - PotentialLabel( - title = "R10", - potential = uiState.r10, - ) + PotentialLabel( + title = "B10", + potential = uiState.b10, + ) + } + } } } } @@ -85,8 +110,9 @@ private fun OverviewPotentialCardPreview() { OverviewPotentialCard( OverviewViewModel.UiState( isLoading = false, - b30 = 13.00, - r10 = 13.00, + scoringMode = ArcaeaScoringMode.B50, + b50 = 13.00, + b10 = 13.00, potential = 13.00, ), Modifier.fillMaxWidth(), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt index bf6fe0ff..b2f830ca 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt @@ -5,34 +5,53 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import kotlin.time.Duration.Companion.seconds class OverviewViewModel( potentialRepository: PotentialRepository, + propertyRepository: PropertyRepository, ) : ViewModel() { data class UiState( val isLoading: Boolean = true, + val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, val b30: Double? = null, val r10: Double? = null, + val b50: Double? = null, + val b10: Double? = null, val potential: Double? = null, ) - private fun calculatePotential( - b30: Double, - r10: Double, - ) = b30 * 0.75 + r10 * 0.25 + private data class SubPotentials( + val b30: Double?, + val r10: Double?, + val b50: Double?, + val b10: Double?, + ) val uiState = combine( - potentialRepository.b30(), - potentialRepository.r10(), - ) { b30, r10 -> + propertyRepository.scoringMode(), + potentialRepository.potential(), + combine( + potentialRepository.b30(), + potentialRepository.r10(), + potentialRepository.b50(), + potentialRepository.b10(), + ) { b30, r10, b50, b10 -> + SubPotentials(b30, r10, b50, b10) + }, + ) { scoringMode, potential, subPotentials -> UiState( isLoading = false, - b30 = b30, - r10 = r10, - potential = calculatePotential(b30 = b30, r10 = r10), + scoringMode = scoringMode, + b30 = subPotentials.b30, + r10 = subPotentials.r10, + b50 = subPotentials.b50, + b10 = subPotentials.b10, + potential = potential, ) }.stateIn( viewModelScope, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt index c6946ee4..197624e1 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/MinimumPlayResultPotentialFields.kt @@ -11,5 +11,5 @@ data class MinimumPlayResultPotentialFields( @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, val score: Int, val constant: Int, - val clearType: ArcaeaPlayResultClearType?, + @ColumnInfo(name = "clear_type") val clearType: ArcaeaPlayResultClearType?, ) From b0c78067908b258610132c141b7bc680d9852a36 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:53:15 +0800 Subject: [PATCH 06/35] feat(app.ui): scoring mode setting in database manage screen The scoring mode is a property of the active database, so the switch lives in the database manage screen instead of the app settings. Adds a generic radio-button SelectPreferencesWidget beside the existing preference widgets. --- .../preferences/SelectPreferencesWidget.kt | 51 +++++++++++++++++++ .../database/manage/DatabaseManageScreen.kt | 38 ++++++++++++++ .../manage/DatabaseManageViewModel.kt | 17 +++++++ app/src/main/res/values-zh-rCN/strings.xml | 6 +++ app/src/main/res/values/strings.xml | 6 +++ 5 files changed, 118 insertions(+) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt new file mode 100644 index 00000000..c7010886 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt @@ -0,0 +1,51 @@ +package xyz.sevive.arcaeaoffline.ui.components.preferences + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha + +data class SelectPreferencesOption( + val value: T, + val label: String, + val description: String? = null, +) + +/** + * A radio-button list of mutually exclusive options; renders one + * [BasePreferencesWidget] row per option without an outer container. + */ +@Composable +fun SelectPreferencesWidget( + options: List>, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + options.forEach { option -> + BasePreferencesWidget( + onClick = { onSelect(option.value) }, + title = { Text(option.label) }, + content = option.description?.let { description -> + { + Text( + description, + Modifier.secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + leadingSlot = { + RadioButton( + selected = option.value == selected, + onClick = null, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt index b59e72e4..f884595c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PendingActions import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Upload import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -29,9 +30,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader +import xyz.sevive.arcaeaoffline.ui.components.preferences.SelectPreferencesOption +import xyz.sevive.arcaeaoffline.ui.components.preferences.SelectPreferencesWidget import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget @Composable @@ -150,6 +154,40 @@ fun DatabaseManageScreen( Modifier.fillMaxWidth(), ) } + + item { HorizontalDivider() } + + item { + ListGroupHeader { + IconRow { + Icon(Icons.Default.Tune, contentDescription = null) + Text(stringResource(R.string.database_manage_scoring_mode_title)) + } + } + } + + item { + val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() + + SelectPreferencesWidget( + options = + listOf( + SelectPreferencesOption( + value = ArcaeaScoringMode.B30_R10, + label = stringResource(R.string.database_manage_scoring_mode_b30_r10), + description = stringResource(R.string.database_manage_scoring_mode_b30_r10_description), + ), + SelectPreferencesOption( + value = ArcaeaScoringMode.B50, + label = stringResource(R.string.database_manage_scoring_mode_b50), + description = stringResource(R.string.database_manage_scoring_mode_b50_description), + ), + ), + selected = scoringMode, + onSelect = viewModel::setScoringMode, + Modifier.fillMaxWidth(), + ) + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt index ece14c75..8c3cb0ca 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn @@ -30,6 +31,7 @@ import xyz.sevive.arcaeaoffline.core.api.DownloadableResource import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoStateHolder import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoUiState import xyz.sevive.arcaeaoffline.core.api.throwableToErrorText +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.externals.exporters.ArcaeaOfflineDEFv2Exporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaPacklistImporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaSonglistImporter @@ -41,6 +43,7 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PackLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PackRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper @@ -63,6 +66,7 @@ class DatabaseManageViewModel( private val difficultyLocalizedRepo: DifficultyLocalizedRepository, private val chartInfoRepo: ChartInfoRepository, private val playResultRepo: PlayResultRepository, + private val propertyRepo: PropertyRepository, private val resourcesApiClient: ArcaeaResourcesApiClient, private val remoteResourcesInfoStateHolder: RemoteResourcesInfoStateHolder, ) : ViewModel() { @@ -108,6 +112,19 @@ class DatabaseManageViewModel( fun refreshRemoteResourcesInfo() = remoteResourcesInfoStateHolder.refresh() + val scoringMode: StateFlow = + propertyRepo + .scoringMode() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + ArcaeaScoringMode.B50, + ) + + fun setScoringMode(mode: ArcaeaScoringMode) { + viewModelScope.launch { propertyRepo.setScoringMode(mode) } + } + internal val uiState = combine( taskQueue.isWorking, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8b16fd1d..5758be03 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -125,6 +125,12 @@ 管理 + 计分方式 + B30 + R10 + 旧版潜力值规则 + B50 + 现行潜力值规则(v7.0+) + 导入 曲包 曲包本地化对象 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c63bc878..ba5eb698 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,6 +146,12 @@ CC BY-SA 3.0 Manage + Scoring mode + B30 + R10 + Legacy potential rules + B50 + Current potential rules (v7.0+) + Import… Packs Pack Localized Objects From 0f1391c62db56b8e4f1aaa17a4a3b141fd482b65 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:57:05 +0800 Subject: [PATCH 07/35] feat(app.ui): database tab follows the scoring mode Under B50 the best list shows the clear-bonus-inclusive ratings and the entry is titled Best50; the R30 entry is hidden since the recent queue does not participate in B50. The score list also rates each record by the active mode's rules. Behavior under B30 + R10 is unchanged. --- .../ui/screens/database/DatabaseNavEntry.kt | 24 ++++-- .../database/DatabaseNavEntryViewModel.kt | 11 +++ .../database/b30list/DatabaseB30ListItem.kt | 1 + .../database/b30list/DatabaseB30ListScreen.kt | 9 ++- .../b30list/DatabaseB30ListViewModel.kt | 80 ++++++++++++------- .../DatabasePlayResultListViewModel.kt | 11 ++- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../core/database/entities/PlayResult.kt | 10 +++ 9 files changed, 108 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt index ba9d800c..c2b79394 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.navigation.MainScreen @@ -38,6 +39,7 @@ fun DatabaseNavEntry( ) { val navContext = LocalListDetailNavigationContext.current val statusUiState by vm.statusUiState.collectAsStateWithLifecycle() + val scoringMode by vm.scoringMode.collectAsStateWithLifecycle() Scaffold( modifier, @@ -88,21 +90,29 @@ fun DatabaseNavEntry( } } + // The B50 route reuses the best list screen with B50 rules item { NavEntryNavigateButton( - titleResId = DatabaseSubScreen.B30.title, + titleResId = + when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> DatabaseSubScreen.B30.title + ArcaeaScoringMode.B50 -> R.string.database_b50_list_title + }, icon = Icons.Default.Star, ) { navContext.navigateToDetail(DatabaseSubScreen.B30.route) } } - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.R30.title, - icon = Icons.Default.History, - ) { - navContext.navigateToDetail(DatabaseSubScreen.R30.route) + // The recent queue only exists under the B30 + R10 rules + if (scoringMode == ArcaeaScoringMode.B30_R10) { + item { + NavEntryNavigateButton( + titleResId = DatabaseSubScreen.R30.title, + icon = Icons.Default.History, + ) { + navContext.navigateToDetail(DatabaseSubScreen.R30.route) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt index dfcb433b..7eed4fc9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt @@ -3,8 +3,10 @@ package xyz.sevive.arcaeaoffline.ui.screens.database import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository @@ -77,4 +79,13 @@ class DatabaseNavEntryViewModel( companion object { private const val TIMEOUT_MILLIS = 1000L } + + val scoringMode: StateFlow = + propertyRepo + .scoringMode() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), + ArcaeaScoringMode.B50, + ) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt index 7bdca576..0963a5b3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt @@ -134,6 +134,7 @@ private fun DatabaseB30ListItemPreview() { } else { null }, + playRatingText = "13.000", chartInfo = if (withDifficulty) { ChartInfo("test", ArcaeaRatingClass.FUTURE, constant = 115, notes = null) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt index ebc22316..18c62884 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.SubScreenTopAppBar import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen @@ -45,6 +46,12 @@ import kotlin.math.round @Composable fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() + + val titleResId = when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> DatabaseSubScreen.B30.title + ArcaeaScoringMode.B50 -> R.string.database_b50_list_title + } var showOptions by rememberSaveable { mutableStateOf(false) } if (showOptions) { @@ -80,7 +87,7 @@ fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) SubScreenContainer( topBar = { SubScreenTopAppBar( - title = { Text(stringResource(DatabaseSubScreen.B30.title)) }, + title = { Text(stringResource(titleResId)) }, actions = { IconButton(onClick = { viewModel.forceReload() }) { Icon(Icons.Default.Refresh, null) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt index 0b54e10c..89fd3754 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode @@ -15,6 +16,7 @@ import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultBestRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder import kotlin.time.Duration.Companion.seconds @@ -23,13 +25,14 @@ class DatabaseB30ListViewModel( playResultBestRepo: PlayResultBestRepository, difficultyWithSongRepo: DifficultyWithSongRepository, chartInfoRepo: ChartInfoRepository, + propertyRepo: PropertyRepository, ) : ViewModel() { data class ListItem( val index: Int, val playResultBest: PlayResultCalculated, val difficultyWithSong: DifficultyWithSong?, val chartInfo: ChartInfo?, - val playRatingText: String = ArcaeaFormatters.potentialToText(playResultBest.playRating), + val playRatingText: String, ) data class UiState( @@ -40,41 +43,58 @@ class DatabaseB30ListViewModel( private val limit = MutableStateFlow(INIT_LIMIT) + val scoringMode = + propertyRepo + .scoringMode() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + ArcaeaScoringMode.B50, + ) + @OptIn(ExperimentalCoroutinesApi::class) val uiState = - limit - .transformLatest { limit -> - emit(UiState(isLoading = true, limit = limit)) + combine(limit, propertyRepo.scoringMode()) { limit, scoringMode -> + limit to scoringMode + }.transformLatest { (limit, scoringMode) -> + emit(UiState(isLoading = true, limit = limit)) - playResultBestRepo - .orderDescWithLimit(limit, ArcaeaScoringMode.B30_R10) - .collectLatest { dbItems -> - val chartCacheHolder = UiDisplayChartCacheHolder() - chartCacheHolder.updateCache( - dbItems.map { it.playResult.songId to it.playResult.ratingClass }, - difficultyWithSongRepo, - chartInfoRepo, - ) + playResultBestRepo + .orderDescWithLimit(limit, scoringMode) + .collectLatest { dbItems -> + val chartCacheHolder = UiDisplayChartCacheHolder() + chartCacheHolder.updateCache( + dbItems.map { it.playResult.songId to it.playResult.ratingClass }, + difficultyWithSongRepo, + chartInfoRepo, + ) - val listItems = - dbItems.mapIndexed { i, dbItem -> - val display = chartCacheHolder.get(dbItem.playResult) + val listItems = + dbItems.mapIndexed { i, dbItem -> + val display = chartCacheHolder.get(dbItem.playResult) - ListItem( - index = i, - playResultBest = dbItem, - difficultyWithSong = display?.difficultyWithSong, - chartInfo = display?.chartInfo, - ) - } + ListItem( + index = i, + playResultBest = dbItem, + difficultyWithSong = display?.difficultyWithSong, + chartInfo = display?.chartInfo, + playRatingText = + ArcaeaFormatters.potentialToText( + when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> dbItem.playRating + ArcaeaScoringMode.B50 -> dbItem.playRatingWithClearBonus + }, + ), + ) + } - emit(UiState(isLoading = false, limit = limit, listItems = listItems)) - } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - UiState(), - ) + emit(UiState(isLoading = false, limit = limit, listItems = listItems)) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + UiState(), + ) fun setLimit(limit: Int) { this.limit.value = limit diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt index a046b862..4614e96e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt @@ -18,11 +18,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.entities.playRating import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters @@ -35,6 +37,7 @@ class DatabasePlayResultListViewModel( private val songRepo: SongRepository, private val difficultyWithSongRepo: DifficultyWithSongRepository, private val chartInfoRepo: ChartInfoRepository, + private val propertyRepo: PropertyRepository, ) : ViewModel() { enum class SortOrder { ASC, @@ -50,9 +53,10 @@ class DatabasePlayResultListViewModel( val playResult: PlayResult, val display: UiDisplayChartCacheHolder.Entry? = null, val isDeletedInGame: Boolean = false, + val scoringMode: ArcaeaScoringMode, ) { val uuid = playResult.uuid - val playRating = display?.chartInfo?.let { playResult.playRating(it) } + val playRating = display?.chartInfo?.let { playResult.playRating(it, scoringMode) } val potentialText = buildAnnotatedString { val baseText = ArcaeaFormatters.potentialToText(playRating) @@ -84,7 +88,9 @@ class DatabasePlayResultListViewModel( @OptIn(ExperimentalCoroutinesApi::class) val rawListItems = - playResultRepo.findAll().transformLatest { dbItems -> + combine(playResultRepo.findAll(), propertyRepo.scoringMode()) { dbItems, scoringMode -> + dbItems to scoringMode + }.transformLatest { (dbItems, scoringMode) -> isLoading.value = true val chartCache = UiDisplayChartCacheHolder() @@ -108,6 +114,7 @@ class DatabasePlayResultListViewModel( playResult = playResult, display = display, isDeletedInGame = playResult.songId in deletedSongIds, + scoringMode = scoringMode, ) } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5758be03..0b1f116a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -174,6 +174,7 @@ B30 列表 + B50 列表 R30 列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba5eb698..6f5aea71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,7 @@ CC BY-SA 3.0 Best30 List + Best50 List Recent30 List diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt index acf7c25f..979b78c8 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt @@ -9,6 +9,7 @@ import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.extensions.PlayResultSerializer import kotlin.time.Instant import kotlin.uuid.Uuid @@ -40,3 +41,12 @@ data class PlayResult( fun PlayResult.playRating(constant: Int): Double = calculatePlayRating(this.score, constant) fun PlayResult.playRating(chartInfo: ChartInfo) = playRating(chartInfo.constant) + +fun PlayResult.playRating( + chartInfo: ChartInfo, + scoringMode: ArcaeaScoringMode, +): Double = + when (scoringMode) { + ArcaeaScoringMode.B30_R10 -> playRating(chartInfo) + ArcaeaScoringMode.B50 -> calculatePlayRating(score, chartInfo.constant, clearType) + } From 32668b477325974f397333d5f4e8d5bb147ba6aa Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 20:59:24 +0800 Subject: [PATCH 08/35] feat(app.ui): add clear-type selector to play rating calculator Under the B50 rules the clear state changes the single-play potential by the clear bonus, so the calculator takes it as an input (default: no clear type, i.e. no bonus). The result is shown to 6 decimals as a tool output, independent of the official display precision. --- .../ui/components/PlayRatingCalculator.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index b304c2d7..87dc2480 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.ui.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,18 +15,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @Composable @@ -38,6 +45,28 @@ fun PlayRatingCalculator( ) { val scoreTextFieldFocusRequester = remember { FocusRequester() } + // No clear type means no clear bonus (same as TRACK_LOST) + var clearType by remember { mutableStateOf(null) } + + var showClearTypeSelectDialog by rememberSaveable { mutableStateOf(false) } + if (showClearTypeSelectDialog) { + val values = remember { ArcaeaPlayResultClearType.entries.sortedBy { it.value } } + SelectDialog( + // Index 0 is the no-clear-type option; the entries follow from index 1 + labels = + buildList { + add(AnnotatedString(stringResource(R.string.play_result_no_clear_type))) + values.forEach { add(AnnotatedString(it.toDisplayString())) } + }, + onDismiss = { showClearTypeSelectDialog = false }, + onSelect = { + clearType = if (it == 0) null else values[it - 1] + showClearTypeSelectDialog = false + }, + selectedOptionIndex = clearType?.let { values.indexOf(it) + 1 }, + ) + } + val scoreTextFieldState = rememberArcaeaScoreTextFieldState( initialValue = score, From 8984fba2172937c83a974f7de3564807bda0a1bc Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 21:03:08 +0800 Subject: [PATCH 09/35] feat(app.ui): warn about missing clear type under B50 scoring A record without a clear type is rated as TRACK LOST under the B50 rules, which is invisible to the user. The new CLEAR_TYPE_MISSING warning surfaces such records in the play result editor and lists so they can be filled in; it only appears under B50, where it affects the rating. The warning stays out of the plain validate(): OCR import uses it as a data-quality gate, and a missing clear type is valid data there. --- .../helpers/ArcaeaPlayResultValidator.kt | 22 +++++++++++++++++++ .../ArcaeaPlayResultValidatorWarnings.kt | 17 ++++++++++++++ .../DatabaseAddPlayResultViewModel.kt | 18 +++++++++++---- .../DatabasePlayResultListViewModel.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt index 2b88f80f..e47330c4 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.helpers +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult @@ -31,4 +32,25 @@ object ArcaeaPlayResultValidator { return warnings.toList() } + + /** + * Display-oriented validation: on top of [validate], flags records whose + * missing clear type changes their B50 rating, so the user can fill it in. + */ + fun validate( + playResult: PlayResult, + chartInfo: ChartInfo?, + scoringMode: ArcaeaScoringMode, + ): List { + val warnings = validate(playResult, chartInfo).toMutableList() + + if ( + scoringMode == ArcaeaScoringMode.B50 && + ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, chartInfo) + ) { + warnings.add(ArcaeaPlayResultValidatorClearTypeMissingWarning) + } + + return warnings + } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 84f3be97..6a71e15b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -244,3 +244,20 @@ data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : (playResult.clearType == ArcaeaPlayResultClearType.EASY_CLEAR && playResult.modifier != ArcaeaPlayResultModifier.EASY) || (playResult.clearType == ArcaeaPlayResultClearType.HARD_CLEAR && playResult.modifier != ArcaeaPlayResultModifier.HARD) } + +data object ArcaeaPlayResultValidatorClearTypeMissingWarning : + ArcaeaPlayResultValidatorWarning { + override val id = "CLEAR_TYPE_MISSING" + + override val title = "Clear type missing" + override val titleId = R.string.play_result_validator_CLEAR_TYPE_MISSING_title + + override val message = + "The B50 rules count a play without a clear type as TRACK LOST, so it earns no clear bonus." + override val messageId = R.string.play_result_validator_CLEAR_TYPE_MISSING_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean = playResult.clearType == null +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt index 70fdcd01..cd7c4649 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt @@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning import kotlin.time.Duration.Companion.seconds @@ -22,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds class DatabaseAddPlayResultViewModel( private val chartInfoRepo: ChartInfoRepository, private val playResultRepo: PlayResultRepository, + private val propertyRepo: PropertyRepository, ) : ViewModel() { data class UiState( val difficulty: Difficulty? = null, @@ -35,19 +38,26 @@ class DatabaseAddPlayResultViewModel( private val _playResult = MutableStateFlow(null) val playResult = _playResult.asStateFlow() - private suspend fun getPlayResultWarnings(playResult: PlayResult?): List { + private suspend fun getPlayResultWarnings( + playResult: PlayResult?, + scoringMode: ArcaeaScoringMode, + ): List { if (playResult == null) return emptyList() val chartInfo = chartInfoRepo.find(playResult).firstOrNull() - return ArcaeaPlayResultValidator.validate(playResult, chartInfo) + return ArcaeaPlayResultValidator.validate(playResult, chartInfo, scoringMode) } val uiState = - combine(difficulty, playResult) { difficulty, playResult -> + combine( + difficulty, + playResult, + propertyRepo.scoringMode(), + ) { difficulty, playResult, scoringMode -> UiState( difficulty = difficulty, playResult = playResult, - warnings = getPlayResultWarnings(playResult), + warnings = getPlayResultWarnings(playResult, scoringMode), ) }.stateIn( viewModelScope, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt index 4614e96e..a9570d46 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt @@ -71,7 +71,7 @@ class DatabasePlayResultListViewModel( } append(baseText) } - val warnings = ArcaeaPlayResultValidator.validate(playResult, display?.chartInfo) + val warnings = ArcaeaPlayResultValidator.validate(playResult, display?.chartInfo, scoringMode) } data class UiState( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0b1f116a..575172f0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -326,6 +326,8 @@ 分数为零 分数为 0。 + 缺少通关状态 + B50 规则下,缺少通关状态的成绩按 TRACK LOST 计,无法获得通关奖励。 分数不在范围内 所给分数不在由 PURE、FAR 计算而得的分数范围内 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f5aea71..6eb21f6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -360,6 +360,8 @@ CC BY-SA 3.0 Score is zero Score is zero. + Clear type missing + The B50 rules count a play without a clear type as TRACK LOST, so it earns no clear bonus. Score out of range The score calculation based on the pure/far value does not match the given score. From e19f911a9324e71ecb088a812f726255523b773e Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 21:04:11 +0800 Subject: [PATCH 10/35] feat(app.ui): seed chart recommend target by scoring mode The initial target play rating mirrors the player's current level; use the b50 average under B50 instead of the b30 average, which no longer represents it. --- .../UtilitiesChartRecommendScreenViewModel.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt index 22295958..2156a5ae 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt @@ -17,9 +17,11 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds @@ -33,6 +35,7 @@ data class UtilitiesChartRecommendScreenUiState( class UtilitiesChartRecommendScreenViewModel( private val difficultyWithSongRepo: DifficultyWithSongRepository, private val potentialRepo: PotentialRepository, + private val propertyRepo: PropertyRepository, ) : ViewModel() { private val logger = Logger.withTag("UtilitiesChartRecommendScreenVM") @@ -46,7 +49,15 @@ class UtilitiesChartRecommendScreenViewModel( init { viewModelScope.launch { - potentialRepo.b30().firstOrNull()?.let { + // Seed the target with the current level of the active scoring rules + val mode = propertyRepo.scoringMode().firstOrNull() ?: ArcaeaScoringMode.B50 + val currentPotential = + when (mode) { + ArcaeaScoringMode.B30_R10 -> potentialRepo.b30().firstOrNull() + ArcaeaScoringMode.B50 -> potentialRepo.b50().firstOrNull() + } + + currentPotential?.let { targetPlayRating.value = ((it + 0.05) * 100).roundToInt() / 100.0 } } From 536b4023e2c32dddd236806af0249f4c5ef2c7cf Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 21:27:28 +0800 Subject: [PATCH 11/35] fix(app.ui): actually apply clear bonus and 6-decimal output in calculator Part of the previous calculator commit did not land: the result was still computed without the clear type and formatted at 4 decimals, and the clear-type row was missing so the selector dialog was unreachable. --- .../ui/components/PlayRatingCalculator.kt | 26 +++++++++++++++++-- .../DatabaseBestListItem.kt} | 0 .../DatabaseBestListScreen.kt} | 0 .../DatabaseBestListViewModel.kt} | 0 4 files changed, 24 insertions(+), 2 deletions(-) rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/{b30list/DatabaseB30ListItem.kt => bestlist/DatabaseBestListItem.kt} (100%) rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/{b30list/DatabaseB30ListScreen.kt => bestlist/DatabaseBestListScreen.kt} (100%) rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/{b30list/DatabaseB30ListViewModel.kt => bestlist/DatabaseBestListViewModel.kt} (100%) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index 87dc2480..fcdf79ee 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -102,7 +102,7 @@ fun PlayRatingCalculator( scoreValue ?: return@derivedStateOf null constantValue ?: return@derivedStateOf null - calculatePlayRating(scoreValue!!, constantValue!!) + calculatePlayRating(scoreValue!!, constantValue!!, clearType) } } @@ -132,12 +132,34 @@ fun PlayRatingCalculator( ) { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null) + // A tool output: shown to 6 decimals, independent of the official + // display precision Text( - potential?.let { String.format(null, "%.4f", it) } ?: "?", + potential?.let { ArcaeaFormatters.potentialToText(it, scale = 6) } ?: "?", Modifier.weight(1f), style = MaterialTheme.typography.titleLarge, ) } + + Row( + Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.arcaea_play_result_clear_type), + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + clearType?.toDisplayString() + ?: stringResource(R.string.play_result_no_clear_type), + Modifier + .weight(1f) + .clickable { showClearTypeSelectDialog = true }, + color = MaterialTheme.colorScheme.primary, + ) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListScreen.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt From dcba64bf5c9773e62fb2ae0cda15537bdefbc934 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 9 Sep 2026 21:28:10 +0800 Subject: [PATCH 12/35] refactor(app.ui): update best list internals to the new names Class names, package declaration, enum entry, route, string keys and references now say best_list; the screen title no longer switches per scoring mode since the entry name is mode-neutral. --- .../xyz/sevive/arcaeaoffline/di/AppModule.kt | 4 ++-- .../arcaeaoffline/ui/navigation/Database.kt | 2 +- .../ui/screens/database/DatabaseEntryScreen.kt | 4 ++-- .../ui/screens/database/DatabaseNavEntry.kt | 10 +++------- .../database/bestlist/DatabaseBestListItem.kt | 16 ++++++++-------- .../database/bestlist/DatabaseBestListScreen.kt | 15 ++++----------- .../bestlist/DatabaseBestListViewModel.kt | 13 ++----------- app/src/main/res/values-zh-rCN/strings.xml | 3 +-- app/src/main/res/values/strings.xml | 3 +-- 9 files changed, 24 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt index 95d6f99b..bbc0ddfc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt @@ -38,7 +38,7 @@ import xyz.sevive.arcaeaoffline.ui.activities.EmergencyModeActivityViewModel import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.DatabaseNavEntryViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult.DatabaseAddPlayResultViewModel -import xyz.sevive.arcaeaoffline.ui.screens.database.b30list.DatabaseB30ListViewModel +import xyz.sevive.arcaeaoffline.ui.screens.database.bestlist.DatabaseBestListViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.deduplicator.DatabaseDeduplicatorViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListViewModel @@ -122,7 +122,7 @@ val appModule = viewModel() viewModel() viewModel() - viewModel() + viewModel() viewModel() viewModel() viewModel() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt index 45ef5e83..1777f178 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt @@ -12,7 +12,7 @@ enum class DatabaseSubScreen( Manage("$DATABASE_NAV_ROUTE_ROOT/manage", R.string.database_manage_title), AddPlayResult("$DATABASE_NAV_ROUTE_ROOT/add_play_result", R.string.database_add_play_result_title), ScoreList("$DATABASE_NAV_ROUTE_ROOT/score_list", R.string.database_play_result_list_title), - B30("$DATABASE_NAV_ROUTE_ROOT/b30_list", R.string.database_b30_list_title), + BestList("$DATABASE_NAV_ROUTE_ROOT/best_list", R.string.database_best_list_title), R30("$DATABASE_NAV_ROUTE_ROOT/r30_list", R.string.database_r30_list_title), Deduplicator("$DATABASE_NAV_ROUTE_ROOT/deduplicator", R.string.database_deduplicator_title), } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt index 1a81c608..1bb7da45 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import xyz.sevive.arcaeaoffline.ui.AdaptiveEntryScreen import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult.DatabaseAddPlayResultScreen -import xyz.sevive.arcaeaoffline.ui.screens.database.b30list.DatabaseB30ListScreen +import xyz.sevive.arcaeaoffline.ui.screens.database.bestlist.DatabaseBestListScreen import xyz.sevive.arcaeaoffline.ui.screens.database.deduplicator.DatabaseDeduplicatorScreen import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageScreen import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListScreen @@ -19,7 +19,7 @@ fun DatabaseEntryScreen() = DatabaseSubScreen.Manage.route -> DatabaseManageScreen() DatabaseSubScreen.AddPlayResult.route -> DatabaseAddPlayResultScreen() DatabaseSubScreen.ScoreList.route -> DatabasePlayResultListScreen() - DatabaseSubScreen.B30.route -> DatabaseB30ListScreen() + DatabaseSubScreen.BestList.route -> DatabaseBestListScreen() DatabaseSubScreen.R30.route -> DatabaseR30ListScreen() DatabaseSubScreen.Deduplicator.route -> DatabaseDeduplicatorScreen() } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt index c2b79394..a8c34de1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt @@ -90,17 +90,13 @@ fun DatabaseNavEntry( } } - // The B50 route reuses the best list screen with B50 rules + // The best list screen applies the active scoring mode's rules item { NavEntryNavigateButton( - titleResId = - when (scoringMode) { - ArcaeaScoringMode.B30_R10 -> DatabaseSubScreen.B30.title - ArcaeaScoringMode.B50 -> R.string.database_b50_list_title - }, + titleResId = DatabaseSubScreen.BestList.title, icon = Icons.Default.Star, ) { - navContext.navigateToDetail(DatabaseSubScreen.B30.route) + navContext.navigateToDetail(DatabaseSubScreen.BestList.route) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt index 0963a5b3..4b7a0d60 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListItem.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.b30list +package xyz.sevive.arcaeaoffline.ui.screens.database.bestlist import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,8 +33,8 @@ import kotlin.time.Instant import kotlin.uuid.Uuid @Composable -internal fun DatabaseB30ListItem( - item: DatabaseB30ListViewModel.ListItem, +internal fun DatabaseBestListItem( + item: DatabaseBestListViewModel.ListItem, modifier: Modifier = Modifier, ) { val indexTextStyle = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold) @@ -88,12 +88,12 @@ internal fun DatabaseB30ListItem( @Preview @Composable -private fun DatabaseB30ListItemPreview() { +private fun DatabaseBestListItemPreview() { fun fakeListItem( index: Int, withDifficulty: Boolean = true, - ): DatabaseB30ListViewModel.ListItem = - DatabaseB30ListViewModel.ListItem( + ): DatabaseBestListViewModel.ListItem = + DatabaseBestListViewModel.ListItem( index = index, playResultBest = PlayResultCalculated( @@ -146,8 +146,8 @@ private fun DatabaseB30ListItemPreview() { ArcaeaOfflineTheme { Surface { Column { - DatabaseB30ListItem(fakeListItem(index = 0)) - DatabaseB30ListItem(fakeListItem(index = 1, withDifficulty = true)) + DatabaseBestListItem(fakeListItem(index = 0)) + DatabaseBestListItem(fakeListItem(index = 1, withDifficulty = true)) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt index 18c62884..6875657e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.b30list +package xyz.sevive.arcaeaoffline.ui.screens.database.bestlist import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform @@ -35,7 +35,6 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.SubScreenTopAppBar import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen @@ -44,14 +43,8 @@ import kotlin.math.round @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) { +fun DatabaseBestListScreen(viewModel: DatabaseBestListViewModel = koinViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() - - val titleResId = when (scoringMode) { - ArcaeaScoringMode.B30_R10 -> DatabaseSubScreen.B30.title - ArcaeaScoringMode.B50 -> R.string.database_b50_list_title - } var showOptions by rememberSaveable { mutableStateOf(false) } if (showOptions) { @@ -87,7 +80,7 @@ fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) SubScreenContainer( topBar = { SubScreenTopAppBar( - title = { Text(stringResource(titleResId)) }, + title = { Text(stringResource(DatabaseSubScreen.BestList.title)) }, actions = { IconButton(onClick = { viewModel.forceReload() }) { Icon(Icons.Default.Refresh, null) @@ -111,7 +104,7 @@ fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), ) { items(uiState.listItems, key = { it.index }) { - DatabaseB30ListItem(it, Modifier.animateItem()) + DatabaseBestListItem(it, Modifier.animateItem()) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt index 89fd3754..11ffaedd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.b30list +package xyz.sevive.arcaeaoffline.ui.screens.database.bestlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,7 +21,7 @@ import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder import kotlin.time.Duration.Companion.seconds -class DatabaseB30ListViewModel( +class DatabaseBestListViewModel( playResultBestRepo: PlayResultBestRepository, difficultyWithSongRepo: DifficultyWithSongRepository, chartInfoRepo: ChartInfoRepository, @@ -43,15 +43,6 @@ class DatabaseB30ListViewModel( private val limit = MutableStateFlow(INIT_LIMIT) - val scoringMode = - propertyRepo - .scoringMode() - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - ArcaeaScoringMode.B50, - ) - @OptIn(ExperimentalCoroutinesApi::class) val uiState = combine(limit, propertyRepo.scoringMode()) { limit, scoringMode -> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 575172f0..a1642506 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -173,8 +173,7 @@ 游玩记录 %s 已更新 - B30 列表 - B50 列表 + 最佳记录列表 R30 列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6eb21f6f..8d6bb3ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,8 +195,7 @@ CC BY-SA 3.0 Play result %s updated - Best30 List - Best50 List + Best Records List Recent30 List From 95a51f963405ec0e0ec0d4b23f4a7a73c8d40a25 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 16 Sep 2026 23:33:29 +0800 Subject: [PATCH 13/35] refactor(app.ui): calculator clear-type row as a preferences widget Renders as a TextPreferencesWidget row: the label as title, the current selection as the description line and a trailing expand icon, matching the other preference-driven selectors instead of an ad-hoc text row. --- .../ui/components/PlayRatingCalculator.kt | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index fcdf79ee..69e1801b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -1,12 +1,12 @@ package xyz.sevive.arcaeaoffline.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowRight +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -32,6 +32,7 @@ import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -141,25 +142,14 @@ fun PlayRatingCalculator( ) } - Row( - Modifier.padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - stringResource(R.string.arcaea_play_result_clear_type), - style = MaterialTheme.typography.bodyMedium, - ) - - Text( + TextPreferencesWidget( + title = stringResource(R.string.arcaea_play_result_clear_type), + content = clearType?.toDisplayString() ?: stringResource(R.string.play_result_no_clear_type), - Modifier - .weight(1f) - .clickable { showClearTypeSelectDialog = true }, - color = MaterialTheme.colorScheme.primary, - ) - } + trailingIcon = Icons.Default.ExpandMore, + onClick = { showClearTypeSelectDialog = true }, + ) } } From 8e47a7c57fcfe1eabd54473c028f40125c23c1b5 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 17 Sep 2026 10:33:45 +0800 Subject: [PATCH 14/35] fix: st3 cleartype parsing --- .../importers/ArcaeaSt3PlayResultImporter.kt | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt index 1096f92a..9d806382 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt @@ -51,18 +51,6 @@ private data class St3PlayResult( val modifier: Int?, val clearType: Int?, ) { - val isClearTypeReliable: Boolean - get() { - if (clearType == ArcaeaPlayResultClearType.FULL_RECALL.value && lost != 0) { - return false - } - if (clearType == ArcaeaPlayResultClearType.PURE_MEMORY.value && lost != 0 && far != 0) { - return false - } - - return true - } - fun toPlayResult(importDate: LocalDate): PlayResult { val commentDateString = importDate.format(LocalDate.Formats.ISO) @@ -75,14 +63,7 @@ private data class St3PlayResult( lost = lost, date = date?.let { Instant.fromEpochSeconds(it) }, modifier = modifier?.let { ArcaeaPlayResultModifier.fromInt(modifier) }, - clearType = - if (!isClearTypeReliable) { - null - } else { - clearType?.let { - ArcaeaPlayResultClearType.fromInt(it) - } - }, + clearType = clearType?.let { ArcaeaPlayResultClearType.fromInt(it) }, comment = "Imported from st3 at $commentDateString", ) } @@ -111,8 +92,7 @@ object ArcaeaSt3PlayResultImporter { ct.clearType FROM scores s - JOIN cleartypes ct ON s.songId = ct.songId - AND s.songDifficulty = ct.songDifficulty""", + LEFT JOIN cleartypes ct ON s.songId = ct.songId AND s.songDifficulty = ct.songDifficulty""", ).use { stmt -> while (stmt.step()) { val st3PlayResult = @@ -130,15 +110,26 @@ FROM var playResult = st3PlayResult.toPlayResult(importDate) - if (playResult.clearType == ArcaeaPlayResultClearType.FULL_RECALL) { - playResult = - playResult.copy( - maxRecall = playResult.pure!! + playResult.far!!, - ) + val pure = playResult.pure + val far = playResult.far + val lost = playResult.lost + + if ( + playResult.clearType == ArcaeaPlayResultClearType.FULL_RECALL && + lost == 0 && + pure != null && + far != null + ) { + playResult = playResult.copy(maxRecall = pure + far) } - if (playResult.clearType == ArcaeaPlayResultClearType.PURE_MEMORY) { - playResult = playResult.copy(maxRecall = playResult.pure) + if ( + playResult.clearType == ArcaeaPlayResultClearType.PURE_MEMORY && + far == 0 && + lost == 0 && + pure != null + ) { + playResult = playResult.copy(maxRecall = pure) } items.add(playResult) From 3aa493fbc299c5563157202a4e5c84b088ecdc3a Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 17 Sep 2026 10:53:57 +0800 Subject: [PATCH 15/35] test: add st3 importer tests --- .../importers/ArcaeaSt3PlayResultImporter.kt | 4 +- .../ArcaeaSt3PlayResultImporterTest.kt | 237 ++++++++++++++++++ .../database/externals/importers/st3_seed.sql | 66 +++++ 3 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporterTest.kt create mode 100644 core/src/test/resources/xyz/sevive/arcaeaoffline/core/database/externals/importers/st3_seed.sql diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt index 9d806382..be654706 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt @@ -31,7 +31,7 @@ import kotlin.time.Instant * * @param [ts] The `date` value from st3 */ -private fun fixSt3Timestamp(ts: Long?): Long? { +internal fun fixSt3Timestamp(ts: Long?): Long? { if (ts == null || ts > 1489017600) return ts val isFixable = (ts in 1489..9999) || ts > 14889 @@ -40,7 +40,7 @@ private fun fixSt3Timestamp(ts: Long?): Long? { return ts.toString().padEnd(10, '0').toLong() } -private data class St3PlayResult( +internal data class St3PlayResult( val songId: String, val ratingClass: Int, val score: Int, diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporterTest.kt b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporterTest.kt new file mode 100644 index 00000000..2c1fca2e --- /dev/null +++ b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporterTest.kt @@ -0,0 +1,237 @@ +package xyz.sevive.arcaeaoffline.core.database.externals.importers + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.datetime.LocalDate +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import kotlin.time.Instant + +class ArcaeaSt3PlayResultImporterTest { + @Test + fun fixSt3TimestampPadsTruncatedAndDropsInvalid() { + assertEquals(null, fixSt3Timestamp(null)) + assertEquals(1788385000L, fixSt3Timestamp(1788385)) + assertEquals(1566000000L, fixSt3Timestamp(1566)) + assertEquals(null, fixSt3Timestamp(0)) + assertEquals(null, fixSt3Timestamp(12000)) + assertEquals(1489017600L, fixSt3Timestamp(1489017600)) + assertEquals(1789000000L, fixSt3Timestamp(1789000000)) + } + + @Test + fun toPlayResultMapsEnumsAndStampsComment() { + val result = + St3PlayResult( + songId = "a", + ratingClass = 3, + score = 9900000, + pure = 900, + far = 50, + lost = 0, + date = 1788385000, + modifier = 2, + clearType = 2, + ).toPlayResult(LocalDate(2026, 1, 2)) + + assertEquals("a", result.songId) + assertEquals(ArcaeaRatingClass.BEYOND, result.ratingClass) + assertEquals(9900000, result.score) + assertEquals(900, result.pure) + assertEquals(50, result.far) + assertEquals(0, result.lost) + assertEquals(Instant.fromEpochSeconds(1788385000), result.date) + assertEquals(ArcaeaPlayResultModifier.HARD, result.modifier) + assertEquals(ArcaeaPlayResultClearType.FULL_RECALL, result.clearType) + assertEquals("Imported from st3 at 2026-01-02", result.comment) + } + + private lateinit var conn: SQLiteConnection + + @Before + fun setUp() { + conn = BundledSQLiteDriver().open(":memory:") + } + + @After + fun tearDown() { + conn.close() + } + + private val seedSql: String by lazy { + javaClass + .getResourceAsStream("/xyz/sevive/arcaeaoffline/core/database/externals/importers/st3_seed.sql")!! + .bufferedReader() + .use { it.readText() } + } + + private fun execScript(script: String) { + script + .lineSequence() + .filterNot { it.trimStart().startsWith("--") } + .joinToString("\n") + .split(';') + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { statement -> + conn.prepare(statement).use { it.step() } + } + } + + private data class ImportedRow( + val songId: String, + val ratingClass: ArcaeaRatingClass, + val score: Int, + val pure: Int?, + val far: Int?, + val lost: Int?, + val date: Instant?, + val modifier: ArcaeaPlayResultModifier?, + val clearType: ArcaeaPlayResultClearType?, + val maxRecall: Int?, + ) + + private fun List.toImportedRows() = + map { + ImportedRow( + it.songId, + it.ratingClass, + it.score, + it.pure, + it.far, + it.lost, + it.date, + it.modifier, + it.clearType, + it.maxRecall, + ) + } + + @Test + fun importsSeedRows() { + execScript(seedSql) + + val result = ArcaeaSt3PlayResultImporter.playResults(conn) + + assertEquals( + listOf( + ImportedRow( + "a", + ArcaeaRatingClass.PRESENT, + 9900000, + 900, + 50, + 0, + Instant.fromEpochSeconds(1788385000), + ArcaeaPlayResultModifier.NORMAL, + ArcaeaPlayResultClearType.FULL_RECALL, + // pure + far: the row is consistent with its FR clear type + 950, + ), + ImportedRow( + "b", + ArcaeaRatingClass.PRESENT, + 9800000, + 800, + 99, + 1, + Instant.fromEpochSeconds(1670283375), + ArcaeaPlayResultModifier.HARD, + ArcaeaPlayResultClearType.FULL_RECALL, + // lost > 0: the best-score play is not the FR play + null, + ), + ImportedRow( + "c", + ArcaeaRatingClass.FUTURE, + 10000000, + 1000, + 0, + 0, + Instant.fromEpochSeconds(1670280000), + ArcaeaPlayResultModifier.EASY, + ArcaeaPlayResultClearType.PURE_MEMORY, + 1000, + ), + ImportedRow( + "d", + ArcaeaRatingClass.FUTURE, + 9700000, + 900, + 95, + 5, + null, + ArcaeaPlayResultModifier.NORMAL, + ArcaeaPlayResultClearType.PURE_MEMORY, + // far > 0: not consistent with PM + null, + ), + ImportedRow( + "e", + ArcaeaRatingClass.PAST, + 8000000, + null, + 100, + 50, + null, + ArcaeaPlayResultModifier.NORMAL, + ArcaeaPlayResultClearType.NORMAL_CLEAR, + null, + ), + ImportedRow( + "f", + ArcaeaRatingClass.BEYOND, + 7000000, + 700, + 0, + 0, + Instant.fromEpochSeconds(1789000000), + ArcaeaPlayResultModifier.NORMAL, + // no cleartypes row: the score survives with a null clear type + null, + null, + ), + ImportedRow( + "g", + ArcaeaRatingClass.BEYOND, + 6000000, + 600, + 30, + 20, + Instant.fromEpochSeconds(1566000000), + ArcaeaPlayResultModifier.NORMAL, + ArcaeaPlayResultClearType.TRACK_LOST, + null, + ), + ), + result.toImportedRows(), + ) + + assertTrue(result.all { it.comment?.startsWith("Imported from st3 at ") == true }) + } + + @Test + fun emptyTablesYieldEmptyList() { + execScript( + """ + CREATE TABLE scores( + id integer primary key autoincrement not null, + version int, score int, shinyPerfectCount int, perfectCount int, + nearCount int, missCount int, date int, songId text, + songDifficulty int, modifier int, health int, ct int default 0); + + CREATE TABLE cleartypes( + id integer primary key autoincrement not null, + songId text, songDifficulty int, clearType int, ct int default 0); + """.trimIndent(), + ) + + assertEquals(emptyList(), ArcaeaSt3PlayResultImporter.playResults(conn).toImportedRows()) + } +} diff --git a/core/src/test/resources/xyz/sevive/arcaeaoffline/core/database/externals/importers/st3_seed.sql b/core/src/test/resources/xyz/sevive/arcaeaoffline/core/database/externals/importers/st3_seed.sql new file mode 100644 index 00000000..5b455b32 --- /dev/null +++ b/core/src/test/resources/xyz/sevive/arcaeaoffline/core/database/externals/importers/st3_seed.sql @@ -0,0 +1,66 @@ +-- Seed data for ArcaeaSt3PlayResultImporterTest. +-- Statements are split on ';', so keep semicolons out of string values. +-- Columns the importer does not read (health, ct, etc.) are filled with 0. +CREATE TABLE scores ( + id INTEGER PRIMARY key autoincrement NOT NULL, + version INT, + score INT, + shinyPerfectCount INT, + perfectCount INT, + nearCount INT, + missCount INT, + "date" INT, + songId text, + songDifficulty INT, + modifier INT, + health INT, + ct INT DEFAULT 0 +); + +CREATE TABLE cleartypes ( + id INTEGER PRIMARY key autoincrement NOT NULL, + songId text, + songDifficulty INT, + clearType INT, + ct INT DEFAULT 0 +); + +CREATE TABLE schemaversion (appliedVersion INT UNIQUE PRIMARY key NOT NULL); + +INSERT INTO schemaversion (appliedVersion) VALUES (4); + +-- FULL RECALL with a self-consistent row: max recall derived +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 9900000, 100, 900, 50, 0, 1788385000, 'a', 1, 0, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('a', 1, 2, 0); + +-- FULL RECALL with lost > 0 (the best-score play is not the FR play): +-- max recall stays null +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 9800000, 90, 800, 99, 1, 1670283375, 'b', 1, 2, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('b', 1, 2, 0); + +-- PURE MEMORY, self-consistent: max recall derived; truncated date padded +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 10000000, 20, 1000, 0, 0, 167028, 'c', 2, 1, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('c', 2, 3, 0); + +-- PURE MEMORY with far > 0: max recall stays null; zero date dropped +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 9700000, 80, 900, 95, 5, 0, 'd', 2, 0, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('d', 2, 3, 0); + +-- Nullable play counts pass through as null +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 8000000, 0, NULL, 100, 50, 0, 'e', 0, 0, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('e', 0, 1, 0); + +-- No cleartypes row: the score is kept, clearType falls to null; +-- a full-precision date passes through untouched +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 7000000, 0, 700, 0, 0, 1789000000, 'f', 3, 0, 0, 0); + +-- TRACK LOST; 4-digit date padded +INSERT INTO scores (version, score, shinyPerfectCount, perfectCount, nearCount, missCount, "date", songId, songDifficulty, modifier, health, ct) +VALUES (1, 6000000, 0, 600, 30, 20, 1566, 'g', 3, 0, 0, 0); +INSERT INTO cleartypes (songId, songDifficulty, clearType, ct) VALUES ('g', 3, 0, 0); From e65c78d093ca210a5fdd6f54b82d74a6449a8869 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 22 Sep 2026 03:37:35 +0800 Subject: [PATCH 16/35] chore: format code --- .../preferences/SelectPreferencesWidget.kt | 19 +++++++++--------- .../screens/overview/OverviewPotentialCard.kt | 9 +++++---- .../UtilitiesChartRecommendScreen.kt | 2 +- .../repositories/PotentialRepository.kt | 12 +++++------ .../core/calculators/CommonCalculatorsTest.kt | 20 ++++++++++--------- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt index c7010886..fccfe46f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt @@ -30,15 +30,16 @@ fun SelectPreferencesWidget( BasePreferencesWidget( onClick = { onSelect(option.value) }, title = { Text(option.label) }, - content = option.description?.let { description -> - { - Text( - description, - Modifier.secondaryItemAlpha(), - style = MaterialTheme.typography.bodySmall, - ) - } - }, + content = + option.description?.let { description -> + { + Text( + description, + Modifier.secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + ) + } + }, leadingSlot = { RadioButton( selected = option.value == selected, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt index 32429ab8..7eed1630 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt @@ -50,10 +50,11 @@ internal fun OverviewPotentialCard( modifier: Modifier = Modifier, ) { // The official display precision is 0.01 for B30 + R10 and 0.001 for B50 - val mainScale = when (uiState.scoringMode) { - ArcaeaScoringMode.B30_R10 -> 2 - ArcaeaScoringMode.B50 -> 3 - } + val mainScale = + when (uiState.scoringMode) { + ArcaeaScoringMode.B30_R10 -> 2 + ArcaeaScoringMode.B50 -> 3 + } Card(modifier) { Row(Modifier.padding(dimensionResource(R.dimen.page_padding))) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index cf4bd405..ff8c7d5b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -57,7 +57,6 @@ import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField @@ -66,6 +65,7 @@ import xyz.sevive.arcaeaoffline.ui.components.PlayRatingCalculator import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.rememberDecimalStepperTextFieldState +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.UtilitiesSubScreen import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import kotlin.math.round diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt index af3e5b9e..fad76eac 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt @@ -31,11 +31,9 @@ class PotentialRepositoryImpl( private val r30EntryRepo: R30EntryRepository, private val propertyRepo: PropertyRepository, ) : PotentialRepository { - private fun b30Entries(): Flow> = - playResultBestRepo.orderDescWithLimit(30, ArcaeaScoringMode.B30_R10) + private fun b30Entries(): Flow> = playResultBestRepo.orderDescWithLimit(30, ArcaeaScoringMode.B30_R10) - private fun b50Entries(): Flow> = - playResultBestRepo.orderDescWithLimit(50, ArcaeaScoringMode.B50) + private fun b50Entries(): Flow> = playResultBestRepo.orderDescWithLimit(50, ArcaeaScoringMode.B50) private fun r10Entries(): Flow> = r30EntryRepo.findAllCombined().mapLatest { @@ -82,12 +80,13 @@ class PotentialRepositoryImpl( override fun potential() = propertyRepo.scoringMode().flatMapLatest { mode -> when (mode) { - ArcaeaScoringMode.B30_R10 -> + ArcaeaScoringMode.B30_R10 -> { combine(b30(), r10()) { b30, r10 -> b30 * 0.75 + r10 * 0.25 } + } - ArcaeaScoringMode.B50 -> + ArcaeaScoringMode.B50 -> { // Official v7.0 formula: the best 10 entries count twice, // i.e. (best50 sum + best10 sum) / 60 b50Entries().mapLatest { entries -> @@ -95,6 +94,7 @@ class PotentialRepositoryImpl( val b10Sum = entries.take(10).sumOf { it.playRatingWithClearBonus } (b50Sum + b10Sum) / 60 } + } } } } diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index ca6f191d..b7bee5e5 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -1,10 +1,10 @@ package xyz.sevive.arcaeaoffline.core.calculators +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType class CommonCalculatorsTest { @Test @@ -68,14 +68,16 @@ class CommonCalculatorsTest { assertEquals(calculatePlayRating(score, constant, null), 12.0, 0.0) assertEquals(calculatePlayRating(score, constant, ArcaeaPlayResultClearType.TRACK_LOST), 12.0, 0.0) - for (clearType in - listOf( - ArcaeaPlayResultClearType.NORMAL_CLEAR, - ArcaeaPlayResultClearType.FULL_RECALL, - ArcaeaPlayResultClearType.PURE_MEMORY, - ArcaeaPlayResultClearType.EASY_CLEAR, - ArcaeaPlayResultClearType.HARD_CLEAR, - )) { + for ( + clearType in + listOf( + ArcaeaPlayResultClearType.NORMAL_CLEAR, + ArcaeaPlayResultClearType.FULL_RECALL, + ArcaeaPlayResultClearType.PURE_MEMORY, + ArcaeaPlayResultClearType.EASY_CLEAR, + ArcaeaPlayResultClearType.HARD_CLEAR, + ) + ) { assertEquals(calculatePlayRating(score, constant, clearType), 12.2, 0.0) } From 5e546f236edffdfe24ef7f7de4b53a245e173a49 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 23 Sep 2026 13:24:40 +0800 Subject: [PATCH 17/35] feat(core): store the scoring mode when a database is created A fresh database has no `scoring_mode` row either, so it follows the fallback value until the user touches the mode selector. That fallback can change in a later release, and a database nobody configured would silently move with it. PropertyRepository.DEFAULT_SCORING_MODE names the fallback, so the callback that writes it and the read path that falls back to it cannot drift apart. --- .../16.json | 689 ++++++++++++++++++ .../core/DatabaseMigrationsTest.kt | 38 + .../core/ScoringModeCreateCallbackTest.kt | 46 ++ .../core/database/ArcaeaOfflineDatabase.kt | 5 +- .../database/ScoringModeCreateCallback.kt | 22 + .../database/migrations/Migration_15_16.kt | 22 + .../repositories/PropertyRepository.kt | 8 +- 7 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/16.json create mode 100644 core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/ScoringModeCreateCallbackTest.kt create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ScoringModeCreateCallback.kt create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_15_16.kt diff --git a/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/16.json b/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/16.json new file mode 100644 index 00000000..96aa50e3 --- /dev/null +++ b/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/16.json @@ -0,0 +1,689 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "695f3a8be7efe40bdffb285ef54f4a81", + "entities": [ + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "packs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "packs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `name` TEXT, `description` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `packs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_packs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_packs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_packs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "packs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idx` INTEGER NOT NULL, `id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `set` TEXT NOT NULL, `bpm` TEXT, `bpm_base` REAL, `audio_preview` INTEGER, `audio_preview_end` INTEGER, `side` INTEGER NOT NULL, `version` TEXT, `date` INTEGER, `bg` TEXT, `bg_inverse` TEXT, `bg_day` TEXT, `bg_night` TEXT, `source` TEXT, `source_copyright` TEXT, `deleted_in_game` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "idx", + "columnName": "idx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "set", + "columnName": "set", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "audioPreview", + "columnName": "audio_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioPreviewEnd", + "columnName": "audio_preview_end", + "affinity": "INTEGER" + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bgDay", + "columnName": "bg_day", + "affinity": "TEXT" + }, + { + "fieldPath": "bgNight", + "columnName": "bg_night", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceCopyright", + "columnName": "source_copyright", + "affinity": "TEXT" + }, + { + "fieldPath": "deletedInGame", + "columnName": "deleted_in_game", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "songs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `source` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `songs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_songs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_songs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_songs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "difficulties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `rating_class_alias` INTEGER, `rating` INTEGER NOT NULL, `rating_plus` INTEGER NOT NULL, `chart_designer` TEXT, `jacket_designer` TEXT, `audio_override` INTEGER NOT NULL, `jacket_override` INTEGER NOT NULL, `jacket_night` TEXT, `title` TEXT, `artist` TEXT, `bg` TEXT, `bg_inverse` TEXT, `bpm` TEXT, `bpm_base` REAL, `version` TEXT, `date` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingClassAlias", + "columnName": "rating_class_alias", + "affinity": "INTEGER" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingPlus", + "columnName": "rating_plus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chartDesigner", + "columnName": "chart_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "jacketDesigner", + "columnName": "jacket_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "audioOverride", + "columnName": "audio_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketOverride", + "columnName": "jacket_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketNight", + "columnName": "jacket_night", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "difficulties_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `artist` TEXT, PRIMARY KEY(`song_id`, `rating_class`), FOREIGN KEY(`song_id`, `rating_class`) REFERENCES `difficulties`(`song_id`, `rating_class`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + }, + "indices": [ + { + "name": "index_difficulties_localized_song_id_rating_class_lang", + "unique": true, + "columnNames": [ + "song_id", + "rating_class", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_difficulties_localized_song_id_rating_class_lang` ON `${TABLE_NAME}` (`song_id`, `rating_class`, `lang`)" + }, + { + "name": "index_difficulties_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_difficulties_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "difficulties", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "song_id", + "rating_class" + ], + "referencedColumns": [ + "song_id", + "rating_class" + ] + } + ] + }, + { + "tableName": "charts_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `constant` INTEGER NOT NULL, `notes` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constant", + "columnName": "constant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "play_results", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, `song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `score` INTEGER NOT NULL, `pure` INTEGER, `far` INTEGER, `lost` INTEGER, `date` INTEGER, `max_recall` INTEGER, `modifier` INTEGER, `clear_type` INTEGER, `comment` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pure", + "columnName": "pure", + "affinity": "INTEGER" + }, + { + "fieldPath": "far", + "columnName": "far", + "affinity": "INTEGER" + }, + { + "fieldPath": "lost", + "columnName": "lost", + "affinity": "INTEGER" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxRecall", + "columnName": "max_recall", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifier", + "columnName": "modifier", + "affinity": "INTEGER" + }, + { + "fieldPath": "clearType", + "columnName": "clear_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_play_results_uuid", + "unique": true, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_play_results_uuid` ON `${TABLE_NAME}` (`uuid`)" + }, + { + "name": "index_play_results_song_id_rating_class", + "unique": false, + "columnNames": [ + "song_id", + "rating_class" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_results_song_id_rating_class` ON `${TABLE_NAME}` (`song_id`, `rating_class`)" + } + ] + }, + { + "tableName": "r30_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, FOREIGN KEY(`uuid`) REFERENCES `play_results`(`uuid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_r30_entries_uuid", + "unique": false, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_r30_entries_uuid` ON `${TABLE_NAME}` (`uuid`)" + } + ], + "foreignKeys": [ + { + "table": "play_results", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '695f3a8be7efe40bdffb285ef54f4a81')" + ] + } +} \ No newline at end of file diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt index b2576d05..697ee0ba 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt @@ -10,6 +10,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_15_16 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 import java.io.IOException @@ -17,6 +18,8 @@ import java.io.IOException class DatabaseMigrationsTest { private val testDatabaseName7To8 = "arcaea-offline-7-8" private val testDatabaseName14To15 = "arcaea-offline-14-15" + private val testDatabaseName15To16Empty = "arcaea-offline-15-16-empty" + private val testDatabaseName15To16Kept = "arcaea-offline-15-16-kept" @get:Rule val helper: MigrationTestHelper = @@ -114,6 +117,41 @@ class DatabaseMigrationsTest { } } + @Test + @Throws(IOException::class) + fun migrate15To16() { + helper.createDatabase(testDatabaseName15To16Empty, 15).close() + + val db = + helper.runMigrationsAndValidate(testDatabaseName15To16Empty, 16, true, Migration_15_16) + + db + .query("SELECT `value` FROM properties WHERE `key` = 'scoring_mode'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("20260827", cursor.getString(0)) + } + } + + @Test + @Throws(IOException::class) + fun migrate15To16KeepsAChosenMode() { + helper.createDatabase(testDatabaseName15To16Kept, 15).apply { + execSQL("INSERT INTO properties (`key`, `value`) VALUES ('scoring_mode', '20170602')") + close() + } + + val db = + helper.runMigrationsAndValidate(testDatabaseName15To16Kept, 16, true, Migration_15_16) + + db + .query("SELECT `value` FROM properties WHERE `key` = 'scoring_mode'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("20170602", cursor.getString(0)) + } + } + private fun execSqlScript( db: SupportSQLiteDatabase, filename: String, diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/ScoringModeCreateCallbackTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/ScoringModeCreateCallbackTest.kt new file mode 100644 index 00000000..124f108f --- /dev/null +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/ScoringModeCreateCallbackTest.kt @@ -0,0 +1,46 @@ +package xyz.sevive.arcaeaoffline.core + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.ScoringModeCreateCallback +import xyz.sevive.arcaeaoffline.core.database.entities.Property +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository + +@RunWith(AndroidJUnit4::class) +class ScoringModeCreateCallbackTest { + private lateinit var db: ArcaeaOfflineDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, ArcaeaOfflineDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .addCallback(ScoringModeCreateCallback) + .build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun creationStoresTheScoringMode() { + val property = runBlocking { db.propertyDao().find(Property.KEY_SCORING_MODE).first() } + + assertEquals(PropertyRepository.DEFAULT_SCORING_MODE.key.toString(), property?.value) + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt index 800dddde..7fdcba09 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt @@ -39,6 +39,7 @@ import xyz.sevive.arcaeaoffline.core.database.entities.Song import xyz.sevive.arcaeaoffline.core.database.entities.SongLocalized import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_5_6 import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_9_10 +import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_15_16 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_6_7 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 @@ -66,7 +67,7 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), ], - version = 15, + version = 16, exportSchema = true, ) @TypeConverters( @@ -125,9 +126,11 @@ abstract class ArcaeaOfflineDatabase : RoomDatabase() { getDatabaseBuilder(context) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addCallback(ScoringModeCreateCallback) .addMigrations( Migration_6_7, Migration_7_8, + Migration_15_16, ).build() .also { instance = it } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ScoringModeCreateCallback.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ScoringModeCreateCallback.kt new file mode 100644 index 00000000..09184b9e --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ScoringModeCreateCallback.kt @@ -0,0 +1,22 @@ +package xyz.sevive.arcaeaoffline.core.database + +import androidx.room.RoomDatabase +import androidx.sqlite.SQLiteConnection +import xyz.sevive.arcaeaoffline.core.database.entities.Property +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository + +/** + * Stores the scoring mode while a database is being created, so what a database is + * interpreted with is written down rather than re-derived from the fallback on every read. + */ +internal object ScoringModeCreateCallback : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + connection + .prepare("INSERT OR IGNORE INTO `properties` (`key`, `value`) VALUES (?, ?)") + .use { statement -> + statement.bindText(1, Property.KEY_SCORING_MODE) + statement.bindText(2, PropertyRepository.DEFAULT_SCORING_MODE.key.toString()) + statement.step() + } + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_15_16.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_15_16.kt new file mode 100644 index 00000000..edaaa95e --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_15_16.kt @@ -0,0 +1,22 @@ +package xyz.sevive.arcaeaoffline.core.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Writes the scoring mode for databases that predate the `scoring_mode` property, so a + * later change of the fallback default cannot reinterpret their play results. + * + * The property key and the mode value are spelled out rather than taken from + * `Property.KEY_SCORING_MODE` and `ArcaeaScoringMode.B50`: what a migration writes has to + * keep its meaning after those declarations change. An existing row is preserved, since + * the property may already hold a mode the user chose. + */ +object Migration_15_16 : Migration(15, 16) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "INSERT OR IGNORE INTO `properties` (`key`, `value`) VALUES ('scoring_mode', '20260827')", + ) + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt index c40961e7..dbf81171 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt @@ -9,6 +9,11 @@ import xyz.sevive.arcaeaoffline.core.database.entities.Property import kotlin.time.Instant interface PropertyRepository { + companion object { + /** The default mode when database has no corresponding property, or one the app cannot recognize. */ + val DEFAULT_SCORING_MODE = ArcaeaScoringMode.B50 + } + fun find(key: String): Flow suspend fun upsert(item: Property) @@ -58,7 +63,8 @@ class PropertyRepositoryImpl( // database file. Falls back to the latest mode when unset or unknown. override fun scoringMode(): Flow = this.find(Property.KEY_SCORING_MODE).map { property -> - property?.value?.toIntOrNull()?.let { ArcaeaScoringMode.fromKey(it) } ?: ArcaeaScoringMode.B50 + property?.value?.toIntOrNull()?.let { ArcaeaScoringMode.fromKey(it) } + ?: PropertyRepository.DEFAULT_SCORING_MODE } override suspend fun setScoringMode(mode: ArcaeaScoringMode) { From 74b133d4ba1d952569b385ea3f9e0ee0565c2ab7 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 23 Sep 2026 22:29:33 +0800 Subject: [PATCH 18/35] fix(shared): treat a non-positive chart constant as no rating --- .../sevive/arcaeaoffline/core/calculators/Common.kt | 10 ++++++---- .../core/calculators/CommonCalculatorsTest.kt | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt index ad5fdc4c..ac32a98f 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt @@ -19,7 +19,8 @@ fun calculatePlayRating( score: Int, constant: Int, ): Double { - if (constant < 0) return 0.0 + // Chart constants are positive; 0 is what a missing chart info reads as. + if (constant <= 0) return 0.0 return if (score >= 10_000_000) { constant / 10.0 + 2 @@ -49,7 +50,7 @@ fun calculatePlayRating( constant: Int, clearType: ArcaeaPlayResultClearType?, ): Double { - if (constant < 0) return 0.0 + if (constant <= 0) return 0.0 val bonus = calculateClearBonus(clearType) return if (score >= 10_000_000) { @@ -64,7 +65,8 @@ fun calculatePlayRating( /** * Calculate a possible score [IntRange] from specified [targetPlayRating] and [constant]. * - * If the [targetPlayRating] is invalid or too high, null result will be returned. + * If the [targetPlayRating] is invalid or too high, or [constant] is not a valid chart + * constant, null result will be returned. * * The core algorithm is provided by Google Gemini. */ @@ -73,7 +75,7 @@ fun calculateInvertScoreRange( constant: Int, tolerance: Double = 1e-3, ): IntRange? { - if (constant < 0 || targetPlayRating < 0.0) return null + if (constant <= 0 || targetPlayRating < 0.0) return null val base = constant / 10.0 diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index b7bee5e5..08f86a2f 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -40,6 +40,13 @@ class CommonCalculatorsTest { 0.0, 0.0, ) + + // A missing chart info reads as constant 0 + assertEquals( + calculatePlayRating(10_000_000, 0), + 0.0, + 0.0, + ) } @Test @@ -57,6 +64,7 @@ class CommonCalculatorsTest { assert(10_002_221 in result3) { "10_002_221 [12.0] > 14.0" } assertNull(calculateInvertScoreRange(targetPlayRating = 14.0, constant = 80)) + assertNull(calculateInvertScoreRange(targetPlayRating = 12.0, constant = 0)) } @Test @@ -85,5 +93,8 @@ class CommonCalculatorsTest { // bonus stays above zero, while TRACK_LOST floors at zero assertEquals(calculatePlayRating(8_000_000, 50, null), 0.0, 0.0) assertEquals(calculatePlayRating(8_000_000, 50, ArcaeaPlayResultClearType.NORMAL_CLEAR), 0.2, 0.0) + + // An invalid constant earns no bonus either + assertEquals(calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0, 0.0) } } From 04019946e21812bc870325c28c6c2369026f6dab Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 24 Sep 2026 00:31:01 +0800 Subject: [PATCH 19/35] test: add B50 potential tests with real save data --- .../repositories/PotentialRepositoryTest.kt | 107 ++++++++++ .../repositories/potential_b50_seed.sql | 193 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt create mode 100644 core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/repositories/potential_b50_seed.sql diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt new file mode 100644 index 00000000..fad9f34d --- /dev/null +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt @@ -0,0 +1,107 @@ +package xyz.sevive.arcaeaoffline.core.database.repositories + +import android.content.Context +import androidx.room.Room +import androidx.room.execSQL +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase + +/** + * B50 calculation over the play results of a real save. + */ +@RunWith(AndroidJUnit4::class) +class PotentialRepositoryTest { + private lateinit var db: ArcaeaOfflineDatabase + private lateinit var potentialRepository: PotentialRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, ArcaeaOfflineDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + + potentialRepository = + PotentialRepositoryImpl( + PlayResultBestRepositoryImpl( + db.playResultBestDao(), + PlayResultCalculatedRepositoryImpl(db.playResultDao(), db.songDao(), db.chartInfoDao()), + ), + R30EntryRepositoryImpl(db.r30EntryDao()), + PropertyRepositoryImpl(db.propertyDao()), + ) + + runBlocking { seed() } + } + + @After + fun tearDown() { + db.close() + } + + private suspend fun seed() { + val statements = + readSeedScript() + .lineSequence() + .filterNot { it.trimStart().startsWith("--") } + .joinToString("\n") + .split(';') + .map { it.trim() } + .filter { it.isNotEmpty() } + + db.useWriterConnection { connection -> + connection.immediateTransaction { + statements.forEach { execSQL(it) } + } + } + } + + private fun readSeedScript(): String { + val stream = javaClass.classLoader?.getResourceAsStream(SEED_FILE) ?: error("Seed file not found: $SEED_FILE") + return stream.bufferedReader().use { it.readText() } + } + + @Test + fun b50MatchesTheSave() = + runBlocking { + // 12.595 truncated to three decimals, the value the app displays + assertEquals(12.595672633333333, potentialRepository.b50().first(), TOLERANCE) + } + + @Test + fun b10MatchesTheSave() = + runBlocking { + // 12.764 + assertEquals(12.764543, potentialRepository.b10().first(), TOLERANCE) + } + + @Test + fun potentialMatchesTheSave() = + runBlocking { + // 12.623 + assertEquals(12.623817694444446, potentialRepository.potential().first(), TOLERANCE) + } + + private companion object { + const val SEED_FILE = "xyz/sevive/arcaeaoffline/core/database/repositories/potential_b50_seed.sql" + + /** + * Repositories sum play ratings in list order, while the expected values were summed with + * SQL; double addition depends on that order, and 1e-7 stays far below what the app shows. + */ + const val TOLERANCE = 1e-7 + } +} diff --git a/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/repositories/potential_b50_seed.sql b/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/repositories/potential_b50_seed.sql new file mode 100644 index 00000000..c30b17fa --- /dev/null +++ b/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/repositories/potential_b50_seed.sql @@ -0,0 +1,193 @@ +-- Seed data for PotentialRepositoryTest. +-- +-- Trimmed to the 60 highest-rated charts: the B50 and B10 lists take the top 50 and top 10 +-- of them, so the charts below the cut cannot change the expected values. +-- +-- Only the fields the calculation reads are kept. The songs rows exist because +-- PlayResultCalculatedRepository drops a play whose song is missing; only `id` matters +-- there, so the display columns hold placeholders. + +INSERT INTO charts_info (song_id, rating_class, constant) VALUES + ('grievouslady', 2, 111), + ('cyaegha', 2, 107), + ('dantalion', 2, 108), + ('crimsonthrone', 2, 106), + ('livefastdieyoung', 2, 106), + ('overwhelm', 2, 106), + ('axiumcrisis', 2, 106), + ('gimmedablood', 2, 105), + ('infinity', 2, 106), + ('infinitestrife', 3, 109), + ('ringedgenesis', 2, 108), + ('spidersthread', 2, 108), + ('seclusion', 2, 106), + ('signal', 4, 107), + ('egoeimi', 2, 105), + ('kyorenromance', 2, 107), + ('singularity', 2, 105), + ('edenwacca', 2, 105), + ('gou', 2, 109), + ('worldvanquisher', 2, 107), + ('corpssansorganes', 2, 106), + ('qovat', 2, 104), + ('felis', 2, 104), + ('goodbyemerry', 2, 106), + ('lamentrain', 2, 106), + ('climax', 2, 104), + ('pentiment', 2, 104), + ('halcyon', 2, 105), + ('distortedfate', 4, 108), + ('ouroboros', 2, 107), + ('ifi', 2, 111), + ('mvurbd', 2, 106), + ('divinelight', 2, 108), + ('genocider', 2, 108), + ('judgement', 2, 105), + ('attraqtia', 2, 106), + ('stasis', 2, 108), + ('chronicle', 2, 105), + ('xtreme', 2, 105), + ('pupa', 2, 104), + ('izana', 2, 103), + ('arcanaeden', 3, 116), + ('alterego', 2, 105), + ('nnglooms', 2, 103), + ('chaos', 2, 109), + ('rainofconflict', 4, 105), + ('amazingmightyyyy', 2, 107), + ('fractureray', 2, 111), + ('impact', 3, 104), + ('garakuta', 2, 104), + ('arcanaeden', 2, 105), + ('viyellastears', 2, 103), + ('cyanine', 2, 106), + ('breachoffaith', 2, 103), + ('lightningscrew', 2, 105), + ('testify', 2, 109), + ('straightintolights', 4, 105), + ('aurgelmir', 2, 105), + ('buchigireberserker', 2, 108), + ('ultradiaxon', 2, 103); + +INSERT INTO songs (idx, id, title, artist, `set`, side) VALUES + (1, 'grievouslady', 'grievouslady', '', 'base', 0), + (2, 'cyaegha', 'cyaegha', '', 'base', 0), + (3, 'dantalion', 'dantalion', '', 'base', 0), + (4, 'crimsonthrone', 'crimsonthrone', '', 'base', 0), + (5, 'livefastdieyoung', 'livefastdieyoung', '', 'base', 0), + (6, 'overwhelm', 'overwhelm', '', 'base', 0), + (7, 'axiumcrisis', 'axiumcrisis', '', 'base', 0), + (8, 'gimmedablood', 'gimmedablood', '', 'base', 0), + (9, 'infinity', 'infinity', '', 'base', 0), + (10, 'infinitestrife', 'infinitestrife', '', 'base', 0), + (11, 'ringedgenesis', 'ringedgenesis', '', 'base', 0), + (12, 'spidersthread', 'spidersthread', '', 'base', 0), + (13, 'seclusion', 'seclusion', '', 'base', 0), + (14, 'signal', 'signal', '', 'base', 0), + (15, 'egoeimi', 'egoeimi', '', 'base', 0), + (16, 'kyorenromance', 'kyorenromance', '', 'base', 0), + (17, 'singularity', 'singularity', '', 'base', 0), + (18, 'edenwacca', 'edenwacca', '', 'base', 0), + (19, 'gou', 'gou', '', 'base', 0), + (20, 'worldvanquisher', 'worldvanquisher', '', 'base', 0), + (21, 'corpssansorganes', 'corpssansorganes', '', 'base', 0), + (22, 'qovat', 'qovat', '', 'base', 0), + (23, 'felis', 'felis', '', 'base', 0), + (24, 'goodbyemerry', 'goodbyemerry', '', 'base', 0), + (25, 'lamentrain', 'lamentrain', '', 'base', 0), + (26, 'climax', 'climax', '', 'base', 0), + (27, 'pentiment', 'pentiment', '', 'base', 0), + (28, 'halcyon', 'halcyon', '', 'base', 0), + (29, 'distortedfate', 'distortedfate', '', 'base', 0), + (30, 'ouroboros', 'ouroboros', '', 'base', 0), + (31, 'ifi', 'ifi', '', 'base', 0), + (32, 'mvurbd', 'mvurbd', '', 'base', 0), + (33, 'divinelight', 'divinelight', '', 'base', 0), + (34, 'genocider', 'genocider', '', 'base', 0), + (35, 'judgement', 'judgement', '', 'base', 0), + (36, 'attraqtia', 'attraqtia', '', 'base', 0), + (37, 'stasis', 'stasis', '', 'base', 0), + (38, 'chronicle', 'chronicle', '', 'base', 0), + (39, 'xtreme', 'xtreme', '', 'base', 0), + (40, 'pupa', 'pupa', '', 'base', 0), + (41, 'izana', 'izana', '', 'base', 0), + (42, 'arcanaeden', 'arcanaeden', '', 'base', 0), + (43, 'alterego', 'alterego', '', 'base', 0), + (44, 'nnglooms', 'nnglooms', '', 'base', 0), + (45, 'chaos', 'chaos', '', 'base', 0), + (46, 'rainofconflict', 'rainofconflict', '', 'base', 0), + (47, 'amazingmightyyyy', 'amazingmightyyyy', '', 'base', 0), + (48, 'fractureray', 'fractureray', '', 'base', 0), + (49, 'impact', 'impact', '', 'base', 0), + (50, 'garakuta', 'garakuta', '', 'base', 0), + (52, 'viyellastears', 'viyellastears', '', 'base', 0), + (53, 'cyanine', 'cyanine', '', 'base', 0), + (54, 'breachoffaith', 'breachoffaith', '', 'base', 0), + (55, 'lightningscrew', 'lightningscrew', '', 'base', 0), + (56, 'testify', 'testify', '', 'base', 0), + (57, 'straightintolights', 'straightintolights', '', 'base', 0), + (58, 'aurgelmir', 'aurgelmir', '', 'base', 0), + (59, 'buchigireberserker', 'buchigireberserker', '', 'base', 0), + (60, 'ultradiaxon', 'ultradiaxon', '', 'base', 0); + +INSERT INTO play_results (uuid, song_id, rating_class, score, clear_type) VALUES + (randomblob(16), 'grievouslady', 2, 9949633, 5), + (randomblob(16), 'cyaegha', 2, 9993970, 2), + (randomblob(16), 'dantalion', 2, 9953872, 5), + (randomblob(16), 'crimsonthrone', 2, 9989814, 2), + (randomblob(16), 'livefastdieyoung', 2, 9985746, 2), + (randomblob(16), 'overwhelm', 2, 9985214, 2), + (randomblob(16), 'axiumcrisis', 2, 9982743, 5), + (randomblob(16), 'gimmedablood', 2, 9996430, 2), + (randomblob(16), 'infinity', 2, 9975898, 2), + (randomblob(16), 'infinitestrife', 3, 9915766, 5), + (randomblob(16), 'ringedgenesis', 2, 9935580, 5), + (randomblob(16), 'spidersthread', 2, 9934588, 5), + (randomblob(16), 'seclusion', 2, 9974565, 5), + (randomblob(16), 'signal', 4, 9952935, 5), + (randomblob(16), 'egoeimi', 2, 9988912, 2), + (randomblob(16), 'kyorenromance', 2, 9948786, 5), + (randomblob(16), 'singularity', 2, 9987481, 2), + (randomblob(16), 'edenwacca', 2, 9986637, 2), + (randomblob(16), 'gou', 2, 9906123, 5), + (randomblob(16), 'worldvanquisher', 2, 9942803, 5), + (randomblob(16), 'corpssansorganes', 2, 9959190, 2), + (randomblob(16), 'qovat', 2, 9997396, 2), + (randomblob(16), 'felis', 2, 9996718, 2), + (randomblob(16), 'goodbyemerry', 2, 9954878, 5), + (randomblob(16), 'lamentrain', 2, 9954174, 5), + (randomblob(16), 'climax', 2, 9993941, 2), + (randomblob(16), 'pentiment', 2, 9993855, 2), + (randomblob(16), 'halcyon', 2, 9972583, 5), + (randomblob(16), 'distortedfate', 4, 9912122, 5), + (randomblob(16), 'ouroboros', 2, 9931862, 5), + (randomblob(16), 'ifi', 2, 9849089, 5), + (randomblob(16), 'mvurbd', 2, 9947947, 2), + (randomblob(16), 'divinelight', 2, 9907842, 5), + (randomblob(16), 'genocider', 2, 9906895, 5), + (randomblob(16), 'judgement', 2, 9966403, 5), + (randomblob(16), 'attraqtia', 2, 9945509, 5), + (randomblob(16), 'stasis', 2, 9902738, 5), + (randomblob(16), 'chronicle', 2, 9961641, 5), + (randomblob(16), 'xtreme', 2, 9961446, 5), + (randomblob(16), 'pupa', 2, 9978264, 5), + (randomblob(16), 'izana', 2, 9995793, 2), + (randomblob(16), 'arcanaeden', 3, 9701956, 1), + (randomblob(16), 'alterego', 2, 9953622, 5), + (randomblob(16), 'nnglooms', 2, 9993523, 5), + (randomblob(16), 'chaos', 2, 9873393, 5), + (randomblob(16), 'rainofconflict', 4, 9953073, 5), + (randomblob(16), 'amazingmightyyyy', 2, 9913072, 5), + (randomblob(16), 'fractureray', 2, 9833047, 5), + (randomblob(16), 'impact', 3, 9972598, 5), + (randomblob(16), 'garakuta', 2, 9971979, 5), + (randomblob(16), 'arcanaeden', 2, 9951462, 5), + (randomblob(16), 'viyellastears', 2, 9990602, 5), + (randomblob(16), 'cyanine', 2, 9928460, 5), + (randomblob(16), 'breachoffaith', 2, 9986735, 2), + (randomblob(16), 'lightningscrew', 2, 9946574, 5), + (randomblob(16), 'testify', 2, 9862827, 5), + (randomblob(16), 'straightintolights', 4, 9942623, 5), + (randomblob(16), 'aurgelmir', 2, 9941885, 2), + (randomblob(16), 'buchigireberserker', 2, 9880815, 5), + (randomblob(16), 'ultradiaxon', 2, 9980794, 2); From 0703e6d1a3a8e34d54f20096d62d672739d74dc6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 24 Sep 2026 15:40:03 +0800 Subject: [PATCH 20/35] refactor: R30 calculating and tests --- .../sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 156 ++------- .../core/database/r30/R30QueueUpdaterTest.kt | 176 ++++++++++ .../core/database/r30/r30_seed.sql | 315 ++++++++++++++++++ .../core/database/daos/PlayResultDao.kt | 8 + .../core/database/entities/ChartBestScore.kt | 11 + .../core/database/r30/R30QueueUpdater.kt | 140 ++++++++ .../repositories/PlayResultRepository.kt | 6 + .../repositories/PotentialRepository.kt | 9 +- 8 files changed, 699 insertions(+), 122 deletions(-) create mode 100644 core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt create mode 100644 core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/r30/r30_seed.sql create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index e633413a..2529945e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -17,35 +17,17 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.core.Progress -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase -import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo -import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.core.database.entities.playRating +import xyz.sevive.arcaeaoffline.core.database.r30.ChartKey +import xyz.sevive.arcaeaoffline.core.database.r30.R30QueueUpdater import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.toWorkData import kotlin.time.Clock -private fun PlayResult.triggersConditionalWrite(): Boolean { - // score >= EX - if (score >= 9_800_000) return true - // is hard lost - if (clearType == ArcaeaPlayResultClearType.TRACK_LOST && modifier == ArcaeaPlayResultModifier.HARD) return true - - return false -} - -private fun List.minByPlayRating(): R30EntryCombined? = - this.minByOrNull { entry -> - entry.chartInfo?.let { entry.playResult.playRating(it) } ?: Double.MAX_VALUE - } - class R30UpdateJob( context: Context, params: WorkerParameters, @@ -56,6 +38,8 @@ class R30UpdateJob( private val playResultRepo: PlayResultRepository, private val chartInfoRepo: ChartInfoRepository, ) : CoroutineWorker(context, params) { + private val r30QueueUpdater = R30QueueUpdater { chartInfoRepo.find(it).firstOrNull() } + companion object { private const val LOG_TAG = "R30UpdateJob" const val WORK_NAME = "R30UpdateJob" @@ -105,28 +89,47 @@ class R30UpdateJob( } val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt() + val cutoff = r30LastUpdatedAt.takeUnless { workOptions.runMode == RunMode.REBUILD } var r30EntryCombinedList = - when (workOptions.runMode) { - RunMode.REBUILD -> emptyList() - else -> r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() + if (cutoff == null) { + emptyList() + } else { + r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() } + // Records of the plays outside the batch, so an incremental run decides on a + // new chart record the same way a rebuild from the whole history would. + val previousBestScores = + cutoff + ?.let { playResultRepo.bestScoresUntil(it) } + ?.associate { ChartKey(it.songId, it.ratingClass) to it.score } + .orEmpty() + val playResults = - when (workOptions.runMode) { - RunMode.REBUILD -> playResultRepo.findAll().firstOrNull() - else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).firstOrNull() } - } ?: emptyList() + if (cutoff == null) { + playResultRepo.findAll().firstOrNull() ?: emptyList() + } else { + playResultRepo.findLaterThan(cutoff).firstOrNull() ?: emptyList() + } val deletedSongIds = songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() - val newPlayResults = playResults.filter { it.date != null && it.songId !in deletedSongIds }.sortedBy { it.date } + val newPlayResults = + playResults + .filter { it.date != null && it.songId !in deletedSongIds } + .sortedWith(compareBy({ it.date }, { it.id })) progressFlow.update { Progress(current = 0, total = newPlayResults.size) } logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } - newPlayResults.forEach { - ensureActive() - r30EntryCombinedList = updateR30List(it, r30EntryCombinedList) - progressFlow.update { progress -> progress.increment() } - } + r30EntryCombinedList = + r30QueueUpdater.replay( + plays = newPlayResults, + entries = r30EntryCombinedList, + previousBestScores = previousBestScores, + onPlay = { + ensureActive() + progressFlow.update { progress -> progress.increment() } + }, + ) // Room3 possibly has a convenient extension function for this // see https://issuetracker.google.com/issues/416306996 @@ -150,91 +153,4 @@ class R30UpdateJob( return Result.failure() } } - - /** - * Wrapper of [updateR30ListByDirectWrite] and [updateR30ListByConditionalWrite] that automatically - * choose one of them depending on the [playResult]'s state. - */ - private suspend fun updateR30List( - playResult: PlayResult, - oldR30List: List, - ): List { - if (oldR30List.size < 30) { - val mutableR30List = oldR30List.toMutableList() - mutableR30List.add(R30EntryCombined.build(playResult, chartInfoRepo)) - return mutableR30List - } - - val newR30Entries = - if (playResult.triggersConditionalWrite()) { - // now check if the play result play rating is higher than the lowest play rating r30 entry - // if any chart info is missing, return the old r30 entries directly - val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return oldR30List - updateR30ListByConditionalWrite(playResult, chartInfo, oldR30List) - } else { - // otherwise, just update the entries by date - updateR30ListByDirectWrite(playResult, oldR30List) - } - - // ensure the new r30 should have at least 10 unique charts - // otherwise keep the entries unmodified - val uniqueChartsCount = newR30Entries.distinctBy { "${it.playResult.songId}|${it.playResult.ratingClass.value}" }.count() - return if (uniqueChartsCount < 10) { - oldR30List - } else { - newR30Entries - } - } - - /** - * Update the R30 entries under the "conditional" circumstance. - * - * This will replace the lowest play rating entry with the new play result. - * - * @param playResult The play result to be inserted - * @param chartInfo The [ChartInfo] of [playResult] - * @param oldR30List Old R30 entries - * @return The new R30 entries - */ - private fun updateR30ListByConditionalWrite( - playResult: PlayResult, - chartInfo: ChartInfo, - oldR30List: List, - ): List { - // try getting the min play rating item in old list - // otherwise leave the old list untouched - val minRatingEntry = oldR30List.minByPlayRating() ?: return oldR30List - val minRatingEntryRating = minRatingEntry.playRating() ?: return oldR30List - - if (playResult.playRating(chartInfo) < minRatingEntryRating) return oldR30List - - val newR30Entries = oldR30List.toMutableList() - newR30Entries.remove(minRatingEntry) - newR30Entries.add(R30EntryCombined.build(playResult, chartInfo)) - return newR30Entries - } - - /** - * Update the R30 entries as usual. - * - * This will replace the oldest entry with the new play result. - * - * @param playResult The play result to be inserted - * @param oldR30List Old R30 entries - * @return The new R30 entries - */ - private suspend fun updateR30ListByDirectWrite( - playResult: PlayResult, - oldR30List: List, - ): List { - val oldestR30Entry = - oldR30List.minByOrNull { - it.playResult.date?.toEpochMilliseconds() ?: Long.MAX_VALUE - } ?: return oldR30List - - val newR30Entries = oldR30List.toMutableList() - newR30Entries.remove(oldestR30Entry) - newR30Entries.add(R30EntryCombined.build(playResult, chartInfoRepo)) - return newR30Entries - } } diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt new file mode 100644 index 00000000..25daa70b --- /dev/null +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt @@ -0,0 +1,176 @@ +package xyz.sevive.arcaeaoffline.core.database.r30 + +import android.content.Context +import androidx.room.Room +import androidx.room.execSQL +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultBestRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultCalculatedRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined +import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepositoryImpl + +/** The recent queue rebuilt from the play history. */ +@RunWith(AndroidJUnit4::class) +class R30QueueUpdaterTest { + private lateinit var db: ArcaeaOfflineDatabase + private lateinit var potentialRepository: PotentialRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, ArcaeaOfflineDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + + potentialRepository = + PotentialRepositoryImpl( + PlayResultBestRepositoryImpl( + db.playResultBestDao(), + PlayResultCalculatedRepositoryImpl(db.playResultDao(), db.songDao(), db.chartInfoDao()), + ), + R30EntryRepositoryImpl(db.r30EntryDao()), + PropertyRepositoryImpl(db.propertyDao()), + ) + + runBlocking { seed() } + } + + @After + fun tearDown() { + db.close() + } + + private suspend fun seed() { + val statements = + readSeedScript() + .lineSequence() + .filterNot { it.trimStart().startsWith("--") } + .joinToString("\n") + .split(';') + .map { it.trim() } + .filter { it.isNotEmpty() } + + db.useWriterConnection { connection -> + connection.immediateTransaction { + statements.forEach { execSQL(it) } + } + } + } + + private fun readSeedScript(): String { + val stream = javaClass.classLoader?.getResourceAsStream(SEED_FILE) ?: error("Seed file not found: $SEED_FILE") + return stream.bufferedReader().use { it.readText() } + } + + private suspend fun replayQueue(): List { + val chartInfoRepository = ChartInfoRepositoryImpl(db.chartInfoDao()) + val playResults = + PlayResultRepositoryImpl(db.playResultDao()) + .findAll() + .first() + .sortedWith(compareBy({ it.date }, { it.id })) + + return R30QueueUpdater { chartInfoRepository.find(it).firstOrNull() }.replay(playResults) + } + + private data class QueueEntry( + val songId: String, + val ratingClass: ArcaeaRatingClass, + val score: Int, + val date: Long?, + ) + + private fun List.toQueueEntries(): List = + map { + QueueEntry( + songId = it.playResult.songId, + ratingClass = it.playResult.ratingClass, + score = it.playResult.score, + date = it.playResult.date?.toEpochMilliseconds(), + ) + }.sortedWith(compareBy({ it.songId }, { it.ratingClass }, { it.score }, { it.date })) + + @Test + fun replayRebuildsTheQueueOfTheSave() = + runBlocking { + assertEquals(EXPECTED_QUEUE, replayQueue().toQueueEntries()) + } + + @Test + fun recentTopTenCountsEachChartOnce() = + runBlocking { + val queue = replayQueue() + + // 18 of the 30 entries are distinct charts, so whether they are counted once or per + // play decides the average below. + assertEquals(18, queue.map { ChartKey(it.playResult.songId, it.playResult.ratingClass) }.distinct().size) + db.r30EntryDao().insertBatch(*queue.map { it.entry }.toTypedArray()) + + assertEquals(12.524324, potentialRepository.r10().first(), TOLERANCE) + } + + private companion object { + const val SEED_FILE = "xyz/sevive/arcaeaoffline/core/database/r30/r30_seed.sql" + + /** + * Repositories sum play ratings in list order; the expected values were summed in a + * different one, and 1e-7 stays far below what the app shows. + */ + const val TOLERANCE = 1e-7 + + val EXPECTED_QUEUE = + listOf( + QueueEntry("aethercrest", ArcaeaRatingClass.ETERNAL, 9757015, 1767253713219), + QueueEntry("aethercrest", ArcaeaRatingClass.ETERNAL, 9816277, 1766662160675), + QueueEntry("blackmind", ArcaeaRatingClass.FUTURE, 9906944, 1768549652129), + QueueEntry("cyaegha", ArcaeaRatingClass.FUTURE, 9939116, 1768549496384), + QueueEntry("egoeimi", ArcaeaRatingClass.FUTURE, 9960223, 1768023159302), + QueueEntry("einherjar", ArcaeaRatingClass.BEYOND, 9920907, 1766660967276), + QueueEntry("extradimensional", ArcaeaRatingClass.ETERNAL, 9813212, 1766661646868), + QueueEntry("extradimensional", ArcaeaRatingClass.ETERNAL, 9819009, 1766399632532), + QueueEntry("extradimensional", ArcaeaRatingClass.ETERNAL, 9853769, 1769502077902), + QueueEntry("gloryroad", ArcaeaRatingClass.FUTURE, 9970939, 1767434768308), + QueueEntry("heavensdoor", ArcaeaRatingClass.BEYOND, 9910133, 1767287763224), + QueueEntry("heavensdoor", ArcaeaRatingClass.BEYOND, 9959003, 1767255830438), + QueueEntry("judgement", ArcaeaRatingClass.FUTURE, 9952373, 1766465853857), + QueueEntry("judgement", ArcaeaRatingClass.FUTURE, 9983873, 1767342892864), + QueueEntry("judgement", ArcaeaRatingClass.FUTURE, 9990840, 1768022442474), + QueueEntry("kyorenromance", ArcaeaRatingClass.FUTURE, 9912544, 1767342491686), + QueueEntry("kyorenromance", ArcaeaRatingClass.FUTURE, 9938869, 1767342069703), + QueueEntry("lilly", ArcaeaRatingClass.ETERNAL, 9901422, 1766399869005), + QueueEntry("lilly", ArcaeaRatingClass.ETERNAL, 9942084, 1767252939533), + QueueEntry("mvurbd", ArcaeaRatingClass.FUTURE, 9964627, 1766466376707), + QueueEntry("rekkaresonance", ArcaeaRatingClass.FUTURE, 9914456, 1768547479513), + QueueEntry("rekkaresonance", ArcaeaRatingClass.FUTURE, 9935083, 1768752552782), + QueueEntry("undyingmacula", ArcaeaRatingClass.ETERNAL, 9857587, 1766400157386), + QueueEntry("viciousheroism", ArcaeaRatingClass.BEYOND, 9835093, 1768114025598), + QueueEntry("vulcanus", ArcaeaRatingClass.FUTURE, 9907382, 1766662605430), + QueueEntry("vulcanus", ArcaeaRatingClass.FUTURE, 9913883, 1768549775561), + QueueEntry("vulcanus", ArcaeaRatingClass.FUTURE, 9943066, 1767342534864), + QueueEntry("welcomequeen", ArcaeaRatingClass.ETERNAL, 9876369, 1767688685531), + QueueEntry("welcomequeen", ArcaeaRatingClass.ETERNAL, 9921491, 1767435842280), + QueueEntry("worldvanquisher", ArcaeaRatingClass.FUTURE, 9956584, 1767343449714), + ) + } +} diff --git a/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/r30/r30_seed.sql b/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/r30/r30_seed.sql new file mode 100644 index 00000000..d4e0d6ed --- /dev/null +++ b/core/src/androidTest/resources/xyz/sevive/arcaeaoffline/core/database/r30/r30_seed.sql @@ -0,0 +1,315 @@ +-- Seed data for R30QueueUpdaterTest. +-- +-- Only the fields the recent-queue rules read are kept: score, modifier (a hard-mode Track Lost), +-- clear type, play time and the chart constant. + +INSERT INTO charts_info (song_id, rating_class, constant) VALUES + ('acheron', 2, 97), + ('aethercrest', 4, 115), + ('alexandrite', 2, 100), + ('alterego', 2, 105), + ('alterego', 4, 113), + ('amazingmightyyyy', 2, 107), + ('anokumene', 2, 92), + ('arcahv', 2, 99), + ('beautifuldreamer', 2, 99), + ('blackmind', 2, 108), + ('blackterritory', 2, 98), + ('blrink', 2, 97), + ('breachoffaith', 3, 109), + ('breakbreak', 4, 103), + ('buchigireberserker', 2, 108), + ('callimakarma', 2, 98), + ('chromafill', 2, 100), + ('clotho', 4, 88), + ('cyaegha', 2, 107), + ('dantalion', 2, 108), + ('deathbyglamour', 4, 94), + ('defection', 2, 98), + ('desive', 4, 108), + ('disintegration', 4, 98), + ('divinelight', 2, 108), + ('dualdependency', 4, 104), + ('dualdoom', 4, 103), + ('edenwacca', 2, 105), + ('egoeimi', 2, 105), + ('einherjar', 2, 98), + ('einherjar', 3, 109), + ('extradimensional', 4, 113), + ('farawaylight', 2, 98), + ('gloryroad', 2, 105), + ('goodbyemerry', 2, 106), + ('grievouslady', 2, 111), + ('hailstone', 2, 97), + ('halcyon', 2, 105), + ('heartjackin', 2, 97), + ('heavensdoor', 3, 108), + ('hiirogekka', 2, 103), + ('hivemind', 2, 100), + ('ignition', 2, 98), + ('ikazuchi', 2, 103), + ('infinitestrife', 2, 99), + ('innocence', 4, 97), + ('izana', 2, 103), + ('judgement', 2, 105), + ('kyogenkigo', 2, 93), + ('kyorenromance', 2, 107), + ('lamentrain', 3, 114), + ('lastgoodbye', 4, 97), + ('lightmyway', 2, 98), + ('lilly', 4, 108), + ('logos', 2, 102), + ('lostdesire', 2, 98), + ('maliciousmischance', 2, 102), + ('masqueradelegion', 2, 100), + ('matenrou', 2, 98), + ('melodyoflove', 2, 96), + ('monochromeprincess', 2, 97), + ('morningloom', 4, 98), + ('mvurbd', 2, 106), + ('namelesspassion', 2, 98), + ('neowings', 2, 102), + ('nirvluce', 2, 102), + ('nowayback', 4, 104), + ('oldschoolsalvage', 2, 98), + ('ouroboros', 2, 107), + ('ouverture', 2, 98), + ('phantasia', 2, 92), + ('pragmatism', 2, 101), + ('primitivelights', 2, 108), + ('ravenspride', 2, 94), + ('raysofremnant', 2, 97), + ('raysofremnant', 3, 104), + ('redandblue', 3, 102), + ('rekkaresonance', 2, 107), + ('renegade', 3, 102), + ('rgb', 2, 98), + ('rostpagegene', 2, 103), + ('scarletlunar', 2, 98), + ('seethelights', 4, 96), + ('seventhsense', 2, 99), + ('sheriruth', 2, 100), + ('soundwitch', 2, 98), + ('stargateextreme', 4, 99), + ('sulfur', 2, 97), + ('summerfireworks', 2, 99), + ('swansong', 2, 99), + ('temptation', 2, 109), + ('toaliceliddell', 2, 103), + ('undyingmacula', 2, 101), + ('undyingmacula', 4, 110), + ('vandalism', 2, 97), + ('viciousheroism', 2, 100), + ('viciousheroism', 3, 112), + ('violaillyria', 2, 95), + ('vulcanus', 2, 109), + ('welcomequeen', 2, 101), + ('welcomequeen', 4, 109), + ('withu', 2, 94), + ('worldvanquisher', 2, 107), + ('xeraphinite', 2, 98), + ('xterfusion', 4, 107), + ('xtreme', 2, 105), + ('yomibitoshirazu', 2, 98); + +INSERT INTO play_results (uuid, song_id, rating_class, score, modifier, clear_type, date) VALUES + (randomblob(16), 'breachoffaith', 3, 9804451, 0, 1, 1766171152983), + (randomblob(16), 'melodyoflove', 2, 9957888, 0, 1, 1766171234112), + (randomblob(16), 'violaillyria', 2, 9984668, 0, 2, 1766171532038), + (randomblob(16), 'swansong', 2, 9935785, 0, 1, 1766171596944), + (randomblob(16), 'raysofremnant', 3, 9844442, 0, 1, 1766173147628), + (randomblob(16), 'melodyoflove', 2, 9990122, 0, 2, 1766173959540), + (randomblob(16), 'withu', 2, 9995476, 0, 2, 1766398399480), + (randomblob(16), 'violaillyria', 2, 9936143, 0, 1, 1766398798465), + (randomblob(16), 'summerfireworks', 2, 9909049, 0, 1, 1766399085962), + (randomblob(16), 'melodyoflove', 2, 9952520, 0, 1, 1766399265597), + (randomblob(16), 'extradimensional', 4, 9819009, 0, 1, 1766399632532), + (randomblob(16), 'lilly', 4, 9901422, 0, 1, 1766399869005), + (randomblob(16), 'undyingmacula', 4, 9857587, 0, 1, 1766400157386), + (randomblob(16), 'disintegration', 4, 9988558, 0, 2, 1766400226407), + (randomblob(16), 'dualdependency', 4, 9871926, 0, 1, 1766464896542), + (randomblob(16), 'nowayback', 4, 9926478, 0, 1, 1766465502093), + (randomblob(16), 'sheriruth', 2, 9948910, 0, 1, 1766465785733), + (randomblob(16), 'judgement', 2, 9952373, 0, 1, 1766465853857), + (randomblob(16), 'mvurbd', 2, 9964627, 0, 1, 1766466376707), + (randomblob(16), 'judgement', 2, 9927969, 0, 1, 1766540814144), + (randomblob(16), 'mvurbd', 2, 9894458, 0, 1, 1766541212308), + (randomblob(16), 'nirvluce', 2, 9868188, 0, 1, 1766541256896), + (randomblob(16), 'matenrou', 2, 19320, 0, 0, 1766541809506), + (randomblob(16), 'einherjar', 2, 0, 0, 0, 1766542057659), + (randomblob(16), 'rgb', 2, 0, 0, 0, 1766542177940), + (randomblob(16), 'swansong', 2, 0, 0, 0, 1766542446238), + (randomblob(16), 'monochromeprincess', 2, 0, 0, 0, 1766542764690), + (randomblob(16), 'raysofremnant', 2, 0, 0, 0, 1766543028602), + (randomblob(16), 'infinitestrife', 2, 0, 0, 0, 1766543154034), + (randomblob(16), 'farawaylight', 2, 0, 0, 0, 1766543391345), + (randomblob(16), 'blrink', 2, 0, 0, 0, 1766543515013), + (randomblob(16), 'lightmyway', 2, 0, 0, 0, 1766543746895), + (randomblob(16), 'soundwitch', 2, 0, 0, 0, 1766543985864), + (randomblob(16), 'vandalism', 2, 0, 0, 0, 1766544136105), + (randomblob(16), 'yomibitoshirazu', 2, 0, 0, 0, 1766544161417), + (randomblob(16), 'defection', 2, 0, 0, 0, 1766544594491), + (randomblob(16), 'blackterritory', 2, 0, 0, 0, 1766545219330), + (randomblob(16), 'hailstone', 2, 0, 0, 0, 1766545268052), + (randomblob(16), 'oldschoolsalvage', 2, 0, 0, 0, 1766545561009), + (randomblob(16), 'summerfireworks', 2, 0, 0, 0, 1766545771816), + (randomblob(16), 'summerfireworks', 2, 0, 0, 0, 1766558033377), + (randomblob(16), 'ignition', 2, 0, 0, 0, 1766558100250), + (randomblob(16), 'ouverture', 2, 0, 0, 0, 1766558369437), + (randomblob(16), 'arcahv', 2, 0, 0, 0, 1766558517803), + (randomblob(16), 'sulfur', 2, 0, 0, 0, 1766558613226), + (randomblob(16), 'acheron', 2, 0, 0, 0, 1766558857284), + (randomblob(16), 'seventhsense', 2, 0, 0, 0, 1766559132961), + (randomblob(16), 'callimakarma', 2, 0, 0, 0, 1766559439928), + (randomblob(16), 'beautifuldreamer', 2, 0, 0, 0, 1766559555183), + (randomblob(16), 'xeraphinite', 2, 0, 0, 0, 1766559826296), + (randomblob(16), 'viciousheroism', 2, 0, 0, 0, 1766598780660), + (randomblob(16), 'edenwacca', 2, 0, 0, 0, 1766600570037), + (randomblob(16), 'maliciousmischance', 2, 0, 0, 0, 1766600839487), + (randomblob(16), 'disintegration', 4, 9921559, 0, 1, 1766602397882), + (randomblob(16), 'seethelights', 4, 9977081, 0, 2, 1766602897905), + (randomblob(16), 'disintegration', 4, 9970298, 0, 1, 1766602958264), + (randomblob(16), 'raysofremnant', 3, 9898119, 0, 1, 1766660065873), + (randomblob(16), 'redandblue', 3, 9934136, 0, 1, 1766660491463), + (randomblob(16), 'einherjar', 3, 9920907, 0, 1, 1766660967276), + (randomblob(16), 'undyingmacula', 4, 9778888, 0, 1, 1766661339013), + (randomblob(16), 'extradimensional', 4, 9813212, 0, 1, 1766661646868), + (randomblob(16), 'aethercrest', 4, 9816277, 0, 1, 1766662160675), + (randomblob(16), 'welcomequeen', 4, 9841728, 0, 1, 1766662223006), + (randomblob(16), 'grievouslady', 2, 9763332, 0, 1, 1766662485295), + (randomblob(16), 'vulcanus', 2, 9907382, 0, 1, 1766662605430), + (randomblob(16), 'amazingmightyyyy', 2, 9840963, 0, 1, 1766665214294), + (randomblob(16), 'alexandrite', 2, 9952856, 2, 5, 1767252908263), + (randomblob(16), 'lilly', 4, 9942084, 2, 5, 1767252939533), + (randomblob(16), 'breakbreak', 4, 9873239, 2, 5, 1767253294433), + (randomblob(16), 'aethercrest', 4, 9757015, 2, 5, 1767253713219), + (randomblob(16), 'defection', 2, 9869546, 2, 5, 1767254338524), + (randomblob(16), 'masqueradelegion', 2, 9982190, 2, 2, 1767254508559), + (randomblob(16), 'infinitestrife', 2, 9921932, 2, 5, 1767254932526), + (randomblob(16), 'gloryroad', 2, 9920242, 2, 5, 1767255132177), + (randomblob(16), 'lamentrain', 3, 9680696, 0, 1, 1767255741313), + (randomblob(16), 'viciousheroism', 3, 9775837, 0, 1, 1767255746331), + (randomblob(16), 'heavensdoor', 3, 9959003, 0, 1, 1767255830438), + (randomblob(16), 'yomibitoshirazu', 2, 9912578, 2, 5, 1767284946628), + (randomblob(16), 'hivemind', 2, 9961193, 2, 2, 1767286543782), + (randomblob(16), 'renegade', 3, 9971048, 2, 5, 1767286705195), + (randomblob(16), 'dualdependency', 4, 9820253, 2, 5, 1767287565296), + (randomblob(16), 'heavensdoor', 3, 9910133, 2, 5, 1767287763224), + (randomblob(16), 'einherjar', 3, 9864325, 0, 1, 1767341211362), + (randomblob(16), 'clotho', 4, 9976674, 2, 2, 1767341546233), + (randomblob(16), 'nowayback', 4, 9956272, 2, 5, 1767341558229), + (randomblob(16), 'kyorenromance', 2, 9938869, 2, 5, 1767342069703), + (randomblob(16), 'kyorenromance', 2, 9912544, 2, 5, 1767342491686), + (randomblob(16), 'vulcanus', 2, 9943066, 2, 5, 1767342534864), + (randomblob(16), 'judgement', 2, 9983873, 2, 5, 1767342892864), + (randomblob(16), 'hiirogekka', 2, 9916688, 2, 5, 1767342918885), + (randomblob(16), 'worldvanquisher', 2, 9956584, 2, 5, 1767343449714), + (randomblob(16), 'gloryroad', 2, 9970939, 2, 5, 1767434768308), + (randomblob(16), 'ravenspride', 2, 9996099, 2, 2, 1767435067606), + (randomblob(16), 'violaillyria', 2, 9984694, 2, 2, 1767435171712), + (randomblob(16), 'melodyoflove', 2, 10000874, 2, 3, 1767435231015), + (randomblob(16), 'welcomequeen', 4, 9921491, 2, 5, 1767435842280), + (randomblob(16), 'extradimensional', 4, 9384428, 2, 0, 1767436002381), + (randomblob(16), 'desive', 4, 9840745, 2, 5, 1767436037212), + (randomblob(16), 'breakbreak', 4, 9833229, 0, 1, 1767454157479), + (randomblob(16), 'disintegration', 4, 9915483, 0, 1, 1767688481376), + (randomblob(16), 'welcomequeen', 4, 9876369, 0, 1, 1767688685531), + (randomblob(16), 'dualdoom', 4, 9598599, 2, 0, 1768021287035), + (randomblob(16), 'renegade', 3, 9983912, 2, 5, 1768021518741), + (randomblob(16), 'alterego', 4, 9599953, 2, 0, 1768021726457), + (randomblob(16), 'defection', 2, 9904609, 2, 5, 1768021920307), + (randomblob(16), 'judgement', 2, 9990840, 2, 5, 1768022442474), + (randomblob(16), 'goodbyemerry', 2, 9839531, 2, 5, 1768022534236), + (randomblob(16), 'halcyon', 2, 9927749, 2, 5, 1768022918130), + (randomblob(16), 'egoeimi', 2, 9960223, 2, 5, 1768023159302), + (randomblob(16), 'pragmatism', 2, 9735430, 2, 5, 1768111539854), + (randomblob(16), 'izana', 2, 9944489, 2, 5, 1768111793388), + (randomblob(16), 'alterego', 2, 9738638, 2, 0, 1768112089841), + (randomblob(16), 'chromafill', 2, 9886026, 2, 5, 1768112450489), + (randomblob(16), 'deathbyglamour', 4, 9902144, 2, 5, 1768112932527), + (randomblob(16), 'swansong', 2, 9972540, 2, 5, 1768112970032), + (randomblob(16), 'scarletlunar', 2, 9947896, 2, 5, 1768113456332), + (randomblob(16), 'rostpagegene', 2, 9825761, 2, 5, 1768113541731), + (randomblob(16), 'viciousheroism', 3, 9835093, 0, 1, 1768114025598), + (randomblob(16), 'anokumene', 2, 9942019, 2, 2, 1768213357616), + (randomblob(16), 'swansong', 2, 9964391, 2, 5, 1768213991317), + (randomblob(16), 'neowings', 2, 9929643, 2, 5, 1768214084597), + (randomblob(16), 'divinelight', 2, 9824591, 2, 5, 1768214470828), + (randomblob(16), 'logos', 2, 9856694, 2, 5, 1768214928726), + (randomblob(16), 'morningloom', 4, 9846342, 2, 5, 1768215230201), + (randomblob(16), 'nowayback', 4, 9956259, 2, 5, 1768215385875), + (randomblob(16), 'undyingmacula', 4, 9840470, 2, 5, 1768215709213), + (randomblob(16), 'welcomequeen', 4, 9692606, 2, 0, 1768385623065), + (randomblob(16), 'masqueradelegion', 2, 9939874, 2, 5, 1768385716308), + (randomblob(16), 'swansong', 2, 9927628, 2, 5, 1768385740042), + (randomblob(16), 'scarletlunar', 2, 9947913, 2, 5, 1768386110624), + (randomblob(16), 'heartjackin', 2, 9911059, 2, 5, 1768386457520), + (randomblob(16), 'judgement', 2, 9920981, 2, 5, 1768386861077), + (randomblob(16), 'toaliceliddell', 2, 9870608, 2, 5, 1768387083093), + (randomblob(16), 'ikazuchi', 2, 9919560, 2, 5, 1768387458709), + (randomblob(16), 'buchigireberserker', 2, 9827698, 2, 5, 1768546476654), + (randomblob(16), 'ouroboros', 2, 9909890, 2, 5, 1768546991065), + (randomblob(16), 'primitivelights', 2, 9824145, 2, 5, 1768547374638), + (randomblob(16), 'rekkaresonance', 2, 9914456, 2, 5, 1768547479513), + (randomblob(16), 'dantalion', 2, 9804823, 2, 5, 1768549205515), + (randomblob(16), 'cyaegha', 2, 9939116, 2, 5, 1768549496384), + (randomblob(16), 'blackmind', 2, 9906944, 2, 5, 1768549652129), + (randomblob(16), 'vulcanus', 2, 9913883, 2, 5, 1768549775561), + (randomblob(16), 'worldvanquisher', 2, 9860116, 0, 1, 1768751824142), + (randomblob(16), 'amazingmightyyyy', 2, 9868991, 2, 5, 1768751905431), + (randomblob(16), 'temptation', 2, 9796217, 2, 5, 1768752430250), + (randomblob(16), 'rekkaresonance', 2, 9935083, 2, 5, 1768752552782), + (randomblob(16), 'judgement', 2, 9907000, 2, 5, 1768753984015), + (randomblob(16), 'xtreme', 2, 8641685, 2, 0, 1768754605483), + (randomblob(16), 'swansong', 2, 9948044, 2, 5, 1768754802908), + (randomblob(16), 'scarletlunar', 2, 9962389, 2, 5, 1768755158084), + (randomblob(16), 'namelesspassion', 2, 9931582, 2, 5, 1768842268697), + (randomblob(16), 'lostdesire', 2, 9901356, 2, 5, 1768842374274), + (randomblob(16), 'heartjackin', 2, 9960532, 2, 5, 1768842954769), + (randomblob(16), 'raysofremnant', 2, 9965271, 2, 5, 1768843379617), + (randomblob(16), 'scarletlunar', 2, 9947902, 2, 5, 1768843425784), + (randomblob(16), 'swansong', 2, 9943987, 2, 5, 1768843553240), + (randomblob(16), 'scarletlunar', 2, 9933426, 0, 1, 1769499597136), + (randomblob(16), 'rekkaresonance', 2, 9864916, 0, 1, 1769501367362), + (randomblob(16), 'extradimensional', 4, 9853769, 0, 1, 1769502077902), + (randomblob(16), 'nowayback', 4, 9911594, 0, 1, 1769502105495), + (randomblob(16), 'lilly', 4, 9860747, 0, 1, 1769502618992), + (randomblob(16), 'xterfusion', 4, 9522334, 0, 1, 1769502710538), + (randomblob(16), 'renegade', 3, 9812198, 0, 1, 1770049174271), + (randomblob(16), 'swansong', 2, 9903102, 0, 1, 1770049382816), + (randomblob(16), 'swansong', 2, 9899059, 0, 1, 1770049399850), + (randomblob(16), 'judgement', 2, 9823193, 0, 1, 1770049568968), + (randomblob(16), 'masqueradelegion', 2, 9930454, 0, 1, 1770051424067), + (randomblob(16), 'seethelights', 4, 9929684, 0, 1, 1770051561225), + (randomblob(16), 'xterfusion', 4, 9675070, 0, 1, 1770052132539), + (randomblob(16), 'lastgoodbye', 4, 9945971, 0, 1, 1770052773486), + (randomblob(16), 'innocence', 4, 9897330, 0, 1, 1770052970240), + (randomblob(16), 'nowayback', 4, 9936427, 0, 1, 1770053088499), + (randomblob(16), 'xterfusion', 4, 9685464, 0, 1, 1770094969840), + (randomblob(16), 'disintegration', 4, 9976399, 0, 1, 1770095043304), + (randomblob(16), 'violaillyria', 2, 9936127, 0, 1, 1770095431786), + (randomblob(16), 'phantasia', 2, 9932592, 0, 1, 1770095525951), + (randomblob(16), 'kyogenkigo', 2, 9923493, 0, 1, 1770096029164), + (randomblob(16), 'kyogenkigo', 2, 9923481, 0, 1, 1770143364822), + (randomblob(16), 'xterfusion', 4, 9709790, 0, 1, 1770473416333), + (randomblob(16), 'welcomequeen', 2, 9753549, 0, 1, 1770633206109), + (randomblob(16), 'undyingmacula', 2, 9821127, 0, 1, 1770634670352), + (randomblob(16), 'gloryroad', 2, 9950648, 0, 1, 1770635869679), + (randomblob(16), 'disintegration', 4, 9860669, 0, 1, 1770636076588), + (randomblob(16), 'welcomequeen', 4, 9834798, 0, 1, 1770648088120), + (randomblob(16), 'xterfusion', 4, 9789607, 0, 1, 1770649632170), + (randomblob(16), 'lilly', 4, 9805308, 0, 1, 1770739701313), + (randomblob(16), 'vulcanus', 2, 9816578, 0, 1, 1770740688777), + (randomblob(16), 'raysofremnant', 3, 9857868, 0, 1, 1770786458948), + (randomblob(16), 'redandblue', 3, 9862949, 0, 1, 1770786633751), + (randomblob(16), 'raysofremnant', 3, 9866833, 0, 1, 1770793909170), + (randomblob(16), 'einherjar', 3, 9834710, 0, 1, 1770794184944), + (randomblob(16), 'seethelights', 4, 9959322, 0, 1, 1770875710327), + (randomblob(16), 'disintegration', 4, 9939845, 0, 1, 1770876310748), + (randomblob(16), 'stargateextreme', 4, 9831342, 0, 1, 1771162701816), + (randomblob(16), 'swansong', 2, 9915381, 0, 1, 1771243629608), + (randomblob(16), 'raysofremnant', 3, 9835514, 0, 1, 1771244019509), + (randomblob(16), 'anokumene', 2, 9983140, 0, 2, 1771381975775), + (randomblob(16), 'kyogenkigo', 2, 9815088, 0, 1, 1771382318158), + (randomblob(16), 'acheron', 2, 9908142, 0, 1, 1771382484973), + (randomblob(16), 'judgement', 2, 9833657, 0, 1, 1771425198005); diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt index 4fb17cb5..e7c795ef 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt @@ -6,6 +6,7 @@ import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.entities.ChartBestScore import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import kotlin.time.Instant import kotlin.uuid.Uuid @@ -24,6 +25,13 @@ interface PlayResultDao { @Query("SELECT * FROM play_results WHERE date > :date") fun findLaterThan(date: Instant): Flow> + @Query( + """SELECT song_id, rating_class, MAX(score) AS score + FROM play_results WHERE `date` <= :date + GROUP BY song_id, rating_class""", + ) + suspend fun bestScoresUntil(date: Instant): List + @Query("SELECT * FROM play_results") fun findAll(): Flow> diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt new file mode 100644 index 00000000..fa4c22a2 --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt @@ -0,0 +1,11 @@ +package xyz.sevive.arcaeaoffline.core.database.entities + +import androidx.room.ColumnInfo +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass + +/** Highest score of a chart, as of a point in the play history. */ +data class ChartBestScore( + @ColumnInfo(name = "song_id") val songId: String, + @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, + val score: Int, +) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt new file mode 100644 index 00000000..3e0f5b2d --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt @@ -0,0 +1,140 @@ +package xyz.sevive.arcaeaoffline.core.database.r30 + +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.entities.playRating +import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined + +/** Identifies one chart: the unit the recent queue counts distinct entries by. */ +data class ChartKey( + val songId: String, + val ratingClass: ArcaeaRatingClass, +) + +/** Best score of a chart before the play being written; absent while the chart has no record. */ +typealias ChartBestScores = Map + +/** + * Rebuilds the recent-30 queue under the B30 + R10 rules from play results. + * + * [replay] applies one play at a time, in play order: + * * a play enters the queue directly, evicting the entry with the earliest play time, or + * conditionally, evicting the entry with the lowest single-play potential and being discarded + * when no entry is lower; + * * conditional writes are triggered by an EX-or-better score, a hard-mode Track Lost, and a + * score above the chart's previous record, the last of which also covers a chart's first + * submission; + * * a play whose direct write would take the queue below ten distinct charts, or lower that + * count, is written conditionally instead; a write that still breaks the floor is dropped. + */ +class R30QueueUpdater( + private val chartInfoOf: suspend (PlayResult) -> ChartInfo?, +) { + /** + * Applies [plays] in order onto [entries]. + * + * [previousBestScores] carries the chart records of plays that came before [plays], so an + * incremental run sees what a rebuild from the whole history would. + */ + suspend fun replay( + plays: List, + entries: List = emptyList(), + previousBestScores: ChartBestScores = emptyMap(), + onPlay: suspend () -> Unit = {}, + ): List { + var queue = entries + val bestScores = previousBestScores.toMutableMap() + + plays.forEach { play -> + val chart = ChartKey(play.songId, play.ratingClass) + queue = update(queue, play, chartInfoOf(play), bestScores[chart]) + + // The record advances even for a play that never enters the queue. + bestScores[chart] = maxOf(bestScores[chart] ?: 0, play.score) + onPlay() + } + + return queue + } + + private fun update( + entries: List, + playResult: PlayResult, + chartInfo: ChartInfo?, + previousBestScore: Int?, + ): List { + val newEntry = R30EntryCombined.build(playResult, chartInfo) + if (entries.size < QUEUE_SIZE) return entries + newEntry + + val conditionally = triggersConditionalWrite(playResult, previousBestScore) + val written = write(entries, playResult, chartInfo, newEntry, conditionally) ?: return entries + + val distinctBefore = distinctCharts(entries) + val distinctAfter = distinctCharts(written) + if (distinctAfter >= DISTINCT_CHART_FLOOR && distinctAfter >= distinctBefore) return written + + val retried = write(entries, playResult, chartInfo, newEntry, conditionally = true) ?: return entries + return if (distinctCharts(retried) < DISTINCT_CHART_FLOOR) entries else retried + } + + /** + * Null when the play is dropped instead of written: a conditional write needs chart info and + * a potential at or above the queue's lowest. + */ + private fun write( + entries: List, + playResult: PlayResult, + chartInfo: ChartInfo?, + newEntry: R30EntryCombined, + conditionally: Boolean, + ): List? = if (conditionally) conditionalWrite(entries, playResult, chartInfo) else directWrite(entries, newEntry) + + private fun conditionalWrite( + entries: List, + playResult: PlayResult, + chartInfo: ChartInfo?, + ): List? { + // A play without chart info has no potential to compare against the queue. + if (chartInfo == null) return null + + val lowest = entries.minByOrNull { it.playRating() ?: Double.MAX_VALUE } ?: return null + val lowestRating = lowest.playRating() ?: return null + if (playResult.playRating(chartInfo) < lowestRating) return null + + return entries - lowest + R30EntryCombined.build(playResult, chartInfo) + } + + private fun directWrite( + entries: List, + newEntry: R30EntryCombined, + ): List { + val earliest = + entries.minByOrNull { it.playResult.date?.toEpochMilliseconds() ?: Long.MAX_VALUE } ?: return entries + + return entries - earliest + newEntry + } + + private fun triggersConditionalWrite( + playResult: PlayResult, + previousBestScore: Int?, + ): Boolean { + if (playResult.score >= EX_SCORE) return true + if (playResult.clearType == ArcaeaPlayResultClearType.TRACK_LOST && playResult.modifier == ArcaeaPlayResultModifier.HARD) { + return true + } + + return previousBestScore == null || playResult.score > previousBestScore + } + + private fun distinctCharts(entries: List): Int = + entries.distinctBy { ChartKey(it.playResult.songId, it.playResult.ratingClass) }.size + + private companion object { + const val QUEUE_SIZE = 30 + const val DISTINCT_CHART_FLOOR = 10 + const val EX_SCORE = 9_800_000 + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt index eb169547..27c4a2f1 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultDao +import xyz.sevive.arcaeaoffline.core.database.entities.ChartBestScore import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import kotlin.time.Instant import kotlin.uuid.Uuid @@ -17,6 +18,9 @@ interface PlayResultRepository { fun findLaterThan(date: Instant): Flow> + /** Best score per chart over plays at or before [date], for an incremental recent-queue run. */ + suspend fun bestScoresUntil(date: Instant): List + fun findAll(): Flow> fun findAllBySongId(songId: String): Flow> @@ -46,6 +50,8 @@ class PlayResultRepositoryImpl( override fun findLaterThan(date: Instant): Flow> = dao.findLaterThan(date) + override suspend fun bestScoresUntil(date: Instant): List = dao.bestScoresUntil(date) + override fun findAll(): Flow> = dao.findAll() override fun findAllBySongId(songId: String): Flow> = dao.findAllBySongId(songId) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt index fad76eac..69cbac68 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated +import xyz.sevive.arcaeaoffline.core.database.r30.ChartKey interface PotentialRepository { /** Average single-play potential of the best 30 entries (legacy display value). */ @@ -36,8 +37,12 @@ class PotentialRepositoryImpl( private fun b50Entries(): Flow> = playResultBestRepo.orderDescWithLimit(50, ArcaeaScoringMode.B50) private fun r10Entries(): Flow> = - r30EntryRepo.findAllCombined().mapLatest { - it.sortedByDescending { it.playRating() ?: -1.0 }.take(10) + r30EntryRepo.findAllCombined().mapLatest { entries -> + // One entry per chart: the highest-rated of its plays in the queue + entries + .sortedByDescending { it.playRating() ?: -1.0 } + .distinctBy { ChartKey(it.playResult.songId, it.playResult.ratingClass) } + .take(10) } override fun b30() = From 24c29bf7200189d704d6f2e393a2b9a09d8005c6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 25 Sep 2026 01:53:23 +0800 Subject: [PATCH 21/35] docs: add potential and st3 docs --- docs/potential.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++ docs/st3.md | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 docs/potential.md create mode 100644 docs/st3.md diff --git a/docs/potential.md b/docs/potential.md new file mode 100644 index 00000000..5729cdb6 --- /dev/null +++ b/docs/potential.md @@ -0,0 +1,88 @@ +# 计分方式 + +> 最后校对于 2026-09-24 + +截至文档写作时间,潜力值的计算有两种规则。其差别包含选取成绩范围、是否计算通关奖励、是否存在 recent 队列、显示精度等。 + +## 单曲潜力值 + +### 定数 + +正式谱面都有比定级更细的定数。 + +- 8 级及以上,按 0.1 细分。定级带 `+` 表示小数部分 ≥0.7 +- 7 级,分 7.0 / 7.5 / 7.8 三档 +- 7 级以下,按 0.5 细分 + +### 分数换算 + +| 分数 | 单曲潜力值 | +|----------------------------|-------------------------------------------| +| ≥ 10,000,000 | `定数 + 2` | +| ≥ 9,800,000 且 < 10,000,000 | `定数 + 1 + (分数 - 9,800,000) / 200,000` | +| < 9,800,000 | `max(0, 定数 + (分数 - 9,500,000) / 300,000)` | + +### 通关奖励 + +B50 下,_TRACK LOST_ 以上的通关状态会在计算时赋 +0.2 的额外奖励。B30 + R10 没有该机制。 + +通关状态与该次成绩独立绑定。即某谱面曾经 TC,不使其后的 TL 成绩也享有奖励。本项目把未记录通关状态视同 TL。 + +### 速查 + +| 分数 | B30 + R10 | B50(TC 及以上) | B50(TL) | +|-------------|-----------|-------------|-----------| +| ≥10,000,000 | 定数 + 2.00 | 定数 + 2.20 | — | +| 9,950,000 | 定数 + 1.75 | 定数 + 1.95 | 定数 + 1.75 | +| 9,800,000 | 定数 + 1.00 | 定数 + 1.20 | 定数 + 1.00 | +| 9,500,000 | 定数 | 定数 + 0.20 | 定数 | +| 8,900,000 | 定数 - 2.00 | 定数 - 1.80 | 定数 - 2.00 | + +## B30 + R10 (20170602) + +> https://wiki.arcaea.cn/index.php?title=%E6%BD%9C%E5%8A%9B%E5%80%BC&oldid=88386 +> +> 已对原文本进行修改。本节内容遵循原文 [CC BY-NC-SA 3.0](http://creativecommons.org/licenses/by-nc-sa/3.0/) 授权。 + +整体潜力值由 30 个历史最高成绩(B30)与 recent top 10(R10)共 40 个单曲潜力值取均值: + +`整体潜力值 = (Best 30 总和 + Recent 10 总和) / 40` + +* **B30**:每个谱面(同一首歌的不同难度分开计)只保留最高分的一条,按单曲潜力值取前 30,只升不降。 + 只要有过一次单曲潜力值不为 0 的游玩,整体潜力值就不会降到 0。 +* **R10**:把最近 30 次游玩(R30 队列)按单曲潜力值从高到低排,取前 10 条。这是潜力值下降的主要来源。 + 移动版下同一谱面只取最高成绩。 + +R30 队列的更新遵循以下规则。 + +首先,判定要写入队列的成绩是否具备以下条件: +* 分数达到 EX 及以上(≥9,800,000) +* 困难模式 Track Lost(不含 *光 (Fracture)* 等特殊搭档的 Overflow 情形) +* 第一次提交某个谱面的成绩 +* 提交的分数大于远程服务器记录(推分保护) +* 本次写入会使 R30 内不同谱面数低于 10 种,或使其降低 + +**若以上条件均不满足**:该成绩**直接进入队列**,并刷出队列内游玩时间最早的一条成绩。 +**若满足上述任一条件**:进行**条件写入**。若本次单曲潜力值高于队列内最低值,则刷出该最低值成绩并写入本次成绩;否则,**舍弃本次成绩**。 + +本规则不计算通关奖励。 + +## B50 (20260827) + +> https://wiki.arcaea.cn/index.php?title=%E6%BD%9C%E5%8A%9B%E5%80%BC&oldid=90370 +> +> 已对原文本进行修改。本节内容遵循原文 [CC BY-NC-SA 3.0](http://creativecommons.org/licenses/by-nc-sa/3.0/) 授权。 + +B50 没有 recent 部分:整体潜力值只由 50 个历史最高成绩(B50)算出,其中最高的 10 条(B10)享有两倍权重: + +`整体潜力值 = (Best 50 总和 + Best 10 总和) / 60` + +* **B50**:每个谱面只保留最高的一条(同一首歌的不同难度分开计,按含通关奖励的单曲潜力值比较), + 取前 50,只升不降。因此同一谱面下通关的成绩可能压过分数更高但 TL 的成绩。 +* 官方也以 B50 展示,单曲潜力值精确到三位小数。 + +## 现有实现注释 + +* **R30 依据本地成绩历史重建**:“第一次提交某个谱面的成绩”与“提交的分数大于远程服务器记录”合并实现为“本次分数高于该谱面此前的记录”, + 其中“此前”指本数据库中更早的成绩记录。 +* **同潜力值成绩按固定顺序刷出**:官方为随机,本项目为便于测试等因素取确定顺序,对最终生成的 R10 **数值**没有影响。 diff --git a/docs/st3.md b/docs/st3.md new file mode 100644 index 00000000..34543451 --- /dev/null +++ b/docs/st3.md @@ -0,0 +1,69 @@ +# st3 说明 + +本文档简述开发 st3 功能时应注意的事项。相关机制除非特殊注明,否则均为经验推导,未经过代码级验证,仅供参考。 + +## clearType “不可靠” + +每次 `songId` + `songDifficulty` 得到最高分后,将其 `score`、`pure`、`far`、`lost` 等写入对应列。 +对于 `clearType`,则会按 *PURE MEMORY* > *FULL RECALL* > *CLEAR* 的优先级顺序判断是否需要写入。 + +这就带来一个问题。假设某个难度首先打出了一个 (FAR 10 · LOST 0) 的 *FULL RECALL* 成绩,那么 st3 便会记录该成绩与 `clearType`。 +假设以后再打出一个 (FAR 0 · LOST 1) 的成绩,明显该成绩分数更高,会覆盖原有成绩行。 +但因为 1 个 LOST 导致其 `clearType` 为*普通 CLEAR*, 优先级小于 *FULL RECALL*,那么 st3 中的 `clearType` 并不会更新。 +所以在后续提取时便会见到 LOST != 0 但 `clearType` 显示为 *FULL RECALL* 的诡异情况。 + +在 B50 计分规则更新前,st3 导入器引入了所谓“可靠性”检查:若 `clearType` 和实际成绩不匹配,则实际转为 `PlayResult` 时将其置空。 +但 B50 引入了 *TRACK COMPLETE* +0.2 的计分机制,所以为确保计分准确性,移除了该项检查。 + +沉痛悼念: +```kotlin +// git show 853a633bc53701933fc112fece9d4037531fd70b:core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt | sed -n '/val isClearTypeReliable/,+34p' + +val isClearTypeReliable: Boolean + get() { + if (clearType == ArcaeaPlayResultClearType.FULL_RECALL.value && lost != 0) return false + if (clearType == ArcaeaPlayResultClearType.PURE_MEMORY.value && lost != 0 && far != 0) return false + + return true + } + +fun toPlayResult(importDate: LocalDate): PlayResult { + return PlayResult( + // ... + clearType = + if (!isClearTypeReliable) null + else clearType?.let { ArcaeaPlayResultClearType.fromInt(it) }, + // ... + ) +} +``` + +## 日期截断 + +> 截止 v7.0.255 可通过表现确认,在 v7.0.1c 代码中验证存在。 + +st3 理论上使用的是 Unix 秒级时间戳,实际打歌记录和备份上传的也是如此。 + +但在备份恢复时,游戏会将服务器回传的**秒级**时间戳视为**毫秒级**时间戳,除以 1000 后再存入 st3。 +所以在当前 10 位时间戳的情况下,你将在新机上喜提 7 位时间戳,时间戳精度减少 1000s。 + +What's worse,在新机上传备份,7 位时间戳会覆盖服务器存档槽位并原样永久保存。 +聪明的读者也猜到了,如果在新新机上恢复备份,那么将喜提 4 位时间戳,时间戳精度减少 1000000s,意味着精确时间不可能再恢复。 + +根据实测情况,该时间戳到最后可降为 0(你知道的,整数除法)。 +导入器尽力在救了,但效果也视数据实际情况,一般并不理想。 + +> 为什么选 1489017600 为判断基准呢?因为那是 Arcaea 的发布日期。 +> +> 你总不能在 Arcaea 开放下载前就产生分数记录了吧? + +```kotlin +internal fun fixSt3Timestamp(ts: Long?): Long? { + if (ts == null || ts > 1489017600) return ts + + val isFixable = (ts in 1489..9999) || ts > 14889 + if (!isFixable) return null + + return ts.toString().padEnd(10, '0').toLong() +} +``` From 1b091e3bda1b9ae5836ae3dc6db4508b44f9c456 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 25 Sep 2026 01:59:28 +0800 Subject: [PATCH 22/35] ci: update deepseek model --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index f2b52dc8..04356cdf 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -44,4 +44,4 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} with: - model: deepseek/deepseek-v4-flash + model: ${{ vars.OPENCODE_MODEL || "deepseek/deepseek-flash" }} From c1873b95ba4e6ea5f8f9e4c7cbe6f86e4a87000f Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 25 Sep 2026 02:08:24 +0800 Subject: [PATCH 23/35] Revert "ci: update deepseek model name" This reverts commit d525a9cbedd49672e790e49c5d1df0da8fb9b3b6. # Conflicts: # .github/workflows/opencode.yml --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 04356cdf..f2b52dc8 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -44,4 +44,4 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} with: - model: ${{ vars.OPENCODE_MODEL || "deepseek/deepseek-flash" }} + model: deepseek/deepseek-v4-flash From be6ee714cac979efecfaa26ce3a191bd460ec9f0 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 25 Sep 2026 02:07:25 +0800 Subject: [PATCH 24/35] ci: update deepseek model name --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index f2b52dc8..a362e11d 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -44,4 +44,4 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} with: - model: deepseek/deepseek-v4-flash + model: ${{ vars.OPENCODE_MODEL || 'deepseek/deepseek-flash' }} From 2b98190c2192125193393c953b4829e7e8019db1 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 2 Oct 2026 23:03:26 +0800 Subject: [PATCH 25/35] refactor: PotentialRepository The repository no longer re-derives averages for every consumer. --- .../helpers/PotentialCalculation.kt | 12 + .../screens/overview/OverviewPotentialCard.kt | 218 +++++++++++++----- .../ui/screens/overview/OverviewViewModel.kt | 33 +-- .../main/res/drawable/chart_line_alert.xml | 17 ++ app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../helpers/PotentialCalculationTest.kt | 39 ++++ .../core/database/r30/R30QueueUpdaterTest.kt | 13 +- .../repositories/PotentialRepositoryTest.kt | 63 +++-- .../repositories/PotentialRepository.kt | 112 ++++----- .../core/calculators/Potential.kt | 11 + .../calculators/PotentialCalculatorsTest.kt | 28 +++ 12 files changed, 391 insertions(+), 169 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculation.kt create mode 100644 app/src/main/res/drawable/chart_line_alert.xml create mode 100644 app/src/test/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculationTest.kt create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Potential.kt create mode 100644 shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/PotentialCalculatorsTest.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculation.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculation.kt new file mode 100644 index 00000000..abacd6b6 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculation.kt @@ -0,0 +1,12 @@ +package xyz.sevive.arcaeaoffline.helpers + +import xyz.sevive.arcaeaoffline.core.calculators.calculatePotentialB30R10 +import xyz.sevive.arcaeaoffline.core.calculators.calculatePotentialB50 +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups + +internal fun ArcaeaScoringMode.calculatePotential(potentialGroups: PotentialGroups): Double = + when (this) { + ArcaeaScoringMode.B30_R10 -> calculatePotentialB30R10(potentialGroups.b30.total, potentialGroups.r10.total) + ArcaeaScoringMode.B50 -> calculatePotentialB50(potentialGroups.b50.total, potentialGroups.b10.total) + } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt index 7eed1630..290f0ba2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt @@ -3,53 +3,146 @@ package xyz.sevive.arcaeaoffline.ui.screens.overview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroup +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups +import xyz.sevive.arcaeaoffline.helpers.calculatePotential +import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme +import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme @Composable -private fun PotentialLabel( - title: String, - potential: Double?, +private fun PotentialRow( + label: @Composable RowScope.() -> Unit, + value: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, - titleTextStyle: TextStyle = MaterialTheme.typography.titleLarge, - potentialTextStyle: TextStyle = MaterialTheme.typography.headlineLarge, - scale: Int = 3, ) { - Column(modifier) { - Text( - title, - style = titleTextStyle, - fontWeight = FontWeight.Light, - ) - Text( - ArcaeaFormatters.potentialToText(potential, scale), - style = potentialTextStyle, - ) + Row(modifier, horizontalArrangement = Arrangement.SpaceBetween) { + label() + value() } } +@Composable +private fun PotentialRow( + label: String, + value: Double?, + divideBy: Int, + modifier: Modifier = Modifier, +) { + val dividedValue = value?.div(divideBy) + + PotentialRow( + label = { Text(label, Modifier.alignByBaseline()) }, + value = { + IconRow(Modifier.alignByBaseline()) { + Text( + "${ArcaeaFormatters.potentialToText(value, 4)} ÷ $divideBy", + Modifier.alignByBaseline(), + style = MaterialTheme.typography.bodyMedium, + ) + + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + Modifier.size(18.dp), + ) + + Text( + ArcaeaFormatters.potentialToText(dividedValue, 4), + Modifier.alignByBaseline(), + style = MaterialTheme.typography.titleLarge, + ) + } + }, + modifier = modifier, + ) +} + +@Composable +private fun PotentialRow( + label: String, + value: String, + modifier: Modifier = Modifier, + labelTextStyle: TextStyle = LocalTextStyle.current, + valueTextStyle: TextStyle = LocalTextStyle.current, +) { + PotentialRow( + label = { Text(label, Modifier.alignByBaseline(), style = labelTextStyle) }, + value = { Text(value, Modifier.alignByBaseline(), style = valueTextStyle) }, + modifier = modifier, + ) +} + +private fun dataCompletenessWarningResIds( + entries: PotentialGroups?, + scoringMode: ArcaeaScoringMode, +): List = + buildList { + if (scoringMode == ArcaeaScoringMode.B30_R10) { + add(R.string.potential_completeness_warning_recent_inaccuracy) + } + + if (entries == null) return@buildList + + when (scoringMode) { + ArcaeaScoringMode.B50 -> { + if (!entries.b50.isComplete) { + add( + if (entries.b10.isComplete) { + R.string.potential_completeness_warning_b50 + } else { + R.string.potential_completeness_warning_b10 + }, + ) + } + } + + ArcaeaScoringMode.B30_R10 -> { + if (!entries.b30.isComplete) add(R.string.potential_completeness_warning_b30) + if (!entries.r10.isComplete) add(R.string.potential_completeness_warning_r10) + } + } + } + @Composable internal fun OverviewPotentialCard( uiState: OverviewViewModel.UiState, modifier: Modifier = Modifier, ) { - // The official display precision is 0.01 for B30 + R10 and 0.001 for B50 + val warningResIds = + remember(uiState.entries, uiState.scoringMode) { + dataCompletenessWarningResIds(uiState.entries, uiState.scoringMode) + } + val warnings = warningResIds.map { stringResource(it) } + val mainScale = when (uiState.scoringMode) { ArcaeaScoringMode.B30_R10 -> 2 @@ -57,44 +150,48 @@ internal fun OverviewPotentialCard( } Card(modifier) { - Row(Modifier.padding(dimensionResource(R.dimen.page_padding))) { - PotentialLabel( - title = stringResource(R.string.arcaea_potential), - potential = uiState.potential, - titleTextStyle = MaterialTheme.typography.headlineSmall, - potentialTextStyle = MaterialTheme.typography.displayLarge, - scale = mainScale, - modifier = - Modifier - .align(Alignment.Bottom) - .weight(1f), - ) + PotentialRow( + label = stringResource(R.string.arcaea_potential), + value = ArcaeaFormatters.potentialToText(uiState.potential, mainScale), + modifier = + Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.page_padding)), + labelTextStyle = MaterialTheme.typography.headlineSmall, + valueTextStyle = MaterialTheme.typography.displayLarge, + ) - Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), - ) { - when (uiState.scoringMode) { - ArcaeaScoringMode.B30_R10 -> { - PotentialLabel( - title = "B30", - potential = uiState.b30, - ) + HorizontalDivider() - PotentialLabel( - title = "R10", - potential = uiState.r10, - ) - } + Column( + Modifier.padding(dimensionResource(R.dimen.page_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), + ) { + when (uiState.scoringMode) { + ArcaeaScoringMode.B50 -> { + PotentialRow("B10", uiState.entries?.b10?.total, 10, Modifier.fillMaxWidth()) + PotentialRow("B50", uiState.entries?.b50?.total, 50, Modifier.fillMaxWidth()) + } - ArcaeaScoringMode.B50 -> { - PotentialLabel( - title = "B50", - potential = uiState.b50, - ) + ArcaeaScoringMode.B30_R10 -> { + PotentialRow("B30", uiState.entries?.b30?.total, 30, Modifier.fillMaxWidth()) + PotentialRow("R10", uiState.entries?.r10?.total, 10, Modifier.fillMaxWidth()) + } + } + + if (warnings.isNotEmpty()) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.extendedColorScheme.warning, + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(painterResource(R.drawable.chart_line_alert), contentDescription = null) - PotentialLabel( - title = "B10", - potential = uiState.b10, + Text( + warnings.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, ) } } @@ -106,15 +203,24 @@ internal fun OverviewPotentialCard( @PreviewLightDark @Composable private fun OverviewPotentialCardPreview() { + val entries = + PotentialGroups( + b10 = PotentialGroup(items = listOf(), total = 125.0, isComplete = true), + b50 = PotentialGroup(items = listOf(), total = 630.0, isComplete = false), + b30 = PotentialGroup(items = listOf(), total = 380.0, isComplete = false), + r10 = PotentialGroup(items = listOf(), total = 130.0, isComplete = true), + ) + val scoringMode = ArcaeaScoringMode.B30_R10 + val potential = scoringMode.calculatePotential(entries) + ArcaeaOfflineTheme { Surface { OverviewPotentialCard( OverviewViewModel.UiState( isLoading = false, - scoringMode = ArcaeaScoringMode.B50, - b50 = 13.00, - b10 = 13.00, - potential = 13.00, + scoringMode = scoringMode, + entries = entries, + potential = potential, ), Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt index b2f830ca..aab15681 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt @@ -6,8 +6,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository +import xyz.sevive.arcaeaoffline.helpers.calculatePotential import kotlin.time.Duration.Companion.seconds class OverviewViewModel( @@ -17,41 +19,20 @@ class OverviewViewModel( data class UiState( val isLoading: Boolean = true, val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, - val b30: Double? = null, - val r10: Double? = null, - val b50: Double? = null, - val b10: Double? = null, + val entries: PotentialGroups? = null, val potential: Double? = null, ) - private data class SubPotentials( - val b30: Double?, - val r10: Double?, - val b50: Double?, - val b10: Double?, - ) - val uiState = combine( propertyRepository.scoringMode(), - potentialRepository.potential(), - combine( - potentialRepository.b30(), - potentialRepository.r10(), - potentialRepository.b50(), - potentialRepository.b10(), - ) { b30, r10, b50, b10 -> - SubPotentials(b30, r10, b50, b10) - }, - ) { scoringMode, potential, subPotentials -> + potentialRepository.groups(), + ) { scoringMode, entries -> UiState( isLoading = false, scoringMode = scoringMode, - b30 = subPotentials.b30, - r10 = subPotentials.r10, - b50 = subPotentials.b50, - b10 = subPotentials.b10, - potential = potential, + entries = entries, + potential = scoringMode.calculatePotential(entries), ) }.stateIn( viewModelScope, diff --git a/app/src/main/res/drawable/chart_line_alert.xml b/app/src/main/res/drawable/chart_line_alert.xml new file mode 100644 index 00000000..fcd248c4 --- /dev/null +++ b/app/src/main/res/drawable/chart_line_alert.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a1642506..2bb86b85 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -100,6 +100,13 @@ OCR 设置 + + 最佳列表不足 10 种谱面 + 最佳列表不足 30 种谱面 + 最佳列表不足 50 种谱面 + 最近队列不足 10 种谱面 + 由于分数历史记录完整度限制,R10 结果可能不准确 + 版本 %d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d6bb3ef..018005fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,13 @@ CC BY-SA 3.0 OCR Settings + + Fewer than 10 charts in the best list. + Fewer than 30 charts in the best list. + Fewer than 50 charts in the best list. + Fewer than 10 charts in the recent queue. + R10 may differ greatly due to limited score history. + Version %d diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculationTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculationTest.kt new file mode 100644 index 00000000..cbe890aa --- /dev/null +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/PotentialCalculationTest.kt @@ -0,0 +1,39 @@ +package xyz.sevive.arcaeaoffline.helpers + +import org.junit.Assert.assertEquals +import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroup +import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups + +class PotentialCalculationTest { + private companion object { + const val TOLERANCE = 1e-7 + } + + @Test + fun b50Calculation() { + val groups = + PotentialGroups( + b50 = PotentialGroup(items = emptyList(), total = 60.0, isComplete = true), + b10 = PotentialGroup(items = emptyList(), total = 12.0, isComplete = true), + b30 = PotentialGroup(items = emptyList(), total = 999.0, isComplete = true), + r10 = PotentialGroup(items = emptyList(), total = 999.0, isComplete = true), + ) + + assertEquals(1.2, ArcaeaScoringMode.B50.calculatePotential(groups), TOLERANCE) + } + + @Test + fun b30R10Calculation() { + val groups = + PotentialGroups( + b50 = PotentialGroup(items = emptyList(), total = 999.0, isComplete = true), + b10 = PotentialGroup(items = emptyList(), total = 999.0, isComplete = true), + b30 = PotentialGroup(items = emptyList(), total = 30.0, isComplete = true), + r10 = PotentialGroup(items = emptyList(), total = 10.0, isComplete = true), + ) + + assertEquals(1.0, ArcaeaScoringMode.B30_R10.calculatePotential(groups), TOLERANCE) + } +} diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt index 25daa70b..3ef73cf5 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt @@ -24,7 +24,6 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultCalculatedR import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepositoryImpl import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepositoryImpl -import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepositoryImpl import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepositoryImpl @@ -50,7 +49,6 @@ class R30QueueUpdaterTest { PlayResultCalculatedRepositoryImpl(db.playResultDao(), db.songDao(), db.chartInfoDao()), ), R30EntryRepositoryImpl(db.r30EntryDao()), - PropertyRepositoryImpl(db.propertyDao()), ) runBlocking { seed() } @@ -123,11 +121,18 @@ class R30QueueUpdaterTest { val queue = replayQueue() // 18 of the 30 entries are distinct charts, so whether they are counted once or per - // play decides the average below. + // play decides the sum below. assertEquals(18, queue.map { ChartKey(it.playResult.songId, it.playResult.ratingClass) }.distinct().size) db.r30EntryDao().insertBatch(*queue.map { it.entry }.toTypedArray()) - assertEquals(12.524324, potentialRepository.r10().first(), TOLERANCE) + assertEquals( + 125.24324, + potentialRepository + .groups() + .first() + .r10.total, + TOLERANCE, + ) } private companion object { diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt index fad9f34d..224611bb 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -28,11 +30,7 @@ class PotentialRepositoryTest { @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() - db = - Room - .inMemoryDatabaseBuilder(context, ArcaeaOfflineDatabase::class.java) - .setDriver(BundledSQLiteDriver()) - .build() + db = Room.inMemoryDatabaseBuilder(context, ArcaeaOfflineDatabase::class.java).setDriver(BundledSQLiteDriver()).build() potentialRepository = PotentialRepositoryImpl( @@ -41,12 +39,18 @@ class PotentialRepositoryTest { PlayResultCalculatedRepositoryImpl(db.playResultDao(), db.songDao(), db.chartInfoDao()), ), R30EntryRepositoryImpl(db.r30EntryDao()), - PropertyRepositoryImpl(db.propertyDao()), ) runBlocking { seed() } } + private suspend fun trimPlaysTo(count: Int) = + db.useWriterConnection { connection -> + connection.immediateTransaction { + execSQL("DELETE FROM play_results WHERE id NOT IN (SELECT id FROM play_results ORDER BY id LIMIT $count)") + } + } + @After fun tearDown() { db.close() @@ -77,22 +81,55 @@ class PotentialRepositoryTest { @Test fun b50MatchesTheSave() = runBlocking { - // 12.595 truncated to three decimals, the value the app displays - assertEquals(12.595672633333333, potentialRepository.b50().first(), TOLERANCE) + assertEquals( + 629.7836316666667, + potentialRepository + .groups() + .first() + .b50.total, + TOLERANCE, + ) } @Test fun b10MatchesTheSave() = runBlocking { - // 12.764 - assertEquals(12.764543, potentialRepository.b10().first(), TOLERANCE) + assertEquals( + 127.64542999999999, + potentialRepository + .groups() + .first() + .b10.total, + TOLERANCE, + ) } @Test - fun potentialMatchesTheSave() = + fun completenessTracksHowManyChartsHavePlayResults() = runBlocking { - // 12.623 - assertEquals(12.623817694444446, potentialRepository.potential().first(), TOLERANCE) + val full = potentialRepository.groups().first() + assertEquals(50, full.b50.items.size) + assertEquals(10, full.b10.items.size) + assertEquals(30, full.b30.items.size) + assertEquals(0, full.r10.items.size) + assertTrue(full.b50.isComplete) + assertTrue(full.b10.isComplete) + assertTrue(full.b30.isComplete) + assertFalse(full.r10.isComplete) + + // Completeness tests + trimPlaysTo(12) + val partial = potentialRepository.groups().first() + assertEquals(10, partial.b10.items.size) + assertTrue(partial.b10.isComplete) + assertFalse(partial.b50.isComplete) + assertFalse(partial.b30.isComplete) + + trimPlaysTo(5) + val few = potentialRepository.groups().first() + assertEquals(5, few.b50.items.size) + assertFalse(few.b10.isComplete) + assertFalse(few.b50.isComplete) } private companion object { diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt index 69cbac68..e151c1bb 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepository.kt @@ -3,34 +3,32 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated import xyz.sevive.arcaeaoffline.core.database.r30.ChartKey -interface PotentialRepository { - /** Average single-play potential of the best 30 entries (legacy display value). */ - fun b30(): Flow - - /** Average single-play potential of the recent top 10 entries (legacy display value). */ - fun r10(): Flow - - /** Average single-play potential of the best 50 entries (B50 display value). */ - fun b50(): Flow +data class PotentialGroup( + val items: List, + val total: Double, + val isComplete: Boolean, +) - /** Average single-play potential of the top 10 of the best 50 entries (B50 display value). */ - fun b10(): Flow +data class PotentialGroups( + val b10: PotentialGroup, + val b50: PotentialGroup, + val b30: PotentialGroup, + val r10: PotentialGroup, +) - /** Overall potential, following the scoring mode stored in the database properties. */ - fun potential(): Flow +interface PotentialRepository { + fun groups(): Flow } @OptIn(ExperimentalCoroutinesApi::class) class PotentialRepositoryImpl( private val playResultBestRepo: PlayResultBestRepository, private val r30EntryRepo: R30EntryRepository, - private val propertyRepo: PropertyRepository, ) : PotentialRepository { private fun b30Entries(): Flow> = playResultBestRepo.orderDescWithLimit(30, ArcaeaScoringMode.B30_R10) @@ -45,61 +43,35 @@ class PotentialRepositoryImpl( .take(10) } - override fun b30() = - this.b30Entries().mapLatest { entries -> - if (entries.isEmpty()) { - 0.0 - } else { - entries.sumOf { it.playRating } / entries.size - } - } - - override fun r10() = - this.r10Entries().mapLatest { entries -> - if (entries.isEmpty()) { - 0.0 - } else { - entries.sumOf { it.playRating() ?: 0.0 } / entries.size - } - } - - override fun b50() = - this.b50Entries().mapLatest { entries -> - if (entries.isEmpty()) { - 0.0 - } else { - entries.sumOf { it.playRatingWithClearBonus } / entries.size - } - } - - override fun b10() = - this.b50Entries().mapLatest { entries -> - val top = entries.take(10) - if (top.isEmpty()) { - 0.0 - } else { - top.sumOf { it.playRatingWithClearBonus } / top.size - } - } - - override fun potential() = - propertyRepo.scoringMode().flatMapLatest { mode -> - when (mode) { - ArcaeaScoringMode.B30_R10 -> { - combine(b30(), r10()) { b30, r10 -> - b30 * 0.75 + r10 * 0.25 - } - } + override fun groups(): Flow = + combine(b30Entries(), b50Entries(), r10Entries()) { b30, b50, r10 -> + val b10 = b50.take(10) - ArcaeaScoringMode.B50 -> { - // Official v7.0 formula: the best 10 entries count twice, - // i.e. (best50 sum + best10 sum) / 60 - b50Entries().mapLatest { entries -> - val b50Sum = entries.sumOf { it.playRatingWithClearBonus } - val b10Sum = entries.take(10).sumOf { it.playRatingWithClearBonus } - (b50Sum + b10Sum) / 60 - } - } - } + PotentialGroups( + b10 = + PotentialGroup( + items = b10, + total = b10.sumOf { it.playRatingWithClearBonus }, + isComplete = b10.count() >= 10, + ), + b50 = + PotentialGroup( + items = b50, + total = b50.sumOf { it.playRatingWithClearBonus }, + isComplete = b50.count() >= 50, + ), + b30 = + PotentialGroup( + items = b30, + total = b30.sumOf { it.playRating }, + isComplete = b30.count() >= 30, + ), + r10 = + PotentialGroup( + items = r10, + total = r10.sumOf { it.playRating() ?: 0.0 }, + isComplete = r10.count() >= 10, + ), + ) } } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Potential.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Potential.kt new file mode 100644 index 00000000..f625de69 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Potential.kt @@ -0,0 +1,11 @@ +package xyz.sevive.arcaeaoffline.core.calculators + +fun calculatePotentialB30R10( + b30Total: Double, + r10Total: Double, +) = (b30Total + r10Total) / 40.0 + +fun calculatePotentialB50( + b50Total: Double, + b10Total: Double, +) = (b50Total + b10Total) / 60.0 diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/PotentialCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/PotentialCalculatorsTest.kt new file mode 100644 index 00000000..e325b074 --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/PotentialCalculatorsTest.kt @@ -0,0 +1,28 @@ +package xyz.sevive.arcaeaoffline.core.calculators + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PotentialCalculatorsTest { + companion object { + const val TOLERANCE: Double = 1e-7 + } + + @Test + fun testB30R10() { + assertEquals( + 12.552726250000001, + calculatePotentialB30R10(377.003555, 125.105495), + TOLERANCE, + ) + } + + @Test + fun testB50() { + assertEquals( + 12.623817694444446, + calculatePotentialB50(629.7836316666667, 127.64542999999999), + TOLERANCE, + ) + } +} From df79ab386957223bc6a9c8c1e8ac196d41702ccc Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 2 Oct 2026 23:35:30 +0800 Subject: [PATCH 26/35] refactor: drop incremental R30 update mode --- .../sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 72 ++----------------- .../database/r30list/DatabaseR30ListScreen.kt | 7 -- .../r30list/DatabaseR30ListViewModel.kt | 16 +---- .../core/database/r30/R30QueueUpdaterTest.kt | 10 +-- .../core/database/daos/PlayResultDao.kt | 12 ---- .../core/database/entities/ChartBestScore.kt | 11 --- .../core/database/r30/R30QueueUpdater.kt | 18 ++--- .../repositories/PlayResultRepository.kt | 11 --- .../repositories/PropertyRepository.kt | 6 -- 9 files changed, 20 insertions(+), 143 deletions(-) delete mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index 2529945e..2164fbf3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase -import xyz.sevive.arcaeaoffline.core.database.r30.ChartKey import xyz.sevive.arcaeaoffline.core.database.r30.R30QueueUpdater import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository @@ -43,44 +42,13 @@ class R30UpdateJob( companion object { private const val LOG_TAG = "R30UpdateJob" const val WORK_NAME = "R30UpdateJob" - - const val DATA_RUN_MODE = "run_mode" } private val logger = Logger.withTag(LOG_TAG) - enum class RunMode( - val value: Int, - ) { - NORMAL(0), - REBUILD(1), ; - - companion object { - fun fromInt(value: Int) = entries.firstOrNull { it.value == value } - } - } - - private data class WorkOptions( - val runMode: RunMode, - ) - - private fun parseRunMode(): RunMode { - val runModeInput = inputData.getInt(DATA_RUN_MODE, 0) - val result = RunMode.fromInt(runModeInput) - if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" } - return result ?: RunMode.NORMAL - } - - private fun getWorkOptions(): WorkOptions = - WorkOptions( - runMode = parseRunMode(), - ) - private val progressFlow = MutableStateFlow(Progress.INDETERMINATE) override suspend fun doWork(): Result { - val workOptions = getWorkOptions() - try { return coroutineScope { val progressPublishJob = @@ -88,43 +56,17 @@ class R30UpdateJob( progressFlow.collectLatest { setProgress(it.toWorkData()) } } - val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt() - val cutoff = r30LastUpdatedAt.takeUnless { workOptions.runMode == RunMode.REBUILD } - - var r30EntryCombinedList = - if (cutoff == null) { - emptyList() - } else { - r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() - } - - // Records of the plays outside the batch, so an incremental run decides on a - // new chart record the same way a rebuild from the whole history would. - val previousBestScores = - cutoff - ?.let { playResultRepo.bestScoresUntil(it) } - ?.associate { ChartKey(it.songId, it.ratingClass) to it.score } - .orEmpty() - - val playResults = - if (cutoff == null) { - playResultRepo.findAll().firstOrNull() ?: emptyList() - } else { - playResultRepo.findLaterThan(cutoff).firstOrNull() ?: emptyList() - } val deletedSongIds = songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() - val newPlayResults = - playResults + val playResults = + (playResultRepo.findAll().firstOrNull() ?: emptyList()) .filter { it.date != null && it.songId !in deletedSongIds } .sortedWith(compareBy({ it.date }, { it.id })) - progressFlow.update { Progress(current = 0, total = newPlayResults.size) } - logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } - r30EntryCombinedList = - r30QueueUpdater.replay( - plays = newPlayResults, - entries = r30EntryCombinedList, - previousBestScores = previousBestScores, + progressFlow.update { Progress(current = 0, total = playResults.size) } + logger.d { "Rebuilding r30 list from ${playResults.size} play results" } + val r30EntryCombinedList = + r30QueueUpdater.rebuild( + plays = playResults, onPlay = { ensureActive() progressFlow.update { progress -> progress.increment() } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt index d0179510..ecc503ce 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt @@ -116,13 +116,6 @@ internal fun DatabaseR30ListScreen(viewModel: DatabaseR30ListViewModel = koinVie ) { Icon(Icons.Default.SyncProblem, null) } - - IconButton( - onClick = { viewModel.requestUpdate() }, - enabled = !isUpdating, - ) { - Icon(Icons.Default.Sync, null) - } }, ) }, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt index 4e749ea9..4d30a621 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.firstOrNull @@ -98,24 +97,15 @@ class DatabaseR30ListViewModel( null, ) - private fun enqueueWork(runMode: R30UpdateJob.RunMode) { - val workRequest = - OneTimeWorkRequestBuilder().setInputData( - workDataOf(R30UpdateJob.DATA_RUN_MODE to runMode.value), - ) - + private fun enqueueWork() { workManager.enqueueUniqueWork( R30UpdateJob.WORK_NAME, ExistingWorkPolicy.REPLACE, - workRequest.build(), + OneTimeWorkRequestBuilder().build(), ) } - fun requestUpdate() { - enqueueWork(R30UpdateJob.RunMode.NORMAL) - } - fun requestRebuild() { - enqueueWork(R30UpdateJob.RunMode.REBUILD) + enqueueWork() } } diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt index 3ef73cf5..931433d0 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt @@ -81,7 +81,7 @@ class R30QueueUpdaterTest { return stream.bufferedReader().use { it.readText() } } - private suspend fun replayQueue(): List { + private suspend fun rebuildQueue(): List { val chartInfoRepository = ChartInfoRepositoryImpl(db.chartInfoDao()) val playResults = PlayResultRepositoryImpl(db.playResultDao()) @@ -89,7 +89,7 @@ class R30QueueUpdaterTest { .first() .sortedWith(compareBy({ it.date }, { it.id })) - return R30QueueUpdater { chartInfoRepository.find(it).firstOrNull() }.replay(playResults) + return R30QueueUpdater { chartInfoRepository.find(it).firstOrNull() }.rebuild(playResults) } private data class QueueEntry( @@ -110,15 +110,15 @@ class R30QueueUpdaterTest { }.sortedWith(compareBy({ it.songId }, { it.ratingClass }, { it.score }, { it.date })) @Test - fun replayRebuildsTheQueueOfTheSave() = + fun rebuildRebuildsTheQueueOfTheSave() = runBlocking { - assertEquals(EXPECTED_QUEUE, replayQueue().toQueueEntries()) + assertEquals(EXPECTED_QUEUE, rebuildQueue().toQueueEntries()) } @Test fun recentTopTenCountsEachChartOnce() = runBlocking { - val queue = replayQueue() + val queue = rebuildQueue() // 18 of the 30 entries are distinct charts, so whether they are counted once or per // play decides the sum below. diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt index e7c795ef..db95df4d 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt @@ -6,9 +6,7 @@ import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass -import xyz.sevive.arcaeaoffline.core.database.entities.ChartBestScore import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import kotlin.time.Instant import kotlin.uuid.Uuid @Dao @@ -22,16 +20,6 @@ interface PlayResultDao { @Query("SELECT * FROM play_results WHERE uuid = :uuid") fun findByUuid(uuid: Uuid): Flow - @Query("SELECT * FROM play_results WHERE date > :date") - fun findLaterThan(date: Instant): Flow> - - @Query( - """SELECT song_id, rating_class, MAX(score) AS score - FROM play_results WHERE `date` <= :date - GROUP BY song_id, rating_class""", - ) - suspend fun bestScoresUntil(date: Instant): List - @Query("SELECT * FROM play_results") fun findAll(): Flow> diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt deleted file mode 100644 index fa4c22a2..00000000 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/ChartBestScore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.sevive.arcaeaoffline.core.database.entities - -import androidx.room.ColumnInfo -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass - -/** Highest score of a chart, as of a point in the play history. */ -data class ChartBestScore( - @ColumnInfo(name = "song_id") val songId: String, - @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, - val score: Int, -) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt index 3e0f5b2d..eca600c2 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt @@ -14,13 +14,10 @@ data class ChartKey( val ratingClass: ArcaeaRatingClass, ) -/** Best score of a chart before the play being written; absent while the chart has no record. */ -typealias ChartBestScores = Map - /** * Rebuilds the recent-30 queue under the B30 + R10 rules from play results. * - * [replay] applies one play at a time, in play order: + * [rebuild] applies one play at a time, in play order: * * a play enters the queue directly, evicting the entry with the earliest play time, or * conditionally, evicting the entry with the lowest single-play potential and being discarded * when no entry is lower; @@ -34,19 +31,14 @@ class R30QueueUpdater( private val chartInfoOf: suspend (PlayResult) -> ChartInfo?, ) { /** - * Applies [plays] in order onto [entries]. - * - * [previousBestScores] carries the chart records of plays that came before [plays], so an - * incremental run sees what a rebuild from the whole history would. + * Applies [plays] in order onto an empty queue. */ - suspend fun replay( + suspend fun rebuild( plays: List, - entries: List = emptyList(), - previousBestScores: ChartBestScores = emptyMap(), onPlay: suspend () -> Unit = {}, ): List { - var queue = entries - val bestScores = previousBestScores.toMutableMap() + var queue = emptyList() + val bestScores = mutableMapOf() plays.forEach { play -> val chart = ChartKey(play.songId, play.ratingClass) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt index 27c4a2f1..9a360340 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt @@ -3,9 +3,7 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultDao -import xyz.sevive.arcaeaoffline.core.database.entities.ChartBestScore import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import kotlin.time.Instant import kotlin.uuid.Uuid interface PlayResultRepository { @@ -16,11 +14,6 @@ interface PlayResultRepository { fun findByUuid(uuid: Uuid): Flow - fun findLaterThan(date: Instant): Flow> - - /** Best score per chart over plays at or before [date], for an incremental recent-queue run. */ - suspend fun bestScoresUntil(date: Instant): List - fun findAll(): Flow> fun findAllBySongId(songId: String): Flow> @@ -48,10 +41,6 @@ class PlayResultRepositoryImpl( override fun findByUuid(uuid: Uuid): Flow = dao.findByUuid(uuid) - override fun findLaterThan(date: Instant): Flow> = dao.findLaterThan(date) - - override suspend fun bestScoresUntil(date: Instant): List = dao.bestScoresUntil(date) - override fun findAll(): Flow> = dao.findAll() override fun findAllBySongId(songId: String): Flow> = dao.findAllBySongId(songId) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt index dbf81171..db404509 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt @@ -33,8 +33,6 @@ interface PropertyRepository { suspend fun r30LastUpdatedAt(): Instant? suspend fun setR30LastUpdatedAt(instant: Instant) - - suspend fun deleteR30LastUpdatedAt() } class PropertyRepositoryImpl( @@ -79,8 +77,4 @@ class PropertyRepositoryImpl( override suspend fun setR30LastUpdatedAt(instant: Instant) { this.upsert(Property(Property.KEY_R30_LAST_UPDATED_AT, instant.toEpochMilliseconds().toString())) } - - override suspend fun deleteR30LastUpdatedAt() { - this.delete(Property.KEY_R30_LAST_UPDATED_AT) - } } From 071b53d6999933b7b53943354ce3b8aca650873d Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 2 Oct 2026 23:55:22 +0800 Subject: [PATCH 27/35] refactor(app.ui): seed chart recommend target with the overall potential --- .../UtilitiesChartRecommendScreenViewModel.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt index 2156a5ae..56cea344 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt @@ -22,6 +22,7 @@ import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository +import xyz.sevive.arcaeaoffline.helpers.calculatePotential import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds @@ -51,14 +52,9 @@ class UtilitiesChartRecommendScreenViewModel( viewModelScope.launch { // Seed the target with the current level of the active scoring rules val mode = propertyRepo.scoringMode().firstOrNull() ?: ArcaeaScoringMode.B50 - val currentPotential = - when (mode) { - ArcaeaScoringMode.B30_R10 -> potentialRepo.b30().firstOrNull() - ArcaeaScoringMode.B50 -> potentialRepo.b50().firstOrNull() - } - - currentPotential?.let { - targetPlayRating.value = ((it + 0.05) * 100).roundToInt() / 100.0 + potentialRepo.groups().firstOrNull()?.let { + val potential = mode.calculatePotential(it) + targetPlayRating.value = ((potential + 0.05) * 100).roundToInt() / 100.0 } } } From c5cb99dff861b16c20a082618ba1775801e382bb Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 3 Oct 2026 00:06:57 +0800 Subject: [PATCH 28/35] chore: cleanups --- .../helpers/ArcaeaPlayResultValidator.kt | 19 ++++++++++--------- .../manage/DatabaseManageViewModel.kt | 5 ++++- .../values-zh-rCN/strings.xml | 3 --- .../composeResources/values/strings.xml | 3 --- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt index e47330c4..4a97d157 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt @@ -42,15 +42,16 @@ object ArcaeaPlayResultValidator { chartInfo: ChartInfo?, scoringMode: ArcaeaScoringMode, ): List { - val warnings = validate(playResult, chartInfo).toMutableList() - - if ( - scoringMode == ArcaeaScoringMode.B50 && - ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, chartInfo) - ) { - warnings.add(ArcaeaPlayResultValidatorClearTypeMissingWarning) - } + val clearTypeMissing = + if ( + scoringMode == ArcaeaScoringMode.B50 && + ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, chartInfo) + ) { + listOf(ArcaeaPlayResultValidatorClearTypeMissingWarning) + } else { + emptyList() + } - return warnings + return validate(playResult, chartInfo) + clearTypeMissing } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt index 8c3cb0ca..79ee8c20 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt @@ -122,7 +122,10 @@ class DatabaseManageViewModel( ) fun setScoringMode(mode: ArcaeaScoringMode) { - viewModelScope.launch { propertyRepo.setScoringMode(mode) } + viewModelScope.launch { + runCatching { propertyRepo.setScoringMode(mode) } + .onFailure { logger.e(it) { "Failed to set scoring mode" } } + } } internal val uiState = diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index eea6fda0..c203f0ab 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -155,9 +155,6 @@ 游玩记录 %s 已更新 - - B30 列表 - R30 列表 重建 R30 列表 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 051378c5..7df8a918 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -171,9 +171,6 @@ Play result %s updated - - Best30 List - Recent30 List Rebuild Recent30 List From 5fc50c2621d885e6d87bf364beba9c001e386582 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 3 Oct 2026 00:07:18 +0800 Subject: [PATCH 29/35] docs: sync implementations and formatting --- docs/potential.md | 43 ++++++++++++++++++++++--------------------- docs/st3.md | 2 -- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/potential.md b/docs/potential.md index 5729cdb6..371518d0 100644 --- a/docs/potential.md +++ b/docs/potential.md @@ -16,27 +16,27 @@ ### 分数换算 -| 分数 | 单曲潜力值 | -|----------------------------|-------------------------------------------| -| ≥ 10,000,000 | `定数 + 2` | +| 分数 | 单曲潜力值 | +|-----------------------------|-----------------------------------------------| +| ≥ 10,000,000 | `定数 + 2` | | ≥ 9,800,000 且 < 10,000,000 | `定数 + 1 + (分数 - 9,800,000) / 200,000` | -| < 9,800,000 | `max(0, 定数 + (分数 - 9,500,000) / 300,000)` | +| < 9,800,000 | `max(0, 定数 + (分数 - 9,500,000) / 300,000)` | ### 通关奖励 -B50 下,_TRACK LOST_ 以上的通关状态会在计算时赋 +0.2 的额外奖励。B30 + R10 没有该机制。 +B50 下, _TRACK LOST_ 以上的通关状态会在计算时赋 +0.2 的额外奖励。B30 + R10 没有该机制。 通关状态与该次成绩独立绑定。即某谱面曾经 TC,不使其后的 TL 成绩也享有奖励。本项目把未记录通关状态视同 TL。 ### 速查 -| 分数 | B30 + R10 | B50(TC 及以上) | B50(TL) | -|-------------|-----------|-------------|-----------| -| ≥10,000,000 | 定数 + 2.00 | 定数 + 2.20 | — | -| 9,950,000 | 定数 + 1.75 | 定数 + 1.95 | 定数 + 1.75 | -| 9,800,000 | 定数 + 1.00 | 定数 + 1.20 | 定数 + 1.00 | -| 9,500,000 | 定数 | 定数 + 0.20 | 定数 | -| 8,900,000 | 定数 - 2.00 | 定数 - 1.80 | 定数 - 2.00 | +| 分数 | B30 + R10 | B50(TC 及以上) | B50(TL) | +|-------------|-------------|------------------|-------------| +| ≥10,000,000 | 定数 + 2.00 | 定数 + 2.20 | — | +| 9,950,000 | 定数 + 1.75 | 定数 + 1.95 | 定数 + 1.75 | +| 9,800,000 | 定数 + 1.00 | 定数 + 1.20 | 定数 + 1.00 | +| 9,500,000 | 定数 | 定数 + 0.20 | 定数 | +| 8,900,000 | 定数 - 2.00 | 定数 - 1.80 | 定数 - 2.00 | ## B30 + R10 (20170602) @@ -48,22 +48,22 @@ B50 下,_TRACK LOST_ 以上的通关状态会在计算时赋 +0.2 的额外奖 `整体潜力值 = (Best 30 总和 + Recent 10 总和) / 40` -* **B30**:每个谱面(同一首歌的不同难度分开计)只保留最高分的一条,按单曲潜力值取前 30,只升不降。 - 只要有过一次单曲潜力值不为 0 的游玩,整体潜力值就不会降到 0。 -* **R10**:把最近 30 次游玩(R30 队列)按单曲潜力值从高到低排,取前 10 条。这是潜力值下降的主要来源。 - 移动版下同一谱面只取最高成绩。 +* **B30**:每个谱面只保留最高分的一条,按单曲潜力值取前 30,只升不降。 只要有过一次单曲潜力值不为 0 的游玩,整体潜力值就不会降到 0。 +* **R10**:把最近 30 次游玩(R30 队列)按单曲潜力值从高到低排,取前 10 条。这是潜力值下降的主要来源。 移动版下同一谱面只取最高成绩。 R30 队列的更新遵循以下规则。 首先,判定要写入队列的成绩是否具备以下条件: + * 分数达到 EX 及以上(≥9,800,000) * 困难模式 Track Lost(不含 *光 (Fracture)* 等特殊搭档的 Overflow 情形) * 第一次提交某个谱面的成绩 * 提交的分数大于远程服务器记录(推分保护) * 本次写入会使 R30 内不同谱面数低于 10 种,或使其降低 -**若以上条件均不满足**:该成绩**直接进入队列**,并刷出队列内游玩时间最早的一条成绩。 -**若满足上述任一条件**:进行**条件写入**。若本次单曲潜力值高于队列内最低值,则刷出该最低值成绩并写入本次成绩;否则,**舍弃本次成绩**。 +**若以上条件均不满足**:该成绩 **直接进入队列**,并刷出队列内游玩时间最早的一条成绩。 + +**若满足上述任一条件**:进行 **条件写入**。若本次单曲潜力值高于队列内最低值,则刷出该最低值成绩并写入本次成绩;否则, **舍弃本次成绩**。 本规则不计算通关奖励。 @@ -77,12 +77,13 @@ B50 没有 recent 部分:整体潜力值只由 50 个历史最高成绩(B50 `整体潜力值 = (Best 50 总和 + Best 10 总和) / 60` -* **B50**:每个谱面只保留最高的一条(同一首歌的不同难度分开计,按含通关奖励的单曲潜力值比较), - 取前 50,只升不降。因此同一谱面下通关的成绩可能压过分数更高但 TL 的成绩。 +* **B50**:每个谱面只保留最高的一条(按含通关奖励的单曲潜力值比较),取前 50,只升不降。因此同一谱面下通关的成绩可能压过分数更高但 TL 的成绩。 * 官方也以 B50 展示,单曲潜力值精确到三位小数。 ## 现有实现注释 * **R30 依据本地成绩历史重建**:“第一次提交某个谱面的成绩”与“提交的分数大于远程服务器记录”合并实现为“本次分数高于该谱面此前的记录”, - 其中“此前”指本数据库中更早的成绩记录。 + 其中“此前”指本数据库中更早的成绩记录。队列总是由完整成绩历史一次性重建,不做增量更新。 +* **条件写入后的下限保护**:条件写入成功之后还要检查队列内不同谱面数,若仍不足 10 种,则本次成绩整体丢弃。 +* **填队列期间无下限检查**:队列未满 30 条时直接追加,不做 10 种谱面下限检查。追加成绩不会降低不同谱面数,此阶段本就凑不满下限。 * **同潜力值成绩按固定顺序刷出**:官方为随机,本项目为便于测试等因素取确定顺序,对最终生成的 R10 **数值**没有影响。 diff --git a/docs/st3.md b/docs/st3.md index 34543451..6c80c8b4 100644 --- a/docs/st3.md +++ b/docs/st3.md @@ -17,8 +17,6 @@ 沉痛悼念: ```kotlin -// git show 853a633bc53701933fc112fece9d4037531fd70b:core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt | sed -n '/val isClearTypeReliable/,+34p' - val isClearTypeReliable: Boolean get() { if (clearType == ArcaeaPlayResultClearType.FULL_RECALL.value && lost != 0) return false From daa45d233dcedddc1514623473181c298fb7edc3 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 4 Oct 2026 01:30:01 +0800 Subject: [PATCH 30/35] clear type bonus and tests --- .../ui/components/PlayRatingCalculator.kt | 49 +++++---------- .../utilities/UtilitiesCalculatorScreen.kt | 10 ++++ .../UtilitiesChartRecommendScreen.kt | 24 +++++++- .../UtilitiesChartRecommendScreenViewModel.kt | 49 +++++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../UtilitiesChartRecommendFilterTest.kt | 60 +++++++++++++++++++ .../arcaeaoffline/core/calculators/Common.kt | 55 ++++++++--------- .../core/calculators/CommonCalculatorsTest.kt | 27 +++++++++ 9 files changed, 205 insertions(+), 73 deletions(-) create mode 100644 app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index 69e1801b..7641f56f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowRight -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -24,15 +23,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState -import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget +import xyz.sevive.arcaeaoffline.ui.components.preferences.SwitchPreferencesWidget import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -43,30 +41,12 @@ fun PlayRatingCalculator( constant: Int = 0, isConstantReadonly: Boolean = true, initialFocusScoreTextField: Boolean = false, + initialCleared: Boolean = false, + countClearBonus: Boolean = true, ) { val scoreTextFieldFocusRequester = remember { FocusRequester() } - // No clear type means no clear bonus (same as TRACK_LOST) - var clearType by remember { mutableStateOf(null) } - - var showClearTypeSelectDialog by rememberSaveable { mutableStateOf(false) } - if (showClearTypeSelectDialog) { - val values = remember { ArcaeaPlayResultClearType.entries.sortedBy { it.value } } - SelectDialog( - // Index 0 is the no-clear-type option; the entries follow from index 1 - labels = - buildList { - add(AnnotatedString(stringResource(R.string.play_result_no_clear_type))) - values.forEach { add(AnnotatedString(it.toDisplayString())) } - }, - onDismiss = { showClearTypeSelectDialog = false }, - onSelect = { - clearType = if (it == 0) null else values[it - 1] - showClearTypeSelectDialog = false - }, - selectedOptionIndex = clearType?.let { values.indexOf(it) + 1 }, - ) - } + var cleared by rememberSaveable { mutableStateOf(initialCleared) } val scoreTextFieldState = rememberArcaeaScoreTextFieldState( @@ -103,7 +83,8 @@ fun PlayRatingCalculator( scoreValue ?: return@derivedStateOf null constantValue ?: return@derivedStateOf null - calculatePlayRating(scoreValue!!, constantValue!!, clearType) + val clearBonus = if (countClearBonus && cleared) PLAY_RATING_CLEAR_BONUS else 0.0 + calculatePlayRating(scoreValue!!, constantValue!!, clearBonus) } } @@ -142,14 +123,14 @@ fun PlayRatingCalculator( ) } - TextPreferencesWidget( - title = stringResource(R.string.arcaea_play_result_clear_type), - content = - clearType?.toDisplayString() - ?: stringResource(R.string.play_result_no_clear_type), - trailingIcon = Icons.Default.ExpandMore, - onClick = { showClearTypeSelectDialog = true }, - ) + if (countClearBonus) { + SwitchPreferencesWidget( + value = cleared, + onValueChange = { cleared = it }, + title = stringResource(R.string.play_rating_cleared), + description = stringResource(R.string.play_rating_cleared_description), + ) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt index ad7eb8ab..85951c01 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt @@ -22,12 +22,15 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.firstOrNull import org.koin.compose.koinInject import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPackAndSongQuickSearch import xyz.sevive.arcaeaoffline.ui.components.ArcaeaRatingClassSelector @@ -41,7 +44,13 @@ fun UtilitiesCalculatorScreen( modifier: Modifier = Modifier, difficultyRepo: DifficultyRepository = koinInject(), chartInfoRepo: ChartInfoRepository = koinInject(), + propertyRepo: PropertyRepository = koinInject(), ) { + val scoringMode by + propertyRepo + .scoringMode() + .collectAsStateWithLifecycle(initialValue = PropertyRepository.DEFAULT_SCORING_MODE) + var constant by remember { mutableIntStateOf(0) } var selectedSongId by remember { mutableStateOf(null) } var selectedRatingClass by remember { mutableStateOf(null) } @@ -112,6 +121,7 @@ fun UtilitiesCalculatorScreen( PlayRatingCalculator( constant = constant, isConstantReadonly = false, + countClearBonus = scoringMode == ArcaeaScoringMode.B50, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index ff8c7d5b..177e0eec 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -52,8 +52,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS import xyz.sevive.arcaeaoffline.core.calculators.calculateInvertScoreRange import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.ui.SubScreenContainer @@ -64,6 +66,7 @@ import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader import xyz.sevive.arcaeaoffline.ui.components.PlayRatingCalculator import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.preferences.SwitchPreferencesWidget import xyz.sevive.arcaeaoffline.ui.components.rememberDecimalStepperTextFieldState import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.UtilitiesSubScreen @@ -138,6 +141,8 @@ data class PlayRatingCalculatorDialogState( private fun PlayRatingCalculatorDialog( onDismissRequest: () -> Unit, state: PlayRatingCalculatorDialogState, + countClearBonus: Boolean, + cleared: Boolean, ) { BasicAlertDialogSurface(onDismissRequest) { contentPadding -> Column( @@ -168,6 +173,8 @@ private fun PlayRatingCalculatorDialog( constant = state.constant, isConstantReadonly = true, initialFocusScoreTextField = true, + countClearBonus = countClearBonus, + initialCleared = cleared, ) } } @@ -177,12 +184,14 @@ data class ResultsListItemState( val item: DifficultyWithSongAndInfo, val scoreRange: IntRange, val targetPlayRating: Double, + val cleared: Boolean = false, ) { val targetScoreRange by lazy { calculateInvertScoreRange( targetPlayRating = targetPlayRating, constant = item.constant, tolerance = 1e-6, + clearBonus = if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0, ) } @@ -195,7 +204,7 @@ data class ResultsListItemState( } val actualPlayRating by lazy { - calculatePlayRating(score, item.constant) + calculatePlayRating(score, item.constant, if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0) } } @@ -274,6 +283,8 @@ fun UtilitiesChartRecommendScreen( PlayRatingCalculatorDialog( onDismissRequest = { showCalculatorDialog = false }, state = calculatorDialogState, + countClearBonus = uiState.scoringMode == ArcaeaScoringMode.B50, + cleared = uiState.cleared, ) } @@ -356,6 +367,15 @@ fun UtilitiesChartRecommendScreen( TextButton({ viewModel.setScoreRange(9_500_000..9_799_999) }) { Text("AA") } } } + + if (uiState.scoringMode == ArcaeaScoringMode.B50) { + SwitchPreferencesWidget( + value = uiState.cleared, + onValueChange = viewModel::setCleared, + title = stringResource(R.string.play_rating_cleared), + description = stringResource(R.string.play_rating_cleared_description), + ) + } } } @@ -383,7 +403,7 @@ fun UtilitiesChartRecommendScreen( } } else { items(uiState.charts, { it.difficultyWithSong.songId + it.difficultyWithSong.ratingClass.name }) { item -> - val state = ResultsListItemState(item, scoreRange, targetPlayRating) + val state = ResultsListItemState(item, scoreRange, targetPlayRating, uiState.cleared) ResultsListItem( state = state, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt index 56cea344..30290b8e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo @@ -30,9 +31,30 @@ data class UtilitiesChartRecommendScreenUiState( val isLoading: Boolean = true, val scoreRange: IntRange = 9_800_000..9_899_999, val targetPlayRating: Double = 0.0, + val scoringMode: ArcaeaScoringMode = PropertyRepository.DEFAULT_SCORING_MODE, + val cleared: Boolean = false, val charts: List = emptyList(), ) +/** + * Charts whose single-play rating within [scoreRange], with the clear bonus + * added when [cleared], can reach [targetPlayRating]. + */ +internal fun filterChartsByTarget( + rows: List, + scoreRange: IntRange, + targetPlayRating: Double, + cleared: Boolean, +): List { + val clearBonus = if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0 + + return rows.filter { row -> + val min = calculatePlayRating(score = scoreRange.first, constant = row.constant, clearBonus = clearBonus) + val max = calculatePlayRating(score = scoreRange.last, constant = row.constant, clearBonus = clearBonus) + targetPlayRating in min..max + } +} + class UtilitiesChartRecommendScreenViewModel( private val difficultyWithSongRepo: DifficultyWithSongRepository, private val potentialRepo: PotentialRepository, @@ -43,10 +65,13 @@ class UtilitiesChartRecommendScreenViewModel( private data class FilterParams( val scoreRange: IntRange, val targetPlayRating: Double, + val cleared: Boolean, + val scoringMode: ArcaeaScoringMode, ) private val scoreRange = MutableStateFlow(9_800_000..9_899_999) private val targetPlayRating = MutableStateFlow(0.0) + private val cleared = MutableStateFlow(false) init { viewModelScope.launch { @@ -61,27 +86,23 @@ class UtilitiesChartRecommendScreenViewModel( @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = - combine(scoreRange, targetPlayRating) { sr, tpr -> - FilterParams(sr, tpr) + combine(scoreRange, targetPlayRating, cleared, propertyRepo.scoringMode()) { sr, tpr, c, mode -> + FilterParams(sr, tpr, c, mode) }.flatMapLatest { params -> // One joined query (ordered by constant) instead of fetching the // chart of each chart info row separately. difficultyWithSongRepo .findAllWithInfo() .map { rows -> - val filtered = - rows - .filter { row -> - val min = calculatePlayRating(score = params.scoreRange.first, constant = row.constant) - val max = calculatePlayRating(score = params.scoreRange.last, constant = row.constant) - params.targetPlayRating in min..max - } + val effectiveCleared = if (params.scoringMode == ArcaeaScoringMode.B50) params.cleared else false UtilitiesChartRecommendScreenUiState( isLoading = false, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, - charts = filtered, + scoringMode = params.scoringMode, + cleared = effectiveCleared, + charts = filterChartsByTarget(rows, params.scoreRange, params.targetPlayRating, effectiveCleared), ) }.onStart { emit( @@ -89,6 +110,8 @@ class UtilitiesChartRecommendScreenViewModel( isLoading = true, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, + scoringMode = params.scoringMode, + cleared = params.cleared, ), ) }.catch { e -> @@ -98,6 +121,8 @@ class UtilitiesChartRecommendScreenViewModel( isLoading = false, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, + scoringMode = params.scoringMode, + cleared = params.cleared, charts = emptyList(), ), ) @@ -118,4 +143,8 @@ class UtilitiesChartRecommendScreenViewModel( fun setTargetPlayRating(newValue: Double) { targetPlayRating.value = newValue } + + fun setCleared(newValue: Boolean) { + cleared.value = newValue + } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2bb86b85..8974f56d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -79,6 +79,8 @@ 分数 MAX RECALL 通关状态 + 通关 + 计入 +0.2 通关奖励 Modifier 备注 潜力值 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 018005fc..2876c52a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,8 @@ CC BY-SA 3.0 LOST MAX RECALL Clear Type + Cleared + Counts the +0.2 clear bonus Modifier Comment st3 diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt new file mode 100644 index 00000000..2a77285f --- /dev/null +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt @@ -0,0 +1,60 @@ +package xyz.sevive.arcaeaoffline.ui.screens.utilities + +import org.junit.Assert.assertEquals +import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo + +class UtilitiesChartRecommendFilterTest { + private fun row(songId: String, constant: Int) = + DifficultyWithSongAndInfo( + DifficultyWithSong(songId, ArcaeaRatingClass.FUTURE, null, 5, false, songId, "artist"), + constant, + null, + ) + + private val rows = listOf(row("c100", 100), row("c0", 0)) + + // Over the EX range, the constant-100 chart earns 11.0..~11.5 without the + // bonus and 11.2..~11.7 with it + private val scoreRange = 9_800_000..9_899_999 + + @Test + fun filterWithoutClearBonus() { + assertEquals( + listOf("c100"), + filterChartsByTarget(rows, scoreRange, 11.05, cleared = false).map { it.difficultyWithSong.songId }, + ) + + // Above the bonus-free maximum + assertEquals(emptyList(), filterChartsByTarget(rows, scoreRange, 11.65, cleared = false)) + } + + @Test + fun filterWithClearBonus() { + // The +0.2 bonus lifts the achievable ratings past the low target + assertEquals( + emptyList(), + filterChartsByTarget(rows, scoreRange, 11.05, cleared = true), + ) + + // ...and reaches targets the bonus-free play cannot + assertEquals( + listOf("c100"), + filterChartsByTarget(rows, scoreRange, 11.65, cleared = true).map { it.difficultyWithSong.songId }, + ) + } + + @Test + fun filterDropsInvalidConstants() { + val rowsWithInvalidOnly = listOf(row("c0", 0)) + + // A missing chart info reads as constant 0 and earns a rating of 0.0, + // which no positive target matches + assertEquals( + emptyList(), + filterChartsByTarget(rowsWithInvalidOnly, scoreRange, 11.0, cleared = false), + ) + } +} diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt index ac32a98f..562d8946 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt @@ -15,50 +15,47 @@ fun calculateScoreRange( return actualScore..actualScore + pure } -fun calculatePlayRating( - score: Int, - constant: Int, -): Double { - // Chart constants are positive; 0 is what a missing chart info reads as. - if (constant <= 0) return 0.0 - - return if (score >= 10_000_000) { - constant / 10.0 + 2 - } else if (score >= 9_800_000) { - constant / 10.0 + 1 + (score - 9_800_000) / 200_000.0 - } else { - max(0.0, constant / 10.0 + (score - 9_500_000) / 300_000.0) - } -} - -/** Bonus added to single-play potential by the v7.0 rules (B50 scoring). */ +/** Bonus added to single-play potential by the B50 scoring. */ const val PLAY_RATING_CLEAR_BONUS = 0.2 /** - * Clear-type bonus of a single play. Any state other than TRACK_LOST earns the - * bonus; a missing clear type is treated as TRACK_LOST, i.e. no bonus. + * [ArcaeaPlayResultClearType] bonus of a single play. + * + * Any state other than [ArcaeaPlayResultClearType.TRACK_LOST] earns the bonus. A missing clear type is treated as no bonus. */ fun calculateClearBonus(clearType: ArcaeaPlayResultClearType?): Double = if (clearType == null || clearType == ArcaeaPlayResultClearType.TRACK_LOST) 0.0 else PLAY_RATING_CLEAR_BONUS /** - * Single-play potential under the v7.0 rules: the score-based value plus the - * clear-type bonus, with the whole sum floored at zero. + * Single-play potential under the legacy rules (no clear bonus). + */ +fun calculatePlayRating( + score: Int, + constant: Int, +): Double = calculatePlayRating(score, constant, 0.0) + +/** + * Single-play potential under the B50 rules (with clear bonus). */ fun calculatePlayRating( score: Int, constant: Int, clearType: ArcaeaPlayResultClearType?, +): Double = calculatePlayRating(score, constant, calculateClearBonus(clearType)) + +fun calculatePlayRating( + score: Int, + constant: Int, + clearBonus: Double, ): Double { if (constant <= 0) return 0.0 - val bonus = calculateClearBonus(clearType) return if (score >= 10_000_000) { - constant / 10.0 + 2 + bonus + constant / 10.0 + 2 + clearBonus } else if (score >= 9_800_000) { - constant / 10.0 + 1 + (score - 9_800_000) / 200_000.0 + bonus + constant / 10.0 + 1 + (score - 9_800_000) / 200_000.0 + clearBonus } else { - max(0.0, constant / 10.0 + (score - 9_500_000) / 300_000.0 + bonus) + max(0.0, constant / 10.0 + (score - 9_500_000) / 300_000.0 + clearBonus) } } @@ -69,19 +66,23 @@ fun calculatePlayRating( * constant, null result will be returned. * * The core algorithm is provided by Google Gemini. + * + * @param clearBonus The clear-type bonus the play is expected to earn. */ fun calculateInvertScoreRange( targetPlayRating: Double, constant: Int, tolerance: Double = 1e-3, + clearBonus: Double = 0.0, ): IntRange? { if (constant <= 0 || targetPlayRating < 0.0) return null + val effectiveTarget = targetPlayRating - clearBonus val base = constant / 10.0 // Actual constraint of play rating - val prMin = max(0.0, targetPlayRating - tolerance) - val prMax = targetPlayRating + tolerance + val prMin = max(0.0, effectiveTarget - tolerance) + val prMax = effectiveTarget + tolerance if (base + 2.0 < prMin) return null diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index 08f86a2f..68ba86e5 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -67,6 +67,29 @@ class CommonCalculatorsTest { assertNull(calculateInvertScoreRange(targetPlayRating = 12.0, constant = 0)) } + @Test + fun testInvertPlayRatingWithClearBonus() { + for ( + (target, constant) in + listOf( + 12.2 to 100, + 12.465 to 107, + 14.0 to 120, + ) + ) { + assertEquals( + calculateInvertScoreRange(target, constant, clearBonus = PLAY_RATING_CLEAR_BONUS), + calculateInvertScoreRange(target - PLAY_RATING_CLEAR_BONUS, constant), + ) + } + + // A cleared play reaches a target the bonus-free play cannot + assertNotNull(calculateInvertScoreRange(12.1, 100, clearBonus = PLAY_RATING_CLEAR_BONUS)) + + // Too high stays unsolvable regardless of the bonus + assertNull(calculateInvertScoreRange(14.0, 80, clearBonus = PLAY_RATING_CLEAR_BONUS)) + } + @Test fun testPlayRatingClearBonus() { val score = 10_000_000 @@ -96,5 +119,9 @@ class CommonCalculatorsTest { // An invalid constant earns no bonus either assertEquals(calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0, 0.0) + + // The bonus overload is the primitive the clear-type overload maps onto + assertEquals(calculatePlayRating(score, constant, PLAY_RATING_CLEAR_BONUS), 12.2, 0.0) + assertEquals(calculatePlayRating(score, constant, 0.0), 12.0, 0.0) } } From d1943c93be62288e2df625c7416f2570e2740aac Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 4 Oct 2026 02:48:30 +0800 Subject: [PATCH 31/35] refactor: PLAY_RATING_CLEAR_BONUS usages - Remove unstable references --- .../ui/components/PlayRatingCalculator.kt | 13 +++---- .../UtilitiesChartRecommendScreen.kt | 25 ++++++------- .../UtilitiesChartRecommendScreenViewModel.kt | 35 ++++++++++--------- .../UtilitiesChartRecommendFilterTest.kt | 34 ++++++++++-------- .../core/calculators/CommonCalculatorsTest.kt | 10 +++--- 5 files changed, 62 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index 7641f56f..259ce904 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -26,8 +26,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS +import xyz.sevive.arcaeaoffline.core.calculators.calculateClearBonus import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.preferences.SwitchPreferencesWidget @@ -41,12 +42,12 @@ fun PlayRatingCalculator( constant: Int = 0, isConstantReadonly: Boolean = true, initialFocusScoreTextField: Boolean = false, - initialCleared: Boolean = false, + initialClearType: ArcaeaPlayResultClearType? = null, countClearBonus: Boolean = true, ) { val scoreTextFieldFocusRequester = remember { FocusRequester() } - var cleared by rememberSaveable { mutableStateOf(initialCleared) } + var clearType by rememberSaveable { mutableStateOf(initialClearType) } val scoreTextFieldState = rememberArcaeaScoreTextFieldState( @@ -83,7 +84,7 @@ fun PlayRatingCalculator( scoreValue ?: return@derivedStateOf null constantValue ?: return@derivedStateOf null - val clearBonus = if (countClearBonus && cleared) PLAY_RATING_CLEAR_BONUS else 0.0 + val clearBonus = calculateClearBonus(if (countClearBonus) clearType else null) calculatePlayRating(scoreValue!!, constantValue!!, clearBonus) } } @@ -125,8 +126,8 @@ fun PlayRatingCalculator( if (countClearBonus) { SwitchPreferencesWidget( - value = cleared, - onValueChange = { cleared = it }, + value = clearType != null && clearType != ArcaeaPlayResultClearType.TRACK_LOST, + onValueChange = { cleared -> clearType = if (cleared) ArcaeaPlayResultClearType.NORMAL_CLEAR else null }, title = stringResource(R.string.play_rating_cleared), description = stringResource(R.string.play_rating_cleared_description), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index 177e0eec..94984fcf 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Calculate import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Link import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -52,9 +51,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS +import xyz.sevive.arcaeaoffline.core.calculators.calculateClearBonus import xyz.sevive.arcaeaoffline.core.calculators.calculateInvertScoreRange import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo @@ -136,13 +136,12 @@ data class PlayRatingCalculatorDialogState( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PlayRatingCalculatorDialog( onDismissRequest: () -> Unit, state: PlayRatingCalculatorDialogState, countClearBonus: Boolean, - cleared: Boolean, + clearType: ArcaeaPlayResultClearType? = null, ) { BasicAlertDialogSurface(onDismissRequest) { contentPadding -> Column( @@ -174,7 +173,7 @@ private fun PlayRatingCalculatorDialog( isConstantReadonly = true, initialFocusScoreTextField = true, countClearBonus = countClearBonus, - initialCleared = cleared, + initialClearType = clearType, ) } } @@ -184,14 +183,14 @@ data class ResultsListItemState( val item: DifficultyWithSongAndInfo, val scoreRange: IntRange, val targetPlayRating: Double, - val cleared: Boolean = false, + val clearType: ArcaeaPlayResultClearType? = null, ) { val targetScoreRange by lazy { calculateInvertScoreRange( targetPlayRating = targetPlayRating, constant = item.constant, tolerance = 1e-6, - clearBonus = if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0, + clearBonus = calculateClearBonus(clearType), ) } @@ -204,7 +203,7 @@ data class ResultsListItemState( } val actualPlayRating by lazy { - calculatePlayRating(score, item.constant, if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0) + calculatePlayRating(score, item.constant, clearType) } } @@ -284,7 +283,7 @@ fun UtilitiesChartRecommendScreen( onDismissRequest = { showCalculatorDialog = false }, state = calculatorDialogState, countClearBonus = uiState.scoringMode == ArcaeaScoringMode.B50, - cleared = uiState.cleared, + clearType = uiState.clearType, ) } @@ -370,8 +369,10 @@ fun UtilitiesChartRecommendScreen( if (uiState.scoringMode == ArcaeaScoringMode.B50) { SwitchPreferencesWidget( - value = uiState.cleared, - onValueChange = viewModel::setCleared, + value = uiState.clearType != null && uiState.clearType != ArcaeaPlayResultClearType.TRACK_LOST, + onValueChange = { cleared -> + viewModel.setClearType(if (cleared) ArcaeaPlayResultClearType.NORMAL_CLEAR else null) + }, title = stringResource(R.string.play_rating_cleared), description = stringResource(R.string.play_rating_cleared_description), ) @@ -403,7 +404,7 @@ fun UtilitiesChartRecommendScreen( } } else { items(uiState.charts, { it.difficultyWithSong.songId + it.difficultyWithSong.ratingClass.name }) { item -> - val state = ResultsListItemState(item, scoreRange, targetPlayRating, uiState.cleared) + val state = ResultsListItemState(item, scoreRange, targetPlayRating, uiState.clearType) ResultsListItem( state = state, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt index 30290b8e..50175af5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreenViewModel.kt @@ -16,8 +16,9 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import xyz.sevive.arcaeaoffline.core.calculators.PLAY_RATING_CLEAR_BONUS +import xyz.sevive.arcaeaoffline.core.calculators.calculateClearBonus import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository @@ -32,21 +33,21 @@ data class UtilitiesChartRecommendScreenUiState( val scoreRange: IntRange = 9_800_000..9_899_999, val targetPlayRating: Double = 0.0, val scoringMode: ArcaeaScoringMode = PropertyRepository.DEFAULT_SCORING_MODE, - val cleared: Boolean = false, + val clearType: ArcaeaPlayResultClearType? = null, val charts: List = emptyList(), ) /** - * Charts whose single-play rating within [scoreRange], with the clear bonus - * added when [cleared], can reach [targetPlayRating]. + * Charts whose single-play rating within [scoreRange], with the clear bonus of + * [clearType], can reach [targetPlayRating]. */ internal fun filterChartsByTarget( rows: List, scoreRange: IntRange, targetPlayRating: Double, - cleared: Boolean, + clearType: ArcaeaPlayResultClearType?, ): List { - val clearBonus = if (cleared) PLAY_RATING_CLEAR_BONUS else 0.0 + val clearBonus = calculateClearBonus(clearType) return rows.filter { row -> val min = calculatePlayRating(score = scoreRange.first, constant = row.constant, clearBonus = clearBonus) @@ -65,13 +66,13 @@ class UtilitiesChartRecommendScreenViewModel( private data class FilterParams( val scoreRange: IntRange, val targetPlayRating: Double, - val cleared: Boolean, + val clearType: ArcaeaPlayResultClearType?, val scoringMode: ArcaeaScoringMode, ) private val scoreRange = MutableStateFlow(9_800_000..9_899_999) private val targetPlayRating = MutableStateFlow(0.0) - private val cleared = MutableStateFlow(false) + private val clearType = MutableStateFlow(null) init { viewModelScope.launch { @@ -86,23 +87,23 @@ class UtilitiesChartRecommendScreenViewModel( @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = - combine(scoreRange, targetPlayRating, cleared, propertyRepo.scoringMode()) { sr, tpr, c, mode -> - FilterParams(sr, tpr, c, mode) + combine(scoreRange, targetPlayRating, clearType, propertyRepo.scoringMode()) { sr, tpr, ct, mode -> + FilterParams(sr, tpr, ct, mode) }.flatMapLatest { params -> // One joined query (ordered by constant) instead of fetching the // chart of each chart info row separately. difficultyWithSongRepo .findAllWithInfo() .map { rows -> - val effectiveCleared = if (params.scoringMode == ArcaeaScoringMode.B50) params.cleared else false + val effectiveClearType = if (params.scoringMode == ArcaeaScoringMode.B50) params.clearType else null UtilitiesChartRecommendScreenUiState( isLoading = false, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, scoringMode = params.scoringMode, - cleared = effectiveCleared, - charts = filterChartsByTarget(rows, params.scoreRange, params.targetPlayRating, effectiveCleared), + clearType = effectiveClearType, + charts = filterChartsByTarget(rows, params.scoreRange, params.targetPlayRating, effectiveClearType), ) }.onStart { emit( @@ -111,7 +112,7 @@ class UtilitiesChartRecommendScreenViewModel( scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, scoringMode = params.scoringMode, - cleared = params.cleared, + clearType = params.clearType, ), ) }.catch { e -> @@ -122,7 +123,7 @@ class UtilitiesChartRecommendScreenViewModel( scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, scoringMode = params.scoringMode, - cleared = params.cleared, + clearType = params.clearType, charts = emptyList(), ), ) @@ -144,7 +145,7 @@ class UtilitiesChartRecommendScreenViewModel( targetPlayRating.value = newValue } - fun setCleared(newValue: Boolean) { - cleared.value = newValue + fun setClearType(newValue: ArcaeaPlayResultClearType?) { + clearType.value = newValue } } diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt index 2a77285f..37c9e822 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt @@ -2,47 +2,51 @@ package xyz.sevive.arcaeaoffline.ui.screens.utilities import org.junit.Assert.assertEquals import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSongAndInfo class UtilitiesChartRecommendFilterTest { - private fun row(songId: String, constant: Int) = - DifficultyWithSongAndInfo( - DifficultyWithSong(songId, ArcaeaRatingClass.FUTURE, null, 5, false, songId, "artist"), - constant, - null, - ) + private fun row( + songId: String, + constant: Int, + ) = DifficultyWithSongAndInfo( + DifficultyWithSong(songId, ArcaeaRatingClass.FUTURE, null, 5, false, songId, "artist"), + constant, + null, + ) private val rows = listOf(row("c100", 100), row("c0", 0)) - // Over the EX range, the constant-100 chart earns 11.0..~11.5 without the - // bonus and 11.2..~11.7 with it + // Over the EX range, the constant-100 chart earns 11.0 to ~11.5 without the + // bonus and 11.2 to ~11.7 with it private val scoreRange = 9_800_000..9_899_999 @Test fun filterWithoutClearBonus() { assertEquals( listOf("c100"), - filterChartsByTarget(rows, scoreRange, 11.05, cleared = false).map { it.difficultyWithSong.songId }, + filterChartsByTarget(rows, scoreRange, 11.05, clearType = null).map { it.difficultyWithSong.songId }, ) // Above the bonus-free maximum - assertEquals(emptyList(), filterChartsByTarget(rows, scoreRange, 11.65, cleared = false)) + assertEquals(emptyList(), filterChartsByTarget(rows, scoreRange, 11.65, clearType = null)) } @Test fun filterWithClearBonus() { // The +0.2 bonus lifts the achievable ratings past the low target assertEquals( - emptyList(), - filterChartsByTarget(rows, scoreRange, 11.05, cleared = true), + emptyList(), + filterChartsByTarget(rows, scoreRange, 11.05, clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), ) // ...and reaches targets the bonus-free play cannot assertEquals( listOf("c100"), - filterChartsByTarget(rows, scoreRange, 11.65, cleared = true).map { it.difficultyWithSong.songId }, + filterChartsByTarget(rows, scoreRange, 11.65, clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR) + .map { it.difficultyWithSong.songId }, ) } @@ -53,8 +57,8 @@ class UtilitiesChartRecommendFilterTest { // A missing chart info reads as constant 0 and earns a rating of 0.0, // which no positive target matches assertEquals( - emptyList(), - filterChartsByTarget(rowsWithInvalidOnly, scoreRange, 11.0, cleared = false), + emptyList(), + filterChartsByTarget(rowsWithInvalidOnly, scoreRange, 11.0, clearType = null), ) } } diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index 68ba86e5..661ab3aa 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -78,16 +78,16 @@ class CommonCalculatorsTest { ) ) { assertEquals( - calculateInvertScoreRange(target, constant, clearBonus = PLAY_RATING_CLEAR_BONUS), - calculateInvertScoreRange(target - PLAY_RATING_CLEAR_BONUS, constant), + calculateInvertScoreRange(target, constant, clearBonus = 0.2), + calculateInvertScoreRange(target - 0.2, constant), ) } // A cleared play reaches a target the bonus-free play cannot - assertNotNull(calculateInvertScoreRange(12.1, 100, clearBonus = PLAY_RATING_CLEAR_BONUS)) + assertNotNull(calculateInvertScoreRange(12.1, 100, clearBonus = 0.2)) // Too high stays unsolvable regardless of the bonus - assertNull(calculateInvertScoreRange(14.0, 80, clearBonus = PLAY_RATING_CLEAR_BONUS)) + assertNull(calculateInvertScoreRange(14.0, 80, clearBonus = 0.2)) } @Test @@ -121,7 +121,7 @@ class CommonCalculatorsTest { assertEquals(calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0, 0.0) // The bonus overload is the primitive the clear-type overload maps onto - assertEquals(calculatePlayRating(score, constant, PLAY_RATING_CLEAR_BONUS), 12.2, 0.0) + assertEquals(calculatePlayRating(score, constant, 0.2), 12.2, 0.0) assertEquals(calculatePlayRating(score, constant, 0.0), 12.0, 0.0) } } From 119c067d56b7aa890005e8a82f00553f0d4125dc Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 4 Oct 2026 02:56:28 +0800 Subject: [PATCH 32/35] test: fix wrong assertEqual orders --- .../core/calculators/CommonCalculatorsTest.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index 661ab3aa..6e64fd7c 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -10,41 +10,41 @@ class CommonCalculatorsTest { @Test fun testScoreRange() { assertEquals( - calculateScoreRange(2221, 2221, 0), 10_000_000..10_002_221, + calculateScoreRange(2221, 2221, 0), ) } @Test fun testPlayRating() { assertEquals( - calculatePlayRating(10_002_221, 120), 14.0, + calculatePlayRating(10_002_221, 120), 0.0, ) assertEquals( - calculatePlayRating(9_949_633, 111), 12.848165, + calculatePlayRating(9_949_633, 111), 1e-4, ) assertEquals( - calculatePlayRating(9_849_089, 111), 12.345445, + calculatePlayRating(9_849_089, 111), 1e-4, ) assertEquals( - calculatePlayRating(5_500_000, 120), 0.0, + calculatePlayRating(5_500_000, 120), 0.0, ) // A missing chart info reads as constant 0 assertEquals( - calculatePlayRating(10_000_000, 0), 0.0, + calculatePlayRating(10_000_000, 0), 0.0, ) } @@ -96,8 +96,8 @@ class CommonCalculatorsTest { val constant = 100 // A missing clear type counts as TRACK_LOST: no bonus - assertEquals(calculatePlayRating(score, constant, null), 12.0, 0.0) - assertEquals(calculatePlayRating(score, constant, ArcaeaPlayResultClearType.TRACK_LOST), 12.0, 0.0) + assertEquals(12.0, calculatePlayRating(score, constant, null), 0.0) + assertEquals(12.0, calculatePlayRating(score, constant, ArcaeaPlayResultClearType.TRACK_LOST), 0.0) for ( clearType in @@ -109,19 +109,19 @@ class CommonCalculatorsTest { ArcaeaPlayResultClearType.HARD_CLEAR, ) ) { - assertEquals(calculatePlayRating(score, constant, clearType), 12.2, 0.0) + assertEquals(12.2, calculatePlayRating(score, constant, clearType), 0.0) } // The bonus participates in the floor at zero: a negative base plus // bonus stays above zero, while TRACK_LOST floors at zero - assertEquals(calculatePlayRating(8_000_000, 50, null), 0.0, 0.0) - assertEquals(calculatePlayRating(8_000_000, 50, ArcaeaPlayResultClearType.NORMAL_CLEAR), 0.2, 0.0) + assertEquals(0.0, calculatePlayRating(8_000_000, 50, null), 0.0) + assertEquals(0.2, calculatePlayRating(8_000_000, 50, ArcaeaPlayResultClearType.NORMAL_CLEAR), 0.0) // An invalid constant earns no bonus either - assertEquals(calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0, 0.0) + assertEquals(0.0, calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0) // The bonus overload is the primitive the clear-type overload maps onto - assertEquals(calculatePlayRating(score, constant, 0.2), 12.2, 0.0) - assertEquals(calculatePlayRating(score, constant, 0.0), 12.0, 0.0) + assertEquals(12.2, calculatePlayRating(score, constant, 0.2), 0.0) + assertEquals(12.0, calculatePlayRating(score, constant, 0.0), 0.0) } } From a78d1a641f76b560469eb8c3cd4a6d7a37d74f74 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 4 Oct 2026 21:50:42 +0800 Subject: [PATCH 33/35] test: add tests --- .../repositories/PotentialRepositoryTest.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt index 224611bb..5b505d29 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt @@ -17,7 +17,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult /** * B50 calculation over the play results of a real save. @@ -104,6 +107,67 @@ class PotentialRepositoryTest { ) } + @Test + fun b30MatchesTheSave() = + runBlocking { + assertEquals( + 373.8710750000001, + potentialRepository + .groups() + .first() + .b30.total, + TOLERANCE, + ) + } + + @Test + fun b50PrefersTheClearedPlayOverTheHigherScoredTrackLost() = + runBlocking { + db.playResultDao().upsertBatch( + trackLostPlay(), + clearedPlay(), + ) + + val groups = potentialRepository.groups().first() + + val b50Entry = groups.b50.items.single { it.playResult.songId == "grievouslady" } + assertEquals(9_970_000, b50Entry.playResult.score) + val b30Entry = groups.b30.items.single { it.playResult.songId == "grievouslady" } + assertEquals(10_000_000, b30Entry.playResult.score) + } + + private fun trackLostPlay() = + PlayResult( + id = 0, + songId = "grievouslady", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 10_000_000, + pure = 10_000, + far = 0, + lost = 0, + date = null, + maxRecall = 10_000, + modifier = null, + clearType = ArcaeaPlayResultClearType.TRACK_LOST, + comment = null, + ) + + private fun clearedPlay() = + PlayResult( + id = 0, + songId = "grievouslady", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_970_000, + pure = 9_940, + far = 30, + lost = 0, + date = null, + maxRecall = 9_940, + modifier = null, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + comment = null, + ) + @Test fun completenessTracksHowManyChartsHavePlayResults() = runBlocking { From 7bba16103fc240d5dafbeb39e140e79ef824eb3f Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 4 Oct 2026 22:23:29 +0800 Subject: [PATCH 34/35] ui improvements --- .../database/manage/DatabaseManageScreen.kt | 68 +++++++++---------- .../screens/overview/OverviewPotentialCard.kt | 19 +----- app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - 4 files changed, 37 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt index f884595c..27c0856a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt @@ -72,6 +72,40 @@ fun DatabaseManageScreen( }, ) { LazyColumn(modifier) { + item { + ListGroupHeader { + IconRow { + Icon(Icons.Default.Tune, contentDescription = null) + Text(stringResource(R.string.database_manage_scoring_mode_title)) + } + } + } + + item { + val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() + + SelectPreferencesWidget( + options = + listOf( + SelectPreferencesOption( + value = ArcaeaScoringMode.B30_R10, + label = stringResource(R.string.database_manage_scoring_mode_b30_r10), + description = ArcaeaScoringMode.B30_R10.key.toString(), + ), + SelectPreferencesOption( + value = ArcaeaScoringMode.B50, + label = stringResource(R.string.database_manage_scoring_mode_b50), + description = ArcaeaScoringMode.B50.key.toString(), + ), + ), + selected = scoringMode, + onSelect = viewModel::setScoringMode, + Modifier.fillMaxWidth(), + ) + } + + item { HorizontalDivider() } + item { ListGroupHeader { IconRow { @@ -154,40 +188,6 @@ fun DatabaseManageScreen( Modifier.fillMaxWidth(), ) } - - item { HorizontalDivider() } - - item { - ListGroupHeader { - IconRow { - Icon(Icons.Default.Tune, contentDescription = null) - Text(stringResource(R.string.database_manage_scoring_mode_title)) - } - } - } - - item { - val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() - - SelectPreferencesWidget( - options = - listOf( - SelectPreferencesOption( - value = ArcaeaScoringMode.B30_R10, - label = stringResource(R.string.database_manage_scoring_mode_b30_r10), - description = stringResource(R.string.database_manage_scoring_mode_b30_r10_description), - ), - SelectPreferencesOption( - value = ArcaeaScoringMode.B50, - label = stringResource(R.string.database_manage_scoring_mode_b50), - description = stringResource(R.string.database_manage_scoring_mode_b50_description), - ), - ), - selected = scoringMode, - onSelect = viewModel::setScoringMode, - Modifier.fillMaxWidth(), - ) - } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt index 290f0ba2..dba8c431 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt @@ -6,9 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -33,7 +30,6 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroup import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups import xyz.sevive.arcaeaoffline.helpers.calculatePotential -import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme @@ -62,19 +58,13 @@ private fun PotentialRow( PotentialRow( label = { Text(label, Modifier.alignByBaseline()) }, value = { - IconRow(Modifier.alignByBaseline()) { + Row(Modifier.alignByBaseline()) { Text( - "${ArcaeaFormatters.potentialToText(value, 4)} ÷ $divideBy", + "${ArcaeaFormatters.potentialToText(value, 4)} / $divideBy = ", Modifier.alignByBaseline(), style = MaterialTheme.typography.bodyMedium, ) - Icon( - Icons.Default.ChevronRight, - contentDescription = null, - Modifier.size(18.dp), - ) - Text( ArcaeaFormatters.potentialToText(dividedValue, 4), Modifier.alignByBaseline(), @@ -153,10 +143,7 @@ internal fun OverviewPotentialCard( PotentialRow( label = stringResource(R.string.arcaea_potential), value = ArcaeaFormatters.potentialToText(uiState.potential, mainScale), - modifier = - Modifier - .fillMaxWidth() - .padding(dimensionResource(R.dimen.page_padding)), + modifier = Modifier.fillMaxWidth().padding(dimensionResource(R.dimen.page_padding)), labelTextStyle = MaterialTheme.typography.headlineSmall, valueTextStyle = MaterialTheme.typography.displayLarge, ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8974f56d..cef71db7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -136,9 +136,7 @@ 计分方式 B30 + R10 - 旧版潜力值规则 B50 - 现行潜力值规则(v7.0+) 导入 曲包 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2876c52a..fd68490a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -157,9 +157,7 @@ CC BY-SA 3.0 Scoring mode B30 + R10 - Legacy potential rules B50 - Current potential rules (v7.0+) Import… Packs From e02c7397f3768cda7f0cf4b37a7b4109ed8ccf4d Mon Sep 17 00:00:00 2001 From: 283375 Date: Mon, 5 Oct 2026 02:35:31 +0800 Subject: [PATCH 35/35] final cleanup --- .../ui/components/PlayRatingCalculator.kt | 2 -- .../ui/helpers/ArcaeaFormatters.kt | 2 +- .../screens/overview/OverviewPotentialCard.kt | 18 +++++++++--------- .../ui/screens/overview/OverviewViewModel.kt | 8 ++++---- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index 259ce904..3623f5b4 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -115,8 +115,6 @@ fun PlayRatingCalculator( ) { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null) - // A tool output: shown to 6 decimals, independent of the official - // display precision Text( potential?.let { ArcaeaFormatters.potentialToText(it, scale = 6) } ?: "?", Modifier.weight(1f), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index a1749a32..11bfac32 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -32,7 +32,7 @@ object ArcaeaFormatters { ?.toBigDecimal() ?.roundToDigitPositionAfterDecimalPoint(scale.toLong(), RoundingMode.TOWARDS_ZERO) ?.scale(scale.toLong()) - ?.toPlainString() ?: "-." + "-".repeat(scale) + ?.toPlainString() ?: ("-." + "-".repeat(scale)) /** * Format the given playResult to a level text. diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt index dba8c431..667d1e72 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewPotentialCard.kt @@ -128,8 +128,8 @@ internal fun OverviewPotentialCard( modifier: Modifier = Modifier, ) { val warningResIds = - remember(uiState.entries, uiState.scoringMode) { - dataCompletenessWarningResIds(uiState.entries, uiState.scoringMode) + remember(uiState.groups, uiState.scoringMode) { + dataCompletenessWarningResIds(uiState.groups, uiState.scoringMode) } val warnings = warningResIds.map { stringResource(it) } @@ -156,13 +156,13 @@ internal fun OverviewPotentialCard( ) { when (uiState.scoringMode) { ArcaeaScoringMode.B50 -> { - PotentialRow("B10", uiState.entries?.b10?.total, 10, Modifier.fillMaxWidth()) - PotentialRow("B50", uiState.entries?.b50?.total, 50, Modifier.fillMaxWidth()) + PotentialRow("B10", uiState.groups?.b10?.total, 10, Modifier.fillMaxWidth()) + PotentialRow("B50", uiState.groups?.b50?.total, 50, Modifier.fillMaxWidth()) } ArcaeaScoringMode.B30_R10 -> { - PotentialRow("B30", uiState.entries?.b30?.total, 30, Modifier.fillMaxWidth()) - PotentialRow("R10", uiState.entries?.r10?.total, 10, Modifier.fillMaxWidth()) + PotentialRow("B30", uiState.groups?.b30?.total, 30, Modifier.fillMaxWidth()) + PotentialRow("R10", uiState.groups?.r10?.total, 10, Modifier.fillMaxWidth()) } } @@ -190,7 +190,7 @@ internal fun OverviewPotentialCard( @PreviewLightDark @Composable private fun OverviewPotentialCardPreview() { - val entries = + val groups = PotentialGroups( b10 = PotentialGroup(items = listOf(), total = 125.0, isComplete = true), b50 = PotentialGroup(items = listOf(), total = 630.0, isComplete = false), @@ -198,7 +198,7 @@ private fun OverviewPotentialCardPreview() { r10 = PotentialGroup(items = listOf(), total = 130.0, isComplete = true), ) val scoringMode = ArcaeaScoringMode.B30_R10 - val potential = scoringMode.calculatePotential(entries) + val potential = scoringMode.calculatePotential(groups) ArcaeaOfflineTheme { Surface { @@ -206,7 +206,7 @@ private fun OverviewPotentialCardPreview() { OverviewViewModel.UiState( isLoading = false, scoringMode = scoringMode, - entries = entries, + groups = groups, potential = potential, ), Modifier.fillMaxWidth(), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt index aab15681..8a104502 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt @@ -19,7 +19,7 @@ class OverviewViewModel( data class UiState( val isLoading: Boolean = true, val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, - val entries: PotentialGroups? = null, + val groups: PotentialGroups? = null, val potential: Double? = null, ) @@ -27,12 +27,12 @@ class OverviewViewModel( combine( propertyRepository.scoringMode(), potentialRepository.groups(), - ) { scoringMode, entries -> + ) { scoringMode, groups -> UiState( isLoading = false, scoringMode = scoringMode, - entries = entries, - potential = scoringMode.calculatePotential(entries), + groups = groups, + potential = scoringMode.calculatePotential(groups), ) }.stateIn( viewModelScope,