#///////////////////////////////////////////////////////////////////// #// Can's Crew Autobalancer #// [cC] Sparty #// [cC] *XYZ*SaYnt #///////////////////////////////////////////////////////////////////// import sys import es,gamethread,playerlib import operator import traceback info = es.AddonInfo() info['name'] = "CCBalance" info['version'] = "0.71" info['author'] = "*XYZ*SaYnt" info['url'] = "http://cans-crew.com" info['description'] = "Team balancing solution for CSS" class Player(object): """ Describes what we know about a player """ def __init__(self): self.userid = 0 self.name = "" self.steamid = "" self.deaths = 0 self.kills = 0 self.deaths = 0 self.rounds = 0 self.overall_deaths = 0 self.overall_kills = 0 self.overall_rounds = 0 self.killrate = 1.0 self.rank = 0 self.team = '0' self.connected = 0 self.swapped_already = 0 self.init_done = False def setkillrate(self,from_overall=False): global rounds_inactive if (self.rounds <= rounds_inactive) or from_overall: k = self.overall_kills r = self.overall_rounds else: k = self.kills r = self.rounds if r == 0: self.killrate = 1.0 else: self.killrate = float(k)/float(r) def addrounds(self,n): self.rounds += n self.overall_rounds += n def addkill(self): self.kills += 1 self.overall_kills += 1 KillTotal[self.team] += 1 #self.setkillrate() def adddeath(self): self.deaths += 1 self.overall_deaths += 1 DeathTotal[self.team] += 1 class Merit(object): """ Holds information on the merit of switching a particular t and ct """ def __init__(self): self.userid_t = 0 self.userid_ct = 0 self.swing = 0 self.align = 0 self.score = 0.0 ######################################################################## ## global variables ######################################################################## ccbalance_debug = True global_rounds = 0 run_frequency = 3 rounds_inactive = 3 allowed_team_imbalance = 1 # bound on the player counts on each team acceptable_strength_imbalance = 10.0 # percentage bound over while a balance is allowed team_imbalance_percentage_trigger = 75.0 # percentage player imbalance that forces a rebalancing wait_until_dead = True Players = {} KillTotal = {} DeathTotal = {} better_factor = 1.5 worse_factor = 0.85 mid_game_load = False better_factor = 1.0 worse_factor = 1.0 ######################################################################## def config(): """ Perform any custom configuration. Inactive for now...see globals above. """ pass def load(): """ EVENT: executes whenever the script is loaded via es_load Perform initializations. """ config() # initialize the database that we will use for persistent storage. es.sql('open','ccbalance','|ccbalance') es.sql('query','ccbalance',"CREATE TABLE IF NOT EXISTS killrate (steamid TEXT,name TEXT DEFAULT 'unnamed',overall_kills TEXT DEFAULT '0',overall_deaths TEXT DEFAULT '0',overall_rounds TEXT DEFAULT '0')") #register a command that can be typed in chat to show internal statistics es.regsaycmd("ccbstats","ccbalance/showstats","Shows team balance merit information") #perform any init that needs to be done if we are loaded when a game is #already in progress. in_game_init() logmsg("CC Autobalancer %s loaded." % info['version']) es.set("ccb_version",info['version']) es.makepublic("ccb_version") def unload(): """ EVENT: executes whenever the script is unloaded via es_unload Perform any necessary cleanup. """ #close down the database. es.sql('close','ccbalance') #clean up the say command. es.unregsaycmd('ccbstats') #say we are done. logmsg("CC Autobalancer %s unloaded." % info['version']) def in_game_init(): """ initialize the team balancer when the script is loaded mid-game """ global mid_game_load mid_game_load = True try: for i in range(4): KillTotal[str(i)] = 0 DeathTotal[str(i)] = 0 myPlayerList = playerlib.getPlayerList('#all') for ply in myPlayerList: userid = int(ply.userid) x = Player() x.userid = userid x.team = str(ply.attributes['teamid']) x.steamid = ply.attributes['steamid'] x.name = ply.attributes['name'] x.connected = 1 Players[userid] = x initkillrate(x.userid,x.steamid) kills = ply.attributes['kills'] deaths = ply.attributes['deaths'] if x.team != '0': KillTotal[x.team] = KillTotal[x.team] + kills DeathTotal[x.team] = DeathTotal[x.team] + deaths log('ingameinit: T kill/death total is now %d %d' % (KillTotal['2'],DeathTotal['2'])) log('ingameinit: CT kill/death total is now %d %d' % (KillTotal['3'],DeathTotal['3'])) setrank() except: log("ERROR: exception caught in in_game_init()") logexception() raise def es_map_start(ev): """ EVENT: executes whenever the map starts. Reset all of the counters and flags that we need to. """ mid_game_load = False # reset the team kill counts for i in range(4): KillTotal[str(i)] = 0 DeathTotal[str(i)] = 0 for uid in Players: Players[uid].swapped_already = 0 Players[uid].kills = 0 Players[uid].deaths = 0 Players[uid].rounds = 0 #debugging line log(' '.join(es.getUseridList())) #all players need to be expunged on a map change. We will pick them up #again as they reconnect back. for uid in Players: delete_player(uid,False) Players.clear() # reset the round counter global global_rounds global_rounds = 0 def round_start(ev): """ EVENT: executes whenever a round starts Increment the number of rounds that have been played for this map. """ global global_rounds global_rounds += 1 def round_end(ev): """ EVENT: executes whenever a round ends Delay a little bit, and call the delayed_round_end function. """ gamethread.delayed(1.0,delayed_round_end,int(ev['reason'])) #gamethread.delayed(2.0,delayed_db_write) def delayed_db_write(): # write player info to database. for v in Players.itervalues(): player_update_database(v.userid,v.steamid) def delayed_round_end(reason): """ Round end processing. executes a second after round end, because of the delay in the round_end that we do above. Keep track of the number of rounds each player has played, and compute their kill rates. If it is time, spring into action and do some team balancing. """ try: global global_rounds global run_frequency global rounds_inactive #do the team balancing calculations here. if reason == 10 or reason == 16: #do nothing on rounds that end in a draw #do nothing for rounds that end for game commence for x in Players: Players[x].addrounds(-1) global_rounds -= 1 return else: try: for x in Players.itervalues(): x.setkillrate() except: log("ERROR: setkillrate() raised an exception") logexception() raise #set everyone's rank, which is determined by their killrate. try: setrank() except: log("ERROR: setrank() raised an exception") logexception() raise number_of_t = int(es.getplayercount('2')) number_of_ct = int(es.getplayercount('3')) percentage = float(min(number_of_t,number_of_ct))/float(max(number_of_t,number_of_ct))*100.0 (st,sct) = compute_team_strength(float(run_frequency)) log("Round %d: Team Strengths: T = %.2f | CT = %.2f" % (global_rounds,st,sct)) # after all of these calculations, we need to swap players if need be. balance_this_round = 0 logmsg("Round %d (balancing every %d rounds)" % (global_rounds,run_frequency)) if (global_rounds % run_frequency) == 0: logmsg("Triggering a balancing based on set round frequency. round %d." % global_rounds) balance_this_round = 1 if percentage < team_imbalance_percentage_trigger: logmsg("Triggering a balancing based on team counts. %.2f" % percentage) balance_this_round = 2 if balance_this_round == 1: if (global_rounds < rounds_inactive) and not mid_game_load: logmsg("Cancelling a balancing; it is too early.") balance_this_round = 0 if balance_this_round == 1: balancepercentage = abs(2*(st-sct)/(st+sct))*100.0 if balancepercentage < acceptable_strength_imbalance: logmsg("Cancelling a balancing; the teams are balanced to %.1f percent." % balancepercentage) balance_this_round = 0 if balance_this_round: try: dobalance() except: log("ERROR: dobalance() raised an exception") logexception() raise except: log("ERROR: exception caught in round_end()") logexception() raise def player_team(ev): """ EVENT: executed whenever a player joins a team Update our player data structures accordingly. Also keep track of the team kills and deaths accordingly. """ #1 = spec #2 = t #3 = ct #es.msg("TEAM " + ev['userid']) if ev['team'] != '0': try: userid = int(ev['userid']) Players[userid].team = ev['team'] KillTotal[ev['team']] += Players[userid].kills DeathTotal[ev['team']] += Players[userid].deaths KillTotal[ev['oldteam']] -= Players[userid].kills DeathTotal[ev['oldteam']] -= Players[userid].deaths # POSSIBLE ISSUE: need to think through what happens here....the ranking will # get computed even when the round is not active....may be better to leave # the rankings uncomputed until it is team balance time. #setrank() except: log("Exception caught in player_team. Probably a KeyError.") logexception() def player_spawn(ev): """ EVENT: executed whenever a player spawns Record the fact that the player played this round. """ #es.msg("SPAWN " + ev['userid']) # QUESTION: if a player is in spec, do they spawn? I think not, but I had # better check. userid = int(ev['userid']) Players[userid].rounds += 1 Players[userid].overall_rounds += 1 Players[userid].team = ev['es_userteam'] # the sequence of events is...connect, spawn, activate, team, team, spawn def player_connect(ev): """ EVENT: a player has connected. Create a new instance of a player and add them to our dictionary. """ #es.msg("CONNECT " + ev['userid']) userid = int(ev['userid']) player_add(userid,ev['name'],ev['networkid']) def es_player_validated(ev): """ EVENT: executed when a player is validated (gets a steam id) """ #log("VALIDATED" + ev['userid']) userid = int(ev['userid']) player_add(userid,ev['es_username'],ev['es_steamid']) def player_activate(ev): """ EVENT: executed when a player is activated We need to handle this event and add a player if they are not already added, to properly handle players through a map change. During a map change, players do not disconnect, but are reactivated at the beginning of the map. """ #log("ACTIVATE " + ev['userid'] + ev['es_username']) userid = int(ev['userid']) player_add(userid,ev['es_username']) def player_add(userid,name,steamid=''): """ handle a player addition. This may be called when the user is validated, or when the user is activated (either one). We must be able to handle both cases, which is the purpose of the logic below. """ if userid not in Players: x = Player() x.connected = 1 x.name = name x.userid = userid Players[userid] = x log("%s added to balancer." % name) if not Players[userid].init_done: if not steamid: steamid = es.getplayersteamid(userid) if "PENDING" not in steamid: initkillrate(userid,steamid) log("%s [%s] updated in balancer. Kill rate of %.2f per round." % (name,steamid,Players[userid].killrate)) def logmsg(s): es.msg("[cCB] " + s) #es.dbgmsg(0,"[cCB] "+ s) log(s) def log(s): es.log("[cCB] " + s) def logexception(): lst = traceback.format_exception(sys.exc_info()[0],10,sys.exc_info()[2]) for l in lst: es.log("[cCB] " + l.rstrip()) def initkillrate(userid,steamid): """ sets the steam ID and initial killrate for a player given by userid and steamid """ Players[userid].steamid = steamid #use a database to initialize the player's killrate. check = es.sql('queryvalue','ccbalance',"SELECT overall_rounds FROM killrate WHERE steamid='%s'" % steamid) if check != None: overall_rounds = es.sql('queryvalue','ccbalance',"SELECT overall_rounds FROM killrate WHERE steamid='%s'" % steamid) Players[userid].overall_rounds = int(overall_rounds) else: Players[userid].overall_rounds = 0 check = es.sql('queryvalue','ccbalance',"SELECT overall_deaths FROM killrate WHERE steamid='%s'" % steamid) if check != None: overall_deaths = es.sql('queryvalue','ccbalance',"SELECT overall_deaths FROM killrate WHERE steamid='%s'" % steamid) Players[userid].overall_deaths = int(overall_deaths) else: Players[userid].overall_deaths = 0 check = es.sql('queryvalue','ccbalance',"SELECT overall_kills FROM killrate WHERE steamid='%s'" % steamid) if check != None: overall_kills = es.sql('queryvalue','ccbalance',"SELECT overall_kills FROM killrate WHERE steamid='%s'" % steamid) Players[userid].overall_kills = int(overall_kills) else: Players[userid].overall_kills = 0 Players[userid].setkillrate(from_overall=True) Players[userid].init_done = True def player_changename(ev): #keep the name in our data structure synced with the player's actual name userid = int(ev['userid']) Players[userid].name = ev['newname'] def player_disconnect(ev): #log('DISCONNECT ' + ev['userid']) userid = int(ev['userid']) delete_player(userid) def delete_player(userid,delete=True): Players[userid].connected = 0 player_update_database(userid,Players[userid].steamid) if delete: del Players[userid] def player_update_database(userid,steamid): """ write player information into the database for cold storage """ check = es.sql('queryvalue','ccbalance',"SELECT steamid FROM killrate WHERE steamid='%s'" % steamid) if check == None: es.sql('query','ccbalance',"INSERT INTO killrate (steamid) VALUES ('%s')" % steamid) killrate = Players[userid].killrate steamid = Players[userid].steamid name = Players[userid].name es.sql('query','ccbalance',"UPDATE killrate SET name = ('%s') WHERE steamid='%s'" % (name, steamid)) es.sql('query','ccbalance',"UPDATE killrate SET overall_deaths = ('%d') WHERE steamid='%s'" % (Players[userid].overall_deaths, steamid)) es.sql('query','ccbalance',"UPDATE killrate SET overall_kills = ('%d') WHERE steamid='%s'" % (Players[userid].overall_kills, steamid)) es.sql('query','ccbalance',"UPDATE killrate SET overall_rounds = ('%d') WHERE steamid='%s'" % (Players[userid].overall_rounds, steamid)) def player_death(ev): """ EVENT: executes every time a player dies. record a death and a kill. """ userid = int(ev['userid']) attacker = int(ev['attacker']) # do not count suicides if userid == attacker: return try: Players[userid].adddeath() if attacker: Players[attacker].addkill() except KeyError: log("Notice: keyerror detected in player_death. Need to be fixed. %d %d" % (userid,attacker)) logexception() def dobalance(): """ compute all merit scores of trading players and do the best trade """ merit = [] number_of_t = int(es.getplayercount('2')) number_of_ct = int(es.getplayercount('3')) log("In dobalance. n_ct = %d, n_t = %d" % (number_of_ct,number_of_t)) # test to see if mani is present global wait_until_dead if hasmani(): wait_until_dead = False else: wait_until_dead = True log("In dobalance. Computing player moves.") # compute all possible player moves. We need to only allow moves that will # satisfy team-number-balance constraints. for p in Players.itervalues(): try: compute_merit_move(merit,p,number_of_t,number_of_ct) except: log("Exception raised in single player move calculations. uid = %d" % p.userid) logexception() pass # compute all possible swaps. We only allow swaps if the current team counts # are within our allowed limits. Otherwise, we work only with player moves # as computed above. log("In dobalance. Computing player swaps.") if abs(number_of_ct-number_of_t) <= allowed_team_imbalance: for t in Players.itervalues(): if t.team == '2': for ct in Players.itervalues(): if ct.team == '3': try: compute_merit_swap(merit,t,ct) except: log("Exception raised in player swap calculations. uid = %d %d" % (t.userid,ct.userid)) logexception() if len(merit) > 0: log("In dobalance. Sorting merits by score.") # now that we have a list of merit objects, sort them from low to high. merit.sort(key=operator.attrgetter('score')) # for debugging, print the merit trades try: if ccbalance_debug: for v in merit: userid_ct = v.userid_ct userid_t = v.userid_t if userid_ct != 0 and userid_t != 0: log("merit of swapping %s (T) and %s (CT): score=%.2f" % (Players[userid_t].name,Players[userid_ct].name,v.score)) elif userid_t != 0: log("merit of moving %s (T): score=%.2f" % (Players[userid_t].name,v.score)) elif userid_ct != 0: log("merit of moving %s (CT): score=%.2f" % (Players[userid_ct].name,v.score)) except: log("Exception raised in debug logging.") logexception() # pick off the first one; which is the best swap that we can possibly # do. Swap those two players (or move, if the merit object tells us that's # what we need to do) try: log("In dobalance. Performing the team changes.") userid_ct = merit[0].userid_ct userid_t = merit[0].userid_t log("In dobalance. Performing the team changes with %d %d" % (userid_ct,userid_t)) if userid_ct == 0: logmsg("moving %s (T) to team CT." % Players[userid_t].name) swapteam(userid_t,'3') Players[userid_t].swapped_already += 1 elif userid_t == 0: logmsg("moving %s (CT) to team T." % Players[userid_ct].name) swapteam(userid_ct,'2') Players[userid_ct].swapped_already += 1 else: logmsg("swapping %s (T) and %s (CT)" % (Players[userid_t].name,Players[userid_ct].name)) swapteam(userid_t,'3') swapteam(userid_ct,'2') Players[userid_t].swapped_already += 1 Players[userid_ct].swapped_already += 1 except: log("Exception raised in team swap procedure.") logexception() raise else: logmsg("Unable to list any valid moves or swaps.") def swapteam(userid,teamid): if hasmani(): isdead = int(es.getplayerprop(str(userid),"CCSPlayer.baseclass.pl.deadflag")) if isdead: es.server.queuecmd("ma_givehealth %s 500" % str(userid)) es.server.queuecmd("ma_swapteam %s" % str(userid)) else: es.changeteam(str(userid),str(teamid)) def compute_merit_move(merit,p,number_of_t,number_of_ct): """ compute the merit of moving a single player """ #if this player is alive, dont allow him to be swapped if wait_until_dead: dead = int(es.getplayerprop(str(p.userid),"CCSPlayer.baseclass.pl.deadflag")) if not dead: return # if this player already has been swapped this map, then dont allow him to be # swapped again. if p.swapped_already > 0: return m = Merit() total_t_kills = KillTotal['2'] total_ct_kills = KillTotal['3'] (st,sct) = compute_team_strength(float(run_frequency)) if (sct < st): #if total_ct_kills < total_t_kills: ctfactor = better_factor tfactor = worse_factor else: tfactor = better_factor ctfactor = worse_factor #compute projection of moving this player. if p.team == '2': proposed_number_of_t = number_of_t - 1 proposed_number_of_ct = number_of_ct + 1 killrate = p.killrate * tfactor m.userid_t = p.userid rounds = 5 t_projection = killrate * rounds t_loss = 0 # should I project a little loss here? if abs(proposed_number_of_t - proposed_number_of_ct) <= allowed_team_imbalance: m.align = (total_t_kills-p.kills-t_loss) - (total_ct_kills+p.kills+t_projection) m.score = float(abs(m.align)) merit.append(m) if p.team == '3': proposed_number_of_t = number_of_t + 1 proposed_number_of_ct = number_of_ct - 1 killrate = p.killrate * ctfactor m.userid_ct = p.userid rounds = 5 ct_projection = killrate * rounds ct_loss = 0 # should I project a little loss here? if abs(proposed_number_of_t - proposed_number_of_ct) <= allowed_team_imbalance: m.align = (total_t_kills+p.kills+ct_projection) - (total_ct_kills-p.kills-ct_loss) m.score = float(abs(m.align)) merit.append(m) def compute_merit_swap(merit,t,ct): """ compute the merit of swapping two players """ if wait_until_dead: dead = int(es.getplayerprop(str(t.userid),"CCSPlayer.baseclass.pl.deadflag")) if not dead: return dead = int(es.getplayerprop(str(ct.userid),"CCSPlayer.baseclass.pl.deadflag")) if not dead: return # dont allow a player that has already been swapped to be swapped again. if t.swapped_already > 0 or ct.swapped_already > 0: return m = Merit() total_t_kills = KillTotal['2'] total_ct_kills = KillTotal['3'] (st,sct) = compute_team_strength(float(run_frequency)) if (sct < st): #if (total_ct_kills < total_t_kills): ctfactor = better_factor tfactor = worse_factor else: tfactor = better_factor ctfactor = worse_factor #compute projection of trading these two players killrate = t.killrate * tfactor rounds = 5 t_projection = killrate * rounds killrate = ct.killrate * ctfactor rounds = 5 ct_projection = killrate * rounds m.userid_t = t.userid m.userid_ct = ct.userid m.swing = t_projection - ct_projection m.align = (total_t_kills-t.kills+ct.kills+ct_projection) - (total_ct_kills-ct.kills+t.kills+t_projection) m.score = float(abs(m.align)) merit.append(m) def showstats(): """ print out stats here, and show it only to the player identified by userid """ (t,ct) = sortedplayerlists() (t_mom,ct_mom) = compute_team_momentum(float(run_frequency)) t_kills = KillTotal['2'] ct_kills = KillTotal['3'] userid = es.getcmduserid() cprint(userid,"="*60) cprint(userid,"Cans Crew Balancer Statistics") cprint(userid,"="*60) cprint(userid,"Total Kills (T) : %d" % t_kills) cprint(userid,"Total Kills (CT): %d" % ct_kills) cprint(userid,"TEAM STRENGTH (T) : %d + %.2f = %.2f" % (t_kills,t_mom,t_mom+float(t_kills))) cprint(userid,"TEAM STRENGTH (CT): %d + %.2f = %.2f" % (ct_kills,ct_mom,ct_mom+float(ct_kills))) cprint(userid,"-"*60) mystring = "%4s %5s %-30s %3s %3s %3s %s" % ("Rank","ID","Name","K","D","Rnd","KillRate") cprint(userid,mystring) cprint(userid,"-"*60) cprint(userid,"Terrorists:") for player in t: showstat(userid,player) cprint(userid,"Counter Terrorists:") for player in ct: showstat(userid,player) es.tell(userid,"#green","[CCB] ccbstat results are displayed in the console.") def compute_team_momentum(nrounds_forward): """ computes the team momentums, meaning the number of kills that they are expected to achieve over the next nrounds_forward rounds. """ s_t = 0.0 s_ct = 0.0 for k in Players.itervalues(): if k.team == '2': s_t += k.killrate*nrounds_forward elif k.team == '3': s_ct += k.killrate*nrounds_forward return (s_t,s_ct) def compute_team_strength(nrounds_forward): """ compute the team strength...the kills projected for each team at the end of nrounds_forward rounds. """ (mt,mct) = compute_team_momentum(nrounds_forward) return (mt + float(KillTotal['2']),mct + float(KillTotal['3'])) def cprint(userid,s): es.cexec(userid,'echo','"%s"' % s) es.log("[CCB] " + s) def showstat(userid,x): #mystring = "%4d %5d %-30s %3d|%d %3d|%d %3d|%d %.2f" % (x.rank,x.userid,x.name,x.kills,x.overall_kills,x.deaths,x.overall_deaths,x.rounds,x.overall_rounds,x.killrate) mystring = "%4d %5d %-30s %3d %3d %3d %.2f" % (x.rank,x.userid,x.name,x.kills,x.deaths,x.rounds,x.killrate) cprint(userid,mystring) def sortedplayerlists(): t = [] ct = [] for k in Players.itervalues(): if k.team == '2': t.append(k) elif k.team == '3': ct.append(k) t.sort(key=operator.attrgetter('killrate')) ct.sort(key=operator.attrgetter('killrate')) t.reverse() ct.reverse() return (t,ct) def setrank(): (t,ct) = sortedplayerlists() # assign the ranks for i,x in enumerate(t): Players[x.userid].rank = i+1 for i,x in enumerate(ct): Players[x.userid].rank = i+1 def hasmani(): has_mani = es.exists("variable","mani_admin_plugin_version") return has_mani