Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a92f1ca
feat(shared): add ArcaeaScoringMode and clear-type bonus to play rating
283375 Sep 9, 2026
a8f8c08
feat(core): store scoring mode in database properties
283375 Sep 9, 2026
60385e3
feat(core): add B50 potential calculation
283375 Sep 9, 2026
a488b96
refactor(app.ui): unify potential display precision at 3 decimals
283375 Sep 9, 2026
2cb98aa
feat(app.ui): scoring-mode-aware overview potential card
283375 Sep 9, 2026
b0c7806
feat(app.ui): scoring mode setting in database manage screen
283375 Sep 9, 2026
0f1391c
feat(app.ui): database tab follows the scoring mode
283375 Sep 9, 2026
32668b4
feat(app.ui): add clear-type selector to play rating calculator
283375 Sep 9, 2026
8984fba
feat(app.ui): warn about missing clear type under B50 scoring
283375 Sep 9, 2026
e19f911
feat(app.ui): seed chart recommend target by scoring mode
283375 Sep 9, 2026
536b402
fix(app.ui): actually apply clear bonus and 6-decimal output in calcu…
283375 Sep 9, 2026
dcba64b
refactor(app.ui): update best list internals to the new names
283375 Sep 9, 2026
95a51f9
refactor(app.ui): calculator clear-type row as a preferences widget
283375 Sep 16, 2026
8e47a7c
fix: st3 cleartype parsing
283375 Sep 17, 2026
3aa493f
test: add st3 importer tests
283375 Sep 17, 2026
e65c78d
chore: format code
283375 Sep 21, 2026
5e546f2
feat(core): store the scoring mode when a database is created
283375 Sep 23, 2026
74b133d
fix(shared): treat a non-positive chart constant as no rating
283375 Sep 23, 2026
0401994
test: add B50 potential tests with real save data
283375 Sep 23, 2026
0703e6d
refactor: R30 calculating and tests
283375 Sep 24, 2026
24c29bf
docs: add potential and st3 docs
283375 Sep 24, 2026
1b091e3
ci: update deepseek model
283375 Sep 24, 2026
c1873b9
Revert "ci: update deepseek model name"
283375 Sep 24, 2026
be6ee71
ci: update deepseek model name
283375 Sep 24, 2026
2b98190
refactor: PotentialRepository
283375 Oct 2, 2026
df79ab3
refactor: drop incremental R30 update mode
283375 Oct 2, 2026
071b53d
refactor(app.ui): seed chart recommend target with the overall potential
283375 Oct 2, 2026
c5cb99d
chore: cleanups
283375 Oct 2, 2026
5fc50c2
docs: sync implementations and formatting
283375 Oct 2, 2026
daa45d2
clear type bonus and tests
283375 Oct 3, 2026
d1943c9
refactor: PLAY_RATING_CLEAR_BONUS usages
283375 Oct 3, 2026
119c067
test: fix wrong assertEqual orders
283375 Oct 3, 2026
a78d1a6
test: add tests
283375 Oct 4, 2026
7bba161
ui improvements
283375 Oct 4, 2026
e02c739
final cleanup
283375 Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ jobs:
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
model: deepseek/deepseek-v4-flash
model: ${{ vars.OPENCODE_MODEL || 'deepseek/deepseek-flash' }}
4 changes: 2 additions & 2 deletions app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import xyz.sevive.arcaeaoffline.ui.activities.EmergencyModeActivityViewModel
import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.DatabaseNavEntryViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult.DatabaseAddPlayResultViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.b30list.DatabaseB30ListViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.bestlist.DatabaseBestListViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.deduplicator.DatabaseDeduplicatorViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageViewModel
import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListViewModel
Expand Down Expand Up @@ -122,7 +122,7 @@ val appModule =
viewModel<DatabaseManageViewModel>()
viewModel<DatabaseAddPlayResultViewModel>()
viewModel<DatabasePlayResultListViewModel>()
viewModel<DatabaseB30ListViewModel>()
viewModel<DatabaseBestListViewModel>()
viewModel<DatabaseR30ListViewModel>()
viewModel<DatabaseDeduplicatorViewModel>()
viewModel<OcrDependenciesScreenViewModel>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package xyz.sevive.arcaeaoffline.helpers

