Add base support for RetroAchievements hardcore mode

This commit is contained in:
Rafael Caetano 2023-03-20 22:10:55 +00:00
parent 4bca90ee42
commit c2658424b3
13 changed files with 109 additions and 48 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 4, "version": 4,
"identityHash": "31374048dc97dc955a845a6920edacaa", "identityHash": "afbfe1a8a5ee33a9307efcca89acecbd",
"entities": [ "entities": [
{ {
"tableName": "cheat_database", "tableName": "cheat_database",
@ -365,7 +365,7 @@
}, },
{ {
"tableName": "ra_user_achievement", "tableName": "ra_user_achievement",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER NOT NULL, `achievement_id` INTEGER NOT NULL, `is_unlocked` INTEGER NOT NULL, PRIMARY KEY(`game_id`, `achievement_id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER NOT NULL, `achievement_id` INTEGER NOT NULL, `is_unlocked` INTEGER NOT NULL, `is_hardcore` INTEGER NOT NULL, PRIMARY KEY(`game_id`, `achievement_id`, `is_hardcore`))",
"fields": [ "fields": [
{ {
"fieldPath": "gameId", "fieldPath": "gameId",
@ -384,13 +384,20 @@
"columnName": "is_unlocked", "columnName": "is_unlocked",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
},
{
"fieldPath": "isHardcore",
"columnName": "is_hardcore",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": false, "autoGenerate": false,
"columnNames": [ "columnNames": [
"game_id", "game_id",
"achievement_id" "achievement_id",
"is_hardcore"
] ]
}, },
"indices": [], "indices": [],
@ -398,7 +405,7 @@
}, },
{ {
"tableName": "ra_game_set_metadata", "tableName": "ra_game_set_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER NOT NULL, `last_achievement_set_updated` INTEGER, `last_user_data_updated` INTEGER, PRIMARY KEY(`game_id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER NOT NULL, `last_achievement_set_updated` INTEGER, `last_user_data_updated` INTEGER, `last_hardcore_user_data_updated` INTEGER, PRIMARY KEY(`game_id`))",
"fields": [ "fields": [
{ {
"fieldPath": "gameId", "fieldPath": "gameId",
@ -413,10 +420,16 @@
"notNull": false "notNull": false
}, },
{ {
"fieldPath": "lastUserDataUpdated", "fieldPath": "lastSoftcoreUserDataUpdated",
"columnName": "last_user_data_updated", "columnName": "last_user_data_updated",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": false "notNull": false
},
{
"fieldPath": "lastHardcoreUserDataUpdated",
"columnName": "last_hardcore_user_data_updated",
"affinity": "INTEGER",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -491,7 +504,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '31374048dc97dc955a845a6920edacaa')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afbfe1a8a5ee33a9307efcca89acecbd')"
] ]
} }
} }

View File

@ -42,8 +42,8 @@ abstract class RAAchievementsDao {
@Query("SELECT * FROM ra_game WHERE game_id = :gameId") @Query("SELECT * FROM ra_game WHERE game_id = :gameId")
abstract suspend fun getGame(gameId: Long): RAGameEntity? abstract suspend fun getGame(gameId: Long): RAGameEntity?
@Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_unlocked = 1") @Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_hardcore = :forHardcoreMode AND is_unlocked = 1")
abstract suspend fun getGameUserUnlockedAchievements(gameId: Long): List<RAUserAchievementEntity> abstract suspend fun getGameUserUnlockedAchievements(gameId: Long, forHardcoreMode: Boolean): List<RAUserAchievementEntity>
@Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId") @Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId")
protected abstract suspend fun deleteGameUserUnlockedAchievements(gameId: Long) protected abstract suspend fun deleteGameUserUnlockedAchievements(gameId: Long)

View File

@ -9,5 +9,6 @@ import java.time.Instant
data class RAGameSetMetadata( data class RAGameSetMetadata(
@PrimaryKey @ColumnInfo(name = "game_id") val gameId: Long, @PrimaryKey @ColumnInfo(name = "game_id") val gameId: Long,
@ColumnInfo(name = "last_achievement_set_updated") val lastAchievementSetUpdated: Instant?, @ColumnInfo(name = "last_achievement_set_updated") val lastAchievementSetUpdated: Instant?,
@ColumnInfo(name = "last_user_data_updated") val lastUserDataUpdated: Instant?, @ColumnInfo(name = "last_user_data_updated") val lastSoftcoreUserDataUpdated: Instant?,
@ColumnInfo(name = "last_hardcore_user_data_updated") val lastHardcoreUserDataUpdated: Instant?,
) )

