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' }} 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/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt index 2b88f80f..4a97d157 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,26 @@ 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 clearTypeMissing = + if ( + scoringMode == ArcaeaScoringMode.B50 && + ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, chartInfo) + ) { + listOf(ArcaeaPlayResultValidatorClearTypeMissingWarning) + } else { + emptyList() + } + + return validate(playResult, chartInfo) + clearTypeMissing + } } 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/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/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index e633413a..2164fbf3 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,16 @@ 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.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,47 +37,18 @@ 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" - - 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 = @@ -104,29 +56,22 @@ class R30UpdateJob( progressFlow.collectLatest { setProgress(it.toWorkData()) } } - val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt() - - var r30EntryCombinedList = - when (workOptions.runMode) { - RunMode.REBUILD -> emptyList() - else -> r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() - } - - val playResults = - when (workOptions.runMode) { - RunMode.REBUILD -> playResultRepo.findAll().firstOrNull() - else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).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 } - - 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() } - } + 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 = 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() } + }, + ) // Room3 possibly has a convenient extension function for this // see https://issuetracker.google.com/issues/416306996 @@ -150,91 +95,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/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..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 @@ -14,7 +14,10 @@ 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 @@ -23,9 +26,13 @@ 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.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 +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @Composable @@ -35,9 +42,13 @@ fun PlayRatingCalculator( constant: Int = 0, isConstantReadonly: Boolean = true, initialFocusScoreTextField: Boolean = false, + initialClearType: ArcaeaPlayResultClearType? = null, + countClearBonus: Boolean = true, ) { val scoreTextFieldFocusRequester = remember { FocusRequester() } + var clearType by rememberSaveable { mutableStateOf(initialClearType) } + val scoreTextFieldState = rememberArcaeaScoreTextFieldState( initialValue = score, @@ -73,7 +84,8 @@ fun PlayRatingCalculator( scoreValue ?: return@derivedStateOf null constantValue ?: return@derivedStateOf null - calculatePlayRating(scoreValue!!, constantValue!!) + val clearBonus = calculateClearBonus(if (countClearBonus) clearType else null) + calculatePlayRating(scoreValue!!, constantValue!!, clearBonus) } } @@ -104,11 +116,20 @@ fun PlayRatingCalculator( Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null) Text( - potential?.let { String.format(null, "%.4f", it) } ?: "?", + potential?.let { ArcaeaFormatters.potentialToText(it, scale = 6) } ?: "?", Modifier.weight(1f), style = MaterialTheme.typography.titleLarge, ) } + + if (countClearBonus) { + SwitchPreferencesWidget( + 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/components/preferences/SelectPreferencesWidget.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt new file mode 100644 index 00000000..fccfe46f --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/preferences/SelectPreferencesWidget.kt @@ -0,0 +1,52 @@ +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/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index c5493b3c..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 @@ -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/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 ba9d800c..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 @@ -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,25 @@ fun DatabaseNavEntry( } } + // The best list screen applies the active scoring mode's rules item { NavEntryNavigateButton( - titleResId = DatabaseSubScreen.B30.title, + titleResId = DatabaseSubScreen.BestList.title, icon = Icons.Default.Star, ) { - navContext.navigateToDetail(DatabaseSubScreen.B30.route) + navContext.navigateToDetail(DatabaseSubScreen.BestList.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/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/b30list/DatabaseB30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt deleted file mode 100644 index cd8be6ca..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListViewModel.kt +++ /dev/null @@ -1,91 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.b30list - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -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.database.entities.ChartInfo -import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong -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.ui.helpers.ArcaeaFormatters -import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder -import kotlin.time.Duration.Companion.seconds - -class DatabaseB30ListViewModel( - playResultBestRepo: PlayResultBestRepository, - difficultyWithSongRepo: DifficultyWithSongRepository, - chartInfoRepo: ChartInfoRepository, -) : ViewModel() { - data class ListItem( - val index: Int, - val playResultBest: PlayResultCalculated, - val difficultyWithSong: DifficultyWithSong?, - val chartInfo: ChartInfo?, - val playRatingText: String = ArcaeaFormatters.potentialToText(playResultBest.playRating), - ) - - data class UiState( - val isLoading: Boolean = false, - val limit: Int = 0, - val listItems: List = emptyList(), - ) - - private val limit = MutableStateFlow(INIT_LIMIT) - - @OptIn(ExperimentalCoroutinesApi::class) - val uiState = - limit - .transformLatest { limit -> - emit(UiState(isLoading = true, limit = limit)) - - playResultBestRepo - .orderDescWithLimit(limit) - .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) - - ListItem( - index = i, - playResultBest = dbItem, - difficultyWithSong = display?.difficultyWithSong, - chartInfo = display?.chartInfo, - ) - } - - emit(UiState(isLoading = false, limit = limit, listItems = listItems)) - } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - UiState(), - ) - - fun setLimit(limit: Int) { - this.limit.value = limit - } - - fun forceReload() { - val limitValue = this.limit.value - this.limit.value = 0 - this.limit.value = limitValue - } - - companion object { - const val INIT_LIMIT = 40 - } -} 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 91% 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 index 7bdca576..4b7a0d60 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/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( @@ -134,6 +134,7 @@ private fun DatabaseB30ListItemPreview() { } else { null }, + playRatingText = "13.000", chartInfo = if (withDifficulty) { ChartInfo("test", ArcaeaRatingClass.FUTURE, constant = 115, notes = null) @@ -145,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/b30list/DatabaseB30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListScreen.kt similarity index 94% 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 index ebc22316..6875657e 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/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 @@ -43,7 +43,7 @@ import kotlin.math.round @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) { +fun DatabaseBestListScreen(viewModel: DatabaseBestListViewModel = koinViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showOptions by rememberSaveable { mutableStateOf(false) } @@ -80,7 +80,7 @@ fun DatabaseB30ListScreen(viewModel: DatabaseB30ListViewModel = koinViewModel()) SubScreenContainer( topBar = { SubScreenTopAppBar( - title = { Text(stringResource(DatabaseSubScreen.B30.title)) }, + title = { Text(stringResource(DatabaseSubScreen.BestList.title)) }, actions = { IconButton(onClick = { viewModel.forceReload() }) { Icon(Icons.Default.Refresh, null) @@ -104,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 new file mode 100644 index 00000000..11ffaedd --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/bestlist/DatabaseBestListViewModel.kt @@ -0,0 +1,103 @@ +package xyz.sevive.arcaeaoffline.ui.screens.database.bestlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong +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 + +class DatabaseBestListViewModel( + 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, + ) + + data class UiState( + val isLoading: Boolean = false, + val limit: Int = 0, + val listItems: List = emptyList(), + ) + + private val limit = MutableStateFlow(INIT_LIMIT) + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState = + combine(limit, propertyRepo.scoringMode()) { limit, scoringMode -> + limit to scoringMode + }.transformLatest { (limit, scoringMode) -> + emit(UiState(isLoading = true, limit = limit)) + + 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) + + 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(), + ) + + fun setLimit(limit: Int) { + this.limit.value = limit + } + + fun forceReload() { + val limitValue = this.limit.value + this.limit.value = 0 + this.limit.value = limitValue + } + + companion object { + const val INIT_LIMIT = 40 + } +} 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..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 @@ -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 @@ -68,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 { 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..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 @@ -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,22 @@ 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 { + runCatching { propertyRepo.setScoringMode(mode) } + .onFailure { logger.e(it) { "Failed to set scoring mode" } } + } + } + internal val uiState = combine( taskQueue.isWorking, 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..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 @@ -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) @@ -67,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( @@ -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/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/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..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 @@ -3,75 +3,185 @@ 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.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.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, ) { - Column(modifier) { - Text( - title, - style = titleTextStyle, - fontWeight = FontWeight.Light, - ) - Text( - ArcaeaFormatters.potentialToText(potential), - 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 = { + Row(Modifier.alignByBaseline()) { + Text( + "${ArcaeaFormatters.potentialToText(value, 4)} / $divideBy = ", + Modifier.alignByBaseline(), + style = MaterialTheme.typography.bodyMedium, + ) + + 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, ) { + val warningResIds = + remember(uiState.groups, uiState.scoringMode) { + dataCompletenessWarningResIds(uiState.groups, uiState.scoringMode) + } + val warnings = warningResIds.map { stringResource(it) } + + val mainScale = + when (uiState.scoringMode) { + ArcaeaScoringMode.B30_R10 -> 2 + ArcaeaScoringMode.B50 -> 3 + } + 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, - 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)), - ) { - 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.groups?.b10?.total, 10, Modifier.fillMaxWidth()) + PotentialRow("B50", uiState.groups?.b50?.total, 50, Modifier.fillMaxWidth()) + } + + ArcaeaScoringMode.B30_R10 -> { + PotentialRow("B30", uiState.groups?.b30?.total, 30, Modifier.fillMaxWidth()) + PotentialRow("R10", uiState.groups?.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) + + Text( + warnings.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } } @@ -80,14 +190,24 @@ internal fun OverviewPotentialCard( @PreviewLightDark @Composable private fun OverviewPotentialCardPreview() { + val groups = + 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(groups) + ArcaeaOfflineTheme { Surface { OverviewPotentialCard( OverviewViewModel.UiState( isLoading = false, - b30 = 13.00, - r10 = 13.00, - potential = 13.00, + scoringMode = scoringMode, + 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 bf6fe0ff..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 @@ -5,34 +5,34 @@ 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.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( potentialRepository: PotentialRepository, + propertyRepository: PropertyRepository, ) : ViewModel() { data class UiState( val isLoading: Boolean = true, - val b30: Double? = null, - val r10: Double? = null, + val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, + val groups: PotentialGroups? = null, val potential: Double? = null, ) - private fun calculatePotential( - b30: Double, - r10: Double, - ) = b30 * 0.75 + r10 * 0.25 - val uiState = combine( - potentialRepository.b30(), - potentialRepository.r10(), - ) { b30, r10 -> + propertyRepository.scoringMode(), + potentialRepository.groups(), + ) { scoringMode, groups -> UiState( isLoading = false, - b30 = b30, - r10 = r10, - potential = calculatePotential(b30 = b30, r10 = r10), + scoringMode = scoringMode, + groups = groups, + potential = scoringMode.calculatePotential(groups), ) }.stateIn( viewModelScope, 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 fae267d3..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,8 +51,11 @@ 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.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 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 @@ -133,11 +136,12 @@ data class PlayRatingCalculatorDialogState( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PlayRatingCalculatorDialog( onDismissRequest: () -> Unit, state: PlayRatingCalculatorDialogState, + countClearBonus: Boolean, + clearType: ArcaeaPlayResultClearType? = null, ) { BasicAlertDialogSurface(onDismissRequest) { contentPadding -> Column( @@ -168,6 +172,8 @@ private fun PlayRatingCalculatorDialog( constant = state.constant, isConstantReadonly = true, initialFocusScoreTextField = true, + countClearBonus = countClearBonus, + initialClearType = clearType, ) } } @@ -177,12 +183,14 @@ data class ResultsListItemState( val item: DifficultyWithSongAndInfo, val scoreRange: IntRange, val targetPlayRating: Double, + val clearType: ArcaeaPlayResultClearType? = null, ) { val targetScoreRange by lazy { calculateInvertScoreRange( targetPlayRating = targetPlayRating, constant = item.constant, tolerance = 1e-6, + clearBonus = calculateClearBonus(clearType), ) } @@ -195,7 +203,7 @@ data class ResultsListItemState( } val actualPlayRating by lazy { - calculatePlayRating(score, item.constant) + calculatePlayRating(score, item.constant, clearType) } } @@ -223,7 +231,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)) @@ -274,6 +282,8 @@ fun UtilitiesChartRecommendScreen( PlayRatingCalculatorDialog( onDismissRequest = { showCalculatorDialog = false }, state = calculatorDialogState, + countClearBonus = uiState.scoringMode == ArcaeaScoringMode.B50, + clearType = uiState.clearType, ) } @@ -356,6 +366,17 @@ fun UtilitiesChartRecommendScreen( TextButton({ viewModel.setScoreRange(9_500_000..9_799_999) }) { Text("AA") } } } + + if (uiState.scoringMode == ArcaeaScoringMode.B50) { + SwitchPreferencesWidget( + 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), + ) + } } } @@ -383,7 +404,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.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 22295958..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,10 +16,15 @@ 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.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 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 @@ -27,54 +32,78 @@ 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 clearType: ArcaeaPlayResultClearType? = null, val charts: List = emptyList(), ) +/** + * 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, + clearType: ArcaeaPlayResultClearType?, +): List { + val clearBonus = calculateClearBonus(clearType) + + 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, + private val propertyRepo: PropertyRepository, ) : ViewModel() { private val logger = Logger.withTag("UtilitiesChartRecommendScreenVM") private data class FilterParams( val scoreRange: IntRange, val targetPlayRating: Double, + val clearType: ArcaeaPlayResultClearType?, + val scoringMode: ArcaeaScoringMode, ) private val scoreRange = MutableStateFlow(9_800_000..9_899_999) private val targetPlayRating = MutableStateFlow(0.0) + private val clearType = MutableStateFlow(null) init { viewModelScope.launch { - potentialRepo.b30().firstOrNull()?.let { - targetPlayRating.value = ((it + 0.05) * 100).roundToInt() / 100.0 + // Seed the target with the current level of the active scoring rules + val mode = propertyRepo.scoringMode().firstOrNull() ?: ArcaeaScoringMode.B50 + potentialRepo.groups().firstOrNull()?.let { + val potential = mode.calculatePotential(it) + targetPlayRating.value = ((potential + 0.05) * 100).roundToInt() / 100.0 } } } @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = - combine(scoreRange, targetPlayRating) { sr, tpr -> - FilterParams(sr, tpr) + 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 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 effectiveClearType = if (params.scoringMode == ArcaeaScoringMode.B50) params.clearType else null UtilitiesChartRecommendScreenUiState( isLoading = false, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, - charts = filtered, + scoringMode = params.scoringMode, + clearType = effectiveClearType, + charts = filterChartsByTarget(rows, params.scoreRange, params.targetPlayRating, effectiveClearType), ) }.onStart { emit( @@ -82,6 +111,8 @@ class UtilitiesChartRecommendScreenViewModel( isLoading = true, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, + scoringMode = params.scoringMode, + clearType = params.clearType, ), ) }.catch { e -> @@ -91,6 +122,8 @@ class UtilitiesChartRecommendScreenViewModel( isLoading = false, scoreRange = params.scoreRange, targetPlayRating = params.targetPlayRating, + scoringMode = params.scoringMode, + clearType = params.clearType, charts = emptyList(), ), ) @@ -111,4 +144,8 @@ class UtilitiesChartRecommendScreenViewModel( fun setTargetPlayRating(newValue: Double) { targetPlayRating.value = newValue } + + fun setClearType(newValue: ArcaeaPlayResultClearType?) { + clearType.value = newValue + } } 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 8b16fd1d..cef71db7 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 备注 潜力值 @@ -100,6 +102,13 @@ OCR 设置 + + 最佳列表不足 10 种谱面 + 最佳列表不足 30 种谱面 + 最佳列表不足 50 种谱面 + 最近队列不足 10 种谱面 + 由于分数历史记录完整度限制,R10 结果可能不准确 + 版本 %d @@ -125,6 +134,10 @@ 管理 + 计分方式 + B30 + R10 + B50 + 导入 曲包 曲包本地化对象 @@ -167,7 +180,7 @@ 游玩记录 %s 已更新 - B30 列表 + 最佳记录列表 R30 列表 @@ -319,6 +332,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 c63bc878..fd68490a 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 @@ -116,6 +118,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 @@ -146,6 +155,10 @@ CC BY-SA 3.0 Manage + Scoring mode + B30 + R10 + B50 + Import… Packs Pack Localized Objects @@ -189,7 +202,7 @@ CC BY-SA 3.0 Play result %s updated - Best30 List + Best Records List Recent30 List @@ -353,6 +366,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. 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/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 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..37c9e822 --- /dev/null +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendFilterTest.kt @@ -0,0 +1,64 @@ +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 val rows = listOf(row("c100", 100), row("c0", 0)) + + // 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, clearType = null).map { it.difficultyWithSong.songId }, + ) + + // Above the bonus-free maximum + 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, clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + ) + + // ...and reaches targets the bonus-free play cannot + assertEquals( + listOf("c100"), + filterChartsByTarget(rows, scoreRange, 11.65, clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR) + .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, clearType = null), + ) + } +} 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/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..931433d0 --- /dev/null +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdaterTest.kt @@ -0,0 +1,181 @@ +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.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()), + ) + + 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 rebuildQueue(): 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() }.rebuild(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 rebuildRebuildsTheQueueOfTheSave() = + runBlocking { + assertEquals(EXPECTED_QUEUE, rebuildQueue().toQueueEntries()) + } + + @Test + fun recentTopTenCountsEachChartOnce() = + runBlocking { + val queue = rebuildQueue() + + // 18 of the 30 entries are distinct charts, so whether they are counted once or per + // 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( + 125.24324, + potentialRepository + .groups() + .first() + .r10.total, + 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/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..5b505d29 --- /dev/null +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PotentialRepositoryTest.kt @@ -0,0 +1,208 @@ +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.Assert.assertFalse +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. + */ +@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()), + ) + + 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() + } + + 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 { + assertEquals( + 629.7836316666667, + potentialRepository + .groups() + .first() + .b50.total, + TOLERANCE, + ) + } + + @Test + fun b10MatchesTheSave() = + runBlocking { + assertEquals( + 127.64542999999999, + potentialRepository + .groups() + .first() + .b10.total, + TOLERANCE, + ) + } + + @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 { + 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 { + 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/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/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); 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/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/daos/PlayResultDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt index 4fb17cb5..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 @@ -7,7 +7,6 @@ import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import kotlin.time.Instant import kotlin.uuid.Uuid @Dao @@ -21,9 +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 * FROM play_results") fun findAll(): Flow> 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..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 @@ -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, + @ColumnInfo(name = "clear_type") val clearType: ArcaeaPlayResultClearType?, ) 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) + } 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/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/externals/importers/ArcaeaSt3PlayResultImporter.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSt3PlayResultImporter.kt index 1096f92a..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, @@ -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) 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/r30/R30QueueUpdater.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt new file mode 100644 index 00000000..eca600c2 --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/r30/R30QueueUpdater.kt @@ -0,0 +1,132 @@ +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, +) + +/** + * Rebuilds the recent-30 queue under the B30 + R10 rules from play results. + * + * [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; + * * 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 an empty queue. + */ + suspend fun rebuild( + plays: List, + onPlay: suspend () -> Unit = {}, + ): List { + var queue = emptyList() + val bestScores = mutableMapOf() + + 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/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/PlayResultRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt index eb169547..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 @@ -4,7 +4,6 @@ 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.PlayResult -import kotlin.time.Instant import kotlin.uuid.Uuid interface PlayResultRepository { @@ -15,8 +14,6 @@ interface PlayResultRepository { fun findByUuid(uuid: Uuid): Flow - fun findLaterThan(date: Instant): Flow> - fun findAll(): Flow> fun findAllBySongId(songId: String): Flow> @@ -44,8 +41,6 @@ class PlayResultRepositoryImpl( override fun findByUuid(uuid: Uuid): Flow = dao.findByUuid(uuid) - override fun findLaterThan(date: Instant): Flow> = dao.findLaterThan(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 a1328b10..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 @@ -2,44 +2,76 @@ 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.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 { - fun b30(): Flow +data class PotentialGroup( + val items: List, + val total: Double, + val isComplete: Boolean, +) + +data class PotentialGroups( + val b10: PotentialGroup, + val b50: PotentialGroup, + val b30: PotentialGroup, + val r10: PotentialGroup, +) - fun r10(): Flow +interface PotentialRepository { + fun groups(): Flow } +@OptIn(ExperimentalCoroutinesApi::class) class PotentialRepositoryImpl( private val playResultBestRepo: PlayResultBestRepository, private val r30EntryRepo: R30EntryRepository, ) : 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) + 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) } - @OptIn(ExperimentalCoroutinesApi::class) - override fun b30() = - this.b30Entries().mapLatest { entries -> - if (entries.isEmpty()) { - 0.0 - } else { - entries.sumOf { it.playRating } / entries.size - } - } + override fun groups(): Flow = + combine(b30Entries(), b50Entries(), r10Entries()) { b30, b50, r10 -> + val b10 = b50.take(10) - @OptIn(ExperimentalCoroutinesApi::class) - override fun r10() = - this.r10Entries().mapLatest { entries -> - if (entries.isEmpty()) { - 0.0 - } else { - entries.sumOf { it.playRating() ?: 0.0 } / entries.size - } + 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/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..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 @@ -3,11 +3,17 @@ 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 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) @@ -20,11 +26,13 @@ interface PropertyRepository { suspend fun setDatabaseVersion(ver: Int) + fun scoringMode(): Flow + + suspend fun setScoringMode(mode: ArcaeaScoringMode) + suspend fun r30LastUpdatedAt(): Instant? suspend fun setR30LastUpdatedAt(instant: Instant) - - suspend fun deleteR30LastUpdatedAt() } class PropertyRepositoryImpl( @@ -48,6 +56,19 @@ 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) } + ?: PropertyRepository.DEFAULT_SCORING_MODE + } + + 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()) @@ -56,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) - } } 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); diff --git a/docs/potential.md b/docs/potential.md new file mode 100644 index 00000000..371518d0 --- /dev/null +++ b/docs/potential.md @@ -0,0 +1,89 @@ +# 计分方式 + +> 最后校对于 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 依据本地成绩历史重建**:“第一次提交某个谱面的成绩”与“提交的分数大于远程服务器记录”合并实现为“本次分数高于该谱面此前的记录”, + 其中“此前”指本数据库中更早的成绩记录。队列总是由完整成绩历史一次性重建,不做增量更新。 +* **条件写入后的下限保护**:条件写入成功之后还要检查队列内不同谱面数,若仍不足 10 种,则本次成绩整体丢弃。 +* **填队列期间无下限检查**:队列未满 30 条时直接追加,不做 10 种谱面下限检查。追加成绩不会降低不同谱面数,此阶段本就凑不满下限。 +* **同潜力值成绩按固定顺序刷出**:官方为随机,本项目为便于测试等因素取确定顺序,对最终生成的 R10 **数值**没有影响。 diff --git a/docs/st3.md b/docs/st3.md new file mode 100644 index 00000000..6c80c8b4 --- /dev/null +++ b/docs/st3.md @@ -0,0 +1,67 @@ +# 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 +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() +} +``` 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 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..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 @@ -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 @@ -14,40 +15,74 @@ fun calculateScoreRange( return actualScore..actualScore + pure } +/** Bonus added to single-play potential by the B50 scoring. */ +const val PLAY_RATING_CLEAR_BONUS = 0.2 + +/** + * [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 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 + if (constant <= 0) return 0.0 return if (score >= 10_000_000) { - constant / 10.0 + 2 + constant / 10.0 + 2 + clearBonus } else if (score >= 9_800_000) { - constant / 10.0 + 1 + (score - 9_800_000) / 200_000.0 + 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) + max(0.0, constant / 10.0 + (score - 9_500_000) / 300_000.0 + clearBonus) } } /** * 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. + * + * @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 + 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/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/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..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 @@ -1,5 +1,6 @@ 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 @@ -9,34 +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( + 0.0, calculatePlayRating(5_500_000, 120), 0.0, + ) + + // A missing chart info reads as constant 0 + assertEquals( + 0.0, + calculatePlayRating(10_000_000, 0), 0.0, ) } @@ -56,5 +64,64 @@ 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 + fun testInvertPlayRatingWithClearBonus() { + for ( + (target, constant) in + listOf( + 12.2 to 100, + 12.465 to 107, + 14.0 to 120, + ) + ) { + assertEquals( + 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 = 0.2)) + + // Too high stays unsolvable regardless of the bonus + assertNull(calculateInvertScoreRange(14.0, 80, clearBonus = 0.2)) + } + + @Test + fun testPlayRatingClearBonus() { + val score = 10_000_000 + val constant = 100 + + // A missing clear type counts as TRACK_LOST: no bonus + assertEquals(12.0, calculatePlayRating(score, constant, null), 0.0) + assertEquals(12.0, calculatePlayRating(score, constant, ArcaeaPlayResultClearType.TRACK_LOST), 0.0) + + for ( + clearType in + listOf( + ArcaeaPlayResultClearType.NORMAL_CLEAR, + ArcaeaPlayResultClearType.FULL_RECALL, + ArcaeaPlayResultClearType.PURE_MEMORY, + ArcaeaPlayResultClearType.EASY_CLEAR, + ArcaeaPlayResultClearType.HARD_CLEAR, + ) + ) { + 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(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(0.0, calculatePlayRating(score, 0, ArcaeaPlayResultClearType.PURE_MEMORY), 0.0) + + // The bonus overload is the primitive the clear-type overload maps onto + assertEquals(12.2, calculatePlayRating(score, constant, 0.2), 0.0) + assertEquals(12.0, calculatePlayRating(score, constant, 0.0), 0.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, + ) + } +}