/** This file was auto-generated by jpp. You probably want to be editing AutoTeamBalance.uc.jpp instead. **/ // vim: tabstop=2 shiftwidth=2 noexpandtab filetype=uc // == AutoTeamBalance ======================================================== // A UT mutator that makes fair teams at the beginning of each teamgame, // Works by recording the relative strengths of players on the server (indexed // by nick/ip/Idc). // It also attempts to put a player joining the game on the weaker team, and // can perform mid-game rebalance when players type "!teams". // by F0X|nogginBasher and Daniel Mastersourcerer at Kitana's Castle. // Copyright Paul Clark 2007, released under the LGPL. // Thanks to: Daniel, iDeFiX, unrealadmin, Matt, the author of adwvaad, and // #unrealscript at EnterTheGame. // Code snippets lifted from iDeFiX's team balancer, TeamBallancer, and the // adwvaad thread. // =========================================================================== // This program is free software: you can redistribute it and/or modify // it under the terms of the Lesser GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Lesser General Public License for more details. // You should have received a copy of the GNU Lesser General Public License // along with this program. If not, see . // =========================================================================== // New changes for 1.4: // Fixed the bug that a spectator could type "!red" and pick up the blue flag! // Finally added a date_last_played entry for each player. Now old records can be recycled better. // As well as displaying the SmartCTF scoreboard, !stats now also shows all the player strengths. (It's a shortcut for "mutate strengths".) // Messages flashed to players now have different colours, position and timeout, and do not appear in the player's console. // Overwrites the default pre-game message which tells players which team they are on, since teams are not assigned until later! // If bShowReason is set, will explain why teams need to be rebalanced. // Now always broadcasts the reason if rebalancing was not possible, even if bBroadcastStuff=False. // Added a new command: mutate listmuts // Also added "mutate strengths extra" - use with care, it may require too much processing with large numbers of players! // Also added "mutate stats" which shows various game stats for each player in the console (e.g. frags, deaths, item pickups). // Added RelativeNormalisationProportion with a default of 0.5, to stop player strengths from shooting off too high. // If a semi-admin accidentally types the semi-admin pass in chat instead of the console, their message is swallowed. // If team sizes differ by 2 or more players, someone will be switched, even if the smaller team had greater strength, but immediate rebalancing will be made possible, and recommended to all players. // Stripped at compile-time: // Testing bSuperBalance, but it has some nasty side-effects. // Testing COOL_CAMERA, but replication is too slow. // Jan 08 // I want the server to have an average player strength of UnknownStrength (50). // This allows us to estimate the strength of unknown players, and keeps the numbers under control, so that strength (units ^^) means a similar thing on the server from day to day, rather than changing depending who is playing. // Problem: the relative normalisation was making numbers shoot off high; the average strength of all the records in my DB came to 58! This may be caused by relatively stronger players spending more time on the server than weaker players. // Solution: When the normalisation is performed, the target is not 50, or the current player average, but halfway between (configurable by RelativeNormalisationProportion). // I'm not sure that this will bring my total average back down to 50 anytime soon (although I could force that in one parse), but I think it will at least keep the average from increasing more. // Changes in 1.3 October 2007: // Added MinRequestsForRebalance. // Fixed the bug "X has lost N cookies" appearing when it shouldn't. // Scaled FlagStrength for non-CTF gametypes, so it is not disproportional. // Also reduced the default to 10, to give less of a disadvantage to the leading team. // Forced FlagStrength to 0 when #players <3, hoping to fix pizzaman's bug. // Made MaxPlayerData configurable - but do not set it above the static limit 4096. // Added bSeparateStatsByMutators, so now you can split up stats by gametype, or by mutators, or both, or neither. (NOTE: sony_scarface, or anyone who had bSeparateStatsByGamemode=True in earlier versions, should set this to True to keep your database as it was.) // Now we only broadcast "fakenickers" when nick changes, not IP. // Gives the player who was switched +1 frags and -1 deaths to make up for the suicide when he changes teams. // Now mid-game warning or multiple requests for !teams will display the proposed player(s) to move in advance. // Changed the default MaxHoursWhenCopyingOldRecord and HoursBeforeRecyclingStrength, so that cookies/strength are more sensitive, i.e. change more each game. // Some minor improvements to messages. // Stripped out a lot of comments. // DONE: we cache the averageGameScore and averagePlayerStrengthThisGame for 3 seconds, to avoid recalculating it unneccessarily. // DONE: longer lasting, better coloured flashing messages // TODO: make the bonus for winning team hidden from scoreboard, and then it might get used more often :) // TODO: when switching players, avoid players whos strength is less known (they have played less time on server) // DONE: when joining unstarted game, u get msg saying u r on Red or Blue team, but we change it later :S - overwrite or empty that message! // TODO: what should we do when it's 3v5 but red team has strong players, so team strengths are similar? // TODO: integrate with AKA - no apparently AKA is no better than ATB's current system; make an IDC-like add-on instead // TODO: when a player joins with a new IP, and we copy their old stats over to // a new record, we should really delete their old stats record, so that any // future copies will use the new (latest) strength. // TODO CHECK: maybe an admin wants disable players from switching team // entirely; if he does that, can ATB still do the switching it needs to?! // TODO: maybe we *can* update stats mid-game; if we rename timeGameStarted to lastTimeUpdated, and change that when we do an update. // Since we scale player scores to "full-time", this should also work for scaling down. // However, the current algorithm will still not count the score/frags earned since the last update, but since the player joined. // TODO: what has IDC support done to bLogFakenickers and bBroadcastFakenickers? // CONSIDER: could add last_date_played, so that we can recycle old records suitably. // CONSIDER: make 2-player balancing optional // CONSIDER: increase mintimewhenipchanges, *especially* if first two digits remain the same // CONSIDER: when ip does change (often), delete the old record - it's no use to us // TEST: When I was testing both ServerActor *and* mutator (not actually desirable), it seemed "!teams" was not working - is this fixed now? // BUG: Do not use NetWait<3; it may cause the teambalance to occur before anyone joins the server! class AutoTeamBalance expands Mutator config(AutoTeamBalance); // #define XOL_SPECIFIC // #define ENABLE_USEISPNOTFULLIP //// Testing: // #define MAP_RATING_SYSTEM // #define SUPERBALANCE // #define COOL_CAMERA // #define TESTING_PRINT_ANGLES // #define TESTING // These config variables are documented in AutoTeamBalance.txt var config bool bBroadcastStuff; var config bool bBroadcastCookies; var config bool bFlashCookies; var config bool bFlashPlayerJoins; var config bool bReportStrengthAsCookies; var config bool bDebugLogging; var config bool bEnablePlayerCommands; var config bool bForceEvenTeams; var config bool bLetPlayersRebalance; var config int MinRequestsForRebalance; var int pidsRequestingRebalance[64]; var int lastRebalanceRequestTime; var config bool bOverrideMinRequests; var config bool bFlashRebalanceRequest; var config bool bShowProposedSwitch; var config bool bAutoBalanceTeamsForCTF; var config bool bAutoBalanceTeamsForTDM; var config bool bAutoBalanceTeamsForAS; var config bool bAutoBalanceTeamsForOtherTeamGames; var config bool bUpdatePlayerStatsForCTF; var config bool bUpdatePlayerStatsForTDM; var config bool bUpdatePlayerStatsForAS; var config bool bUpdatePlayerStatsForOtherTeamGames; var config bool bUpdatePlayerStatsForNonTeamGames; var config bool bBalanceBots; var config bool bRankBots; var config bool bAllowSemiAdminKick; var config bool bAllowSemiAdminForceTravel; var config String SemiAdminPass; var config bool bWarnMidGameUnbalance; var config int CheckFrequency; var config bool bShowReason; var config bool bFlashOnWarning; var config bool bShakeOnWarning; var config bool bBuzzOnWarning; var config bool bShakeWhenMoved; var config int MinSecondsBeforeRebalance; var config bool bNeverRebalanceWhenTeamsAreEven; var config bool bLogExtraStats; var config float MaxHoursWhenCopyingOldRecord; var config float HoursBeforeRecyclingStrength; var config int MinHumansForStats; var config int ScoringMethod; // 0=score, 1=frags, 2=average_frags_and_score, 3=0-100_ordered_ranking var config bool bNormaliseScores; // var config bool bRelativeNormalisation; var config float RelativeNormalisationProportion; var config float StrengthProportionFromCurrentGame; var config bool bScalePlayerScoreToFullTime; // Leave this true, more accurate this way var config int NormalisedStrength; var config int UnknownStrength; var config int BotStrength; var config int FlagStrength; var config int StrengthThreshold; var config int WinningTeamBonus; var config bool bClanWar; var config string clanTag; // var config bool bUseOnlyInGameScoresForRebalance; var config bool bLogFakenickers; var config bool bBroadcastFakenickers; var config bool bSeparateStatsByGamemode; var config bool bSeparateStatsByMutators; var config string LastUpdate; // For storing player strength data: var config int MaxPlayerData; var config String playerData[4096]; // String-format of the player data stored in the config (ini-file), including ip/nick/avg_score/time_played data // Internal (parsed) player data: var bool CopyConfigDone; // set to true after the arrays have been populated (so we don't do it twice) var String ip[4096]; // We could consider using instead the default struct Guid { var int A, B, C, D; }; var String nick[4096]; var float avg_score[4096]; var float hours_played[4096]; var String date_last_played[4096]; // var int games_played[MaxPlayerDataMax]; var float currentDateDays; // Used by FindOldestPlayerRecordMeasure(). // For local state caching (not repeating when called by Tick's or Timer's): var bool initialized; // Mutator initialized flag var bool gameStartDone; // Teams initialized flag (we never initialise this to False, but I guess Unreal does that for us) var bool gameEndDone; var int timeGameStarted; var int lastBalanceTime; var float averageGameScore; var float averagePlayerStrengthThisGame; var float LastCalculatedAverages; var Color colorWhite,colorRed,colorBlue,colorGreen,colorYellow,colorCyan,colorMagenta,colorOrange,colorGray,colorBlack; var config Color warnColor; defaultproperties { bBroadcastStuff=True bBroadcastCookies=False bFlashCookies=False bFlashPlayerJoins=True bReportStrengthAsCookies=False bDebugLogging=False bEnablePlayerCommands=True bForceEvenTeams=False bLetPlayersRebalance=True MinRequestsForRebalance=1 bOverrideMinRequests=True // MinRequestsForRebalancePercent=25 // or 25% of players request it. bFlashRebalanceRequest=True bShowProposedSwitch=True bWarnMidGameUnbalance=False CheckFrequency=15 // How often to check for mid-game imbalance, and flash the warning if neccessary, in seconds. Note: This is also how often ATB checks for the end of the game, so it must be less than the time your servers takes to move to the next map after the game ends, otherwise the stats will not be updated! bShowReason=True bFlashOnWarning=True bShakeOnWarning=False bBuzzOnWarning=False bShakeWhenMoved=False bAllowSemiAdminKick=True bAllowSemiAdminForceTravel=True bBalanceBots=False bRankBots=False MinSecondsBeforeRebalance=20 bNeverRebalanceWhenTeamsAreEven=False SemiAdminPass="defaults_to_admin_pass" bAutoBalanceTeamsForCTF=True bAutoBalanceTeamsForTDM=True bAutoBalanceTeamsForAS=True bAutoBalanceTeamsForOtherTeamGames=True bUpdatePlayerStatsForCTF=True bUpdatePlayerStatsForTDM=True bUpdatePlayerStatsForAS=True bUpdatePlayerStatsForOtherTeamGames=True bUpdatePlayerStatsForNonTeamGames=True bLogExtraStats=False MaxHoursWhenCopyingOldRecord=2.0 HoursBeforeRecyclingStrength=4.0 MinHumansForStats=4 ScoringMethod=2 bNormaliseScores=True // bRelativeNormalisation=True RelativeNormalisationProportion=0.5 // 0.0 = no relative, scores always normalised around NormalisedStrength (50); 1.0 = scores normalised around average strength of players in game StrengthProportionFromCurrentGame=0.5 bScalePlayerScoreToFullTime=True NormalisedStrength=50 UnknownStrength=50 BotStrength=10 FlagStrength=10 StrengthThreshold=100 WinningTeamBonus=0 bClanWar=False clanTag="XOL" // bUseOnlyInGameScoresForRebalance=False bLogFakenickers=False bBroadcastFakenickers=False bSeparateStatsByGamemode=False bSeparateStatsByMutators=False MaxPlayerData=4096 colorWhite=(R=255,G=255,B=255,A=32), colorRed=(R=255,G=32,B=32,A=32), colorBlue=(R=32,G=32,B=255,A=32), colorGreen=(R=32,G=255,B=32,A=32), colorYellow=(R=255,G=255,B=32,A=32), colorCyan=(R=32,G=255,B=255,A=32), colorMagenta=(R=255,G=32,B=255,A=32), colorOrange=(R=255,G=144,B=32,A=32), colorGray=(R=192,G=192,B=192,A=32), colorBlack=(R=0,G=0,B=0,A=32), warnColor=(R=200,G=160,B=0,A=24), } // #define NormalLog(X); if (bLogging) { Log(X); } // #define NormalLog(X) Log(X); // ==== Hooks or overrides - functions and events called externally: ==== // // Initialize the system function PostBeginPlay() { Super.PostBeginPlay(); if (initialized) { if (bDebugLogging) { Log(Self$".PostBeginPlay() called with initialized already true; quitting."); }; return; } if (bDebugLogging) { Log(Self$".PostBeginPlay() initialising"); }; // If AutoTeamBalance was installed as a ServerActor, we need to register it as a mutator: // AddMutator() will check that it is not already in the mutator chain. Level.Game.BaseMutator.AddMutator(Self); if (initialized) { if (bDebugLogging) { Log(Self$".PostBeginPlay() disabling self on request"); }; gameStartDone=True; // Disable('Tick'); return; } if (bDebugLogging) { Log(Self$".PostBeginPlay() added self as mutator"); }; // We always want to register as a messenger, so that players may type "!red" or "!blue" Level.Game.RegisterMessageMutator(Self); if (bDebugLogging) { Log(Self$".PostBeginPlay() registered self as messenger"); }; // This is how we detect the moment just before game-start (in CheckGameStart()), to do a final team balance: SetTimer(1,True); gameEndDone = false; // Kinda redundant, since it will have been default initialised to false anyway. CopyConfigIntoArrays(); // First time the data is needed, we must convert it. initialized = true; } // Implementation of AddMutator which prevents double or recursive adding: function AddMutator(Mutator Other) { if (bDebugLogging) { Log(Self$".AddMutator("$Other$") called."); }; if (Other != None && Other.Class == Self.Class) { if (Other == Self) { if (bDebugLogging) { Log(Self$".AddMutator("$Other$"): not adding mutator self again!"); }; } else { if (bDebugLogging) { Log(Self$".AddMutator("$Other$"): destroying other instance with "$Other$".Destroy()"); }; AutoTeamBalance(Other).initialized = true; // tell the other copy it should not initialize Other.Destroy(); // seems to do nothing useful; the mutator continues to run through PostBeginPlay(). } } else { Super.AddMutator(Other); } } // Timer is initially set at 1 second to detect the moment before game-start for ForceFullTeamsRebalance(). // Then it is set to CheckFrequency seconds during play, to detect mid-game unbalance, if bWarnMidGameUnbalance or bForceEvenTeams is set. // Also (after HandleEndGame() is called), it detects the real game end, and calls UpdateStatsAtEndOfGame(). event Timer() { if (!gameStartDone) CheckGameStart(); if (gameStartDone) CheckGameEnd(); if ((bWarnMidGameUnbalance || bForceEvenTeams) && gameStartDone && !gameEndDone && Level.Game.IsA('TeamGamePlus') && !DeathMatchPlus(Level.Game).bTournament) CheckMidGameBalance(); } // If a new player joins a game which has already started, this will send him to the most appropriate ("weaker") team (based on summed strength of each team, plus capbonuses). // This may cause a little lag on slow CPU servers when a new player joins, because it will search the whole database to find his record; if this is a problem, set bUseOnlyInGameScoresForRebalance. function ModifyLogin(out class SpawnClass, out string Portal, out string Options) { local int selectedTeam; local int teamSize[2]; local int teamSizeWithBots[2]; local int teamStr[2]; // each team's strength, only used if the #players on each team is equal local int teamnr; local String plname; local Pawn p; local TournamentGameReplicationInfo GRI; if (NextMutator!= None) NextMutator.ModifyLogin(SpawnClass, Portal, Options); if (!ShouldBalance(Level.Game)) return; if (bDebugLogging) { Log("AutoTeamBalance.ModifyLogin("$SpawnClass$","$Portal$",\""$Options$"\")"); }; // read this player's selected team selectedTeam=Level.Game.GetIntOption(Options,"Team",255); // get team scores GRI=TournamentGameReplicationInfo(Level.Game.GameReplicationInfo); teamStr[0]=GRI.Teams[0].Score*GetFlagStrength(); teamStr[1]=GRI.Teams[1].Score*GetFlagStrength(); teamSize[0]=0; teamSize[1]=0; teamSizeWithBots[0]=0; teamSizeWithBots[1]=0; // Check team balance of current players in game // Calculate sum of player strengths for each team (as well as the flagbonus above) for (p=Level.PawnList; p!=None; p=p.NextPawn) { // ignore non-player pawns if (p.bIsPlayer && !p.IsA('Spectator')) { teamnr=p.PlayerReplicationInfo.Team; if (teamnr<2) { // I changed this from Daniel's version, so that bot strengths are not considered. // Since a player is joining, one of the bots may leave, or switch team, so counting that bot's strength is inaccurate, and we don't know which bot it will be. So let's just count player strengths. if (!p.IsA('Bot')) { teamSize[teamnr]++; teamStr[teamnr] += GetPlayerStrength(p); } teamSizeWithBots[teamnr]++; } } } if (bClanWar) { // send player to his clan's team teamnr=0; plname=Level.Game.ParseOption(Options,"Name"); if (Instr(Caps(plname),Caps(clanTag))==-1) teamnr=1; } else { // if both teams have the same number of players send the new player to the weaker team if (teamSize[0]==teamSize[1]) { // teamnr=0; if (teamStr[0]>teamStr[1]) teamnr=1; teamnr=0; if (teamStr[0]>=teamStr[1]+Rand(2)) teamnr=1; if (bDebugLogging) { Log("AutoTeamBalance.ModifyLogin(): "$teamSize[0]$"v"$teamSize[1]$" and "$teamStr[0]$"v"$teamStr[1]$" so sending new player to WEAKER team "$getTeamName(teamnr)$"."); }; } else { // send player to the team with fewer players // teamnr=0; if (teamSize[0]>teamSize[1]) teamnr=1; teamnr=0; if (teamSize[0]>=teamSize[1]+Rand(2)) teamnr=1; if (bDebugLogging) { Log("AutoTeamBalance.ModifyLogin(): "$teamSize[0]$"v"$teamSize[1]$" so sending new player to SMALLER team "$getTeamName(teamnr)$"."); }; } } // if selected team does not equal forced team then modify login if (teamnr!=selectedTeam) Options="?Team=" $ teamnr $ Options; FixTeamsizeBug(); } function FixTeamsizeBug() { local TournamentGameReplicationInfo GRI; local Pawn p; local int teamnr; local int teamSizeWithBots[2]; GRI=TournamentGameReplicationInfo(Level.Game.GameReplicationInfo); for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.bIsPlayer && !p.IsA('Spectator')) { teamnr=p.PlayerReplicationInfo.Team; if (teamnr<2) { teamSizeWithBots[teamnr]++; } } } // Fix teamsize bug in Botpack.TeamGamePlus if (GRI.Teams[0].Size!=teamSizeWithBots[0] || GRI.Teams[1].Size!=teamSizeWithBots[1]) { if (bDebugLogging) { Log("AutoTeamBalance.FixTeamsizeBug(): Fixing team size (" $ GRI.Teams[0].Size $ "," $ GRI.Teams[1].Size $ ") should be (" $ teamSizeWithBots[0] $ "," $ teamSizeWithBots[1] $ ")"); }; GRI.Teams[0].Size=teamSizeWithBots[0]; GRI.Teams[1].Size=teamSizeWithBots[1]; } } // We use MutatorTeamMessage and MutatorBroadcastMessage to catch messages said by players and spectators respectively. // Catch messages from spectators: function bool MutatorBroadcastMessage(Actor Sender, Pawn Receiver, out coerce string Msg, optional bool bBeep, out optional name Type) { // Not working here. if (bFlashPlayerJoins) { if (StrContains(Msg," has joined ")) { ClearAllProgressMessages(); FlashToAllPlayers("Someone has joined the game!",warnColor,2); } if (StrContains(Msg," has left ")) { ClearAllProgressMessages(); FlashToAllPlayers("Someone has left the game!",warnColor,2); } } // Swallow lines containing the semi-admin pass: if (StrContains(Caps(Msg),Caps(GetEffectiveSemiAdminPass()))) return False; if (Sender == Receiver && Sender.IsA('Spectator')) { // Only process the message once. if (bDebugLogging) { Log("AutoTeamBalance.MutatorBroadcastMessage() Checking ("$Sender.getHumanName()$") "$Msg$""); }; // Spectator messages start with the extra ":". We remove this. CheckMessage(Mid(Msg,InStr(Msg,":")+1), Receiver); } return Super.MutatorBroadcastMessage(Sender,Receiver,Msg,bBeep,Type); } // Catch messages from players: function bool MutatorTeamMessage(Actor Sender, Pawn Receiver, PlayerReplicationInfo PRI, coerce string Msg, name Type, optional bool bBeep) { // Swallow lines containing the semi-admin pass: if (StrContains(Caps(Msg),Caps(GetEffectiveSemiAdminPass()))) return False; if (Sender == Receiver) { // Only process the message once. if (bDebugLogging) { Log("AutoTeamBalance.MutatorTeamMessage() Checking ("$Sender.getHumanName()$") "$Msg$""); }; CheckMessage(Msg, Receiver); } return Super.MutatorTeamMessage(Sender,Receiver,PRI,Msg,Type,bBeep); } function ShowStatsTo(PlayerPawn Sender) { local int i; local Pawn p; Sender.ClientMessage("Team | Name | IP | Ping | PktLoss | Strength | Hours | Last | Score | Frags | Deaths | Items | Spree | Secret | Time"); for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (!p.IsA('Spectator') && AllowedToRank(p)) { i = FindPlayerRecord(p); Sender.ClientMessage(""$p.PlayerReplicationInfo.Team$" | "$p.getHumanName()$" | "$getIP(p)$" | "$p.PlayerReplicationInfo.Ping$" | "$p.PlayerReplicationInfo.PacketLoss$" | "$Int(avg_score[i])$" | "$Int(hours_played[i])$" | "$date_last_played[i]$" | "$Int(p.PlayerReplicationInfo.Score)$" | "$p.KillCount$" | "$Int(p.PlayerReplicationInfo.Deaths)$" | "$p.ItemCount$" | "$p.Spree$" | "$p.SecretCount$" | "$Int(Level.TimeSeconds - p.PlayerReplicationInfo.StartTime)$""); } } } function ShowStrengthsTo(PlayerPawn Sender,bool bExtra) { local Pawn p; local int team; local int i; local float playerGameStrength,deltaStrength; local string deltaStrengthStr; local string redBonus,blueBonus; if (bExtra) deltaStrengthStr = "(+/-) GameStrength UsedStrength "; Sender.ClientMessage("[Team] Strength "$deltaStrengthStr$"| Name | Time"); GetAveragesThisGame(); for (team=0;team<2;team++) { for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (AllowedToBalance(p) && p.PlayerReplicationInfo.Team == team) { i = FindPlayerRecord(p); if (i > -1) { // actually it's guaranteed to be > -1 // Sender.ClientMessage("["$getTeamName(p.PlayerReplicationInfo.Team)$"] "$p.getHumanName()$" has strength "$Int(avg_score[i])$" after "$Left(""$hours_played[i],5)$" hours."); if (bExtra) { playerGameStrength = NormaliseScore(GetScoreForPlayer(p)); // playerGameStrength = averagePlayerStrengthThisGame; deltaStrength = playerGameStrength - avg_score[i]; deltaStrengthStr = ""$Int(deltaStrength+0.5); if (deltaStrength>0) deltaStrengthStr = "+"$deltaStrengthStr; deltaStrengthStr = "(" $ deltaStrengthStr $ ") " $ Int(playerGameStrength) $ " " $ Int(GetPlayerStrength(p)) $ " "; } Sender.ClientMessage("["$getTeamName(p.PlayerReplicationInfo.Team)$"] "$Int(avg_score[i])$" "$deltaStrengthStr$"| "$p.getHumanName()$" | "$Left(""$hours_played[i],4)$" hours"); } } } } if (GetFlagStrengthForTeam(0) > 0) redBonus = " + " $ Int(GetFlagStrengthForTeam(0)); if (GetFlagStrengthForTeam(1) > 0) blueBonus = " + " $ Int(GetFlagStrengthForTeam(1)); Sender.ClientMessage("| Red team strength is "$Int(GetTeamStrengthNoFlagStrength(0))$redBonus$", Blue team strength is "$Int(GetTeamStrengthNoFlagStrength(1))$blueBonus$" (difference "$Int(GetTeamStrength(1)-GetTeamStrength(0))$")."); Sender.ClientMessage("| Average strength is "$Left(""$averagePlayerStrengthThisGame,4)$" ("$Left(""$FloatWeUseForAverageGameStrength(),4)$"), teamscore bonus is "$Int(GetFlagStrength())$"."); } function ListMutsTo(PlayerPawn Sender) { local Mutator m; local String s; m = Level.Game.BaseMutator; while (m != None) { s = s $ m.Class.Name; m = m.NextMutator; if (m != None) s = s $ ", "; } Sender.ClientMessage("Mutators are: "$s); } function String GetEffectiveSemiAdminPass() { if (SemiAdminPass == "defaults_to_admin_pass") return ConsoleCommand("get engine.gameinfo AdminPassword"); else return SemiAdminPass; } // Catch mutate messages (from players, semi-admins or admins) function Mutate(String str, PlayerPawn Sender) { local String args[256]; // local array args; local int argcount; local String localPass; // the password we will require for semi-admin commands local String pass_if_needed; // for the help (to display whether pass is needed or not) // temporary utility vars local String msg; local int i; local Pawn p; local bool bTempBool; if (bDebugLogging) { Log("AutoTeamBalance.Mutate("$str$","$sender$") was called."); }; localPass = GetEffectiveSemiAdminPass(); // Decide now how we will handle the password later (if a password is even required): if (Sender.bAdmin) localPass = ""; // any or no pass is accepted argcount = SplitString(str," ",args); // Commands which do not require a password: if ( args[0]~="STRENGTHS" || args[0]~="STRENGTH" ) { ShowStrengthsTo(Sender, (args[1] ~= "EXTRA")); } if ( args[0]~="STATS" ) { ShowStatsTo(Sender); } if ( args[0]~="LISTMUTS" || args[0]~="LISTMUTATORS" ) { ListMutsTo(Sender); } // Commands which do require the password: if (localPass=="" || args[argcount-1]~=localPass) { // Semi-admin privilege commands: switch ( Caps(args[0]) ) { case "TEAMS": if (!Level.Game.GameReplicationInfo.bTeamGame) { Sender.ClientMessage("AutoTeamBalance cannot balance teams: this isn't a team game!"); } else { MidGameRebalance(True); } break; case "FORCETEAMS": // Sender.ClientMessage("AutoTeamBalance performing full teams rebalance..."); // if (bBroadcastStuff) { BroadcastMessageAndLog(Sender.getHumanName()$" has forced a full teams rebalance."); } // To make this balance as accurate as possible, we update the stats now, so we can use the scores from this game so-far. // But since this would mess up the end-game stats updating (counting this part of the game twice), we restore the stats from the config afterwards. UpdateStatsAtEndOfGame(); ForceFullTeamsRebalance(); CopyConfigIntoArrays(); break; case "TORED": // if (bBroadcastStuff) { BroadcastMessageAndLog(Sender.getHumanName()$" is trying to fix the teams."); } ChangePlayerToTeam(FindPlayerNamed(args[1]),0,true); // Sender.ClientMessage("Red team strength is now "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); BroadcastTeamStrengths(); break; case "TOBLUE": // if (bBroadcastStuff) { BroadcastMessageAndLog(Sender.getHumanName()$" is trying to fix the teams."); } ChangePlayerToTeam(FindPlayerNamed(args[1]),1,true); // Sender.ClientMessage("Red team strength is now "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); BroadcastTeamStrengths(); break; case "TOGREEN": ChangePlayerToTeam(FindPlayerNamed(args[1]),2,true); // Sender.ClientMessage("Red team strength is now "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); BroadcastTeamStrengths(); break; case "TOGOLD": ChangePlayerToTeam(FindPlayerNamed(args[1]),3,true); // Sender.ClientMessage("Red team strength is now "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); BroadcastTeamStrengths(); break; case "SWITCH": SwitchTwoPlayers(Sender,args[1],args[2]); break; case "SWAP": SwitchTwoPlayers(Sender,args[1],args[2]); break; case "WARN": msg=""; for (i=2;i ) case "SET": ConsoleCommand("set " $ args[1] $ " " $ args[2] $ " " $ args[3]); Sender.ClientMessage( args[1] $ ":" $ args[2] $ " = " $ ConsoleCommand("get " $ args[1] $ " " $ args[2]) ); break; case "GETPROP": Sender.ClientMessage( args[1] $ " = " $ GetPropertyText(args[1]) ); break; // Allows admins to write to in-game variables case "SETPROP": SetPropertyText(args[1],args[2]); Sender.ClientMessage( args[1] $ " = " $ GetPropertyText(args[1]) ); Sender.ClientMessage(args[1] $ " = " $ ConsoleCommand("get " $ args[1] $ " " $ args[2])); // read it back to the user, to check it worked break; // Allows admins to run any console command on the server case "CONSOLE": msg=""; for (i=2;i" $ pass_if_needed); Sender.ClientMessage(" mutate toblue " $ pass_if_needed); Sender.ClientMessage(" mutate switch " $ pass_if_needed); Sender.ClientMessage(" mutate flash " $ pass_if_needed); Sender.ClientMessage(" mutate warn " $ pass_if_needed); } else { Sender.ClientMessage(" mutate help []"); } if (bAllowSemiAdminKick) { Sender.ClientMessage(" mutate kick []" $ pass_if_needed); Sender.ClientMessage(" mutate kickban []" $ pass_if_needed); } if (bAllowSemiAdminForceTravel) { Sender.ClientMessage(" mutate forcetravel " $ pass_if_needed); } if (Sender.bAdmin) { Sender.ClientMessage("AutoTeamBalance "$ "1.4" $" admin-only console commands:"); Sender.ClientMessage(" mutate saveconfig"); Sender.ClientMessage(" mutate grantadmin "); Sender.ClientMessage(" mutate get "); Sender.ClientMessage(" mutate set "); Sender.ClientMessage(" mutate getprop "); Sender.ClientMessage(" mutate setprop "); Sender.ClientMessage(" mutate console "); } } Super.Mutate(str,Sender); } function SwitchTwoPlayers(PlayerPawn sender, String name1, String name2) { local Pawn player1, player2; local int newteam1, newteam2; player1 = FindPlayerNamed(name1); player2 = FindPlayerNamed(name2); if (player1 == None) { Sender.ClientMessage("Could not find player matching \""$name1$"\"."); return; } if (player2 == None) { Sender.ClientMessage("Could not find player matching \""$name2$"\"."); return; } if (player1.PlayerReplicationInfo.Team == player2.PlayerReplicationInfo.Team) { Sender.ClientMessage("Players \""$player1.getHumanName()$"\" and \""$player2.getHumanName()$"\" are on the same team!"); return; } newteam1 = player2.PlayerReplicationInfo.Team; newteam2 = player1.PlayerReplicationInfo.Team; ChangePlayerToTeam(player1,newteam1,true); ChangePlayerToTeam(player2,newteam2,true); BroadcastTeamStrengths(); } function ToggleAdminOnPlayer(Pawn p) { local PlayerPawn player; if (p!=None && p.IsA('PlayerPawn')) { player = PlayerPawn(p); player.bAdmin = !player.bAdmin; player.PlayerReplicationInfo.bAdmin = player.bAdmin; } } // HandleEndGame gets called when the game time limit expires, BUT the game may go into overtime without us knowing (one of the earlier mutators, or the gametype itself, might decide this). // So at this point I set a Timer to check in CheckFrequency seconds whether the game really has ended or not. // DONE: if not needed for bWarnMidGameUnbalance or bForceEvenTeams, the timer is disabled after one check, then we wait for this function to get called again before it is started again. function bool HandleEndGame() { local bool b; SetTimer(CheckFrequency,bWarnMidGameUnbalance || bForceEvenTeams); // only loop if we need to check team balance during overtime; if we are only looking for the real end-game, then we only need to use the timer once more if (bDebugLogging) { Log("AutoTeamBalance.HandleEndGame(): Set Timer() for "$CheckFrequency$" seconds. [bOverTime="$Level.Game.bOverTime$",bGameEnded="$Level.Game.bGameEnded$"]"); }; if ( NextMutator != None ) { b = NextMutator.HandleEndGame(); return b; } return false; } // =========== Our State Model =========== // // Checks if the game has begun. function CheckGameStart() { local int c,n,e; local Pawn p; // We can disable the timer immediately, if AutoTeamBalance is not needed for this game. // If we are going to balance, then the timer waits until 2 seconds before the game starts. // If we are going to update stats, we need to record the time the game actually started at, so we wait the same way. if (!ShouldBalance(Level.Game) && !ShouldUpdateStats(Level.Game)) { // We do this early, to check at the very least that this is a teamgame, to avoid accessed none's below DoGameStart(); return; } // TODO BUG: if bUpdatePlayerStatsForNonTeamGames is enabled, then on DM maps, we reach here and throw some Accessed None errors. // But we still want the game start-time. e = TeamGamePlus(Level.Game).ElapsedTime; n = TeamGamePlus(Level.Game).NetWait; c = TeamGamePlus(Level.Game).countdown; c = Min(c,n-e); // DebugLog("c="$c$" n-e="$(n-e)$" e="$e$" n="$n$" p="$p); // Initialize teams 1 or 2 seconds before the game starts: if (c<2) { DoGameStart(); } else { if (!DeathMatchPlus(Level.Game).bTournament) { // We don't flash during tournament mode, because it flashes all the way through warmup! FlashPreGameLines(); } } } function FlashPreGameLines() { local int targetLine; local Pawn p; // Override the line which says what team each player is on (since teams have not yet been decided!): // Line 3 usually displays "You are on the Red/Blue team" before the game starts. // But since we won't balance teams until 2 seconds before game start, we want to overwrite line 3. // We also overwrite line 4, which usually displays "Use Options -> Player Setup to change teams". for (p=Level.PawnList; p!=None; p=p.NextPawn) { // The check for UTServer Avoids logging repeated calls to UTServerAdminSpectator before anyone has joined the server. if (p.IsA('PlayerPawn') && !p.IsA('Spectator') && InStr(String(p.class),"UTServer")==-1) { /* // Only override the line, iff that line is currently displaying the player's team prematurely. (Avoid conflicting with XOL's pre-game hiscore display.) // Does not work! if (StrContains(PlayerPawn(p).ProgressMessage[3],"You") || StrContains(PlayerPawn(p).ProgressMessage[2],"You") || StrContains(PlayerPawn(p).ProgressMessage[1],"You are on ") || StrContains(PlayerPawn(p).ProgressMessage[5],"You") || StrContains(PlayerPawn(p).ProgressMessage[4],"You") ) { */ // Different game types use a different line to display which team you are "on": // So far I have only checked CTF and Assault. targetLine = 5; if (String(Level.Game.Class) == "Botpack.CTFGame") targetLine = 3; if (String(Level.Game.Class) == "Botpack.Assault") targetLine = 2; // // #define LINENR_FOR_FLASH 5 // #define LINENR_FOR_FLASH Int(ConditionalString(level.timeseconds < 8, "3", "5")) // #else // #define LINENR_FOR_FLASH 3 // #endif if (bFlashCookies) { if (bReportStrengthAsCookies) FlashMessageToPlayer(PlayerPawn(p), p.getHumanName() $", you have "$ Int(GetRecordedPlayerStrength(p)) $" cookies.",colorOrange,targetLine); else FlashMessageToPlayer(PlayerPawn(p), p.getHumanName() $" you have strength "$ Int(GetRecordedPlayerStrength(p)) $"",colorOrange,targetLine); } else { FlashMessageToPlayer(PlayerPawn(p),"Teams not yet assigned.",colorWhite,targetLine); // colMagenta // FlashMessageToPlayer(PlayerPawn(p),"Assigning teams in "$Max(c-1,n-e-1),colorMagenta,3); } if (String(Level.Game.Class) == "Botpack.CTFGame" || String(Level.Game.Class) == "Botpack.Assault") { // This clears the line which says "Use Options -> Player Setup to change team." // FlashMessageToPlayer(PlayerPawn(p),"Type !teams if they become uneven.",colorWhite,(targetLine+1)); // colMagenta FlashMessageToPlayer(PlayerPawn(p),"",colorWhite,(targetLine+1)); // colMagenta } // #undef LINENR_FOR_FLASH // } } } } function DoGameStart() { local Pawn p; local Color msgColor; timeGameStarted = Level.TimeSeconds+1.5; // (since we are called on average 1.5 seconds before starting countdown ends) if (ShouldBalance(Level.Game)) { //// We could also do this once or twice *after* the ForceFullTeamsRebalance(), to make teams really even by strength (not pickup style). ForceFullTeamsRebalance(); // (This must come after the team switching, otherwise the default start-game "xxx is on Red" will overwrite this text.) // TODO CONSIDER BUG: isn't it more important that the player sees which team they were moved to?! for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator')) { // PlayerPawn(p).ClearProgressMessages(); // Clear the pre-game messages before showing new team and cookies. switch (p.PlayerReplicationInfo.Team) { case 0: msgColor = colorRed; break; case 1: msgColor = colorBlue; break; case 2: msgColor = colorGreen; break; case 3: msgColor = colorYellow; break; default: msgColor = colorWhite; break; } // PlayerPawn(p).ClearProgressMessages(); // But on XOL, when the game does start, line 3 is used to display Highest # covers. So on XOL, we use line 5. // #undef LINENR_FOR_FLASH // #define LINENR_FOR_FLASH -1 // TODO CONSIDER: PlayerPawn(p).ClearProgressMessages(); FlashMessageToPlayer(PlayerPawn(p),"You are on the "$Caps(getTeamName(p.PlayerReplicationInfo.Team))$" team.",msgColor,3); } } // BroadcastMessage("",False); // if (bBroadcastStuff) { BroadcastMessageAndLog("Red team strength is "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); } } gameStartDone=True; // Should ensure CheckGameStart() is never called again. // Disable('Tick'); // We disable the timer, if it is not needed to check mid-game teambalance. // HandleEndGame() will set it again, if it is needed for CheckGameEnd(). if (bWarnMidGameUnbalance || bForceEvenTeams) { SetTimer(CheckFrequency,True); } else { SetTimer(0,False); } } // Deals with mid-game team unbalance, only called if bForceEvenTeams and/or bWarnMidGameUnbalance are set. function CheckMidGameBalance() { local int redTeamCount,blueTeamCount; local int redTeamStrength,blueTeamStrength; local int weakerTeam; local String problem; // human-readable explanation of the team unbalance local Pawn p; local int i; weakerTeam = -1; redTeamCount = GetTeamSize(0); blueTeamCount = GetTeamSize(1); // Is one of the teams down 2 or more players? if (redTeamCount>=blueTeamCount+2) { weakerTeam = 1; problem = " "$redTeamCount$"v"$blueTeamCount$"."; } if (redTeamCount<=blueTeamCount-2) { weakerTeam = 0; problem = " "$redTeamCount$"v"$blueTeamCount$"."; } // If so, and bForceEvenTeams is set, then take action! if (bForceEvenTeams && weakerTeam != -1) { MidGameRebalance(True); return; // DONE: bForceEvenTeams does *not* take action if the teams differ by less than 2 players. But maybe it should, if they are really unfair by strength! -- Nee leave that for bWarnMidGameUnbalance } // Do we want to warn players of any imbalance? if (bWarnMidGameUnbalance) { if (weakerTeam == -1 && redTeamCount+blueTeamCount>=3) { // no point checking this on a 1v1 ;) if (redTeamCount == blueTeamCount && bNeverRebalanceWhenTeamsAreEven) { return; } // So teams differ by <2 players. Now calculate which team is weaker, and check if that team has fewer players: redTeamStrength = GetTeamStrength(0); blueTeamStrength = GetTeamStrength(1); if (redTeamCount>=blueTeamCount && redTeamStrength>blueTeamStrength+StrengthThreshold) { weakerTeam = 1; problem = " Strength "$redTeamStrength$" v "$blueTeamStrength$"."; } if (redTeamCount<=blueTeamCount && blueTeamStrength>redTeamStrength+StrengthThreshold) { weakerTeam = 0; problem = " Strength "$redTeamStrength$" v "$blueTeamStrength$"."; } } if (weakerTeam == -1) { return; } if (!bShowReason) problem = ""; // Send all players the team imbalance warning: if (bLetPlayersRebalance && bShowProposedSwitch) { // OK now we suggest who to move: MidGameRebalance(False); // Note: this will clear the progress messages, which is why we do it first. // The suggestion to fix teams requires justification: if (bShowReason && problem != "") { if (bFlashRebalanceRequest) { FlashToAllPlayers("Teams look uneven!"$problem,warnColor,6); } else { BroadcastMessageAndLog("Teams look uneven!"$problem); } } } else { for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator')) { // Players on different teams get slightly different messages: if (p.PlayerReplicationInfo.Team == weakerTeam) { // Weaker team: if (bLetPlayersRebalance) { if (bFlashOnWarning) { PlayerPawn(p).ClearProgressMessages(); FlashMessageToPlayer(PlayerPawn(p),"Teams look uneven!"$problem$" Type !teams to fix them",warnColor,6); } else { p.ClientMessage("Teams look uneven!"$problem$" Type !teams to fix them",'Event',False); } } } else { // Stronger team: if (bFlashOnWarning) { PlayerPawn(p).ClearProgressMessages(); FlashMessageToPlayer(PlayerPawn(p),"Teams look uneven!"$problem$" Type "$ConditionalString(bLetPlayersRebalance,"!teams or ","")$"!"$Locs(getTeamName(weakerTeam))$"",warnColor,6); } else { p.ClientMessage("Teams look uneven!"$problem$" Type "$ConditionalString(bLetPlayersRebalance,"!teams or ","")$"!"$Locs(getTeamName(weakerTeam))$"",'Event',False); } // We may "punish" the stronger team, by shaking their view, or sending them a buzzing sound: if (bShakeOnWarning) { p.ShakeView(1.0,2000.0,2000.0); } if (bBuzzOnWarning) { p.PlaySound(sound'FlyBuzz', SLOT_Interface, 2.5, False, 32, 16); // an annoying buzzing fly sound } } } } } } } // Before we flash new progress messages, we need to clear what was there before. // On XOL this is the hiscore records. // But on other servers in general, you will see the message // "The match has begun". function ClearAllProgressMessages() { local Pawn p; // local int i; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator')) { PlayerPawn(p).ClearProgressMessages(); } } // for (i=0;i<8;i++) { // FlashToAllPlayers(" ",colorWhite,i); // } } function String ConditionalString(bool b, String yes, String no) { if (b) { return yes; } else { return no; } } function name ConditionalName(bool b, name yes, name no) { if (b) { return yes; } else { return no; } } function CheckGameEnd() { if (Level.Game.bGameEnded) { if (gameEndDone) return; gameEndDone = true; // We could (but don't) turn the Timer off now if (ShouldUpdateStats(Level.Game)) { UpdateStatsAtEndOfGame(); CopyArraysIntoConfig(); SaveConfig(); } } } function bool CheckMessage(String Msg, Pawn Sender) { if (bEnablePlayerCommands) { if (Sender.IsA('PlayerPawn') && !Sender.IsA('Spectator')) { if (Msg ~= "!RED") { ChangePlayerToTeam(PlayerPawn(Sender),0,false); BroadcastTeamStrengths(); } if (Msg ~= "!BLUE") { ChangePlayerToTeam(PlayerPawn(Sender),1,false); BroadcastTeamStrengths(); } if (Msg ~= "!GREEN") { ChangePlayerToTeam(PlayerPawn(Sender),2,false); BroadcastTeamStrengths(); } if (Msg ~= "!GOLD" || Msg ~= "!YELLOW") { ChangePlayerToTeam(PlayerPawn(Sender),3,false); BroadcastTeamStrengths(); } } // Somewhere around here, XOL was giving Accessed None. if (Sender.IsA('PlayerPawn') && !Sender.IsA('Spectator')) { if (Msg ~= "!SPEC" || Msg ~= "!SPECTATE") { PlayerPawn(Sender).PreClientTravel(); // not sure if this is actually needed PlayerPawn(Sender).ClientTravel("?OverrideClass=Botpack.CHSpectator",TRAVEL_Relative, False); } } if (Sender.IsA('Spectator')) { if (Msg ~= "!PLAY") { PlayerPawn(Sender).PreClientTravel(); // not sure if this is actually needed PlayerPawn(Sender).ClientTravel("?OverrideClass=",TRAVEL_Relative, False); } } if (Msg ~= "!VOTE" || Msg ~= "!MAPVOTE") { Level.Game.BaseMutator.Mutate("bdbmapvote votemenu",PlayerPawn(Sender)); } if (Msg ~= "!STATS") { Level.Game.BaseMutator.Mutate("smartctf stats",PlayerPawn(Sender)); // TODO: Turn on console (toggle console if not on)? ShowStrengthsTo(PlayerPawn(Sender),False); } } if (Msg ~= "TEAMS" || Msg ~= "!TEAMS") { if (bLetPlayersRebalance && !DeathMatchPlus(Level.Game).bTournament) { if (bDebugLogging) { Log("AutoTeamBalance.MutatorTeamMessage(): Calling RequestMidGameRebalance()."); }; RequestMidGameRebalance(PlayerPawn(Sender)); } } // #ifdef TESTING if (StrStartsWith(Locs(Msg),"!MUTATE ")) { PlayerPawn(Sender).Mutate(StrAfter(Msg," ")); } // #endif } // =========== Balancing Algorithms =========== // // Also see ModifyLogin() above, for the decision of which team to send a player to when they join a running game. // Balance the teams just before the start of a new game. No need for FlagStrength here. // It can also be forced by a semi-admin mid-game, using "mutate forceteams". // In this case, it doesn't check which players are holding flags. function ForceFullTeamsRebalance() { local Pawn p; local int st; local int pid; local Pawn pl[64]; // hashmap of playerpawns, with i = PlayerID%64 local int ps[64]; // their strengths local int moved[64]; // so 0=false 1=true :P local int plorder[32]; local int i; local int n; local int mx; local int teamnr; local int teamstr[2]; local TeamGamePlus g; // my linux ucc make had trouble with TeamGamePlus :| local int oldMaxTeamSize; local bool oldbPlayersBalanceTeams, oldbNoTeamChanges; // We can't balance if it's not a teamgame if (!Level.Game.GameReplicationInfo.bTeamGame) return; if (bDebugLogging) { Log("AutoTeamBalance.ForceFullTeamsRebalance(): Running..."); }; if (bBroadcastStuff) { BroadcastMessageAndLog("AutoTeamBalance is attempting to balance the teams..."); } // rate all players, and put them in a temporary structure (pl[],ps[]): for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (AllowedToBalance(p)) { st=GetPlayerStrength(p); pid=p.PlayerReplicationInfo.PlayerID % 64; pl[pid]=p; ps[pid]=st; moved[pid] = 0; if (bDebugLogging) { Log("AutoTeamBalance.ForceFullTeamsRebalance(): Player " $ p.getHumanName() $ " on team " $ p.PlayerReplicationInfo.Team $ " has db-key " $ GetDBName(p) $ " and score " $ p.PlayerReplicationInfo.Score $ "."); }; } } // sort players by strength (move them out of the structure, into plorder[]) n=0; do { pid=-1; mx=0; // find pid=i with max tg[i] for (i=0; i<64; i++) { // Is this the strongest not-yet-moved player in this cycle? if ( pl[i] != None && moved[i]==0 && (pid == -1 || ps[i]>mx) ) { pid=i; mx=ps[i]; } } // If we found one, add him as the next player in the list if (pid != -1) { plorder[n]=pid; // ps[pid]=0; moved[pid] = 1; n++; if (bDebugLogging) { Log("AutoTeamBalance.ForceFullTeamsRebalance(): [Ranking] "$ps[pid]$" "$ pl[pid].getHumanName() $""); }; } } until (pid==-1); // save team changing rules before we override them g=TeamGamePlus(Level.Game); oldMaxTeamSize=g.MaxTeamSize; oldbPlayersBalanceTeams=g.bPlayersBalanceTeams; oldbNoTeamChanges=g.bNoTeamChanges; // deactivate team changing rules g.MaxTeamSize=32; g.bPlayersBalanceTeams=False; g.bNoTeamChanges=False; if (bClanWar) { // rebuild teams by clan tags teamstr[0]=0; teamstr[1]=0; for (i=0; i=teamstr[1]+Rand(2)) teamnr=1; if (bDebugLogging) { Log("AutoTeamBalance.ForceFullTeamsRebalance(): "$n$" is odd so sending last player to WEAKER team "$teamnr$"."); }; ChangePlayerToTeam(pl[pid],teamnr,gameStartDone); teamstr[teamnr]+=ps[pid]; } } // restore team changing rules g.MaxTeamSize=oldMaxTeamSize; g.bPlayersBalanceTeams=oldbPlayersBalanceTeams; g.bNoTeamChanges=oldbNoTeamChanges; // Show team strengths to all players // if (bBroadcastStuff) { BroadcastMessageAndLog("Red team strength is " $ teamstr[0] $ ". Blue team strength is " $ teamstr[1] $ "."); } // if (bBroadcastStuff) { BroadcastMessageAndLog("Red team strength is "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); } BroadcastTeamStrengths(); FixTeamsizeBug(); } function BroadcastTeamStrengths() { if (bBroadcastStuff) { BroadcastMessageAndLog("Red team strength is "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); } } // TODO: There's little point asking for additional "!teams" requests, if the algorithm will refuse to move any players anyway! (Well, this is DONE if bShowProposedSwitch=True.) // TODO/DONE?: Also, it asks for additional requests, when bWarnMidGameUnbalance is flashing - it shouldn't! Well this is DONE if the flashing is caused by #players, but not if it's caused by strength imbalance. function RequestMidGameRebalance(PlayerPawn Sender) { local int i; local int countRequests; local int additionalRequiredRequests; local Pawn p; local string s; // If the last request was a long time ago (>1 minute), reset the request list // Refuse to balance teams more than once every MinSecondsBeforeRebalance seconds: // This also fixed the bug that (I think) if the player who said "!teams" was switched, a second call to MutatorTeamMessage was made, and MidGameRebalance was getting called again. if (/*MinRequestsForRebalance<2 &&*/ lastBalanceTime + MinSecondsBeforeRebalance > Level.TimeSeconds) { // DebugLog("MidGameRebalance() refusing to rebalance since lastBalanceTime="$lastBalanceTime$" is too close to current time "$Level.TimeSeconds); BroadcastMessageAndLog("AutoTeamBalance refuses to rebalance teams again so soon."); return; } if (lastRebalanceRequestTime < Level.TimeSeconds - 60) { for (i=0;i<64;i++) { pidsRequestingRebalance[i] = 0; } } // Set that this player is requesting balance pidsRequestingRebalance[Sender.PlayerReplicationInfo.PlayerID] = 1; // Count the number of requests at this time countRequests = 0; for (i=0;i<64;i++) { if (pidsRequestingRebalance[i] != 0) { countRequests++; } } // Work out how many more requests are needed additionalRequiredRequests = MinRequestsForRebalance - countRequests; // But if teams differ in size by 2 or more players, only one request to rebalance is needed: // TODO: we could also require only 1 request if the stronger team has more players if (bOverrideMinRequests && Abs(GetTeamSize(0)-GetTeamSize(1))>=2) { additionalRequiredRequests = 0; } // Decide what to do if (additionalRequiredRequests <= 0) { MidGameRebalance(True); lastRebalanceRequestTime = -60; // Will force a reset the next time we are called } else { if (bShowProposedSwitch) { MidGameRebalance(False); // This will send a message } else { if (additionalRequiredRequests==1) { s=""; } else { s="s"; } if (bFlashRebalanceRequest) { for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator') && !p.IsA('Bot')) { PlayerPawn(p).ClearProgressMessages(); FlashMessageToPlayer(PlayerPawn(p),""$additionalRequiredRequests$" more player"$s$" must type !teams for rebalance.",warnColor,7); } } } else { // BroadcastMessageAndLog("I require "$additionalRequiredRequests$" more requests before I will rebalance the teams. Say \"!teams\" if you agree."); BroadcastMessageAndLog(""$additionalRequiredRequests$" more player"$s$" must type !teams for rebalance."); } } lastRebalanceRequestTime = Level.TimeSeconds; } // After a request for rebalance, whether changes were made or not, show current team strengths to all players. // if (bBroadcastStuff) { BroadcastMessageAndLog("Red team strength is "$Int(GetTeamStrength(0))$", Blue team strength is "$Int(GetTeamStrength(1))$"."); } BroadcastTeamStrengths(); } // If bDo=False, then instead of performing the change, it will instead call ProposeChange() which will message all players to suggest they type "!teams" to make the change happen. function MidGameRebalance(bool bDo) { local int redTeamCount,blueTeamCount; if (!Level.Game.IsA('TeamGamePlus') || !Level.Game.bTeamGame) return; if (bDo) { lastBalanceTime = Level.TimeSeconds; } if (!bDo) { ClearAllProgressMessages(); } redTeamCount = GetTeamSize(0); blueTeamCount = GetTeamSize(1); // We assume bot skills are pretty much irrelevant, and the bots will auto-switch to balance teams after we move any players around. if (bDebugLogging) { Log("MidGameRebalance() "$redTeamCount$" v "$blueTeamCount$""); }; // TODO: what if redTeamCount << blueTeamCount ? e.g. it's 6v2 so we need to move two players. we could balance in a while loop if it's guaranteed to end - although the system should really be changed entirely, since it tries to balance strengths on the first switch, it will be harder to keep them balanced on the second switch. if (redTeamCount < blueTeamCount) { MidGameTeamBalanceSwitchOnePlayer(bDo,1,0); } else if (blueTeamCount < redTeamCount) { MidGameTeamBalanceSwitchOnePlayer(bDo,0,1); } else if (!bNeverRebalanceWhenTeamsAreEven) { MidGameTeamBalanceSwitchTwoPlayers(bDo); } } function bool MidGameTeamBalanceSwitchOnePlayer(bool bDo, int fromTeam, int toTeam) { local float fromTeamStrength, toTeamStrength, currentDifference, playerStrength; local Pawn p; local Pawn closestPlayer; // the most ideal potential player to switch local float newDifference; // the absolute strength difference between the two teams after the potential switch local int playerCountDifference; fromTeamStrength = GetTeamStrength(fromTeam); toTeamStrength = GetTeamStrength(toTeam); currentDifference = fromTeamStrength - toTeamStrength; playerCountDifference = GetTeamSize(fromTeam) - GetTeamSize(toTeam); if (currentDifference<0 && playerCountDifference<2) { BroadcastMessageAndLog(""$getTeamName(toTeam)$" is already stronger ("$Int(toTeamStrength)$">"$Int(fromTeamStrength)$")"); return False; } // Find the player on fromTeam with strength closest to difference, and switch him/her for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (AllowedToBalance(p) && p.PlayerReplicationInfo.Team==fromTeam && p.PlayerReplicationInfo.HasFlag==None) { playerStrength = GetPlayerStrength(p); if (closestPlayer == None || Abs(currentDifference-playerStrength*2) < newDifference) { closestPlayer = p; // Note we multiply playerStrength by 2 here, because switching him will cause -strength to fromTeam and +strength to toTeam. newDifference = Abs(currentDifference-playerStrength*2); } } } if (closestPlayer == None) { BroadcastMessageAndLog("Could not find any player on "$getTeamName(fromTeam)$" to switch"); return False; } if (newDifference >= currentDifference && !bForceEvenTeams && CountHumanPlayers()>3) { // We only decline to switch if #players>3 and we aren't "forcing" even teams and if teams sizes only differ by 1 player. if (playerCountDifference>=2) { BroadcastMessageAndLog(""$getTeamName(toTeam)$" looks stronger than "$getTeamName(fromTeam)$". Please consider rebalancing again!"); lastBalanceTime = Level.TimeSeconds - MinSecondsBeforeRebalance; // Make immediate rebalance possible } else { // BroadcastMessageAndLog("Not switching "$closestPlayer.getHumanName()$" because that would make "$getTeamName(toTeam)$" team too strong!"); BroadcastMessageAndLog(""$getTeamName(toTeam)$" team would be too strong with "$closestPlayer.getHumanName()$""); return False; } } if (bDo) { ChangePlayerToTeam(closestPlayer,toTeam,gameStartDone); BroadcastTeamStrengths(); } else { ProposeChange(closestPlayer,None); } return True; } function bool MidGameTeamBalanceSwitchTwoPlayers(bool bDo) { // initial: local float redTeamStrength, blueTeamStrength, difference; // during loop: local Pawn redP,blueP; local float redPStrength, bluePStrength; local float potentialNewDifference; // the strength difference between the two teams after switching these two players // best found: local Pawn redPlayerToMove,bluePlayerToMove; // the best two players found so far local float newdifference; // the strength difference between the two teams after switching these players redTeamStrength = GetTeamStrength(0); blueTeamStrength = GetTeamStrength(1); difference = blueTeamStrength - redTeamStrength; // positive implies Team 1 is stronger than Team 0 newdifference = difference; // FIXED by "hashing": These repeated calls to GetPlayerStrength() are going to be inefficient, possibly causing some lag while the server calculates. for (redP=Level.PawnList; redP!=None; redP=redP.NextPawn) { for (blueP=Level.PawnList; blueP!=None; blueP=blueP.NextPawn) { if (redP != blueP && redP.PlayerReplicationInfo.Team==0 && blueP.PlayerReplicationInfo.Team==1 && AllowedToBalance(redP) && AllowedToBalance(blueP) && redP.PlayerReplicationInfo.HasFlag == None && blueP.PlayerReplicationInfo.HasFlag == None ) { redPStrength = GetPlayerStrength(redP); bluePStrength = GetPlayerStrength(blueP); // Note we multiply playerStrength by 2 here, because switching him will cause -strength to fromTeam and +strength to toTeam. potentialNewDifference = blueTeamStrength + redPStrength*2 - redTeamStrength - bluePStrength*2; if (Abs(potentialNewDifference) < Abs(newdifference)) { newdifference = potentialNewDifference; redPlayerToMove = redP; bluePlayerToMove = blueP; } } } } // CONSIDER: if one of the players is a bot, we should probably move him last, because bots tend to switch back to the other team, if UT.ini is configured that way. Alternatively, we could copy Daniel's temporary-ut-balance-disable code into ChangePlayerToTeam. Hmm probably nobody uses bBalanceBots anyway. if (redPlayerToMove != None && bluePlayerToMove != None) { if (bDo) { ChangePlayerToTeam(redPlayerToMove,1,gameStartDone); ChangePlayerToTeam(bluePlayerToMove,0,gameStartDone); BroadcastTeamStrengths(); } else { ProposeChange(redPlayerToMove,bluePlayerToMove); } return True; } else { BroadcastMessageAndLog("AutoTeamBalance could not find two switches to improve the teams."); return False; } } // Shows all players the request to rebalance with "!teams", and the player(s) who will be moved. // Only called if bShowProposedSwitch=True. // Can be caused by a player typing "!teams", or by bWarnMidGameUnbalance. // one must be a valid player, but two can be None. function ProposeChange(Pawn one, Pawn two) { local Pawn p; local String msg; if (two == None) { // msg = "Type !teams to move "$one.getHumanName(); msg = "Type !teams to move "$one.getHumanName()$" to "$getTeamName(1-one.PlayerReplicationInfo.Team); } else { msg = "Type !teams to swap "$one.getHumanName()$" with "$two.getHumanName(); } if (bFlashRebalanceRequest) { for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator') && !p.IsA('Bot')) { FlashMessageToPlayer(PlayerPawn(p),msg,warnColor,7); } } } else { BroadcastMessageAndLog(msg); } } // ======== Change game or message players: ======== // function ChangePlayerToTeam(Pawn p, int teamnum, bool bInform) { local Color msgColor; if (teamnum == p.PlayerReplicationInfo.Team) { if (bDebugLogging) { Log("AutoTeamBalance.ChangePlayerToTeam("$p.getHumanName()$","$teamnum$"): doing nothing since player is already on team "$teamnum); }; return; } if (teamnum<0 || teamnum>=TeamGamePlus(Level.Game).MaxTeams) { if (bDebugLogging) { Log("AutoTeamBalance.ChangePlayerToTeam("$p.getHumanName()$","$teamnum$"): WARN FAIL teamnum must be in range 0-" $ (TeamGamePlus(Level.Game).MaxTeams - 1) $ "."); }; return; } if (p.IsA('Bot')) { Bot(p).ConsoleCommand("taunt wave"); } if (bDebugLogging) { Log("AutoTeamBalance.ChangePlayerToTeam("$p.getHumanName()$"): "$p.PlayerReplicationInfo.Team$" -> "$teamnum); }; Level.Game.ChangeTeam(p,teamnum); // TODO: suppress the BroadcastMessage() made by TeamGame.AddToTeam() when we are flashing team to player elsewhere anyway // Recompensate player for suicide/death points: if (gameStartDone) { // p.KillCount++; // Did not work p.PlayerReplicationInfo.Score += 1.0; p.PlayerReplicationInfo.Deaths -= 1; } // Kill the player, forcing them to drop flag if they have it (before this we could get a red player holding the red flag!) p.Died(None, '', p.Location); if (bInform) { switch (teamnum) { case 0: msgColor = colorRed; break; case 1: msgColor = colorBlue; break; case 2: msgColor = colorGreen; break; case 3: msgColor = colorYellow; break; default: msgColor = colorWhite; break; } BroadcastMessage(p.getHumanName()$" has been moved to the "$getTeamName(teamnum)$" team."); PlayerPawn(p).ClearProgressMessages(); FlashMessageToPlayer(PlayerPawn(p),"You have been moved to the "$Caps(getTeamName(teamnum))$" team!",msgColor,3); // BUG: Unfortunately this message is soon hidden by the scoreboard, which is displayed automatically when a player dies, so we also send a message to their console: PlayerPawn(p).ClientMessage("You have been moved to the "$Caps(getTeamName(teamnum))$" team!"); if (bShakeWhenMoved) { p.ShakeView(2.0,2000.0,0.0); } } if (gameStartDone) { FixTeamsizeBug(); } } // I want to Log all calls to BroadcastMessage() so that I can see without playing how much the players are getting spammed by broadcasts. // Eventually, calls to BroadcastMessageAndLog could be turned back to just BroadcastMessage() calls. function BroadcastMessageAndLog(string Msg) { if (bDebugLogging) { Log("AutoTeamBalance Broadcasting: "$Msg); }; BroadcastMessage(Msg); } function FlashMessageToPlayer(PlayerPawn p, string Msg, Color msgColor, optional int linenum) { if (bDebugLogging) { Log("AutoTeamBalance Flashing message to "$p.getHumanName()$": "$Msg); }; // p.ClientMessage(Msg, 'CriticalEvent', False); // goes to HUD and console, no beep // Coloured messages, with our own choice of colour and timeout: if (linenum == 0) { linenum = -2; // I'm not sure whether this actually does anything! } // p.ClearProgressMessages(); p.SetProgressTime(4); p.SetProgressColor(msgColor,linenum); p.SetProgressMessage(Msg,linenum); if (gameStartDone) { // Prevent multiple (and badly overlapping) beeps during the multiple Flashes at the start of the game p.PlaySound(sound'Beep', SLOT_Interface, 2.5, False, 32, 32); // we play our own sound } } function FlashToAllPlayers(String Msg, Color msgColor, optional int linenum) { local PlayerPawn P; if (bDebugLogging) { Log("AutoTeamBalance.FlashMessageToPlayer(): Flashing \""$Msg$"\""); }; foreach AllActors(class'PlayerPawn',P) { if (!P.IsA('Spectator')) { FlashMessageToPlayer(P,Msg,msgColor,linenum); } } } // ======== Library functions which do not change any state: ======== // function bool ShouldBalance(GameInfo game) { // Never balance in tournament mode if (DeathMatchPlus(Level.Game).bTournament) return False; // We can't balance if it's not a teamgame if (!Level.Game.GameReplicationInfo.bTeamGame) return False; if (String(Level.Game.Class) == "Botpack.CTFGame") return bAutoBalanceTeamsForCTF; if (String(Level.Game.Class) == "Botpack.TeamGamePlus") return bAutoBalanceTeamsForTDM; if (String(Level.Game.Class) == "Botpack.Assault") { // Do not balance AS game if we're in the second half of the game if (Assault(Level.Game).Part != 1) return False; else return bAutoBalanceTeamsForAS; } // OK so it's an unknown teamgame return bAutoBalanceTeamsForOtherTeamGames; } function bool ShouldUpdateStats(GameInfo game) { if (String(Level.Game.Class) == "Botpack.CTFGame") return bUpdatePlayerStatsForCTF; if (String(Level.Game.Class) == "Botpack.TeamGamePlus") return bUpdatePlayerStatsForTDM; if (String(Level.Game.Class) == "Botpack.Assault") return bUpdatePlayerStatsForAS; // OK so it's not CTF or TDM or AS, but is it another type of team game? if (Level.Game.GameReplicationInfo.bTeamGame) return bUpdatePlayerStatsForOtherTeamGames; return bUpdatePlayerStatsForNonTeamGames; } function bool AllowedToBalance(Pawn b) { if (b.IsA('Bot')) return bBalanceBots; else return b.IsA('PlayerPawn') && !b.IsA('Spectator'); } // Checks that the player is a human, or a bot when bRankBots is set. Does not check whether the human player is a spectator. function bool AllowedToRank(Pawn b) { if (b.IsA('Bot')) return bRankBots; else return b.IsA('PlayerPawn'); } // This is used for checking and performing mid-game teambalance. It never counts bots. function int GetTeamSize(int team) { local int count; local Pawn p; count = 0; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') && !p.IsA('Spectator') && p.PlayerReplicationInfo.Team == team) count++; } return count; } function int CountHumanPlayers() { local Pawn p; local int countHumanPlayers; countHumanPlayers = 0; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.bIsPlayer && !p.IsA('Spectator') && !p.IsA('Bot') && p.IsA('PlayerPawn') && p.bIsHuman) { // maybe the last 2 are not needed countHumanPlayers++; } } return countHumanPlayers; } function String getTeamName(int teamNum) { return TeamGamePlus(Level.Game).Teams[teamNum].TeamName; } // Team strength is the sum of all players on that team, plus caps*FlagStrength (or other teamscore). function float GetTeamStrength(int teamNum) { // Add flagstrength: return GetTeamStrengthNoFlagStrength(teamNum) + GetFlagStrengthForTeam(teamNum); } function float GetFlagStrengthForTeam(int teamNum) { return TournamentGameReplicationInfo(Level.Game.GameReplicationInfo).Teams[teamNum].Score*GetFlagStrength(); } function float GetTeamStrengthNoFlagStrength(int teamNum) { local Pawn p; local float strength; strength = 0; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.bIsPlayer && !p.IsA('Spectator') && p.PlayerReplicationInfo.Team == teamNum) { strength += GetPlayerStrength(p); } } return strength; } // Scale FlagStrength, so it is appropriate for non-CTF gametypes: // Some common GoalTeamScores are: CTF 7 | (DM 30) | TDM 100 | DOM 100 | Siege 20/30 | Unknown 150 function float GetFlagStrength() { if (CountHumanPlayers() < 3) return 0; // Hopefully fixes the bug that in a 2v0, it was refusing to move either player to the "stronger" team! if (String(Level.Game.Class) == "Botpack.CTFGame") return FlagStrength; if (String(Level.Game.Class) == "Botpack.TeamGamePlus") // TDM return Float(FlagStrength)/14.0; if (String(Level.Game.Class) == "Botpack.Domination") return Float(FlagStrength)/14.0; if (String(Level.Game.Class) == "Botpack.Assault") return 0; if (StrAfter(String(Level.Game.Class),".") == "SiegeGI") return Float(FlagStrength)/4.0; // Unknown gametype; assume GoalTeamScore 150 return Float(FlagStrength)/21.0; } // Returns the strength of a player function float GetPlayerStrength(Pawn p) { // DONE: if gameStartDone and/or gametime>1minute then guess the player's strength from their current score local float timeInGame; // if (bUseOnlyInGameScoresForRebalance && gameStartDone && !gameEndDone) { // return p.PlayerReplicationInfo.Score; // } if (StrengthProportionFromCurrentGame>0 && gameStartDone) { timeInGame = Level.TimeSeconds - p.PlayerReplicationInfo.StartTime; if (timeInGame > 180) { return NormaliseScore(GetScoreForPlayer(p)) * StrengthProportionFromCurrentGame + GetRecordedPlayerStrength(p) * (1.0 - StrengthProportionFromCurrentGame); } } return GetRecordedPlayerStrength(p); } // Returns the recorded strength of a player function float GetRecordedPlayerStrength(Pawn p) { local int found; if (!AllowedToRank(p) && !AllowedToBalance(p)) { return BotStrength; } found = FindPlayerRecord(p); if (found == -1) { return UnknownStrength; // unknown player or player is too weak for list (should never happen) } else { return avg_score[found]; // player's recorded strength } } // Find player by name, or partial name function Pawn FindPlayerNamed(String name) { local Pawn p; local Pawn found; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (p.IsA('PlayerPawn') || p.IsA('Bot')) { if (p.getHumanName() ~= name) { // exact case insensitive match, return player return p; } if (Instr(Caps(p.getHumanName()),Caps(name))>=0) { // partial match, remember it but keep searching for exact match found = p; } } } return found; // return partial match, or None } // ======== Player database: ======== // // Copies from playerData[] to ip[],nick[],avg_score[],... (should be done at the start) function CopyConfigIntoArrays() { local int field; local int i; local String data; local String args[256]; CopyConfigDone=True; if (bDebugLogging) { Log("AutoTeamBalance.CopyConfigIntoArrays() "$GetDate()$" running"); }; for (i=0; i BN // str = str $ Left(String(m.Class),1) $ Left(StrAfter(String(m.Class),"."),1); // NEW METHOD: Select only capitalised parts of the class name: e.g. WhoPushedMe.WhoPushedMe => WPM // Possible BUG: People *may* write mutators that are not capitalised, in which case those mutators will generate no signature. // However, we can't change the signature now, without breaking the nicks for admins upgrading from earlier versions of ATB (although some player strengths might be retained via ip-matching) tmpstr = StrAfter(String(m.Class),"."); for (i=0;i=Asc("A") && c<=Asc("Z")) { str = str $ Chr(c); } } m = m.NextMutator; if (m != None) { str = str $ "+"; } } } return str; } // function int FindPlayerRecord(Pawn p) // // Will always return a valid exact record index, creating a new record if neccessary. // // For speed, this implementation keeps the record at position // p.PlayerReplicationInfo.PlayerID in the database, switching with another // record in that spot if necessary. It calls FindPlayerRecordNoFastHash() to // do the actual lookup. This makes it possible to call FindPlayerRecord(p) // frequently and efficiently. function int FindPlayerRecord(Pawn p) { local int i; local int found; local string tmp_player_nick, tmp_player_ip; local float tmp_avg_score, tmp_hours_played; local string tmp_date_last_played; i = p.PlayerReplicationInfo.PlayerID % MaxPlayerData; // BUG TODO: When bRankBots=True (or bBalanceBots=True?), all the bots have i = 1635, // they all override that record and none of them optimise. // Is the player's record already at i? if (GetDBName(p) == nick[i] && getIP(p) == ip[i]) { // DebugLog("AutoTeamBalance.FindPlayerRecord(p) FAST EXACT match for "$nick[i]$","$ip[i]$": ["$i$"] ("$avg_score[i]$","$hours_played[i]$","$date_last_played[i]$")"); return i; } // Is there an exact or partial match for this player in the database? found = FindPlayerRecordNoFastHash(p); // If an exact record for the player was found, move it to index i for the rest of this game (by swapping it with whichever record is there). This will make lookups more efficient during the rest of the game. if (found != -1 && GetDBName(p) == nick[found] && getIP(p) == ip[found]) { if (bDebugLogging) { Log("AutoTeamBalance.FindPlayerRecord(): Optimising exact match (lookup "$i$"<->"$found$") for "$nick[found]$" "$ip[found]); }; // Swap record [i] for record [found]: tmp_player_nick = nick[i]; tmp_player_ip = ip[i]; tmp_avg_score = avg_score[i]; tmp_hours_played = hours_played[i]; tmp_date_last_played = date_last_played[i]; nick[i] = nick[found]; ip[i] = ip[found]; avg_score[i] = avg_score[found]; hours_played[i] = hours_played[found]; date_last_played[i] = date_last_played[found]; nick[found] = tmp_player_nick; ip[found] = tmp_player_ip; avg_score[found] = tmp_avg_score; hours_played[found] = tmp_hours_played; date_last_played[found] = tmp_date_last_played; return i; } // No exact record for the player was found; we have performed a full search of the database :| if (found > -1) { if (bDebugLogging) { Log("AutoTeamBalance.FindPlayerRecord(): PARTIAL match for "$GetDBName(p)$" @ "$getIP(p)$"."); }; } else { if (bDebugLogging) { Log("AutoTeamBalance.FindPlayerRecord(): FAILED match for "$GetDBName(p)$" @ "$getIP(p)$"."); }; } // Let's create a new record for this player+ip, to avoid doing that again. i = CreateNewPlayerRecord(p); // i=unknown, but the new record will be optimally indexed the next time FindPlayerRecord() is called. if (found > -1) { if (bDebugLogging) { Log("AutoTeamBalance.FindPlayerRecord(p) COPY ["$i$"] <- ["$found$"]"); }; // Copy over strength from the partial-match player, but partially reset their time, to make their old strength last for max MaxHoursWhenCopyingOldRecord hours. avg_score[i] = avg_score[found]; // Copy score from partial match record hours_played[i] = Min(MaxHoursWhenCopyingOldRecord,hours_played[found]); // date_last_played[i] = "copied_from_"$nick[found]$":"$ip[found]; // should get set before being written date_last_played[i] = GetDate(); // SO: changing nick or IP will NOT reset your avg_score immediately, but after some hours of play your old record will only count for 50%. This helps to protect players who were matched incorrectly. (Different members of a family playing from the same IP, or different players using the same nick.) // Optionally log/broadcast the fakenicker, now only if IP was matched but nick is different. if (!(GetDBName(p) ~= nick[i])) { if (bLogFakenickers) { Log("AutoTeamBalance: Fakenicker "$p.getHumanName()$" was previously "$nick[found]$" (ip "$ip[found]$")"); } if (bBroadcastFakenickers) { BroadcastMessage(p.getHumanName()$" was previously "$nick[found]$" (ip "$ip[found]$")"); } } } return i; // if we didn't copy any stats over, he will have UnknownStrength, the same as when we returned -1 } // If an exact match for the player exists, return the index // If not, return the index of a record with matching nick, or (preferably) matching ip // If not, return -1 function int FindPlayerRecordNoFastHash(Pawn p) { local int found; local int i; local string player_nick; local string player_ip; player_nick = GetDBName(p); player_ip = getIP(p); found = -1; for (i=0;i= MaxPlayerData) { // all records were full // DONE: find the record with lowest hours_played and replace that one // DONE: better, find the oldest record and replace it (we need last_date_played for that) // TODO: first seek "oldest player record with min play-time", but if it fails, find "oldest player record" pos = FindOldestPlayerRecord(); } if (bDebugLogging) { Log("AutoTeamBalance.CreateNewPlayerRecord(p) DEL ["$pos$"] "$ nick[pos] $" "$ ip[pos] $" "$ avg_score[pos] $" "$ hours_played[pos] $" "$ date_last_played[pos]); }; ip[pos] = getIP(p); nick[pos] = GetDBName(p); avg_score[pos] = UnknownStrength; hours_played[pos] = 0; // UnknownMinutes/60; // CONSIDER: using some UnknownMinutes might be better, for players who play only for a short time and get an unrepresentative strength for the next game - with UnknownMinutes their strength will be closer to the average, hence balancing will concentrate more on players we know about. // date_last_played[pos] = "fresh_record"; date_last_played[pos] = GetDate(); if (bDebugLogging) { Log("AutoTeamBalance.CreateNewPlayerRecord(p) NEW ["$pos$"] "$ nick[pos] $" "$ ip[pos] $" "$ avg_score[pos] $" "$ hours_played[pos] $" "$ date_last_played[pos]); }; // if (bBroadcastCookies) { BroadcastMessageAndLog("Welcome "$ nick[pos] $"! You have "$ avg_score[pos] $" cookies."); } return pos; } function int CreateNewPlayerRecordInnerBatch(int posStart) { local int pos; // Find an empty slot: for (pos=posStart;pos "$Float(str)); }; } return Float(str); // NOTE: float is not all that accurate; it cannot see the time-of-day: // 200701010000 -> 200701018112.000000 } // Returns days since 1st/Jan/1970 from a string like: 2008/01/19-13:17.08 function float DaysFromDateString(String datestr) { local int year,month,day,hour,minute,second; local float days; local String str; str = StrFilterNum(datestr); if (str == "") return 0; // No date => 1st/Jan/1970 year = Int(Mid(str, 0,4)) - 1977; month = Int(Mid(str, 4,2)) - 1; day = Int(Mid(str, 6,2)) - 1; days = day + 365.25*month/12 + 365.25*year; // This is called so many times, for efficiency, we skip hours:minutes:seconds, since they are not really relevant. if (FRand()<0.002) { if (bDebugLogging) { Log("DaysFromDateString(): "$datestr$" -> "$days); }; } return days; } /* // Int is not large enough; I get: NumFromDateString(): 200701010000 -> -1162452912 function int NumFromDateString(String str) { // str = StrReplace(str,"-",""); // str = StrReplace(str,":",""); // str = StrReplace(str,"/",""); str = StrFilterNum(str); if (FRand()<0.01) { DebugLog("NumFromDateString(): "$str$" -> "$Int(str)); } return Int(str); // NOTE: float is not all that accurate; it cannot see the time-of-day: // 200701010000 -> 200701018112.000000 } */ // =========== Updating Stats on player database: =========== // function UpdateStatsAtEndOfGame() { local Pawn p; local int i; // Do not update stats for games with WinningTeam.Score) ) WinningTeam = thisTeamGame.Teams[i]; // Check for tie: for ( i=0; i to >= so if you tie with another player, you lose out! if ( (ScaleToFullTime(p)*p.PlayerReplicationInfo.Score) >= (ScaleToFullTime(other)*other.PlayerReplicationInfo.Score) ) { playersAbove++; } else { playersBelow++; } } } return 100 * playersBelow / (playersBelow + playersAbove); } // Returns the score the player will be awarded for this game, depending on the scoring method, and scaled up to full game time. Note that score normalisation is done elsewhere. function float GetScoreForPlayer(Pawn p) { local float award_score; if (ScoringMethod == 0) { award_score = p.PlayerReplicationInfo.Score * ScaleToFullTime(p); } else if (ScoringMethod == 1) { award_score = p.KillCount * ScaleToFullTime(p); } else if (ScoringMethod == 2) { award_score = ScaleToFullTime(p) * (p.KillCount + p.PlayerReplicationInfo.Score) / 2.0; } else if (ScoringMethod == 3 || ScoringMethod > 3) { award_score = GetRankingPoints(p); // Note that for this method, scaling score to full time is done *inside* GetRankingPoints() } return award_score; } function int UpdateStatsForPlayer(Pawn p) { local int i,j; local float current_score; local float old_hours_played; local float new_hours_played; local float hours_played_this_game; local int previousPolls; local int gameDuration; local int timeInGame; local float weightScore; local float previous_average; i = FindPlayerRecord(p); // guaranteed to return a record. gameDuration = Level.TimeSeconds - timeGameStarted; timeInGame = Level.TimeSeconds - p.PlayerReplicationInfo.StartTime; if (timeInGame>gameDuration) timeInGame = gameDuration; if (timeInGame < 60) { // The player has been in the game for less than 1 minute. if (bDebugLogging) { Log("AutoTeamBalance.UpdateStatsForPlayer("$p$") Not updating this player since his timeInGame "$timeInGame$" < 60s."); }; return i; } hours_played_this_game = Float(timeInGame)/60.0/60.0; current_score = GetScoreForPlayer(p); // Normalisation, or not: // ScoringMethod 3 requires no normalisation. if (ScoringMethod==0 || ScoringMethod==1 || ScoringMethod==2) { if (bNormaliseScores) { current_score = NormaliseScore(current_score); // to get an average score of 50 (different now that we use bRelativeNormalisation) } else { // We are not normalising the scores relative to other players, we are just recording end-game scores. // But if this was a short game, scores will probably be lower, so we // scale the scores up to what they might have been if the game had gone the full (assumed) 20 minutes. current_score = current_score * (1.0/3.0) / hours_played_this_game; } } old_hours_played = hours_played[i]; if (old_hours_played > HoursBeforeRecyclingStrength) { old_hours_played = HoursBeforeRecyclingStrength; } new_hours_played = old_hours_played + hours_played_this_game; previous_average = avg_score[i]; if (bDebugLogging) { Log("AutoTeamBalance.UpdateStatsForPlayer(p) ["$i$"] "$p.getHumanName()$" avg_score = ( ("$avg_score[i]$" * "$old_hours_played$") + "$current_score$"*"$hours_played_this_game$") / "$(new_hours_played)); }; avg_score[i] = ( (avg_score[i] * old_hours_played) + current_score*hours_played_this_game) / new_hours_played; hours_played[i] += hours_played_this_game; date_last_played[i] = GetDate(); if (avg_score[i]>previous_average+2) { if (bReportStrengthAsCookies) { if (bBroadcastCookies) { BroadcastMessageAndLog(""$ p.getHumanName() $" has earned "$ Int(avg_score[i]-previous_average) $" cookies!"); } if (bFlashCookies) { FlashMessageToPlayer(PlayerPawn(p),"You earned "$ Int(avg_score[i]-previous_average) $" cookies this game.",colorOrange,3); } // BUG: unfortunately hidden by scoreboard, but still appears in console } else { if (bBroadcastCookies) { BroadcastMessageAndLog(""$ p.getHumanName() $" has gained "$ Int(avg_score[i]-previous_average) $" points of strength!"); } if (bFlashCookies) { FlashMessageToPlayer(PlayerPawn(p),"Your strength increased by "$ Int(avg_score[i]-previous_average) $" points this game.",colorOrange,3); } // BUG: unfortunately hidden by scoreboard, but still appears in console } } else if (previous_average>avg_score[i]+2) { if (bReportStrengthAsCookies) { if (bBroadcastCookies) { BroadcastMessageAndLog(""$ p.getHumanName() $" has lost "$ Int(previous_average-avg_score[i]) $" cookies."); } if (bFlashCookies) { FlashMessageToPlayer(PlayerPawn(p),"You lost "$ Int(previous_average-avg_score[i]) $" cookies this game.",colorOrange,3); } // BUG: unfortunately hidden by scoreboard, but still appears in console } else { if (bBroadcastCookies) { BroadcastMessageAndLog(""$ p.getHumanName() $" has lost "$ Int(previous_average-avg_score[i]) $" points of strength."); } if (bFlashCookies) { FlashMessageToPlayer(PlayerPawn(p),"Your strength decreased by "$ Int(previous_average-avg_score[i]) $" points this game.",colorOrange,3); } // BUG: unfortunately hidden by scoreboard, but still appears in console } } return i; } function GetAveragesThisGame() { local Pawn p; local int playerCount; if (LastCalculatedAverages + 5 >= Level.TimeSeconds) return; playerCount = 0; averageGameScore = 0.0; averagePlayerStrengthThisGame = 0.0; for (p=Level.PawnList; p!=None; p=p.NextPawn) { if (!p.IsA('Spectator') && AllowedToRank(p)) { averageGameScore += GetScoreForPlayer(p); averagePlayerStrengthThisGame += GetRecordedPlayerStrength(p); playerCount++; } } averageGameScore = averageGameScore / Float(playerCount); averagePlayerStrengthThisGame = averagePlayerStrengthThisGame / Float(playerCount); LastCalculatedAverages = Level.TimeSeconds; } // Normalises a player's score so that the average output score will be NormalisedStrength (or with bRelativeNormalisation, the average strength of current players on the server). // This is to fix the problem that some games (e.g. 2v2 w00t or PureAction or iG) have much higher scores than others, which will confuse the stats. function float NormaliseScore(float score) { GetAveragesThisGame(); // Avoid division-by-zero error here. You guys got average <2 frags? Screw you I'm not scaling that up to NormalisedStrength! if (averageGameScore < 2.0) { averageGameScore = NormalisedStrength; // CONSIDER: maybe just better not to update } // BT games will tend to have a lot of -ve scores. if (bDebugLogging) { Log("AutoTeamBalance.NormaliseScore("$score$"): Average game score was "$averageGameScore$", average player strength was "$averagePlayerStrengthThisGame$""); }; /* if (bRelativeNormalisation) { return score * averagePlayerStrengthThisGame / averageGameScore; } else { return score * NormalisedStrength / averageGameScore; } */ return score * FloatWeUseForAverageGameStrength() / averageGameScore; } function float FloatWeUseForAverageGameStrength() { return averagePlayerStrengthThisGame * RelativeNormalisationProportion + NormalisedStrength * (1.0 - RelativeNormalisationProportion); } // Takes everything before the first ":" - used when getting the IP from PlayerPawn.GetPlayerNetworkAddress(); since the client's port number changes frequently. function string stripPort(string ip_and_port) { if ((""$ip_and_port)=="None" || ip_and_port=="") { // DebugLog("stripPort(): ip_and_port="$ip_and_port); return "0.0.0.0"; } return Left(ip_and_port,InStr(ip_and_port,":")); } // Include my library of common UnrealScript functions: //===============// // // // JLib.uc.jpp // // // //===============// function int SplitString(String str, String divider, out String parts[256]) { // local String parts[256]; // local array parts; local int i,nextSplit; i=0; while (true) { nextSplit = InStr(str,divider); if (nextSplit >= 0) { // parts.insert(i,1); parts[i] = Left(str,nextSplit); str = Mid(str,nextSplit+Len(divider)); i++; } else { // parts.insert(i,1); parts[i] = str; i++; break; } } // return parts; return i; } function string GetDate() { local string Date, Time; Date = Level.Year$"/"$PrePad(Level.Month,"0",2)$"/"$PrePad(Level.Day,"0",2); Time = PrePad(Level.Hour,"0",2)$":"$PrePad(Level.Minute,"0",2)$":"$PrePad(Level.Second,"0",2); return Date$"-"$Time; } // NOTE: may cause an infinite loop if p="" function string PrePad(coerce string s, string p, int i) { while (Len(s) < i) s = p$s; return s; } function bool StrStartsWith(string s, string x) { return (InStr(s,x) == 0); // return (Left(s,Len(x)) ~= x); } function bool StrEndsWith(string s, string x) { return (Right(s,Len(x)) ~= x); } function bool StrContains(String s, String x) { return (InStr(s,x) > -1); } function String StrAfter(String s, String x) { return StrAfterFirst(s,x); } function String StrAfterFirst(String s, String x) { local int i; i = Instr(s,x); return Mid(s,i+Len(x)); } function string StrAfterLast(string s, string x) { local int i; i = InStr(s,x); if (i == -1) { return s; } while (i != -1) { s = Mid(s,i+Len(x)); i = InStr(s,x); } return s; } function string StrBefore(string s, string x) { return StrBeforeFirst(s,x); } function string StrBeforeFirst(string s, string x) { local int i; i = InStr(s,x); if (i == -1) { return s; } else { return Left(s,i); } } function string StrBeforeLast(string s, string x) { local int i; i = InStrLast(s,x); if (i == -1) { return s; } else { return Left(s,i); } } function int InStrOff(string haystack, string needle, int offset) { local int instrRest; instrRest = InStr(Mid(haystack,offset),needle); if (instrRest == -1) { return instrRest; } else { return offset + instrRest; } } function int InStrLast(string haystack, string needle) { local int pos; local int posRest; pos = InStr(haystack,needle); if (pos == -1) { return -1; } else { posRest = InStrLast(Mid(haystack,pos+Len(needle)),needle); if (posRest == -1) { return pos; } else { return pos + Len(needle) + posRest; } } } // Converts a string to lower-case. function String Locs(String in) { local String out; local int i; local int c; out = ""; for (i=0;i=65 && c<=90) { c = c + 32; } out = out $ Chr(c); } return out; } function String StrFilterNum(String in) { local String out; local int i; local int c; out = ""; for (i=0;i=Asc("0") && c<=Asc("9")) { out = out $ Chr(c); } } return out; } // UT2k4 had Repl(in,search,replace). function String StrReplace(String in, String search, String replace) { local String out; local int i; out = ""; for (i=0;i