View File

@ -5,10 +5,11 @@ import androidx.room.Entity
@Entity( @Entity(
tableName = "ra_user_achievement", tableName = "ra_user_achievement",
primaryKeys = ["game_id", "achievement_id"], primaryKeys = ["game_id", "achievement_id", "is_hardcore"],
) )
data class RAUserAchievementEntity( data class RAUserAchievementEntity(
@ColumnInfo(name = "game_id") val gameId: Long, @ColumnInfo(name = "game_id") val gameId: Long,
@ColumnInfo(name = "achievement_id") val achievementId: Long, @ColumnInfo(name = "achievement_id") val achievementId: Long,
@ColumnInfo(name = "is_unlocked") val isUnlocked: Boolean, @ColumnInfo(name = "is_unlocked") val isUnlocked: Boolean,
@ColumnInfo(name = "is_hardcore") val isHardcore: Boolean,
) )

View File

@ -5,4 +5,22 @@ import me.magnum.rcheevosapi.model.RAAchievement
data class RAUserAchievement( data class RAUserAchievement(
val achievement: RAAchievement, val achievement: RAAchievement,
val isUnlocked: Boolean, val isUnlocked: Boolean,
) val forHardcoreMode: Boolean,
) {
/**
* Returns the number of points that this achievement is worth for the user in the current state. This depends on the actual number of points that the achievements awards,
* whether the user has unlocked it or not and if it was unlocked in hardcore or softcore mode.
*/
fun pointsWorth(): Int {
return if (isUnlocked) {
if (forHardcoreMode) {
achievement.points * 2
} else {
achievement.points
}
} else {
0
}
}
}

View File

