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  }