diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/AchievementBlocks.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/AchievementBlocks.kt index 7d261eac7..f201dfa23 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/AchievementBlocks.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/AchievementBlocks.kt @@ -15,6 +15,8 @@ import java.util.Date * @param icon the relative URL to the achievement's icon (You will still need to complete the URL with Steam's CDN URL and appId) * @param iconGray the relative URL to the achievement's grayscale icon (shown when locked) (You will still need to complete the URL with Steam's URL and appId) * @param hidden whether the achievement is hidden until unlocked + * @param progressCurrent current value of a stat-linked progress achievement, normalized so 0 == min_val (null if none) + * @param progressMax max value of a stat-linked progress achievement (max_val - min_val) (null if none) */ @JavaSteamAddition data class AchievementBlocks( @@ -26,7 +28,14 @@ data class AchievementBlocks( val icon: String? = null, val iconGray: String? = null, val hidden: Boolean = false, + val progressCurrent: Float? = null, + val progressMax: Float? = null, ) { + /** + * Returns true if this achievement tracks partial progress (e.g. 45 / 100). + */ + val hasProgress: Boolean + get() = progressMax != null && progressMax > 0f /** * Returns true if this achievement is unlocked. * An achievement is considered unlocked if it has any non-zero unlock time. diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/callback/UserStatsCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/callback/UserStatsCallback.kt index 610a6dfd2..340de5963 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/callback/UserStatsCallback.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/callback/UserStatsCallback.kt @@ -108,16 +108,27 @@ class UserStatsCallback(packetMsg: IPacketMsg) : CallbackMsg() { @JvmOverloads fun getExpandedAchievements(language: String = "english"): List { val expandedAchievements = mutableListOf() + val statIdToValue = this.stats.associate { it.statId to it.statValue } try { - val stats = schemaKeyValues["stats"] - if (stats == KeyValue.INVALID) { + val schemaStats = schemaKeyValues["stats"] + if (schemaStats == KeyValue.INVALID) { return achievementBlocks // Return original blocks if schema parsing failed } + // Stat name -> id, from the stat definitions (entries with a "name" but no "bits") + val statNameToId = HashMap() + for (entry in schemaStats.children) { + val id = entry.name?.toIntOrNull() ?: continue + val statName = entry["name"].value + if (statName != null && entry["bits"] == KeyValue.INVALID) { + statNameToId[statName] = id + } + } + // Iterate through each achievement block for (block in achievementBlocks) { - val statBlock = stats[block.achievementId.toString()] + val statBlock = schemaStats[block.achievementId.toString()] val bitsBlock = statBlock["bits"] if (bitsBlock != KeyValue.INVALID) { @@ -141,6 +152,21 @@ class UserStatsCallback(packetMsg: IPacketMsg) : CallbackMsg() { 0 } + // Stat-linked progress (e.g. 45 / 100), if defined + var progressCurrent: Float? = null + var progressMax: Float? = null + val progress = bitEntry["progress"] + if (progress != KeyValue.INVALID) { + val maxVal = progress["max_val"].value?.toFloatOrNull() + val minVal = progress["min_val"].value?.toFloatOrNull() ?: 0f + val operand = progress["value"]["operand1"].value + val current = operand?.let { statNameToId[it] }?.let { statIdToValue[it]?.toFloat() } + if (maxVal != null && maxVal > minVal) { + progressMax = maxVal - minVal + progressCurrent = ((current ?: 0f) - minVal).coerceIn(0f, progressMax) + } + } + // Create individual achievement entry expandedAchievements.add( AchievementBlocks( @@ -151,7 +177,9 @@ class UserStatsCallback(packetMsg: IPacketMsg) : CallbackMsg() { description = description, icon = icon, iconGray = iconGray, - hidden = hidden + hidden = hidden, + progressCurrent = progressCurrent, + progressMax = progressMax ) ) } diff --git a/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java b/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java index 5a6d6e1a2..797a17686 100644 --- a/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java +++ b/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java @@ -292,6 +292,32 @@ public void testAchievementBlockWithManyUnlocks() throws IOException { Assertions.assertTrue(expanded.get(2).isUnlocked()); } + @Test + public void testGetExpandedAchievementsWithProgress() throws IOException { + // A locked achievement linked to a stat should expose its current/max progress + IPacketMsg testMsg = createProgressResponseMessage(); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + List expanded = callback.getExpandedAchievements(); + Assertions.assertEquals(1, expanded.size()); + + AchievementBlocks ach = expanded.get(0); + Assertions.assertEquals("ACH_SLAYER", ach.getName()); + Assertions.assertFalse(ach.isUnlocked()); + + // Progress is pulled from the linked "enemies_killed" stat (45) against max_val (100) + Assertions.assertTrue(ach.getHasProgress()); + Assertions.assertNotNull(ach.getProgressMax()); + Assertions.assertNotNull(ach.getProgressCurrent()); + Assertions.assertEquals(100f, ach.getProgressMax(), 0.001f); + Assertions.assertEquals(45f, ach.getProgressCurrent(), 0.001f); + } + /** * Helper method to create a mock UserStatsResponse packet with achievement * data. @@ -468,6 +494,91 @@ private byte[] createMockSchema() throws IOException { return baos.toByteArray(); } + /** + * Helper method to create a UserStatsResponse packet with a single locked, + * stat-linked progress achievement (current 45, max 100). + */ + private IPacketMsg createProgressResponseMessage() throws IOException { + ClientMsgProtobuf msg = new ClientMsgProtobuf<>( + CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + + CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); + body.setGameId(440L); + body.setEresult(EResult.OK.code()); + body.setCrcStats(123456); + body.setSchema(ByteString.copyFrom(createProgressSchema())); + + // One achievement block with a single locked achievement + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); + block.setAchievementId(21); + block.addUnlockTime(0); // Locked + body.addAchievementBlocks(block); + + // Current value of the linked stat (id 1) + body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() + .setStatId(1) + .setStatValue(45)); + + return CMClient.getPacketMsg(msg.serialize()); + } + + /** + * Helper method to create a schema with one progress achievement linked to a stat. + * The achievement references the "enemies_killed" stat with a max_val of 100. + */ + private byte[] createProgressSchema() throws IOException { + KeyValue wrapper = new KeyValue("UserGameStatsSchema"); + KeyValue stats = new KeyValue("stats"); + + // Stat definition the progress achievement is linked to + KeyValue stat1 = new KeyValue("1"); + stat1.getChildren().add(new KeyValue("type", "1")); + stat1.getChildren().add(new KeyValue("name", "enemies_killed")); + stats.getChildren().add(stat1); + + // Achievement block 21 with a single progress achievement + KeyValue block21 = new KeyValue("21"); + block21.getChildren().add(new KeyValue("type", "4")); + + KeyValue bits = new KeyValue("bits"); + KeyValue bit0 = new KeyValue("0"); + bit0.getChildren().add(new KeyValue("bit", "0")); + bit0.getChildren().add(new KeyValue("name", "ACH_SLAYER")); + + KeyValue display = new KeyValue("display"); + KeyValue name = new KeyValue("name"); + name.getChildren().add(new KeyValue("english", "Slayer")); + display.getChildren().add(name); + KeyValue desc = new KeyValue("desc"); + desc.getChildren().add(new KeyValue("english", "Kill 100 enemies")); + display.getChildren().add(desc); + display.getChildren().add(new KeyValue("icon", "slayer.jpg")); + display.getChildren().add(new KeyValue("icon_gray", "slayer_gray.jpg")); + display.getChildren().add(new KeyValue("hidden", "0")); + bit0.getChildren().add(display); + + // Progress: current value comes from the "enemies_killed" stat, max 100 + KeyValue progress = new KeyValue("progress"); + progress.getChildren().add(new KeyValue("min_val", "0")); + progress.getChildren().add(new KeyValue("max_val", "100")); + KeyValue value = new KeyValue("value"); + value.getChildren().add(new KeyValue("operation", "statvalue")); + value.getChildren().add(new KeyValue("operand1", "enemies_killed")); + progress.getChildren().add(value); + bit0.getChildren().add(progress); + + bits.getChildren().add(bit0); + block21.getChildren().add(bits); + stats.getChildren().add(block21); + + wrapper.getChildren().add(stats); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + wrapper.saveToStream(baos, true); // Binary format + return baos.toByteArray(); + } + private static void printKeyValue(KeyValue keyvalue, int depth) { if (keyvalue.getChildren().isEmpty()) System.out.println(" ".repeat(depth * 4) + " " + keyvalue.getName() + ": " + keyvalue.getValue());