@ -9,10 +9,10 @@ interface RetroAchievementsRepository {
suspend fun getUserAuthentication(): RAUserAuth? suspend fun getUserAuthentication(): RAUserAuth?
suspend fun login(username: String, password: String): Result<Unit> suspend fun login(username: String, password: String): Result<Unit>
suspend fun logout() suspend fun logout()
suspend fun getGameUserAchievements(gameHash: String): Result<List<RAUserAchievement>> suspend fun getGameUserAchievements(gameHash: String, forHardcoreMode: Boolean): Result<List<RAUserAchievement>>
suspend fun getGameRichPresencePatch(gameHash: String): String? suspend fun getGameRichPresencePatch(gameHash: String): String?
suspend fun getAchievement(achievementId: Long): Result<RAAchievement?> suspend fun getAchievement(achievementId: Long): Result<RAAchievement?>
suspend fun awardAchievement(achievement: RAAchievement) suspend fun awardAchievement(achievement: RAAchievement, forHardcoreMode: Boolean)
suspend fun submitPendingAchievements(): Result<Unit> suspend fun submitPendingAchievements(): Result<Unit>
suspend fun startSession(gameHash: String) suspend fun startSession(gameHash: String)
suspend fun sendSessionHeartbeat(gameHash: String, richPresenceDescription: String?) suspend fun sendSessionHeartbeat(gameHash: String, richPresenceDescription: String?)

View File

@ -51,6 +51,7 @@ interface SettingsRepository {
fun getSoftInputOpacity(): Int fun getSoftInputOpacity(): Int
fun isRetroAchievementsRichPresenceEnabled(): Boolean fun isRetroAchievementsRichPresenceEnabled(): Boolean
fun isRetroAchievementsHardcoreEnabled(): Boolean
fun areCheatsEnabled(): Boolean fun areCheatsEnabled(): Boolean

View File

@ -55,7 +55,7 @@ class AndroidRetroAchievementsRepository(
raUserAuthStore.clearUserAuth() raUserAuthStore.clearUserAuth()
} }
override suspend fun getGameUserAchievements(gameHash: String): Result<List<RAUserAchievement>> { override suspend fun getGameUserAchievements(gameHash: String, forHardcoreMode: Boolean): Result<List<RAUserAchievement>> {
val gameIdResult = getGameIdFromGameHash(gameHash) val gameIdResult = getGameIdFromGameHash(gameHash)
if (gameIdResult.isFailure) { if (gameIdResult.isFailure) {
return Result.failure(gameIdResult.exceptionOrNull()!!) return Result.failure(gameIdResult.exceptionOrNull()!!)
@ -74,7 +74,7 @@ class AndroidRetroAchievementsRepository(
return Result.failure(gameAchievementsResult.exceptionOrNull()!!) return Result.failure(gameAchievementsResult.exceptionOrNull()!!)
} }
val userUnlocksResult = fetchGameUserUnlockedAchievements(gameId, currentMetadata) val userUnlocksResult = fetchGameUserUnlockedAchievements(gameId, forHardcoreMode, currentMetadata)
if (userUnlocksResult.isFailure) { if (userUnlocksResult.isFailure) {
return Result.failure(userUnlocksResult.exceptionOrNull()!!) return Result.failure(userUnlocksResult.exceptionOrNull()!!)
} }
@ -86,6 +86,7 @@ class AndroidRetroAchievementsRepository(
RAUserAchievement( RAUserAchievement(
achievement = it, achievement = it,
isUnlocked = userUnlocks.contains(it.id), isUnlocked = userUnlocks.contains(it.id),
forHardcoreMode = forHardcoreMode,
) )
} }
return Result.success(userAchievements) return Result.success(userAchievements)
@ -108,14 +109,16 @@ class AndroidRetroAchievementsRepository(
} }
} }
override suspend fun awardAchievement(achievement: RAAchievement) { override suspend fun awardAchievement(achievement: RAAchievement, forHardcoreMode: Boolean) {
submitAchievementAward(achievement.id, achievement.gameId, false, true) submitAchievementAward(achievement.id, achievement.gameId, forHardcoreMode).onFailure {
scheduleAchievementSubmissionJob()
}
} }
override suspend fun submitPendingAchievements(): Result<Unit> { override suspend fun submitPendingAchievements(): Result<Unit> {
achievementsDao.getPendingAchievementSubmissions().forEach { achievementsDao.getPendingAchievementSubmissions().forEach {
// Do not schedule resubmission if this fails. The current submission job should schedule another attempt // Do not schedule resubmission if this fails. The current submission job should schedule another attempt
val submissionResult = submitAchievementAward(it.achievementId, RAGameId(it.gameId), it.forHardcoreMode, false) val submissionResult = submitAchievementAward(it.achievementId, RAGameId(it.gameId), it.forHardcoreMode)
if (submissionResult.isFailure) { if (submissionResult.isFailure) {
return submissionResult return submissionResult
} }
@ -136,9 +139,14 @@ class AndroidRetroAchievementsRepository(
raApi.sendPing(gameId, richPresenceDescription) raApi.sendPing(gameId, richPresenceDescription)
} }
private suspend fun submitAchievementAward(achievementId: Long, gameId: RAGameId, forHardcoreMode: Boolean, scheduleResubmissionOnFailure: Boolean): Result<Unit> { private suspend fun submitAchievementAward(achievementId: Long, gameId: RAGameId, forHardcoreMode: Boolean): Result<Unit> {
// Award the achievement immediately locally // Award the achievement immediately locally
val userAchievement = RAUserAchievementEntity(gameId.id, achievementId, true) val userAchievement = RAUserAchievementEntity(
gameId = gameId.id,
achievementId = achievementId,
isUnlocked = true,
isHardcore = forHardcoreMode,
)
achievementsDao.addUserAchievement(userAchievement) achievementsDao.addUserAchievement(userAchievement)
return raApi.awardAchievement(achievementId, forHardcoreMode).onFailure { return raApi.awardAchievement(achievementId, forHardcoreMode).onFailure {
@ -149,9 +157,6 @@ class AndroidRetroAchievementsRepository(
forHardcoreMode = forHardcoreMode, forHardcoreMode = forHardcoreMode,
) )
achievementsDao.addPendingAchievementSubmission(pendingAchievementSubmissionEntity) achievementsDao.addPendingAchievementSubmission(pendingAchievementSubmissionEntity)
if (scheduleResubmissionOnFailure) {
scheduleAchievementSubmissionJob()
}
} }
} }
@ -217,24 +222,25 @@ class AndroidRetroAchievementsRepository(
} }
} }
private suspend fun fetchGameUserUnlockedAchievements(gameId: RAGameId, gameSetMetadata: CurrentGameSetMetadata): Result<List<Long>> { private suspend fun fetchGameUserUnlockedAchievements(gameId: RAGameId, forHardcoreMode: Boolean, gameSetMetadata: CurrentGameSetMetadata): Result<List<Long>> {
return if (mustRefreshUserData(gameSetMetadata.currentMetadata)) { return if (mustRefreshUserData(gameSetMetadata.currentMetadata, forHardcoreMode)) {
raApi.getUserUnlockedAchievements(gameId, false).onSuccess { userUnlocks -> raApi.getUserUnlockedAchievements(gameId, forHardcoreMode).onSuccess { userUnlocks ->
val userAchievementEntities = userUnlocks.map { val userAchievementEntities = userUnlocks.map {
RAUserAchievementEntity( RAUserAchievementEntity(
gameId.id, gameId = gameId.id,
it, achievementId = it,
true, isUnlocked = true,
isHardcore = forHardcoreMode,
) )
} }
val newMetadata = gameSetMetadata.withNewUserAchievementsUpdate() val newMetadata = gameSetMetadata.withNewUserAchievementsUpdate(forHardcoreMode)
achievementsDao.updateGameUserUnlockedAchievements(gameId.id, userAchievementEntities) achievementsDao.updateGameUserUnlockedAchievements(gameId.id, userAchievementEntities)
achievementsDao.updateGameSetMetadata(newMetadata) achievementsDao.updateGameSetMetadata(newMetadata)
}.recoverCatching { exception -> }.recoverCatching { exception ->
if (gameSetMetadata.isUserAchievementDataKnown()) { if (gameSetMetadata.isUserAchievementDataKnown(forHardcoreMode)) {
// Load DB data because we know that it was previously loaded // Load DB data because we know that it was previously loaded
achievementsDao.getGameUserUnlockedAchievements(gameId.id).map { achievementsDao.getGameUserUnlockedAchievements(gameId.id, forHardcoreMode).map {
it.achievementId it.achievementId
} }
} else { } else {
@ -244,7 +250,7 @@ class AndroidRetroAchievementsRepository(
} }
} else { } else {
runCatching { runCatching {
achievementsDao.getGameUserUnlockedAchievements(gameId.id).map { achievementsDao.getGameUserUnlockedAchievements(gameId.id, forHardcoreMode).map {
it.achievementId it.achievementId
} }
} }
@ -268,13 +274,19 @@ class AndroidRetroAchievementsRepository(
return Duration.between(gameSetMetadata.lastAchievementSetUpdated, Instant.now()) >= Duration.ofDays(7) return Duration.between(gameSetMetadata.lastAchievementSetUpdated, Instant.now()) >= Duration.ofDays(7)
} }
private fun mustRefreshUserData(gameSetMetadata: RAGameSetMetadata?): Boolean { private fun mustRefreshUserData(gameSetMetadata: RAGameSetMetadata?, forHardcoreMode: Boolean): Boolean {
if (gameSetMetadata?.lastUserDataUpdated == null) { val lastUserDataUpdateTimestamp = if (forHardcoreMode) {
gameSetMetadata?.lastHardcoreUserDataUpdated
} else {
gameSetMetadata?.lastSoftcoreUserDataUpdated
}
if (lastUserDataUpdateTimestamp == null) {
return true return true
} }
// Sync user achievement data once a day // Sync user achievement data once a day
return Duration.between(gameSetMetadata.lastUserDataUpdated, Instant.now()) >= Duration.ofDays(1) return Duration.between(lastUserDataUpdateTimestamp, Instant.now()) >= Duration.ofDays(1)
} }
private fun scheduleAchievementSubmissionJob() { private fun scheduleAchievementSubmissionJob() {
@ -296,14 +308,20 @@ class AndroidRetroAchievementsRepository(
private set private set
fun withNewAchievementSetUpdate(): RAGameSetMetadata { fun withNewAchievementSetUpdate(): RAGameSetMetadata {
return (currentMetadata?.copy(lastAchievementSetUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, Instant.now(), null)).also { return (currentMetadata?.copy(lastAchievementSetUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, Instant.now(), null, null)).also {
currentMetadata = it currentMetadata = it
} }
} }
fun withNewUserAchievementsUpdate(): RAGameSetMetadata { fun withNewUserAchievementsUpdate(forHardcoreMode: Boolean): RAGameSetMetadata {
return (currentMetadata?.copy(lastUserDataUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, null, Instant.now())).also { return if (forHardcoreMode) {
currentMetadata = it currentMetadata?.copy(lastHardcoreUserDataUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, null, null, Instant.now()).also {
currentMetadata = it
}
} else {
currentMetadata?.copy(lastSoftcoreUserDataUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, null, Instant.now(), null).also {
currentMetadata = it
}
} }
} }
@ -311,8 +329,12 @@ class AndroidRetroAchievementsRepository(
return currentMetadata?.lastAchievementSetUpdated != null return currentMetadata?.lastAchievementSetUpdated != null
} }
fun isUserAchievementDataKnown(): Boolean { fun isUserAchievementDataKnown(forHardcoreMode: Boolean): Boolean {
return currentMetadata?.lastUserDataUpdated != null return if (forHardcoreMode) {
currentMetadata?.lastHardcoreUserDataUpdated != null
} else {
currentMetadata?.lastSoftcoreUserDataUpdated != null
}
} }
} }
} }

View File

@ -370,6 +370,10 @@ class SharedPreferencesSettingsRepository(
return preferences.getBoolean("ra_rich_presence", true) return preferences.getBoolean("ra_rich_presence", true)
} }
override fun isRetroAchievementsHardcoreEnabled(): Boolean {
return preferences.getBoolean("ra_hardcore_enabled", false)
}
override fun areCheatsEnabled(): Boolean { override fun areCheatsEnabled(): Boolean {
return preferences.getBoolean("cheats_enabled", false) return preferences.getBoolean("cheats_enabled", false)
} }

View File

@ -290,7 +290,7 @@ class EmulatorViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
if (retroAchievementsRepository.isUserAuthenticated()) { if (retroAchievementsRepository.isUserAuthenticated()) {
val achievementData = retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash).map { achievements -> val achievementData = retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash, false).map { achievements ->
achievements.filter { !it.isUnlocked }.map { RASimpleAchievement(it.achievement.id, it.achievement.memoryAddress) } achievements.filter { !it.isUnlocked }.map { RASimpleAchievement(it.achievement.id, it.achievement.memoryAddress) }
}.fold( }.fold(
onSuccess = { onSuccess = {
@ -315,7 +315,7 @@ class EmulatorViewModel @Inject constructor(
.onSuccess { .onSuccess {
if (it != null) { if (it != null) {
_achievementTriggeredEvent.emit(it) _achievementTriggeredEvent.emit(it)
retroAchievementsRepository.awardAchievement(it) retroAchievementsRepository.awardAchievement(it, false)
} }
} }
} }

View File

@ -40,7 +40,7 @@ class RomRetroAchievementsViewModel @Inject constructor(
private fun loadAchievements() { private fun loadAchievements() {
viewModelScope.launch { viewModelScope.launch {
if (retroAchievementsRepository.isUserAuthenticated()) { if (retroAchievementsRepository.isUserAuthenticated()) {
retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash).fold( retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash, false).fold(
onSuccess = { achievements -> onSuccess = { achievements ->
val sortedAchievements = achievements.sortedBy { val sortedAchievements = achievements.sortedBy {
// Display unlocked achievements first // Display unlocked achievements first
@ -82,7 +82,7 @@ class RomRetroAchievementsViewModel @Inject constructor(
return RomAchievementsSummary( return RomAchievementsSummary(
totalAchievements = userAchievements.size, totalAchievements = userAchievements.size,
completedAchievements = userAchievements.count { it.isUnlocked }, completedAchievements = userAchievements.count { it.isUnlocked },
totalPoints = userAchievements.sumOf { if (it.isUnlocked) it.achievement.points else 0 }, totalPoints = userAchievements.sumOf { it.pointsWorth() },
) )
} }
} }

View File

@ -191,6 +191,7 @@ fun PreviewRomAchievementUi() {
userAchievement = RAUserAchievement( userAchievement = RAUserAchievement(
achievement = mockRAAchievementPreview(), achievement = mockRAAchievementPreview(),
isUnlocked = true, isUnlocked = true,
forHardcoreMode = false,
), ),
onViewAchievement = {}, onViewAchievement = {},
) )

View File

@ -293,8 +293,8 @@ private fun PreviewContent() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
content = RomRetroAchievementsUiState.Ready( content = RomRetroAchievementsUiState.Ready(
listOf( listOf(
RAUserAchievement(mockRAAchievementPreview(id = 1), false), RAUserAchievement(mockRAAchievementPreview(id = 1), false, false),
RAUserAchievement(mockRAAchievementPreview(id = 2, title = "This is another amazing achievement", description = "But this one cannot be missed."), false), RAUserAchievement(mockRAAchievementPreview(id = 2, title = "This is another amazing achievement", description = "But this one cannot be missed."), false, false),
), ),
RomAchievementsSummary(50, 20, 85), RomAchievementsSummary(50, 20, 85),
), ),