Commit dc0a5964 authored by Mo Firouz's avatar Mo Firouz
Browse files

Add advanced matchmaker. Merged #118

parent e3cb888f
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -4,6 +4,9 @@ All notable changes to this project are documented below.
The format is based on [keep a changelog](http://keepachangelog.com/) and this project uses [semantic versioning](http://semver.org/).

### [Unreleased]
### Added
- Advanced Matchmaking with custom filters and user properties.

### Changed
- Script runtime RPC and HTTP hook errors now return more detail when verbose logging is enabled.

+1 −1
Original line number Diff line number Diff line
version: '3'
services:
  cockroachdb:
    image: cockroachdb/cockroach:v1.0.3
    image: cockroachdb/cockroach:v1.0.6
    command: start --insecure --store=attrs=ssd,path=/var/lib/cockroach/
    restart: always
    volumes:
+55 −2
Original line number Diff line number Diff line
@@ -887,6 +887,47 @@ message TopicPresence {
  repeated UserPresence leaves = 3;
}

/**
 * PropertyPair is a core domain type respresenting a single user property
 */
message PropertyPair {
  /// Set of string user property
  message StringSet {
    repeated string values = 1;
  }

  string key = 1;
  oneof value {
    StringSet stringSet = 2;
    bool boolValue = 3;
    int64 intValue = 4;
  }
}

/**
 * MatchmakeFilter is a core domain type respresenting a filter to use for matchmaking.
 */
message MatchmakeFilter {
  /// String term filters
  message TermFilter {
    repeated string terms = 1;
    bool matchAllTerms = 2;
  }

  /// Numeric range filter
  message RangeFilter {
    int64 lower_bound = 1; // inclusive lower_bound
    int64 upper_bound = 2; // inclusive upper_bound
  }

  string name = 1;
  oneof value {
    TermFilter term = 2;
    RangeFilter range = 3;
    bool check = 4;
  }
}

/**
 * TMatchmakeAdd is used to add the current user to the matchmaking pool.
 *
@@ -894,7 +935,11 @@ message TopicPresence {
 */
message TMatchmakeAdd {
  /// Match user with other users looking for a match with the the following number of users.
  int64 requiredCount = 1;
  int64 required_count = 1;
  /// List of filters that need to match.
  repeated MatchmakeFilter filters = 2; // "AND"
  /// List of properties for the current user.
  repeated PropertyPair properties = 3;
}

/**
@@ -915,12 +960,20 @@ message TMatchmakeRemove {
 * MatchmakeMatched is the core domain type representing a found match via matchmaking.
 */
message MatchmakeMatched {
  /// Matched user presence and properties
  message UserProperty {
    bytes user_id = 1;
    repeated PropertyPair properties = 2;
    repeated MatchmakeFilter filters = 3;
  }

  /// Matchmaking ticket. Use this to invalidate ticket cache on the client.
  bytes ticket = 1;
  /// Matchmaking token. Use this to accept the match. This is a onetime token which is only valid for 15seconds.
  /// Matchmaking token. Use this to accept the match. This is a onetime token which is only valid for a limited time.
  bytes token = 2;
  repeated UserPresence presences = 3;
  UserPresence self = 4;
  repeated UserProperty properties = 5;
}

/**
+194 −23
Original line number Diff line number Diff line
@@ -16,17 +16,62 @@ package server

import (
	"errors"
	"github.com/satori/go.uuid"
	"sync"

	"github.com/satori/go.uuid"
)

type Matchmaker interface {
	Add(sessionID uuid.UUID, userID uuid.UUID, meta PresenceMeta, requiredCount int64) (uuid.UUID, map[MatchmakerKey]*MatchmakerProfile)
	Add(sessionID uuid.UUID, userID uuid.UUID, requestProfile *MatchmakerProfile) (uuid.UUID, map[MatchmakerKey]*MatchmakerProfile, []*MatchmakerAcceptedProperty)
	Remove(sessionID uuid.UUID, userID uuid.UUID, ticket uuid.UUID) error
	RemoveAll(sessionID uuid.UUID)
	UpdateAll(sessionID uuid.UUID, meta PresenceMeta)
}

type Filter int

const (
	BOOL Filter = iota
	RANGE
	TERM
)

type MatchmakerFilter interface {
	Type() Filter
}

type MatchmakerTermFilter struct {
	Terms    []string
	AllTerms bool // set to False for Any Term
}

func (*MatchmakerTermFilter) Type() Filter {
	return TERM
}

type MatchmakerRangeFilter struct {
	LowerBound int64
	UpperBound int64
}

func (*MatchmakerRangeFilter) Type() Filter {
	return RANGE
}

type MatchmakerBoolFilter struct {
	Value bool
}

func (*MatchmakerBoolFilter) Type() Filter {
	return BOOL
}

type MatchmakerAcceptedProperty struct {
	UserID     uuid.UUID
	Properties map[string]interface{}
	Filters    map[string]MatchmakerFilter
}

type MatchmakerKey struct {
	ID     PresenceID
	UserID uuid.UUID
@@ -35,7 +80,9 @@ type MatchmakerKey struct {

type MatchmakerProfile struct {
	Meta          PresenceMeta
	RequiredCount int64
	RequiredCount int
	Properties    map[string]interface{}
	Filters       map[string]MatchmakerFilter
}

type MatchmakerService struct {
@@ -51,36 +98,160 @@ func NewMatchmakerService(name string) *MatchmakerService {
	}
}

func (m *MatchmakerService) Add(sessionID uuid.UUID, userID uuid.UUID, meta PresenceMeta, requiredCount int64) (uuid.UUID, map[MatchmakerKey]*MatchmakerProfile) {
func (m *MatchmakerService) Add(sessionID uuid.UUID, userID uuid.UUID, incomingProfile *MatchmakerProfile) (uuid.UUID, map[MatchmakerKey]*MatchmakerProfile, []*MatchmakerAcceptedProperty) {
	ticket := uuid.NewV4()
	selected := make(map[MatchmakerKey]*MatchmakerProfile, requiredCount-1)
	qmk := MatchmakerKey{ID: PresenceID{SessionID: sessionID, Node: m.name}, UserID: userID, Ticket: ticket}
	qmp := &MatchmakerProfile{Meta: meta, RequiredCount: requiredCount}
	candidates := make(map[MatchmakerKey]*MatchmakerProfile, incomingProfile.RequiredCount-1)
	requestKey := MatchmakerKey{ID: PresenceID{SessionID: sessionID, Node: m.name}, UserID: userID, Ticket: ticket}

	m.Lock()
	for mk, mp := range m.values {
		if mk.ID.SessionID != sessionID && mk.UserID != userID && mp.RequiredCount == requiredCount {
			selected[mk] = mp
			if int64(len(selected)) == requiredCount-1 {
				break
	defer m.Unlock()

	// find list of suitable candidates
	for key, profile := range m.values {
		// if queued users match the current user, then skip
		if key.ID.SessionID == sessionID || key.UserID == userID {
			continue
		}

		// compatible with the request's filter
		if !m.checkFilter(incomingProfile, profile) {
			continue
		}

		// compatible with the profile's filter
		if !m.checkFilter(profile, incomingProfile) {
			continue
		}

		candidates[key] = profile
	}
	if int64(len(selected)) == requiredCount-1 {
		for mk, _ := range selected {

	// cross match all previously selected profiles
	// to see if they are compatible with each other as well
	matches := m.crossmatchCandidates(candidates, incomingProfile.RequiredCount-1)

	// not enough profiles, bail out early
	if len(matches) < int(incomingProfile.RequiredCount-1) {
		m.values[requestKey] = incomingProfile
		return ticket, nil, nil
	}

	// remove the matched profiles from the queue
	for mk, _ := range matches {
		delete(m.values, mk)
	}
		selected[qmk] = qmp
	} else {
		m.values[qmk] = qmp

	// add the incoming profile to the final list
	matches[requestKey] = incomingProfile

	return ticket, matches, m.calculateAcceptedProperties(matches)
}
	m.Unlock()

	if int64(len(selected)) != requiredCount {
		return ticket, nil
	} else {
		return ticket, selected
func (m *MatchmakerService) crossmatchCandidates(candidates map[MatchmakerKey]*MatchmakerProfile, requiredCount int) map[MatchmakerKey]*MatchmakerProfile {
	if requiredCount == 0 {
		return map[MatchmakerKey]*MatchmakerProfile{}
	}

	if requiredCount > len(candidates) {
		return nil
	}

	keys := make([]MatchmakerKey, 0)
	values := make([]*MatchmakerProfile, 0)
	for key, value := range candidates {
		keys = append(keys, key)
		values = append(values, value)
	}

	for i := 0; i < len(keys); i++ {
		s := values[i]
		tempCandidates := make(map[MatchmakerKey]*MatchmakerProfile, 0)
		for j := i + 1; j < len(keys); j++ {
			p := values[j]
			if m.checkFilter(s, p) && m.checkFilter(p, s) {
				tempCandidates[keys[j]] = p
			}
		}

		findCandidateResult := m.crossmatchCandidates(tempCandidates, requiredCount-1)
		if findCandidateResult != nil {
			findCandidateResult[keys[i]] = s
			return findCandidateResult
		}
	}
	return nil
}

func (m *MatchmakerService) checkFilter(requestProfile, queuedProfile *MatchmakerProfile) bool {
	if queuedProfile.RequiredCount != requestProfile.RequiredCount {
		return false
	}

	for filterName, filter := range requestProfile.Filters {
		propertyValue := queuedProfile.Properties[filterName]
		if propertyValue == nil {
			return false
		}

		if filter.Type() == TERM {
			termFilter := filter.(*MatchmakerTermFilter)
			propertyTermList, ok := propertyValue.([]string)
			if !ok {
				return false
			}

			matchingTerms := m.intersection(termFilter.Terms, propertyTermList)
			if len(matchingTerms) == 0 {
				return false
			}

			if termFilter.AllTerms && len(matchingTerms) != len(termFilter.Terms) {
				return false
			}
		} else if filter.Type() == RANGE {
			rangeFilter := filter.(*MatchmakerRangeFilter)
			propertyInt, ok := propertyValue.(int64)

			if !ok || propertyInt < rangeFilter.LowerBound || propertyInt > rangeFilter.UpperBound {
				return false
			}
		} else if filter.Type() == BOOL {
			boolFilter := filter.(*MatchmakerBoolFilter)
			propertyBool, ok := propertyValue.(bool)
			if !ok || boolFilter.Value != propertyBool {
				return false
			}
		}
	}

	return true
}

func (m *MatchmakerService) calculateAcceptedProperties(matched map[MatchmakerKey]*MatchmakerProfile) []*MatchmakerAcceptedProperty {
	props := make([]*MatchmakerAcceptedProperty, 0)
	for key, profile := range matched {
		prop := &MatchmakerAcceptedProperty{
			UserID:     key.UserID,
			Properties: profile.Properties,
			Filters:    profile.Filters,
		}
		props = append(props, prop)
	}

	return props
}

func (m *MatchmakerService) intersection(a, b []string) []string {
	o := make([]string, 0)
	for i := range a {
		for j := range b {
			if a[i] == b[j] {
				o = append(o, a[i])
				break
			}
		}
	}
	return o
}

func (m *MatchmakerService) Remove(sessionID uuid.UUID, userID uuid.UUID, ticket uuid.UUID) error {
+93 −5
Original line number Diff line number Diff line
@@ -15,20 +15,52 @@
package server

import (
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/satori/go.uuid"
	"go.uber.org/zap"
	"time"
)

func (p *pipeline) matchmakeAdd(logger *zap.Logger, session *session, envelope *Envelope) {
	requiredCount := envelope.GetMatchmakeAdd().RequiredCount
	matchmakeAdd := envelope.GetMatchmakeAdd()
	requiredCount := matchmakeAdd.RequiredCount
	if requiredCount < 2 {
		session.Send(ErrorMessageBadInput(envelope.CollationId, "Required count must be >= 2"))
		return
	}

	ticket, selected := p.matchmaker.Add(session.id, session.userID, PresenceMeta{Handle: session.handle.Load()}, requiredCount)
	properties := make(map[string]interface{}, 0)
	for _, pair := range matchmakeAdd.Properties {
		switch v := pair.Value.(type) {
		case *PropertyPair_BoolValue:
			properties[pair.Key] = v.BoolValue
		case *PropertyPair_IntValue:
			properties[pair.Key] = v.IntValue
		case *PropertyPair_StringSet_:
			properties[pair.Key] = uniqueList(v.StringSet.Values)
		}
	}

	filters := make(map[string]MatchmakerFilter)
	for _, filter := range matchmakeAdd.Filters {
		switch v := filter.Value.(type) {
		case *MatchmakeFilter_Check:
			filters[filter.Name] = &MatchmakerBoolFilter{v.Check}
		case *MatchmakeFilter_Range:
			filters[filter.Name] = &MatchmakerRangeFilter{v.Range.LowerBound, v.Range.UpperBound}
		case *MatchmakeFilter_Term:
			filters[filter.Name] = &MatchmakerTermFilter{uniqueList(v.Term.Terms), v.Term.MatchAllTerms}
		}
	}

	matchmakerProfile := &MatchmakerProfile{
		Meta:          PresenceMeta{Handle: session.handle.Load()},
		RequiredCount: int(requiredCount),
		Properties:    properties,
		Filters:       filters,
	}
	ticket, selected, props := p.matchmaker.Add(session.id, session.userID, matchmakerProfile)

	session.Send(&Envelope{CollationId: envelope.CollationId, Payload: &Envelope_MatchmakeTicket{MatchmakeTicket: &TMatchmakeTicket{
		Ticket: ticket.Bytes(),
@@ -55,10 +87,51 @@ func (p *pipeline) matchmakeAdd(logger *zap.Logger, session *session, envelope *
		}
		idx++
	}

	protoProps := make([]*MatchmakeMatched_UserProperty, 0)
	for _, prop := range props {
		protoProp := &MatchmakeMatched_UserProperty{
			UserId:     prop.UserID.Bytes(),
			Properties: make([]*PropertyPair, 0),
			Filters:    make([]*MatchmakeFilter, 0),
		}
		protoProps = append(protoProps, protoProp)

		for userPropertyKey, userPropertyValue := range prop.Properties {
			pair := &PropertyPair{Key: userPropertyKey}
			protoProp.Properties = append(protoProp.Properties, pair)
			switch v := userPropertyValue.(type) {
			case int64:
				pair.Value = &PropertyPair_IntValue{v}
			case bool:
				pair.Value = &PropertyPair_BoolValue{v}
			case []string:
				pair.Value = &PropertyPair_StringSet_{&PropertyPair_StringSet{v}}
			}
		}

		for userFilterKey, userFilterValue := range prop.Filters {
			filter := &MatchmakeFilter{Name: userFilterKey}
			protoProp.Filters = append(protoProp.Filters, filter)
			switch userFilterValue.Type() {
			case TERM:
				f := userFilterValue.(*MatchmakerTermFilter)
				filter.Value = &MatchmakeFilter_Term{&MatchmakeFilter_TermFilter{f.Terms, f.AllTerms}}
			case RANGE:
				f := userFilterValue.(*MatchmakerRangeFilter)
				filter.Value = &MatchmakeFilter_Range{&MatchmakeFilter_RangeFilter{f.LowerBound, f.UpperBound}}
			case BOOL:
				f := userFilterValue.(*MatchmakerBoolFilter)
				filter.Value = &MatchmakeFilter_Check{f.Value}
			}
		}
	}

	outgoing := &Envelope{Payload: &Envelope_MatchmakeMatched{MatchmakeMatched: &MatchmakeMatched{
		// Ticket: ..., // Set individually below for each recipient.
		Token:      []byte(signedToken),
		Presences:  ps,
		Properties: protoProps,
		// Self:   ..., // Set individually below for each recipient.
	}}}
	for mk, mp := range selected {
@@ -76,6 +149,7 @@ func (p *pipeline) matchmakeAdd(logger *zap.Logger, session *session, envelope *
			SessionId: mk.ID.SessionID.Bytes(),
			Handle:    mp.Meta.Handle,
		}

		p.messageRouter.Send(logger, to, outgoing)
	}
}
@@ -96,3 +170,17 @@ func (p *pipeline) matchmakeRemove(logger *zap.Logger, session *session, envelop

	session.Send(&Envelope{CollationId: envelope.CollationId})
}

func uniqueList(values []string) []string {
	m := make(map[string]struct{})
	set := make([]string, 0)

	for _, v := range values {
		if _, ok := m[v]; !ok {
			m[v] = struct{}{}
			set = append(set, v)
		}
	}

	return set
}
Loading