Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.pubnub.matchmaking.internal.UserImpl
import com.pubnub.matchmaking.internal.common.USER_STATUS_CHANNEL_PREFIX
import com.pubnub.matchmaking.internal.serverREST.entities.MatchGroup
import com.pubnub.matchmaking.internal.serverREST.entities.MatchmakingResult
import com.pubnub.matchmaking.internal.serverREST.entities.UserPairWithScore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
Expand All @@ -24,7 +25,7 @@ import kotlin.coroutines.resumeWithException
import kotlin.math.abs

// this class represents server-side REST API
class MatchmakingRestService(
class MatchmakingRestService( // todo do we need this?
private val matchmaking: Matchmaking,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) {
Expand All @@ -39,7 +40,7 @@ class MatchmakingRestService(
PubNubException("Id is required").asFuture()
} else {
PNFuture { callback ->
CoroutineScope(Dispatchers.Default).launch {
scope.launch {
try {
// check if user exists, if not then throw
getUserMetadata(userId)
Expand Down Expand Up @@ -82,7 +83,7 @@ class MatchmakingRestService(
processingQueueInProgress = true
scope.launch {
try {
while (true) {
while (processingQueueInProgress) {
processMatchmakingQueue()
delay(5000L) // Wait 5 seconds between processing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the 5 second delay? and why 5 seconds?

}
Expand Down Expand Up @@ -178,6 +179,7 @@ class MatchmakingRestService(
group.users.forEach { user ->
println("Found match for userId: ${user.id} in group: ${group.users.map { it.id }}")
setMatchMakingStatus(user.id, MatchmakingStatus.MATCH_FOUND)
// todo pass matchMaking result data
}
}
}
Expand Down Expand Up @@ -209,13 +211,13 @@ class MatchmakingRestService(
}

// Create all possible pairs (only including allowed pairs)
private fun createAllPairs(users: List<User>): List<Triple<Int, Int, Double>> {
val pairs = mutableListOf<Triple<Int, Int, Double>>()
private fun createAllPairs(users: List<User>): List<UserPairWithScore> {
val pairs = mutableListOf<UserPairWithScore>()
for (i in users.indices) {
for (j in i + 1 until users.size) {
val score = calculateScore(users[i], users[j])
if (score != Double.POSITIVE_INFINITY) {
pairs.add(Triple(i, j, score))
pairs.add(UserPairWithScore(i, j, score))
}
}
}
Expand All @@ -224,28 +226,60 @@ class MatchmakingRestService(

// Greedy pairing: sort by score and select pairs without conflicts.
// Instead of returning MatchmakingPair, we create a MatchGroup that holds a set of users.
private fun pairUsersBySkill(users: List<User>): MatchmakingResult {
val allPairs = createAllPairs(users).sortedBy { it.third }
val pairedIndices = mutableSetOf<Int>()
val groups = mutableSetOf<MatchGroup>()

for ((i, j, _) in allPairs) {
if (i !in pairedIndices && j !in pairedIndices) {
groups.add(
MatchGroup(
setOf(
users[i],
users[j]
private fun pairUsersBySkill(users: List<User>, groupSize: Int = 2): MatchmakingResult {
// If there aren’t enough users to form a single group, return them as unpaired.
if (users.size < groupSize) {
return MatchmakingResult(emptySet(), users.map { it.id }.toSet())
}

// For pairs, use the existing logic with explicit naming via UserPair.
if (groupSize == 2) {
val allPairs = createAllPairs(users).sortedBy { it.score }
val pairedIndices = mutableSetOf<Int>()
val groups = mutableSetOf<MatchGroup>()

for (pair in allPairs) {
if (pair.firstUserIndex !in pairedIndices && pair.secondUserIndex !in pairedIndices) {
groups.add(
MatchGroup(
setOf(
users[pair.firstUserIndex],
users[pair.secondUserIndex]
)
)
)
) // todo here we are creating group that consist of 2 player. Make it flexible to be able to return group of N players
pairedIndices.add(i)
pairedIndices.add(j)
pairedIndices.add(pair.firstUserIndex)
pairedIndices.add(pair.secondUserIndex)
}
}
val unpaired = users.indices.filter { it !in pairedIndices }
.map { users[it].id }.toSet()
return MatchmakingResult(groups, unpaired)
} else {
// For groups larger than 2, we use a simple greedy grouping.
// First, sort users by their Elo rating (fallback to 0 if missing).
val sortedUsers = users.sortedBy { (it.custom?.get("elo") as? Int) ?: 0 }
val groups = mutableSetOf<MatchGroup>()
val usedIndices = mutableSetOf<Int>()

// Greedily form groups of the desired size from the sorted list.
var i = 0
while (i <= sortedUsers.size - groupSize) {
// Create a group from a consecutive sublist.
val groupUsers = sortedUsers.subList(i, i + groupSize).toSet()
groups.add(MatchGroup(groupUsers))
// Mark these users as grouped.
groupUsers.forEach { user ->
usedIndices.add(users.indexOf(user))
}
i += groupSize
}
// Any remaining users who didn't fit into a full group are considered unpaired.
val unpaired = users.indices.filter { it !in usedIndices }
.map { users[it].id }.toSet()
return MatchmakingResult(groups, unpaired)
}
val unpaired = users.indices.filter { it !in pairedIndices }
.map { users[it].id }.toSet()
return MatchmakingResult(groups, unpaired)
}

private fun isValidId(id: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.pubnub.matchmaking.internal.serverREST

import com.pubnub.api.PubNubException
import com.pubnub.api.models.consumer.objects.uuid.PNUUIDMetadataResult
import com.pubnub.api.v2.callbacks.Result
import com.pubnub.kmp.PNFuture
import com.pubnub.kmp.asFuture
import com.pubnub.matchmaking.Matchmaking
import com.pubnub.matchmaking.User
import com.pubnub.matchmaking.internal.UserImpl
import com.pubnub.matchmaking.internal.serverREST.entities.MatchGroup
import com.pubnub.matchmaking.internal.serverREST.entities.MatchResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.abs

// this class represents server-side REST API
class MatchmakingRestServiceNew(
private val matchmaking: Matchmaking,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) {
// Instead of a queue, we maintain a list of open match groups.
private val openMatchGroups = mutableListOf<OpenMatchGroup>()
private val groupsMutex = Mutex()

@Throws(MatchMakingException::class)
Copy link
Contributor

@wkal-pubnub wkal-pubnub Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't need @throws if it returns PNFuture because the exception is delivered inside the future

fun findMatch(userId: String): PNFuture<MatchResult> =
if (!isValidId(userId)) {
PubNubException("Id is required").asFuture()
} else {
PNFuture { callback ->
scope.launch {
try {
// Validate user exists
val userMeta = getUserMetadata(userId)
val user = UserImpl.fromDTO(matchmaking = matchmaking, user = userMeta.data)
// Attempt to join an existing group or create a new one.
val result: MatchResult = findOrCreateMatchGroup(user)
callback.accept(Result.success(result))
} catch (e: Exception) {
callback.accept(Result.failure(e))
}
}
}
}

// Tries to find a compatible open group based on a simple Elo check.
// If one is found and becomes full, a MatchResult is constructed and sent to all waiting callers.
private suspend fun findOrCreateMatchGroup(user: User): MatchResult {
// Create a channel for the current user’s match notification.
val userChannel = Channel<MatchResult>(Channel.RENDEZVOUS)
var groupToJoin: OpenMatchGroup?

groupsMutex.withLock {
// Find an open group where the user's skill is compatible.
groupToJoin = openMatchGroups.firstOrNull { group ->
isSkillCompatible(user, group) && group.users.size < group.requiredSize
}
if (groupToJoin != null) {
// Join the found group.
groupToJoin!!.users.add(user)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reorganize the code to avoid using "!!", for example:
remove the groupToJoin variable, do instead: openMatchGroups.firstOrNull { ... }?.let { groupToJoin -> ... } ?: run { // group not found }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or just make it a val instead of var so you can use smart cast

groupToJoin!!.waitingChannels.add(userChannel)
// When the group is full, prepare the final MatchGroup and notify all waiting channels.
if (groupToJoin!!.users.size == groupToJoin!!.requiredSize) {
val finalGroup = MatchGroup(users = groupToJoin!!.users.toSet())
// Optionally update status for all group members.
finalGroup.users.forEach { groupUser ->
println("Found match for user: $groupUser")
}
// Create matchData. This can be extended as needed.
val matchData = mapOf(
"status" to "matchFound",
"groupSize" to groupToJoin!!.requiredSize.toString()
)
val matchResult = MatchResult(match = finalGroup, matchData = matchData)
// Notify every waiting channel.
groupToJoin!!.waitingChannels.forEach { channel ->
scope.launch {
channel.send(matchResult)
}
}
// Remove the group now that it is complete.
openMatchGroups.remove(groupToJoin)
} else { // if must have both main and 'else' branches if used as an expression
Unit
}
} else {
// No suitable group found; create a new one.
groupToJoin = OpenMatchGroup(requiredSize = 2)
groupToJoin!!.users.add(user)
groupToJoin!!.waitingChannels.add(userChannel)
openMatchGroups.add(groupToJoin!!)
}
}
// Wait until the group becomes complete and a MatchResult is sent.
return userChannel.receive()
}

// Simple compatibility check based on Elo difference.
private fun isSkillCompatible(user: User, group: OpenMatchGroup): Boolean {
// If the group is empty, any user is compatible.
if (group.users.isEmpty()) {
return true
}
val userElo = (user.custom?.get("elo") as? Int) ?: 0
val groupAverageElo = group.users.map { (it.custom?.get("elo") as? Int ?: 0) }.average()
// Example: user is compatible if the difference is 50 or less.
return abs(userElo - groupAverageElo) <= 50 // todo get this value from Illuminate
}

private suspend fun getUserMetadata(userId: String): PNUUIDMetadataResult {
val pnUuidMetadataResult: PNUUIDMetadataResult
try {
pnUuidMetadataResult = matchmaking.pubNub.getUUIDMetadata(uuid = userId, includeCustom = true).await()
} catch (e: PubNubException) {
if (e.statusCode == 404) {
// Log.error
println("User does not exist in AppContext")
throw PubNubException("getUsersByIds: User does not exist")
} else {
throw PubNubException(e.message)
}
}
return pnUuidMetadataResult
}

private fun isValidId(id: String): Boolean {
return id.isNotEmpty()
}

private suspend fun <T> PNFuture<T>.await(): T =
suspendCancellableCoroutine { cont ->
async { result ->
result.onSuccess {
cont.resume(it)
}.onFailure {
cont.resumeWithException(it)
}
}
}
}

class MatchMakingException : Exception() // todo implement

private data class OpenMatchGroup(
val requiredSize: Int = 2, // For pairing, group size is 2 (can be configurable)
val users: MutableList<User> = mutableListOf(),
// Each waiting user gets a channel to receive the match result.
val waitingChannels: MutableList<Channel<MatchResult>> = mutableListOf()
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.pubnub.matchmaking.internal.serverREST.entities

class MatchResult(val match: MatchGroup, val matchData: Map<String, String>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.pubnub.matchmaking.internal.serverREST.entities

data class UserPairWithScore(val firstUserIndex: Int, val secondUserIndex: Int, val score: Double)
Loading