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 }