Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,27 @@ class UserStatsCallback(packetMsg: IPacketMsg) : CallbackMsg() {
@JvmOverloads
fun getExpandedAchievements(language: String = "english"): List<AchievementBlocks> {
val expandedAchievements = mutableListOf<AchievementBlocks>()
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<String, Int>()
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) {
Expand All @@ -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()
Comment thread
LossyDragon marked this conversation as resolved.
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(
Expand All @@ -151,7 +177,9 @@ class UserStatsCallback(packetMsg: IPacketMsg) : CallbackMsg() {
description = description,
icon = icon,
iconGray = iconGray,
hidden = hidden
hidden = hidden,
progressCurrent = progressCurrent,
progressMax = progressMax
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AchievementBlocks> 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.
Expand Down Expand Up @@ -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<CMsgClientGetUserStatsResponse.Builder> 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());
Expand Down
Loading