diff --git a/build.gradle.kts b/build.gradle.kts index 753cfba6..b11ed834 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -131,7 +131,19 @@ 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) 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 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..6641f129 --- /dev/null +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_032_achievements/SampleAchievements.java @@ -0,0 +1,303 @@ +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: 440 - Team Fortress 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); + + details.setLoginID(149); + + 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); + } + + //! 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"); + 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()); + + // 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(); + 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()); + + // 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)); + 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/AchievementBlocks.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/AchievementBlocks.kt index f2c4fd19..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 @@ -1,12 +1,57 @@ 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. * @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 + * @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 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 -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 + + /** + * 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/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..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 @@ -70,12 +70,101 @@ 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 (_: Exception) { + // Schema parsing failed, schemaKeyValues will remain empty + // This is okay - we'll just have achievements without enriched metadata } - MemoryStream(schema.toByteArray()).use { - schemaKeyValues.tryReadAsBinary(it) + // 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 + */ + @JvmOverloads + fun getExpandedAchievements(language: String = "english"): List { + val expandedAchievements = mutableListOf() + + try { + 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[block.achievementId.toString()] + val bitsBlock = statBlock["bits"] + + if (bitsBlock != KeyValue.INVALID) { + // This block has bit-level achievements, expand them and get the values + for (bitEntry in bitsBlock.children) { + val bitIndex = bitEntry["bit"].asInteger() + val displaySection = bitEntry["display"] + + // Extract metadata + 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) { + block.unlockTime[bitIndex] + } else { + 0 + } + + // 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 (_: Exception) { + // If expansion fails, return original blocks + return achievementBlocks + } + + return expandedAchievements + } } diff --git a/src/test/java/in/dragonbra/javasteam/TestPackets.java b/src/test/java/in/dragonbra/javasteam/TestPackets.java index 5be6ad5a..1746b9ed 100644 --- a/src/test/java/in/dragonbra/javasteam/TestPackets.java +++ b/src/test/java/in/dragonbra/javasteam/TestPackets.java @@ -106,14 +106,16 @@ public static byte[] getPacket(EMsg msgType, boolean isProto) { // return clientGetCDNAuthTokenResponse(); case ClientCheckAppBetaPasswordResponse: return clientCheckAppBetaPasswordResponse(); + case ClientGetUserStatsResponse: + return clientGetUserStatsResponse(); default: throw new NullPointerException(); } } 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 +389,10 @@ private static byte[] clientLicenseList() { return loadFile("ClientLicenseList.bin"); } + private static byte[] clientGetUserStatsResponse() { + return loadFile("ClientGetUserStatsResponse.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 new file mode 100644 index 00000000..5a6d6e1a --- /dev/null +++ b/src/test/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStatsTest.java @@ -0,0 +1,480 @@ +package in.dragonbra.javasteam.steam.handlers.steamuserstats; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Date; +import java.util.List; + +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; +import org.mockito.junit.jupiter.MockitoSettings; +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; +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; + +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) +public class SteamUserStatsTest extends HandlerTestBase { + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + @Override + 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().get(0); + Assertions.assertEquals(17, statFirst.getStatId()); + Assertions.assertEquals(2737815391L, Integer.toUnsignedLong(statFirst.getStatValue())); + + Stats statLast = callback.getStats().get(1); + 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().get(0); + Assertions.assertEquals(17, blockFirst.getAchievementId()); + Assertions.assertEquals(1733977234, blockFirst.getUnlockTime().get(0)); + + AchievementBlocks blockSecond = callback.getAchievementBlocks().get(1); + 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 + * 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 + 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", "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); + + // 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", "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); + + // 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", "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); + + 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", "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); + + // 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", "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); + + 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(); + } + + 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); + } + } +} diff --git a/src/test/resources/testpackets/ClientGetUserStatsResponse.bin b/src/test/resources/testpackets/ClientGetUserStatsResponse.bin new file mode 100644 index 00000000..30dcd1ed Binary files /dev/null and b/src/test/resources/testpackets/ClientGetUserStatsResponse.bin differ