From ed9e11909bbc11a1806f67b3c16d7dc9b5515a27 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 21 Nov 2025 21:20:08 +0000 Subject: [PATCH 01/21] feat: Add Steam achievements support with expanded metadata parsing - Add getExpandedAchievements() to UserStatsCallback to parse bit-level achievements from schema - Add Sample 32 to demonstrate achievement retrieval with full metadata - Parse achievement blocks into individual achievements with names, descriptions, icons, and unlock times - Support for base game and DLC achievements organized in stat blocks --- .../_032_achievements/SampleAchievements.java | 293 ++++++++++++++++++ .../callback/UserStatsCallback.kt | 128 +++++++- 2 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java new file mode 100644 index 00000000..5fc183d6 --- /dev/null +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java @@ -0,0 +1,293 @@ +package in.dragonbra.javasteamsamples._032_achievements; + +import in.dragonbra.javasteam.enums.EResult; +import in.dragonbra.javasteam.steam.authentication.AuthPollResult; +import in.dragonbra.javasteam.steam.authentication.AuthSession; +import in.dragonbra.javasteam.steam.authentication.AuthSessionDetails; +import in.dragonbra.javasteam.steam.authentication.AuthenticationException; +import in.dragonbra.javasteam.steam.authentication.IAuthenticator; +import in.dragonbra.javasteam.steam.handlers.steamuser.LogOnDetails; +import in.dragonbra.javasteam.steam.handlers.steamuser.SteamUser; +import in.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback; +import in.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOnCallback; +import in.dragonbra.javasteam.steam.handlers.steamuserstats.AchievementBlocks; +import in.dragonbra.javasteam.steam.handlers.steamuserstats.SteamUserStats; +import in.dragonbra.javasteam.steam.handlers.steamuserstats.callback.UserStatsCallback; +import in.dragonbra.javasteam.steam.steamclient.SteamClient; +import in.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackManager; +import in.dragonbra.javasteam.steam.steamclient.callbacks.ConnectedCallback; +import in.dragonbra.javasteam.steam.steamclient.callbacks.DisconnectedCallback; +import in.dragonbra.javasteam.types.SteamID; +import in.dragonbra.javasteam.util.log.DefaultLogListener; +import in.dragonbra.javasteam.util.log.LogManager; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; + +/** + * Sample 32: Steam Achievements Demonstrates retrieving achievement data for a + * Steam game using UserStatsCallback. Shows both the raw achievement blocks and + * the expanded individual achievements. + */ +@SuppressWarnings("FieldCanBeLocal") +public class SampleAchievements implements Runnable { + + // Default to Team Fortress 2 (Free to play game with achievements) + // Other free games with achievements you can try: + // - 730 (CS:GO/CS2) + // - 440 (Team Fortress 2) + // - 570 (Dota 2) + // - 49520 (Borderlands 2 - requires ownership) + private static final int DEFAULT_APP_ID = 440; + + private SteamClient steamClient; + private CallbackManager manager; + private SteamUser steamUser; + private SteamUserStats steamUserStats; + private boolean isRunning; + private final String user; + private final String pass; + private final int appId; + private SteamID currentUserSteamID; + + public SampleAchievements(String user, String pass, int appId) { + this.user = user; + this.pass = pass; + this.appId = appId; + } + + public static void main(String[] args) { + if (args.length < 2) { + System.out.println("Sample 032: Steam Achievements"); + System.out.println("Usage: sample032 [appid]"); + System.out.println(" appid: Optional Steam App ID (default: 49520 - Borderlands 2)"); + System.out.println(" You will be prompted for 2FA code if needed"); + return; + } + + int appId = DEFAULT_APP_ID; + if (args.length >= 3) { + try { + appId = Integer.parseInt(args[2]); + } catch (NumberFormatException e) { + System.err.println("Invalid App ID, using default: " + DEFAULT_APP_ID); + } + } + + LogManager.addListener(new DefaultLogListener()); + new SampleAchievements(args[0], args[1], appId).run(); + } + + @Override + public void run() { + steamClient = new SteamClient(); + manager = new CallbackManager(steamClient); + steamUser = steamClient.getHandler(SteamUser.class); + steamUserStats = steamClient.getHandler(SteamUserStats.class); + + manager.subscribe(ConnectedCallback.class, this::onConnected); + manager.subscribe(DisconnectedCallback.class, this::onDisconnected); + manager.subscribe(LoggedOnCallback.class, this::onLoggedOn); + manager.subscribe(LoggedOffCallback.class, this::onLoggedOff); + manager.subscribe(UserStatsCallback.class, this::onUserStats); + + isRunning = true; + System.out.println("Connecting to Steam..."); + steamClient.connect(); + + while (isRunning) { + manager.runWaitCallbacks(1000L); + } + } + + private void onConnected(ConnectedCallback callback) { + System.out.println("Connected! Logging in as " + user + "..."); + + AuthSessionDetails authDetails = new AuthSessionDetails(); + authDetails.username = user; + authDetails.password = pass; + authDetails.persistentSession = false; + authDetails.authenticator = new ConsoleAuthenticator(); + + try { + var authSession = steamClient.getAuthentication().beginAuthSessionViaCredentials(authDetails).get(); + AuthPollResult pollResponse = authSession.pollingWaitForResult().get(); + + LogOnDetails details = new LogOnDetails(); + details.setUsername(pollResponse.getAccountName()); + details.setAccessToken(pollResponse.getRefreshToken()); + details.setShouldRememberPassword(true); + + steamUser.logOn(details); + } catch (Exception e) { + if (e instanceof AuthenticationException) { + System.err.println("Authentication error: " + e.getMessage()); + } else if (e instanceof CancellationException) { + System.err.println("Timeout occurred: " + e.getMessage()); + } else { + System.err.println("Error: " + e.getMessage()); + } + isRunning = false; + } + } + + private void onDisconnected(DisconnectedCallback callback) { + System.out.println("Disconnected from Steam"); + isRunning = false; + } + + private void onLoggedOn(LoggedOnCallback callback) { + if (callback.getResult() != EResult.OK) { + System.out.println("Unable to logon: " + callback.getResult() + " / " + callback.getExtendedResult()); + isRunning = false; + return; + } + + currentUserSteamID = callback.getClientSteamID(); + System.out.println("Logged on! SteamID: " + currentUserSteamID.convertToUInt64()); + System.out.println(); + System.out.println("Requesting achievement data for App ID: " + appId + "..."); + + steamUserStats.getUserStats(appId, currentUserSteamID); + } + + private void onUserStats(UserStatsCallback callback) { + System.out.println("\n" + "=".repeat(80)); + System.out.println("ACHIEVEMENT DATA RECEIVED"); + System.out.println("=".repeat(80) + "\n"); + + System.out.println("Result: " + callback.getResult()); + System.out.println("Game ID: " + callback.getGameId()); + + if (callback.getResult() != EResult.OK) { + System.err.println("\nFailed to get achievements: " + callback.getResult()); + System.err.println("This could mean:"); + System.err.println(" - The game doesn't have Steam achievements"); + System.err.println(" - You don't own the game"); + System.err.println(" - The game hasn't been launched yet (required for some games)"); + System.err.println(" - Steam servers are having issues"); + steamClient.disconnect(); + return; + } + + // Get the raw achievement blocks + List blocks = callback.getAchievementBlocks(); + System.out.println("\nAchievement Blocks (Base Game + DLCs): " + blocks.size()); + + for (int i = 0; i < blocks.size(); i++) { + AchievementBlocks block = blocks.get(i); + long unlocked = block.getUnlockTime().stream().filter(t -> t > 0).count(); + System.out.println(" Block " + (i + 1) + " (ID " + block.getAchievementId() + "): " + + unlocked + "/" + block.getUnlockTime().size() + " unlocked"); + } + + // Get the expanded individual achievements + List achievements = callback.getExpandedAchievements(); + System.out.println("\nTotal Individual Achievements: " + achievements.size()); + + long unlockedCount = achievements.stream().filter(AchievementBlocks::isUnlocked).count(); + System.out.println("Unlocked: " + unlockedCount); + System.out.println("Locked: " + (achievements.size() - unlockedCount)); + System.out.println("Completion: " + String.format("%.1f%%", (unlockedCount * 100.0 / achievements.size()))); + + System.out.println("\n" + "=".repeat(80)); + System.out.println("ACHIEVEMENT DETAILS"); + System.out.println("=".repeat(80) + "\n"); + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + for (int i = 0; i < achievements.size(); i++) { + AchievementBlocks achievement = achievements.get(i); + + System.out.println(String.format("[%d/%d] %s", + i + 1, + achievements.size(), + achievement.getDisplayName() != null ? achievement.getDisplayName() + : (achievement.getName() != null ? achievement.getName() : "Achievement #" + achievement.getAchievementId()) + )); + + if (achievement.getDescription() != null && !achievement.getDescription().isEmpty()) { + System.out.println(" \"" + achievement.getDescription() + "\""); + } + + System.out.println(" Status: " + (achievement.isUnlocked() ? "✓ UNLOCKED" : "✗ LOCKED")); + + if (achievement.isUnlocked() && achievement.getUnlockTimestamp() > 0) { + String unlockDate = dateFormat.format(new Date(achievement.getUnlockTimestamp() * 1000L)); + System.out.println(" Unlocked: " + unlockDate); + } + + if (achievement.getHidden()) { + System.out.println(" [Hidden Achievement]"); + } + + // Show icon URLs if available + if (achievement.getIcon() != null && !achievement.getIcon().isEmpty()) { + String iconUrl = "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/" + + callback.getGameId() + "/" + achievement.getIcon(); + System.out.println(" Icon: " + iconUrl); + } + + System.out.println(); + } + + System.out.println("=".repeat(80)); + steamClient.disconnect(); + } + + private void onLoggedOff(LoggedOffCallback callback) { + System.out.println("Logged off: " + callback.getResult()); + isRunning = false; + } + + /** + * Simple console-based authenticator for handling 2FA codes + */ + private static class ConsoleAuthenticator implements IAuthenticator { + + @Override + public CompletableFuture getDeviceCode(boolean previousCodeWasIncorrect) { + if (previousCodeWasIncorrect) { + System.err.println("Previous code was incorrect, please try again."); + } + System.out.print("Enter 2FA code from your authenticator app: "); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String code = reader.readLine(); + return CompletableFuture.completedFuture(code); + } catch (Exception e) { + System.err.println("Failed to read 2FA code: " + e.getMessage()); + return CompletableFuture.failedFuture(e); + } + } + + @Override + public CompletableFuture getEmailCode(String email, boolean previousCodeWasIncorrect) { + if (previousCodeWasIncorrect) { + System.err.println("Previous code was incorrect, please try again."); + } + System.out.print("Enter email code sent to " + email + ": "); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String code = reader.readLine(); + return CompletableFuture.completedFuture(code); + } catch (Exception e) { + System.err.println("Failed to read email code: " + e.getMessage()); + return CompletableFuture.failedFuture(e); + } + } + + @Override + public CompletableFuture acceptDeviceConfirmation() { + System.out.println("STEAM GUARD! Please confirm this login on your Steam Mobile App..."); + // Return true to indicate we want to poll for confirmation + // The AuthSession will handle the actual polling loop + return CompletableFuture.completedFuture(true); + } + } +} 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 fb1f3aa1..55600c0a 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 @@ -70,12 +70,132 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { stats = resp.statsList.map { Stats(statId = it.statId, statValue = it.statValue) } - achievementBlocks = resp.achievementBlocksList.map { - AchievementBlocks(achievementId = it.achievementId, unlockTime = it.unlockTimeList) + + // Parse the schema first so we can enrich achievement data + // Some games may have empty or invalid schemas, so handle gracefully + try { + if (schema.size() > 0) { + MemoryStream(schema.toByteArray()).use { + schemaKeyValues.tryReadAsBinary(it) + } + } + } catch (e: Exception) { + // Schema parsing failed, schemaKeyValues will remain empty + // This is okay - we'll just have achievements without enriched metadata + } + + // Build achievement blocks as originally provided + achievementBlocks = resp.achievementBlocksList.map { block -> + AchievementBlocks( + achievementId = block.achievementId, + unlockTime = block.unlockTimeList, + name = null, + displayName = null, + description = null, + icon = null, + iconGray = null, + hidden = false + ) } + } + + /** + * Expands achievement blocks into individual achievements based on the bit-level structure in the schema. + * Each achievement block contains multiple achievements as bits (typically 32 per block for base game and DLCs). + * + * @return List of individual [AchievementBlocks] with enriched metadata from schema + */ + fun getExpandedAchievements(): List { + val expandedAchievements = mutableListOf() + + try { + val stats = schemaKeyValues.get("stats") + if (stats == null) return achievementBlocks // Return original blocks if schema parsing failed + + // Iterate through each achievement block + for (block in achievementBlocks) { + val statBlock = stats.get(block.achievementId.toString()) + val bitsBlock = statBlock?.get("bits") + + if (bitsBlock != null) { + // This block has bit-level achievements, expand them + for (bitEntry in bitsBlock.children) { + val bitIndex = bitEntry.get("bit")?.asInteger() ?: continue + val displaySection = bitEntry.get("display") + + // Extract metadata + val name = bitEntry.get("name")?.value + val displayName = displaySection?.get("name")?.get("english")?.value + val description = displaySection?.get("desc")?.get("english")?.value + val icon = displaySection?.get("icon")?.value + val iconGray = displaySection?.get("icon_gray")?.value + val hidden = displaySection?.get("hidden")?.value == "1" + + // Get unlock time for this specific bit + val unlockTime = if (bitIndex < block.unlockTime.size) { + block.unlockTime[bitIndex] + } else { + 0 + } - MemoryStream(schema.toByteArray()).use { - schemaKeyValues.tryReadAsBinary(it) + // Create individual achievement entry + expandedAchievements.add( + AchievementBlocks( + achievementId = block.achievementId * 100 + bitIndex, // Create unique ID + unlockTime = listOf(unlockTime), // Single timestamp for this achievement + name = name, + displayName = displayName, + description = description, + icon = icon, + iconGray = iconGray, + hidden = hidden + ) + ) + } + } else { + // No bits structure, keep the block as-is + expandedAchievements.add(block) + } + } + } catch (e: Exception) { + // If expansion fails, return original blocks + return achievementBlocks } + + return expandedAchievements + } + + /** + * Builds a map of achievement ID to metadata from the schema KeyValue. + */ + private fun buildAchievementMetadataMap(schema: KeyValue): Map> { + val metadataMap = mutableMapOf>() + + try { + val achievements = schema.get("stats")?.get("achievements") + + if (achievements != null) { + for (achievement in achievements.children) { + val idKey = achievement.get("id") + if (idKey != null) { + val achievementId = idKey.asInteger() + val metadata = mutableMapOf() + + achievement.get("name")?.value?.let { metadata["name"] = it } + achievement.get("displayName")?.value?.let { metadata["displayName"] = it } + achievement.get("description")?.value?.let { metadata["description"] = it } + achievement.get("icon")?.value?.let { metadata["icon"] = it } + achievement.get("icon_gray")?.value?.let { metadata["iconGray"] = it } + achievement.get("hidden")?.value?.let { metadata["hidden"] = it } + + metadataMap[achievementId] = metadata + } + } + } + } catch (e: Exception) { + // If schema parsing fails, return empty map + } + + return metadataMap } } From 857efad206f3848f8eddf38ffc84f0485567b281 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 21 Nov 2025 21:53:26 +0000 Subject: [PATCH 02/21] comments and clarifications --- .../_032_achievements/SampleAchievements.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java index 5fc183d6..3c199cb3 100644 --- a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java @@ -40,7 +40,7 @@ public class SampleAchievements implements Runnable { // Default to Team Fortress 2 (Free to play game with achievements) // Other free games with achievements you can try: // - 730 (CS:GO/CS2) - // - 440 (Team Fortress 2) + // - 440 (Team Fortress 2) // - 570 (Dota 2) // - 49520 (Borderlands 2 - requires ownership) private static final int DEFAULT_APP_ID = 440; @@ -65,7 +65,7 @@ public static void main(String[] args) { if (args.length < 2) { System.out.println("Sample 032: Steam Achievements"); System.out.println("Usage: sample032 [appid]"); - System.out.println(" appid: Optional Steam App ID (default: 49520 - Borderlands 2)"); + System.out.println(" appid: Optional Steam App ID (default: 440 - Team Fortress 2)"); System.out.println(" You will be prompted for 2FA code if needed"); return; } @@ -156,6 +156,7 @@ private void onLoggedOn(LoggedOnCallback callback) { steamUserStats.getUserStats(appId, currentUserSteamID); } + //! This is where the meat of the Sample is. private void onUserStats(UserStatsCallback callback) { System.out.println("\n" + "=".repeat(80)); System.out.println("ACHIEVEMENT DATA RECEIVED"); @@ -179,6 +180,11 @@ private void onUserStats(UserStatsCallback callback) { List blocks = callback.getAchievementBlocks(); System.out.println("\nAchievement Blocks (Base Game + DLCs): " + blocks.size()); + // There can be multiple blocks when you grab an appId. (Usually Game + DLC/Expansions) + // AchievementBlocks are filtered into unlockTimes + // Timestamp exists? -> Unlocked + // Timestamp does not exist? -> Locked + // Timestamps are in an array and are sorted by the ID of the specific achievement for (int i = 0; i < blocks.size(); i++) { AchievementBlocks block = blocks.get(i); long unlocked = block.getUnlockTime().stream().filter(t -> t > 0).count(); @@ -188,8 +194,10 @@ private void onUserStats(UserStatsCallback callback) { // Get the expanded individual achievements List achievements = callback.getExpandedAchievements(); + System.out.println("\nTotal Individual Achievements: " + achievements.size()); + // Now we can filter out if they are unlocked thanks to the timestamp. long unlockedCount = achievements.stream().filter(AchievementBlocks::isUnlocked).count(); System.out.println("Unlocked: " + unlockedCount); System.out.println("Locked: " + (achievements.size() - unlockedCount)); From 9271a34a5126e49f75174b19714b9ae5b4c24e6c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 21 Nov 2025 23:08:48 +0000 Subject: [PATCH 03/21] Updated the data class for AchievementBlocks. --- .../steamuserstats/AchievementBlocks.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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 f2c4fd19..fc37959d 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 @@ -7,6 +7,36 @@ import `in`.dragonbra.javasteam.util.JavaSteamAddition * @param achievementId the achievement id. * @param unlockTime a [List] of integers containing when an achievement was unlocked. * An unlockTime of 0 means it has not been achieved, unlocked achievements are displayed as valve-timestamps. + * @param name the internal name of the achievement (e.g., "ACH_FIRST_BLOOD") + * @param displayName the localized display name of the achievement + * @param description the localized description of the achievement + * @param icon the URL to the achievement's icon + * @param iconGray the URL to the achievement's grayscale icon (shown when locked) + * @param hidden whether the achievement is hidden until unlocked */ @JavaSteamAddition -data class AchievementBlocks(val achievementId: Int, val unlockTime: List) +data class AchievementBlocks( + val achievementId: Int, + val unlockTime: List, + val name: String? = null, + val displayName: String? = null, + val description: String? = null, + val icon: String? = null, + val iconGray: String? = null, + val hidden: Boolean = false +) { + /** + * Returns true if this achievement is unlocked. + * An achievement is considered unlocked if it has any non-zero unlock time. + */ + val isUnlocked: Boolean + get() = unlockTime.any { it > 0 } + + /** + * Returns the unlock timestamp as a single integer. + * For expanded achievements with a single timestamp, returns that value. + * For blocks with multiple timestamps, returns the first non-zero timestamp, or 0 if all locked. + */ + val unlockTimestamp: Int + get() = unlockTime.firstOrNull { it > 0 } ?: 0 +} From 961fb9d8fd7bb5d7db0152b3bfb357aa0fe94bfb Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 21 Nov 2025 23:09:46 +0000 Subject: [PATCH 04/21] Generated Unit test suite. Going to actually go through this and work through it, but at least I've set got it set up to be asserted and now I can make adjustments. --- .../steamuserstats/SteamUserStatsTest.java | 419 ++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java 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 new file mode 100644 index 00000000..d58a5727 --- /dev/null +++ b/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java @@ -0,0 +1,419 @@ +package in.dragonbra.javasteam.steam.handlers.steamuserstats; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.google.protobuf.ByteString; + +import in.dragonbra.javasteam.base.ClientMsgProtobuf; +import in.dragonbra.javasteam.base.IPacketMsg; +import in.dragonbra.javasteam.enums.EMsg; +import in.dragonbra.javasteam.enums.EResult; +import in.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverUserstats.CMsgClientGetUserStatsResponse; +import in.dragonbra.javasteam.steam.CMClient; +import in.dragonbra.javasteam.steam.handlers.HandlerTestBase; +import in.dragonbra.javasteam.steam.handlers.steamuserstats.callback.UserStatsCallback; +import in.dragonbra.javasteam.types.KeyValue; + +/** + * Unit tests for SteamUserStats handler, specifically testing achievement + * parsing functionality. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SteamUserStatsTest extends HandlerTestBase { + + @Override + protected SteamUserStats createHandler() { + return new SteamUserStats(); + } + + /** + * Helper method to create a mock schema with achievement metadata. This + * simulates the KeyValue schema structure returned by Steam. + * + * The schema needs to be structured so that when read by + * KeyValue.tryReadAsBinary(), it creates: schemaKeyValues -> stats -> + * [blocks] This means we need to write binary that starts with a wrapper + * node containing stats. + */ + private byte[] createMockSchema() throws IOException { + // Create a wrapper so schemaKeyValues will have "stats" as a child, not be "stats" itself + KeyValue wrapper = new KeyValue("UserGameStatsSchema"); // Mimicking Steam's actual format + KeyValue stats = new KeyValue("stats"); + + // Block 21 - Base game achievements (3 achievements) + KeyValue block21 = new KeyValue("21"); + block21.getChildren().add(new KeyValue("type", "4")); // Type 4 = achievement block + + KeyValue bits21 = new KeyValue("bits"); + + // Achievement 0 in block 21 + KeyValue bit0 = new KeyValue("0"); + bit0.getChildren().add(new KeyValue("bit", "0")); // Bit index as a property + bit0.getChildren().add(new KeyValue("name", "ACH_FIRST_BLOOD")); + KeyValue display0 = new KeyValue("display"); + KeyValue name0 = new KeyValue("name"); + name0.getChildren().add(new KeyValue("english", "First Blood")); + display0.getChildren().add(name0); + KeyValue desc0 = new KeyValue("desc"); + desc0.getChildren().add(new KeyValue("english", "Kill your first enemy")); + display0.getChildren().add(desc0); + display0.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_0.jpg")); + display0.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_0_gray.jpg")); + display0.getChildren().add(new KeyValue("hidden", "0")); + bit0.getChildren().add(display0); + bits21.getChildren().add(bit0); + + // Achievement 1 in block 21 + KeyValue bit1 = new KeyValue("1"); + bit1.getChildren().add(new KeyValue("bit", "1")); // Bit index as a property + bit1.getChildren().add(new KeyValue("name", "ACH_VETERAN")); + KeyValue display1 = new KeyValue("display"); + KeyValue name1 = new KeyValue("name"); + name1.getChildren().add(new KeyValue("english", "Veteran")); + display1.getChildren().add(name1); + KeyValue desc1 = new KeyValue("desc"); + desc1.getChildren().add(new KeyValue("english", "Reach level 10")); + display1.getChildren().add(desc1); + display1.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_1.jpg")); + display1.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_1_gray.jpg")); + display1.getChildren().add(new KeyValue("hidden", "0")); + bit1.getChildren().add(display1); + bits21.getChildren().add(bit1); + + // Achievement 2 in block 21 (hidden achievement) + KeyValue bit2 = new KeyValue("2"); + bit2.getChildren().add(new KeyValue("bit", "2")); // Bit index as a property + bit2.getChildren().add(new KeyValue("name", "ACH_SECRET")); + KeyValue display2 = new KeyValue("display"); + KeyValue name2 = new KeyValue("name"); + name2.getChildren().add(new KeyValue("english", "Secret Achievement")); + display2.getChildren().add(name2); + KeyValue desc2 = new KeyValue("desc"); + desc2.getChildren().add(new KeyValue("english", "Find the secret")); + display2.getChildren().add(desc2); + display2.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_2.jpg")); + display2.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_2_gray.jpg")); + display2.getChildren().add(new KeyValue("hidden", "1")); // Hidden achievement + bit2.getChildren().add(display2); + bits21.getChildren().add(bit2); + + block21.getChildren().add(bits21); + stats.getChildren().add(block21); + + // Block 22 - DLC achievements (2 achievements) + KeyValue block22 = new KeyValue("22"); + block22.getChildren().add(new KeyValue("type", "4")); + + KeyValue bits22 = new KeyValue("bits"); + + // Achievement 0 in block 22 (DLC) + KeyValue bit22_0 = new KeyValue("0"); + bit22_0.getChildren().add(new KeyValue("bit", "0")); // Bit index as a property + bit22_0.getChildren().add(new KeyValue("name", "ACH_DLC_MASTER")); + KeyValue display22_0 = new KeyValue("display"); + KeyValue name22_0 = new KeyValue("name"); + name22_0.getChildren().add(new KeyValue("english", "DLC Master")); + display22_0.getChildren().add(name22_0); + KeyValue desc22_0 = new KeyValue("desc"); + desc22_0.getChildren().add(new KeyValue("english", "Complete all DLC missions")); + display22_0.getChildren().add(desc22_0); + display22_0.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_0.jpg")); + display22_0.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_0_gray.jpg")); + display22_0.getChildren().add(new KeyValue("hidden", "0")); + bit22_0.getChildren().add(display22_0); + bits22.getChildren().add(bit22_0); + + // Achievement 1 in block 22 (DLC) + KeyValue bit22_1 = new KeyValue("1"); + bit22_1.getChildren().add(new KeyValue("bit", "1")); // Bit index as a property + bit22_1.getChildren().add(new KeyValue("name", "ACH_DLC_EXPERT")); + KeyValue display22_1 = new KeyValue("display"); + KeyValue name22_1 = new KeyValue("name"); + name22_1.getChildren().add(new KeyValue("english", "DLC Expert")); + display22_1.getChildren().add(name22_1); + KeyValue desc22_1 = new KeyValue("desc"); + desc22_1.getChildren().add(new KeyValue("english", "Master the DLC content")); + display22_1.getChildren().add(desc22_1); + display22_1.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_1.jpg")); + display22_1.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_1_gray.jpg")); + display22_1.getChildren().add(new KeyValue("hidden", "0")); + bit22_1.getChildren().add(display22_1); + bits22.getChildren().add(bit22_1); + + block22.getChildren().add(bits22); + stats.getChildren().add(block22); + + // Add stats to wrapper + wrapper.getChildren().add(stats); + + // Serialize to binary format + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + wrapper.saveToStream(baos, true); // Binary format + return baos.toByteArray(); + } + + /** + * Helper method to create a mock UserStatsResponse packet with achievement + * data. + */ + private IPacketMsg createUserStatsResponseMessage(boolean includeSchema) throws IOException { + ClientMsgProtobuf msg + = new ClientMsgProtobuf<>(CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + + CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); + body.setGameId(440L); // Team Fortress 2 + body.setEresult(EResult.OK.code()); + body.setCrcStats(123456); + + // Add schema if requested + if (includeSchema) { + body.setSchema(ByteString.copyFrom(createMockSchema())); + } + + // Add achievement block 21 with 3 achievements + // Achievements 0 and 2 are unlocked, achievement 1 is locked + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block21 + = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + block21.setAchievementId(21); + block21.addUnlockTime(1609459200); // Achievement 0 unlocked on Jan 1, 2021 + block21.addUnlockTime(0); // Achievement 1 locked + block21.addUnlockTime(1640995200); // Achievement 2 unlocked on Jan 1, 2022 + body.addAchievementBlocks(block21); + + // Add achievement block 22 with 2 achievements + // Achievement 0 is unlocked, achievement 1 is locked + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block22 + = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + block22.setAchievementId(22); + block22.addUnlockTime(1672531200); // Achievement 0 unlocked on Jan 1, 2023 + block22.addUnlockTime(0); // Achievement 1 locked + body.addAchievementBlocks(block22); + + // Add some stats for completeness + body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() + .setStatId(1) + .setStatValue(100)); + body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() + .setStatId(2) + .setStatValue(50)); + + // Serialize and convert to IPacketMsg + return CMClient.getPacketMsg(msg.serialize()); + } + + @Test + public void testHandleUserStatsResponse() throws IOException { + IPacketMsg testMsg = createUserStatsResponseMessage(true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Verify basic callback data + assertEquals(EResult.OK, callback.getResult()); + assertEquals(440L, callback.getGameId()); + assertEquals(123456, callback.getCrcStats()); + + // Verify achievement blocks + List blocks = callback.getAchievementBlocks(); + assertNotNull(blocks); + assertEquals(2, blocks.size()); + + // Verify block 21 + AchievementBlocks block21 = blocks.get(0); + assertEquals(21, block21.getAchievementId()); + assertEquals(3, block21.getUnlockTime().size()); + + // Verify block 22 + AchievementBlocks block22 = blocks.get(1); + assertEquals(22, block22.getAchievementId()); + assertEquals(2, block22.getUnlockTime().size()); + } + + @Test + public void testGetExpandedAchievements() throws IOException { + IPacketMsg testMsg = createUserStatsResponseMessage(true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Get expanded achievements + List expandedAchievements = callback.getExpandedAchievements(); + + assertNotNull(expandedAchievements); + assertEquals(5, expandedAchievements.size()); // 3 from block 21 + 2 from block 22 + + // Verify first achievement (block 21, bit 0) + AchievementBlocks ach0 = expandedAchievements.get(0); + assertEquals("ACH_FIRST_BLOOD", ach0.getName()); + assertEquals("First Blood", ach0.getDisplayName()); + assertEquals("Kill your first enemy", ach0.getDescription()); + assertNotNull(ach0.getIcon()); + assertTrue(ach0.getIcon().contains("achievement_0.jpg")); + assertNotNull(ach0.getIconGray()); + assertFalse(ach0.getHidden()); + assertTrue(ach0.isUnlocked()); + assertEquals(1609459200, ach0.getUnlockTimestamp()); + + // Verify second achievement (block 21, bit 1) - locked + AchievementBlocks ach1 = expandedAchievements.get(1); + assertEquals("ACH_VETERAN", ach1.getName()); + assertEquals("Veteran", ach1.getDisplayName()); + assertEquals("Reach level 10", ach1.getDescription()); + assertFalse(ach1.getHidden()); + assertFalse(ach1.isUnlocked()); + assertEquals(0, ach1.getUnlockTimestamp()); + + // Verify third achievement (block 21, bit 2) - hidden and unlocked + AchievementBlocks ach2 = expandedAchievements.get(2); + assertEquals("ACH_SECRET", ach2.getName()); + assertEquals("Secret Achievement", ach2.getDisplayName()); + assertTrue(ach2.getHidden()); + assertTrue(ach2.isUnlocked()); + assertEquals(1640995200, ach2.getUnlockTimestamp()); + + // Verify first DLC achievement (block 22, bit 0) - unlocked + AchievementBlocks ach3 = expandedAchievements.get(3); + assertEquals("ACH_DLC_MASTER", ach3.getName()); + assertEquals("DLC Master", ach3.getDisplayName()); + assertEquals("Complete all DLC missions", ach3.getDescription()); + assertFalse(ach3.getHidden()); + assertTrue(ach3.isUnlocked()); + assertEquals(1672531200, ach3.getUnlockTimestamp()); + + // Verify second DLC achievement (block 22, bit 1) - locked + AchievementBlocks ach4 = expandedAchievements.get(4); + assertEquals("ACH_DLC_EXPERT", ach4.getName()); + assertEquals("DLC Expert", ach4.getDisplayName()); + assertFalse(ach4.getHidden()); + assertFalse(ach4.isUnlocked()); + } + + @Test + public void testGetExpandedAchievementsWithoutSchema() throws IOException { + // Test with no schema - should fall back to original blocks + IPacketMsg testMsg = createUserStatsResponseMessage(false); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + List expandedAchievements = callback.getExpandedAchievements(); + + // Without schema, should return original blocks + assertNotNull(expandedAchievements); + assertEquals(2, expandedAchievements.size()); + + // Verify blocks have no enriched metadata + AchievementBlocks block21 = expandedAchievements.get(0); + assertEquals(21, block21.getAchievementId()); + assertNull(block21.getName()); + assertNull(block21.getDisplayName()); + assertNull(block21.getDescription()); + + AchievementBlocks block22 = expandedAchievements.get(1); + assertEquals(22, block22.getAchievementId()); + assertNull(block22.getName()); + assertNull(block22.getDisplayName()); + } + + @Test + public void testSchemaParsingHandlesCorruptData() { + // Create a packet with invalid schema data + 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(new byte[]{0x01, 0x02, 0x03})); // Invalid schema + + // Serialize and convert to IPacketMsg + IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); + + // Call handler to process the message + handler.handleMsg(packetMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Should not throw exception, just have empty schema + assertNotNull(callback); + assertEquals(EResult.OK, callback.getResult()); + + // getExpandedAchievements should still work and fall back to empty list + List expanded = callback.getExpandedAchievements(); + assertNotNull(expanded); + assertTrue(expanded.isEmpty()); + } + + @Test + public void testAchievementBlockWithManyUnlocks() throws IOException { + // Test with a block that has many achievements unlocked + 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(createMockSchema())); + + // Create a block with 32 achievements (maximum per block) + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block + = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + block.setAchievementId(21); + + // Add 32 unlock times (some locked, some unlocked) + for (int i = 0; i < 32; i++) { + if (i < 3) { + // First 3 achievements have unlock times (matching our schema) + block.addUnlockTime(1609459200 + i * 86400); + } else { + // Rest are locked + block.addUnlockTime(0); + } + } + body.addAchievementBlocks(block); + + // Serialize and convert to IPacketMsg + IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); + + // Call handler to process the message + handler.handleMsg(packetMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + List expanded = callback.getExpandedAchievements(); + + // Should only expand the 3 achievements that have schema entries + assertEquals(3, expanded.size()); + + // Verify unlocked achievements have correct timestamps + assertTrue(expanded.get(0).isUnlocked()); + assertTrue(expanded.get(1).isUnlocked()); + assertTrue(expanded.get(2).isUnlocked()); + } +} From 0916e11dc23ca7b6a09076a0eff42cb559905100 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 21 Nov 2025 23:10:00 +0000 Subject: [PATCH 05/21] updated stats to check for INVALID instead. --- .../steam/handlers/steamuserstats/callback/UserStatsCallback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 55600c0a..15b290ad 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 @@ -110,7 +110,7 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { try { val stats = schemaKeyValues.get("stats") - if (stats == null) return achievementBlocks // Return original blocks if schema parsing failed + if (stats == KeyValue.INVALID) return achievementBlocks // Return original blocks if schema parsing failed // Iterate through each achievement block for (block in achievementBlocks) { From 7b61846fef39bfdd2a054fedc1bf67ab77fbb205 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 08:50:48 +0000 Subject: [PATCH 06/21] removed old experimental function. --- .../callback/UserStatsCallback.kt | 37 +------------------ 1 file changed, 2 insertions(+), 35 deletions(-) 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 15b290ad..2158b149 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 @@ -117,8 +117,9 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { val statBlock = stats.get(block.achievementId.toString()) val bitsBlock = statBlock?.get("bits") + if (bitsBlock != null) { - // This block has bit-level achievements, expand them + // This block has bit-level achievements, expand them and get the values for (bitEntry in bitsBlock.children) { val bitIndex = bitEntry.get("bit")?.asInteger() ?: continue val displaySection = bitEntry.get("display") @@ -164,38 +165,4 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { return expandedAchievements } - - /** - * Builds a map of achievement ID to metadata from the schema KeyValue. - */ - private fun buildAchievementMetadataMap(schema: KeyValue): Map> { - val metadataMap = mutableMapOf>() - - try { - val achievements = schema.get("stats")?.get("achievements") - - if (achievements != null) { - for (achievement in achievements.children) { - val idKey = achievement.get("id") - if (idKey != null) { - val achievementId = idKey.asInteger() - val metadata = mutableMapOf() - - achievement.get("name")?.value?.let { metadata["name"] = it } - achievement.get("displayName")?.value?.let { metadata["displayName"] = it } - achievement.get("description")?.value?.let { metadata["description"] = it } - achievement.get("icon")?.value?.let { metadata["icon"] = it } - achievement.get("icon_gray")?.value?.let { metadata["iconGray"] = it } - achievement.get("hidden")?.value?.let { metadata["hidden"] = it } - - metadataMap[achievementId] = metadata - } - } - } - } catch (e: Exception) { - // If schema parsing fails, return empty map - } - - return metadataMap - } } From 4ec207619fa08a1dc9019d3ff67962bbf0f19647 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 08:57:12 +0000 Subject: [PATCH 07/21] Updated the comments of AchievementBlocks for clarity --- .../steam/handlers/steamuserstats/AchievementBlocks.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 fc37959d..bc599ecf 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 @@ -10,8 +10,8 @@ import `in`.dragonbra.javasteam.util.JavaSteamAddition * @param name the internal name of the achievement (e.g., "ACH_FIRST_BLOOD") * @param displayName the localized display name of the achievement * @param description the localized description of the achievement - * @param icon the URL to the achievement's icon - * @param iconGray the URL to the achievement's grayscale icon (shown when locked) + * @param icon the relative URL to the achievement's icon (You will still need to complete the URL with Steam's CDN.) + * @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 CDN.) * @param hidden whether the achievement is hidden until unlocked */ @JavaSteamAddition From b372ef2da9fc1d3ee35e781ec264412ab4c9176a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 09:16:32 +0000 Subject: [PATCH 08/21] fixed value and assertions for achievement icons. --- .../steamuserstats/SteamUserStatsTest.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 d58a5727..d1616c13 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 @@ -71,8 +71,8 @@ private byte[] createMockSchema() throws IOException { KeyValue desc0 = new KeyValue("desc"); desc0.getChildren().add(new KeyValue("english", "Kill your first enemy")); display0.getChildren().add(desc0); - display0.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_0.jpg")); - display0.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_0_gray.jpg")); + display0.getChildren().add(new KeyValue("icon", "achievement_0.jpg")); + display0.getChildren().add(new KeyValue("icon_gray", "achievement_0_gray.jpg")); display0.getChildren().add(new KeyValue("hidden", "0")); bit0.getChildren().add(display0); bits21.getChildren().add(bit0); @@ -88,8 +88,8 @@ private byte[] createMockSchema() throws IOException { KeyValue desc1 = new KeyValue("desc"); desc1.getChildren().add(new KeyValue("english", "Reach level 10")); display1.getChildren().add(desc1); - display1.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_1.jpg")); - display1.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_1_gray.jpg")); + display1.getChildren().add(new KeyValue("icon", "achievement_1.jpg")); + display1.getChildren().add(new KeyValue("icon_gray", "achievement_1_gray.jpg")); display1.getChildren().add(new KeyValue("hidden", "0")); bit1.getChildren().add(display1); bits21.getChildren().add(bit1); @@ -105,8 +105,8 @@ private byte[] createMockSchema() throws IOException { KeyValue desc2 = new KeyValue("desc"); desc2.getChildren().add(new KeyValue("english", "Find the secret")); display2.getChildren().add(desc2); - display2.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_2.jpg")); - display2.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_2_gray.jpg")); + display2.getChildren().add(new KeyValue("icon", "achievement_2.jpg")); + display2.getChildren().add(new KeyValue("icon_gray", "achievement_2_gray.jpg")); display2.getChildren().add(new KeyValue("hidden", "1")); // Hidden achievement bit2.getChildren().add(display2); bits21.getChildren().add(bit2); @@ -131,8 +131,8 @@ private byte[] createMockSchema() throws IOException { KeyValue desc22_0 = new KeyValue("desc"); desc22_0.getChildren().add(new KeyValue("english", "Complete all DLC missions")); display22_0.getChildren().add(desc22_0); - display22_0.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_0.jpg")); - display22_0.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_0_gray.jpg")); + display22_0.getChildren().add(new KeyValue("icon", "achievement_dlc_0.jpg")); + display22_0.getChildren().add(new KeyValue("icon_gray", "achievement_dlc_0_gray.jpg")); display22_0.getChildren().add(new KeyValue("hidden", "0")); bit22_0.getChildren().add(display22_0); bits22.getChildren().add(bit22_0); @@ -148,8 +148,8 @@ private byte[] createMockSchema() throws IOException { KeyValue desc22_1 = new KeyValue("desc"); desc22_1.getChildren().add(new KeyValue("english", "Master the DLC content")); display22_1.getChildren().add(desc22_1); - display22_1.getChildren().add(new KeyValue("icon", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_1.jpg")); - display22_1.getChildren().add(new KeyValue("icon_gray", "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/achievement_dlc_1_gray.jpg")); + display22_1.getChildren().add(new KeyValue("icon", "achievement_dlc_1.jpg")); + display22_1.getChildren().add(new KeyValue("icon_gray", "achievement_dlc_1_gray.jpg")); display22_1.getChildren().add(new KeyValue("hidden", "0")); bit22_1.getChildren().add(display22_1); bits22.getChildren().add(bit22_1); From 6d24241f8928de252a965e5c53e787f7560aecc6 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 09:19:02 +0000 Subject: [PATCH 09/21] updated description for better understand of users. --- .../steam/handlers/steamuserstats/AchievementBlocks.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bc599ecf..e8f22486 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 @@ -10,8 +10,8 @@ import `in`.dragonbra.javasteam.util.JavaSteamAddition * @param name the internal name of the achievement (e.g., "ACH_FIRST_BLOOD") * @param displayName the localized display name of the achievement * @param description the localized description of the achievement - * @param icon the relative URL to the achievement's icon (You will still need to complete the URL with Steam's CDN.) - * @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 CDN.) + * @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 */ @JavaSteamAddition From b5fb9118e3c745e287afff195a9217bcd9554a3a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 09:22:49 +0000 Subject: [PATCH 10/21] Added more accurate descriptor for unlock Time. RemindMe 13 years when 32-bit integers won't be able to work with unix timestamps :D --- .../steam/handlers/steamuserstats/AchievementBlocks.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e8f22486..5919b78e 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 @@ -5,7 +5,7 @@ import `in`.dragonbra.javasteam.util.JavaSteamAddition /** * A Block of achievements with the timestamp of when the achievement (in order of the schema) is unlocked. * @param achievementId the achievement id. - * @param unlockTime a [List] of integers containing when an achievement was unlocked. + * @param unlockTime a [List] of integers (Unix Timestamps) containing when an achievement was unlocked. * An unlockTime of 0 means it has not been achieved, unlocked achievements are displayed as valve-timestamps. * @param name the internal name of the achievement (e.g., "ACH_FIRST_BLOOD") * @param displayName the localized display name of the achievement From 77e10da84bfa935753f61a006c0f2c9edaa3a743 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 22 Nov 2025 09:26:13 +0000 Subject: [PATCH 11/21] Added helper function to get the unlockTime to date (YYYY-MM-SS HH:mm:ss) just to make things easier for the users. --- .../handlers/steamuserstats/AchievementBlocks.kt | 15 +++++++++++++++ .../steamuserstats/SteamUserStatsTest.java | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) 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 5919b78e..095ce497 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 @@ -1,6 +1,8 @@ package `in`.dragonbra.javasteam.steam.handlers.steamuserstats import `in`.dragonbra.javasteam.util.JavaSteamAddition +import java.text.SimpleDateFormat +import java.util.Date /** * A Block of achievements with the timestamp of when the achievement (in order of the schema) is unlocked. @@ -39,4 +41,17 @@ data class AchievementBlocks( */ val unlockTimestamp: Int get() = unlockTime.firstOrNull { it > 0 } ?: 0 + + /** + * Returns the unlock timestamp formatted as "yyyy-MM-dd HH:mm:ss". + * Returns null if the achievement is not unlocked. + * The timestamp is converted from Unix epoch seconds to a formatted date string. + */ + fun getFormattedUnlockTime(): String? { + val timestamp = unlockTimestamp + if (timestamp == 0) return null + + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + return dateFormat.format(Date(timestamp * 1000L)) + } } 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 d1616c13..c86525c7 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 @@ -47,7 +47,8 @@ protected SteamUserStats createHandler() { * The schema needs to be structured so that when read by * KeyValue.tryReadAsBinary(), it creates: schemaKeyValues -> stats -> * [blocks] This means we need to write binary that starts with a wrapper - * node containing stats. + * node containing stats. ! This is a decently clunky was of doing it. + * Opento suggestions/input on how to improve it. */ private byte[] createMockSchema() throws IOException { // Create a wrapper so schemaKeyValues will have "stats" as a child, not be "stats" itself @@ -273,6 +274,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach0.getHidden()); assertTrue(ach0.isUnlocked()); assertEquals(1609459200, ach0.getUnlockTimestamp()); + assertEquals("2021-01-01 00:00:00", ach0.getFormattedUnlockTime()); // Verify second achievement (block 21, bit 1) - locked AchievementBlocks ach1 = expandedAchievements.get(1); @@ -282,6 +284,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach1.getHidden()); assertFalse(ach1.isUnlocked()); assertEquals(0, ach1.getUnlockTimestamp()); + assertNull(ach1.getFormattedUnlockTime()); // Verify third achievement (block 21, bit 2) - hidden and unlocked AchievementBlocks ach2 = expandedAchievements.get(2); @@ -290,6 +293,7 @@ public void testGetExpandedAchievements() throws IOException { assertTrue(ach2.getHidden()); assertTrue(ach2.isUnlocked()); assertEquals(1640995200, ach2.getUnlockTimestamp()); + assertEquals("2022-01-01 00:00:00", ach2.getFormattedUnlockTime()); // Verify first DLC achievement (block 22, bit 0) - unlocked AchievementBlocks ach3 = expandedAchievements.get(3); @@ -299,6 +303,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach3.getHidden()); assertTrue(ach3.isUnlocked()); assertEquals(1672531200, ach3.getUnlockTimestamp()); + assertEquals("2023-01-01 00:00:00", ach3.getFormattedUnlockTime()); // Verify second DLC achievement (block 22, bit 1) - locked AchievementBlocks ach4 = expandedAchievements.get(4); @@ -306,6 +311,7 @@ public void testGetExpandedAchievements() throws IOException { assertEquals("DLC Expert", ach4.getDisplayName()); assertFalse(ach4.getHidden()); assertFalse(ach4.isUnlocked()); + assertNull(ach4.getFormattedUnlockTime()); } @Test From 12c4928334b1f0dc7d212fb95119b01d0bb584a1 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 22 Nov 2025 21:42:13 +0000 Subject: [PATCH 12/21] fixed to use new Date() constructor instead of a string as timezones are funny. --- .../steamuserstats/SteamUserStatsTest.java | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) 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 c86525c7..d42d0139 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 @@ -2,6 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.sql.Date; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -51,7 +52,8 @@ protected SteamUserStats createHandler() { * Opento suggestions/input on how to improve it. */ private byte[] createMockSchema() throws IOException { - // Create a wrapper so schemaKeyValues will have "stats" as a child, not be "stats" itself + // Create a wrapper so schemaKeyValues will have "stats" as a child, not be + // "stats" itself KeyValue wrapper = new KeyValue("UserGameStatsSchema"); // Mimicking Steam's actual format KeyValue stats = new KeyValue("stats"); @@ -172,8 +174,8 @@ private byte[] createMockSchema() throws IOException { * data. */ private IPacketMsg createUserStatsResponseMessage(boolean includeSchema) throws IOException { - ClientMsgProtobuf msg - = new ClientMsgProtobuf<>(CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + ClientMsgProtobuf msg = new ClientMsgProtobuf<>( + CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); body.setGameId(440L); // Team Fortress 2 @@ -187,21 +189,21 @@ private IPacketMsg createUserStatsResponseMessage(boolean includeSchema) throws // Add achievement block 21 with 3 achievements // Achievements 0 and 2 are unlocked, achievement 1 is locked - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block21 - = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block21 = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); block21.setAchievementId(21); block21.addUnlockTime(1609459200); // Achievement 0 unlocked on Jan 1, 2021 - block21.addUnlockTime(0); // Achievement 1 locked + block21.addUnlockTime(0); // Achievement 1 locked block21.addUnlockTime(1640995200); // Achievement 2 unlocked on Jan 1, 2022 body.addAchievementBlocks(block21); // Add achievement block 22 with 2 achievements // Achievement 0 is unlocked, achievement 1 is locked - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block22 - = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block22 = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); block22.setAchievementId(22); block22.addUnlockTime(1672531200); // Achievement 0 unlocked on Jan 1, 2023 - block22.addUnlockTime(0); // Achievement 1 locked + block22.addUnlockTime(0); // Achievement 1 locked body.addAchievementBlocks(block22); // Add some stats for completeness @@ -274,7 +276,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach0.getHidden()); assertTrue(ach0.isUnlocked()); assertEquals(1609459200, ach0.getUnlockTimestamp()); - assertEquals("2021-01-01 00:00:00", ach0.getFormattedUnlockTime()); + assertEquals(new Date(2020, 1, 1), ach0.getFormattedUnlockTime()); // Verify second achievement (block 21, bit 1) - locked AchievementBlocks ach1 = expandedAchievements.get(1); @@ -347,14 +349,14 @@ public void testGetExpandedAchievementsWithoutSchema() throws IOException { @Test public void testSchemaParsingHandlesCorruptData() { // Create a packet with invalid schema data - ClientMsgProtobuf msg - = new ClientMsgProtobuf<>(CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + 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(new byte[]{0x01, 0x02, 0x03})); // Invalid schema + body.setSchema(ByteString.copyFrom(new byte[] { 0x01, 0x02, 0x03 })); // Invalid schema // Serialize and convert to IPacketMsg IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); @@ -378,8 +380,8 @@ public void testSchemaParsingHandlesCorruptData() { @Test public void testAchievementBlockWithManyUnlocks() throws IOException { // Test with a block that has many achievements unlocked - ClientMsgProtobuf msg - = new ClientMsgProtobuf<>(CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + ClientMsgProtobuf msg = new ClientMsgProtobuf<>( + CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); body.setGameId(440L); @@ -388,8 +390,8 @@ public void testAchievementBlockWithManyUnlocks() throws IOException { body.setSchema(ByteString.copyFrom(createMockSchema())); // Create a block with 32 achievements (maximum per block) - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block - = CMsgClientGetUserStatsResponse.Achievement_Blocks.newBuilder(); + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); block.setAchievementId(21); // Add 32 unlock times (some locked, some unlocked) From 2396a38e1abc48f5c6609e660fe483ee489f4226 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 22 Nov 2025 21:54:25 +0000 Subject: [PATCH 13/21] fixing dates. --- .../steam/handlers/steamuserstats/SteamUserStatsTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 d42d0139..0e8f282e 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 @@ -276,7 +276,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach0.getHidden()); assertTrue(ach0.isUnlocked()); assertEquals(1609459200, ach0.getUnlockTimestamp()); - assertEquals(new Date(2020, 1, 1), ach0.getFormattedUnlockTime()); + assertEquals(new Date("2020-01-01 00:00:00"), ach0.getFormattedUnlockTime()); // Verify second achievement (block 21, bit 1) - locked AchievementBlocks ach1 = expandedAchievements.get(1); @@ -295,7 +295,7 @@ public void testGetExpandedAchievements() throws IOException { assertTrue(ach2.getHidden()); assertTrue(ach2.isUnlocked()); assertEquals(1640995200, ach2.getUnlockTimestamp()); - assertEquals("2022-01-01 00:00:00", ach2.getFormattedUnlockTime()); + assertEquals(new Date("2022-01-01 00:00:00"), ach2.getFormattedUnlockTime()); // Verify first DLC achievement (block 22, bit 0) - unlocked AchievementBlocks ach3 = expandedAchievements.get(3); @@ -305,7 +305,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach3.getHidden()); assertTrue(ach3.isUnlocked()); assertEquals(1672531200, ach3.getUnlockTimestamp()); - assertEquals("2023-01-01 00:00:00", ach3.getFormattedUnlockTime()); + assertEquals(new Date("2023-01-01 00:00:00"), ach3.getFormattedUnlockTime()); // Verify second DLC achievement (block 22, bit 1) - locked AchievementBlocks ach4 = expandedAchievements.get(4); From 7102f130b2af1906a85c3e8966ee62a04fa606cf Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 22 Nov 2025 22:05:41 +0000 Subject: [PATCH 14/21] using the same formatting to check dates. --- .../handlers/steamuserstats/SteamUserStatsTest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 0e8f282e..123114c2 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 @@ -17,7 +17,7 @@ import org.mockito.quality.Strictness; import com.google.protobuf.ByteString; - +import java.text.SimpleDateFormat; import in.dragonbra.javasteam.base.ClientMsgProtobuf; import in.dragonbra.javasteam.base.IPacketMsg; import in.dragonbra.javasteam.enums.EMsg; @@ -36,6 +36,8 @@ @MockitoSettings(strictness = Strictness.LENIENT) public class SteamUserStatsTest extends HandlerTestBase { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + @Override protected SteamUserStats createHandler() { return new SteamUserStats(); @@ -276,7 +278,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach0.getHidden()); assertTrue(ach0.isUnlocked()); assertEquals(1609459200, ach0.getUnlockTimestamp()); - assertEquals(new Date("2020-01-01 00:00:00"), ach0.getFormattedUnlockTime()); + assertEquals(dateFormat.format(new Date("2020-01-01 00:00:00")), ach0.getFormattedUnlockTime()); // Verify second achievement (block 21, bit 1) - locked AchievementBlocks ach1 = expandedAchievements.get(1); @@ -295,8 +297,7 @@ public void testGetExpandedAchievements() throws IOException { assertTrue(ach2.getHidden()); assertTrue(ach2.isUnlocked()); assertEquals(1640995200, ach2.getUnlockTimestamp()); - assertEquals(new Date("2022-01-01 00:00:00"), ach2.getFormattedUnlockTime()); - + assertEquals(dateFormat.format(new Date("2022-01-01 00:00:00")), ach2.getFormattedUnlockTime()); // Verify first DLC achievement (block 22, bit 0) - unlocked AchievementBlocks ach3 = expandedAchievements.get(3); assertEquals("ACH_DLC_MASTER", ach3.getName()); @@ -305,7 +306,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach3.getHidden()); assertTrue(ach3.isUnlocked()); assertEquals(1672531200, ach3.getUnlockTimestamp()); - assertEquals(new Date("2023-01-01 00:00:00"), ach3.getFormattedUnlockTime()); + assertEquals(dateFormat.format(new Date("2023-01-01 00:00:00")), ach3.getFormattedUnlockTime()); // Verify second DLC achievement (block 22, bit 1) - locked AchievementBlocks ach4 = expandedAchievements.get(4); From 6c6018aff09071fa7da72597d5d4df0a1ba0eddc Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 22 Nov 2025 22:15:18 +0000 Subject: [PATCH 15/21] Fixing so that we're using the proper time format parsing. Turns out that there's inconsistencies with how the date lib works. I hate dates SO MUCH --- .../steam/handlers/steamuserstats/AchievementBlocks.kt | 2 +- .../handlers/steamuserstats/callback/UserStatsCallback.kt | 1 - .../steam/handlers/steamuserstats/SteamUserStatsTest.java | 8 ++++---- 3 files changed, 5 insertions(+), 6 deletions(-) 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 095ce497..7d261eac 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 @@ -25,7 +25,7 @@ data class AchievementBlocks( val description: String? = null, val icon: String? = null, val iconGray: String? = null, - val hidden: Boolean = false + val hidden: Boolean = false, ) { /** * Returns true if this achievement is unlocked. 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 2158b149..c4ddcb95 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 @@ -117,7 +117,6 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { val statBlock = stats.get(block.achievementId.toString()) val bitsBlock = statBlock?.get("bits") - if (bitsBlock != null) { // This block has bit-level achievements, expand them and get the values for (bitEntry in bitsBlock.children) { 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 123114c2..a207cfc3 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 @@ -2,7 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.sql.Date; +import java.util.Date; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -278,7 +278,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach0.getHidden()); assertTrue(ach0.isUnlocked()); assertEquals(1609459200, ach0.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date("2020-01-01 00:00:00")), ach0.getFormattedUnlockTime()); + assertEquals(dateFormat.format(new Date(1609459200L * 1000L)), ach0.getFormattedUnlockTime()); // Verify second achievement (block 21, bit 1) - locked AchievementBlocks ach1 = expandedAchievements.get(1); @@ -297,7 +297,7 @@ public void testGetExpandedAchievements() throws IOException { assertTrue(ach2.getHidden()); assertTrue(ach2.isUnlocked()); assertEquals(1640995200, ach2.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date("2022-01-01 00:00:00")), ach2.getFormattedUnlockTime()); + assertEquals(dateFormat.format(new Date(1640995200L * 1000L)), ach2.getFormattedUnlockTime()); // Verify first DLC achievement (block 22, bit 0) - unlocked AchievementBlocks ach3 = expandedAchievements.get(3); assertEquals("ACH_DLC_MASTER", ach3.getName()); @@ -306,7 +306,7 @@ public void testGetExpandedAchievements() throws IOException { assertFalse(ach3.getHidden()); assertTrue(ach3.isUnlocked()); assertEquals(1672531200, ach3.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date("2023-01-01 00:00:00")), ach3.getFormattedUnlockTime()); + assertEquals(dateFormat.format(new Date(1672531200L * 1000L)), ach3.getFormattedUnlockTime()); // Verify second DLC achievement (block 22, bit 1) - locked AchievementBlocks ach4 = expandedAchievements.get(4); From de8167339f56077b6f7e052282dd2c03c8bebf07 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 5 Dec 2025 17:32:35 -0600 Subject: [PATCH 16/21] Set a login id so pc steam client stops disconnecting. --- .../_032_achievements/SampleAchievements.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java index 3c199cb3..c6f19dfe 100644 --- a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java @@ -43,7 +43,7 @@ public class SampleAchievements implements Runnable { // - 440 (Team Fortress 2) // - 570 (Dota 2) // - 49520 (Borderlands 2 - requires ownership) - private static final int DEFAULT_APP_ID = 440; + private static final int DEFAULT_APP_ID = 3527290; private SteamClient steamClient; private CallbackManager manager; @@ -123,6 +123,8 @@ private void onConnected(ConnectedCallback callback) { details.setAccessToken(pollResponse.getRefreshToken()); details.setShouldRememberPassword(true); + details.setLoginID(149); + steamUser.logOn(details); } catch (Exception e) { if (e instanceof AuthenticationException) { From ddb7999d6429de7266f088d0d3e32ea0ce8a0d94 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Fri, 5 Dec 2025 21:27:02 -0600 Subject: [PATCH 17/21] Quiet gradle warning --- build.gradle.kts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 753cfba6..b5cced20 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -131,7 +131,18 @@ tasks.withType { this.source = this.source.minus(fileTree("build/generated")).asFileTree } +/* JDK and Mockito self attachment fix */ +// "Mockito is currently self-attaching to enable the inline-mock-maker. +// This will no longer work in future releases of the JDK" +val mockitoAgent = configurations.create("mockitoAgent") +tasks.withType { + doFirst { + jvmArgs("-javaagent:${mockitoAgent.asPath}") + }} + dependencies { + mockitoAgent(libs.test.mock.core) { isTransitive = false } + implementation(libs.bundles.ktor) implementation(libs.commons.lang3) implementation(libs.kotlin.coroutines) From 77ebc3ff67db4a9ecbabd603dff173e85cf4597c Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sat, 6 Dec 2025 00:27:26 -0600 Subject: [PATCH 18/21] Quiet gradle dokka --- gradle.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle.properties b/gradle.properties index d99aea2c..a4b39382 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,5 @@ ossrhUsername= ossrhPassword= org.gradle.jvmargs=-Xmx2048m + +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true From 4d3192a8f3d1c7211b290de9cf75a80e5685db4b Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sat, 6 Dec 2025 01:42:28 -0600 Subject: [PATCH 19/21] Update tests to include real data from a response packet. --- .../callback/UserStatsCallback.kt | 35 +- .../in/dragonbra/javasteam/TestPackets.java | 27 +- .../steamuserstats/SteamUserStatsTest.java | 568 ++++++++++-------- ...n_819_k_EMsgClientGetUserStatsResponse.bin | Bin 0 -> 73364 bytes 4 files changed, 354 insertions(+), 276 deletions(-) create mode 100644 src/test/resources/packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin 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 c4ddcb95..0127bfcf 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 @@ -79,7 +79,7 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { schemaKeyValues.tryReadAsBinary(it) } } - } catch (e: Exception) { + } catch (_: Exception) { // Schema parsing failed, schemaKeyValues will remain empty // This is okay - we'll just have achievements without enriched metadata } @@ -105,31 +105,34 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { * * @return List of individual [AchievementBlocks] with enriched metadata from schema */ - fun getExpandedAchievements(): List { + @JvmOverloads + fun getExpandedAchievements(language: String = "english"): List { val expandedAchievements = mutableListOf() try { - val stats = schemaKeyValues.get("stats") - if (stats == KeyValue.INVALID) return achievementBlocks // Return original blocks if schema parsing failed + val stats = schemaKeyValues["stats"] + if (stats == KeyValue.INVALID) { + return achievementBlocks // Return original blocks if schema parsing failed + } // Iterate through each achievement block for (block in achievementBlocks) { - val statBlock = stats.get(block.achievementId.toString()) - val bitsBlock = statBlock?.get("bits") + val statBlock = stats[block.achievementId.toString()] + val bitsBlock = statBlock["bits"] - if (bitsBlock != null) { + if (bitsBlock != KeyValue.INVALID) { // This block has bit-level achievements, expand them and get the values for (bitEntry in bitsBlock.children) { - val bitIndex = bitEntry.get("bit")?.asInteger() ?: continue - val displaySection = bitEntry.get("display") + val bitIndex = bitEntry["bit"].asInteger() + val displaySection = bitEntry["display"] // Extract metadata - val name = bitEntry.get("name")?.value - val displayName = displaySection?.get("name")?.get("english")?.value - val description = displaySection?.get("desc")?.get("english")?.value - val icon = displaySection?.get("icon")?.value - val iconGray = displaySection?.get("icon_gray")?.value - val hidden = displaySection?.get("hidden")?.value == "1" + val name = bitEntry["name"].value + val displayName = displaySection["name"][language].value + val description = displaySection["desc"][language].value + val icon = displaySection["icon"].value + val iconGray = displaySection["icon_gray"].value + val hidden = displaySection["hidden"].value == "1" // Get unlock time for this specific bit val unlockTime = if (bitIndex < block.unlockTime.size) { @@ -157,7 +160,7 @@ class UserStatsCallback(packetMsg: IPacketMsg?) : CallbackMsg() { expandedAchievements.add(block) } } - } catch (e: Exception) { + } catch (_: Exception) { // If expansion fails, return original blocks return achievementBlocks } diff --git a/src/test/java/in/dragonbra/javasteam/TestPackets.java b/src/test/java/in/dragonbra/javasteam/TestPackets.java index 5be6ad5a..3a4f1016 100644 --- a/src/test/java/in/dragonbra/javasteam/TestPackets.java +++ b/src/test/java/in/dragonbra/javasteam/TestPackets.java @@ -106,14 +106,33 @@ public static byte[] getPacket(EMsg msgType, boolean isProto) { // return clientGetCDNAuthTokenResponse(); case ClientCheckAppBetaPasswordResponse: return clientCheckAppBetaPasswordResponse(); + case ClientGetUserStatsResponse: + return clientGetUserStatsResponse(); default: throw new NullPointerException(); } } + /** + * Load packets captured from {@link in.dragonbra.javasteam.util.NetHookNetworkListener}. + * + * @param name the bin file name. + * @return a byte array from the loaded file. + */ + private static byte[] loadNetHookFile(String name) { + try (var file = TestPackets.class.getClassLoader().getResourceAsStream("packets/" + name)) { + if (file == null) { + return null; + } + return IOUtils.toByteArray(file); + } catch (IOException e) { + return null; + } + } + private static byte[] loadFile(String name) { - try(var file = TestPackets.class.getClassLoader().getResourceAsStream("testpackets/" + name)) { - if(file == null) { + try (var file = TestPackets.class.getClassLoader().getResourceAsStream("testpackets/" + name)) { + if (file == null) { return null; } return IOUtils.toByteArray(file); @@ -387,6 +406,10 @@ private static byte[] clientLicenseList() { return loadFile("ClientLicenseList.bin"); } + private static byte[] clientGetUserStatsResponse() { + return loadNetHookFile("226_in_819_k_EMsgClientGetUserStatsResponse.bin"); + } + private static byte[] clientGameConnectTokens() { ClientMsgProtobuf msg = new ClientMsgProtobuf<>(CMsgClientGameConnectTokens.class, EMsg.ClientGameConnectTokens); 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 a207cfc3..7ee3bebf 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 @@ -5,11 +5,7 @@ import java.util.Date; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -17,7 +13,9 @@ import org.mockito.quality.Strictness; import com.google.protobuf.ByteString; + import java.text.SimpleDateFormat; + import in.dragonbra.javasteam.base.ClientMsgProtobuf; import in.dragonbra.javasteam.base.IPacketMsg; import in.dragonbra.javasteam.enums.EMsg; @@ -28,9 +26,14 @@ import in.dragonbra.javasteam.steam.handlers.steamuserstats.callback.UserStatsCallback; import in.dragonbra.javasteam.types.KeyValue; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + /** * Unit tests for SteamUserStats handler, specifically testing achievement * parsing functionality. + * "ClientGetUserStatsResponse" returns a copy of Dredge */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -43,10 +46,304 @@ protected SteamUserStats createHandler() { return new SteamUserStats(); } + @Test + public void testHandleUserStatsResponse() { + IPacketMsg testMsg = getPacket(EMsg.ClientGetUserStatsResponse, true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Verify basic callback data + Assertions.assertEquals(EResult.OK, callback.getResult()); + Assertions.assertEquals(1562430, callback.getGameId()); + + // CRC values can be long, but protobuf java converts uint32 to integers. + Assertions.assertEquals(3020832857L, Integer.toUnsignedLong(callback.getCrcStats())); + + // Verify stats + List stats = callback.getStats(); + Assertions.assertNotNull(stats); + Assertions.assertFalse(stats.isEmpty()); + Assertions.assertEquals(2, stats.size()); + + // Verify achievement blocks + List blocks = callback.getAchievementBlocks(); + Assertions.assertNotNull(blocks); + Assertions.assertFalse(blocks.isEmpty()); + Assertions.assertEquals(2, blocks.size()); + + // Verify schema size + var schema = callback.getSchema(); + Assertions.assertNotNull(schema); + Assertions.assertFalse(schema.isEmpty()); + Assertions.assertEquals(72959, schema.size()); + + // Verify version and game name. + Assertions.assertEquals("Dredge", callback.getSchemaKeyValues().get("gamename").asString()); + Assertions.assertEquals(24, callback.getSchemaKeyValues().get("version").asInteger()); + } + + @Test + public void testUserStatsResponseStats() { + IPacketMsg testMsg = getPacket(EMsg.ClientGetUserStatsResponse, true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Grab a few random stats + + Stats statFirst = callback.getStats().getFirst(); + Assertions.assertEquals(17, statFirst.getStatId()); + Assertions.assertEquals(2737815391L, Integer.toUnsignedLong(statFirst.getStatValue())); + + Stats statLast = callback.getStats().getLast(); + Assertions.assertEquals(19, statLast.getStatId()); + Assertions.assertEquals(487, statLast.getStatValue()); + } + + @Test + public void testUserStatsResponseBlocks() { + IPacketMsg testMsg = getPacket(EMsg.ClientGetUserStatsResponse, true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Grab a few random achievement blocks + + AchievementBlocks blockFirst = callback.getAchievementBlocks().getFirst(); + Assertions.assertEquals(17, blockFirst.getAchievementId()); + Assertions.assertEquals(1733977234, blockFirst.getUnlockTime().getFirst()); + + AchievementBlocks blockSecond = callback.getAchievementBlocks().getLast(); + Assertions.assertEquals(19, blockSecond.getAchievementId()); + Assertions.assertEquals(1733721477, blockSecond.getUnlockTime().get(8)); + } + + @Test + public void testSchemaParsingHandlesCorruptData() { + // Create a packet with invalid schema data + 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(new byte[]{0x01, 0x02, 0x03})); // Invalid schema + + // Serialize and convert to IPacketMsg + IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); + + // Call handler to process the message + handler.handleMsg(packetMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Should not throw exception, just have empty schema + Assertions.assertNotNull(callback); + Assertions.assertEquals(EResult.OK, callback.getResult()); + + // getExpandedAchievements should still work and fall back to empty list + List expanded = callback.getExpandedAchievements(); + Assertions.assertNotNull(expanded); + Assertions.assertTrue(expanded.isEmpty()); + } + + @Test + public void testGetExpandedAchievements() { + IPacketMsg testMsg = getPacket(EMsg.ClientGetUserStatsResponse, true); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + // Get expanded achievements + List expandedAchievements = callback.getExpandedAchievements(); + + Assertions.assertNotNull(expandedAchievements); + Assertions.assertFalse(expandedAchievements.isEmpty()); + Assertions.assertEquals(60, expandedAchievements.size()); + + // Verify an unlocked achievement + AchievementBlocks ach0 = expandedAchievements.get(0); + Assertions.assertEquals("CATCH_FISH_ROD_1", ach0.getName()); + Assertions.assertEquals("Lifted From the Deep", ach0.getDisplayName()); + Assertions.assertEquals("Catch 250 fish using rods.", ach0.getDescription()); + Assertions.assertNotNull(ach0.getIcon()); + Assertions.assertTrue(ach0.getIcon().contains("12ee49fe9ad45969bb4d106c099517279a940521.jpg")); + Assertions.assertNotNull(ach0.getIconGray()); + Assertions.assertFalse(ach0.getHidden()); + Assertions.assertTrue(ach0.isUnlocked()); + Assertions.assertEquals(1733977234, ach0.getUnlockTimestamp()); + Assertions.assertEquals(dateFormat.format(new Date(1733977234 * 1000L)), ach0.getFormattedUnlockTime()); + + // Verify a locked achievement + AchievementBlocks ach1 = expandedAchievements.get(5); + Assertions.assertEquals("DISCARD_FISH", ach1.getName()); + Assertions.assertEquals("Unwanted", ach1.getDisplayName()); + Assertions.assertEquals("Discard 25 fish.", ach1.getDescription()); + Assertions.assertFalse(ach1.getHidden()); + Assertions.assertFalse(ach1.isUnlocked()); + Assertions.assertEquals(0, ach1.getUnlockTimestamp()); + Assertions.assertNull(ach1.getFormattedUnlockTime()); + + // Verify an unlocked DLC achievement + // TODO + + // Verify a locked DLC achievement + AchievementBlocks ach3 = expandedAchievements.get(40); + Assertions.assertEquals("DLC_3_1", ach3.getName()); + Assertions.assertEquals("Polar Angler", ach3.getDisplayName()); + Assertions.assertEquals("Catch all known species of fish in The Pale Reach.", ach3.getDescription()); + Assertions.assertFalse(ach3.getHidden()); + Assertions.assertFalse(ach3.isUnlocked()); + Assertions.assertEquals(0, ach3.getUnlockTimestamp()); + Assertions.assertNull(ach1.getFormattedUnlockTime()); + } + + @Test + public void testGetExpandedAchievementsWithoutSchema() throws IOException { + // Test with no schema - should fall back to original blocks + IPacketMsg testMsg = createUserStatsResponseMessage(false); + + // Call handler to process the message + handler.handleMsg(testMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + + List expandedAchievements = callback.getExpandedAchievements(); + + // Without schema, should return original blocks + assertNotNull(expandedAchievements); + assertEquals(2, expandedAchievements.size()); + + // Verify blocks have no enriched metadata + AchievementBlocks block21 = expandedAchievements.get(0); + assertEquals(21, block21.getAchievementId()); + assertNull(block21.getName()); + assertNull(block21.getDisplayName()); + assertNull(block21.getDescription()); + + AchievementBlocks block22 = expandedAchievements.get(1); + assertEquals(22, block22.getAchievementId()); + assertNull(block22.getName()); + assertNull(block22.getDisplayName()); + } + + + @Test + public void testAchievementBlockWithManyUnlocks() throws IOException { + // Test with a block that has many achievements unlocked + 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(createMockSchema())); + + // Create a block with 32 achievements (maximum per block) + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); + block.setAchievementId(21); + + // Add 32 unlock times (some locked, some unlocked) + for (int i = 0; i < 32; i++) { + if (i < 3) { + // First 3 achievements have unlock times (matching our schema) + block.addUnlockTime(1609459200 + i * 86400); + } else { + // Rest are locked + block.addUnlockTime(0); + } + } + body.addAchievementBlocks(block); + + // Serialize and convert to IPacketMsg + IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); + + // Call handler to process the message + handler.handleMsg(packetMsg); + + // Verify the callback was posted + UserStatsCallback callback = verifyCallback(); + List expanded = callback.getExpandedAchievements(); + + // Should only expand the 3 achievements that have schema entries + Assertions.assertEquals(3, expanded.size()); + + // Verify unlocked achievements have correct timestamps + Assertions.assertTrue(expanded.get(0).isUnlocked()); + Assertions.assertTrue(expanded.get(1).isUnlocked()); + Assertions.assertTrue(expanded.get(2).isUnlocked()); + } + + /** + * Helper method to create a mock UserStatsResponse packet with achievement + * data. + */ + private IPacketMsg createUserStatsResponseMessage(boolean includeSchema) throws IOException { + ClientMsgProtobuf msg = new ClientMsgProtobuf<>( + CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); + + CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); + body.setGameId(440L); // Team Fortress 2 + body.setEresult(EResult.OK.code()); + body.setCrcStats(123456); + + // Add schema if requested + if (includeSchema) { + body.setSchema(ByteString.copyFrom(createMockSchema())); + } + + // Add achievement block 21 with 3 achievements, 0 and 2 are unlocked, achievement 1 is locked + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block21 = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); + block21.setAchievementId(21); + block21.addUnlockTime(1609459200); // Achievement 0 unlocked on Jan 1, 2021 + block21.addUnlockTime(0); // Achievement 1 locked + block21.addUnlockTime(1640995200); // Achievement 2 unlocked on Jan 1, 2022 + body.addAchievementBlocks(block21); + + // Add achievement block 22 with 2 achievements + // Achievement 0 is unlocked, achievement 1 is locked + CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block22 = CMsgClientGetUserStatsResponse.Achievement_Blocks + .newBuilder(); + block22.setAchievementId(22); + block22.addUnlockTime(1672531200); // Achievement 0 unlocked on Jan 1, 2023 + block22.addUnlockTime(0); // Achievement 1 locked + body.addAchievementBlocks(block22); + + // Add some stats for completeness + body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() + .setStatId(1) + .setStatValue(100)); + body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() + .setStatId(2) + .setStatValue(50)); + + // Serialize and convert to IPacketMsg + return CMClient.getPacketMsg(msg.serialize()); + } + /** * Helper method to create a mock schema with achievement metadata. This * simulates the KeyValue schema structure returned by Steam. - * + *

* The schema needs to be structured so that when read by * KeyValue.tryReadAsBinary(), it creates: schemaKeyValues -> stats -> * [blocks] This means we need to write binary that starts with a wrapper @@ -171,258 +468,13 @@ private byte[] createMockSchema() throws IOException { return baos.toByteArray(); } - /** - * Helper method to create a mock UserStatsResponse packet with achievement - * data. - */ - private IPacketMsg createUserStatsResponseMessage(boolean includeSchema) throws IOException { - ClientMsgProtobuf msg = new ClientMsgProtobuf<>( - CMsgClientGetUserStatsResponse.class, EMsg.ClientGetUserStatsResponse); - - CMsgClientGetUserStatsResponse.Builder body = msg.getBody(); - body.setGameId(440L); // Team Fortress 2 - body.setEresult(EResult.OK.code()); - body.setCrcStats(123456); - - // Add schema if requested - if (includeSchema) { - body.setSchema(ByteString.copyFrom(createMockSchema())); + private static void printKeyValue(KeyValue keyvalue, int depth) { + if (keyvalue.getChildren().isEmpty()) + System.out.println(" ".repeat(depth * 4) + " " + keyvalue.getName() + ": " + keyvalue.getValue()); + else { + System.out.println(" ".repeat(depth * 4) + " " + keyvalue.getName() + ":"); + for (KeyValue child : keyvalue.getChildren()) + printKeyValue(child, depth + 1); } - - // Add achievement block 21 with 3 achievements - // Achievements 0 and 2 are unlocked, achievement 1 is locked - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block21 = CMsgClientGetUserStatsResponse.Achievement_Blocks - .newBuilder(); - block21.setAchievementId(21); - block21.addUnlockTime(1609459200); // Achievement 0 unlocked on Jan 1, 2021 - block21.addUnlockTime(0); // Achievement 1 locked - block21.addUnlockTime(1640995200); // Achievement 2 unlocked on Jan 1, 2022 - body.addAchievementBlocks(block21); - - // Add achievement block 22 with 2 achievements - // Achievement 0 is unlocked, achievement 1 is locked - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block22 = CMsgClientGetUserStatsResponse.Achievement_Blocks - .newBuilder(); - block22.setAchievementId(22); - block22.addUnlockTime(1672531200); // Achievement 0 unlocked on Jan 1, 2023 - block22.addUnlockTime(0); // Achievement 1 locked - body.addAchievementBlocks(block22); - - // Add some stats for completeness - body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() - .setStatId(1) - .setStatValue(100)); - body.addStats(CMsgClientGetUserStatsResponse.Stats.newBuilder() - .setStatId(2) - .setStatValue(50)); - - // Serialize and convert to IPacketMsg - return CMClient.getPacketMsg(msg.serialize()); - } - - @Test - public void testHandleUserStatsResponse() throws IOException { - IPacketMsg testMsg = createUserStatsResponseMessage(true); - - // Call handler to process the message - handler.handleMsg(testMsg); - - // Verify the callback was posted - UserStatsCallback callback = verifyCallback(); - - // Verify basic callback data - assertEquals(EResult.OK, callback.getResult()); - assertEquals(440L, callback.getGameId()); - assertEquals(123456, callback.getCrcStats()); - - // Verify achievement blocks - List blocks = callback.getAchievementBlocks(); - assertNotNull(blocks); - assertEquals(2, blocks.size()); - - // Verify block 21 - AchievementBlocks block21 = blocks.get(0); - assertEquals(21, block21.getAchievementId()); - assertEquals(3, block21.getUnlockTime().size()); - - // Verify block 22 - AchievementBlocks block22 = blocks.get(1); - assertEquals(22, block22.getAchievementId()); - assertEquals(2, block22.getUnlockTime().size()); - } - - @Test - public void testGetExpandedAchievements() throws IOException { - IPacketMsg testMsg = createUserStatsResponseMessage(true); - - // Call handler to process the message - handler.handleMsg(testMsg); - - // Verify the callback was posted - UserStatsCallback callback = verifyCallback(); - - // Get expanded achievements - List expandedAchievements = callback.getExpandedAchievements(); - - assertNotNull(expandedAchievements); - assertEquals(5, expandedAchievements.size()); // 3 from block 21 + 2 from block 22 - - // Verify first achievement (block 21, bit 0) - AchievementBlocks ach0 = expandedAchievements.get(0); - assertEquals("ACH_FIRST_BLOOD", ach0.getName()); - assertEquals("First Blood", ach0.getDisplayName()); - assertEquals("Kill your first enemy", ach0.getDescription()); - assertNotNull(ach0.getIcon()); - assertTrue(ach0.getIcon().contains("achievement_0.jpg")); - assertNotNull(ach0.getIconGray()); - assertFalse(ach0.getHidden()); - assertTrue(ach0.isUnlocked()); - assertEquals(1609459200, ach0.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date(1609459200L * 1000L)), ach0.getFormattedUnlockTime()); - - // Verify second achievement (block 21, bit 1) - locked - AchievementBlocks ach1 = expandedAchievements.get(1); - assertEquals("ACH_VETERAN", ach1.getName()); - assertEquals("Veteran", ach1.getDisplayName()); - assertEquals("Reach level 10", ach1.getDescription()); - assertFalse(ach1.getHidden()); - assertFalse(ach1.isUnlocked()); - assertEquals(0, ach1.getUnlockTimestamp()); - assertNull(ach1.getFormattedUnlockTime()); - - // Verify third achievement (block 21, bit 2) - hidden and unlocked - AchievementBlocks ach2 = expandedAchievements.get(2); - assertEquals("ACH_SECRET", ach2.getName()); - assertEquals("Secret Achievement", ach2.getDisplayName()); - assertTrue(ach2.getHidden()); - assertTrue(ach2.isUnlocked()); - assertEquals(1640995200, ach2.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date(1640995200L * 1000L)), ach2.getFormattedUnlockTime()); - // Verify first DLC achievement (block 22, bit 0) - unlocked - AchievementBlocks ach3 = expandedAchievements.get(3); - assertEquals("ACH_DLC_MASTER", ach3.getName()); - assertEquals("DLC Master", ach3.getDisplayName()); - assertEquals("Complete all DLC missions", ach3.getDescription()); - assertFalse(ach3.getHidden()); - assertTrue(ach3.isUnlocked()); - assertEquals(1672531200, ach3.getUnlockTimestamp()); - assertEquals(dateFormat.format(new Date(1672531200L * 1000L)), ach3.getFormattedUnlockTime()); - - // Verify second DLC achievement (block 22, bit 1) - locked - AchievementBlocks ach4 = expandedAchievements.get(4); - assertEquals("ACH_DLC_EXPERT", ach4.getName()); - assertEquals("DLC Expert", ach4.getDisplayName()); - assertFalse(ach4.getHidden()); - assertFalse(ach4.isUnlocked()); - assertNull(ach4.getFormattedUnlockTime()); - } - - @Test - public void testGetExpandedAchievementsWithoutSchema() throws IOException { - // Test with no schema - should fall back to original blocks - IPacketMsg testMsg = createUserStatsResponseMessage(false); - - // Call handler to process the message - handler.handleMsg(testMsg); - - // Verify the callback was posted - UserStatsCallback callback = verifyCallback(); - - List expandedAchievements = callback.getExpandedAchievements(); - - // Without schema, should return original blocks - assertNotNull(expandedAchievements); - assertEquals(2, expandedAchievements.size()); - - // Verify blocks have no enriched metadata - AchievementBlocks block21 = expandedAchievements.get(0); - assertEquals(21, block21.getAchievementId()); - assertNull(block21.getName()); - assertNull(block21.getDisplayName()); - assertNull(block21.getDescription()); - - AchievementBlocks block22 = expandedAchievements.get(1); - assertEquals(22, block22.getAchievementId()); - assertNull(block22.getName()); - assertNull(block22.getDisplayName()); - } - - @Test - public void testSchemaParsingHandlesCorruptData() { - // Create a packet with invalid schema data - 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(new byte[] { 0x01, 0x02, 0x03 })); // Invalid schema - - // Serialize and convert to IPacketMsg - IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); - - // Call handler to process the message - handler.handleMsg(packetMsg); - - // Verify the callback was posted - UserStatsCallback callback = verifyCallback(); - - // Should not throw exception, just have empty schema - assertNotNull(callback); - assertEquals(EResult.OK, callback.getResult()); - - // getExpandedAchievements should still work and fall back to empty list - List expanded = callback.getExpandedAchievements(); - assertNotNull(expanded); - assertTrue(expanded.isEmpty()); - } - - @Test - public void testAchievementBlockWithManyUnlocks() throws IOException { - // Test with a block that has many achievements unlocked - 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(createMockSchema())); - - // Create a block with 32 achievements (maximum per block) - CMsgClientGetUserStatsResponse.Achievement_Blocks.Builder block = CMsgClientGetUserStatsResponse.Achievement_Blocks - .newBuilder(); - block.setAchievementId(21); - - // Add 32 unlock times (some locked, some unlocked) - for (int i = 0; i < 32; i++) { - if (i < 3) { - // First 3 achievements have unlock times (matching our schema) - block.addUnlockTime(1609459200 + i * 86400); - } else { - // Rest are locked - block.addUnlockTime(0); - } - } - body.addAchievementBlocks(block); - - // Serialize and convert to IPacketMsg - IPacketMsg packetMsg = CMClient.getPacketMsg(msg.serialize()); - - // Call handler to process the message - handler.handleMsg(packetMsg); - - // Verify the callback was posted - UserStatsCallback callback = verifyCallback(); - List expanded = callback.getExpandedAchievements(); - - // Should only expand the 3 achievements that have schema entries - assertEquals(3, expanded.size()); - - // Verify unlocked achievements have correct timestamps - assertTrue(expanded.get(0).isUnlocked()); - assertTrue(expanded.get(1).isUnlocked()); - assertTrue(expanded.get(2).isUnlocked()); } } diff --git a/src/test/resources/packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin b/src/test/resources/packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin new file mode 100644 index 0000000000000000000000000000000000000000..30dcd1ed0ff0cb1239c062c32499cbde2c4cbe45 GIT binary patch literal 73364 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 P{QO7m-e}za_qYE5-T;yL literal 0 HcmV?d00001 From 2a0382b8eec00957ec90539980421f0e04628ffc Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sat, 6 Dec 2025 01:45:17 -0600 Subject: [PATCH 20/21] Change default app back to 440 --- .../javasteamsamples/_032_achievements/SampleAchievements.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java index c6f19dfe..6641f129 100644 --- a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java @@ -43,7 +43,7 @@ public class SampleAchievements implements Runnable { // - 440 (Team Fortress 2) // - 570 (Dota 2) // - 49520 (Borderlands 2 - requires ownership) - private static final int DEFAULT_APP_ID = 3527290; + private static final int DEFAULT_APP_ID = 440; private SteamClient steamClient; private CallbackManager manager; From 7024c5c53ac31cfc6be7ce52e027f88d4eca1321 Mon Sep 17 00:00:00 2001 From: LossyDragon Date: Sat, 6 Dec 2025 02:07:08 -0600 Subject: [PATCH 21/21] Move test packet to testpackets directory. --- build.gradle.kts | 3 ++- .../in/dragonbra/javasteam/TestPackets.java | 19 +----------------- .../steamuserstats/SteamUserStatsTest.java | 10 ++++----- .../ClientGetUserStatsResponse.bin} | Bin 4 files changed, 8 insertions(+), 24 deletions(-) rename src/test/resources/{packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin => testpackets/ClientGetUserStatsResponse.bin} (100%) diff --git a/build.gradle.kts b/build.gradle.kts index b5cced20..b11ed834 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,7 +138,8 @@ val mockitoAgent = configurations.create("mockitoAgent") tasks.withType { doFirst { jvmArgs("-javaagent:${mockitoAgent.asPath}") - }} + } +} dependencies { mockitoAgent(libs.test.mock.core) { isTransitive = false } diff --git a/src/test/java/in/dragonbra/javasteam/TestPackets.java b/src/test/java/in/dragonbra/javasteam/TestPackets.java index 3a4f1016..1746b9ed 100644 --- a/src/test/java/in/dragonbra/javasteam/TestPackets.java +++ b/src/test/java/in/dragonbra/javasteam/TestPackets.java @@ -113,23 +113,6 @@ public static byte[] getPacket(EMsg msgType, boolean isProto) { } } - /** - * Load packets captured from {@link in.dragonbra.javasteam.util.NetHookNetworkListener}. - * - * @param name the bin file name. - * @return a byte array from the loaded file. - */ - private static byte[] loadNetHookFile(String name) { - try (var file = TestPackets.class.getClassLoader().getResourceAsStream("packets/" + name)) { - if (file == null) { - return null; - } - return IOUtils.toByteArray(file); - } catch (IOException e) { - return null; - } - } - private static byte[] loadFile(String name) { try (var file = TestPackets.class.getClassLoader().getResourceAsStream("testpackets/" + name)) { if (file == null) { @@ -407,7 +390,7 @@ private static byte[] clientLicenseList() { } private static byte[] clientGetUserStatsResponse() { - return loadNetHookFile("226_in_819_k_EMsgClientGetUserStatsResponse.bin"); + return loadFile("ClientGetUserStatsResponse.bin"); } private static byte[] clientGameConnectTokens() { 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 7ee3bebf..5a6d6e1a 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 @@ -98,11 +98,11 @@ public void testUserStatsResponseStats() { // Grab a few random stats - Stats statFirst = callback.getStats().getFirst(); + Stats statFirst = callback.getStats().get(0); Assertions.assertEquals(17, statFirst.getStatId()); Assertions.assertEquals(2737815391L, Integer.toUnsignedLong(statFirst.getStatValue())); - Stats statLast = callback.getStats().getLast(); + Stats statLast = callback.getStats().get(1); Assertions.assertEquals(19, statLast.getStatId()); Assertions.assertEquals(487, statLast.getStatValue()); } @@ -119,11 +119,11 @@ public void testUserStatsResponseBlocks() { // Grab a few random achievement blocks - AchievementBlocks blockFirst = callback.getAchievementBlocks().getFirst(); + AchievementBlocks blockFirst = callback.getAchievementBlocks().get(0); Assertions.assertEquals(17, blockFirst.getAchievementId()); - Assertions.assertEquals(1733977234, blockFirst.getUnlockTime().getFirst()); + Assertions.assertEquals(1733977234, blockFirst.getUnlockTime().get(0)); - AchievementBlocks blockSecond = callback.getAchievementBlocks().getLast(); + AchievementBlocks blockSecond = callback.getAchievementBlocks().get(1); Assertions.assertEquals(19, blockSecond.getAchievementId()); Assertions.assertEquals(1733721477, blockSecond.getUnlockTime().get(8)); } diff --git a/src/test/resources/packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin b/src/test/resources/testpackets/ClientGetUserStatsResponse.bin similarity index 100% rename from src/test/resources/packets/226_in_819_k_EMsgClientGetUserStatsResponse.bin rename to src/test/resources/testpackets/ClientGetUserStatsResponse.bin