go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/experiments/matchmaker/pkg/sim/simulation.go (about) 1 /* 2 3 Copyright (c) 2024 - Present. Will Charczuk. All rights reserved. 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 5 6 */ 7 8 package sim 9 10 import ( 11 "fmt" 12 "math" 13 "math/rand" 14 "slices" 15 "time" 16 17 c9s "go.charczuk.com/sdk/collections" 18 "go.charczuk.com/sdk/mathutil" 19 "go.charczuk.com/sdk/uuid" 20 ) 21 22 type Simulation struct { 23 // Config holds configuration options that will 24 // be satisfied by defaults if they're not set. 25 Config SimulationConfig 26 27 // Clock is the source of time for the simulation. 28 // 29 // If it's not set, it will be assigned a default. 30 Clock Clock 31 Matchmaker Matchmaker 32 RandSource rand.Source 33 34 // rand is to save some gc churn. 35 r *rand.Rand 36 37 // players is the authoritative full list of players. 38 players PlayerLookup 39 // playersAvailable are players that have not connected. 40 playersAvailable PlayerLookup 41 // playersIdle are players that have connected and are 42 // waiting to join the matchmaker queue. 43 playersIdle PlayerLookup 44 // playersInQueue are players that have connected and are 45 // in the matchmaker queue. 46 playersInQueue PlayerLookup 47 // playersInGame are players that are in game(s). 48 playersInGame PlayerLookup 49 50 // servers is the authoritative full list of servers. 51 servers ServerLookup 52 // serversIdle are the servers that can be used 53 // to host games by the matchmaker. 54 serversIdle ServerLookup 55 // serversHostingGames are the servers that are currently hosting 56 // games and should be checked if they're done. 57 serversHostingGames ServerLookup 58 } 59 60 // Player is an alias to lookup for player. 61 type PlayerLookup = Lookup[uuid.UUID, *Player] 62 63 // ServerLookup is an alias to lookup for server. 64 type ServerLookup = Lookup[uuid.UUID, *Server] 65 66 // Init sets up internal data stores and resolves dependencies. 67 // 68 // You must call `Init` before you call `Simulate`. 69 func (s *Simulation) Init() { 70 if s.RandSource == nil { 71 s.RandSource = rand.NewSource(time.Now().UnixMilli()) 72 } 73 if s.Clock == nil { 74 s.Clock = NewSimulatedClock(time.Now()) 75 } 76 if s.Matchmaker == nil { 77 s.Matchmaker = new(SimpleMatchmaker) 78 } 79 80 s.r = rand.New(s.RandSource) 81 s.Matchmaker.Init(s.r) 82 83 s.players = s.generatePlayers() 84 s.playersAvailable = s.players.Copy() 85 s.playersIdle = make(PlayerLookup) 86 s.playersInQueue = make(PlayerLookup) 87 s.playersInGame = make(PlayerLookup) 88 89 s.servers = s.generateServers() 90 s.serversIdle = s.servers.Copy() 91 s.serversHostingGames = make(ServerLookup) 92 } 93 94 // Simulate runs the simulation and returns results. 95 func (s *Simulation) Simulate() SimulationResults { 96 startTime := s.Clock.Now() 97 var lastTimestamp, displayLastTimestamp, currentTimestamp time.Time = startTime, startTime, startTime 98 var state SimulationResultState 99 for { 100 currentTimestamp = s.Clock.Now() 101 if currentTimestamp.Sub(startTime) > s.Config.SimulationLengthOrDefault() { 102 break 103 } 104 s.simulateTick(currentTimestamp, currentTimestamp.Sub(lastTimestamp), &state) 105 s.Clock.Wait(s.Config.TickIntervalOrDefault()) 106 lastTimestamp = currentTimestamp 107 if displayLastTimestamp.IsZero() { 108 displayLastTimestamp = currentTimestamp 109 } else if currentTimestamp.Sub(displayLastTimestamp) >= time.Hour { 110 // log( 111 // "tick (1hr)", 112 // tag{"ts", currentTimestamp.Format("15:04")}, 113 // tag{"elapsed", currentTimestamp.Sub(startTime)}, 114 // tag{"ql", s.Matchmaker.QueueLen()}, 115 // tag{"p", len(s.players)}, 116 // tag{"pi", len(s.playersIdle)}, 117 // tag{"piq", len(s.playersInQueue)}, 118 // tag{"pig", len(s.playersInGame)}, 119 // tag{"s", len(s.servers)}, 120 // tag{"si", len(s.serversIdle)}, 121 // tag{"shg", len(s.serversHostingGames)}, 122 // ) 123 // log("\tplayers in queue", s.queuedPlayersByRank()...) 124 displayLastTimestamp = currentTimestamp 125 } 126 } 127 128 return s.simulateProcessResults(&state) 129 } 130 131 func (s *Simulation) simulateProcessResults(state *SimulationResultState) (res SimulationResults) { 132 ts := s.Clock.Now() 133 for _, p := range s.playersInQueue { 134 state.queuedElapsed = append(state.queuedElapsed, ts.Sub(p.QueuedAt)) 135 } 136 res.GamesPlayed = len(state.games) 137 res.GamesLobsided = state.gamesLobsided 138 res.GamesTied = state.gamesTied 139 res.QueueTimeMean = mathutil.MeanDuration(state.queuedElapsed) 140 res.QueueTimeP95 = mathutil.Percentile(state.queuedElapsed, 95.0) 141 142 var playersByWinrate []*Player 143 var winrates, naturalSkills, ratings []float64 144 for _, p := range s.players { 145 if p.Games <= 5 { 146 continue 147 } 148 149 playersByWinrate = append(playersByWinrate, p) 150 winrates = append(winrates, p.Winrate()) 151 naturalSkills = append(naturalSkills, float64(p.NaturalSkill)) 152 ratings = append(ratings, float64(p.Rating)) 153 } 154 155 slices.SortFunc(playersByWinrate, func(a, b *Player) int { 156 awr := a.Winrate() 157 bwr := b.Winrate() 158 if awr > bwr { 159 return -1 160 } 161 if awr == bwr { 162 return 0 163 } 164 return 1 165 }) 166 167 res.PlayerNaturalSkillToRatingCorrelation = mathutil.Correlation(naturalSkills, ratings) 168 169 res.PlayerTop10 = playersByWinrate[0:10] 170 pl := len(playersByWinrate) 171 res.PlayerBottom10 = playersByWinrate[pl-10 : pl] 172 res.Players = playersByWinrate 173 174 slices.Sort(winrates) 175 res.WinrateP20 = mathutil.PercentileSorted(winrates, 20.0) 176 res.WinrateMean = mathutil.PercentileSorted(winrates, 50.0) 177 res.WinrateP80 = mathutil.PercentileSorted(winrates, 80.0) 178 res.PlayerCountsByRank = s.countPlayersByRank(s.players) 179 return res 180 } 181 182 func (s *Simulation) countPlayersByRank(players PlayerLookup) []Tag { 183 lookup := make(map[string]int) 184 for _, tier := range rankTiers { 185 lookup[tier.Name] = 0 186 } 187 for _, p := range players { 188 var didFindPlayerTier bool 189 for _, tier := range rankTiers { 190 if tier.MaxRating >= p.Rating && tier.MinRating < p.Rating { 191 lookup[tier.Name]++ 192 didFindPlayerTier = true 193 break 194 } 195 } 196 if !didFindPlayerTier { 197 println("bad player rating", p.Rating) 198 } 199 } 200 var output []Tag 201 for _, tier := range rankTiers { 202 output = append(output, Tag{tier.Name, fmt.Sprint(lookup[tier.Name])}) 203 } 204 return output 205 } 206 207 func (s *Simulation) simulateTick(currentTimestamp time.Time, elapsedSinceLastTick time.Duration, state *SimulationResultState) { 208 s.tickPlayerArrivals(currentTimestamp, elapsedSinceLastTick) 209 s.tickQueueIdlePlayersIntoMatchmaker(currentTimestamp) 210 s.tickMatchmakerFormGames(currentTimestamp, state) 211 s.tickFinalizeGames(currentTimestamp, state) 212 s.tickMoveTimedOutPlayersToAvailable(currentTimestamp) 213 } 214 215 func (s *Simulation) tickPlayerArrivals(currentTimestamp time.Time, elapsedSinceLastTick time.Duration) { 216 newPlayerCount := s.randomNewPlayerCount(currentTimestamp, elapsedSinceLastTick) 217 for x := 0; x < newPlayerCount; x++ { 218 p, ok := s.playersAvailable.PopRandom(s.r) 219 if !ok { 220 p = s.createPlayer() 221 s.players.Add(p) 222 } 223 p.IdleAt = currentTimestamp 224 p.TimedOutAt = currentTimestamp.Add(s.generatePlayerTimedOutAfter()) 225 s.playersIdle.Add(p) 226 } 227 return 228 } 229 230 func (s *Simulation) generatePlayerTimedOutAfter() time.Duration { 231 playerActiveDuration := float64(s.Config.PlayerActiveDurationOrDefault()) 232 return time.Duration(s.randomNormal(playerActiveDuration, playerActiveDuration)) 233 } 234 235 func (s *Simulation) randomNewPlayerCount(currentTimestamp time.Time, elapsedSinceLastTick time.Duration) int { 236 playersPerHour := float64(targetPlayerCountsByHour[currentTimestamp.Hour()]) 237 elapsedHours := float64(elapsedSinceLastTick) / float64(time.Hour) 238 playerMean := math.Floor(playersPerHour * elapsedHours) 239 return int(s.randomNormal(playerMean, playerMean)) 240 } 241 242 func (s *Simulation) tickMoveTimedOutPlayersToAvailable(currentTimestamp time.Time) { 243 var newlyTimedOutPlayers []*Player 244 for _, player := range s.playersIdle { 245 if player.IsTimedOut(currentTimestamp) { 246 newlyTimedOutPlayers = append(newlyTimedOutPlayers, player) 247 } 248 } 249 for _, player := range newlyTimedOutPlayers { 250 s.playersIdle.Del(player) 251 s.playersAvailable.Add(player) 252 } 253 } 254 255 func (s *Simulation) tickQueueIdlePlayersIntoMatchmaker(currentTimestamp time.Time) { 256 var newlyQueuedPlayers []*Player 257 for _, player := range s.playersIdle { 258 newlyQueuedPlayers = append(newlyQueuedPlayers, player) 259 player.QueuedAt = currentTimestamp 260 s.playersInQueue.Add(player) 261 s.Matchmaker.Queue(player) 262 } 263 for _, player := range newlyQueuedPlayers { 264 s.playersIdle.Del(player) 265 } 266 } 267 268 func (s *Simulation) tickMatchmakerFormGames(currentTimestamp time.Time, state *SimulationResultState) { 269 if len(s.serversIdle) == 0 { 270 return 271 } 272 games := s.Matchmaker.FormGames(MatchmakerConfig{ 273 TeamSize: s.Config.GameTeamSizeOrDefault(), 274 MaxGames: len(s.serversIdle), 275 }) 276 for _, mg := range games { 277 svr, ok := s.serversIdle.PopRandom(s.r) 278 if !ok { 279 panic(fmt.Errorf("critical error; cannot assign game to server; no servers available")) 280 } 281 svr.Game = s.createGame(currentTimestamp, mg.TeamA, mg.TeamB) 282 s.measurePlayerQueuedElapsed(currentTimestamp, svr.Game, state) 283 s.moveGamePlayersToInGame(svr.Game) 284 s.serversHostingGames.Add(svr) 285 } 286 } 287 288 func (s *Simulation) tickFinalizeGames(currentTimestamp time.Time, state *SimulationResultState) { 289 var serversNewlyIdle []*Server 290 for _, svr := range s.serversHostingGames { 291 if svr.Game.Done(currentTimestamp) { 292 state.games = append(state.games, s.tickFinalizeGame(svr.Game, state)) 293 serversNewlyIdle = append(serversNewlyIdle, svr) 294 } 295 } 296 for _, svr := range serversNewlyIdle { 297 s.serversHostingGames.Del(svr) 298 s.serversIdle.Add(svr) 299 } 300 } 301 302 func (s *Simulation) measurePlayerQueuedElapsed(currentTimestamp time.Time, g *Game, state *SimulationResultState) { 303 for _, player := range g.TeamA { 304 queuedFor := currentTimestamp.Sub(player.QueuedAt) 305 state.queuedElapsed = append(state.queuedElapsed, queuedFor) 306 player.QueuedElapsed = append(player.QueuedElapsed, queuedFor) 307 } 308 for _, player := range g.TeamB { 309 queuedFor := currentTimestamp.Sub(player.QueuedAt) 310 state.queuedElapsed = append(state.queuedElapsed, queuedFor) 311 player.QueuedElapsed = append(player.QueuedElapsed, queuedFor) 312 } 313 } 314 315 func (s *Simulation) tickFinalizeGame(g *Game, state *SimulationResultState) *Game { 316 scorea, scoreb := s.computeGameOutcome(g) 317 318 if scorea > scoreb { 319 g.Outcome = TeamA 320 } else if scoreb > scorea { 321 g.Outcome = TeamB 322 } else { 323 state.gamesTied++ 324 if s.r.Float64() > 0.5 { 325 g.Outcome = TeamA 326 } else { 327 g.Outcome = TeamB 328 } 329 } 330 331 if math.Abs(mathutil.PercentChange(float64(scorea), float64(scoreb))) > 0.20 { 332 state.gamesLobsided++ 333 } 334 s.updateGamePlayerStatsAndHistory(g) 335 s.moveGamePlayersToIdle(g) 336 return g 337 } 338 339 func (s *Simulation) computeGameOutcome(g *Game) (scorea, scoreb int) { 340 var besta, bestb int 341 for _, player := range g.TeamA { 342 scorea += player.NaturalSkill 343 if besta < player.NaturalSkill { 344 besta = player.NaturalSkill 345 } 346 } 347 for _, player := range g.TeamB { 348 scoreb += player.NaturalSkill 349 if bestb < player.NaturalSkill { 350 bestb = player.NaturalSkill 351 } 352 } 353 354 scorea += (2 * besta) 355 scoreb += (2 * bestb) 356 scorea += s.r.Intn(500) 357 scoreb += s.r.Intn(500) 358 return 359 } 360 361 func (s *Simulation) updateGamePlayerStatsAndHistory(g *Game) { 362 for playerID := range g.TeamA { 363 s.updatePlayerStatsAndHistory(playerID, g) 364 } 365 for playerID := range g.TeamB { 366 s.updatePlayerStatsAndHistory(playerID, g) 367 } 368 } 369 370 func (s *Simulation) updatePlayerStatsAndHistory(playerID uuid.UUID, g *Game) { 371 p := s.players[playerID] 372 if g.PlayerTeam(p.ID) == g.Outcome { 373 p.Wins++ 374 } 375 p.Games++ 376 377 p.Recent.Push(g) 378 for p.Recent.Len() > s.Config.PlayerRatingHistoryLenOrDefault() { 379 _, _ = p.Recent.Pop() 380 } 381 p.Rating = s.calculatePlayerRating(p) 382 } 383 384 func (s *Simulation) moveGamePlayersToIdle(g *Game) { 385 for playerID := range g.TeamA { 386 s.movePlayerToIdle(playerID) 387 } 388 for playerID := range g.TeamB { 389 s.movePlayerToIdle(playerID) 390 } 391 } 392 393 func (s *Simulation) movePlayerToIdle(playerID uuid.UUID) { 394 player := s.players[playerID] 395 s.playersInGame.Del(player) 396 s.playersIdle.Add(player) 397 } 398 399 func (s *Simulation) moveGamePlayersToInGame(g *Game) { 400 for playerID := range g.TeamA { 401 s.movePlayerToInGame(playerID) 402 } 403 for playerID := range g.TeamB { 404 s.movePlayerToInGame(playerID) 405 } 406 } 407 408 func (s *Simulation) movePlayerToInGame(playerID uuid.UUID) { 409 player := s.players[playerID] 410 s.playersInQueue.Del(player) 411 s.playersInGame.Add(player) 412 } 413 414 func (s *Simulation) generatePlayers() (output Lookup[uuid.UUID, *Player]) { 415 output = make(Lookup[uuid.UUID, *Player]) 416 for x := 0; x < s.Config.PlayerCount; x++ { 417 output.Add(s.createPlayer()) 418 } 419 return 420 } 421 422 func (s *Simulation) generateServers() (output Lookup[uuid.UUID, *Server]) { 423 output = make(Lookup[uuid.UUID, *Server]) 424 for x := 0; x < s.Config.ServerCountOrDefault(); x++ { 425 output.Add(s.createServer()) 426 } 427 return output 428 } 429 430 func (s *Simulation) createServer() *Server { 431 return &Server{ 432 ID: uuid.V4(), 433 } 434 } 435 436 func (s *Simulation) createPlayer() *Player { 437 return &Player{ 438 ID: uuid.V4(), 439 NaturalSkill: int(s.randomNormal( 440 s.Config.PlayerNaturalRatingMedianOrDefault(), 441 s.Config.PlayerNaturalRatingStdDevOrDefault(), 442 )), 443 Rating: ratingBase, 444 Recent: new(c9s.Queue[*Game]), 445 } 446 } 447 448 func (s *Simulation) createGame(currentTimestamp time.Time, teamA, teamB []*Player) *Game { 449 return &Game{ 450 ID: uuid.V4(), 451 StartedAt: currentTimestamp, 452 FinishedAt: currentTimestamp.Add(s.Config.GameDurationOrDefault()), 453 TeamA: NewLookup(teamA), 454 RatingTeamA: s.calculateTeamRating(teamA), 455 TeamB: NewLookup(teamB), 456 RatingTeamB: s.calculateTeamRating(teamB), 457 } 458 }