import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode
import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo
import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult

Expand Down Expand Up @@ -31,4 +32,26 @@ object ArcaeaPlayResultValidator {

return warnings.toList()
}

/**
* Display-oriented validation: on top of [validate], flags records whose
* missing clear type changes their B50 rating, so the user can fill it in.
*/
fun validate(
playResult: PlayResult,
chartInfo: ChartInfo?,
scoringMode: ArcaeaScoringMode,
): List<ArcaeaPlayResultValidatorWarning> {
val clearTypeMissing =
if (
scoringMode == ArcaeaScoringMode.B50 &&
ArcaeaPlayResultValidatorClearTypeMissingWarning.conditionsMet(playResult, chartInfo)
) {
listOf(ArcaeaPlayResultValidatorClearTypeMissingWarning)
} else {
emptyList()
}
Comment on lines +40 to +53

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider future refactors?


return validate(playResult, chartInfo) + clearTypeMissing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,20 @@ data object ArcaeaPlayResultValidatorModifierClearTypeMismatchWarning :
(playResult.clearType == ArcaeaPlayResultClearType.EASY_CLEAR && playResult.modifier != ArcaeaPlayResultModifier.EASY) ||
(playResult.clearType == ArcaeaPlayResultClearType.HARD_CLEAR && playResult.modifier != ArcaeaPlayResultModifier.HARD)
}

