open-match.dev/open-match@v1.8.1/examples/scale/scenarios/teamshooter/teamshooter.go (about)

     1  // Copyright 2019 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // TeamShooterScenario is a scenario which is designed to emulate the
    16  // approximate behavior to open match that a skill based team game would have.
    17  // It doesn't try to provide good matchmaking for real players. There are three
    18  // arguments used:
    19  // mode: The game mode the players wants to play in. mode is a hard partition.
    20  // regions: Players may have good latency to one or more regions. A player will
    21  //
    22  //	search for matches in all eligible regions.
    23  //
    24  // skill: Players have a random skill based on a normal distribution. Players
    25  //
    26  //	will only be matched with other players who have a close skill value. The
    27  //	match functions have overlapping partitions of the skill brackets.
    28  package teamshooter
    29  
    30  import (
    31  	"fmt"
    32  	"io"
    33  	"math"
    34  	"math/rand"
    35  	"sort"
    36  	"time"
    37  
    38  	"google.golang.org/protobuf/types/known/anypb"
    39  	"google.golang.org/protobuf/types/known/wrapperspb"
    40  	"open-match.dev/open-match/pkg/pb"
    41  )
    42  
    43  const (
    44  	poolName = "all"
    45  	skillArg = "skill"
    46  	modeArg  = "mode"
    47  )
    48  
    49  // TeamShooterScenario provides the required methods for running a scenario.
    50  type TeamShooterScenario struct {
    51  	// Names of available region tags.
    52  	regions []string
    53  	// Maximum regions a player can search in.
    54  	maxRegions int
    55  	// Number of tickets which form a match.
    56  	playersPerGame int
    57  	// For each pair of consequitive values, the value to split profiles on by
    58  	// skill.
    59  	skillBoundaries []float64
    60  	// Maximum difference between two tickets to consider a match valid.
    61  	maxSkillDifference float64
    62  	// List of mode names.
    63  	modes []string
    64  	// Returns a random mode, with some weight.
    65  	randomMode func() string
    66  }
    67  
    68  // Scenario creates a new TeamShooterScenario.
    69  func Scenario() *TeamShooterScenario {
    70  
    71  	modes, randomMode := weightedChoice(map[string]int{
    72  		"pl": 100, // Payload, very popular.
    73  		"cp": 25,  // Capture point, 1/4 as popular.
    74  	})
    75  
    76  	regions := []string{}
    77  	for i := 0; i < 2; i++ {
    78  		regions = append(regions, fmt.Sprintf("region_%d", i))
    79  	}
    80  
    81  	return &TeamShooterScenario{
    82  		regions:            regions,
    83  		maxRegions:         1,
    84  		playersPerGame:     12,
    85  		skillBoundaries:    []float64{math.Inf(-1), 0, math.Inf(1)},
    86  		maxSkillDifference: 0.01,
    87  		modes:              modes,
    88  		randomMode:         randomMode,
    89  	}
    90  }
    91  
    92  // Profiles shards the player base on mode, region, and skill.
    93  func (t *TeamShooterScenario) Profiles() []*pb.MatchProfile {
    94  	p := []*pb.MatchProfile{}
    95  
    96  	for _, region := range t.regions {
    97  		for _, mode := range t.modes {
    98  			for i := 0; i+1 < len(t.skillBoundaries); i++ {
    99  				skillMin := t.skillBoundaries[i] - t.maxSkillDifference/2
   100  				skillMax := t.skillBoundaries[i+1] + t.maxSkillDifference/2
   101  				p = append(p, &pb.MatchProfile{
   102  					Name: fmt.Sprintf("%s_%s_%v-%v", region, mode, skillMin, skillMax),
   103  					Pools: []*pb.Pool{
   104  						{
   105  							Name: poolName,
   106  							DoubleRangeFilters: []*pb.DoubleRangeFilter{
   107  								{
   108  									DoubleArg: skillArg,
   109  									Min:       skillMin,
   110  									Max:       skillMax,
   111  								},
   112  							},
   113  							TagPresentFilters: []*pb.TagPresentFilter{
   114  								{
   115  									Tag: region,
   116  								},
   117  							},
   118  							StringEqualsFilters: []*pb.StringEqualsFilter{
   119  								{
   120  									StringArg: modeArg,
   121  									Value:     mode,
   122  								},
   123  							},
   124  						},
   125  					},
   126  				})
   127  			}
   128  		}
   129  	}
   130  
   131  	return p
   132  }
   133  
   134  // Ticket creates a randomized player.
   135  func (t *TeamShooterScenario) Ticket() *pb.Ticket {
   136  	region := rand.Intn(len(t.regions))
   137  	numRegions := rand.Intn(t.maxRegions) + 1
   138  
   139  	tags := []string{}
   140  	for i := 0; i < numRegions; i++ {
   141  		tags = append(tags, t.regions[region])
   142  		// The Earth is actually a circle.
   143  		region = (region + 1) % len(t.regions)
   144  	}
   145  
   146  	return &pb.Ticket{
   147  		SearchFields: &pb.SearchFields{
   148  			DoubleArgs: map[string]float64{
   149  				skillArg: clamp(rand.NormFloat64(), -3, 3),
   150  			},
   151  			StringArgs: map[string]string{
   152  				modeArg: t.randomMode(),
   153  			},
   154  			Tags: tags,
   155  		},
   156  	}
   157  }
   158  
   159  func (t *TeamShooterScenario) Backfill() *pb.Backfill {
   160  	return nil
   161  }
   162  
   163  // MatchFunction puts tickets into matches based on their skill, finding the
   164  // required number of tickets for a game within the maximum skill difference.
   165  func (t *TeamShooterScenario) MatchFunction(p *pb.MatchProfile, poolBackfills map[string][]*pb.Backfill, poolTickets map[string][]*pb.Ticket) ([]*pb.Match, error) {
   166  	skill := func(t *pb.Ticket) float64 {
   167  		return t.SearchFields.DoubleArgs[skillArg]
   168  	}
   169  
   170  	tickets := poolTickets[poolName]
   171  	var matches []*pb.Match
   172  
   173  	sort.Slice(tickets, func(i, j int) bool {
   174  		return skill(tickets[i]) < skill(tickets[j])
   175  	})
   176  
   177  	for i := 0; i+t.playersPerGame <= len(tickets); i++ {
   178  		mt := tickets[i : i+t.playersPerGame]
   179  		if skill(mt[len(mt)-1])-skill(mt[0]) < t.maxSkillDifference {
   180  			avg := float64(0)
   181  			for _, t := range mt {
   182  				avg += skill(t)
   183  			}
   184  			avg /= float64(len(mt))
   185  
   186  			q := float64(0)
   187  			for _, t := range mt {
   188  				diff := skill(t) - avg
   189  				q -= diff * diff
   190  			}
   191  
   192  			m, err := (&matchExt{
   193  				id:            fmt.Sprintf("profile-%v-time-%v-%v", p.GetName(), time.Now().Format("2006-01-02T15:04:05.00"), len(matches)),
   194  				matchProfile:  p.GetName(),
   195  				matchFunction: "skillmatcher",
   196  				tickets:       mt,
   197  				quality:       q,
   198  			}).pack()
   199  			if err != nil {
   200  				return nil, err
   201  			}
   202  			matches = append(matches, m)
   203  		}
   204  	}
   205  
   206  	return matches, nil
   207  }
   208  
   209  // Evaluate returns matches in order of highest quality, skipping any matches
   210  // which contain tickets that are already used.
   211  func (t *TeamShooterScenario) Evaluate(stream pb.Evaluator_EvaluateServer) error {
   212  	// Unpacked proposal matches.
   213  	proposals := []*matchExt{}
   214  	// Ticket ids which are used in a match.
   215  	used := map[string]struct{}{}
   216  
   217  	for {
   218  		req, err := stream.Recv()
   219  		if err == io.EOF {
   220  			break
   221  		}
   222  		if err != nil {
   223  			return fmt.Errorf("Error reading evaluator input stream: %w", err)
   224  		}
   225  
   226  		p, err := unpackMatch(req.GetMatch())
   227  		if err != nil {
   228  			return err
   229  		}
   230  		proposals = append(proposals, p)
   231  	}
   232  
   233  	// Higher quality is better.
   234  	sort.Slice(proposals, func(i, j int) bool {
   235  		return proposals[i].quality > proposals[j].quality
   236  	})
   237  
   238  outer:
   239  	for _, p := range proposals {
   240  		for _, t := range p.tickets {
   241  			if _, ok := used[t.Id]; ok {
   242  				continue outer
   243  			}
   244  		}
   245  
   246  		for _, t := range p.tickets {
   247  			used[t.Id] = struct{}{}
   248  		}
   249  
   250  		err := stream.Send(&pb.EvaluateResponse{MatchId: p.id})
   251  		if err != nil {
   252  			return fmt.Errorf("Error sending evaluator output stream: %w", err)
   253  		}
   254  	}
   255  
   256  	return nil
   257  }
   258  
   259  // matchExt presents the match and extension data in a native form, and allows
   260  // easy conversion to and from proto format.
   261  type matchExt struct {
   262  	id            string
   263  	tickets       []*pb.Ticket
   264  	quality       float64
   265  	matchProfile  string
   266  	matchFunction string
   267  }
   268  
   269  func unpackMatch(m *pb.Match) (*matchExt, error) {
   270  	v := &wrapperspb.DoubleValue{}
   271  
   272  	err := m.Extensions["quality"].UnmarshalTo(v)
   273  	if err != nil {
   274  		return nil, fmt.Errorf("Error unpacking match quality: %w", err)
   275  	}
   276  
   277  	return &matchExt{
   278  		id:            m.MatchId,
   279  		tickets:       m.Tickets,
   280  		quality:       v.Value,
   281  		matchProfile:  m.MatchProfile,
   282  		matchFunction: m.MatchFunction,
   283  	}, nil
   284  }
   285  
   286  func (m *matchExt) pack() (*pb.Match, error) {
   287  	v := &wrapperspb.DoubleValue{Value: m.quality}
   288  
   289  	a, err := anypb.New(v)
   290  	if err != nil {
   291  		return nil, fmt.Errorf("Error packing match quality: %w", err)
   292  	}
   293  
   294  	return &pb.Match{
   295  		MatchId:       m.id,
   296  		Tickets:       m.tickets,
   297  		MatchProfile:  m.matchProfile,
   298  		MatchFunction: m.matchFunction,
   299  		Extensions: map[string]*anypb.Any{
   300  			"quality": a,
   301  		},
   302  	}, nil
   303  }
   304  
   305  func clamp(v float64, min float64, max float64) float64 {
   306  	if v < min {
   307  		return min
   308  	}
   309  	if v > max {
   310  		return max
   311  	}
   312  	return v
   313  }
   314  
   315  // weightedChoice takes a map of values, and their relative probability.  It
   316  // returns a list of the values, along with a function which will return random
   317  // choices from the values with the weighted probability.
   318  func weightedChoice(m map[string]int) ([]string, func() string) {
   319  	s := make([]string, 0, len(m))
   320  	total := 0
   321  	for k, v := range m {
   322  		s = append(s, k)
   323  		total += v
   324  	}
   325  
   326  	return s, func() string {
   327  		remainder := rand.Intn(total)
   328  		for k, v := range m {
   329  			remainder -= v
   330  			if remainder < 0 {
   331  				return k
   332  			}
   333  		}
   334  		panic("weightedChoice is broken.")
   335  	}
   336  }