From e81573bc01d1ff3e840bb696ee605fbeaa7aff1c Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 06:52:52 +0800 Subject: [PATCH 01/20] warnings correctness fix --- .../helpers/ArcaeaPlayResultValidator.kt | 1 + .../ArcaeaPlayResultValidatorWarnings.kt | 60 ++++++-- app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../helpers/ArcaeaPlayResultValidatorTest.kt | 132 ++++++++++++++++++ 5 files changed, 188 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt index 4a97d157..e8e1c61a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt @@ -10,6 +10,7 @@ object ArcaeaPlayResultValidator { ArcaeaPlayResultValidatorScoreIsZeroWarning, ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, ArcaeaPlayResultValidatorScoreOutOfRangeWarning, + ArcaeaPlayResultValidatorScoreImpossibleWarning, ArcaeaPlayResultValidatorPflOverflowWarning, ArcaeaPlayResultValidatorMaxRecallOverflowWarning, ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 6a71e15b..ae8a5704 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -9,6 +9,16 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +/** + * A data problem detected in a [PlayResult]. + * + * Two null-value policies exist: + * + * * Contradiction warnings need both compared values. An unknown value (a null [PlayResult] + * field or missing [ChartInfo.notes]) makes the check pass silently. + * * Suspicion warnings, titled with a "?", verify the judgment counts implied by a claimed + * clear type. Unknown counts cannot confirm the claim and also trigger the warning. + */ sealed interface ArcaeaPlayResultValidatorWarning { val id: String @@ -79,6 +89,26 @@ data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultVa } } +data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultValidatorWarning { + override val id = "SCORE_IMPOSSIBLE" + + override val title = "Impossible score" + override val titleId = R.string.play_result_validator_SCORE_IMPOSSIBLE_title + + override val message = "The score is below zero, or above the Max PURE MEMORY value." + override val messageId = R.string.play_result_validator_SCORE_IMPOSSIBLE_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean { + if (playResult.score < 0) return true + + val notes = chartInfo?.notes ?: return false + return playResult.score > 10_000_000 + notes + } +} + data object ArcaeaPlayResultValidatorPflOverflowWarning : ArcaeaPlayResultValidatorWarning { override val id = "PFL_OVERFLOW" @@ -149,7 +179,10 @@ data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : return false } - return playResult.maxRecall != chartInfo?.notes + val notes = chartInfo?.notes ?: return false + val maxRecall = playResult.maxRecall ?: return false + + return maxRecall != notes } } @@ -166,20 +199,25 @@ data object ArcaeaPlayResultValidatorClearPflMismatchWarning : ArcaeaPlayResultV 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) { + val notes = chartInfo?.notes ?: return false + val clearType = playResult.clearType ?: return false + + // 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 false } - if (playResult.pure == null || playResult.far == null || playResult.lost == null) { - return false - } + val pure = playResult.pure ?: return false + val far = playResult.far ?: return false + val lost = playResult.lost ?: return false - return playResult.pure!! + playResult.far!! + playResult.lost!! != chartInfo.notes + return pure + far + lost != notes } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c683418f..b08c8739 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -428,6 +428,9 @@ 分数不在范围内 所给分数不在由 PURE、FAR 计算而得的分数范围内 + 分数超出理论范围 + 分数为负或超过该谱面理论值 + Note 溢出 PURE、FAR、LOST 的和比谱面总物件数要大 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f00828b..d34a2ea5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -471,6 +471,9 @@ CC BY-SA 3.0 Score out of range The score calculation based on the pure/far value does not match the given score. + Impossible score + The score is below zero or above Max PURE MEMORY. + Notes overflow The sum of pure, far and lost is greater than the total notes of the chart. diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index f2ee4512..9302b780 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -1,8 +1,10 @@ package xyz.sevive.arcaeaoffline.helpers +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult @@ -106,4 +108,134 @@ class ArcaeaPlayResultValidatorTest { ), ) } + + @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) + + assertFalse( + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + playResultMismatched, + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + playResultNoMaxRecall, + chartInfo, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + playResultMismatched, + chartInfo, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + 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) + + assertTrue( + ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(trackLost, chartInfo), + ) + assertTrue( + ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet( + trackLost.copy(modifier = ArcaeaPlayResultModifier.NORMAL), + chartInfo, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet( + 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) + + assertTrue( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultNegative, null), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultZero, null), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultTheoretical, chartInfo), + ) + assertTrue( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultBeyondTheoretical, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultBeyondTheoretical, chartInfoNotesNull), + ) + } } From 187ec93d6ec6b3085a876a7a2ec9a41dfa6c4886 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 22:41:04 +0800 Subject: [PATCH 02/20] add warnings --- .../helpers/ArcaeaPlayResultValidator.kt | 4 + .../ArcaeaPlayResultValidatorWarnings.kt | 105 +++++- app/src/main/res/values-zh-rCN/strings.xml | 16 +- app/src/main/res/values/strings.xml | 16 +- .../helpers/ArcaeaPlayResultValidatorTest.kt | 314 ++++++++++++++++++ .../arcaeaoffline/core/calculators/Common.kt | 6 + 6 files changed, 446 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt index e8e1c61a..f4546708 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt @@ -9,12 +9,16 @@ object ArcaeaPlayResultValidator { listOf( ArcaeaPlayResultValidatorScoreIsZeroWarning, ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, + ArcaeaPlayResultValidatorAllPureNotPmWarning, ArcaeaPlayResultValidatorScoreOutOfRangeWarning, ArcaeaPlayResultValidatorScoreImpossibleWarning, + ArcaeaPlayResultValidatorPmScoreTooLowWarning, ArcaeaPlayResultValidatorPflOverflowWarning, ArcaeaPlayResultValidatorMaxRecallOverflowWarning, + ArcaeaPlayResultValidatorNegativeCountWarning, ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning, + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning, ArcaeaPlayResultValidatorClearPflMismatchWarning, ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index ae8a5704..52ffd617 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -80,15 +80,35 @@ data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultVa playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - if (chartInfo?.notes == null || playResult.pure == null || playResult.far == null) { - return false - } + val notes = chartInfo?.notes ?: return false + // A non-positive note count is corrupt chart data, not a play data problem. + if (notes <= 0) return false - val scoreRange = calculateScoreRange(chartInfo.notes!!, playResult.pure!!, playResult.far!!) + val pure = playResult.pure ?: return false + val far = playResult.far ?: return false + + val scoreRange = calculateScoreRange(notes, pure, far) return !scoreRange.contains(playResult.score) } } +data object ArcaeaPlayResultValidatorPmScoreTooLowWarning : ArcaeaPlayResultValidatorWarning { + override val id = "PM_SCORE_TOO_LOW" + + override val title = "PURE MEMORY score too low" + override val titleId = R.string.play_result_validator_PM_SCORE_TOO_LOW_title + + override val message = "A PURE MEMORY play result scores at least 10000000." + override val messageId = R.string.play_result_validator_PM_SCORE_TOO_LOW_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean = + playResult.clearType == ArcaeaPlayResultClearType.PURE_MEMORY && + playResult.score < 10_000_000 +} + data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultValidatorWarning { override val id = "SCORE_IMPOSSIBLE" @@ -148,14 +168,32 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - if (chartInfo?.notes == null || playResult.maxRecall == null) { - return false - } + val notes = chartInfo?.notes ?: return false + val maxRecall = playResult.maxRecall ?: return false - return playResult.maxRecall!! > chartInfo.notes!! + return maxRecall > notes - (playResult.lost ?: 0) } } +data object ArcaeaPlayResultValidatorNegativeCountWarning : ArcaeaPlayResultValidatorWarning { + override val id = "NEGATIVE_COUNT" + + override val title = "Negative count" + override val titleId = R.string.play_result_validator_NEGATIVE_COUNT_title + + override val message = "pure, far, lost and max recall cannot be negative." + override val messageId = R.string.play_result_validator_NEGATIVE_COUNT_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean = + (playResult.pure ?: 0) < 0 || + (playResult.far ?: 0) < 0 || + (playResult.lost ?: 0) < 0 || + (playResult.maxRecall ?: 0) < 0 +} + data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : ArcaeaPlayResultValidatorWarning { override val id = "FR_PM_MAX_RECALL_MISMATCH" @@ -243,6 +281,28 @@ data object ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning : } } +data object ArcaeaPlayResultValidatorPlainClearWithoutLostWarning : ArcaeaPlayResultValidatorWarning { + override val id = "PLAIN_CLEAR_WITHOUT_LOST" + + override val title = "Clear type & lost mismatch" + override val titleId = R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title + + override val message = "A Normal/Easy/Hard Clear play result should have at least one lost." + override val messageId = R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean = + playResult.clearType in + listOf( + ArcaeaPlayResultClearType.NORMAL_CLEAR, + ArcaeaPlayResultClearType.EASY_CLEAR, + ArcaeaPlayResultClearType.HARD_CLEAR, + ) && + playResult.lost == 0 +} + data object ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning : ArcaeaPlayResultValidatorWarning { override val id = "PURE_MEMORY_FAR_LOST_NOT_ZERO" @@ -265,6 +325,25 @@ data object ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning : } } +data object ArcaeaPlayResultValidatorAllPureNotPmWarning : ArcaeaPlayResultValidatorWarning { + override val id = "ALL_PURE_NOT_PM" + + override val title = "Should be PURE MEMORY" + override val titleId = R.string.play_result_validator_ALL_PURE_NOT_PM_title + + override val message = "A play result with 0 far and 0 lost should be a PURE MEMORY." + override val messageId = R.string.play_result_validator_ALL_PURE_NOT_PM_message + + override fun conditionsMet( + playResult: PlayResult, + chartInfo: ChartInfo?, + ): Boolean = + playResult.clearType != null && + playResult.clearType != ArcaeaPlayResultClearType.PURE_MEMORY && + playResult.far == 0 && + playResult.lost == 0 +} + data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : ArcaeaPlayResultValidatorWarning { override val id = "MODIFIER_CLEAR_TYPE_MISMATCH" @@ -278,9 +357,13 @@ data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : 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) + ): Boolean { + val modifier = playResult.modifier ?: return false + + return (playResult.clearType == ArcaeaPlayResultClearType.EASY_CLEAR && modifier != ArcaeaPlayResultModifier.EASY) || + (playResult.clearType == ArcaeaPlayResultClearType.HARD_CLEAR && modifier != ArcaeaPlayResultModifier.HARD) || + (playResult.clearType == ArcaeaPlayResultClearType.NORMAL_CLEAR && modifier != ArcaeaPlayResultModifier.NORMAL) + } } data object ArcaeaPlayResultValidatorClearTypeMissingWarning : diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b08c8739..34666672 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -431,11 +431,17 @@ 分数超出理论范围 分数为负或超过该谱面理论值 + PURE MEMORY 分数过低 + PURE MEMORY 结算的成绩分数应至少为 10000000 + Note 溢出 PURE、FAR、LOST 的和比谱面总物件数要大 MAX RECALL 溢出 - 最大连击数比谱面总物件数要大 + 最大连击数比谱面总物件数减去 LOST 数要大 + + 计数为负 + PURE、FAR、LOST 和 MAX RECALL 不能为负数 FR/PM MAX RECALL 有误 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 @@ -446,11 +452,17 @@ 真全想起来了? FULL RECALL 时,LOST 应为 0 + 通关标志与 LOST 不符 + Normal/Easy/Hard Clear 结算的成绩应至少有 1 个 LOST + 真纯净回忆? PURE MEMORY 时,FAR 和 LOST 应为 0 + 应为 PURE MEMORY + FAR 和 LOST 均为 0 的成绩应为 PURE MEMORY + Modifier 与 Clear Type 不匹配 - 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY CLEAR 同理 + 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY 和 NORMAL CLEAR 同理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d34a2ea5..05596be8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -474,11 +474,17 @@ CC BY-SA 3.0 Impossible score The score is below zero or above Max PURE MEMORY. + PURE MEMORY score too low + A PURE MEMORY play result scores at least 10000000. + 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. + The max recall is greater than the total notes of the chart minus the lost count. + + Negative counts + pure, far, lost and max recall cannot be negative. 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. @@ -489,11 +495,17 @@ CC BY-SA 3.0 FULL RECALL? The lost of a FULL RECALL play result should be zero. + Clear type & lost mismatch + A Normal/Easy/Hard Clear play result should have at least one lost. + PURE MEMORY? The far and lost of a PURE MEMORY play result should be zero. + Should be PURE MEMORY + A play result with 0 far and 0 lost should be a PURE MEMORY. + Modifier & Clear Type mismatch - A HARD CLEAR clear type should have the HARD modifier, and the same for EASY CLEAR. + A HARD CLEAR clear type should have the HARD modifier, and the same for EASY and NORMAL CLEAR. diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index 9302b780..67eebdf6 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -238,4 +238,318 @@ class ArcaeaPlayResultValidatorTest { ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultBeyondTheoretical, chartInfoNotesNull), ) } + + @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, + ) + + assertTrue( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet(playResult, null), + ) + assertTrue( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST), + null, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.PURE_MEMORY), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(far = 1), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(far = null), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + playResult.copy(lost = null), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + 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, + ) + + assertTrue( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet(playResult, null), + ) + assertTrue( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.EASY_CLEAR), + null, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.HARD_CLEAR), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + playResult.copy(lost = 1), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + playResult.copy(lost = null), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + 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, + ) + + assertTrue( + ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet(playResult, null), + ) + assertFalse( + ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + playResult.copy(score = 10_000_000), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + 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, + ) + + assertTrue( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + playResult.copy(pure = -1), + null, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + playResult.copy(far = -1), + null, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + playResult.copy(lost = -1), + null, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + playResult.copy(maxRecall = -1), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet(playResult, null), + ) + assertFalse( + ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + 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, + pure = 900, + far = 19, + lost = 1, + maxRecall = 920, + ) + + assertTrue( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(lost = 0), + chartInfo, + ), + ) + assertTrue( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(lost = null, maxRecall = 921), + chartInfo, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(lost = null), + chartInfo, + ), + ) + } + + @Test + fun modifierClearTypeMismatchNormalDirection() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + modifier = ArcaeaPlayResultModifier.EASY, + ) + + assertTrue( + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet(playResult, null), + ) + assertTrue( + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + playResult.copy(modifier = ArcaeaPlayResultModifier.HARD), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + playResult.copy(modifier = ArcaeaPlayResultModifier.NORMAL), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + playResult.copy(modifier = null), + null, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + 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, + ) + + assertFalse( + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet( + playResult, + chartInfo.copy(notes = -5), + ), + ) + } } 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..9299a65e 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,11 +5,17 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max +/** + * Calculates the theoretical score range with given parameters. + * Returns an empty range when [notes] is not positive. + */ fun calculateScoreRange( notes: Int, pure: Int, far: Int, ): IntRange { + if (notes <= 0) return IntRange.EMPTY + val singleNoteScore = 10000000.0 / notes val actualScore = floor(singleNoteScore * pure + singleNoteScore * 0.5 * far).toInt() return actualScore..actualScore + pure From 726a36a0a15615742ce8b9cf13c319fafb815598 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 00:00:56 +0800 Subject: [PATCH 03/20] add documentation --- README.md | 1 + docs/play-result-mechanisms.md | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 docs/play-result-mechanisms.md 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/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 等于物量。 From 9d42a155c3d143cadc11d58e6c473f3b278aab38 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 01:06:48 +0800 Subject: [PATCH 04/20] fix score range calculation double precision issue --- .../arcaeaoffline/core/calculators/Common.kt | 8 +++---- .../core/calculators/CommonCalculatorsTest.kt | 21 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt index 9299a65e..c0096c00 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 @@ -6,7 +6,8 @@ import kotlin.math.floor import kotlin.math.max /** - * Calculates the theoretical score range with given parameters. + * 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. */ fun calculateScoreRange( @@ -16,9 +17,8 @@ fun calculateScoreRange( ): IntRange { if (notes <= 0) return IntRange.EMPTY - val singleNoteScore = 10000000.0 / notes - val actualScore = floor(singleNoteScore * pure + singleNoteScore * 0.5 * far).toInt() - return actualScore..actualScore + pure + val base = 10_000_000L * (2L * pure + far) / (2L * notes) + return base.toInt()..(base + pure).toInt() } /** Bonus added to single-play potential by the B50 scoring. */ 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..79fdd2df 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,10 +9,23 @@ import kotlin.test.assertNull class CommonCalculatorsTest { @Test fun testScoreRange() { - assertEquals( - 10_000_000..10_002_221, - calculateScoreRange(2221, 2221, 0), - ) + // Counts such as 1046 and 1081 are where a floating-point bound falls one point short, + // 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 From 861ddf17cd44740b891d0b669319436d258b8b1e Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 01:22:29 +0800 Subject: [PATCH 05/20] fix shiny pure calculate error --- .../10.json | 2 +- .../11.json | 2 +- .../12.json | 2 +- .../13.json | 2 +- .../7.json | 2 +- .../8.json | 2 +- .../9.json | 2 +- .../core/database/entities/PlayResultCalculated.kt | 6 +++--- .../arcaeaoffline/core/database/migrations/Migration_6_7.kt | 2 +- .../arcaeaoffline/core/database/migrations/Migration_7_8.kt | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) 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/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 From 62c29489d7c5abb757e13c7ae404c357244103f4 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 02:09:03 +0800 Subject: [PATCH 06/20] sync fallback message --- .../helpers/ArcaeaPlayResultValidatorWarnings.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 52ffd617..2bdde30c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -161,7 +161,7 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult 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 message = "max recall > chart note count - lost" override val messageId = R.string.play_result_validator_MAX_RECALL_OVERFLOW_message override fun conditionsMet( @@ -230,7 +230,7 @@ data object ArcaeaPlayResultValidatorClearPflMismatchWarning : ArcaeaPlayResultV 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 message = "pure + far + lost != chart note count (except HARD TRACK LOST)" override val messageId = R.string.play_result_validator_CLEAR_PFL_MISMATCH_message override fun conditionsMet( @@ -351,7 +351,7 @@ data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : 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 message = "easy clear != easy || hard clear != hard || normal clear != normal" override val messageId = R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message override fun conditionsMet( From d14b33ea1372c4a38bcf14d61074106fe2357b5a Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 02:31:26 +0800 Subject: [PATCH 07/20] skip when ChartInfo notes invalid --- .../ArcaeaPlayResultValidatorWarnings.kt | 26 ++--- .../helpers/ArcaeaPlayResultValidatorTest.kt | 94 +++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 2bdde30c..9c0868bc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -9,13 +9,19 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +/** + * The chart's note count when it can validate a play result, or `null` when the chart data is + * missing or non-positive. + */ +private fun ChartInfo?.usableNotes(): Int? = this?.notes?.takeIf { it > 0 } + /** * A data problem detected in a [PlayResult]. * * Two null-value policies exist: * * * Contradiction warnings need both compared values. An unknown value (a null [PlayResult] - * field or missing [ChartInfo.notes]) makes the check pass silently. + * field or a missing or non-positive [ChartInfo.notes]) makes the check pass silently. * * Suspicion warnings, titled with a "?", verify the judgment counts implied by a claimed * clear type. Unknown counts cannot confirm the claim and also trigger the warning. */ @@ -80,9 +86,7 @@ data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultVa playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - val notes = chartInfo?.notes ?: return false - // A non-positive note count is corrupt chart data, not a play data problem. - if (notes <= 0) return false + val notes = chartInfo.usableNotes() ?: return false val pure = playResult.pure ?: return false val far = playResult.far ?: return false @@ -124,7 +128,7 @@ data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultVa ): Boolean { if (playResult.score < 0) return true - val notes = chartInfo?.notes ?: return false + val notes = chartInfo.usableNotes() ?: return false return playResult.score > 10_000_000 + notes } } @@ -142,16 +146,14 @@ data object ArcaeaPlayResultValidatorPflOverflowWarning : ArcaeaPlayResultValida playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - if (chartInfo?.notes == null) { - return false - } + val notes = chartInfo.usableNotes() ?: return false var sum = 0 sum += playResult.pure ?: 0 sum += playResult.far ?: 0 sum += playResult.lost ?: 0 - return sum > chartInfo.notes!! + return sum > notes } } @@ -168,7 +170,7 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - val notes = chartInfo?.notes ?: return false + val notes = chartInfo.usableNotes() ?: return false val maxRecall = playResult.maxRecall ?: return false return maxRecall > notes - (playResult.lost ?: 0) @@ -217,7 +219,7 @@ data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : return false } - val notes = chartInfo?.notes ?: return false + val notes = chartInfo.usableNotes() ?: return false val maxRecall = playResult.maxRecall ?: return false return maxRecall != notes @@ -237,7 +239,7 @@ data object ArcaeaPlayResultValidatorClearPflMismatchWarning : ArcaeaPlayResultV playResult: PlayResult, chartInfo: ChartInfo?, ): Boolean { - val notes = chartInfo?.notes ?: return false + val notes = chartInfo.usableNotes() ?: return false val clearType = playResult.clearType ?: return false // A HARD play can be interrupted before the chart ends, so a TRACK LOST result may have diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index 67eebdf6..4eb7f83a 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -552,4 +552,98 @@ class ArcaeaPlayResultValidatorTest { ), ) } + + @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) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, corrupt), + ) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResult, corrupt), + ) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet(playResult, corrupt), + ) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, corrupt), + ) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet(playResult, corrupt), + ) + assertFalse( + "notes=$notes", + ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(playResult, corrupt), + ) + } + + // A negative score stays impossible even when the note count is unusable. + assertTrue( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet( + 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, + ) + + assertTrue( + ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet( + playResult.copy(lost = 0), + chartInfo, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet( + playResult, + chartInfo.copy(notes = 0), + ), + ) + } } From 67e735e598609fc87cdb980e422ae8ebb1c806f5 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 03:37:26 +0800 Subject: [PATCH 08/20] isolate SCORE_OUT_OF_RANGE and SCORE_IMPOSSIBLE --- .../ArcaeaPlayResultValidatorWarnings.kt | 13 +++-- .../helpers/ArcaeaPlayResultValidatorTest.kt | 52 +++++++++++++++++++ .../core/calculators/CommonCalculatorsTest.kt | 4 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 9c0868bc..86957a4d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -10,11 +10,14 @@ import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult /** - * The chart's note count when it can validate a play result, or `null` when the chart data is - * missing or non-positive. + * 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. */ +private fun maxScore(notes: Int): Int = 10_000_000 + notes + /** * A data problem detected in a [PlayResult]. * @@ -88,6 +91,10 @@ data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultVa ): Boolean { val notes = chartInfo.usableNotes() ?: return false + // 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 false + val pure = playResult.pure ?: return false val far = playResult.far ?: return false @@ -129,7 +136,7 @@ data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultVa if (playResult.score < 0) return true val notes = chartInfo.usableNotes() ?: return false - return playResult.score > 10_000_000 + notes + return playResult.score > maxScore(notes) } } diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index 4eb7f83a..a25aaa0a 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.helpers +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -646,4 +647,55 @@ class ArcaeaPlayResultValidatorTest { ), ) } + + @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. + assertTrue( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, chartInfo), + ) + + val negative = playResult.copy(score = -1) + assertTrue( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(negative, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(negative, chartInfo), + ) + + // Below what 920 PURE judgments can score. + val inconsistent = playResult.copy(score = 9_000_000) + assertFalse( + ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(inconsistent, chartInfo), + ) + assertTrue( + ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(inconsistent, chartInfo), + ) + + // validate() reports the score problem once. + assertEquals( + listOf(ArcaeaPlayResultValidatorScoreImpossibleWarning), + ArcaeaPlayResultValidator.validate(playResult, chartInfo), + ) + } } 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 79fdd2df..178098fa 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,8 +9,8 @@ import kotlin.test.assertNull class CommonCalculatorsTest { @Test fun testScoreRange() { - // Counts such as 1046 and 1081 are where a floating-point bound falls one point short, - // so the sweep covers every note count up to 3000. + // 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), From 39a972b6489859523d225aaa12bd3dcda473c2dd Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 19:34:01 +0800 Subject: [PATCH 09/20] correct comments --- .../helpers/ArcaeaPlayResultValidatorWarnings.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 86957a4d..3fa85134 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -21,10 +21,12 @@ private fun maxScore(notes: Int): Int = 10_000_000 + notes /** * A data problem detected in a [PlayResult]. * - * Two null-value policies exist: + * Three null-value policies exist: * - * * Contradiction warnings need both compared values. An unknown value (a null [PlayResult] + * * 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 treat an unknown count as zero. A real value would only raise the sum or + * lower the bound, so the check can miss an overflow but never invent one. * * Suspicion warnings, titled with a "?", verify the judgment counts implied by a claimed * clear type. Unknown counts cannot confirm the claim and also trigger the warning. */ From 65153c9ccaf0e4eff8b8ee4edc45f8be215b2bcc Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 19:40:05 +0800 Subject: [PATCH 10/20] fix max recall overflow warning --- .../ArcaeaPlayResultValidatorWarnings.kt | 15 +++-- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../helpers/ArcaeaPlayResultValidatorTest.kt | 64 +++++++++++++++++-- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt index 3fa85134..f6b6f6cc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt @@ -25,8 +25,8 @@ private fun maxScore(notes: Int): Int = 10_000_000 + notes * * * 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 treat an unknown count as zero. A real value would only raise the sum or - * lower the bound, so the check can miss an overflow but never invent one. + * * 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, titled with a "?", verify the judgment counts implied by a claimed * clear type. Unknown counts cannot confirm the claim and also trigger the warning. */ @@ -172,7 +172,7 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult 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 - lost" + override val message = "max recall > non-LOST judgments" override val messageId = R.string.play_result_validator_MAX_RECALL_OVERFLOW_message override fun conditionsMet( @@ -182,7 +182,14 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult val notes = chartInfo.usableNotes() ?: return false val maxRecall = playResult.maxRecall ?: return false - return maxRecall > notes - (playResult.lost ?: 0) + 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 maxRecall > bound } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 34666672..e1beb208 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -438,7 +438,7 @@ PURE、FAR、LOST 的和比谱面总物件数要大 MAX RECALL 溢出 - 最大连击数比谱面总物件数减去 LOST 数要大 + 最大连击数大于非 LOST 判定的数量 计数为负 PURE、FAR、LOST 和 MAX RECALL 不能为负数 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05596be8..76e719cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -481,7 +481,7 @@ CC BY-SA 3.0 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 minus the lost count. + The max recall is greater than the number of judgments that are not LOST. Negative counts pure, far, lost and max recall cannot be negative. diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index a25aaa0a..4d1e4670 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -453,10 +453,8 @@ class ArcaeaPlayResultValidatorTest { songId = "test", ratingClass = ArcaeaRatingClass.FUTURE, score = 9_900_000, - pure = 900, - far = 19, - lost = 1, - maxRecall = 920, + lost = 5, + maxRecall = 916, ) assertTrue( @@ -464,7 +462,7 @@ class ArcaeaPlayResultValidatorTest { ) assertFalse( ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( - playResult.copy(lost = 0), + playResult.copy(maxRecall = 915), chartInfo, ), ) @@ -476,7 +474,61 @@ class ArcaeaPlayResultValidatorTest { ) assertFalse( ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( - playResult.copy(lost = null), + 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, + ) + + assertTrue( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), + ) + assertFalse( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(maxRecall = 820), + chartInfo, + ), + ) + + // An interrupted play: notes minus lost (915) is looser than the 820 judged notes. + assertTrue( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(lost = 5), + chartInfo, + ), + ) + + // PURE + FAR exceed the note count, so notes minus lost (910) is the tighter bound. + assertTrue( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 911), + chartInfo, + ), + ) + assertFalse( + ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 910), chartInfo, ), ) From d185d4bd1f6d294a6247f732be11e5f2dc75f4d4 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 19:46:40 +0800 Subject: [PATCH 11/20] add tests --- .../helpers/ArcaeaPlayResultValidatorTest.kt | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt index 4d1e4670..d0b7a068 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult @@ -750,4 +751,115 @@ class ArcaeaPlayResultValidatorTest { ArcaeaPlayResultValidator.validate(playResult, chartInfo), ) } + + @Test + fun warningRegistryIsComplete() { + val allWarnings = + setOf( + ArcaeaPlayResultValidatorScoreIsZeroWarning, + ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, + ArcaeaPlayResultValidatorAllPureNotPmWarning, + ArcaeaPlayResultValidatorScoreOutOfRangeWarning, + ArcaeaPlayResultValidatorScoreImpossibleWarning, + ArcaeaPlayResultValidatorPmScoreTooLowWarning, + ArcaeaPlayResultValidatorPflOverflowWarning, + ArcaeaPlayResultValidatorMaxRecallOverflowWarning, + ArcaeaPlayResultValidatorNegativeCountWarning, + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, + ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning, + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning, + ArcaeaPlayResultValidatorClearPflMismatchWarning, + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning, + ArcaeaPlayResultValidatorClearTypeMissingWarning, + ) + + // CLEAR_TYPE_MISSING is only added by the B50 overload, so the general registry leaves it + // out. + assertEquals( + allWarnings - ArcaeaPlayResultValidatorClearTypeMissingWarning, + ArcaeaPlayResultValidator.WARNINGS.toSet(), + ) + } + + @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( + ArcaeaPlayResultValidator + .validate(playResult, chartInfo, ArcaeaScoringMode.B50) + .contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + ) + assertFalse( + ArcaeaPlayResultValidator + .validate(playResult, chartInfo, ArcaeaScoringMode.B30_R10) + .contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + ) + assertFalse( + ArcaeaPlayResultValidator + .validate( + playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + chartInfo, + ArcaeaScoringMode.B50, + ).contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + ) + } + + @Test + fun clearTypeMissing() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + clearType = null, + ) + + assertTrue( + ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, null), + ) + assertFalse( + ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet( + playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), + null, + ), + ) + } + + @Test + fun scoreIsZero() { + val playResult = + PlayResult( + songId = "test", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 0, + ) + + assertTrue( + ArcaeaPlayResultValidatorScoreIsZeroWarning.conditionsMet(playResult, null), + ) + assertFalse( + ArcaeaPlayResultValidatorScoreIsZeroWarning.conditionsMet( + playResult.copy(score = 1), + null, + ), + ) + } } From 7486d2ff5e2a46e359661c941e2848067b2775d1 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 7 Nov 2026 22:38:31 +0800 Subject: [PATCH 12/20] move play results warnings and validator to `:shared` --- app/AGENTS.md | 2 +- .../jobs/OcrQueueProcessingJob.kt | 2 +- .../jobs/OcrQueueProcessingJobTaskExecutor.kt | 2 +- .../ocrfromshare/OcrFromShareActions.kt | 4 +- .../ocrfromshare/OcrFromShareOcrResult.kt | 2 +- .../ocrfromshare/OcrFromShareViewModel.kt | 2 +- .../ui/components/ArcaeaPlayResultCard.kt | 2 +- ...ArcaeaPlayResultValidatorWarningDetails.kt | 28 ++-- .../ui/components/SaveWithWarningsDialog.kt | 2 +- .../ArcaeaPlayResultValidatorWarningText.kt | 60 +++++++++ .../DatabaseAddPlayResultScreen.kt | 2 +- .../DatabaseAddPlayResultViewModel.kt | 4 +- .../DatabaseDeduplicatorGroupListItem.kt | 2 +- .../playresultlist/PlayResultListUiItem.kt | 2 +- .../ocr/queue/OcrQueueScreenViewModel.kt | 2 +- shared/AGENTS.md | 3 +- .../validation}/ArcaeaPlayResultValidator.kt | 2 +- .../ArcaeaPlayResultValidatorWarnings.kt | 120 +----------------- .../ArcaeaPlayResultValidatorTest.kt | 22 ++-- 19 files changed, 104 insertions(+), 161 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt rename {app/src/main/java/xyz/sevive/arcaeaoffline/helpers => shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation}/ArcaeaPlayResultValidator.kt (97%) rename {app/src/main/java/xyz/sevive/arcaeaoffline/helpers => shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation}/ArcaeaPlayResultValidatorWarnings.kt (64%) rename {app/src/test/java/xyz/sevive/arcaeaoffline/helpers => shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation}/ArcaeaPlayResultValidatorTest.kt (99%) diff --git a/app/AGENTS.md b/app/AGENTS.md index 225273ff..e2ad0c06 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -24,7 +24,7 @@ xyz.sevive.arcaeaoffline/ ├── 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/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 3bf7f28a..c49fc68c 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.ArcaeaPlayResultValidator 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 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..110962e6 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.ArcaeaPlayResultValidator 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 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..23c147d3 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.ArcaeaPlayResultValidatorScoreIsZeroWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme 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..3543d209 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.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog 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..5ee2d88b 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.ArcaeaPlayResultValidator 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 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..5d12d90f 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 @@ -57,7 +57,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.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt index d8ea025f..854ee4cd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt @@ -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,16 +34,18 @@ 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark 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.core.validation.ArcaeaPlayResultValidatorMaxRecallOverflowWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPflOverflowWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.ui.helpers.messageRes +import xyz.sevive.arcaeaoffline.ui.helpers.titleRes import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme import xyz.sevive.arcaeaoffline.ui.theme.spacing @@ -60,7 +61,6 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( label = "expandArrow", ) - val context = LocalContext.current val density = LocalDensity.current val padding = MaterialTheme.spacing.cardPadding @@ -70,12 +70,8 @@ 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 message = stringResource(warning.messageRes()) Column( Modifier @@ -96,7 +92,7 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( Column(Modifier.weight(1f)) { Text(title) - AnimatedVisibility(visible = showWarningId) { + AnimatedVisibility(visible = expanded) { Text( warning.id, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), @@ -105,7 +101,7 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( } } - if (!message.isNullOrBlank()) { + if (message.isNotBlank()) { Icon( Icons.Default.ExpandMore, contentDescription = null, @@ -115,7 +111,7 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( } AnimatedVisibility(visible = expanded) { - if (!message.isNullOrBlank()) { + if (message.isNotBlank()) { Text( message, Modifier 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..b7d3b11f 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,7 +5,7 @@ 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.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt new file mode 100644 index 00000000..d25e49a5 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt @@ -0,0 +1,60 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import androidx.annotation.StringRes +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorAllPureNotPmWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorClearPflMismatchWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorClearTypeMissingWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorMaxRecallOverflowWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorNegativeCountWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPflOverflowWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPlainClearWithoutLostWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPmScoreTooLowWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreImpossibleWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreIsZeroWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreOutOfRangeWarning +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning + +@StringRes +internal fun ArcaeaPlayResultValidatorWarning.titleRes(): Int = + when (this) { + ArcaeaPlayResultValidatorScoreIsZeroWarning -> R.string.play_result_validator_SCORE_IS_ZERO_title + ArcaeaPlayResultValidatorScoreOutOfRangeWarning -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_title + ArcaeaPlayResultValidatorPmScoreTooLowWarning -> R.string.play_result_validator_PM_SCORE_TOO_LOW_title + ArcaeaPlayResultValidatorScoreImpossibleWarning -> R.string.play_result_validator_SCORE_IMPOSSIBLE_title + ArcaeaPlayResultValidatorPflOverflowWarning -> R.string.play_result_validator_PFL_OVERFLOW_title + ArcaeaPlayResultValidatorMaxRecallOverflowWarning -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_title + ArcaeaPlayResultValidatorNegativeCountWarning -> R.string.play_result_validator_NEGATIVE_COUNT_title + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_title + ArcaeaPlayResultValidatorClearPflMismatchWarning -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_title + ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_title + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title + ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_title + ArcaeaPlayResultValidatorAllPureNotPmWarning -> R.string.play_result_validator_ALL_PURE_NOT_PM_title + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_title + ArcaeaPlayResultValidatorClearTypeMissingWarning -> R.string.play_result_validator_CLEAR_TYPE_MISSING_title + } + +@StringRes +internal fun ArcaeaPlayResultValidatorWarning.messageRes(): Int = + when (this) { + ArcaeaPlayResultValidatorScoreIsZeroWarning -> R.string.play_result_validator_SCORE_IS_ZERO_message + ArcaeaPlayResultValidatorScoreOutOfRangeWarning -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_message + ArcaeaPlayResultValidatorPmScoreTooLowWarning -> R.string.play_result_validator_PM_SCORE_TOO_LOW_message + ArcaeaPlayResultValidatorScoreImpossibleWarning -> R.string.play_result_validator_SCORE_IMPOSSIBLE_message + ArcaeaPlayResultValidatorPflOverflowWarning -> R.string.play_result_validator_PFL_OVERFLOW_message + ArcaeaPlayResultValidatorMaxRecallOverflowWarning -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_message + ArcaeaPlayResultValidatorNegativeCountWarning -> R.string.play_result_validator_NEGATIVE_COUNT_message + ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_message + ArcaeaPlayResultValidatorClearPflMismatchWarning -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_message + ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_message + ArcaeaPlayResultValidatorPlainClearWithoutLostWarning -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message + ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_message + ArcaeaPlayResultValidatorAllPureNotPmWarning -> R.string.play_result_validator_ALL_PURE_NOT_PM_message + ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message + ArcaeaPlayResultValidatorClearTypeMissingWarning -> R.string.play_result_validator_CLEAR_TYPE_MISSING_message + } 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..5c212376 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 @@ -47,7 +47,7 @@ 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.core.validation.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetailsDialog 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..285ad6b2 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.ArcaeaPlayResultValidator +import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import kotlin.time.Clock 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..c2e00f22 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.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog 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..b038bfac 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.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder 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..2ce852a7 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.ArcaeaPlayResultValidator 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 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 5cc732f5..9aae310e 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -15,7 +15,8 @@ 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) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt similarity index 97% rename from app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt index f4546708..9a6b06c3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidator.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.helpers +package xyz.sevive.arcaeaoffline.core.validation import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt similarity index 64% rename from app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt index f6b6f6cc..18ef4f01 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorWarnings.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt @@ -1,8 +1,5 @@ -package xyz.sevive.arcaeaoffline.helpers +package xyz.sevive.arcaeaoffline.core.validation -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 @@ -27,32 +24,12 @@ private fun maxScore(notes: Int): Int = 10_000_000 + notes * 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, titled with a "?", verify the judgment counts implied by a claimed - * clear type. Unknown counts cannot confirm the claim and also trigger the warning. + * * Suspicion warnings verify the judgment counts implied by a claimed clear type. Unknown + * counts cannot confirm the claim and also trigger the warning. */ 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. @@ -66,12 +43,6 @@ sealed interface ArcaeaPlayResultValidatorWarning { 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?, @@ -81,12 +52,6 @@ data object ArcaeaPlayResultValidatorScoreIsZeroWarning : ArcaeaPlayResultValida 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?, @@ -108,12 +73,6 @@ data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultVa data object ArcaeaPlayResultValidatorPmScoreTooLowWarning : ArcaeaPlayResultValidatorWarning { override val id = "PM_SCORE_TOO_LOW" - override val title = "PURE MEMORY score too low" - override val titleId = R.string.play_result_validator_PM_SCORE_TOO_LOW_title - - override val message = "A PURE MEMORY play result scores at least 10000000." - override val messageId = R.string.play_result_validator_PM_SCORE_TOO_LOW_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -125,12 +84,6 @@ data object ArcaeaPlayResultValidatorPmScoreTooLowWarning : ArcaeaPlayResultVali data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultValidatorWarning { override val id = "SCORE_IMPOSSIBLE" - override val title = "Impossible score" - override val titleId = R.string.play_result_validator_SCORE_IMPOSSIBLE_title - - override val message = "The score is below zero, or above the Max PURE MEMORY value." - override val messageId = R.string.play_result_validator_SCORE_IMPOSSIBLE_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -145,12 +98,6 @@ data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultVa 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?, @@ -169,12 +116,6 @@ data object ArcaeaPlayResultValidatorPflOverflowWarning : ArcaeaPlayResultValida 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 > non-LOST judgments" - override val messageId = R.string.play_result_validator_MAX_RECALL_OVERFLOW_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -196,12 +137,6 @@ data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResult data object ArcaeaPlayResultValidatorNegativeCountWarning : ArcaeaPlayResultValidatorWarning { override val id = "NEGATIVE_COUNT" - override val title = "Negative count" - override val titleId = R.string.play_result_validator_NEGATIVE_COUNT_title - - override val message = "pure, far, lost and max recall cannot be negative." - override val messageId = R.string.play_result_validator_NEGATIVE_COUNT_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -216,12 +151,6 @@ 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?, @@ -245,12 +174,6 @@ data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : 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 (except HARD TRACK LOST)" - override val messageId = R.string.play_result_validator_CLEAR_PFL_MISMATCH_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -281,12 +204,6 @@ 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?, @@ -302,12 +219,6 @@ data object ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning : data object ArcaeaPlayResultValidatorPlainClearWithoutLostWarning : ArcaeaPlayResultValidatorWarning { override val id = "PLAIN_CLEAR_WITHOUT_LOST" - override val title = "Clear type & lost mismatch" - override val titleId = R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title - - override val message = "A Normal/Easy/Hard Clear play result should have at least one lost." - override val messageId = R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -325,12 +236,6 @@ 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?, @@ -346,12 +251,6 @@ data object ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning : data object ArcaeaPlayResultValidatorAllPureNotPmWarning : ArcaeaPlayResultValidatorWarning { override val id = "ALL_PURE_NOT_PM" - override val title = "Should be PURE MEMORY" - override val titleId = R.string.play_result_validator_ALL_PURE_NOT_PM_title - - override val message = "A play result with 0 far and 0 lost should be a PURE MEMORY." - override val messageId = R.string.play_result_validator_ALL_PURE_NOT_PM_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -366,12 +265,6 @@ 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 || normal clear != normal" - override val messageId = R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message - override fun conditionsMet( playResult: PlayResult, chartInfo: ChartInfo?, @@ -388,13 +281,6 @@ 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?, diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt similarity index 99% rename from app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt rename to shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt index d0b7a068..1e0fe4cd 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPlayResultValidatorTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt @@ -1,15 +1,15 @@ -package xyz.sevive.arcaeaoffline.helpers +package xyz.sevive.arcaeaoffline.core.validation -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import 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.assertTrue class ArcaeaPlayResultValidatorTest { @Test @@ -631,28 +631,28 @@ class ArcaeaPlayResultValidatorTest { for (notes in listOf(0, -5)) { val corrupt = chartInfo.copy(notes = notes) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) assertFalse( - "notes=$notes", ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(playResult, corrupt), + "notes=$notes", ) } From b9e40b29deb8e3d66d322164054a6d41a9106c45 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 8 Nov 2026 01:30:28 +0800 Subject: [PATCH 13/20] refactor warnings to enum and detail model --- .../jobs/OcrQueueProcessingJob.kt | 4 +- .../jobs/OcrQueueProcessingJobTaskExecutor.kt | 4 +- .../ocrfromshare/OcrFromShareActions.kt | 8 +- .../ocrfromshare/OcrFromShareOcrResult.kt | 4 +- .../ocrfromshare/OcrFromShareViewModel.kt | 4 +- .../ui/components/ArcaeaPlayResultCard.kt | 8 +- ...Details.kt => PlayResultWarningDetails.kt} | 49 +- .../ui/components/SaveWithWarningsDialog.kt | 6 +- .../ArcaeaPlayResultValidatorWarningText.kt | 60 --- .../ui/helpers/PlayResultWarningText.kt | 45 ++ .../DatabaseAddPlayResultScreen.kt | 8 +- .../DatabaseAddPlayResultViewModel.kt | 8 +- .../DatabaseDeduplicatorGroupListItem.kt | 4 +- .../playresultlist/PlayResultListUiItem.kt | 4 +- .../ocr/queue/OcrQueueScreenViewModel.kt | 4 +- shared/build.gradle.kts | 2 + .../validation/ArcaeaPlayResultValidator.kt | 62 --- .../ArcaeaPlayResultValidatorWarnings.kt | 288 ----------- .../core/validation/PlayResultValidator.kt | 30 ++ .../core/validation/PlayResultWarning.kt | 329 ++++++++++++ .../validation/PlayResultWarningDetail.kt | 56 ++ ...atorTest.kt => PlayResultValidatorTest.kt} | 482 +++++++++++------- 22 files changed, 807 insertions(+), 662 deletions(-) rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/{ArcaeaPlayResultValidatorWarningDetails.kt => PlayResultWarningDetails.kt} (77%) delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt delete mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt delete mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidator.kt create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarningDetail.kt rename shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/{ArcaeaPlayResultValidatorTest.kt => PlayResultValidatorTest.kt} (61%) 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 c49fc68c..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,7 +25,7 @@ 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.ArcaeaPlayResultValidator +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 @@ -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 110962e6..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,7 +8,7 @@ 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.ArcaeaPlayResultValidator +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 @@ -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 23c147d3..e6ef3fbe 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.core.validation.ArcaeaPlayResultValidatorScoreIsZeroWarning -import xyz.sevive.arcaeaoffline.core.validation.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.SCORE_IS_ZERO)), 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 3543d209..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.core.validation.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/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 5ee2d88b..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,7 +42,7 @@ 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.ArcaeaPlayResultValidator +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 @@ -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/components/ArcaeaPlayResultCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultCard.kt index 5d12d90f..8b70c314 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 @@ -57,7 +57,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.core.validation.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -124,7 +124,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 +174,7 @@ fun ArcaeaPlayResultCard( var showWarningsDialog by rememberSaveable { mutableStateOf(false) } if (showWarningsDialog && warnings.isNotEmpty()) { - ArcaeaPlayResultValidatorWarningDetailsDialog( + PlayResultWarningDetailsDialog( onDismissRequest = { showWarningsDialog = false }, warnings = warnings, ) @@ -302,7 +302,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/ArcaeaPlayResultValidatorWarningDetails.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.kt similarity index 77% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.kt index 854ee4cd..e0250e40 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultValidatorWarningDetails.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.kt @@ -39,10 +39,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.sp -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorMaxRecallOverflowWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPflOverflowWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.helpers.messageRes import xyz.sevive.arcaeaoffline.ui.helpers.titleRes @@ -51,10 +49,11 @@ import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme import xyz.sevive.arcaeaoffline.ui.theme.spacing @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, @@ -94,7 +93,7 @@ private fun ArcaeaPlayResultValidatorWarningDetailItem( Text(title) AnimatedVisibility(visible = expanded) { Text( - warning.id, + warning.name, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), ) } @@ -124,22 +123,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( @@ -161,7 +160,7 @@ fun ArcaeaPlayResultValidatorWarningDetailsDialog( }, text = { LazyColumn { - item { ArcaeaPlayResultValidatorWarningDetails(warnings) } + item { PlayResultWarningDetails(warnings) } } }, confirmButton = { /* Nothing */ }, @@ -170,25 +169,25 @@ fun ArcaeaPlayResultValidatorWarningDetailsDialog( @PreviewLightDark @Composable -private fun ArcaeaPlayResultValidatorWarningDetailItemPreview() { +private fun PlayResultWarningDetailItemPreview() { ArcaeaOfflineTheme { Surface { - ArcaeaPlayResultValidatorWarningDetailItem(ArcaeaPlayResultValidatorPflOverflowWarning) + PlayResultWarningDetailItem(PlayResultFinding(PlayResultWarning.PFL_OVERFLOW)) } } } @PreviewLightDark @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), ), ) } @@ -197,16 +196,16 @@ private fun ArcaeaPlayResultValidatorWarningDetailsPreview() { @PreviewLightDark @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/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt index b7d3b11f..3c6f62c0 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,21 @@ 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.core.validation.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton /** 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/helpers/ArcaeaPlayResultValidatorWarningText.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt deleted file mode 100644 index d25e49a5..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaPlayResultValidatorWarningText.kt +++ /dev/null @@ -1,60 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.helpers - -import androidx.annotation.StringRes -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorAllPureNotPmWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorClearPflMismatchWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorClearTypeMissingWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorMaxRecallOverflowWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorNegativeCountWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPflOverflowWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPlainClearWithoutLostWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPmScoreTooLowWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreImpossibleWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreIsZeroWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorScoreOutOfRangeWarning -import xyz.sevive.arcaeaoffline.core.validation.ArcaeaPlayResultValidatorWarning - -@StringRes -internal fun ArcaeaPlayResultValidatorWarning.titleRes(): Int = - when (this) { - ArcaeaPlayResultValidatorScoreIsZeroWarning -> R.string.play_result_validator_SCORE_IS_ZERO_title - ArcaeaPlayResultValidatorScoreOutOfRangeWarning -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_title - ArcaeaPlayResultValidatorPmScoreTooLowWarning -> R.string.play_result_validator_PM_SCORE_TOO_LOW_title - ArcaeaPlayResultValidatorScoreImpossibleWarning -> R.string.play_result_validator_SCORE_IMPOSSIBLE_title - ArcaeaPlayResultValidatorPflOverflowWarning -> R.string.play_result_validator_PFL_OVERFLOW_title - ArcaeaPlayResultValidatorMaxRecallOverflowWarning -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_title - ArcaeaPlayResultValidatorNegativeCountWarning -> R.string.play_result_validator_NEGATIVE_COUNT_title - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_title - ArcaeaPlayResultValidatorClearPflMismatchWarning -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_title - ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_title - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_title - ArcaeaPlayResultValidatorAllPureNotPmWarning -> R.string.play_result_validator_ALL_PURE_NOT_PM_title - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_title - ArcaeaPlayResultValidatorClearTypeMissingWarning -> R.string.play_result_validator_CLEAR_TYPE_MISSING_title - } - -@StringRes -internal fun ArcaeaPlayResultValidatorWarning.messageRes(): Int = - when (this) { - ArcaeaPlayResultValidatorScoreIsZeroWarning -> R.string.play_result_validator_SCORE_IS_ZERO_message - ArcaeaPlayResultValidatorScoreOutOfRangeWarning -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_message - ArcaeaPlayResultValidatorPmScoreTooLowWarning -> R.string.play_result_validator_PM_SCORE_TOO_LOW_message - ArcaeaPlayResultValidatorScoreImpossibleWarning -> R.string.play_result_validator_SCORE_IMPOSSIBLE_message - ArcaeaPlayResultValidatorPflOverflowWarning -> R.string.play_result_validator_PFL_OVERFLOW_message - ArcaeaPlayResultValidatorMaxRecallOverflowWarning -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_message - ArcaeaPlayResultValidatorNegativeCountWarning -> R.string.play_result_validator_NEGATIVE_COUNT_message - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_message - ArcaeaPlayResultValidatorClearPflMismatchWarning -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_message - ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_message - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_message - ArcaeaPlayResultValidatorAllPureNotPmWarning -> R.string.play_result_validator_ALL_PURE_NOT_PM_message - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message - ArcaeaPlayResultValidatorClearTypeMissingWarning -> R.string.play_result_validator_CLEAR_TYPE_MISSING_message - } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt new file mode 100644 index 00000000..95a67ba8 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt @@ -0,0 +1,45 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import androidx.annotation.StringRes +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning + +@StringRes +internal fun PlayResultWarning.titleRes(): Int = + when (this) { + PlayResultWarning.SCORE_IS_ZERO -> R.string.play_result_validator_SCORE_IS_ZERO_title + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_title + PlayResultWarning.ALL_PURE_NOT_PM -> R.string.play_result_validator_ALL_PURE_NOT_PM_title + PlayResultWarning.SCORE_OUT_OF_RANGE -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_title + PlayResultWarning.SCORE_IMPOSSIBLE -> R.string.play_result_validator_SCORE_IMPOSSIBLE_title + PlayResultWarning.PM_SCORE_TOO_LOW -> R.string.play_result_validator_PM_SCORE_TOO_LOW_title + PlayResultWarning.PFL_OVERFLOW -> R.string.play_result_validator_PFL_OVERFLOW_title + PlayResultWarning.MAX_RECALL_OVERFLOW -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_title + PlayResultWarning.NEGATIVE_COUNT -> R.string.play_result_validator_NEGATIVE_COUNT_title + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_title + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_title + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title + PlayResultWarning.CLEAR_PFL_MISMATCH -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_title + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_title + PlayResultWarning.CLEAR_TYPE_MISSING -> R.string.play_result_validator_CLEAR_TYPE_MISSING_title + } + +@StringRes +internal fun PlayResultWarning.messageRes(): Int = + when (this) { + PlayResultWarning.SCORE_IS_ZERO -> R.string.play_result_validator_SCORE_IS_ZERO_message + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_message + PlayResultWarning.ALL_PURE_NOT_PM -> R.string.play_result_validator_ALL_PURE_NOT_PM_message + PlayResultWarning.SCORE_OUT_OF_RANGE -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_message + PlayResultWarning.SCORE_IMPOSSIBLE -> R.string.play_result_validator_SCORE_IMPOSSIBLE_message + PlayResultWarning.PM_SCORE_TOO_LOW -> R.string.play_result_validator_PM_SCORE_TOO_LOW_message + PlayResultWarning.PFL_OVERFLOW -> R.string.play_result_validator_PFL_OVERFLOW_message + PlayResultWarning.MAX_RECALL_OVERFLOW -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_message + PlayResultWarning.NEGATIVE_COUNT -> R.string.play_result_validator_NEGATIVE_COUNT_message + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_message + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_message + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message + PlayResultWarning.CLEAR_PFL_MISMATCH -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_message + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message + PlayResultWarning.CLEAR_TYPE_MISSING -> R.string.play_result_validator_CLEAR_TYPE_MISSING_message + } 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 5c212376..df6291ce 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 @@ -47,10 +47,9 @@ 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.core.validation.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha 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 @@ -60,6 +59,7 @@ import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorLostField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorMaxRecallField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorModifierField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorPureField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultWarningDetailsDialog import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader import xyz.sevive.arcaeaoffline.ui.components.TextItem @@ -72,7 +72,7 @@ import xyz.sevive.arcaeaoffline.ui.theme.spacing @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, ) 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 285ad6b2..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.core.validation.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.core.validation.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 c2e00f22..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.core.validation.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 b038bfac..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.core.validation.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/ocr/queue/OcrQueueScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt index 2ce852a7..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,7 +28,7 @@ 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.ArcaeaPlayResultValidator +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 @@ -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/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/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt deleted file mode 100644 index 9a6b06c3..00000000 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidator.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 - -object ArcaeaPlayResultValidator { - val WARNINGS = - listOf( - ArcaeaPlayResultValidatorScoreIsZeroWarning, - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, - ArcaeaPlayResultValidatorAllPureNotPmWarning, - ArcaeaPlayResultValidatorScoreOutOfRangeWarning, - ArcaeaPlayResultValidatorScoreImpossibleWarning, - ArcaeaPlayResultValidatorPmScoreTooLowWarning, - ArcaeaPlayResultValidatorPflOverflowWarning, - ArcaeaPlayResultValidatorMaxRecallOverflowWarning, - ArcaeaPlayResultValidatorNegativeCountWarning, - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, - ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning, - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning, - 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/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt deleted file mode 100644 index 18ef4f01..00000000 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorWarnings.kt +++ /dev/null @@ -1,288 +0,0 @@ -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.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. */ -private fun maxScore(notes: Int): Int = 10_000_000 + notes - -/** - * A data problem detected in a [PlayResult]. - * - * Three 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 and also trigger the warning. - */ -sealed interface ArcaeaPlayResultValidatorWarning { - val id: String - - /** - * 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 fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = playResult.score == 0 -} - -data object ArcaeaPlayResultValidatorScoreOutOfRangeWarning : ArcaeaPlayResultValidatorWarning { - override val id = "SCORE_OUT_OF_RANGE" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - val notes = chartInfo.usableNotes() ?: return false - - // 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 false - - val pure = playResult.pure ?: return false - val far = playResult.far ?: return false - - val scoreRange = calculateScoreRange(notes, pure, far) - return !scoreRange.contains(playResult.score) - } -} - -data object ArcaeaPlayResultValidatorPmScoreTooLowWarning : ArcaeaPlayResultValidatorWarning { - override val id = "PM_SCORE_TOO_LOW" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = - playResult.clearType == ArcaeaPlayResultClearType.PURE_MEMORY && - playResult.score < 10_000_000 -} - -data object ArcaeaPlayResultValidatorScoreImpossibleWarning : ArcaeaPlayResultValidatorWarning { - override val id = "SCORE_IMPOSSIBLE" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.score < 0) return true - - val notes = chartInfo.usableNotes() ?: return false - return playResult.score > maxScore(notes) - } -} - -data object ArcaeaPlayResultValidatorPflOverflowWarning : ArcaeaPlayResultValidatorWarning { - override val id = "PFL_OVERFLOW" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - val notes = chartInfo.usableNotes() ?: return false - - var sum = 0 - sum += playResult.pure ?: 0 - sum += playResult.far ?: 0 - sum += playResult.lost ?: 0 - - return sum > notes - } -} - -data object ArcaeaPlayResultValidatorMaxRecallOverflowWarning : ArcaeaPlayResultValidatorWarning { - override val id = "MAX_RECALL_OVERFLOW" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - val notes = chartInfo.usableNotes() ?: return false - val maxRecall = playResult.maxRecall ?: return false - - 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 maxRecall > bound - } -} - -data object ArcaeaPlayResultValidatorNegativeCountWarning : ArcaeaPlayResultValidatorWarning { - override val id = "NEGATIVE_COUNT" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = - (playResult.pure ?: 0) < 0 || - (playResult.far ?: 0) < 0 || - (playResult.lost ?: 0) < 0 || - (playResult.maxRecall ?: 0) < 0 -} - -data object ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "FR_PM_MAX_RECALL_MISMATCH" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.clearType !in - listOf( - ArcaeaPlayResultClearType.FULL_RECALL, - ArcaeaPlayResultClearType.PURE_MEMORY, - ) - ) { - return false - } - - val notes = chartInfo.usableNotes() ?: return false - val maxRecall = playResult.maxRecall ?: return false - - return maxRecall != notes - } -} - -data object ArcaeaPlayResultValidatorClearPflMismatchWarning : ArcaeaPlayResultValidatorWarning { - override val id = "CLEAR_PFL_MISMATCH" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - val notes = chartInfo.usableNotes() ?: return false - val clearType = playResult.clearType ?: return false - - // 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 false - } - - val pure = playResult.pure ?: return false - val far = playResult.far ?: return false - val lost = playResult.lost ?: return false - - return pure + far + lost != notes - } -} - -data object ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "FULL_RECALL_LOST_NOT_ZERO" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - if (playResult.clearType != ArcaeaPlayResultClearType.FULL_RECALL) { - return false - } - - return playResult.lost != 0 - } -} - -data object ArcaeaPlayResultValidatorPlainClearWithoutLostWarning : ArcaeaPlayResultValidatorWarning { - override val id = "PLAIN_CLEAR_WITHOUT_LOST" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = - playResult.clearType in - listOf( - ArcaeaPlayResultClearType.NORMAL_CLEAR, - ArcaeaPlayResultClearType.EASY_CLEAR, - ArcaeaPlayResultClearType.HARD_CLEAR, - ) && - playResult.lost == 0 -} - -data object ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "PURE_MEMORY_FAR_LOST_NOT_ZERO" - - 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 ArcaeaPlayResultValidatorAllPureNotPmWarning : ArcaeaPlayResultValidatorWarning { - override val id = "ALL_PURE_NOT_PM" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = - playResult.clearType != null && - playResult.clearType != ArcaeaPlayResultClearType.PURE_MEMORY && - playResult.far == 0 && - playResult.lost == 0 -} - -data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "MODIFIER_CLEAR_TYPE_MISMATCH" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean { - val modifier = playResult.modifier ?: return false - - return (playResult.clearType == ArcaeaPlayResultClearType.EASY_CLEAR && modifier != ArcaeaPlayResultModifier.EASY) || - (playResult.clearType == ArcaeaPlayResultClearType.HARD_CLEAR && modifier != ArcaeaPlayResultModifier.HARD) || - (playResult.clearType == ArcaeaPlayResultClearType.NORMAL_CLEAR && modifier != ArcaeaPlayResultModifier.NORMAL) - } -} - -data object ArcaeaPlayResultValidatorClearTypeMissingWarning : - ArcaeaPlayResultValidatorWarning { - override val id = "CLEAR_TYPE_MISSING" - - override fun conditionsMet( - playResult: PlayResult, - chartInfo: ChartInfo?, - ): Boolean = playResult.clearType == null -} 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..9f07f9a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt @@ -0,0 +1,329 @@ +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. */ +private fun maxScore(notes: Int): Int = 10_000_000 + notes + +/** 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]. + * + * Three 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. + */ +enum class PlayResultWarning( + val scope: PlayResultWarningScope = PlayResultWarningScope.ALWAYS, +) { + SCORE_IS_ZERO { + override fun check( + playResult: PlayResult, + chartInfo: ChartInfo?, + ) = if (playResult.score == 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/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt similarity index 61% rename from shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt rename to shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt index 1e0fe4cd..bda9dab7 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/ArcaeaPlayResultValidatorTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt @@ -1,5 +1,6 @@ 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 @@ -9,9 +10,11 @@ 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 ArcaeaPlayResultValidatorTest { +class PlayResultValidatorTest { @Test fun maxRecallOverflow() { val chartInfo = @@ -38,8 +41,8 @@ class ArcaeaPlayResultValidatorTest { comment = null, ) - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), ) } @@ -91,20 +94,20 @@ class ArcaeaPlayResultValidatorTest { comment = null, ) - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( playResultHasFar, null, ), ) - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( playResultHasLost, null, ), ) - assertTrue( - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning.conditionsMet( + assertNotNull( + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO.check( playResultHasNothing, null, ), @@ -135,26 +138,26 @@ class ArcaeaPlayResultValidatorTest { val playResultMismatched = playResultMatches.copy(maxRecall = 900) val playResultNoMaxRecall = playResultMatches.copy(maxRecall = null) - assertFalse( - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( playResultMismatched, null, ), ) - assertFalse( - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( playResultNoMaxRecall, chartInfo, ), ) - assertTrue( - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + assertNotNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( playResultMismatched, chartInfo, ), ) - assertFalse( - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check( playResultMatches, chartInfo, ), @@ -183,20 +186,20 @@ class ArcaeaPlayResultValidatorTest { ) val trackLost = playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST) - assertTrue( - ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(trackLost, chartInfo), + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(trackLost, chartInfo), ) - assertTrue( - ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet( + assertNotNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check( trackLost.copy(modifier = ArcaeaPlayResultModifier.NORMAL), chartInfo, ), ) - assertFalse( - ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check( trackLost.copy(modifier = ArcaeaPlayResultModifier.HARD), chartInfo, ), @@ -224,20 +227,20 @@ class ArcaeaPlayResultValidatorTest { val playResultTheoretical = playResultNegative.copy(score = 10_000_920) val playResultBeyondTheoretical = playResultNegative.copy(score = 10_000_921) - assertTrue( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultNegative, null), + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultNegative, null), ) - assertFalse( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultZero, null), + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultZero, null), ) - assertFalse( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultTheoretical, chartInfo), + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultTheoretical, chartInfo), ) - assertTrue( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultBeyondTheoretical, chartInfo), + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultBeyondTheoretical, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResultBeyondTheoretical, chartInfoNotesNull), + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResultBeyondTheoretical, chartInfoNotesNull), ) } @@ -254,47 +257,47 @@ class ArcaeaPlayResultValidatorTest { clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, ) - assertTrue( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check(playResult, null), ) - assertTrue( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST), null, ), ) - assertTrue( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNotNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(clearType = ArcaeaPlayResultClearType.PURE_MEMORY), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(far = 1), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(far = null), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(lost = null), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorAllPureNotPmWarning.conditionsMet( + assertNull( + PlayResultWarning.ALL_PURE_NOT_PM.check( playResult.copy(clearType = null), null, ), @@ -314,41 +317,41 @@ class ArcaeaPlayResultValidatorTest { clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, ) - assertTrue( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check(playResult, null), ) - assertTrue( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(clearType = ArcaeaPlayResultClearType.EASY_CLEAR), null, ), ) - assertTrue( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNotNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(clearType = ArcaeaPlayResultClearType.HARD_CLEAR), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(lost = 1), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(lost = null), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning.conditionsMet( + assertNull( + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST.check( playResult.copy(clearType = ArcaeaPlayResultClearType.TRACK_LOST), null, ), @@ -365,23 +368,23 @@ class ArcaeaPlayResultValidatorTest { clearType = ArcaeaPlayResultClearType.PURE_MEMORY, ) - assertTrue( - ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check(playResult, null), ) - assertFalse( - ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( playResult.copy(score = 10_000_000), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( playResult.copy(clearType = ArcaeaPlayResultClearType.FULL_RECALL), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorPmScoreTooLowWarning.conditionsMet( + assertNull( + PlayResultWarning.PM_SCORE_TOO_LOW.check( playResult.copy(clearType = null), null, ), @@ -401,35 +404,35 @@ class ArcaeaPlayResultValidatorTest { maxRecall = 920, ) - assertTrue( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( playResult.copy(pure = -1), null, ), ) - assertTrue( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( playResult.copy(far = -1), null, ), ) - assertTrue( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( playResult.copy(lost = -1), null, ), ) - assertTrue( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + assertNotNull( + PlayResultWarning.NEGATIVE_COUNT.check( playResult.copy(maxRecall = -1), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet(playResult, null), + assertNull( + PlayResultWarning.NEGATIVE_COUNT.check(playResult, null), ) - assertFalse( - ArcaeaPlayResultValidatorNegativeCountWarning.conditionsMet( + assertNull( + PlayResultWarning.NEGATIVE_COUNT.check( PlayResult( songId = "test", ratingClass = ArcaeaRatingClass.FUTURE, @@ -458,23 +461,23 @@ class ArcaeaPlayResultValidatorTest { maxRecall = 916, ) - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( playResult.copy(maxRecall = 915), chartInfo, ), ) - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( playResult.copy(lost = null, maxRecall = 921), chartInfo, ), ) - assertFalse( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( playResult.copy(lost = null, maxRecall = 920), chartInfo, ), @@ -502,33 +505,33 @@ class ArcaeaPlayResultValidatorTest { maxRecall = 821, ) - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + 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. - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + 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. - assertTrue( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + assertNotNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 911), chartInfo, ), ) - assertFalse( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet( + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check( playResult.copy(pure = 900, far = 30, lost = 10, maxRecall = 910), chartInfo, ), @@ -546,29 +549,29 @@ class ArcaeaPlayResultValidatorTest { modifier = ArcaeaPlayResultModifier.EASY, ) - assertTrue( - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check(playResult, null), ) - assertTrue( - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + assertNotNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( playResult.copy(modifier = ArcaeaPlayResultModifier.HARD), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( playResult.copy(modifier = ArcaeaPlayResultModifier.NORMAL), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( playResult.copy(modifier = null), null, ), ) - assertFalse( - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning.conditionsMet( + assertNull( + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH.check( playResult.copy( clearType = ArcaeaPlayResultClearType.EASY_CLEAR, modifier = ArcaeaPlayResultModifier.EASY, @@ -596,11 +599,11 @@ class ArcaeaPlayResultValidatorTest { far = 20, ) - assertFalse( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, chartInfo), + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet( + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check( playResult, chartInfo.copy(notes = -5), ), @@ -630,35 +633,35 @@ class ArcaeaPlayResultValidatorTest { for (notes in listOf(0, -5)) { val corrupt = chartInfo.copy(notes = notes) - assertFalse( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, corrupt), "notes=$notes", ) - assertFalse( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult, corrupt), "notes=$notes", ) - assertFalse( - ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.PFL_OVERFLOW.check(playResult, corrupt), "notes=$notes", ) - assertFalse( - ArcaeaPlayResultValidatorMaxRecallOverflowWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.MAX_RECALL_OVERFLOW.check(playResult, corrupt), "notes=$notes", ) - assertFalse( - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH.check(playResult, corrupt), "notes=$notes", ) - assertFalse( - ArcaeaPlayResultValidatorClearPflMismatchWarning.conditionsMet(playResult, corrupt), + assertNull( + PlayResultWarning.CLEAR_PFL_MISMATCH.check(playResult, corrupt), "notes=$notes", ) } // A negative score stays impossible even when the note count is unusable. - assertTrue( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet( + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check( playResult.copy(score = -1), chartInfo.copy(notes = 0), ), @@ -684,17 +687,17 @@ class ArcaeaPlayResultValidatorTest { lost = 1, ) - assertTrue( - ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.PFL_OVERFLOW.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet( + assertNull( + PlayResultWarning.PFL_OVERFLOW.check( playResult.copy(lost = 0), chartInfo, ), ) - assertFalse( - ArcaeaPlayResultValidatorPflOverflowWarning.conditionsMet( + assertNull( + PlayResultWarning.PFL_OVERFLOW.check( playResult, chartInfo.copy(notes = 0), ), @@ -721,63 +724,34 @@ class ArcaeaPlayResultValidatorTest { ) // One point above the theoretical value. - assertTrue( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(playResult, chartInfo), + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(playResult, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(playResult, chartInfo), + assertNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(playResult, chartInfo), ) val negative = playResult.copy(score = -1) - assertTrue( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(negative, chartInfo), + assertNotNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(negative, chartInfo), ) - assertFalse( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(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) - assertFalse( - ArcaeaPlayResultValidatorScoreImpossibleWarning.conditionsMet(inconsistent, chartInfo), + assertNull( + PlayResultWarning.SCORE_IMPOSSIBLE.check(inconsistent, chartInfo), ) - assertTrue( - ArcaeaPlayResultValidatorScoreOutOfRangeWarning.conditionsMet(inconsistent, chartInfo), + assertNotNull( + PlayResultWarning.SCORE_OUT_OF_RANGE.check(inconsistent, chartInfo), ) // validate() reports the score problem once. assertEquals( - listOf(ArcaeaPlayResultValidatorScoreImpossibleWarning), - ArcaeaPlayResultValidator.validate(playResult, chartInfo), - ) - } - - @Test - fun warningRegistryIsComplete() { - val allWarnings = - setOf( - ArcaeaPlayResultValidatorScoreIsZeroWarning, - ArcaeaPlayResultValidatorPureMemoryFarLostNotZeroWarning, - ArcaeaPlayResultValidatorAllPureNotPmWarning, - ArcaeaPlayResultValidatorScoreOutOfRangeWarning, - ArcaeaPlayResultValidatorScoreImpossibleWarning, - ArcaeaPlayResultValidatorPmScoreTooLowWarning, - ArcaeaPlayResultValidatorPflOverflowWarning, - ArcaeaPlayResultValidatorMaxRecallOverflowWarning, - ArcaeaPlayResultValidatorNegativeCountWarning, - ArcaeaPlayResultValidatorFrPmMaxRecallMismatchWarning, - ArcaeaPlayResultValidatorFullRecallLostNotZeroWarning, - ArcaeaPlayResultValidatorPlainClearWithoutLostWarning, - ArcaeaPlayResultValidatorClearPflMismatchWarning, - ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning, - ArcaeaPlayResultValidatorClearTypeMissingWarning, - ) - - // CLEAR_TYPE_MISSING is only added by the B50 overload, so the general registry leaves it - // out. - assertEquals( - allWarnings - ArcaeaPlayResultValidatorClearTypeMissingWarning, - ArcaeaPlayResultValidator.WARNINGS.toSet(), + listOf(PlayResultWarning.SCORE_IMPOSSIBLE), + PlayResultValidator.validate(playResult, chartInfo).map { it.warning }, ) } @@ -803,22 +777,22 @@ class ArcaeaPlayResultValidatorTest { ) assertTrue( - ArcaeaPlayResultValidator + PlayResultValidator .validate(playResult, chartInfo, ArcaeaScoringMode.B50) - .contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + .any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, ) assertFalse( - ArcaeaPlayResultValidator + PlayResultValidator .validate(playResult, chartInfo, ArcaeaScoringMode.B30_R10) - .contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + .any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, ) assertFalse( - ArcaeaPlayResultValidator + PlayResultValidator .validate( playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), chartInfo, ArcaeaScoringMode.B50, - ).contains(ArcaeaPlayResultValidatorClearTypeMissingWarning), + ).any { it.warning == PlayResultWarning.CLEAR_TYPE_MISSING }, ) } @@ -832,11 +806,11 @@ class ArcaeaPlayResultValidatorTest { clearType = null, ) - assertTrue( - ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.CLEAR_TYPE_MISSING.check(playResult, null), ) - assertFalse( - ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet( + assertNull( + PlayResultWarning.CLEAR_TYPE_MISSING.check( playResult.copy(clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR), null, ), @@ -852,14 +826,134 @@ class ArcaeaPlayResultValidatorTest { score = 0, ) - assertTrue( - ArcaeaPlayResultValidatorScoreIsZeroWarning.conditionsMet(playResult, null), + assertNotNull( + PlayResultWarning.SCORE_IS_ZERO.check(playResult, null), ) - assertFalse( - ArcaeaPlayResultValidatorScoreIsZeroWarning.conditionsMet( + assertNull( + PlayResultWarning.SCORE_IS_ZERO.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, + ), + ) + } } From a7803403bf96e484b01082f91afa62146f56455b Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 8 Nov 2026 01:58:16 +0800 Subject: [PATCH 14/20] move text and basic UI components --- .../sevive/arcaeaoffline/helpers/Compose.kt | 3 - .../ocrfromshare/OcrFromShareScreen.kt | 2 +- .../ui/common/PermissionRequiredDialog.kt | 2 +- .../ui/components/ArcaeaPlayResultCard.kt | 10 +-- .../ui/components/ImportLogSheet.kt | 2 +- .../ui/components/SaveWithWarningsDialog.kt | 1 + .../ui/components/SettingsItems.kt | 2 +- .../ui/helpers/PlayResultWarningText.kt | 45 ----------- .../DatabaseAddPlayResultScreen.kt | 8 +- .../ocr/performance/OcrPerformanceScreen.kt | 2 +- .../screens/ocr/queue/OcrQueueCategoryItem.kt | 2 +- .../overview/ArcaeaPlayResultSummaryCard.kt | 2 +- .../ui/screens/resources/ResourcesScreen.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 50 ------------ app/src/main/res/values/strings.xml | 51 ------------ .../values-zh-rCN/strings.xml | 49 ++++++++++++ .../composeResources/values/strings.xml | 50 ++++++++++++ .../playresult}/PlayResultWarningDetails.kt | 16 ++-- .../playresult/PlayResultWarningText.kt | 79 +++++++++++++++++++ .../sevive/arcaeaoffline/ui/utils/Alpha.kt | 8 ++ 20 files changed, 209 insertions(+), 177 deletions(-) delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt rename {app/src/main/java/xyz/sevive/arcaeaoffline/ui/components => shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult}/PlayResultWarningDetails.kt (94%) create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningText.kt create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt 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..df18bae0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt @@ -9,12 +9,9 @@ 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/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/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 8b70c314..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 @@ -59,6 +58,8 @@ import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult 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 @@ -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), ) } } 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 3c6f62c0..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 @@ -8,6 +8,7 @@ import xyz.sevive.arcaeaoffline.R 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 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..29c2b746 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 @@ -31,10 +31,10 @@ 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.secondaryItemAlpha private val HorizontalPadding @Composable diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt deleted file mode 100644 index 95a67ba8..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/PlayResultWarningText.kt +++ /dev/null @@ -1,45 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.helpers - -import androidx.annotation.StringRes -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning - -@StringRes -internal fun PlayResultWarning.titleRes(): Int = - when (this) { - PlayResultWarning.SCORE_IS_ZERO -> R.string.play_result_validator_SCORE_IS_ZERO_title - PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_title - PlayResultWarning.ALL_PURE_NOT_PM -> R.string.play_result_validator_ALL_PURE_NOT_PM_title - PlayResultWarning.SCORE_OUT_OF_RANGE -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_title - PlayResultWarning.SCORE_IMPOSSIBLE -> R.string.play_result_validator_SCORE_IMPOSSIBLE_title - PlayResultWarning.PM_SCORE_TOO_LOW -> R.string.play_result_validator_PM_SCORE_TOO_LOW_title - PlayResultWarning.PFL_OVERFLOW -> R.string.play_result_validator_PFL_OVERFLOW_title - PlayResultWarning.MAX_RECALL_OVERFLOW -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_title - PlayResultWarning.NEGATIVE_COUNT -> R.string.play_result_validator_NEGATIVE_COUNT_title - PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_title - PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_title - PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_title - PlayResultWarning.CLEAR_PFL_MISMATCH -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_title - PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_title - PlayResultWarning.CLEAR_TYPE_MISSING -> R.string.play_result_validator_CLEAR_TYPE_MISSING_title - } - -@StringRes -internal fun PlayResultWarning.messageRes(): Int = - when (this) { - PlayResultWarning.SCORE_IS_ZERO -> R.string.play_result_validator_SCORE_IS_ZERO_message - PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> R.string.play_result_validator_PURE_MEMORY_FAR_LOST_NOT_ZERO_message - PlayResultWarning.ALL_PURE_NOT_PM -> R.string.play_result_validator_ALL_PURE_NOT_PM_message - PlayResultWarning.SCORE_OUT_OF_RANGE -> R.string.play_result_validator_SCORE_OUT_OF_RANGE_message - PlayResultWarning.SCORE_IMPOSSIBLE -> R.string.play_result_validator_SCORE_IMPOSSIBLE_message - PlayResultWarning.PM_SCORE_TOO_LOW -> R.string.play_result_validator_PM_SCORE_TOO_LOW_message - PlayResultWarning.PFL_OVERFLOW -> R.string.play_result_validator_PFL_OVERFLOW_message - PlayResultWarning.MAX_RECALL_OVERFLOW -> R.string.play_result_validator_MAX_RECALL_OVERFLOW_message - PlayResultWarning.NEGATIVE_COUNT -> R.string.play_result_validator_NEGATIVE_COUNT_message - PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> R.string.play_result_validator_FR_PM_MAX_RECALL_MISMATCH_message - PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> R.string.play_result_validator_FULL_RECALL_LOST_NOT_ZERO_message - PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> R.string.play_result_validator_PLAIN_CLEAR_WITHOUT_LOST_message - PlayResultWarning.CLEAR_PFL_MISMATCH -> R.string.play_result_validator_CLEAR_PFL_MISMATCH_message - PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> R.string.play_result_validator_MODIFIER_CLEAR_TYPE_MISMATCH_message - PlayResultWarning.CLEAR_TYPE_MISSING -> R.string.play_result_validator_CLEAR_TYPE_MISSING_message - } 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 df6291ce..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 @@ -48,7 +47,6 @@ import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.entities.playRating import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.LabeledCheckbox import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorClearTypeField @@ -59,16 +57,18 @@ import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorLostField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorMaxRecallField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorModifierField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorPureField -import xyz.sevive.arcaeaoffline.ui.components.PlayResultWarningDetailsDialog import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog 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( @@ -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/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/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 e1beb208..071b081e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -415,56 +415,6 @@ 对分享图片 OCR - - - %d 条警告 - - - 分数为零 - 分数为 0。 - 缺少通关状态 - B50 规则下,缺少通关状态的成绩按 TRACK LOST 计,无法获得通关奖励。 - - 分数不在范围内 - 所给分数不在由 PURE、FAR 计算而得的分数范围内 - - 分数超出理论范围 - 分数为负或超过该谱面理论值 - - PURE MEMORY 分数过低 - PURE MEMORY 结算的成绩分数应至少为 10000000 - - Note 溢出 - PURE、FAR、LOST 的和比谱面总物件数要大 - - MAX RECALL 溢出 - 最大连击数大于非 LOST 判定的数量 - - 计数为负 - PURE、FAR、LOST 和 MAX RECALL 不能为负数 - - FR/PM MAX RECALL 有误 - 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 - - Note 有误 - 非 HARD LOST 结算的游玩结果,其 PURE、FAR、LOST 的和应与谱面总物件数相等 - - 真全想起来了? - FULL RECALL 时,LOST 应为 0 - - 通关标志与 LOST 不符 - Normal/Easy/Hard Clear 结算的成绩应至少有 1 个 LOST - - 真纯净回忆? - PURE MEMORY 时,FAR 和 LOST 应为 0 - - 应为 PURE MEMORY - FAR 和 LOST 均为 0 的成绩应为 PURE MEMORY - - Modifier 与 Clear Type 不匹配 - 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY 和 NORMAL CLEAR 同理 - - 不稳定版本 应用随时可能崩溃,并且数据可能丢失甚至无法恢复。\n请注意备份! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76e719cf..f1d75c76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -457,57 +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. - - Impossible score - The score is below zero or above Max PURE MEMORY. - - PURE MEMORY score too low - A PURE MEMORY play result scores at least 10000000. - - 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 number of judgments that are not LOST. - - Negative counts - pure, far, lost and max recall cannot be negative. - - 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. - - Clear type & lost mismatch - A Normal/Easy/Hard Clear play result should have at least one lost. - - PURE MEMORY? - The far and lost of a PURE MEMORY play result should be zero. - - Should be PURE MEMORY - A play result with 0 far and 0 lost should be a PURE MEMORY. - - Modifier & Clear Type mismatch - A HARD CLEAR clear type should have the HARD modifier, and the same for EASY and NORMAL 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/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index e179332d..23393ed6 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -9,4 +9,53 @@ 分数 定数 单曲潜力值 + + + + %1$d 条警告 + + + 分数为零 + 分数为 0。 + 缺少通关状态 + B50 规则下,缺少通关状态的成绩按 TRACK LOST 计,无法获得通关奖励。 + + 分数不在范围内 + 所给分数不在由 PURE、FAR 计算而得的分数范围内 + + 分数超出理论范围 + 分数为负或超过该谱面理论值 + + PURE MEMORY 分数过低 + PURE MEMORY 结算的成绩分数应至少为 10000000 + + Note 溢出 + PURE、FAR、LOST 的和比谱面总物件数要大 + + MAX RECALL 溢出 + 最大连击数大于非 LOST 判定的数量 + + 计数为负 + PURE、FAR、LOST 和 MAX RECALL 不能为负数 + + FR/PM MAX RECALL 有误 + 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 + + Note 有误 + 非 HARD LOST 结算的游玩结果,其 PURE、FAR、LOST 的和应与谱面总物件数相等 + + 真全想起来了? + FULL RECALL 时,LOST 应为 0 + + 通关标志与 LOST 不符 + Normal/Easy/Hard Clear 结算的成绩应至少有 1 个 LOST + + 真纯净回忆? + PURE MEMORY 时,FAR 和 LOST 应为 0 + + 应为 PURE MEMORY + FAR 和 LOST 均为 0 的成绩应为 PURE MEMORY + + Modifier 与 Clear Type 不匹配 + 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY 和 NORMAL CLEAR 同理 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index c7c327a3..90060163 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -9,4 +9,54 @@ Score Constant Play Rating + + + + %1$d warning + %1$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. + + Impossible score + The score is below zero or above Max PURE MEMORY. + + PURE MEMORY score too low + A PURE MEMORY play result scores at least 10000000. + + 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 number of judgments that are not LOST. + + Negative counts + pure, far, lost and max recall cannot be negative. + + 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. + + Clear type & lost mismatch + A Normal/Easy/Hard Clear play result should have at least one lost. + + PURE MEMORY? + The far and lost of a PURE MEMORY play result should be zero. + + Should be PURE MEMORY + A play result with 0 far and 0 lost should be a PURE MEMORY. + + Modifier & Clear Type mismatch + A HARD CLEAR clear type should have the HARD modifier, and the same for EASY and NORMAL CLEAR. diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt similarity index 94% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt index e0250e40..9e72db8b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultWarningDetails.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 @@ -35,18 +35,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource 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 org.jetbrains.compose.resources.stringResource import xyz.sevive.arcaeaoffline.core.validation.PlayResultFinding import xyz.sevive.arcaeaoffline.core.validation.PlayResultWarning -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha -import xyz.sevive.arcaeaoffline.ui.helpers.messageRes -import xyz.sevive.arcaeaoffline.ui.helpers.titleRes 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 PlayResultWarningDetailItem( @@ -167,7 +165,7 @@ fun PlayResultWarningDetailsDialog( ) } -@PreviewLightDark +@Preview @Composable private fun PlayResultWarningDetailItemPreview() { ArcaeaOfflineTheme { @@ -177,7 +175,7 @@ private fun PlayResultWarningDetailItemPreview() { } } -@PreviewLightDark +@Preview @Composable private fun PlayResultWarningDetailsPreview() { ArcaeaOfflineTheme { @@ -194,7 +192,7 @@ private fun PlayResultWarningDetailsPreview() { } } -@PreviewLightDark +@Preview @Composable private fun PlayResultWarningDetailsDialogPreview() { ArcaeaOfflineTheme { 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..961b9b3b --- /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_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_all_pure_not_pm_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_pfl_mismatch_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_pfl_mismatch_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_type_missing_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_clear_type_missing_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_fr_pm_max_recall_mismatch_message +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_message +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_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_max_recall_overflow_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_modifier_clear_type_mismatch_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_modifier_clear_type_mismatch_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_negative_count_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_negative_count_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_pfl_overflow_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_plain_clear_without_lost_message +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_message +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_message +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_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_impossible_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_is_zero_message +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_is_zero_title +import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_out_of_range_message +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.SCORE_IS_ZERO -> Res.string.play_result_validator_score_is_zero_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.messageRes(): StringResource = + when (this) { + PlayResultWarning.SCORE_IS_ZERO -> Res.string.play_result_validator_score_is_zero_message + PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> Res.string.play_result_validator_pure_memory_far_lost_not_zero_message + PlayResultWarning.ALL_PURE_NOT_PM -> Res.string.play_result_validator_all_pure_not_pm_message + PlayResultWarning.SCORE_OUT_OF_RANGE -> Res.string.play_result_validator_score_out_of_range_message + PlayResultWarning.SCORE_IMPOSSIBLE -> Res.string.play_result_validator_score_impossible_message + PlayResultWarning.PM_SCORE_TOO_LOW -> Res.string.play_result_validator_pm_score_too_low_message + PlayResultWarning.PFL_OVERFLOW -> Res.string.play_result_validator_pfl_overflow_message + PlayResultWarning.MAX_RECALL_OVERFLOW -> Res.string.play_result_validator_max_recall_overflow_message + PlayResultWarning.NEGATIVE_COUNT -> Res.string.play_result_validator_negative_count_message + PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> Res.string.play_result_validator_fr_pm_max_recall_mismatch_message + PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> Res.string.play_result_validator_full_recall_lost_not_zero_message + PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> Res.string.play_result_validator_plain_clear_without_lost_message + PlayResultWarning.CLEAR_PFL_MISMATCH -> Res.string.play_result_validator_clear_pfl_mismatch_message + PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> Res.string.play_result_validator_modifier_clear_type_mismatch_message + PlayResultWarning.CLEAR_TYPE_MISSING -> Res.string.play_result_validator_clear_type_missing_message + } + +@Composable +fun playResultWarningCountText(count: Int): String = pluralStringResource(Res.plurals.play_result_validator_warning_count, count, count) 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..63680368 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt @@ -0,0 +1,8 @@ +package xyz.sevive.arcaeaoffline.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha + +const val SECONDARY_ALPHA = .75f + +fun Modifier.secondaryItemAlpha() = this.alpha(SECONDARY_ALPHA) From 6a2d0595587eb829091aaaed84656d11b919aacc Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 8 Nov 2026 02:01:23 +0800 Subject: [PATCH 15/20] move DISABLED_ALPHA --- .../main/java/xyz/sevive/arcaeaoffline/helpers/Compose.kt | 6 ------ .../xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt | 2 +- .../playresultlist/components/FilterSelectableRow.kt | 2 +- .../kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt | 3 +++ 4 files changed, 5 insertions(+), 8 deletions(-) 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 df18bae0..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,13 +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 @Composable fun rememberFileChooserLauncher(onResult: (Uri?) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent(), onResult) - -fun Modifier.disabledItemAlpha() = this.alpha(DISABLED_ALPHA) 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 29c2b746..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,10 +30,10 @@ 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.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 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/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt index 63680368..b52ecdcc 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/Alpha.kt @@ -3,6 +3,9 @@ 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) From 75d6d5cd0909c26910b5da3bc37694bff95695be Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 11 Nov 2026 01:38:10 +0800 Subject: [PATCH 16/20] add warning hints and detailed warning messages --- .../ocrfromshare/OcrFromShareActions.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../values-zh-rCN/strings.xml | 71 ++++--- .../composeResources/values/strings.xml | 73 ++++--- .../core/validation/PlayResultWarning.kt | 21 +- .../playresult/PlayResultWarningDetails.kt | 36 ++-- .../playresult/PlayResultWarningMessage.kt | 185 ++++++++++++++++++ .../playresult/PlayResultWarningText.kt | 66 +++---- .../validation/PlayResultValidatorTest.kt | 21 +- 10 files changed, 367 insertions(+), 116 deletions(-) create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningMessage.kt 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 e6ef3fbe..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 @@ -118,7 +118,7 @@ private fun PreviewActions() { saved = false, cached = false, ), - warnings = listOf(PlayResultFinding(PlayResultWarning.SCORE_IS_ZERO)), + warnings = listOf(PlayResultFinding(PlayResultWarning.EMPTY_PLAY_RESULT)), onSavePlayResult = {}, onCachePlayResult = {}, ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 071b081e..51185b74 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -88,7 +88,7 @@ 谱面 游玩记录 分数 - MAX RECALL + Max Recall 通关状态 通关 计入 +0.2 通关奖励 @@ -441,4 +441,4 @@ 我已了解这些风险,继续。 退出 - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1d75c76..6c78aeae 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 @@ -486,4 +486,4 @@ CC BY-SA 3.0 I am aware of the risks. Continue. Exit - + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index 23393ed6..1baabfc1 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -15,47 +15,64 @@ %1$d 条警告 - 分数为零 - 分数为 0。 - 缺少通关状态 - B50 规则下,缺少通关状态的成绩按 TRACK LOST 计,无法获得通关奖励。 + 成绩未填写 + 请填写分数与判定数 + + 真纯净回忆? + 请核对 FAR/LOST 与通关状态 + Pure Memory 时 FAR 和 LOST 都应为 0 + + 应为 Pure Memory + 请核对 FAR/LOST 与通关状态 + FAR 和 LOST 均为 0 时,通关状态应为 Pure Memory 分数不在范围内 - 所给分数不在由 PURE、FAR 计算而得的分数范围内 + 请核对 PURE/FAR 判定数与谱面物量 + 所给分数 %1$d 不在 PURE %4$d、FAR %5$d 对应的 %2$d~%3$d 范围内 分数超出理论范围 - 分数为负或超过该谱面理论值 + 请核对分数与谱面物量 + 分数 %1$d 为负数 + 分数 %1$d 超过该谱面的理论值 %2$d - PURE MEMORY 分数过低 - PURE MEMORY 结算的成绩分数应至少为 10000000 + Pure Memory 分数过低 + Pure Memory 成绩应不低于 10'000'000 + 分数 %1$d 低于 Pure Memory 的最低分 10'000'000 - Note 溢出 - PURE、FAR、LOST 的和比谱面总物件数要大 + 判定数超过物量 + 请核对 PURE、FAR、LOST 与谱面物量 + 判定数之和超过谱面物量 + PURE %1$d + FAR %2$d + LOST %3$d 超过谱面总物量 %4$d - MAX RECALL 溢出 - 最大连击数大于非 LOST 判定的数量 + Max Recall 溢出 + 请核对 Max Recall 与判定数 + Max Recall %1$d 超过允许的上限 %2$d 计数为负 - PURE、FAR、LOST 和 MAX RECALL 不能为负数 + 请填入 0 或正数 + PURE、FAR、LOST 和 Max Recall 不能为负数 - FR/PM MAX RECALL 有误 - 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 - - Note 有误 - 非 HARD LOST 结算的游玩结果,其 PURE、FAR、LOST 的和应与谱面总物件数相等 + FR/PM 与 Max Recall 不符 + 请核对通关状态与 Max Recall + Max Recall %1$d 与谱面物量 %2$d 不符 真全想起来了? - FULL RECALL 时,LOST 应为 0 + 请核对 LOST 与通关状态 + Full Recall 时 LOST 应为 0 - 通关标志与 LOST 不符 - Normal/Easy/Hard Clear 结算的成绩应至少有 1 个 LOST + 通关状态与 LOST 不符 + 请检查 LOST 数,或修改通关状态 + 该通关状态应至少有 1 个 LOST - 真纯净回忆? - PURE MEMORY 时,FAR 和 LOST 应为 0 + 判定数与物量不符 + 请核对判定数与谱面物量 + PURE %1$d + FAR %2$d + LOST %3$d 与谱面物量 %4$d 不符 - 应为 PURE MEMORY - FAR 和 LOST 均为 0 的成绩应为 PURE MEMORY + Modifier 与通关状态不匹配 + 请核对 Modifier 与通关状态 + Easy Clear、Hard Clear、Normal Clear 需要对应的 Modifier - Modifier 与 Clear Type 不匹配 - 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY 和 NORMAL CLEAR 同理 + 缺少通关状态 + 请补全通关状态 + 缺少通关状态时,B50 规则按 Track Lost 计算,无法获得通关奖励 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 90060163..fa6c5b26 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -16,47 +16,64 @@ %1$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. + 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 - The score calculation based on the pure/far value does not match the given score. + Check the PURE/FAR counts and the chart note count. + The score %1$d is outside %2$d–%3$d, the range for %4$d PURE and %5$d FAR. Impossible score - The score is below zero or above Max PURE MEMORY. + Check the score and the chart note count. + The score %1$d is negative. + The score %1$d is above the chart's maximum %2$d. - PURE MEMORY score too low - A PURE MEMORY play result scores at least 10000000. + Pure Memory score too low + A Pure Memory play scores at least 10'000'000. + The score %1$d is below the Pure Memory minimum of 10'000'000. - Notes overflow - The sum of pure, far and lost is greater than the total notes of the chart. + 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 - The max recall is greater than the number of judgments that are not LOST. + Max Recall overflow + Check Max Recall and the judgment counts. + Max Recall %1$d exceeds the allowed maximum %2$d. Negative counts - pure, far, lost and max recall cannot be negative. + Enter 0 or a positive count. + PURE, FAR, LOST and Max Recall cannot be negative. - 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. + 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. - 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? + Check LOST and the clear type. + LOST should be 0 for Full Recall. - FULL RECALL? - The lost of a FULL RECALL play result should be zero. + Clear type and LOST mismatch + Check the LOST count, or change the clear type. + This clear type requires at least one LOST. - Clear type & lost mismatch - A Normal/Easy/Hard Clear play result should have 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. - PURE MEMORY? - The far and lost of a PURE MEMORY play result should be zero. + Modifier and clear type mismatch + Check the modifier and the clear type. + Easy Clear, Hard Clear and Normal Clear each require the matching modifier. - Should be PURE MEMORY - A play result with 0 far and 0 lost should be a PURE MEMORY. - - Modifier & Clear Type mismatch - A HARD CLEAR clear type should have the HARD modifier, and the same for EASY and NORMAL CLEAR. + 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/validation/PlayResultWarning.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt index 9f07f9a3..a9a9c7e7 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt @@ -32,7 +32,7 @@ enum class PlayResultWarningScope { /** * A data problem detected in a [PlayResult]. * - * Three null-value policies exist: + * 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. @@ -40,15 +40,26 @@ enum class PlayResultWarningScope { * 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, ) { - SCORE_IS_ZERO { + EMPTY_PLAY_RESULT { override fun check( playResult: PlayResult, chartInfo: ChartInfo?, - ) = if (playResult.score == 0) PlayResultWarningDetail.None else null + ) = 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 { @@ -174,7 +185,7 @@ enum class PlayResultWarning( val pure = playResult.pure val far = playResult.far - // Max recall cannot exceed the judged non-LOST count, nor the chart's notes minus lost. + // 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) { @@ -269,7 +280,7 @@ enum class PlayResultWarning( 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 + // 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 ( diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt index 9e72db8b..224612cf 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningDetails.kt @@ -68,11 +68,13 @@ private fun PlayResultWarningDetailItem( } val title = stringResource(warning.titleRes()) - val message = stringResource(warning.messageRes()) + 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( @@ -89,16 +91,15 @@ private fun PlayResultWarningDetailItem( Column(Modifier.weight(1f)) { Text(title) - AnimatedVisibility(visible = expanded) { - Text( - warning.name, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light), - ) - } + Text( + action, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - if (message.isNotBlank()) { + if (expandable) { Icon( Icons.Default.ExpandMore, contentDescription = null, @@ -107,13 +108,18 @@ private fun PlayResultWarningDetailItem( } } - AnimatedVisibility(visible = expanded) { - if (message.isNotBlank()) { + 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(), ) } } 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..8940de6c --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/playresult/PlayResultWarningMessage.kt @@ -0,0 +1,185 @@ +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 + +@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, + d.actual, + d.expected.first, + 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, + d.actual, + 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, + 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 index 961b9b3b..f16df757 100644 --- 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 @@ -5,41 +5,41 @@ 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_message +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_message +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_message +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_fr_pm_max_recall_mismatch_message +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_message +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_message +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_message +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_message +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_message +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_message +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_message +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_message +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_message +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_is_zero_message -import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_is_zero_title -import xyz.sevive.arcaeaoffline.resources.play_result_validator_score_out_of_range_message +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.SCORE_IS_ZERO -> Res.string.play_result_validator_score_is_zero_title + 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 @@ -56,23 +56,23 @@ internal fun PlayResultWarning.titleRes(): StringResource = PlayResultWarning.CLEAR_TYPE_MISSING -> Res.string.play_result_validator_clear_type_missing_title } -internal fun PlayResultWarning.messageRes(): StringResource = +internal fun PlayResultWarning.actionRes(): StringResource = when (this) { - PlayResultWarning.SCORE_IS_ZERO -> Res.string.play_result_validator_score_is_zero_message - PlayResultWarning.PURE_MEMORY_FAR_LOST_NOT_ZERO -> Res.string.play_result_validator_pure_memory_far_lost_not_zero_message - PlayResultWarning.ALL_PURE_NOT_PM -> Res.string.play_result_validator_all_pure_not_pm_message - PlayResultWarning.SCORE_OUT_OF_RANGE -> Res.string.play_result_validator_score_out_of_range_message - PlayResultWarning.SCORE_IMPOSSIBLE -> Res.string.play_result_validator_score_impossible_message - PlayResultWarning.PM_SCORE_TOO_LOW -> Res.string.play_result_validator_pm_score_too_low_message - PlayResultWarning.PFL_OVERFLOW -> Res.string.play_result_validator_pfl_overflow_message - PlayResultWarning.MAX_RECALL_OVERFLOW -> Res.string.play_result_validator_max_recall_overflow_message - PlayResultWarning.NEGATIVE_COUNT -> Res.string.play_result_validator_negative_count_message - PlayResultWarning.FR_PM_MAX_RECALL_MISMATCH -> Res.string.play_result_validator_fr_pm_max_recall_mismatch_message - PlayResultWarning.FULL_RECALL_LOST_NOT_ZERO -> Res.string.play_result_validator_full_recall_lost_not_zero_message - PlayResultWarning.PLAIN_CLEAR_WITHOUT_LOST -> Res.string.play_result_validator_plain_clear_without_lost_message - PlayResultWarning.CLEAR_PFL_MISMATCH -> Res.string.play_result_validator_clear_pfl_mismatch_message - PlayResultWarning.MODIFIER_CLEAR_TYPE_MISMATCH -> Res.string.play_result_validator_modifier_clear_type_mismatch_message - PlayResultWarning.CLEAR_TYPE_MISSING -> Res.string.play_result_validator_clear_type_missing_message + 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 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 index bda9dab7..e08fd9e7 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt @@ -818,19 +818,34 @@ class PlayResultValidatorTest { } @Test - fun scoreIsZero() { + fun emptyPlayResult() { val playResult = PlayResult( songId = "test", ratingClass = ArcaeaRatingClass.FUTURE, score = 0, + pure = 0, + far = 0, + lost = 0, ) assertNotNull( - PlayResultWarning.SCORE_IS_ZERO.check(playResult, null), + 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.SCORE_IS_ZERO.check( + PlayResultWarning.EMPTY_PLAY_RESULT.check( playResult.copy(score = 1), null, ), From c172b8b09a82d42685f194390372c24ce6faccdc Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 11 Nov 2026 01:54:35 +0800 Subject: [PATCH 17/20] move ArcaeaFormatters to `:shared` --- app/AGENTS.md | 2 +- shared/AGENTS.md | 1 + .../xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt | 0 .../sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt | 4 ++-- 4 files changed, 4 insertions(+), 3 deletions(-) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt (100%) rename {app/src/test/java => shared/src/commonTest/kotlin}/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt (99%) diff --git a/app/AGENTS.md b/app/AGENTS.md index e2ad0c06..96624383 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -17,7 +17,7 @@ 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 diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 9aae310e..69035364 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -20,6 +20,7 @@ xyz.sevive.arcaeaoffline/ └── 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/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/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 From 9970ec2a5941446898de84867b75f4648a015837 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 11 Nov 2026 02:30:23 +0800 Subject: [PATCH 18/20] adjust score formatting --- .../composeResources/values-zh-rCN/strings.xml | 6 +++--- .../composeResources/values/strings.xml | 6 +++--- .../playresult/PlayResultWarningMessage.kt | 15 +++++++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index 1baabfc1..65e2be1c 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -28,16 +28,16 @@ 分数不在范围内 请核对 PURE/FAR 判定数与谱面物量 - 所给分数 %1$d 不在 PURE %4$d、FAR %5$d 对应的 %2$d~%3$d 范围内 + 所给分数 %1$s 不在 PURE %4$d、FAR %5$d 对应的 %2$s~%3$s 范围内 分数超出理论范围 请核对分数与谱面物量 分数 %1$d 为负数 - 分数 %1$d 超过该谱面的理论值 %2$d + 分数 %1$s 超过该谱面的理论值 %2$s Pure Memory 分数过低 Pure Memory 成绩应不低于 10'000'000 - 分数 %1$d 低于 Pure Memory 的最低分 10'000'000 + 分数 %1$s 低于 Pure Memory 的最低分 10'000'000 判定数超过物量 请核对 PURE、FAR、LOST 与谱面物量 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index fa6c5b26..1e8de5bf 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -29,16 +29,16 @@ Score out of range Check the PURE/FAR counts and the chart note count. - The score %1$d is outside %2$d–%3$d, the range for %4$d PURE and %5$d FAR. + 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$d is above the chart's maximum %2$d. + 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$d is below the Pure Memory minimum of 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. 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 index 8940de6c..4d3d7852 100644 --- 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 @@ -22,6 +22,9 @@ import xyz.sevive.arcaeaoffline.resources.play_result_validator_pure_memory_far_ 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? = @@ -43,9 +46,9 @@ internal fun PlayResultFinding.messageText(): String? = is PlayResultWarningDetail.ScoreOutOfRange -> { stringResource( Res.string.play_result_validator_score_out_of_range_message, - d.actual, - d.expected.first, - d.expected.last, + formatScore(d.actual), + formatScore(d.expected.first), + formatScore(d.expected.last), d.pure, d.far, ) @@ -69,8 +72,8 @@ internal fun PlayResultFinding.messageText(): String? = is PlayResultWarningDetail.ScoreBeyondMaximum -> { stringResource( Res.string.play_result_validator_score_impossible_message_beyond_max, - d.actual, - d.maximum, + formatScore(d.actual), + formatScore(d.maximum), ) } @@ -85,7 +88,7 @@ internal fun PlayResultFinding.messageText(): String? = is PlayResultWarningDetail.ScoreTooLow -> { stringResource( Res.string.play_result_validator_pm_score_too_low_message, - d.actual, + formatScore(d.actual), ) } From 8f05ebc646b4952a1fa7b11b99ca44f74dbc6dc2 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 11 Nov 2026 03:12:33 +0800 Subject: [PATCH 19/20] unify labels --- app/src/main/res/values-zh-rCN/strings.xml | 8 ++++---- .../core/constants/ArcaeaPlayResultClearType.kt | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 51185b74..dacea207 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 // // 无备注 // @@ -231,9 +231,9 @@ 分数 全部分数 选择分数段 - 通关类型 - 全部通关类型 - 选择通关类型 + 通关状态 + 全部通关状态 + 选择通关状态 搜索曲包…… 显示没有游玩记录的曲包 数据库中暂无曲包 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 { From a0c5cd472ab5284292eac30b316bf7620dfb751a Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 11 Nov 2026 04:11:04 +0800 Subject: [PATCH 20/20] fix potential overflow issue --- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../arcaeaoffline/core/calculators/Common.kt | 7 ++++-- .../core/validation/PlayResultWarning.kt | 8 +++++-- .../core/calculators/CommonCalculatorsTest.kt | 12 ++++++++++ .../validation/PlayResultValidatorTest.kt | 24 +++++++++++++++++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dacea207..4f3831e8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -441,4 +441,4 @@ 我已了解这些风险,继续。 退出 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c78aeae..a2c17fdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -486,4 +486,4 @@ CC BY-SA 3.0 I am aware of the risks. Continue. Exit - \ No newline at end of file + 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 c0096c00..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 @@ -8,7 +8,8 @@ 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. + * 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, @@ -18,7 +19,9 @@ fun calculateScoreRange( if (notes <= 0) return IntRange.EMPTY val base = 10_000_000L * (2L * pure + far) / (2L * notes) - return base.toInt()..(base + pure).toInt() + 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/validation/PlayResultWarning.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt index a9a9c7e7..484be4bf 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultWarning.kt @@ -13,8 +13,12 @@ import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult */ 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. */ -private fun maxScore(notes: Int): Int = 10_000_000 + notes +/** + * 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 { 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 178098fa..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 @@ -28,6 +28,18 @@ class CommonCalculatorsTest { 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( + Int.MAX_VALUE..Int.MAX_VALUE, + calculateScoreRange(notes = 1, pure = 0, far = 2_000_000_000), + ) + } + @Test fun testPlayRating() { assertEquals( 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 index e08fd9e7..577ef604 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/validation/PlayResultValidatorTest.kt @@ -244,6 +244,30 @@ class PlayResultValidatorTest { ) } + @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 =