data object ArcaeaPlayResultValidatorClearTypeMissingWarning :
ArcaeaPlayResultValidatorWarning {
override val id = "CLEAR_TYPE_MISSING"

override val title = "Clear type missing"
override val titleId = R.string.play_result_validator_CLEAR_TYPE_MISSING_title

override val message =
"The B50 rules count a play without a clear type as TRACK LOST, so it earns no clear bonus."
override val messageId = R.string.play_result_validator_CLEAR_TYPE_MISSING_message

override fun conditionsMet(
playResult: PlayResult,
chartInfo: ChartInfo?,
): Boolean = playResult.clearType == null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package xyz.sevive.arcaeaoffline.helpers

import xyz.sevive.arcaeaoffline.core.calculators.calculatePotentialB30R10
import xyz.sevive.arcaeaoffline.core.calculators.calculatePotentialB50
import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode
import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups

internal fun ArcaeaScoringMode.calculatePotential(potentialGroups: PotentialGroups): Double =
when (this) {
ArcaeaScoringMode.B30_R10 -> calculatePotentialB30R10(potentialGroups.b30.total, potentialGroups.r10.total)
ArcaeaScoringMode.B50 -> calculatePotentialB50(potentialGroups.b50.total, potentialGroups.b10.total)
}
178 changes: 18 additions & 160 deletions app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,16 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import xyz.sevive.arcaeaoffline.core.Progress
import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType
import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier
import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase
import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo
import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult
import xyz.sevive.arcaeaoffline.core.database.entities.playRating
import xyz.sevive.arcaeaoffline.core.database.r30.R30QueueUpdater
import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository
import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository
import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository
import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined
import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository
import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository
import xyz.sevive.arcaeaoffline.helpers.toWorkData
import kotlin.time.Clock

private fun PlayResult.triggersConditionalWrite(): Boolean {
// score >= EX
if (score >= 9_800_000) return true
// is hard lost
if (clearType == ArcaeaPlayResultClearType.TRACK_LOST && modifier == ArcaeaPlayResultModifier.HARD) return true

return false
}

private fun List<R30EntryCombined>.minByPlayRating(): R30EntryCombined? =
this.minByOrNull { entry ->
entry.chartInfo?.let { entry.playResult.playRating(it) } ?: Double.MAX_VALUE
}

class R30UpdateJob(
context: Context,
params: WorkerParameters,
Expand All @@ -56,77 +37,41 @@ class R30UpdateJob(
private val playResultRepo: PlayResultRepository,
private val chartInfoRepo: ChartInfoRepository,
) : CoroutineWorker(context, params) {
private val r30QueueUpdater = R30QueueUpdater { chartInfoRepo.find(it).firstOrNull() }

companion object {
private const val LOG_TAG = "R30UpdateJob"
const val WORK_NAME = "R30UpdateJob"

const val DATA_RUN_MODE = "run_mode"
}

private val logger = Logger.withTag(LOG_TAG)

enum class RunMode(
val value: Int,
) {
NORMAL(0),
REBUILD(1), ;

companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
}
}

private data class WorkOptions(
val runMode: RunMode,
)

private fun parseRunMode(): RunMode {
val runModeInput = inputData.getInt(DATA_RUN_MODE, 0)
val result = RunMode.fromInt(runModeInput)
if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" }
return result ?: RunMode.NORMAL
}

private fun getWorkOptions(): WorkOptions =
WorkOptions(
runMode = parseRunMode(),
)

private val progressFlow = MutableStateFlow(Progress.INDETERMINATE)

override suspend fun doWork(): Result {
val workOptions = getWorkOptions()

try {
return coroutineScope {
val progressPublishJob =
launch {
progressFlow.collectLatest { setProgress(it.toWorkData()) }
}

val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt()

var r30EntryCombinedList =
when (workOptions.runMode) {
RunMode.REBUILD -> emptyList()
else -> r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList()
}

val playResults =
when (workOptions.runMode) {
RunMode.REBUILD -> playResultRepo.findAll().firstOrNull()
else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).firstOrNull() }
} ?: emptyList()
val deletedSongIds = songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList()
val newPlayResults = playResults.filter { it.date != null && it.songId !in deletedSongIds }.sortedBy { it.date }

progressFlow.update { Progress(current = 0, total = newPlayResults.size) }
logger.d { "Updating r30 list with ${newPlayResults.size} new play results" }
newPlayResults.forEach {
ensureActive()
r30EntryCombinedList = updateR30List(it, r30EntryCombinedList)
progressFlow.update { progress -> progress.increment() }
}
val playResults =
(playResultRepo.findAll().firstOrNull() ?: emptyList())
.filter { it.date != null && it.songId !in deletedSongIds }
.sortedWith(compareBy({ it.date }, { it.id }))

progressFlow.update { Progress(current = 0, total = playResults.size) }
logger.d { "Rebuilding r30 list from ${playResults.size} play results" }
val r30EntryCombinedList =
r30QueueUpdater.rebuild(
plays = playResults,
onPlay = {
ensureActive()
progressFlow.update { progress -> progress.increment() }
},
)

// Room3 possibly has a convenient extension function for this
// see https://issuetracker.google.com/issues/416306996
Expand All @@ -150,91 +95,4 @@ class R30UpdateJob(
return Result.failure()
}
}

/**
* Wrapper of [updateR30ListByDirectWrite] and [updateR30ListByConditionalWrite] that automatically
* choose one of them depending on the [playResult]'s state.
*/
private suspend fun updateR30List(
playResult: PlayResult,
oldR30List: List<R30EntryCombined>,
): List<R30EntryCombined> {
if (oldR30List.size < 30) {
val mutableR30List = oldR30List.toMutableList()
mutableR30List.add(R30EntryCombined.build(playResult, chartInfoRepo))
return mutableR30List
}

val newR30Entries =
if (playResult.triggersConditionalWrite()) {
// now check if the play result play rating is higher than the lowest play rating r30 entry
// if any chart info is missing, return the old r30 entries directly
val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return oldR30List
updateR30ListByConditionalWrite(playResult, chartInfo, oldR30List)
} else {
// otherwise, just update the entries by date
updateR30ListByDirectWrite(playResult, oldR30List)
}

// ensure the new r30 should have at least 10 unique charts
// otherwise keep the entries unmodified
val uniqueChartsCount = newR30Entries.distinctBy { "${it.playResult.songId}|${it.playResult.ratingClass.value}" }.count()
return if (uniqueChartsCount < 10) {
oldR30List
} else {
newR30Entries
}
}

/**
* Update the R30 entries under the "conditional" circumstance.
*
* This will replace the lowest play rating entry with the new play result.
*
* @param playResult The play result to be inserted
* @param chartInfo The [ChartInfo] of [playResult]
* @param oldR30List Old R30 entries
* @return The new R30 entries
*/
private fun updateR30ListByConditionalWrite(
playResult: PlayResult,
chartInfo: ChartInfo,
oldR30List: List<R30EntryCombined>,
): List<R30EntryCombined> {
// try getting the min play rating item in old list
// otherwise leave the old list untouched
val minRatingEntry = oldR30List.minByPlayRating() ?: return oldR30List
val minRatingEntryRating = minRatingEntry.playRating() ?: return oldR30List

if (playResult.playRating(chartInfo) < minRatingEntryRating) return oldR30List

val newR30Entries = oldR30List.toMutableList()
newR30Entries.remove(minRatingEntry)
newR30Entries.add(R30EntryCombined.build(playResult, chartInfo))
return newR30Entries
}

/**
* Update the R30 entries as usual.
*
* This will replace the oldest entry with the new play result.
*
* @param playResult The play result to be inserted
* @param oldR30List Old R30 entries
* @return The new R30 entries
*/
private suspend fun updateR30ListByDirectWrite(
playResult: PlayResult,
oldR30List: List<R30EntryCombined>,
): List<R30EntryCombined> {
val oldestR30Entry =
oldR30List.minByOrNull {
it.playResult.date?.toEpochMilliseconds() ?: Long.MAX_VALUE
} ?: return oldR30List

val newR30Entries = oldR30List.toMutableList()
newR30Entries.remove(oldestR30Entry)
newR30Entries.add(R30EntryCombined.build(playResult, chartInfoRepo))
return newR30Entries
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
Expand All @@ -23,9 +26,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.sevive.arcaeaoffline.R
import xyz.sevive.arcaeaoffline.core.calculators.calculateClearBonus
import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating
import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType
import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField
import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState
import xyz.sevive.arcaeaoffline.ui.components.preferences.SwitchPreferencesWidget
import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters
import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme

@Composable
Expand All @@ -35,9 +42,13 @@ fun PlayRatingCalculator(
constant: Int = 0,
isConstantReadonly: Boolean = true,
initialFocusScoreTextField: Boolean = false,
initialClearType: ArcaeaPlayResultClearType? = null,
countClearBonus: Boolean = true,
) {
val scoreTextFieldFocusRequester = remember { FocusRequester() }

var clearType by rememberSaveable { mutableStateOf(initialClearType) }

val scoreTextFieldState =
rememberArcaeaScoreTextFieldState(
initialValue = score,
Expand Down Expand Up @@ -73,7 +84,8 @@ fun PlayRatingCalculator(
scoreValue ?: return@derivedStateOf null
constantValue ?: return@derivedStateOf null

calculatePlayRating(scoreValue!!, constantValue!!)
val clearBonus = calculateClearBonus(if (countClearBonus) clearType else null)
calculatePlayRating(scoreValue!!, constantValue!!, clearBonus)
}
}

Expand Down Expand Up @@ -104,11 +116,20 @@ fun PlayRatingCalculator(
Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null)

Text(
potential?.let { String.format(null, "%.4f", it) } ?: "?",
potential?.let { ArcaeaFormatters.potentialToText(it, scale = 6) } ?: "?",
Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
)
}

if (countClearBonus) {
SwitchPreferencesWidget(
value = clearType != null && clearType != ArcaeaPlayResultClearType.TRACK_LOST,
onValueChange = { cleared -> clearType = if (cleared) ArcaeaPlayResultClearType.NORMAL_CLEAR else null },
title = stringResource(R.string.play_rating_cleared),
description = stringResource(R.string.play_rating_cleared_description),
)
}
}
}

Expand Down
Loading
Loading