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,
"database": {
"version": 4,
"identityHash": "31374048dc97dc955a845a6920edacaa",
"identityHash": "afbfe1a8a5ee33a9307efcca89acecbd",
"entities": [
{
"tableName": "cheat_database",
@ -365,7 +365,7 @@
},
{
"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": [
{
"fieldPath": "gameId",
@ -384,13 +384,20 @@
"columnName": "is_unlocked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isHardcore",
"columnName": "is_hardcore",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"game_id",
"achievement_id"
"achievement_id",
"is_hardcore"
]
},
"indices": [],
@ -398,7 +405,7 @@
},
{
"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": [
{
"fieldPath": "gameId",
@ -413,10 +420,16 @@
"notNull": false
},
{
"fieldPath": "lastUserDataUpdated",
"fieldPath": "lastSoftcoreUserDataUpdated",
"columnName": "last_user_data_updated",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastHardcoreUserDataUpdated",
"columnName": "last_hardcore_user_data_updated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
@ -491,7 +504,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '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")
abstract suspend fun getGame(gameId: Long): RAGameEntity?
@Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_unlocked = 1")
abstract suspend fun getGameUserUnlockedAchievements(gameId: Long): List<RAUserAchievementEntity>
@Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_hardcore = :forHardcoreMode AND is_unlocked = 1")
abstract suspend fun getGameUserUnlockedAchievements(gameId: Long, forHardcoreMode: Boolean): List<RAUserAchievementEntity>
@Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId")
protected abstract suspend fun deleteGameUserUnlockedAchievements(gameId: Long)

View File

@ -9,5 +9,6 @@ import java.time.Instant
data class RAGameSetMetadata(
@PrimaryKey @ColumnInfo(name = "game_id") val gameId: Long,
@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(
tableName = "ra_user_achievement",
primaryKeys = ["game_id", "achievement_id"],
primaryKeys = ["game_id", "achievement_id", "is_hardcore"],
)
data class RAUserAchievementEntity(
@ColumnInfo(name = "game_id") val gameId: Long,
@ColumnInfo(name = "achievement_id") val achievementId: Long,
@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(
val achievement: RAAchievement,
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 login(username: String, password: String): Result<Unit>
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 getAchievement(achievementId: Long): Result<RAAchievement?>
suspend fun awardAchievement(achievement: RAAchievement)
suspend fun awardAchievement(achievement: RAAchievement, forHardcoreMode: Boolean)
suspend fun submitPendingAchievements(): Result<Unit>
suspend fun startSession(gameHash: String)
suspend fun sendSessionHeartbeat(gameHash: String, richPresenceDescription: String?)

View File

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

View File

@ -55,7 +55,7 @@ class AndroidRetroAchievementsRepository(
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)
if (gameIdResult.isFailure) {
return Result.failure(gameIdResult.exceptionOrNull()!!)
@ -74,7 +74,7 @@ class AndroidRetroAchievementsRepository(
return Result.failure(gameAchievementsResult.exceptionOrNull()!!)
}
val userUnlocksResult = fetchGameUserUnlockedAchievements(gameId, currentMetadata)
val userUnlocksResult = fetchGameUserUnlockedAchievements(gameId, forHardcoreMode, currentMetadata)
if (userUnlocksResult.isFailure) {
return Result.failure(userUnlocksResult.exceptionOrNull()!!)
}
@ -86,6 +86,7 @@ class AndroidRetroAchievementsRepository(
RAUserAchievement(
achievement = it,
isUnlocked = userUnlocks.contains(it.id),
forHardcoreMode = forHardcoreMode,
)
}
return Result.success(userAchievements)
@ -108,14 +109,16 @@ class AndroidRetroAchievementsRepository(
}
}
override suspend fun awardAchievement(achievement: RAAchievement) {
submitAchievementAward(achievement.id, achievement.gameId, false, true)
override suspend fun awardAchievement(achievement: RAAchievement, forHardcoreMode: Boolean) {
submitAchievementAward(achievement.id, achievement.gameId, forHardcoreMode).onFailure {
scheduleAchievementSubmissionJob()
}
}
override suspend fun submitPendingAchievements(): Result<Unit> {
achievementsDao.getPendingAchievementSubmissions().forEach {
// 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) {
return submissionResult
}
@ -136,9 +139,14 @@ class AndroidRetroAchievementsRepository(
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
val userAchievement = RAUserAchievementEntity(gameId.id, achievementId, true)
val userAchievement = RAUserAchievementEntity(
gameId = gameId.id,
achievementId = achievementId,
isUnlocked = true,
isHardcore = forHardcoreMode,
)
achievementsDao.addUserAchievement(userAchievement)
return raApi.awardAchievement(achievementId, forHardcoreMode).onFailure {
@ -149,9 +157,6 @@ class AndroidRetroAchievementsRepository(
forHardcoreMode = forHardcoreMode,
)
achievementsDao.addPendingAchievementSubmission(pendingAchievementSubmissionEntity)
if (scheduleResubmissionOnFailure) {
scheduleAchievementSubmissionJob()
}
}
}
@ -217,24 +222,25 @@ class AndroidRetroAchievementsRepository(
}
}
private suspend fun fetchGameUserUnlockedAchievements(gameId: RAGameId, gameSetMetadata: CurrentGameSetMetadata): Result<List<Long>> {
return if (mustRefreshUserData(gameSetMetadata.currentMetadata)) {
raApi.getUserUnlockedAchievements(gameId, false).onSuccess { userUnlocks ->
private suspend fun fetchGameUserUnlockedAchievements(gameId: RAGameId, forHardcoreMode: Boolean, gameSetMetadata: CurrentGameSetMetadata): Result<List<Long>> {
return if (mustRefreshUserData(gameSetMetadata.currentMetadata, forHardcoreMode)) {
raApi.getUserUnlockedAchievements(gameId, forHardcoreMode).onSuccess { userUnlocks ->
val userAchievementEntities = userUnlocks.map {
RAUserAchievementEntity(
gameId.id,
it,
true,
gameId = gameId.id,
achievementId = it,
isUnlocked = true,
isHardcore = forHardcoreMode,
)
}
val newMetadata = gameSetMetadata.withNewUserAchievementsUpdate()
val newMetadata = gameSetMetadata.withNewUserAchievementsUpdate(forHardcoreMode)
achievementsDao.updateGameUserUnlockedAchievements(gameId.id, userAchievementEntities)
achievementsDao.updateGameSetMetadata(newMetadata)
}.recoverCatching { exception ->
if (gameSetMetadata.isUserAchievementDataKnown()) {
if (gameSetMetadata.isUserAchievementDataKnown(forHardcoreMode)) {
// Load DB data because we know that it was previously loaded
achievementsDao.getGameUserUnlockedAchievements(gameId.id).map {
achievementsDao.getGameUserUnlockedAchievements(gameId.id, forHardcoreMode).map {
it.achievementId
}
} else {
@ -244,7 +250,7 @@ class AndroidRetroAchievementsRepository(
}
} else {
runCatching {
achievementsDao.getGameUserUnlockedAchievements(gameId.id).map {
achievementsDao.getGameUserUnlockedAchievements(gameId.id, forHardcoreMode).map {
it.achievementId
}
}
@ -268,13 +274,19 @@ class AndroidRetroAchievementsRepository(
return Duration.between(gameSetMetadata.lastAchievementSetUpdated, Instant.now()) >= Duration.ofDays(7)
}
private fun mustRefreshUserData(gameSetMetadata: RAGameSetMetadata?): Boolean {
if (gameSetMetadata?.lastUserDataUpdated == null) {
private fun mustRefreshUserData(gameSetMetadata: RAGameSetMetadata?, forHardcoreMode: Boolean): Boolean {
val lastUserDataUpdateTimestamp = if (forHardcoreMode) {
gameSetMetadata?.lastHardcoreUserDataUpdated
} else {
gameSetMetadata?.lastSoftcoreUserDataUpdated
}
if (lastUserDataUpdateTimestamp == null) {
return true
}
// 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() {
@ -296,14 +308,20 @@ class AndroidRetroAchievementsRepository(
private set
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
}
}
fun withNewUserAchievementsUpdate(): RAGameSetMetadata {
return (currentMetadata?.copy(lastUserDataUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, null, Instant.now())).also {
currentMetadata = it
fun withNewUserAchievementsUpdate(forHardcoreMode: Boolean): RAGameSetMetadata {
return if (forHardcoreMode) {
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
}
fun isUserAchievementDataKnown(): Boolean {
return currentMetadata?.lastUserDataUpdated != null
fun isUserAchievementDataKnown(forHardcoreMode: Boolean): Boolean {
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)
}
override fun isRetroAchievementsHardcoreEnabled(): Boolean {
return preferences.getBoolean("ra_hardcore_enabled", false)
}
override fun areCheatsEnabled(): Boolean {
return preferences.getBoolean("cheats_enabled", false)
}

View File

@ -290,7 +290,7 @@ class EmulatorViewModel @Inject constructor(
viewModelScope.launch {
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) }
}.fold(
onSuccess = {
@ -315,7 +315,7 @@ class EmulatorViewModel @Inject constructor(
.onSuccess {
if (it != null) {
_achievementTriggeredEvent.emit(it)
retroAchievementsRepository.awardAchievement(it)
retroAchievementsRepository.awardAchievement(it, false)
}
}
}

View File

@ -40,7 +40,7 @@ class RomRetroAchievementsViewModel @Inject constructor(
private fun loadAchievements() {
viewModelScope.launch {
if (retroAchievementsRepository.isUserAuthenticated()) {
retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash).fold(
retroAchievementsRepository.getGameUserAchievements(rom.retroAchievementsHash, false).fold(
onSuccess = { achievements ->
val sortedAchievements = achievements.sortedBy {
// Display unlocked achievements first
@ -82,7 +82,7 @@ class RomRetroAchievementsViewModel @Inject constructor(
return RomAchievementsSummary(
totalAchievements = userAchievements.size,
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(
achievement = mockRAAchievementPreview(),
isUnlocked = true,
forHardcoreMode = false,
),
onViewAchievement = {},
)

View File

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