diff --git a/README.md b/README.md index a9b179c8..df0957a8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ If the local maven repo does not exist, Gradle falls back to Maven Central. This - [ARCHITECTURE.md](ARCHITECTURE.md): module boundaries and where new code goes - [potential.md](./docs/potential.md) (Chinese): potential calculation - [st3.md](./docs/st3.md) (Chinese): caveats of the in-game score database +- [play-result-mechanisms.md](./docs/play-result-mechanisms.md) (Chinese): mechanisms about pure, far, lost, clear type, etc. ## License diff --git a/app/AGENTS.md b/app/AGENTS.md index 225273ff..96624383 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -17,14 +17,14 @@ xyz.sevive.arcaeaoffline/ │ ├── activities/ entry activities' screens and ViewModels, including ocrfromshare/ │ ├── common/ dialogs shared by several screens │ ├── components/ composables used by 2+ features: dialogs/, ocr/, resources/ -│ ├── helpers/ formatters, display-chart cache +│ ├── helpers/ display helpers, display-chart cache, UI events │ ├── navigation/ MainScreen, MainNavigationGraph, per-tab route enums │ └── screens//[/] ├── database/ app-private Room 3 databases: AppDatabase, OcrQueueDatabase ├── datastore/ DataStore stores and their repositories ├── data/ Paths, BuildFlavor, maintenance tasks, notification channels, ImportLog, etc. ├── jobs/ WorkManager workers -├── helpers/ validators, OCR and resource helpers, diagnostics, activity/context extensions +├── helpers/ OCR and resource helpers, diagnostics, activity/context extensions ├── di/AppModule.kt the Koin module ├── logging/ rolling file log writer └── permissions/ saving bitmaps to the gallery diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt deleted file mode 100644 index 4a97d157..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ /dev/null @@ -1,57 +0,0 @@ -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 - -object ArcaeaPlayResultValidator { - val WARNINGS = - listOf( - ArcaeaPlayResultValidatorScoreIsZeroWarning, - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, - ArcaeaPlayResultValidatorScoreOutOfRangeWarning, - ArcaeaPlayResultValidatorPflOverflowWarning, - ArcaeaPlayResultValidatorMaxRecallOverflowWarning, - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, - ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning, - ArcaeaPlayResultValidatorClearPflMismatchWarning, - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning, - ) - - fun validate( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): List { - val warnings = mutableListOf() - - for (warning in WARNINGS) { - if (warning.conditionsMet(playResult, chartInfo)) { - warnings.add(warning) - } - } - - 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 deleted file mode 100644 index 6a71e15b..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ /dev/null @@ -1,263 +0,0 @@ -package xyz.sevive.arcaeaoffline.helpers - -import android.content.Context -import androidx.core.content.res.ResourcesCompat -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.calculators.calculateScoreRange -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier -import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo -import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult - -sealed interface ArcaeaPlayResultValidatorWarning { - val id: String - - val title: String - val titleId: Int - - fun getTitle(context: Context? = null): String { - if (context == null || this.titleId == ResourcesCompat.ID_NULL) { - return title - } - return context.getString(titleId) - } - - val message: String? - val messageId: Int - - fun getMessage(context: Context? = null): String? { - if (context == null || this.messageId == ResourcesCompat.ID_NULL) { - return message - } - return context.getString(messageId) - } - - /** - * If the given values will trigger this warning, return `true`. That is, there are - * problems with the given values. - */ - fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo? = null, - ): Boolean -} - -data object ArcaeaPlayResultValidatorScoreIsZeroWarning : ArcaeaPlayResultValidatorWarning { - override val id = "SCORE_IS_ZERO" - - override val title = "Score is zero" - override val titleId = R.string.play_result_validator_SCORE_IS_ZERO_title - - override val message = "Score is zero" - override val messageId = R.string.play_result_validator_SCORE_IS_ZERO_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = playResult.score == 0 -} - -data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultValidatorWarning { - override val id = "SCORE_OUT_OF_RANGE" - - override val title = "Score out of range" - override val titleId = R.string.play_result_validator_SCORE_OUT_OF_RANGE_title - - override val message = "Score out of range" - override val messageId = R.string.play_result_validator_SCORE_OUT_OF_RANGE_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (chartInfo?.notes == null || playResult.pure == null || playResult.far == null) { - return false - } - - val scoreRange = calculateScoreRange(chartInfo.notes!!, playResult.pure!!, playResult.far!!) - return !scoreRange.contains(playResult.score) - } -} - -data object ArcaeaPlayResultValidatorPflOverflowWarning : ArcaeaPlayResultValidatorWarning { - override val id = "PFL_OVERFLOW" - - override val title = "Notes overflow" - override val titleId = R.string.play_result_validator_PFL_OVERFLOW_title - - override val message = "pure + far + lost > chart note count" - override val messageId = R.string.play_result_validator_PFL_OVERFLOW_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (chartInfo?.notes == null) { - return false - } - - var sum = 0 - sum += playResult.pure ?: 0 - sum += playResult.far ?: 0 - sum += playResult.lost ?: 0 - - return sum > chartInfo.notes!! - } -} - -data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResultValidatorWarning { - override val id = "MAX_RECALL_OVERFLOW" - - override val title = "Max recall overflow" - override val titleId = R.string.play_result_validator_MAX_RECALL_OVERFLOW_title - - override val message = "max recall > chart note count" - override val messageId = R.string.play_result_validator_MAX_RECALL_OVERFLOW_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (chartInfo?.notes == null || playResult.maxRecall == null) { - return false - } - - return playResult.maxRecall!! > chartInfo.notes!! - } -} - -data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "FR_PM_MAX_RECALL_MISMATCH" - - override val title = "FR/PM max recall mismatch" - override val titleId = R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_title - - override val message = "A FR/PM play result should have same max recall with the chart notes." - override val messageId = R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.clearType !in - listOf( - ArcaeaPlayResultClearType.FULL_RECALL, - ArcaeaPlayResultClearType.PURE_MEMORY, - ) - ) { - return false - } - - return playResult.maxRecall != chartInfo?.notes - } -} - -data object ArcaeaPlayResultValidatorClearPflMismatchWarning : ArcaeaPlayResultValidatorWarning { - override val id = "CLEAR_PFL_MISMATCH" - - override val title = "Notes mismatch" - override val titleId = R.string.play_result_validator_CLEAR_PFL_MISMATCH_title - - override val message = "pure + far + lost != chart note count" - override val messageId = R.string.play_result_validator_CLEAR_PFL_MISMATCH_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (chartInfo?.notes == null || playResult.clearType == null || playResult.modifier == null) { - return false - } - - // HARD LOST does not conform to this rule - if (playResult.clearType == ArcaeaPlayResultClearType.TRACK_LOST && playResult.modifier == ArcaeaPlayResultModifier.HARD) { - return false - } - - if (playResult.pure == null || playResult.far == null || playResult.lost == null) { - return false - } - - return playResult.pure!! + playResult.far!! + playResult.lost!! != chartInfo.notes - } -} - -data object ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "FULL_RECALL_LOST_NOT_ZERO" - - override val title = "FULL RECALL?" - override val titleId = R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_title - - override val message = "A FR play result should have 0 lost." - override val messageId = R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.clearType != ArcaeaPlayResultClearType.FULL_RECALL) { - return false - } - - return playResult.lost != 0 - } -} - -data object ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "PURE_MEMORY_FAR_LOST_NOT_ZERO" - - override val title = "PURE MEMORY?" - override val titleId = R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_title - - override val message = "A PM play result should have 0 far and 0 lost." - override val messageId = R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.clearType != ArcaeaPlayResultClearType.PURE_MEMORY) { - return false - } - - return playResult.far != 0 || playResult.lost != 0 - } -} - -data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "MODIFIER_CLEAR_TYPE_MISMATCH" - - override val title = "Modifier <> Clear type mismatch" - override val titleId = R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_title - - override val message = "easy clear != easy || hard clear != hard" - override val messageId = R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = - (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/Compose.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt index dc2c1f60..f1c6bb55 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt @@ -5,16 +5,7 @@ import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha - -const val DISABLED_ALPHA = .38f -const val SECONDARY_ALPHA = .75f @Composable fun rememberFileChooserLauncher(onResult: (Uri?) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent(), onResult) - -fun Modifier.disabledItemAlpha() = this.alpha(DISABLED_ALPHA) - -fun Modifier.secondaryItemAlpha() = this.alpha(SECONDARY_ALPHA) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 3bf7f28a..4a1bc81c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt @@ -25,12 +25,12 @@ import kotlinx.coroutines.withContext import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.data.notification.Notifications import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository import xyz.sevive.arcaeaoffline.datastore.OcrQueueParallelism -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.toWorkData import kotlin.time.Duration.Companion.milliseconds @@ -161,7 +161,7 @@ class OcrQueueProcessingJob( taskRepo.findByStatus(OcrQueueTaskStatus.DONE).firstOrNull().orEmpty().filter { task -> task.playResult?.let { playResult -> val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return@let false - ArcaeaPlayResultValidator.validate(playResult, chartInfo).isNotEmpty() + PlayResultValidator.validate(playResult, chartInfo).isNotEmpty() } == true } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt index 0c46e59c..08aa0c25 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt @@ -8,10 +8,10 @@ import kotlinx.coroutines.flow.firstOrNull import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import kotlin.time.Clock @@ -107,7 +107,7 @@ class OcrQueueFixTaskExecutor( val chartInfo = chartInfoRepo.find(songId, ratingClass).firstOrNull() ?: continue val newPlayResult = task.playResult.copy(songId = songId) - if (ArcaeaPlayResultValidator.validate(newPlayResult, chartInfo).isEmpty()) { + if (PlayResultValidator.validate(newPlayResult, chartInfo).isEmpty()) { ocrQueueTaskRepo.update(task.copy(playResult = newPlayResult)) return } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index 3607905f..bc38262c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -22,8 +22,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorScoreIsZeroWarning -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -31,7 +31,7 @@ import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme internal fun OcrFromShareActions( hasPlayResult: Boolean, playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, - warnings: List, + warnings: List, onSavePlayResult: () -> Unit, onCachePlayResult: () -> Unit, ) { @@ -118,7 +118,7 @@ private fun PreviewActions() { saved = false, cached = false, ), - warnings = listOf(ArcaeaPlayResultValidatorScoreIsZeroWarning), + warnings = listOf(PlayResultFinding(PlayResultWarning.EMPTY_PLAY_RESULT)), onSavePlayResult = {}, onCachePlayResult = {}, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index 61dfd2e2..f7c65098 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -41,7 +41,7 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog @@ -57,7 +57,7 @@ internal fun OcrFromShareOcrResult( playResult: PlayResult?, onSetPlayResult: (PlayResult) -> Unit, display: DifficultyWithSongDisplay?, - warnings: List, + warnings: List, playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, exception: Exception?, onRetryOcr: () -> Unit, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 1e43a819..1fdf4294 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -78,7 +78,6 @@ import com.github.panpf.sketch.PainterState import com.github.panpf.sketch.rememberAsyncImageState import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.SECONDARY_ALPHA import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog @@ -87,6 +86,7 @@ import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.SECONDARY_ALPHA @Composable private fun OcrFromShareReturnToShareAppButton( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index d5e7f08d..4ee3396c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -42,10 +42,10 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRep import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.data.OcrPaths import xyz.sevive.arcaeaoffline.database.entities.OcrHistory import xyz.sevive.arcaeaoffline.database.repositories.OcrHistoryRepository -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder @@ -209,7 +209,7 @@ class OcrFromShareViewModel( flowOf(emptyList()) } else { display.map { d -> - ArcaeaPlayResultValidator.validate(playResult = playResult, chartInfo = d?.chartInfo) + PlayResultValidator.validate(playResult = playResult, chartInfo = d?.chartInfo) } } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/PermissionRequiredDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/PermissionRequiredDialog.kt index 560b3387..71542ce6 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/PermissionRequiredDialog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/PermissionRequiredDialog.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.components.BaseSettingsItem import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha @Composable fun rememberPermissionIcon(permission: String): ImageVector? = diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultCard.kt index 46144504..37452d96 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultCard.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -57,8 +56,10 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime +import xyz.sevive.arcaeaoffline.ui.components.playresult.PlayResultWarningDetailsDialog +import xyz.sevive.arcaeaoffline.ui.components.playresult.playResultWarningCountText import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.arcaeaColors @@ -124,7 +125,7 @@ fun ArcaeaPlayResultCard( playResult: PlayResult, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, - warnings: List = emptyList(), + warnings: List = emptyList(), shape: Shape = CardDefaults.shape, colors: CardColors? = CardDefaults.cardColors(), ) { @@ -174,7 +175,7 @@ fun ArcaeaPlayResultCard( var showWarningsDialog by rememberSaveable { mutableStateOf(false) } if (showWarningsDialog && warnings.isNotEmpty()) { - ArcaeaPlayResultValidatorWarningDetailsDialog( + PlayResultWarningDetailsDialog( onDismissRequest = { showWarningsDialog = false }, warnings = warnings, ) @@ -217,12 +218,7 @@ fun ArcaeaPlayResultCard( ) { Icon( Icons.Default.Warning, - contentDescription = - pluralStringResource( - R.plurals.play_result_validator_warning_count, - warnings.size, - warnings.size, - ), + contentDescription = playResultWarningCountText(warnings.size), ) } } @@ -302,7 +298,7 @@ fun ArcaeaPlayResultCard( playResult: PlayResult, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, - warnings: List = emptyList(), + warnings: List = emptyList(), difficultyWithSong: DifficultyWithSong?, chartInfo: ChartInfo? = null, colors: CardColors? = CardDefaults.cardColors(), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt index 9e142816..6bd3fdfa 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt @@ -47,10 +47,10 @@ import xyz.sevive.arcaeaoffline.helpers.context.findActivity import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDate import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedTime -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha import kotlin.time.Instant import kotlin.uuid.Uuid diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt index 8818b3eb..4db469de 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt @@ -5,21 +5,22 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton +import xyz.sevive.arcaeaoffline.ui.components.playresult.PlayResultWarningDetails /** Asks for confirmation before saving a play result that raised validator warnings. */ @Composable fun SaveWithWarningsDialog( - warnings: List, + warnings: List, onConfirm: () -> Unit, onDismissRequest: () -> Unit, ) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.play_result_save_with_warnings_title)) }, - text = { ArcaeaPlayResultValidatorWarningDetails(warnings) }, + text = { PlayResultWarningDetails(warnings) }, confirmButton = { DialogConfirmButton( onClick = onConfirm, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt index fde124f0..1d8e8a7f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt @@ -30,11 +30,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark -import xyz.sevive.arcaeaoffline.helpers.DISABLED_ALPHA -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.header import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.DISABLED_ALPHA +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha private val HorizontalPadding @Composable diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt index c3b1f32b..aa823a1c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel @@ -47,10 +46,8 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode 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.helpers.ArcaeaPlayResultValidatorWarning -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetailsDialog import xyz.sevive.arcaeaoffline.ui.components.LabeledCheckbox import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorClearTypeField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorCommentField @@ -65,14 +62,17 @@ import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.playresult.PlayResultWarningDetailsDialog +import xyz.sevive.arcaeaoffline.ui.components.playresult.playResultWarningCountText import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha @Composable internal fun BottomActionsBar( - warnings: List, + warnings: List, keepChart: Boolean, onKeepChartChange: (Boolean) -> Unit, onReset: () -> Unit, @@ -82,7 +82,7 @@ internal fun BottomActionsBar( ) { var showWarningsDialog by rememberSaveable { mutableStateOf(false) } if (showWarningsDialog && warnings.isNotEmpty()) { - ArcaeaPlayResultValidatorWarningDetailsDialog( + PlayResultWarningDetailsDialog( onDismissRequest = { showWarningsDialog = false }, warnings = warnings, ) @@ -96,7 +96,7 @@ internal fun BottomActionsBar( LocalContentColor provides MaterialTheme.extendedColorScheme.onWarningContainer, ) { TextItem( - title = pluralStringResource(R.plurals.play_result_validator_warning_count, warnings.size, warnings.size), + title = playResultWarningCountText(warnings.size), leadingIcon = Icons.Default.Warning, leadingIconTint = MaterialTheme.extendedColorScheme.onWarningContainer, onClick = { showWarningsDialog = true }, 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 5cba9fba..3013e76f 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 @@ -27,8 +27,8 @@ 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.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import kotlin.time.Clock @@ -46,7 +46,7 @@ class DatabaseAddPlayResultViewModel( val chartInfo: ChartInfo? = null, val playResult: PlayResult? = null, val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, - val warnings: List = emptyList(), + val warnings: List = emptyList(), val keepChartAfterSave: Boolean = false, ) @@ -80,7 +80,7 @@ class DatabaseAddPlayResultViewModel( chartInfo = chartInfo, playResult = playResult, scoringMode = scoringMode, - warnings = playResult?.let { ArcaeaPlayResultValidator.validate(it, chartInfo, scoringMode) } ?: emptyList(), + warnings = playResult?.let { PlayResultValidator.validate(it, chartInfo, scoringMode) } ?: emptyList(), keepChartAfterSave = keepChartAfterSave, ) }.stateIn( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt index 46b22587..78e55790 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog @@ -93,7 +93,7 @@ internal fun DatabaseDeduplicatorGroupListItem( val onSelect = { onPlayResultSelectedChange(it.uuid, !selected) } val warnings = remember(it, item.display) { - ArcaeaPlayResultValidator.validate(it, item.display?.chartInfo) + PlayResultValidator.validate(it, item.display?.chartInfo) } Row( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/PlayResultListUiItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/PlayResultListUiItem.kt index 43f13e53..a7050fb1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/PlayResultListUiItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/PlayResultListUiItem.kt @@ -10,7 +10,7 @@ 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.search.SearchEngine -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder @@ -39,7 +39,7 @@ data class PlayResultListUiItem( } append(baseText) } - val warnings = ArcaeaPlayResultValidator.validate(playResult, display?.chartInfo, scoringMode) + val warnings = PlayResultValidator.validate(playResult, display?.chartInfo, scoringMode) val scoreGrade = ArcaeaScoreGrade.of(playResult.score) val clearStatus = ArcaeaPlayResultClearStatus.of(playResult.clearType) val normalizedComment = playResult.comment?.let(SearchEngine::normalize) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/components/FilterSelectableRow.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/components/FilterSelectableRow.kt index bd832e8f..353c7bc9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/components/FilterSelectableRow.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/components/FilterSelectableRow.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.semantics.Role -import xyz.sevive.arcaeaoffline.helpers.DISABLED_ALPHA import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.DISABLED_ALPHA @Composable internal fun SelectableRow( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt index 5bb9944a..cd501b58 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt @@ -53,7 +53,6 @@ import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDate import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedTime -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.BaseSettingsItem import xyz.sevive.arcaeaoffline.ui.components.IconRow @@ -63,6 +62,7 @@ import xyz.sevive.arcaeaoffline.ui.components.SliderItem import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha import java.util.Locale @Composable diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueCategoryItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueCategoryItem.kt index a77ae287..739b0080 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueCategoryItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueCategoryItem.kt @@ -20,10 +20,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.PreviewLightDark -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha @Composable internal fun OcrQueueCategoryItem( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt index e56e72b8..9ceb0bad 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt @@ -28,13 +28,13 @@ import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository +import xyz.sevive.arcaeaoffline.core.validation.PlayResultValidator import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepositoryImpl import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesSerializer -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.jobs.OcrQueueProcessingJob import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent @@ -71,7 +71,7 @@ class OcrQueueScreenViewModel( val display: UiDisplayChartCacheHolder.Entry? = null, ) { val warnings = - dbItem.playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) } + dbItem.playResult?.let { PlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) } val hasWarnings = warnings?.isNotEmpty() == true val canEditChart = dbItem.status in listOf(OcrQueueTaskStatus.DONE, OcrQueueTaskStatus.ERROR) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt index 8a9a1d1f..78018dbe 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt @@ -25,12 +25,12 @@ 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.helpers.formatAsLocalizedDateTime -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay import xyz.sevive.arcaeaoffline.ui.components.rememberRatingClassAccent import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.playResultGradeGradientBrush import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha /** * A single play result at a glance: chart, grade, score, play rating and date. diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt index 08fb8645..c8c1e2fc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt @@ -45,7 +45,6 @@ import xyz.sevive.arcaeaoffline.core.api.DownloadableResource import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoUiState import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime import xyz.sevive.arcaeaoffline.helpers.rememberFileChooserLauncher -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.components.ArcaeaAppIcon import xyz.sevive.arcaeaoffline.ui.components.ImportLogBottomSheet import xyz.sevive.arcaeaoffline.ui.components.SelectItem @@ -53,6 +52,7 @@ import xyz.sevive.arcaeaoffline.ui.components.SelectOption import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.resources.RemoteResourceDownloadItem import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c683418f..4f3831e8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -42,7 +42,7 @@ // 无日期 // - // 无 Clear Type // + // 无通关状态 // // 无 Modifier // // 无备注 // @@ -88,7 +88,7 @@ 谱面 游玩记录 分数 - MAX RECALL + Max Recall 通关状态 通关 计入 +0.2 通关奖励 @@ -231,9 +231,9 @@ 分数 全部分数 选择分数段 - 通关类型 - 全部通关类型 - 选择通关类型 + 通关状态 + 全部通关状态 + 选择通关状态 搜索曲包…… 显示没有游玩记录的曲包 数据库中暂无曲包 @@ -415,41 +415,6 @@ 对分享图片 OCR - - - %d 条警告 - - - 分数为零 - 分数为 0。 - 缺少通关状态 - B50 规则下,缺少通关状态的成绩按 TRACK LOST 计,无法获得通关奖励。 - - 分数不在范围内 - 所给分数不在由 PURE、FAR 计算而得的分数范围内 - - Note 溢出 - PURE、FAR、LOST 的和比谱面总物件数要大 - - MAX RECALL 溢出 - 最大连击数比谱面总物件数要大 - - FR/PM MAX RECALL 有误 - 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 - - Note 有误 - 非 HARD LOST 结算的游玩结果,其 PURE、FAR、LOST 的和应与谱面总物件数相等 - - 真全想起来了? - FULL RECALL 时,LOST 应为 0 - - 真纯净回忆? - PURE MEMORY 时,FAR 和 LOST 应为 0 - - Modifier 与 Clear Type 不匹配 - 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY CLEAR 同理 - - 不稳定版本 应用随时可能崩溃,并且数据可能丢失甚至无法恢复。\n请注意备份! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f00828b..a2c17fdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,7 +103,7 @@ CC BY-SA 3.0 PURE FAR LOST - MAX RECALL + Max Recall Clear Type Cleared Counts the +0.2 clear bonus @@ -457,42 +457,6 @@ CC BY-SA 3.0 OCR from Sharing - - - %d warning - %d warnings - - - 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. - - Notes overflow - The sum of pure, far and lost is greater than the total notes of the chart. - - MAX RECALL overflow - The max recall is greater than the total notes of the chart. - - FR/PM MAX RECALL mismatch - The max recall of a FULL RECALL / PURE MEMORY play result should be equal to the total notes of the chart. - - Notes mismatch - The sum of pure, far, lost of a non HARD LOST play result should be equal to the total notes of the chart. - - FULL RECALL? - The lost of a FULL RECALL play result should be zero. - - PURE MEMORY? - The far and lost of a PURE MEMORY play result should be zero. - - Modifier & Clear Type mismatch - A HARD CLEAR clear type should have the HARD modifier, and the same for EASY CLEAR. - - UNSTABLE VERSION The app may crash at any time, and your data is under the risk of being lost or even irrecoverable.\nBe sure to backup your data! diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt deleted file mode 100644 index f2ee4512..00000000 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package xyz.sevive.arcaeaoffline.helpers - -import org.junit.Assert.assertTrue -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.ChartInfo -import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult - -class ArcaeaPlayResultValidatorTest { - @Test - fun maxRecallOverflow() { - val chartInfo = - ChartInfo( - songId = "test", - ratingClass = ArcaeaRatingClass.FUTURE, - constant = 95, - notes = 920, - ) - - val playResult = - PlayResult( - id = 0, - songId = "test", - ratingClass = ArcaeaRatingClass.FUTURE, - score = 9984560, - pure = 917, - far = 3, - lost = 0, - date = null, - maxRecall = 1001, - modifier = null, - clearType = ArcaeaPlayResultClearType.FULL_RECALL, - comment = null, - ) - - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), - ) - } - - @Test - fun pureMemoryFarLostNotZero() { - val playResultHasFar = - PlayResult( - id = 0, - songId = "test", - ratingClass = ArcaeaRatingClass.BEYOND, - score = 10002221, - pure = 2221, - far = 1, - lost = 0, - date = null, - maxRecall = 2221, - modifier = null, - clearType = ArcaeaPlayResultClearType.PURE_MEMORY, - comment = null, - ) - val playResultHasLost = - PlayResult( - id = 0, - songId = "test", - ratingClass = ArcaeaRatingClass.BEYOND, - score = 10002221, - pure = 2221, - far = 0, - lost = 1, - date = null, - maxRecall = 2221, - modifier = null, - clearType = ArcaeaPlayResultClearType.PURE_MEMORY, - comment = null, - ) - val playResultHasNothing = - PlayResult( - id = 0, - songId = "test", - ratingClass = ArcaeaRatingClass.BEYOND, - score = 10002221, - pure = 2221, - far = null, - lost = null, - date = null, - maxRecall = 2221, - modifier = null, - clearType = ArcaeaPlayResultClearType.PURE_MEMORY, - comment = null, - ) - - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( - playResultHasFar, - null, - ), - ) - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( - playResultHasLost, - null, - ), - ) - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( - playResultHasNothing, - null, - ), - ) - } -} diff --git a/docs/play-result-mechanisms.md b/docs/play-result-mechanisms.md new file mode 100644 index 00000000..a1320c20 --- /dev/null +++ b/docs/play-result-mechanisms.md @@ -0,0 +1,105 @@ +# 游玩结算机制 + +> 最后校对于 2026-11-06 + +本文档记录游玩结算各项数据机制,如分数、判定计数(PURE、FAR、LOST)、最大连击(MAX RECALL)、通关类型(Clear Type)等。 + +## 来源 + +* [机制](https://wiki.arcaea.cn/index.php?title=%E6%9C%BA%E5%88%B6&oldid=93758) +* [界面](https://wiki.arcaea.cn/index.php?title=%E7%95%8C%E9%9D%A2&oldid=93764) + +已对原文改写与归纳,本文遵循原文 [CC BY-NC-SA 3.0](https://creativecommons.org/licenses/by-nc-sa/3.0/) 授权。 + +## 标记说明 + +* `[wiki]` Wiki 原文陈述 +* `[infer]` 基于 Wiki 原文与实际游玩经验的推断行为 +* `[unk]` 未知且 Wiki 未记载 + +## 范围 + +本文档重点关注常规游玩结算,即成绩会被游戏保存,并作为潜力值计算输入的游玩结果。 + +以下情形与常规规则存在出入: + +* `[wiki]` 异象与特殊挑战(Tempestissimo 异象、Sigil of the Abyss 等),即使达成 PM/FR 条件也只显示 TRACK COMPLETE,且成绩不被游戏保存。 +* `[wiki]` 2023 愚人节活动的 bug,曾出现判定全为 PURE 却未判为 PM 的情形。 + +`[infer]` 异象、特殊挑战、愚人节等成绩均不会作为潜力值计算输入,故以下不考虑这些特殊情况。 + +## 判定与计分 + +* `[wiki]` 判定分为 PURE、FAR、LOST 三种,PURE 按精确度再分大 PURE 与小 PURE。 +* `[wiki]` 单个音符的基本分为 `10000000 ÷ 物量`。PURE 得全额,FAR 得一半,LOST 不得分。 +* `[wiki]` 每个大 PURE 额外加 1 分,其余判定不产生附加分。 +* `[wiki]` 显示分数向下取整。基本分与附加分全部拿满时为理论值,此时成绩为 `10000000 + 物量`。 +* `[infer]` PM 成绩的分数范围是 [10000000, 10000000 + 物量]。 + * 前提:PM 的判定标准是无 FAR/LOST 判定而非分数超过 10000000。 + +| 判定 | 区间 | +|---------|-------------------------------------------| +| 大 PURE | ±25ms | +| 小 PURE | ±25~50ms | +| FAR | ±50~100ms | +| LOST | ±100ms内未击中 **或** 提前 100~120ms 击中 | + +## 物量 + +* `[wiki*]` 物量不按物件数计数,而按判定点计数。 +* `[wiki*]` 普通 Tap、Arctap 计 1 物量,长条按拍数产生多个判定点,个别特殊物件不计入物量。 +* `[infer]` 打完全程时 PURE + FAR + LOST 即等于物量。 +* `[infer]` 中断游玩后,剩余未判定音符不计入判定计数。 + +## 连击 + +* `[wiki]` MAX RECALL 为本次游玩的最高连击数。 + * [界面](https://wiki.arcaea.cn/index.php?title=%E7%95%8C%E9%9D%A2&oldid=93764) +* `[wiki]` PURE 与 FAR 使连击数 +1,LOST 使连击数清空。 +* `[infer]` `MAX RECALL ≤ 物量 − LOST`。 +* `[infer]` FR 与 PM 成绩的 MAX RECALL 等于物量。 + +## 回忆收集条 + +`[wiki]` 每个谱面有固定的回忆系数,与物量和难度相关,谱面等级 ≥ 11 时额外 ×0.8。常规回忆条如下。 + +| 回忆条 | 初始回忆率 | PURE | FAR | LOST | 通关条件 | 中断 | +|--------|------------|---------|-----------|----------|-------------|-------------------------| +| Normal | 0 | +1×系数 | +0.5×系数 | −2 | 结算时 ≥ 70 | 无 | +| Easy | 0 | +1×系数 | +0.5×系数 | −1.2 | 结算时 ≥ 70 | 无 | +| Hard | 100 | +1×系数 | +0.5×系数 | 分段扣除 | 结算时 > 0 | 回忆率低于 0 时立即中断 | + +* `[wiki]` Hard 的 LOST 按当前回忆率分段扣除(默认 −9×扣除倍率,回忆率 ≤ 30 时 −5×扣除倍率),倍率受搭档技能与陷落章法则影响。 +* `[wiki]` 结算时未满足通关条件的,一律得到 TRACK LOST 评价。 +* `[wiki]` 中断机制仅见于 Hard 及其变体条。Normal 与 Easy 回忆条没有中途中断机制,是否 TL 在结算时判定。 +* `[wiki]` 搭档技能条与失落章 Beyond Challenge 存在增减规则不同的变体,与判定计数相关的要点: + * 部分变体条的 FAR 也扣回忆率(Beyond 挑战等),这些变体条在成绩记录中仍归入 Normal/Easy/Hard 之一。 + * 部分搭档的机制使得全连也可 TRACK LOST。 + * 部分搭档的机制使得全 LOST 也可 TRACK COMPLETE。 +* `[infer]` LOST = 0 不蕴含通关,全连 TRACK LOST 的成绩是合法存在。 +* `[infer]` LOST 等于物量(全部 LOST)不蕴含 TRACK LOST:全部 LOST 通关的成绩是合法存在。 + +## 通关类型 + +`[wiki]` + +* PURE MEMORY:所有判定均为 PURE,不区分大小 PURE。 + * 原文特注:PM 的判定标准是无 FAR/LOST 判定,而非分数超过 10000000。 +* FULL RECALL:有 FAR 判定但无 LOST 判定,并且满足通关条件。 +* Clear:达成通关条件但未达 PM/FR。 + * 通关标志按回忆条类型给出 Normal Clear、Easy Clear、Hard Clear。 + * 以 Easy Clear 通关的曲目不计入通关曲目数量。 +* TRACK LOST:结算时未满足通关条件。 + +`[wiki]` 通关类型按优先级 PM > FR > Clear > TL,高类型覆写低类型。例如,PM 能覆写 FR、Clear 与 TL,FR 能覆写 Clear 与 TL,TL 不能覆写任何类型。 + +`[infer]` 由定义与覆写规则: + +* PM ⇒ FAR = 0 且 LOST = 0。 +* FR ⇒ LOST = 0 且 FAR ≥ 1。 +* FAR = 0 且 LOST = 0(全部判定为 PURE)⇒ PM。 + * 全 PURE 成绩覆写包括 TL 在内的其他评价。 +* Clear 徽标(Normal/Easy/Hard)⇒ LOST ≥ 1。 +* FR/PM ⇒ MAX RECALL 等于物量(见[连击](#连击)节)。 +* 使用困难回忆条且被中断的 TL 成绩可能未打完全程,PURE + FAR + LOST 可小于物量。 +* Normal/Easy 回忆条的 TL 打完全程,PURE + FAR + LOST 等于物量。 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 5cc732f5..69035364 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -15,10 +15,12 @@ xyz.sevive.arcaeaoffline/ │ ├── calculators/ pure math (potential/B30); keep dependency-free and unit-tested │ ├── constants/ Arcaea domain enums/constants (rating classes, clear types, ...) │ ├── database/ the main Room database (largest package; below) -│ └── search/ in-memory song/pack catalog and text matching (SearchEngine, ArcaeaCatalog) +│ ├── search/ in-memory song/pack catalog and text matching (SearchEngine, ArcaeaCatalog) +│ └── validation/ PlayResult warnings and the validator └── ui/ ├── theme/ expect Theme + CustomColors, ColorSchemes, Typography, Spacing ├── components/ cross-platform composables (e.g. ArcaeaScoreTextField) + ├── helpers/ cross-platform formatters (ArcaeaFormatters) └── utils/ UI helpers (e.g. AutoRepeatController) ``` diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c4c70694..34af2ccc 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -78,6 +78,8 @@ kotlin { compilerOptions { // Room3 compiler opt out freeCompilerArgs.add("-Xexpect-actual-classes") + + optIn.add("kotlin.uuid.ExperimentalUuidApi") } jvm() diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/10.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/10.json index 3ad9981d..b14f73b5 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/10.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/10.json @@ -708,7 +708,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/11.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/11.json index 6ecbc6e1..6238b389 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/11.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/11.json @@ -714,7 +714,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/12.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/12.json index f6457ebd..dce7d460 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/12.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/12.json @@ -724,7 +724,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/13.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/13.json index 0ee45451..a2f787ce 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/13.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/13.json @@ -683,7 +683,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment \n FROM difficulties d\n JOIN songs s ON d.song_id = s.id \n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class\n WHERE s.deleted_in_game IS NULL OR s.deleted_in_game == 0" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/7.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/7.json index d6f9b4c5..144fa766 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/7.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/7.json @@ -682,7 +682,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/8.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/8.json index 43f25fac..c4fce729 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/8.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/8.json @@ -682,7 +682,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" }, { "viewName": "play_results_best", diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/9.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/9.json index 31d2a152..7530e1b1 100644 --- a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/9.json +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/9.json @@ -720,7 +720,7 @@ }, { "viewName": "play_results_calculated", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" }, { "viewName": "play_results_best", diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index e179332d..65e2be1c 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -9,4 +9,70 @@ 分数 定数 单曲潜力值 + + + + %1$d 条警告 + + + 成绩未填写 + 请填写分数与判定数 + + 真纯净回忆? + 请核对 FAR/LOST 与通关状态 + Pure Memory 时 FAR 和 LOST 都应为 0 + + 应为 Pure Memory + 请核对 FAR/LOST 与通关状态 + FAR 和 LOST 均为 0 时,通关状态应为 Pure Memory + + 分数不在范围内 + 请核对 PURE/FAR 判定数与谱面物量 + 所给分数 %1$s 不在 PURE %4$d、FAR %5$d 对应的 %2$s~%3$s 范围内 + + 分数超出理论范围 + 请核对分数与谱面物量 + 分数 %1$d 为负数 + 分数 %1$s 超过该谱面的理论值 %2$s + + Pure Memory 分数过低 + Pure Memory 成绩应不低于 10'000'000 + 分数 %1$s 低于 Pure Memory 的最低分 10'000'000 + + 判定数超过物量 + 请核对 PURE、FAR、LOST 与谱面物量 + 判定数之和超过谱面物量 + PURE %1$d + FAR %2$d + LOST %3$d 超过谱面总物量 %4$d + + Max Recall 溢出 + 请核对 Max Recall 与判定数 + Max Recall %1$d 超过允许的上限 %2$d + + 计数为负 + 请填入 0 或正数 + PURE、FAR、LOST 和 Max Recall 不能为负数 + + FR/PM 与 Max Recall 不符 + 请核对通关状态与 Max Recall + Max Recall %1$d 与谱面物量 %2$d 不符 + + 真全想起来了? + 请核对 LOST 与通关状态 + Full Recall 时 LOST 应为 0 + + 通关状态与 LOST 不符 + 请检查 LOST 数,或修改通关状态 + 该通关状态应至少有 1 个 LOST + + 判定数与物量不符 + 请核对判定数与谱面物量 + PURE %1$d + FAR %2$d + LOST %3$d 与谱面物量 %4$d 不符 + + Modifier 与通关状态不匹配 + 请核对 Modifier 与通关状态 + Easy Clear、Hard Clear、Normal Clear 需要对应的 Modifier + + 缺少通关状态 + 请补全通关状态 + 缺少通关状态时,B50 规则按 Track Lost 计算,无法获得通关奖励 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index c7c327a3..1e8de5bf 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -9,4 +9,71 @@ Score Constant Play Rating + + + + %1$d warning + %1$d warnings + + + Empty play result + Fill in the score and judgment counts. + + Pure Memory? + Check FAR/LOST and the clear type. + FAR and LOST should both be 0 for Pure Memory. + + Should be Pure Memory + Check FAR/LOST and the clear type. + FAR and LOST are both 0, so the clear type should be Pure Memory. + + Score out of range + Check the PURE/FAR counts and the chart note count. + The score %1$s is outside %2$s–%3$s, the range for %4$d PURE and %5$d FAR. + + Impossible score + Check the score and the chart note count. + The score %1$d is negative. + The score %1$s is above the chart's maximum %2$s. + + Pure Memory score too low + A Pure Memory play scores at least 10'000'000. + The score %1$s is below the Pure Memory minimum of 10'000'000. + + Judgment counts exceed the note count + Check PURE, FAR and LOST against the chart note count. + The judgment counts exceed the chart's note count. + PURE %1$d + FAR %2$d + LOST %3$d exceeds the chart's %4$d notes. + + Max Recall overflow + Check Max Recall and the judgment counts. + Max Recall %1$d exceeds the allowed maximum %2$d. + + Negative counts + Enter 0 or a positive count. + PURE, FAR, LOST and Max Recall cannot be negative. + + FR/PM and Max Recall mismatch + Check the clear type and Max Recall. + Max Recall %1$d does not match the chart's %2$d notes. + + Full Recall? + Check LOST and the clear type. + LOST should be 0 for Full Recall. + + Clear type and LOST mismatch + Check the LOST count, or change the clear type. + This clear type requires at least one LOST. + + Judgment counts and note count mismatch + Check the judgment counts against the chart note count. + PURE %1$d + FAR %2$d + LOST %3$d does not match the chart's %4$d notes. + + Modifier and clear type mismatch + Check the modifier and the clear type. + Easy Clear, Hard Clear and Normal Clear each require the matching modifier. + + Clear type missing + Set the clear type. + Without a clear type, the B50 rules count the play as Track Lost, so it earns no clear bonus. 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 562d8946..21cbbbab 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 @@ -5,14 +5,23 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max +/** + * Calculates the theoretical score range of a play with [pure] and [far] judgments on a chart with [notes]. + * + * Returns an empty range when [notes] is not positive. Values outside the [Int] range are clamped to + * 0 and [Int.MAX_VALUE]. + */ fun calculateScoreRange( notes: Int, pure: Int, far: Int, ): IntRange { - val singleNoteScore = 10000000.0 / notes - val actualScore = floor(singleNoteScore * pure + singleNoteScore * 0.5 * far).toInt() - return actualScore..actualScore + pure + if (notes <= 0) return IntRange.EMPTY + + val base = 10_000_000L * (2L * pure + far) / (2L * notes) + val start = base.coerceIn(0L, Int.MAX_VALUE.toLong()) + val end = (base + pure).coerceIn(start, Int.MAX_VALUE.toLong()) + return start.toInt()..end.toInt() } /** Bonus added to single-play potential by the B50 scoring. */ diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt index 98b941ff..a7afa7f9 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt @@ -13,7 +13,15 @@ enum class ArcaeaPlayResultClearType( HARD_CLEAR(5), ; - fun toDisplayString(): String = this.name.replace('_', ' ') + fun toDisplayString(): String = + when (this) { + TRACK_LOST -> "Track Lost" + NORMAL_CLEAR -> "Normal Clear" + FULL_RECALL -> "Full Recall" + PURE_MEMORY -> "Pure Memory" + EASY_CLEAR -> "Easy Clear" + HARD_CLEAR -> "Hard Clear" + } companion object { fun fromInt(clearTypeInt: Int): ArcaeaPlayResultClearType { diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt index 02143604..04d6f0e8 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResultCalculated.kt @@ -1,7 +1,6 @@ package xyz.sevive.arcaeaoffline.core.database.entities import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating -import kotlin.math.floor data class PlayResultCalculated( val playResult: PlayResult, @@ -27,9 +26,10 @@ data class PlayResultCalculated( 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) { + if (chartInfo.notes == null || chartInfo.notes <= 0 || pure == null || far == null || lost == null) { null } else { - (score - floor((pure * 10000000.0 / chartInfo.notes) + (far * 0.5 * 100000000.0 / chartInfo.notes))).toInt() + val base = 10_000_000L * (2L * pure + far) / (2L * chartInfo.notes) + (score - base).toInt() } } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt index a68328df..9889d407 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt @@ -16,7 +16,7 @@ private const val CREATE_TABLE_PLAY_RESULTS_7 = private const val CREATE_UNIQUE_INDEX_PLAY_RESULTS_UUID_7 = "CREATE UNIQUE INDEX IF NOT EXISTS `index_play_results_uuid` ON play_results (`uuid`)" private const val CREATE_VIEW_PLAY_RESULTS_CALCULATED_7 = - "CREATE VIEW `play_results_calculated` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" + "CREATE VIEW `play_results_calculated` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class" private const val CREATE_VIEW_PLAY_RESULTS_BEST_7 = "CREATE VIEW `play_results_best` AS SELECT\n prc.id, prc.uuid, prc.song_id, prc.rating_class, prc.score,\n prc.pure, prc.shiny_pure, prc.far, prc.lost,\n prc.date, prc.max_recall, prc.modifier, prc.clear_type,\n MAX(prc.potential) AS potential,\n prc.comment\n FROM play_results_calculated prc\n GROUP BY prc.song_id, prc.rating_class\n ORDER BY prc.potential DESC" diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_7_8.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_7_8.kt index e6125da1..c418c09f 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_7_8.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_7_8.kt @@ -81,7 +81,7 @@ object Migration_7_8 : Migration(7, 8) { connection.execSQL("CREATE TABLE `play_results` (`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)") connection.execSQL("CREATE UNIQUE INDEX `index_play_results_uuid` ON `play_results` (`uuid`)") connection.execSQL("CREATE VIEW `charts` AS SELECT\n s.idx AS song_idx, d.song_id, d.rating_class, d.rating, d.rating_plus,\n COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist,\n s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base,\n s.audio_preview, s.audio_preview_end, s.side,\n COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date,\n COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse,\n s.bg_day, s.bg_night, s.source, s.source_copyright,\n d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override,\n d.jacket_night, ci.constant, ci.notes\n FROM difficulties d\n INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n INNER JOIN songs s ON d.song_id = s.id") - connection.execSQL("CREATE VIEW `play_results_calculated` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 100000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 100000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class") + connection.execSQL("CREATE VIEW `play_results_calculated` AS SELECT\n pr.id, pr.uuid, d.song_id, d.rating_class, pr.score, pr.pure,\n CASE\n WHEN ci.notes IS NOT NULL AND pr.pure IS NOT NULL AND pr.far IS NOT NULL AND ci.notes <> 0\n THEN pr.score - FLOOR((pr.pure * 10000000.0 / ci.notes) + (pr.far * 0.5 * 10000000.0 / ci.notes))\n ELSE NULL\n END AS shiny_pure,\n pr.far, pr.lost, pr.date, pr.max_recall, pr.modifier, pr.clear_type,\n CASE\n WHEN pr.score >= 10000000 THEN ci.constant / 10.0 + 2\n WHEN pr.score >= 9800000 THEN ci.constant / 10.0 + 1 + (pr.score - 9800000) / 200000.0\n ELSE MAX(ci.constant / 10.0 + (pr.score - 9500000) / 300000.0, 0)\n END AS potential,\n pr.comment\n FROM difficulties d\n JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class\n JOIN play_results pr ON d.song_id = pr.song_id AND d.rating_class = pr.rating_class") connection.execSQL("CREATE VIEW `play_results_best` AS SELECT\n prc.id, prc.uuid, prc.song_id, prc.rating_class, prc.score,\n prc.pure, prc.shiny_pure, prc.far, prc.lost,\n prc.date, prc.max_recall, prc.modifier, prc.clear_type,\n MAX(prc.potential) AS potential,\n prc.comment\n FROM play_results_calculated prc\n GROUP BY prc.song_id, prc.rating_class\n ORDER BY prc.potential DESC") // move existing data to new tables diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidator.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidator.kt new file mode 100644 index 00000000..9b5b8a96 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidator.kt @@ -0,0 +1,30 @@ +package xyz.sevive.arcaeaoffline.core.validation + +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult + +/** A warning raised for a play result, with the values it compared. */ +data class PlayResultFinding( + val warning: PlayResultWarning, + val detail: PlayResultWarningDetail = PlayResultWarningDetail.None, +) + +object PlayResultValidator { + /** + * Warnings raised for [playResult] and [chartInfo], in declaration order. A null + * [scoringMode] runs the general validation, which leaves out B50-only warnings. + */ + fun validate( + playResult: PlayResult, + chartInfo: ChartInfo? = null, + scoringMode: ArcaeaScoringMode? = null, + ): List = + PlayResultWarning.entries + .filter { it.scope.appliesTo(scoringMode) } + .mapNotNull { warning -> + warning.check(playResult, chartInfo)?.let { detail -> + PlayResultFinding(warning, detail) + } + } +} diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt new file mode 100644 index 00000000..484be4bf --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt @@ -0,0 +1,344 @@ +package xyz.sevive.arcaeaoffline.core.validation + +import xyz.sevive.arcaeaoffline.core.calculators.calculateScoreRange +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult + +/** + * The chart's note count when it is usable for validation, or `null` when the count is missing + * or non-positive. + */ +private fun ChartInfo?.usableNotes(): Int? = this?.notes?.takeIf { it > 0 } + +/** + * The highest score a play can reach on a chart of [notes]: every PURE earns the shiny PURE bonus. + * + * The sum is computed in [Long] and clamped to [Int], so a corrupt note count cannot overflow it. + */ +private fun maxScore(notes: Int): Int = (10_000_000L + notes).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + +/** Which scoring modes a warning takes part in. */ +enum class PlayResultWarningScope { + ALWAYS, + B50_ONLY, + ; + + /** + * Whether this scope takes part in validation for [scoringMode]. A null mode runs the general + * validation, which leaves out [PlayResultWarningScope.B50_ONLY]. + */ + fun appliesTo(scoringMode: ArcaeaScoringMode?): Boolean = this == ALWAYS || scoringMode == ArcaeaScoringMode.B50 +} + +/** + * A data problem detected in a [PlayResult]. + * + * Four null-value policies exist: + * + * * Contradiction warnings need every compared value. An unknown value (a null [PlayResult] + * field or a missing or non-positive [ChartInfo.notes]) makes the check pass silently. + * * Overflow warnings ignore an unknown count. A known value could only tighten the check, so + * an overflow can be missed but never invented. + * * Suspicion warnings verify the judgment counts implied by a claimed clear type. Unknown + * counts cannot confirm the claim, so the warning is still raised. + * * EMPTY_PLAY_RESULT treats a null judgment count as 0, because the editor cannot distinguish + * an untouched count from one it cleared. + */ +enum class PlayResultWarning( + val scope: PlayResultWarningScope = PlayResultWarningScope.ALWAYS, +) { + EMPTY_PLAY_RESULT { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if ( + playResult.score == 0 && + (playResult.pure ?: 0) == 0 && + (playResult.far ?: 0) == 0 && + (playResult.lost ?: 0) == 0 + ) { + PlayResultWarningDetail.None + } else { + null + } + }, + + PURE_MEMORY_FAR_LOST_NOT_ZERO { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + if (playResult.clearType != ArcaeaPlayResultClearType.PURE_MEMORY) return null + + val far = playResult.far + val lost = playResult.lost + if (far == 0 && lost == 0) return null + + return if (far != null && lost != null) { + PlayResultWarningDetail.FarLost(far, lost) + } else { + PlayResultWarningDetail.None + } + } + }, + + ALL_PURE_NOT_PM { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if (playResult.clearType != null && + playResult.clearType != ArcaeaPlayResultClearType.PURE_MEMORY && + playResult.far == 0 && + playResult.lost == 0 + ) { + PlayResultWarningDetail.None + } else { + null + } + }, + + SCORE_OUT_OF_RANGE { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + val notes = chartInfo.usableNotes() ?: return null + + // SCORE_IMPOSSIBLE reports scores outside [0, maxScore(notes)]. Without this guard the + // range check below would report them too. + if (playResult.score < 0 || playResult.score > maxScore(notes)) return null + + val pure = playResult.pure ?: return null + val far = playResult.far ?: return null + val expected = calculateScoreRange(notes, pure, far) + + return if (playResult.score in expected) { + null + } else { + PlayResultWarningDetail.ScoreOutOfRange(playResult.score, expected, pure, far) + } + } + }, + + SCORE_IMPOSSIBLE { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + if (playResult.score < 0) { + return PlayResultWarningDetail.ScoreNegative(playResult.score) + } + + val notes = chartInfo.usableNotes() ?: return null + val maximum = maxScore(notes) + return if (playResult.score > maximum) { + PlayResultWarningDetail.ScoreBeyondMaximum(playResult.score, maximum) + } else { + null + } + } + }, + + PM_SCORE_TOO_LOW { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? = + if (playResult.clearType == ArcaeaPlayResultClearType.PURE_MEMORY && + playResult.score < 10_000_000 + ) { + PlayResultWarningDetail.ScoreTooLow(playResult.score, 10_000_000) + } else { + null + } + }, + + PFL_OVERFLOW { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + val notes = chartInfo.usableNotes() ?: return null + + val pure = playResult.pure + val far = playResult.far + val lost = playResult.lost + val sum = (pure ?: 0) + (far ?: 0) + (lost ?: 0) + if (sum <= notes) return null + + return if (pure != null && far != null && lost != null) { + PlayResultWarningDetail.Judgments(pure, far, lost, notes) + } else { + PlayResultWarningDetail.None + } + } + }, + + MAX_RECALL_OVERFLOW { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + val notes = chartInfo.usableNotes() ?: return null + val maxRecall = playResult.maxRecall ?: return null + + val notesBound = notes - (playResult.lost ?: 0) + val pure = playResult.pure + val far = playResult.far + + // Max Recall cannot exceed the judged non-LOST count, nor the chart's notes minus LOST. + // The smaller bound is the tighter check. + val bound = if (pure != null && far != null) minOf(notesBound, pure + far) else notesBound + return if (maxRecall > bound) { + PlayResultWarningDetail.MaxRecall(maxRecall, bound) + } else { + null + } + } + }, + + NEGATIVE_COUNT { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if ((playResult.pure ?: 0) < 0 || + (playResult.far ?: 0) < 0 || + (playResult.lost ?: 0) < 0 || + (playResult.maxRecall ?: 0) < 0 + ) { + PlayResultWarningDetail.None + } else { + null + } + }, + + FR_PM_MAX_RECALL_MISMATCH { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + if (playResult.clearType !in + listOf( + ArcaeaPlayResultClearType.FULL_RECALL, + ArcaeaPlayResultClearType.PURE_MEMORY, + ) + ) { + return null + } + + val notes = chartInfo.usableNotes() ?: return null + val maxRecall = playResult.maxRecall ?: return null + + return if (maxRecall != notes) { + PlayResultWarningDetail.MaxRecall(maxRecall, notes) + } else { + null + } + } + }, + + FULL_RECALL_LOST_NOT_ZERO { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + if (playResult.clearType != ArcaeaPlayResultClearType.FULL_RECALL) return null + + val lost = playResult.lost + if (lost == 0) return null + + return if (lost != null) { + PlayResultWarningDetail.Lost(lost) + } else { + PlayResultWarningDetail.None + } + } + }, + + PLAIN_CLEAR_WITHOUT_LOST { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if (playResult.clearType in + listOf( + ArcaeaPlayResultClearType.NORMAL_CLEAR, + ArcaeaPlayResultClearType.EASY_CLEAR, + ArcaeaPlayResultClearType.HARD_CLEAR, + ) && + playResult.lost == 0 + ) { + PlayResultWarningDetail.None + } else { + null + } + }, + + CLEAR_PFL_MISMATCH { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + val notes = chartInfo.usableNotes() ?: return null + val clearType = playResult.clearType ?: return null + + // A HARD play can be interrupted before the chart ends, so a Track Lost result may have + // pure + far + lost below the note count. An unknown modifier cannot rule HARD out. + // NORMAL and EASY gauges cannot interrupt a play. + if ( + clearType == ArcaeaPlayResultClearType.TRACK_LOST && + playResult.modifier != ArcaeaPlayResultModifier.NORMAL && + playResult.modifier != ArcaeaPlayResultModifier.EASY + ) { + return null + } + + val pure = playResult.pure ?: return null + val far = playResult.far ?: return null + val lost = playResult.lost ?: return null + + return if (pure + far + lost != notes) { + PlayResultWarningDetail.Judgments(pure, far, lost, notes) + } else { + null + } + } + }, + + MODIFIER_CLEAR_TYPE_MISMATCH { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? { + val modifier = playResult.modifier ?: return null + val expectedModifier = + when (playResult.clearType) { + ArcaeaPlayResultClearType.EASY_CLEAR -> ArcaeaPlayResultModifier.EASY + ArcaeaPlayResultClearType.HARD_CLEAR -> ArcaeaPlayResultModifier.HARD + ArcaeaPlayResultClearType.NORMAL_CLEAR -> ArcaeaPlayResultModifier.NORMAL + else -> return null + } + + return if (modifier != expectedModifier) PlayResultWarningDetail.None else null + } + }, + + CLEAR_TYPE_MISSING(scope = PlayResultWarningScope.B50_ONLY) { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if (playResult.clearType == null) PlayResultWarningDetail.None else null + }, + ; + + /** + * The evidence this warning found, or `null` when it does not apply. + * [PlayResultWarningDetail.None] means it applies but the compared values were incomplete. + */ + abstract fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): PlayResultWarningDetail? +} diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarningDetail.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarningDetail.kt new file mode 100644 index 00000000..9b3fb4f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarningDetail.kt @@ -0,0 +1,56 @@ +package xyz.sevive.arcaeaoffline.core.validation + +/** + * The values a warning compared. [None] means the warning is raised but the compared values were + * incomplete, so there is no evidence to show. + */ +sealed interface PlayResultWarningDetail { + data object None : PlayResultWarningDetail + + data class ScoreOutOfRange( + val actual: Int, + val expected: IntRange, + val pure: Int, + val far: Int, + ) : PlayResultWarningDetail + + data class ScoreTooLow( + val actual: Int, + val minimum: Int, + ) : PlayResultWarningDetail + + data class ScoreNegative( + val actual: Int, + ) : PlayResultWarningDetail + + data class ScoreBeyondMaximum( + val actual: Int, + val maximum: Int, + ) : PlayResultWarningDetail + + data class Judgments( + val pure: Int, + val far: Int, + val lost: Int, + val notes: Int, + ) : PlayResultWarningDetail + + /** + * [expected] is the tightest allowed value: the upper bound for + * [PlayResultWarning.MAX_RECALL_OVERFLOW], the chart's note count for + * [PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH]. + */ + data class MaxRecall( + val actual: Int, + val expected: Int, + ) : PlayResultWarningDetail + + data class FarLost( + val far: Int, + val lost: Int, + ) : PlayResultWarningDetail + + data class Lost( + val actual: Int, + ) : PlayResultWarningDetail +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt similarity index 63% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt index d8ea025f..224612cf 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.components +package xyz.sevive.arcaeaoffline.ui.components.playresult import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState @@ -26,7 +26,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,32 +34,30 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorMaxRecallOverflowWarning -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorPflOverflowWarning -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import org.jetbrains.compose.resources.stringResource +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.ui.utils.secondaryItemAlpha @Composable -private fun ArcaeaPlayResultValidatorWarningDetailItem( - warning: ArcaeaPlayResultValidatorWarning, +private fun PlayResultWarningDetailItem( + finding: PlayResultFinding, modifier: Modifier = Modifier, ) { + val warning = finding.warning var expanded by rememberSaveable { mutableStateOf(false) } val expandArrowRotateDegree by animateFloatAsState( targetValue = if (expanded) 180f else 0f, label = "expandArrow", ) - val context = LocalContext.current val density = LocalDensity.current val padding = MaterialTheme.spacing.cardPadding @@ -70,16 +67,14 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( PaddingValues(start = start, end = padding, bottom = padding) } - val title = remember(warning) { warning.getTitle(context) } - val message = remember(warning) { warning.getMessage(context) } - - val showWarningId by remember { - derivedStateOf { message.isNullOrBlank() || expanded } - } + val title = stringResource(warning.titleRes()) + val action = stringResource(warning.actionRes()) + val message = finding.messageText() + val expandable = message != null Column( Modifier - .clickable { expanded = !expanded } + .clickable(enabled = expandable) { expanded = !expanded } .then(modifier), ) { Row( @@ -96,16 +91,15 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( Column(Modifier.weight(1f)) { Text(title) - AnimatedVisibility(visible = showWarningId) { - Text( - warning.id, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), - ) - } + Text( + action, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - if (!message.isNullOrBlank()) { + if (expandable) { Icon( Icons.Default.ExpandMore, contentDescription = null, @@ -114,13 +108,18 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( } } - AnimatedVisibility(visible = expanded) { - if (!message.isNullOrBlank()) { + AnimatedVisibility(visible = expandable && expanded) { + Column( + Modifier.padding(messagePadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + if (message != null) { + Text(message, Modifier.secondaryItemAlpha()) + } Text( - message, - Modifier - .secondaryItemAlpha() - .padding(messagePadding), + warning.name, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), + modifier = Modifier.secondaryItemAlpha(), ) } } @@ -128,22 +127,22 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( } @Composable -fun ArcaeaPlayResultValidatorWarningDetails( - warnings: List, +fun PlayResultWarningDetails( + warnings: List, modifier: Modifier = Modifier, ) { Card(modifier) { warnings.forEachIndexed { i, it -> - ArcaeaPlayResultValidatorWarningDetailItem(warning = it) + PlayResultWarningDetailItem(finding = it) if (i != warnings.lastIndex) HorizontalDivider() } } } @Composable -fun ArcaeaPlayResultValidatorWarningDetailsDialog( +fun PlayResultWarningDetailsDialog( onDismissRequest: () -> Unit, - warnings: List, + warnings: List, modifier: Modifier = Modifier, ) { AlertDialog( @@ -165,52 +164,52 @@ fun ArcaeaPlayResultValidatorWarningDetailsDialog( }, text = { LazyColumn { - item { ArcaeaPlayResultValidatorWarningDetails(warnings) } + item { PlayResultWarningDetails(warnings) } } }, confirmButton = { /* Nothing */ }, ) } -@PreviewLightDark +@Preview @Composable -private fun ArcaeaPlayResultValidatorWarningDetailItemPreview() { +private fun PlayResultWarningDetailItemPreview() { ArcaeaOfflineTheme { Surface { - ArcaeaPlayResultValidatorWarningDetailItem(ArcaeaPlayResultValidatorPflOverflowWarning) + PlayResultWarningDetailItem(PlayResultFinding(PlayResultWarning.PFL_OVERFLOW)) } } } -@PreviewLightDark +@Preview @Composable -private fun ArcaeaPlayResultValidatorWarningDetailsPreview() { +private fun PlayResultWarningDetailsPreview() { ArcaeaOfflineTheme { Surface { - ArcaeaPlayResultValidatorWarningDetails( + PlayResultWarningDetails( warnings = listOf( - ArcaeaPlayResultValidatorPflOverflowWarning, - ArcaeaPlayResultValidatorMaxRecallOverflowWarning, - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, + PlayResultFinding(PlayResultWarning.PFL_OVERFLOW), + PlayResultFinding(PlayResultWarning.MAX_RECALL_OVERFLOW), + PlayResultFinding(PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO), ), ) } } } -@PreviewLightDark +@Preview @Composable -private fun ArcaeaPlayResultValidatorWarningDetailsDialogPreview() { +private fun PlayResultWarningDetailsDialogPreview() { ArcaeaOfflineTheme { Surface { - ArcaeaPlayResultValidatorWarningDetailsDialog( + PlayResultWarningDetailsDialog( onDismissRequest = {}, warnings = listOf( - ArcaeaPlayResultValidatorPflOverflowWarning, - ArcaeaPlayResultValidatorMaxRecallOverflowWarning, - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, + PlayResultFinding(PlayResultWarning.PFL_OVERFLOW), + PlayResultFinding(PlayResultWarning.MAX_RECALL_OVERFLOW), + PlayResultFinding(PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO), ), ) } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningMessage.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningMessage.kt new file mode 100644 index 00000000..4d3d7852 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningMessage.kt @@ -0,0 +1,188 @@ +package xyz.sevive.arcaeaoffline.ui.components.playresult + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarningDetail +import xyz.sevive.arcaeaoffline.resources.Res +import xyz.sevive.arcaeaoffline.resources.play_result_validator_all_pure_not_pm_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_pfl_mismatch_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_type_missing_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_fr_pm_max_recall_mismatch_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_full_recall_lost_not_zero_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_max_recall_overflow_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_modifier_clear_type_mismatch_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_negative_count_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_message_detailed +import xyz.sevive.arcaeaoffline.resources.play_result_validator_plain_clear_without_lost_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pm_score_too_low_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pure_memory_far_lost_not_zero_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_impossible_message_beyond_max +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_impossible_message_negative +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_out_of_range_message +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters + +private fun formatScore(score: Int): String = if (score < 0) score.toString() else ArcaeaFormatters.score(score) + +@Composable +internal fun PlayResultFinding.messageText(): String? = + when (warning) { + PlayResultWarning.EMPTY_PLAY_RESULT -> { + null + } + + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> { + stringResource(Res.string.play_result_validator_pure_memory_far_lost_not_zero_message) + } + + PlayResultWarning.ALL_PURE_NOT_PM -> { + stringResource(Res.string.play_result_validator_all_pure_not_pm_message) + } + + PlayResultWarning.SCORE_OUT_OF_RANGE -> { + when (val d = detail) { + is PlayResultWarningDetail.ScoreOutOfRange -> { + stringResource( + Res.string.play_result_validator_score_out_of_range_message, + formatScore(d.actual), + formatScore(d.expected.first), + formatScore(d.expected.last), + d.pure, + d.far, + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.SCORE_IMPOSSIBLE -> { + when (val d = detail) { + is PlayResultWarningDetail.ScoreNegative -> { + stringResource( + Res.string.play_result_validator_score_impossible_message_negative, + d.actual, + ) + } + + is PlayResultWarningDetail.ScoreBeyondMaximum -> { + stringResource( + Res.string.play_result_validator_score_impossible_message_beyond_max, + formatScore(d.actual), + formatScore(d.maximum), + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.PM_SCORE_TOO_LOW -> { + when (val d = detail) { + is PlayResultWarningDetail.ScoreTooLow -> { + stringResource( + Res.string.play_result_validator_pm_score_too_low_message, + formatScore(d.actual), + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.PFL_OVERFLOW -> { + when (val d = detail) { + is PlayResultWarningDetail.Judgments -> { + stringResource( + Res.string.play_result_validator_pfl_overflow_message_detailed, + d.pure, + d.far, + d.lost, + d.notes, + ) + } + + else -> { + stringResource(Res.string.play_result_validator_pfl_overflow_message) + } + } + } + + PlayResultWarning.MAX_RECALL_OVERFLOW -> { + when (val d = detail) { + is PlayResultWarningDetail.MaxRecall -> { + stringResource( + Res.string.play_result_validator_max_recall_overflow_message, + d.actual, + d.expected, + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.NEGATIVE_COUNT -> { + stringResource(Res.string.play_result_validator_negative_count_message) + } + + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> { + when (val d = detail) { + is PlayResultWarningDetail.MaxRecall -> { + stringResource( + Res.string.play_result_validator_fr_pm_max_recall_mismatch_message, + d.actual, + d.expected, + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> { + stringResource(Res.string.play_result_validator_full_recall_lost_not_zero_message) + } + + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> { + stringResource(Res.string.play_result_validator_plain_clear_without_lost_message) + } + + PlayResultWarning.CLEAR_PFL_MISMATCH -> { + when (val d = detail) { + is PlayResultWarningDetail.Judgments -> { + stringResource( + Res.string.play_result_validator_clear_pfl_mismatch_message, + d.pure, + d.far, + d.lost, + d.notes, + ) + } + + else -> { + null + } + } + } + + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> { + stringResource(Res.string.play_result_validator_modifier_clear_type_mismatch_message) + } + + PlayResultWarning.CLEAR_TYPE_MISSING -> { + stringResource(Res.string.play_result_validator_clear_type_missing_message) + } + } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningText.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningText.kt new file mode 100644 index 00000000..f16df757 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningText.kt @@ -0,0 +1,79 @@ +package xyz.sevive.arcaeaoffline.ui.components.playresult + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning +import xyz.sevive.arcaeaoffline.resources.Res +import xyz.sevive.arcaeaoffline.resources.play_result_validator_all_pure_not_pm_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_all_pure_not_pm_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_pfl_mismatch_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_pfl_mismatch_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_type_missing_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_type_missing_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_empty_play_result_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_empty_play_result_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_fr_pm_max_recall_mismatch_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_fr_pm_max_recall_mismatch_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_full_recall_lost_not_zero_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_full_recall_lost_not_zero_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_max_recall_overflow_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_max_recall_overflow_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_modifier_clear_type_mismatch_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_modifier_clear_type_mismatch_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_negative_count_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_negative_count_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_plain_clear_without_lost_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_plain_clear_without_lost_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pm_score_too_low_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pm_score_too_low_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pure_memory_far_lost_not_zero_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pure_memory_far_lost_not_zero_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_impossible_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_impossible_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_out_of_range_action +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_out_of_range_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_warning_count + +internal fun PlayResultWarning.titleRes(): StringResource = + when (this) { + PlayResultWarning.EMPTY_PLAY_RESULT -> Res.string.play_result_validator_empty_play_result_title + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> Res.string.play_result_validator_pure_memory_far_lost_not_zero_title + PlayResultWarning.ALL_PURE_NOT_PM -> Res.string.play_result_validator_all_pure_not_pm_title + PlayResultWarning.SCORE_OUT_OF_RANGE -> Res.string.play_result_validator_score_out_of_range_title + PlayResultWarning.SCORE_IMPOSSIBLE -> Res.string.play_result_validator_score_impossible_title + PlayResultWarning.PM_SCORE_TOO_LOW -> Res.string.play_result_validator_pm_score_too_low_title + PlayResultWarning.PFL_OVERFLOW -> Res.string.play_result_validator_pfl_overflow_title + PlayResultWarning.MAX_RECALL_OVERFLOW -> Res.string.play_result_validator_max_recall_overflow_title + PlayResultWarning.NEGATIVE_COUNT -> Res.string.play_result_validator_negative_count_title + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> Res.string.play_result_validator_fr_pm_max_recall_mismatch_title + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> Res.string.play_result_validator_full_recall_lost_not_zero_title + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> Res.string.play_result_validator_plain_clear_without_lost_title + PlayResultWarning.CLEAR_PFL_MISMATCH -> Res.string.play_result_validator_clear_pfl_mismatch_title + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> Res.string.play_result_validator_modifier_clear_type_mismatch_title + PlayResultWarning.CLEAR_TYPE_MISSING -> Res.string.play_result_validator_clear_type_missing_title + } + +internal fun PlayResultWarning.actionRes(): StringResource = + when (this) { + PlayResultWarning.EMPTY_PLAY_RESULT -> Res.string.play_result_validator_empty_play_result_action + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> Res.string.play_result_validator_pure_memory_far_lost_not_zero_action + PlayResultWarning.ALL_PURE_NOT_PM -> Res.string.play_result_validator_all_pure_not_pm_action + PlayResultWarning.SCORE_OUT_OF_RANGE -> Res.string.play_result_validator_score_out_of_range_action + PlayResultWarning.SCORE_IMPOSSIBLE -> Res.string.play_result_validator_score_impossible_action + PlayResultWarning.PM_SCORE_TOO_LOW -> Res.string.play_result_validator_pm_score_too_low_action + PlayResultWarning.PFL_OVERFLOW -> Res.string.play_result_validator_pfl_overflow_action + PlayResultWarning.MAX_RECALL_OVERFLOW -> Res.string.play_result_validator_max_recall_overflow_action + PlayResultWarning.NEGATIVE_COUNT -> Res.string.play_result_validator_negative_count_action + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> Res.string.play_result_validator_fr_pm_max_recall_mismatch_action + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> Res.string.play_result_validator_full_recall_lost_not_zero_action + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> Res.string.play_result_validator_plain_clear_without_lost_action + PlayResultWarning.CLEAR_PFL_MISMATCH -> Res.string.play_result_validator_clear_pfl_mismatch_action + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> Res.string.play_result_validator_modifier_clear_type_mismatch_action + PlayResultWarning.CLEAR_TYPE_MISSING -> Res.string.play_result_validator_clear_type_missing_action + } + +@Composable +fun playResultWarningCountText(count: Int): String = pluralStringResource(Res.plurals.play_result_validator_warning_count, count, count) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt new file mode 100644 index 00000000..b52ecdcc --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt @@ -0,0 +1,11 @@ +package xyz.sevive.arcaeaoffline.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha + +const val DISABLED_ALPHA = .38f +const val SECONDARY_ALPHA = .75f + +fun Modifier.disabledItemAlpha() = this.alpha(DISABLED_ALPHA) + +fun Modifier.secondaryItemAlpha() = this.alpha(SECONDARY_ALPHA) 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 6e64fd7c..d6865836 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 @@ -9,9 +9,34 @@ import kotlin.test.assertNull class CommonCalculatorsTest { @Test fun testScoreRange() { + // A single sample misses the counts where a floating-point bound is one point low + // (1046, 1081, ...), so the sweep covers every note count up to 3000. + for (notes in 1..3000) { + assertEquals( + 10_000_000..(10_000_000 + notes), + calculateScoreRange(notes, notes, 0), + "notes=$notes", + ) + } + + // Expected values are hand-computed from the score formula. See docs/play-result-mechanisms.md + assertEquals(9_843_750..9_844_339, calculateScoreRange(608, 589, 19)) + assertEquals(9_983_695..9_984_612, calculateScoreRange(920, 917, 3)) + assertEquals(9_890_057..9_891_080, calculateScoreRange(1046, 1023, 23)) + + assertEquals(IntRange.EMPTY, calculateScoreRange(0, 0, 0)) + assertEquals(IntRange.EMPTY, calculateScoreRange(-1, 10, 0)) + } + + @Test + fun testScoreRangeClampsOverflow() { + assertEquals( + Int.MAX_VALUE..Int.MAX_VALUE, + calculateScoreRange(notes = 1, pure = 2_000_000_000, far = 0), + ) assertEquals( - 10_000_000..10_002_221, - calculateScoreRange(2221, 2221, 0), + Int.MAX_VALUE..Int.MAX_VALUE, + calculateScoreRange(notes = 1, pure = 0, far = 2_000_000_000), ) } diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt new file mode 100644 index 00000000..577ef604 --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt @@ -0,0 +1,998 @@ +package xyz.sevive.arcaeaoffline.core.validation + +import xyz.sevive.arcaeaoffline.core.calculators.calculateScoreRange +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.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlayResultValidatorTest { + @Test + fun maxRecallOverflow() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + + val playResult = + PlayResult( + id = 0, + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9984560, + pure = 917, + far = 3, + lost = 0, + date = null, + maxRecall = 1001, + modifier = null, + clearType = ArcaeaPlayResultClearType.FULL_RECALL, + comment = null, + ) + + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), + ) + } + + @Test + fun pureMemoryFarLostNotZero() { + val playResultHasFar = + PlayResult( + id = 0, + songId = "test", + ratingClass = ArcaeaRatingClass.BEYOND, + score = 10002221, + pure = 2221, + far = 1, + lost = 0, + date = null, + maxRecall = 2221, + modifier = null, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + comment = null, + ) + val playResultHasLost = + PlayResult( + id = 0, + songId = "test", + ratingClass = ArcaeaRatingClass.BEYOND, + score = 10002221, + pure = 2221, + far = 0, + lost = 1, + date = null, + maxRecall = 2221, + modifier = null, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + comment = null, + ) + val playResultHasNothing = + PlayResult( + id = 0, + songId = "test", + ratingClass = ArcaeaRatingClass.BEYOND, + score = 10002221, + pure = 2221, + far = null, + lost = null, + date = null, + maxRecall = 2221, + modifier = null, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + comment = null, + ) + + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( + playResultHasFar, + null, + ), + ) + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( + playResultHasLost, + null, + ), + ) + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( + playResultHasNothing, + null, + ), + ) + } + + @Test + fun frPmMaxRecallMismatchSkipsUnknownValues() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + + val playResultMatches = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 10_000_000, + pure = 920, + far = 0, + lost = 0, + maxRecall = 920, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + ) + val playResultMismatched = playResultMatches.copy(maxRecall = 900) + val playResultNoMaxRecall = playResultMatches.copy(maxRecall = null) + + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( + playResultMismatched, + null, + ), + ) + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( + playResultNoMaxRecall, + chartInfo, + ), + ) + assertNotNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( + playResultMismatched, + chartInfo, + ), + ) + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( + playResultMatches, + chartInfo, + ), + ) + } + + @Test + fun clearPflMismatchModifierNullHandling() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_800_000, + pure = 880, + far = 20, + lost = 10, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + ) + val trackLost = playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST) + + assertNotNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(trackLost, chartInfo), + ) + assertNotNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check( + trackLost.copy(modifier = ArcaeaPlayResultModifier.NORMAL), + chartInfo, + ), + ) + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check( + trackLost.copy(modifier = ArcaeaPlayResultModifier.HARD), + chartInfo, + ), + ) + } + + @Test + fun scoreImpossible() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val chartInfoNotesNull = chartInfo.copy(notes = null) + + val playResultNegative = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = -1, + ) + val playResultZero = playResultNegative.copy(score = 0) + val playResultTheoretical = playResultNegative.copy(score = 10_000_920) + val playResultBeyondTheoretical = playResultNegative.copy(score = 10_000_921) + + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultNegative, null), + ) + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultZero, null), + ) + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultTheoretical, chartInfo), + ) + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultBeyondTheoretical, chartInfo), + ) + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultBeyondTheoretical, chartInfoNotesNull), + ) + } + + @Test + fun scoreImpossibleDoesNotOverflowWithHugeNotes() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = Int.MAX_VALUE, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 1, + pure = 0, + far = 0, + lost = 0, + ) + + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult, chartInfo), + ) + } + + @Test + fun allPureNotPm() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 10_000_000, + pure = 920, + far = 0, + lost = 0, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + ) + + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check(playResult, null), + ) + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST), + null, + ), + ) + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.PURE_MEMORY), + null, + ), + ) + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(far = 1), + null, + ), + ) + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(far = null), + null, + ), + ) + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(lost = null), + null, + ), + ) + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( + playResult.copy(clearType = null), + null, + ), + ) + } + + @Test + fun clearWithoutLost() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 900, + far = 20, + lost = 0, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + ) + + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check(playResult, null), + ) + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.EASY_CLEAR), + null, + ), + ) + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.HARD_CLEAR), + null, + ), + ) + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(lost = 1), + null, + ), + ) + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(lost = null), + null, + ), + ) + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST), + null, + ), + ) + } + + @Test + fun pmScoreTooLow() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_999_999, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + ) + + assertNotNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check(playResult, null), + ) + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( + playResult.copy(score = 10_000_000), + null, + ), + ) + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( + playResult.copy(clearType = null), + null, + ), + ) + } + + @Test + fun negativeCount() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 900, + far = 20, + lost = 0, + maxRecall = 920, + ) + + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( + playResult.copy(pure = -1), + null, + ), + ) + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( + playResult.copy(far = -1), + null, + ), + ) + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( + playResult.copy(lost = -1), + null, + ), + ) + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( + playResult.copy(maxRecall = -1), + null, + ), + ) + assertNull( + PlayResultWarning.NEGATIVE_COUNT.check(playResult, null), + ) + assertNull( + PlayResultWarning.NEGATIVE_COUNT.check( + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + ), + null, + ), + ) + } + + @Test + fun maxRecallOverflowWithLost() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + lost = 5, + maxRecall = 916, + ) + + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(maxRecall = 915), + chartInfo, + ), + ) + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(lost = null, maxRecall = 921), + chartInfo, + ), + ) + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(lost = null, maxRecall = 920), + chartInfo, + ), + ) + } + + @Test + fun maxRecallOverflowAgainstJudgedCounts() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + // 920 notes with 800 PURE and 20 FAR: only 820 judgments are not LOST. + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 800, + far = 20, + lost = 0, + maxRecall = 821, + ) + + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(maxRecall = 820), + chartInfo, + ), + ) + + // An interrupted play: notes minus lost (915) is looser than the 820 judged notes. + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(lost = 5), + chartInfo, + ), + ) + + // PURE + FAR exceed the note count, so notes minus lost (910) is the tighter bound. + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 911), + chartInfo, + ), + ) + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 910), + chartInfo, + ), + ) + } + + @Test + fun modifierClearTypeMismatchNormalDirection() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + modifier = ArcaeaPlayResultModifier.EASY, + ) + + assertNotNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check(playResult, null), + ) + assertNotNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( + playResult.copy(modifier = ArcaeaPlayResultModifier.HARD), + null, + ), + ) + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( + playResult.copy(modifier = ArcaeaPlayResultModifier.NORMAL), + null, + ), + ) + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( + playResult.copy(modifier = null), + null, + ), + ) + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( + playResult.copy( + clearType = ArcaeaPlayResultClearType.EASY_CLEAR, + modifier = ArcaeaPlayResultModifier.EASY, + ), + null, + ), + ) + } + + @Test + fun scoreOutOfRangeNonPositiveNotes() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 0, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 900, + far = 20, + ) + + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check( + playResult, + chartInfo.copy(notes = -5), + ), + ) + } + + @Test + fun noteBasedWarningsSkipNonPositiveNotes() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 10_000_500, + pure = 900, + far = 20, + lost = 0, + maxRecall = 920, + clearType = ArcaeaPlayResultClearType.FULL_RECALL, + ) + + for (notes in listOf(0, -5)) { + val corrupt = chartInfo.copy(notes = notes) + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, corrupt), + "notes=$notes", + ) + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult, corrupt), + "notes=$notes", + ) + assertNull( + PlayResultWarning.PFL_OVERFLOW.check(playResult, corrupt), + "notes=$notes", + ) + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, corrupt), + "notes=$notes", + ) + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check(playResult, corrupt), + "notes=$notes", + ) + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(playResult, corrupt), + "notes=$notes", + ) + } + + // A negative score stays impossible even when the note count is unusable. + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check( + playResult.copy(score = -1), + chartInfo.copy(notes = 0), + ), + ) + } + + @Test + fun pflOverflow() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 917, + far = 3, + lost = 1, + ) + + assertNotNull( + PlayResultWarning.PFL_OVERFLOW.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.PFL_OVERFLOW.check( + playResult.copy(lost = 0), + chartInfo, + ), + ) + assertNull( + PlayResultWarning.PFL_OVERFLOW.check( + playResult, + chartInfo.copy(notes = 0), + ), + ) + } + + @Test + fun scoreWarningsDoNotOverlap() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 10_000_921, + pure = 920, + far = 0, + lost = 0, + ) + + // One point above the theoretical value. + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult, chartInfo), + ) + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, chartInfo), + ) + + val negative = playResult.copy(score = -1) + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(negative, chartInfo), + ) + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(negative, chartInfo), + ) + + // Below what 920 PURE judgments can score. + val inconsistent = playResult.copy(score = 9_000_000) + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(inconsistent, chartInfo), + ) + assertNotNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(inconsistent, chartInfo), + ) + + // validate() reports the score problem once. + assertEquals( + listOf(PlayResultWarning.SCORE_IMPOSSIBLE), + PlayResultValidator.validate(playResult, chartInfo).map { it.warning }, + ) + } + + @Test + fun clearTypeMissingOnlyUnderB50() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + pure = 900, + far = 20, + lost = 0, + maxRecall = 920, + clearType = null, + ) + + assertTrue( + PlayResultValidator + .validate(playResult, chartInfo, ArcaeaScoringMode.B50) + .any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, + ) + assertFalse( + PlayResultValidator + .validate(playResult, chartInfo, ArcaeaScoringMode.B30_R10) + .any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, + ) + assertFalse( + PlayResultValidator + .validate( + playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + chartInfo, + ArcaeaScoringMode.B50, + ).any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, + ) + } + + @Test + fun clearTypeMissing() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + clearType = null, + ) + + assertNotNull( + PlayResultWarning.CLEAR_TYPE_MISSING.check(playResult, null), + ) + assertNull( + PlayResultWarning.CLEAR_TYPE_MISSING.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + null, + ), + ) + } + + @Test + fun emptyPlayResult() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 0, + pure = 0, + far = 0, + lost = 0, + ) + + assertNotNull( + PlayResultWarning.EMPTY_PLAY_RESULT.check(playResult, null), + ) + assertNotNull( + PlayResultWarning.EMPTY_PLAY_RESULT.check( + playResult.copy(pure = null, far = null, lost = null), + null, + ), + ) + assertNull( + PlayResultWarning.EMPTY_PLAY_RESULT.check( + playResult.copy(lost = 1), + null, + ), + ) + assertNull( + PlayResultWarning.EMPTY_PLAY_RESULT.check( + playResult.copy(score = 1), + null, + ), + ) + } + + @Test + fun warningDetails() { + val chartInfo = + ChartInfo( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + constant = 95, + notes = 920, + ) + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_000_000, + pure = 900, + far = 20, + lost = 0, + ) + + assertEquals( + PlayResultWarningDetail.ScoreOutOfRange( + actual = 9_000_000, + expected = calculateScoreRange(920, 900, 20), + pure = 900, + far = 20, + ), + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, chartInfo), + ) + + assertEquals( + PlayResultWarningDetail.ScoreTooLow(actual = 9_900_000, minimum = 10_000_000), + PlayResultWarning.PM_SCORE_TOO_LOW.check( + playResult.copy( + score = 9_900_000, + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + ), + chartInfo, + ), + ) + + assertEquals( + PlayResultWarningDetail.ScoreNegative(actual = -1), + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult.copy(score = -1), chartInfo), + ) + assertEquals( + PlayResultWarningDetail.ScoreBeyondMaximum(actual = 10_000_921, maximum = 10_000_920), + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult.copy(score = 10_000_921), chartInfo), + ) + + assertEquals( + PlayResultWarningDetail.Judgments(pure = 917, far = 3, lost = 1, notes = 920), + PlayResultWarning.PFL_OVERFLOW.check( + playResult.copy(pure = 917, far = 3, lost = 1), + chartInfo, + ), + ) + // The overflow warning is still raised when a count is unknown, but there is no complete evidence. + assertEquals( + PlayResultWarningDetail.None, + PlayResultWarning.PFL_OVERFLOW.check( + playResult.copy(pure = null, far = 3, lost = 1), + chartInfo.copy(notes = 3), + ), + ) + + assertEquals( + PlayResultWarningDetail.MaxRecall(actual = 921, expected = 920), + PlayResultWarning.MAX_RECALL_OVERFLOW.check( + playResult.copy(pure = 917, far = 3, lost = 0, maxRecall = 921), + chartInfo, + ), + ) + + assertEquals( + PlayResultWarningDetail.MaxRecall(actual = 919, expected = 920), + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( + playResult.copy( + clearType = ArcaeaPlayResultClearType.FULL_RECALL, + maxRecall = 919, + ), + chartInfo, + ), + ) + + assertEquals( + PlayResultWarningDetail.Judgments(pure = 900, far = 20, lost = 1, notes = 920), + PlayResultWarning.CLEAR_PFL_MISMATCH.check( + playResult.copy(lost = 1, clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + chartInfo, + ), + ) + + assertEquals( + PlayResultWarningDetail.FarLost(far = 1, lost = 0), + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( + playResult.copy( + clearType = ArcaeaPlayResultClearType.PURE_MEMORY, + far = 1, + lost = 0, + ), + chartInfo, + ), + ) + + assertEquals( + PlayResultWarningDetail.Lost(actual = 1), + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL, lost = 1), + chartInfo, + ), + ) + assertEquals( + PlayResultWarningDetail.None, + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO.check( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL, lost = null), + chartInfo, + ), + ) + } +} diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt similarity index 99% rename from app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt rename to shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt index 5cc4227d..a042179f 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt @@ -1,10 +1,10 @@ package xyz.sevive.arcaeaoffline.ui.helpers -import org.junit.Assert.assertEquals -import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong +import kotlin.test.Test +import kotlin.test.assertEquals class ArcaeaFormattersTest { @Test