diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_014_steammatchmaking/SampleSteamMatchmaking.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_014_steammatchmaking/SampleSteamMatchmaking.java new file mode 100644 index 00000000..565fb6dd --- /dev/null +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_014_steammatchmaking/SampleSteamMatchmaking.java @@ -0,0 +1,247 @@ +package in.dragonbra.javasteamsamples._014_steammatchmaking; + +import in.dragonbra.javasteam.enums.ELobbyComparison; +import in.dragonbra.javasteam.enums.ELobbyDistanceFilter; +import in.dragonbra.javasteam.enums.EResult; +import in.dragonbra.javasteam.steam.authentication.*; +import in.dragonbra.javasteam.steam.handlers.steammatchmaking.*; +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.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.util.log.DefaultLogListener; +import in.dragonbra.javasteam.util.log.LogManager; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; + +/** + * @author lossy + * @since 2025-05-21 + */ +@SuppressWarnings("FieldCanBeLocal") +public class SampleSteamMatchmaking implements Runnable { + + private final Integer appID = 480; // Team Fortress 2 + + private SteamClient steamClient; + + private CallbackManager manager; + + private SteamUser steamUser; + + private SteamMatchmaking steamMatchmaking; + + private boolean isRunning; + + private final String user; + + private final String pass; + + private List subscriptions; + + public SampleSteamMatchmaking(String user, String pass) { + this.user = user; + this.pass = pass; + } + + public static void main(String[] args) { + if (args.length < 2) { + System.out.println("Sample1: No username and password specified!"); + return; + } + + LogManager.addListener(new DefaultLogListener()); + + new SampleSteamMatchmaking(args[0], args[1]).run(); + } + + @Override + public void run() { + // create our steamclient instance using default configuration + steamClient = new SteamClient(); + + // create the callback manager which will route callbacks to function calls + manager = new CallbackManager(steamClient); + + // get the steamuser handler, which is used for logging on after successfully connecting + steamUser = steamClient.getHandler(SteamUser.class); + + // get the steammatchmaking handler. + steamMatchmaking = steamClient.getHandler(SteamMatchmaking.class); + + // The callbacks are a closeable, and to properly fix + // "'Closeable' used without 'try'-with-resources statement", they should be closed once done. + // Usually putting them in a list and close each of them once the client is finished is recommended. + subscriptions = new ArrayList<>(); + + // register a few callbacks we're interested in + // these are registered upon creation to a callback manager, which will then route the callbacks + // to the functions specified + subscriptions.add(manager.subscribe(ConnectedCallback.class, this::onConnected)); + subscriptions.add(manager.subscribe(DisconnectedCallback.class, this::onDisconnected)); + subscriptions.add(manager.subscribe(LoggedOnCallback.class, this::onLoggedOn)); + subscriptions.add(manager.subscribe(LoggedOffCallback.class, this::onLoggedOff)); + + isRunning = true; + + System.out.println("Connecting to steam..."); + + // initiate the connection + steamClient.connect(); + + // create our callback handling loop + while (isRunning) { + // in order for the callbacks to get routed, they need to be handled by the manager + manager.runWaitCallbacks(1000L); + } + + // Close the subscriptions when done. + System.out.println("Closing " + subscriptions.size() + " callbacks"); + for (var subscription : subscriptions) { + try { + subscription.close(); + } catch (IOException e) { + System.out.println("Couldn't close a callback."); + } + } + } + + @SuppressWarnings("DanglingJavadoc") + private void onConnected(ConnectedCallback callback) { + System.out.println("Connected to Steam! Logging in " + user + "..."); + + var shouldRememberPassword = false; + + AuthSessionDetails authDetails = new AuthSessionDetails(); + authDetails.username = user; + authDetails.password = pass; + authDetails.persistentSession = shouldRememberPassword; + + /** + * {@link UserConsoleAuthenticator} is the default authenticator implementation provided by JavaSteam + * for ease of use which blocks the thread and asks for user input to enter the code. + * However, if you require special handling (e.g. you have the TOTP secret and can generate codes on the fly), + * you can implement your own {@link IAuthenticator}. + */ + authDetails.authenticator = new UserConsoleAuthenticator(); + + try { + // Begin authenticating via credentials. + var authSession = steamClient.getAuthentication().beginAuthSessionViaCredentials(authDetails).get(); + + // Note: This is blocking, it would be up to you to make it non-blocking for Java. + // Note: Kotlin uses should use ".pollingWaitForResult()" as its a suspending function. + AuthPollResult pollResponse = authSession.pollingWaitForResult().get(); + + // Logon to Steam with the access token we have received + // Note that we are using RefreshToken for logging on here + LogOnDetails details = new LogOnDetails(); + details.setUsername(pollResponse.getAccountName()); + details.setAccessToken(pollResponse.getRefreshToken()); + + // Set LoginID to a non-zero value if you have another client connected using the same account, + // the same private ip, and same public ip. + details.setLoginID(149); + + steamUser.logOn(details); + + } catch (Exception e) { + // List a couple of exceptions that could be important to handle. + if (e instanceof AuthenticationException) { + System.err.println("An Authentication error has occurred. " + e.getMessage()); + } else if (e instanceof CancellationException) { + System.err.println("An Cancellation exception was raised. Usually means a timeout occurred. " + e.getMessage()); + } else { + System.err.println("An error occurred:" + e.getMessage()); + } + + steamUser.logOff(); + } + } + + private void onDisconnected(DisconnectedCallback callback) { + System.out.println("Disconnected from Steam. User initialized: " + callback.isUserInitiated()); + + // If the disconnection was not user initiated, we will retry connecting to steam again after a short delay. + if (callback.isUserInitiated()) { + isRunning = false; + } else { + try { + Thread.sleep(2000L); + steamClient.connect(); + } catch (InterruptedException e) { + System.err.println("An Interrupted exception occurred. " + e.getMessage()); + } + } + } + + private void onLoggedOn(LoggedOnCallback callback) { + if (callback.getResult() != EResult.OK) { + System.out.println("Unable to logon to Steam: " + callback.getResult() + " / " + callback.getExtendedResult()); + + isRunning = false; + return; + } + + System.out.println("Successfully logged on!"); + + // at this point, we'd be able to perform actions on Steam + + try { + var filters = List.of( + new DistanceFilter(ELobbyDistanceFilter.Worldwide), + new StringFilter("CONMETHOD", "P2P", ELobbyComparison.Equal) + ); + var lobbyListCallback = steamMatchmaking.getLobbyList(appID, filters, 20).toFuture().get(); + + System.out.println("App ID: " + lobbyListCallback.getAppID()); + System.out.println("Result: " + lobbyListCallback.getResult()); + System.out.println("Lobby Size: " + lobbyListCallback.getLobbies().size()); + lobbyListCallback.getLobbies().forEach(lobby -> { + System.out.println("\tsteamID: " + lobby.getSteamID().convertToUInt64()); + System.out.println("\tlobbyType: " + lobby.getLobbyType()); + System.out.println("\tlobbyFlags: " + lobby.getLobbyFlags()); + System.out.println("\townerSteamID: " + lobby.getOwnerSteamID()); + + System.out.println("\tMetadata:"); + lobby.getMetadata().forEach((k, v) -> System.out.println("\t\tkey: " + k + " value: " + v)); + + System.out.println("\tmaxMembers: " + lobby.getMaxMembers()); + System.out.println("\tnumMembers: " + lobby.getNumMembers()); + + System.out.println("\tMembers:"); + lobby.getMembers().forEach(member -> { + System.out.println("\t\tsteamID: " + member.getSteamID().convertToUInt64()); + System.out.println("\t\tpersonaName: " + member.getPersonaName()); + System.out.println("\t\tMember Metadata:"); + member.getMetadata().forEach((k, v) -> + System.out.println("\t\t\tkey: " + k + " value: " + v)); + }); + + System.out.println("\tdistance: " + lobby.getDistance()); + System.out.println("\tweight: " + lobby.getWeight()); + System.out.println("\n"); + }); + + + } catch (Exception e) { + System.err.println(e.getMessage()); + } finally { + steamUser.logOff(); + } + } + + private void onLoggedOff(LoggedOffCallback callback) { + System.out.println("Logged off of Steam: " + callback.getResult()); + + isRunning = false; + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/CMClient.java b/src/main/java/in/dragonbra/javasteam/steam/CMClient.java index 1351b746..2860c497 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/CMClient.java +++ b/src/main/java/in/dragonbra/javasteam/steam/CMClient.java @@ -18,10 +18,7 @@ import in.dragonbra.javasteam.steam.discovery.SmartCMServerList; import in.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration; import in.dragonbra.javasteam.types.SteamID; -import in.dragonbra.javasteam.util.IDebugNetworkListener; -import in.dragonbra.javasteam.util.MsgUtil; -import in.dragonbra.javasteam.util.NetHookNetworkListener; -import in.dragonbra.javasteam.util.Strings; +import in.dragonbra.javasteam.util.*; import in.dragonbra.javasteam.util.event.EventArgs; import in.dragonbra.javasteam.util.event.EventHandler; import in.dragonbra.javasteam.util.event.ScheduledFunction; @@ -48,6 +45,12 @@ public abstract class CMClient { private final SteamConfiguration configuration; + @Nullable + private InetAddress publicIP; + + @Nullable + private String ipCountryCode; + private boolean isConnected; private long sessionToken; @@ -425,6 +428,8 @@ private void handleLogOnResponse(IPacketMsg packetMsg) { steamID = new SteamID(logonResp.getProtoHeader().getSteamid()); cellID = logonResp.getBody().getCellId(); + publicIP = NetHelpers.getIPAddress(logonResp.getBody().getPublicIp()); + ipCountryCode = logonResp.getBody().getIpCountryCode(); // restart heartbeat heartBeatFunc.stop(); @@ -445,6 +450,8 @@ private void handleLoggedOff(IPacketMsg packetMsg) { steamID = null; cellID = null; + publicIP = null; + ipCountryCode = null; heartBeatFunc.stop(); @@ -519,6 +526,26 @@ public SmartCMServerList getServers() { return connection.getCurrentEndPoint(); } + /** + * Gets the public IP address of this client. This value is assigned after a logon attempt has succeeded. + * This value will be null if the client is logged off of Steam. + * + * @return The {@link InetSocketAddress} public ip + */ + public @Nullable InetAddress getPublicIP() { + return publicIP; + } + + /** + * Gets the country code of our public IP address according to Steam. This value is assigned after a logon attempt has succeeded. + * This value will be null if the client is logged off of Steam. + * + * @return the ip country code. + */ + public @Nullable String getIpCountryCode() { + return ipCountryCode; + } + /** * Gets the universe of this client. * diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamfriends/callback/ChatMemberInfoCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamfriends/callback/ChatMemberInfoCallback.kt index 4fab55d4..549d451d 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamfriends/callback/ChatMemberInfoCallback.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamfriends/callback/ChatMemberInfoCallback.kt @@ -35,7 +35,7 @@ class ChatMemberInfoCallback(packetMsg: IPacketMsg) : CallbackMsg() { val type: EChatInfoType /** - * Gets the state change info for member info updates. + * Gets the state change info for [EChatInfoType.StateChange] member info updates. */ var stateChangeInfo: StateChangeDetails? = null diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Filter.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Filter.kt new file mode 100644 index 00000000..c4849d5a --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Filter.kt @@ -0,0 +1,191 @@ +@file:Suppress("unused") + +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking + +import `in`.dragonbra.javasteam.enums.ELobbyComparison +import `in`.dragonbra.javasteam.enums.ELobbyDistanceFilter +import `in`.dragonbra.javasteam.enums.ELobbyFilterType +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms + +/** + * The lobby filter base class. + * + * @constructor Base constructor for all filter subclasses. + * @param filterType The type of filter. + * @param key The metadata key this filter pertains to. + * @param comparison The comparison method used by this filter. + * + * @property filterType The type of filter. + * @property key The metadata key this filter pertains to. Under certain circumstances e.g. a distance filter, this will be an empty string. + * @property comparison The comparison method used by this filter. + * + * @author Lossy + * @since 2025-05-21 + */ +abstract class Filter( + val filterType: ELobbyFilterType, + val key: String, + val comparison: ELobbyComparison, +) { + /** + * Serializes the filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + open fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.newBuilder().apply { + this.filterType = this@Filter.filterType.code() + this.key = this@Filter.key + this.comparision = this@Filter.comparison.code() + } +} + +/** + * Can be used to filter lobbies geographically (based on IP according to Steam's IP database). + * + * @constructor Initializes a new instance of the [DistanceFilter] class. + * @param value Steam distance filter value. + * + * @property value Steam distance filter value. + * + * @author Lossy + * @since 2025-05-21 + */ +class DistanceFilter( + val value: ELobbyDistanceFilter, +) : Filter( + filterType = ELobbyFilterType.Distance, + key = "", + comparison = ELobbyComparison.Equal +) { + /** + * Serializes the distance filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + override fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + super.serialize().apply { + this.value = this@DistanceFilter.value.code().toString() + } +} + +/** + * Can be used to filter lobbies with a metadata value closest to the specified value. Multiple + * near filters can be specified, with former filters taking precedence over latter filters. + * + * @constructor Initializes a new instance of the [NearValueFilter] class. + * @param key The metadata key this filter pertains to. + * @param value Integer value to compare against + * + * @param value Integer value that lobbies' metadata value should be close to. + * + * @author Lossy + * @since 2025-05-21 + */ +class NearValueFilter( + key: String, + val value: Int, +) : Filter( + filterType = ELobbyFilterType.NearValue, + key = key, + comparison = ELobbyComparison.Equal +) { + /** + * Serializes the slots available filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + override fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + super.serialize().apply { + this.value = this@NearValueFilter.value.toString() + } +} + +/** + * Can be used to filter lobbies by comparing an integer against a value in each lobby's metadata. + * + * @constructor Initializes a new instance of the [NumericalFilter] class. + * @param key The metadata key this filter pertains to. + * @param comparison The comparison method used by this filter. + * @param value Integer value to compare against. + * + * @property value Integer value to compare against. + * + * @author Lossy + * @since 2025-05-21 + */ +class NumericalFilter( + key: String, + val value: Int, + comparison: ELobbyComparison, +) : Filter( + filterType = ELobbyFilterType.Numerical, + key = key, + comparison = comparison +) { + /** + * Serializes the numerical filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + override fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + super.serialize().apply { + this.value = this@NumericalFilter.value.toString() + } +} + +/** + * Can be used to filter lobbies by minimum number of slots available. + * + * @constructor Initializes a new instance of the [SlotsAvailableFilter] class. + * @param slotsAvailable Integer value to compare against. + * + * @property slotsAvailable Minimum number of slots available in the lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class SlotsAvailableFilter( + val slotsAvailable: Int, +) : Filter( + filterType = ELobbyFilterType.SlotsAvailable, + key = "", + comparison = ELobbyComparison.Equal, +) { + /** + * Serializes the slots available filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + override fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + super.serialize().apply { + this.value = slotsAvailable.toString() + } +} + +/** + * Can be used to filter lobbies by comparing a string against a value in each lobby's metadata. + * + * @constructor Initializes a new instance of the [StringFilter] class. + * @param key The metadata key this filter pertains to. + * @param comparison The comparison method used by this filter. + * @param value String value to compare against. + * + * @property value String value to compare against. + * + * @author Lossy + * @since 2025-05-21 + */ +class StringFilter( + key: String, + val value: String, + comparison: ELobbyComparison, +) : Filter( + filterType = ELobbyFilterType.String, + key = key, + comparison = comparison +) { + /** + * Serializes the string filter into a representation used internally by SteamMatchmaking. + * @return A protobuf serializable representation of this filter. + */ + override fun serialize(): SteammessagesClientserverMms.CMsgClientMMSGetLobbyList.Filter.Builder = + super.serialize().apply { + this.value = this@StringFilter.value + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt new file mode 100644 index 00000000..7b950dbb --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt @@ -0,0 +1,88 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking + +import com.google.protobuf.ByteString +import `in`.dragonbra.javasteam.enums.ELobbyType +import `in`.dragonbra.javasteam.types.KeyValue +import `in`.dragonbra.javasteam.types.SteamID +import `in`.dragonbra.javasteam.util.stream.MemoryStream + +/** + * Represents a Steam lobby. + * @param steamID SteamID of the lobby. + * @param lobbyType The type of the lobby. + * @param lobbyFlags The lobby's flags. + * @param ownerSteamID The SteamID of the lobby's owner. Please keep in mind that Steam does not provide lobby + * owner details for lobbies returned in a lobby list. As such, lobbies that have been + * obtained/updated as a result of calling [SteamMatchmaking.getLobbyList] + * may have a null (or non-null but state) owner. + * @param metadata The metadata of the lobby; string key-value pairs. + * @param maxMembers The maximum number of members that can occupy the lobby. + * @param numMembers The number of members that are currently occupying the lobby. + * @param members A list of lobby members. This will only be populated for the user's current lobby. + * @param distance The distance of the lobby. + * @param weight The weight of the lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class Lobby( + val steamID: SteamID, + val lobbyType: ELobbyType, + val lobbyFlags: Int, + val ownerSteamID: SteamID?, + val metadata: Map = mapOf(), + val maxMembers: Int, + val numMembers: Int, + val members: List = listOf(), + val distance: Float?, + val weight: Long?, +) { + companion object { + + internal fun ByteArray.toByteString(): ByteString = ByteString.copyFrom(this) + + @JvmStatic + internal fun encodeMetadata(metadata: Map?): ByteArray { + val keyValue = KeyValue("") + + metadata?.forEach { entry -> + keyValue[entry.key] = KeyValue(null, entry.value) + } + + return MemoryStream().use { ms -> + keyValue.saveToStream(ms.asOutputStream(), true) + ms.toByteArray() + } + } + + @JvmStatic + internal fun decodeMetadata(buffer: ByteString?): Map = decodeMetadata(buffer?.toByteArray()) + + @JvmStatic + internal fun decodeMetadata(buffer: ByteArray?): Map { + if (buffer == null || buffer.isEmpty()) { + return emptyMap() + } + + val keyValue = KeyValue() + + MemoryStream(buffer).use { ms -> + if (!keyValue.tryReadAsBinary(ms)) { + throw NumberFormatException("Lobby metadata is of an unexpected format") + } + } + + val metadata = mutableMapOf() + + keyValue.children.forEach { value -> + if (value.name == null || value.value == null) { + return metadata + } + + metadata[value.name] = value.value + } + + return metadata.toMap() + } + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/LobbyCache.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/LobbyCache.kt new file mode 100644 index 00000000..e8bcaf35 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/LobbyCache.kt @@ -0,0 +1,124 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking + +import `in`.dragonbra.javasteam.types.SteamID +import java.util.concurrent.ConcurrentHashMap + +/** + * Cache for managing Steam lobbies. + * + * @author Lossy + * @since 2025-05-21 + */ +@Suppress("unused") +class LobbyCache { + + private val lobbies: ConcurrentHashMap> = ConcurrentHashMap() + + fun getLobby(appId: Int, lobbySteamId: Long): Lobby? = getLobby(appId, SteamID(lobbySteamId)) + + fun getLobby(appId: Int, lobbySteamId: SteamID): Lobby? = getAppLobbies(appId)[lobbySteamId] + + fun cacheLobby(appId: Int, lobby: Lobby) { + getAppLobbies(appId)[lobby.steamID] = lobby + } + + fun addLobbyMember(appId: Int, lobby: Lobby, memberId: Long, personaName: String): Member? = addLobbyMember(appId, lobby, SteamID(memberId), personaName) + + fun addLobbyMember(appId: Int, lobby: Lobby, memberId: SteamID, personaName: String): Member? { + val existingMember = lobby.members.firstOrNull { it.steamID == memberId } + + if (existingMember != null) { + // Already in lobby + return null + } + + val addedMember = Member(steamID = memberId, personaName = personaName) + + val members = ArrayList(lobby.members.size + 1) + members.addAll(lobby.members) + members.add(addedMember) + + updateLobbyMembers(appId = appId, lobby = lobby, members = members) + + return addedMember + } + + fun removeLobbyMember(appId: Int, lobby: Lobby, memberId: Long): Member? = removeLobbyMember(appId, lobby, SteamID(memberId)) + + fun removeLobbyMember(appId: Int, lobby: Lobby, memberId: SteamID): Member? { + val removedMember = lobby.members.firstOrNull { it.steamID == memberId } + + if (removedMember == null) { + return null + } + + val members = lobby.members.filter { it != removedMember } + + if (members.isNotEmpty()) { + updateLobbyMembers(appId = appId, lobby = lobby, members = members) + } else { + // Steam deletes lobbies that contain no members + deleteLobby(appId = appId, lobbySteamId = lobby.steamID) + } + + return removedMember + } + + fun clearLobbyMembers(appId: Int, lobbySteamId: Long) { + clearLobbyMembers(appId, SteamID(lobbySteamId)) + } + + fun clearLobbyMembers(appId: Int, lobbySteamId: SteamID) { + val lobby = getLobby(appId = appId, lobbySteamId = lobbySteamId) + + if (lobby != null) { + updateLobbyMembers(appId = appId, lobby = lobby, owner = null, members = null) + } + } + + fun updateLobbyOwner(appId: Int, lobbySteamId: Long, ownerSteamId: Long) { + updateLobbyOwner(appId = appId, lobbySteamId = SteamID(lobbySteamId), ownerSteamId = SteamID(ownerSteamId)) + } + + fun updateLobbyOwner(appId: Int, lobbySteamId: SteamID, ownerSteamId: SteamID) { + val lobby = getLobby(appId = appId, lobbySteamId = lobbySteamId) + + if (lobby != null) { + updateLobbyMembers(appId = appId, lobby = lobby, owner = ownerSteamId, members = lobby.members) + } + } + + fun updateLobbyMembers(appId: Int, lobby: Lobby, members: List) { + updateLobbyMembers(appId = appId, lobby = lobby, owner = lobby.ownerSteamID, members = members) + } + + fun clear() { + lobbies.clear() + } + + private fun updateLobbyMembers(appId: Int, lobby: Lobby, owner: SteamID?, members: List?) { + cacheLobby( + appId = appId, + lobby = Lobby( + steamID = lobby.steamID, + lobbyType = lobby.lobbyType, + lobbyFlags = lobby.lobbyFlags, + ownerSteamID = owner, + metadata = lobby.metadata, + maxMembers = lobby.maxMembers, + numMembers = lobby.numMembers, + members = members ?: listOf(), + distance = lobby.distance, + weight = lobby.weight + ) + ) + } + + private fun getAppLobbies(appId: Int): ConcurrentHashMap = + lobbies.computeIfAbsent(appId) { ConcurrentHashMap() } + + private fun deleteLobby(appId: Int, lobbySteamId: SteamID): Lobby? { + val appLobbies = lobbies[appId] ?: return null + return appLobbies.remove(lobbySteamId) + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Member.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Member.kt new file mode 100644 index 00000000..7efbabb3 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Member.kt @@ -0,0 +1,35 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking + +import `in`.dragonbra.javasteam.types.SteamID + +/** + * Represents a Steam user within a lobby. + * @param steamID SteamID of the lobby member. + * @param personaName Steam persona of the lobby member. + * @param metadata Metadata attached to the lobby member. + * + * @author Lossy + * @since 2025-05-21 + */ +data class Member( + val steamID: SteamID, + val personaName: String, + val metadata: Map = emptyMap(), +) { + /** + * Checks to see if this lobby member is equal to another. Only the SteamID of the lobby member is taken into account. + * @return true, if obj is [Member] with a matching SteamID. Otherwise, false. + */ + override fun equals(other: Any?): Boolean { + if (other is Member) { + return steamID == other.steamID + } + return false + } + + /** + * Hash code of the lobby member. Only the SteamID of the lobby member is taken into account. + * @return The hash code of this lobby member. + */ + override fun hashCode(): Int = steamID.hashCode() +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/SteamMatchmaking.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/SteamMatchmaking.kt new file mode 100644 index 00000000..139f2c09 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/SteamMatchmaking.kt @@ -0,0 +1,728 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking + +import com.google.protobuf.GeneratedMessage +import `in`.dragonbra.javasteam.base.ClientMsgProtobuf +import `in`.dragonbra.javasteam.base.IPacketMsg +import `in`.dragonbra.javasteam.enums.EChatRoomEnterResponse +import `in`.dragonbra.javasteam.enums.ELobbyType +import `in`.dragonbra.javasteam.enums.EMsg +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSCreateLobby +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSCreateLobbyResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSGetLobbyData +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSGetLobbyList +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSGetLobbyListResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSInviteToLobby +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSJoinLobby +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSJoinLobbyResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSLeaveLobby +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSLeaveLobbyResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSLobbyData +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSSetLobbyData +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSSetLobbyDataResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSSetLobbyOwner +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSSetLobbyOwnerResponse +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSUserJoinedLobby +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverMms.CMsgClientMMSUserLeftLobby +import `in`.dragonbra.javasteam.steam.handlers.ClientMsgHandler +import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Lobby.Companion.toByteString +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.CreateLobbyCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.GetLobbyListCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.JoinLobbyCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.LeaveLobbyCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.LobbyDataCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.SetLobbyDataCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.SetLobbyOwnerCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.UserJoinedLobbyCallback +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback.UserLeftLobbyCallback +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.AsyncJobSingle +import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.SteamID +import `in`.dragonbra.javasteam.util.NetHelpers +import java.util.concurrent.ConcurrentHashMap + +/** + * This handler is used for creating, joining and obtaining lobby information. + * + * @author Lossy + * @since 2025-05-21 + */ +@Suppress("unused") +class SteamMatchmaking : ClientMsgHandler() { + + private fun getHandler(packetMsg: IPacketMsg): ((IPacketMsg) -> Unit)? = when (packetMsg.msgType) { + EMsg.ClientMMSCreateLobbyResponse -> ::handleCreateLobbyResponse + EMsg.ClientMMSSetLobbyDataResponse -> ::handleSetLobbyDataResponse + EMsg.ClientMMSSetLobbyOwnerResponse -> ::handleSetLobbyOwnerResponse + EMsg.ClientMMSLobbyData -> ::handleLobbyData + EMsg.ClientMMSGetLobbyListResponse -> ::handleGetLobbyListResponse + EMsg.ClientMMSJoinLobbyResponse -> ::handleJoinLobbyResponse + EMsg.ClientMMSLeaveLobbyResponse -> ::handleLeaveLobbyResponse + EMsg.ClientMMSUserJoinedLobby -> ::handleUserJoinedLobby + EMsg.ClientMMSUserLeftLobby -> ::handleUserLeftLobby + else -> null + } + + private val lobbyManipulationRequests: ConcurrentHashMap = ConcurrentHashMap() + + private val lobbyCache: LobbyCache = LobbyCache() + + /** + * Sends a request to create a lobby. + * @param appId ID of the app the lobby will belong to. + * @param lobbyType The lobby type. + * @param maxMembers The maximum number of members that may occupy the lobby. + * @param lobbyFlags The lobby flags. Defaults to 0. + * @param metadata The metadata for the lobby. Defaults to null (treated as an empty dictionary). + * @return null, if the request could not be submitted i.e., not yet logged in. Otherwise, an [CreateLobbyCallback]. + */ + @JvmOverloads + fun createLobby( + appId: Int, + lobbyType: ELobbyType, + maxMembers: Int, + lobbyFlags: Int = 0, + metadata: Map? = null, + ): AsyncJobSingle? { + if (client.cellID == null) { + return null + } + + val personaName = client.getHandler()!!.getPersonaName() + + val createLobby = ClientMsgProtobuf( + CMsgClientMMSCreateLobby::class.java, + EMsg.ClientMMSCreateLobby + ).apply { + body.appId = appId + body.lobbyType = lobbyType.code() + body.maxMembers = maxMembers + body.lobbyFlags = lobbyFlags + body.metadata = Lobby.encodeMetadata(metadata).toByteString() + body.cellId = client.cellID!! + body.publicIp = NetHelpers.getMsgIPAddress(client.publicIP!!) + body.personaNameOwner = personaName + + sourceJobID = client.getNextJobID() + } + + send(msg = createLobby, appId = appId) + + lobbyManipulationRequests[createLobby.sourceJobID] = createLobby.body.build() + return attachIncompleteManipulationHandler( + job = AsyncJobSingle(client, createLobby.sourceJobID) + ) + } + + /** + * Sends a request to update a lobby. + * @param appId ID of app the lobby belongs to. + * @param lobbySteamId The SteamID of the lobby that should be updated. + * @param lobbyType The lobby type. + * @param maxMembers The maximum number of members that may occupy the lobby. + * @param lobbyFlags The lobby flags. Defaults to 0. + * @param metadata The metadata for the lobby. Defaults to null (treated as an empty dictionary). + * @return An [SetLobbyDataCallback]. + */ + @JvmOverloads + fun setLobbyData( + appId: Int, + lobbySteamId: SteamID, + lobbyType: ELobbyType, + maxMembers: Int, + lobbyFlags: Int = 0, + metadata: Map? = null, + ): AsyncJobSingle { + val setLobbyData = ClientMsgProtobuf( + CMsgClientMMSSetLobbyData::class.java, + EMsg.ClientMMSSetLobbyData + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + body.steamIdMember = 0 + body.lobbyType = lobbyType.code() + body.maxMembers = maxMembers + body.lobbyFlags = lobbyFlags + body.metadata = Lobby.encodeMetadata(metadata).toByteString() + + sourceJobID = client.getNextJobID() + } + + send(msg = setLobbyData, appId = appId) + + lobbyManipulationRequests[setLobbyData.sourceJobID] = setLobbyData.body.build() + return attachIncompleteManipulationHandler( + job = AsyncJobSingle(client, setLobbyData.sourceJobID) + ) + } + + /** + * Sends a request to update the current user's lobby metadata. + * @param appId ID of app the lobby belongs to. + * @param lobbySteamId The SteamID of the lobby that should be updated. + * @param metadata The metadata for the lobby. + * @return null, if the request could not be submitted i.e. not yet logged in. Otherwise, an [SetLobbyDataCallback]. + */ + fun setLobbyMemberData( + appId: Int, + lobbySteamId: SteamID, + metadata: Map, + ): AsyncJobSingle? { + if (client.steamID == null) { + return null + } + + val setLobbyData = ClientMsgProtobuf( + CMsgClientMMSSetLobbyData::class.java, + EMsg.ClientMMSSetLobbyData + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + body.steamIdMember = client.steamID!!.convertToUInt64() + body.metadata = Lobby.encodeMetadata(metadata).toByteString() + + sourceJobID = client.getNextJobID() + } + + send(msg = setLobbyData, appId = appId) + + lobbyManipulationRequests[setLobbyData.sourceJobID] = setLobbyData.body.build() + return attachIncompleteManipulationHandler( + job = AsyncJobSingle(client, setLobbyData.sourceJobID) + ) + } + + /** + * Sends a request to update the owner of a lobby. + * @param appId ID of app the lobby belongs to. + * @param lobbySteamId The SteamID of the lobby that should have its owner updated. + * @param newOwner The SteamID of the owner. + * @return An [SetLobbyOwnerCallback]. + */ + fun setLobbyOwner( + appId: Int, + lobbySteamId: SteamID, + newOwner: SteamID, + ): AsyncJobSingle { + val setLobbyOwner = ClientMsgProtobuf( + CMsgClientMMSSetLobbyOwner::class.java, + EMsg.ClientMMSSetLobbyOwner + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + body.steamIdNewOwner = newOwner.convertToUInt64() + + sourceJobID = client.getNextJobID() + } + + send(msg = setLobbyOwner, appId = appId) + + lobbyManipulationRequests[setLobbyOwner.sourceJobID] = setLobbyOwner.body.build() + return attachIncompleteManipulationHandler( + job = AsyncJobSingle(client, setLobbyOwner.sourceJobID) + ) + } + + /** + * Sends a request to obtain a list of lobbies matching the specified criteria. + * @param appId The ID of app for which we're requesting a list of lobbies. + * @param filters An optional list of filters. + * @param maxLobbies An optional maximum number of lobbies that will be returned. + * @return null, if the request could not be submitted i.e. not yet logged in. Otherwise, an [GetLobbyListCallback]. + */ + @JvmOverloads + fun getLobbyList( + appId: Int, + filters: List? = null, + maxLobbies: Int = -1, + ): AsyncJobSingle? { + if (client.cellID == null) { + return null + } + + val getLobbies = ClientMsgProtobuf( + CMsgClientMMSGetLobbyList::class.java, + EMsg.ClientMMSGetLobbyList + ).apply { + body.appId = appId + body.cellId = client.cellID!! + body.publicIp = NetHelpers.getMsgIPAddress(client.publicIP!!) + body.numLobbiesRequested = maxLobbies + + sourceJobID = client.getNextJobID() + } + + filters?.forEach { filter -> + getLobbies.body.addFilters(filter.serialize().build()) + } + + send(msg = getLobbies, appId = appId) + + return AsyncJobSingle(client, getLobbies.sourceJobID) + } + + /** + * Sends a request to join a lobby. + * @param appId ID of app the lobby belongs to. + * @param lobbySteamId The SteamID of the lobby that should be joined. + * @return null, if the request could not be submitted i.e. not yet logged in. Otherwise, an [JoinLobbyCallback]. + */ + fun joinLobby( + appId: Int, + lobbySteamId: SteamID, + ): AsyncJobSingle? { + val personaName = client.getHandler()?.getPersonaName() + + if (personaName == null) { + return null + } + + val joinLobby = ClientMsgProtobuf( + CMsgClientMMSJoinLobby::class.java, + EMsg.ClientMMSJoinLobby + ).apply { + body.appId = appId + body.personaName = personaName + body.steamIdLobby = lobbySteamId.convertToUInt64() + + sourceJobID = client.getNextJobID() + } + + send(msg = joinLobby, appId = appId) + + return AsyncJobSingle(client, joinLobby.sourceJobID) + } + + /** + * Sends a request to leave a lobby. + * @param appId ID of app the lobby belongs to. + * @param lobbySteamId The SteamID of the lobby that should be left. + * @return An [LeaveLobbyCallback]. + */ + fun leaveLobby(appId: Int, lobbySteamId: SteamID): AsyncJobSingle { + val leaveLobby = ClientMsgProtobuf( + CMsgClientMMSLeaveLobby::class.java, + EMsg.ClientMMSLeaveLobby + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + + sourceJobID = client.getNextJobID() + } + + send(msg = leaveLobby, appId = appId) + + return AsyncJobSingle(client, leaveLobby.sourceJobID) + } + + /** + * Sends a request to obtain a lobby's data. + * @param appId The ID of app which we're attempting to obtain lobby data for. + * @param lobbySteamId The SteamID of the lobby whose data is being requested. + * @return An [LobbyDataCallback]. + */ + fun getLobbyData(appId: Int, lobbySteamId: SteamID): AsyncJobSingle { + val getLobbyData = ClientMsgProtobuf( + CMsgClientMMSGetLobbyData::class.java, + EMsg.ClientMMSGetLobbyData + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + + sourceJobID = client.getNextJobID() + } + + send(msg = getLobbyData, appId = appId) + + return AsyncJobSingle(client, getLobbyData.sourceJobID) + } + + /** + * Sends a lobby invite request. + * NOTE: Steam provides no functionality to determine if the user was successfully invited. + * @param appId The ID of app which owns the lobby we're inviting a user to. + * @param lobbySteamId The SteamID of the lobby we're inviting a user to. + * @param userSteamId The SteamID of the user we're inviting. + */ + fun inviteToLobby(appId: Int, lobbySteamId: SteamID, userSteamId: SteamID) { + val getLobbyData = ClientMsgProtobuf( + CMsgClientMMSInviteToLobby::class.java, + EMsg.ClientMMSInviteToLobby + ).apply { + body.appId = appId + body.steamIdLobby = lobbySteamId.convertToUInt64() + body.steamIdUserInvited = userSteamId.convertToUInt64() + } + + send(msg = getLobbyData, appId = appId) + } + + /** + * Obtains a [Lobby] by its [SteamID], if the data is cached locally. + * This method does not send a network request. + * @param appId The ID of app which we're attempting to obtain a lobby for. + * @param lobbySteamId The SteamID of the lobby that should be returned. + * @return The [Lobby] corresponding with the specified app and lobby ID, if cached. Otherwise, null. + */ + fun getLobby(appId: Int, lobbySteamId: SteamID): Lobby? = lobbyCache.getLobby(appId, lobbySteamId) + + /** + * Sends a matchmaking message for a specific app. + * @param msg The matchmaking message to send. + * @param appId The ID of the app this message pertains to. + */ + fun > send(msg: ClientMsgProtobuf, appId: Int) { + msg.protoHeader.routingAppid = appId + client.send(msg) + } + + /** + * Handles a client message. This should not be called directly. + * @param packetMsg The packet message that contains the data. + */ + override fun handleMsg(packetMsg: IPacketMsg) { + getHandler(packetMsg)?.invoke(packetMsg) + } + + internal fun clearLobbyCache() { + lobbyCache.clear() + } + + // TODO verify... + private fun attachIncompleteManipulationHandler(job: AsyncJobSingle): AsyncJobSingle { + // Manipulation requests typically complete (and are removed from lobbyManipulationRequests) when + // a message is handled. However, jobs can also be faulted, or be cancelled (e.g. when SteamClient + // disconnects.) Thus, when a job fails we remove the JobID/request from lobbyManipulationRequests. + job.toFuture().exceptionally { task -> + lobbyManipulationRequests.remove(job.jobID) + null + } + return job + } + + // region ClientMsg Handlers + + private fun handleCreateLobbyResponse(packetMsg: IPacketMsg) { + val createLobbyResponse = ClientMsgProtobuf( + CMsgClientMMSCreateLobbyResponse::class.java, + packetMsg + ) + val body = createLobbyResponse.body + + lobbyManipulationRequests.remove(createLobbyResponse.targetJobID)?.let { request -> + if (body.eresult == EResult.OK.code()) { + val createLobby = request as CMsgClientMMSCreateLobby + val members = List(1) { + Member(client.steamID!!, createLobby.personaNameOwner) + } + + lobbyCache.cacheLobby( + createLobby.appId, + Lobby( + steamID = SteamID(body.steamIdLobby), + lobbyType = ELobbyType.from(createLobby.lobbyType), + lobbyFlags = createLobby.lobbyFlags, + ownerSteamID = client.steamID, + metadata = Lobby.decodeMetadata(createLobby.metadata), + maxMembers = createLobby.maxMembers, + numMembers = 1, + members = members, + distance = null, + weight = null + ) + ) + } + } + + CreateLobbyCallback( + jobID = createLobbyResponse.targetJobID, + appID = body.appId, + result = EResult.from(body.eresult), + lobbySteamID = SteamID(body.steamIdLobby) + ).also(client::postCallback) + } + + fun handleSetLobbyDataResponse(packetMsg: IPacketMsg) { + val setLobbyDataResponse = ClientMsgProtobuf( + CMsgClientMMSSetLobbyDataResponse::class.java, + packetMsg + ) + val body = setLobbyDataResponse.body + + lobbyManipulationRequests.remove(setLobbyDataResponse.targetJobID)?.let { request -> + if (body.eresult == EResult.OK.code()) { + val setLobbyData = request as CMsgClientMMSSetLobbyData + val lobby = lobbyCache.getLobby(appId = setLobbyData.appId, lobbySteamId = setLobbyData.steamIdLobby) + + if (lobby != null) { + val metadata = Lobby.decodeMetadata(setLobbyData.metadata) + + if (setLobbyData.steamIdMember == 0L) { + lobbyCache.cacheLobby( + appId = setLobbyData.appId, + lobby = Lobby( + steamID = lobby.steamID, + lobbyType = ELobbyType.from(setLobbyData.lobbyType), + lobbyFlags = setLobbyData.lobbyFlags, + ownerSteamID = lobby.ownerSteamID, + metadata = metadata, + maxMembers = setLobbyData.maxMembers, + numMembers = lobby.numMembers, + members = lobby.members, + distance = lobby.distance, + weight = lobby.weight + ) + ) + } else { + val members = lobby.members.map { m -> + if (m.steamID.convertToUInt64() == setLobbyData.steamIdMember) { + Member(steamID = m.steamID, personaName = m.personaName, metadata = metadata) + } else { + m + } + } + + lobbyCache.updateLobbyMembers(appId = setLobbyData.appId, lobby = lobby, members = members) + } + } + } + } + + SetLobbyDataCallback( + jobID = setLobbyDataResponse.targetJobID, + appID = body.appId, + result = EResult.from(body.eresult), + lobbySteamID = SteamID(body.steamIdLobby) + ).also(client::postCallback) + } + + fun handleSetLobbyOwnerResponse(packetMsg: IPacketMsg) { + val setLobbyOwnerResponse = ClientMsgProtobuf( + CMsgClientMMSSetLobbyOwnerResponse::class.java, + packetMsg + ) + val body = setLobbyOwnerResponse.body + + lobbyManipulationRequests.remove(setLobbyOwnerResponse.targetJobID)?.let { request -> + if (body.eresult == EResult.OK.code()) { + val setLobbyOwner = request as CMsgClientMMSSetLobbyOwner + lobbyCache.updateLobbyOwner( + appId = body.appId, + lobbySteamId = body.steamIdLobby, + ownerSteamId = setLobbyOwner.steamIdNewOwner + ) + } + } + + SetLobbyOwnerCallback( + jobID = setLobbyOwnerResponse.targetJobID, + appID = body.appId, + result = EResult.from(body.eresult), + lobbySteamID = SteamID(body.steamIdLobby) + ).also(client::postCallback) + } + + fun handleGetLobbyListResponse(packetMsg: IPacketMsg) { + val lobbyListResponse = ClientMsgProtobuf( + CMsgClientMMSGetLobbyListResponse::class.java, + packetMsg + ) + val body = lobbyListResponse.body + + val lobbyList = body.lobbiesList.map { lobby -> + val existingLobby = lobbyCache.getLobby(appId = body.appId, lobbySteamId = lobby.steamId) + val members = existingLobby?.members + Lobby( + steamID = SteamID(lobby.steamId), + lobbyType = ELobbyType.from(lobby.lobbyType), + lobbyFlags = lobby.lobbyFlags, + ownerSteamID = existingLobby?.ownerSteamID, + metadata = Lobby.decodeMetadata(lobby.metadata), + maxMembers = lobby.maxMembers, + numMembers = lobby.numMembers, + members = members ?: listOf(), + distance = lobby.distance, + weight = lobby.weight + ) + } + + lobbyList.forEach { lobby -> + lobbyCache.cacheLobby(appId = body.appId, lobby = lobby) + } + + GetLobbyListCallback( + jobID = lobbyListResponse.targetJobID, + appID = body.appId, + result = EResult.from(body.eresult), + lobbies = lobbyList + ).also(client::postCallback) + } + + fun handleJoinLobbyResponse(packetMsg: IPacketMsg) { + val joinLobbyResponse = ClientMsgProtobuf( + CMsgClientMMSJoinLobbyResponse::class.java, + packetMsg + ) + val body = joinLobbyResponse.body + + var joinedLobby: Lobby? = null + + if (body.hasSteamIdLobby()) { + val members = body.membersList.map { member -> + Member( + steamID = SteamID(member.steamId), + personaName = member.personaName, + metadata = Lobby.decodeMetadata(member.metadata), + ) + } + + val cachedLobby = lobbyCache.getLobby(appId = body.appId, lobbySteamId = body.steamIdLobby) + + joinedLobby = Lobby( + steamID = SteamID(body.steamIdLobby), + lobbyType = ELobbyType.from(body.lobbyType), + lobbyFlags = body.lobbyFlags, + ownerSteamID = SteamID(body.steamIdOwner), + metadata = Lobby.decodeMetadata(body.metadata), + maxMembers = body.maxMembers, + numMembers = members.size, + members = members, + distance = cachedLobby?.distance, + weight = cachedLobby?.weight + ) + + lobbyCache.cacheLobby(appId = body.appId, lobby = joinedLobby) + } + + JoinLobbyCallback( + jobID = joinLobbyResponse.targetJobID, + appID = body.appId, + chatRoomEnterResponse = EChatRoomEnterResponse.from(body.chatRoomEnterResponse), + lobby = joinedLobby + ).also(client::postCallback) + } + + fun handleLeaveLobbyResponse(packetMsg: IPacketMsg) { + val leaveLobbyResponse = ClientMsgProtobuf( + CMsgClientMMSLeaveLobbyResponse::class.java, + packetMsg + ) + val body = leaveLobbyResponse.body + + if (body.eresult == EResult.OK.code()) { + lobbyCache.clearLobbyMembers(appId = body.appId, lobbySteamId = body.steamIdLobby) + } + + LeaveLobbyCallback( + jobID = leaveLobbyResponse.targetJobID, + appID = body.appId, + result = EResult.from(body.eresult), + lobbySteamID = SteamID(body.steamIdLobby) + ).also(client::postCallback) + } + + fun handleLobbyData(packetMsg: IPacketMsg) { + val lobbyDataResponse = ClientMsgProtobuf( + CMsgClientMMSLobbyData::class.java, + packetMsg + ) + val body = lobbyDataResponse.body + + val cachedLobby = lobbyCache.getLobby(appId = body.appId, lobbySteamId = body.steamIdLobby) + val members = if (body.membersList.isEmpty()) { + cachedLobby?.members + } else { + body.membersList.map { member -> + Member( + steamID = SteamID(member.steamId), + personaName = member.personaName, + metadata = Lobby.decodeMetadata(member.metadata) + ) + } + } + + val updatedLobby = Lobby( + steamID = SteamID(body.steamIdLobby), + lobbyType = ELobbyType.from(body.lobbyType), + lobbyFlags = body.lobbyFlags, + ownerSteamID = SteamID(body.steamIdOwner), + metadata = Lobby.decodeMetadata(body.metadata), + maxMembers = body.maxMembers, + numMembers = body.numMembers, + members = members ?: listOf(), + distance = cachedLobby?.distance, + weight = cachedLobby?.weight + ) + + lobbyCache.cacheLobby(appId = body.appId, lobby = updatedLobby) + + LobbyDataCallback( + jobID = lobbyDataResponse.targetJobID, + appID = body.appId, + lobby = updatedLobby + ).also(client::postCallback) + } + + fun handleUserJoinedLobby(packetMsg: IPacketMsg) { + val userJoinedLobby = ClientMsgProtobuf( + CMsgClientMMSUserJoinedLobby::class.java, + packetMsg + ) + val body = userJoinedLobby.body + + val lobby = lobbyCache.getLobby(appId = body.appId, lobbySteamId = body.steamIdLobby) + + if (lobby != null && lobby.members.isNotEmpty()) { + val joiningMember = lobbyCache.addLobbyMember( + appId = body.appId, + lobby = lobby, + memberId = body.steamIdUser, + personaName = body.personaName + ) + + if (joiningMember != null) { + UserJoinedLobbyCallback( + appID = body.appId, + lobbySteamID = SteamID(body.steamIdLobby), + user = joiningMember + ).also(client::postCallback) + } + } + } + + fun handleUserLeftLobby(packetMsg: IPacketMsg) { + val userLeftLobby = ClientMsgProtobuf( + CMsgClientMMSUserLeftLobby::class.java, + packetMsg + ) + val body = userLeftLobby.body + + val lobby = lobbyCache.getLobby(appId = body.appId, lobbySteamId = body.steamIdLobby) + + if (lobby != null && lobby.members.isNotEmpty()) { + val leavingMember = lobbyCache.removeLobbyMember( + appId = body.appId, + lobby = lobby, + memberId = body.steamIdUser + ) + + if (leavingMember == null) { + return + } + + if (leavingMember.steamID == client.steamID) { + lobbyCache.clearLobbyMembers(appId = body.appId, lobbySteamId = body.steamIdLobby) + } + + UserLeftLobbyCallback( + appID = body.appId, + lobbySteamID = SteamID(body.steamIdLobby), + user = leavingMember + ).also(client::postCallback) + } + } + + // endregion +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/CreateLobbyCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/CreateLobbyCallback.kt new file mode 100644 index 00000000..45a9e88a --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/CreateLobbyCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired in response to [SteamMatchmaking.createLobby]. + * + * @param appID ID of the app the created lobby belongs to. + * @param result The result of the request. + * @param lobbySteamID The SteamID of the created lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class CreateLobbyCallback( + jobID: JobID, + val appID: Int, + val result: EResult, + val lobbySteamID: SteamID, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/GetLobbyListCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/GetLobbyListCallback.kt new file mode 100644 index 00000000..753f1d19 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/GetLobbyListCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Lobby +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID + +/** + * This callback is fired in response to [SteamMatchmaking.getLobbyList] + * + * @param appID ID of the app the lobbies belongs to. + * @param result The result of the request. + * @param lobbies The list of lobbies matching the criteria specified with [SteamMatchmaking.getLobbyList]. + * + * @author Lossy + * @since 2025-05-21 + */ +class GetLobbyListCallback( + jobID: JobID, + val appID: Int, + val result: EResult, + val lobbies: List, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/JoinLobbyCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/JoinLobbyCallback.kt new file mode 100644 index 00000000..392418eb --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/JoinLobbyCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EChatRoomEnterResponse +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Lobby +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID + +/** + * This callback is fired in response to [SteamMatchmaking.joinLobby]. + * + * @param appID ID of the app the targeted lobby belongs to. + * @param chatRoomEnterResponse The result of the request. + * @param lobby The joined [Lobby], when [chatRoomEnterResponse] equals [EChatRoomEnterResponse.Success], otherwise null + * + * @author Lossy + * @since 2025-05-21 + */ +class JoinLobbyCallback( + jobID: JobID, + val appID: Int, + val chatRoomEnterResponse: EChatRoomEnterResponse, + val lobby: Lobby?, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LeaveLobbyCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LeaveLobbyCallback.kt new file mode 100644 index 00000000..662ece78 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LeaveLobbyCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired in response to [SteamMatchmaking.leaveLobby]. + * + * @param appID ID of the app the targeted lobby belongs to. + * @param result The result of the request. + * @param lobbySteamID The SteamID of the targeted Lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class LeaveLobbyCallback( + jobID: JobID, + val appID: Int, + val result: EResult, + val lobbySteamID: SteamID, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LobbyDataCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LobbyDataCallback.kt new file mode 100644 index 00000000..9f4f5895 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/LobbyDataCallback.kt @@ -0,0 +1,26 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Lobby +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID + +/** + * This callback is fired in response to [SteamMatchmaking.getLobbyData], + * as well as whenever Steam sends us updated lobby data. + * + * @param appID ID of the app the updated lobby belongs to. + * @param lobby The lobby that was updated. + * + * @author Lossy + * @since 2025-05-21 + */ +class LobbyDataCallback( + jobID: JobID, + val appID: Int, + val lobby: Lobby, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyDataCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyDataCallback.kt new file mode 100644 index 00000000..e4e5cbe9 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyDataCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired in response to [SteamMatchmaking.setLobbyData]. + * + * @param appID ID of the app the targeted lobby belongs to. + * @param result The result of the request. + * @param lobbySteamID The SteamID of the targeted Lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class SetLobbyDataCallback( + jobID: JobID, + val appID: Int, + val result: EResult, + val lobbySteamID: SteamID, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyOwnerCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyOwnerCallback.kt new file mode 100644 index 00000000..ebb40662 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/SetLobbyOwnerCallback.kt @@ -0,0 +1,28 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired in response to [SteamMatchmaking.setLobbyOwner]. + * + * @param appID ID of the app the targeted lobby belongs to. + * @param result The result of the request. + * @param lobbySteamID The SteamID of the targeted Lobby. + * + * @author Lossy + * @since 2025-05-21 + */ +class SetLobbyOwnerCallback( + jobID: JobID, + val appID: Int, + val result: EResult, + val lobbySteamID: SteamID, +) : CallbackMsg() { + init { + this.jobID = jobID + } +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserJoinedLobbyCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserJoinedLobbyCallback.kt new file mode 100644 index 00000000..ea408af5 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserJoinedLobbyCallback.kt @@ -0,0 +1,23 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Member +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired whenever Steam informs us a user has joined a lobby. + * + * @param appID ID of the app the lobby belongs to. + * @param lobbySteamID The SteamID of the lobby that a member joined. + * @param user The lobby member that joined. + * + * @author Lossy + * @since 2025-05-21 + */ +class UserJoinedLobbyCallback( + val appID: Int, + val lobbySteamID: SteamID, + val user: Member, +) : CallbackMsg() { + // No job set declared in SK. +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserLeftLobbyCallback.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserLeftLobbyCallback.kt new file mode 100644 index 00000000..23ce300d --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/callback/UserLeftLobbyCallback.kt @@ -0,0 +1,23 @@ +package `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.callback + +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.Member +import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg +import `in`.dragonbra.javasteam.types.SteamID + +/** + * This callback is fired whenever Steam informs us a user has left a lobby. + * + * @param appID ID of the app the lobby belongs to. + * @param lobbySteamID The SteamID of the lobby that a member left. + * @param user The lobby member that left. + * + * @author Lossy + * @since 2025-05-21 + */ +class UserLeftLobbyCallback( + val appID: Int, + val lobbySteamID: SteamID, + val user: Member, +) : CallbackMsg() { + // No job set declared in SK. +} diff --git a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt index 229d7ade..84be65dc 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt @@ -12,6 +12,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends import `in`.dragonbra.javasteam.steam.handlers.steamgamecoordinator.SteamGameCoordinator import `in`.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer import `in`.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer +import `in`.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking import `in`.dragonbra.javasteam.steam.handlers.steamnetworking.SteamNetworking import `in`.dragonbra.javasteam.steam.handlers.steamnotifications.SteamNotifications import `in`.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots @@ -78,6 +79,7 @@ class SteamClient @JvmOverloads constructor( addHandlerCore(SteamWorkshop()) addHandlerCore(SteamUnifiedMessages()) addHandlerCore(SteamScreenshots()) + addHandlerCore(SteamMatchmaking()) addHandlerCore(SteamNetworking()) addHandlerCore(SteamNotifications()) addHandlerCore(SteamUserStats()) @@ -266,12 +268,14 @@ class SteamClient @JvmOverloads constructor( jobManager.setTimeoutsEnabled(false) - // clearHandlerCaches() + clearHandlerCaches() postCallback(DisconnectedCallback(userInitiated)) } - // fun clearHandlerCaches() + fun clearHandlerCaches() { + getHandler()?.clearLobbyCache() + } private fun handleJobHeartbeat(packetMsg: IPacketMsg) { JobID(packetMsg.getTargetJobID()).let(jobManager::heartbeatJob) @@ -284,6 +288,6 @@ class SteamClient @JvmOverloads constructor( companion object { private val logger: Logger = LogManager.getLogger(SteamClient::class.java) - private const val HANDLERS_COUNT = 14 + private const val HANDLERS_COUNT = 15 } } diff --git a/src/test/java/in/dragonbra/javasteam/steam/steamclient/SteamClientTest.java b/src/test/java/in/dragonbra/javasteam/steam/steamclient/SteamClientTest.java index 405163e4..59bac2f1 100644 --- a/src/test/java/in/dragonbra/javasteam/steam/steamclient/SteamClientTest.java +++ b/src/test/java/in/dragonbra/javasteam/steam/steamclient/SteamClientTest.java @@ -8,6 +8,7 @@ import in.dragonbra.javasteam.steam.handlers.steamgamecoordinator.SteamGameCoordinator; import in.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer; import in.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer; +import in.dragonbra.javasteam.steam.handlers.steammatchmaking.SteamMatchmaking; import in.dragonbra.javasteam.steam.handlers.steamnetworking.SteamNetworking; import in.dragonbra.javasteam.steam.handlers.steamnotifications.SteamNotifications; import in.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots; @@ -19,6 +20,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.HashMap; + class SteamClientTest { private SteamClient client; @@ -28,6 +31,28 @@ public void setUp() { client = new SteamClient(); } + /** + * Check to make sure the allocated handlers size matches the initial handlers account. + */ + @Test + public void handlersCountCheck() { + try { + // get the private 'handlers' variable + var handlersField = SteamClient.class.getDeclaredField("handlers"); + handlersField.setAccessible(true); + var handlers = (HashMap) handlersField.get(client); + + // get the private static 'HANDLERS_COUNT' variable + var handlersCountField = SteamClient.class.getDeclaredField("HANDLERS_COUNT"); + handlersCountField.setAccessible(true); + var handlersCount = (Integer) handlersCountField.get(null); + + Assertions.assertEquals(handlersCount, handlers.size(), "Handlers size should match HANDLERS_COUNT"); + } catch (Exception e) { + Assertions.fail(e); + } + } + @Test public void constructorSetsInitialHandlers() { Assertions.assertNotNull(client.getHandler(SteamFriends.class)); @@ -40,6 +65,7 @@ public void constructorSetsInitialHandlers() { Assertions.assertNotNull(client.getHandler(SteamWorkshop.class)); Assertions.assertNotNull(client.getHandler(SteamUnifiedMessages.class)); Assertions.assertNotNull(client.getHandler(SteamScreenshots.class)); + Assertions.assertNotNull(client.getHandler(SteamMatchmaking.class)); Assertions.assertNotNull(client.getHandler(SteamNetworking.class)); Assertions.assertNotNull(client.getHandler(SteamNotifications.class)); Assertions.assertNotNull(client.getHandler(SteamUserStats.class));