diff --git a/README.md b/README.md index 11630ef..ca05943 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,60 @@ # UT99-Mod-ChaChaRESTStats -A tiny mod to allow reading server stats with REST requests. - -/!\ Require UT99 v451+, not compatible with v436, prefered 469+. \ No newline at end of file +A tiny mod to allow reading server stats with REST requests. + +!!! Require UT99 v451+, not compatible with v436, prefered 469+. + +# Installation +## On Linux (crudini required) + + ./Run.sh () + +## Manual installation + +- Copy __ChaChaRESTStats.u__ file to your UT99 __System__ dir + +- Edit UnrealTournament.ini and update / adapt [UWeb.WebServer] section: + + + [UWeb.WebServer] + Applications[0]=UTServerAdmin.UTServerAdmin + ApplicationPaths[0]=/ServerAdmin + Applications[1]=UTServerAdmin.UTImageServer + ApplicationPaths[1]=/images + Applications[2]=ChaChaRESTStats.ChaChaRESTStats + ApplicationPaths[2]=/api/v1 + DefaultApplication=0 + bEnabled=True + ListenPort= + +## Available Resources: +__GET__ /api/v1/map_list + +__GET__ /api/v1/current_all + +__GET__ /api/v1/current_game + +__GET__ /api/v1/current_players + +__GET__ /api/v1/default_all + +__GET__ /api/v1/defaults_settings + +__GET__ /api/v1/defaults_rules + +__GET__ /api/v1/defaults_server + +## Sample Output + +__GET__ http://YOUR_SERVER_IP:YOUR_LISTEN_PORT/api/v1/map_list + + {"Maplist":["CTF-Gauntlet.unr","CTF-Command.unr","CTF-Coret.unr","CTF-Dreary.unr","CTF-LavaGiant.unr","CTF-November.unr"]} + + +__GET__ http://YOUR_SERVER_IP:YOUR_LISTEN_PORT/api/v1/current_all + + {"GameName":"Capture the Flag","GameClass":"Botpack.CTFGame","LevelTitle":"Lava Giant","Level":"CTF-LavaGiant","Mutators":["Botpack.FatBoy"],"player_list":[{"PlayerName":"chacha","Ping":12,"Score":0.000000,"bIsABot":false,"bIsSpectator":true,"IP":"172.16.4.101"}]} + +__GET__ http://YOUR_SERVER_IP:YOUR_LISTEN_PORT/api/v1/default_all + + {"GameStyle":"HardCore","GameStyle":"Turbo","GameSpeed":100.000000,"AirControl":35.000000,"UseTranslocator":True,"MaxPlayers":16,"MaxSpectators":2,"bMultiWeaponStay":True,"bTournament":False,"bPlayersBalanceTeams":False,"bForceRespawn":False,"GoalTeamScore":3.000000,"TimeLimit":0,"FriendlyFireScale":0.000000,"ServerName":"Another UT Server","AdminName":"","AdminEmail":"","MOTDLine1":"","MOTDLine2":"","MOTDLine3":"","MOTDLine4":"","bWorldLog":True} \ No newline at end of file diff --git a/Run.sh b/Run.sh new file mode 100644 index 0000000..32cb01e --- /dev/null +++ b/Run.sh @@ -0,0 +1,114 @@ +#!/bin/bash +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +OUTPUT_DIR="$2" +DEFAULT_CFG_FILE=UnrealTournament.ini +CFG_FILE="${3:-$DEFAULT_CFG_FILE}" + +function add_iniKeyEx() { + crudini --set $OUTPUT_DIR/System/$1 $2 __$3 $4 + # Warning: ugly hack with sed to allow multiple key instances + to remove space around '=' + sed -i "s/[[:space:]]*__$(echo $3 | sed -e 's/\([[\/.*]\|\]\)/\\&/g')[[:space:]]*=[[:space:]]*/$(echo $3 | sed -e 's/\([[\/.*]\|\]\)/\\&/g')=/g" $OUTPUT_DIR/System/$1 +} +# !!Warning!! section is not considered +function del_iniKeyEx() { + sed -i "/[[:space:]]*$(echo $3 | sed -e 's/\([[\/.*]\|\]\)/\\&/g')[[:space:]]*=[[:space:]]*$(echo $4 | sed -e 's/\([[\/.*]\|\]\)/\\&/g')/d" $OUTPUT_DIR/System/$1 +} +function add_iniKey() { + add_iniKeyEx $CFG_FILE $1 $2 $3 +} +# !!Warning!! section is not considered +function del_iniKey() { + del_iniKeyEx $CFG_FILE $1 $2 $3 +} +function add_ServerPackage() { + add_iniKey 'Engine.GameEngine' ServerPackages $1 + add_iniKey 'XC_Engine.XC_GameEngine' ServerPackages $1 +} +function del_ServerPackage() { + del_iniKey 'Engine.GameEngine' ServerPackages $1 + del_iniKey 'XC_Engine.XC_GameEngine' ServerPackages $1 +} +function add_ServerActors() { + add_iniKey 'Engine.GameEngine' ServerActors $1 + add_iniKey 'XC_Engine.XC_GameEngine' ServerActors $1 +} +function del_ServerActors() { + del_iniKey 'Engine.GameEngine' ServerActors $1 + del_iniKey 'XC_Engine.XC_GameEngine' ServerActors $1 +} + +function install() { + rsync -a $SCRIPT_DIR/System/ $OUTPUT_DIR/System/ --exclude '.git' + echo install ok +} + +function enable() { + add_iniKeyEx IpToCountry.ini UWeb.WebServer 'Applications[2]' ChaChaRESTStats.ChaChaRESTStats + add_iniKeyEx IpToCountry.ini UWeb.WebServer 'ApplicationPaths[2]' '/api/v1' + add_iniKeyEx IpToCountry.ini UWeb.WebServer 'bEnabled' True + + echo enable ok +} + +function disable() { + crudini --del $OUTPUT_DIR/System/$CFG_FILE UWeb.WebServer 'Applications[2]' + crudini --del $OUTPUT_DIR/System/$CFG_FILE UWeb.WebServer 'ApplicationPaths[2]' + echo disable ok +} + +function show_help() { + echo + echo "Usage: $0 { install | enable | disable } []" + echo +} + +function check_cfg_file() { + if [ -z ${CFG_FILE} ] + then + echo "CFG_FILE is unset, setting it to $DEFAULT_CFG_FILE" + CFG_FILE=$DEFAULT_CFG_FILE + else + echo "CFG_FILE is set to '$CFG_FILE'" + fi + + if [ ! -f $OUTPUT_DIR/System/$CFG_FILE ] + then + echo "$OUTPUT_DIR/System/$CFG_FILE does not exist" + show_help + exit 9999 # die with error code 9999 + fi +} + +function check_game_dir() { + ### Check if a directory does not exist ### + if [ ! -d $OUTPUT_DIR ] + then + echo "incorrect " + show_help + exit 9999 # die with error code 9999 + fi +} + +case "$1" in + 'install') + check_game_dir + install + ;; + 'enable') + check_game_dir + check_cfg_file + disable + enable + ;; + 'disable') + check_game_dir + check_cfg_file + disable + ;; + *) + show_help + exit 1 + ;; +esac + +exit 0 \ No newline at end of file diff --git a/Sources/Classes/ChaChaRESTStats.uc b/Sources/Classes/ChaChaRESTStats.uc new file mode 100644 index 0000000..615784f --- /dev/null +++ b/Sources/Classes/ChaChaRESTStats.uc @@ -0,0 +1,436 @@ +class ChaChaRESTStats expands WebApplication; + +var() class SpectatorType; +var UTServerAdminSpectator Spectator; + + +var ListItem IncludeMutators; +var ListItem ExcludeMutators; + +/* Usage: + +[UWeb.WebServer] +Applications[X]=ChaChaRESTStats.ChaChaRESTStats +ApplicationPaths[X]=/api/v1 +bEnabled=True + +http://server.ip.address/api/v1/current_game + +*/ + +event Init() +{ + Super.Init(); + + if (SpectatorType != None) + Spectator = Level.Spawn(SpectatorType); + else + Spectator = Level.Spawn(class'UTServerAdminSpectator'); + + // won't change as long as the server is up + LoadMutators(); +} + +function LoadMutators() +{ + local int NumMutatorClasses; + local string NextMutator, NextDesc; + local listitem TempItem; + local Mutator M; + local int k; + + ExcludeMutators = None; + + Level.GetNextIntDesc("Engine.Mutator", 0, NextMutator, NextDesc); + while( (NextMutator != "") && (NumMutatorClasses < 1024) ) + { + TempItem = new(None) class'ListItem'; + + k = InStr(NextDesc, ","); + if (k == -1) + TempItem.Tag = NextDesc; + else + TempItem.Tag = Left(NextDesc, k); + + TempItem.Data = NextMutator; + + if (ExcludeMutators == None) + ExcludeMutators = TempItem; + else + ExcludeMutators.AddSortedElement(ExcludeMutators, TempItem); + NumMutatorClasses++; + Level.GetNextIntDesc("Engine.Mutator", NumMutatorClasses, NextMutator, NextDesc); + } + + IncludeMutators = None; + + for ( M=Level.Game.BaseMutator.NextMutator ; M!=None ; M=M.NextMutator ) + { + TempItem = ExcludeMutators.DeleteElement(ExcludeMutators, String(M.Class)); + + if (TempItem != None) + { + if (IncludeMutators == None) + IncludeMutators = TempItem; + else + IncludeMutators.AddElement(TempItem); + } + else + log("Unknown Mutator in use: "$String(M.Class)); + } +} + +event Query(WebRequest Request, WebResponse Response) +{ + local string tmp; + //local int i; + + /* Kept in case auth needed + if(Request.Username != "test" || Request.Password != "test") + { + Response.FailAuthentication("HelloWeb"); + return; + } + */ + Response.SendStandardHeaders("application/json",False); + Response.bSentText=True; //avoid header to be re-sent + switch(Request.URI) + { + case "/map_list": + Response.SendText(EncloseJSON(QueryMapList())); + break; + case "/current_all": + tmp=QueryCurrentGame(); + tmp$=","; + tmp$=QueryCurrentPlayers(); + Response.SendText(EncloseJSON(tmp)); + break; + case "/current_game": + Response.SendText(EncloseJSON(QueryCurrentGame())); + break; + case "/current_players": + Response.SendText(EncloseJSON(QueryCurrentPlayers())); + break; + case "/default_all": + tmp=QueryDefaultsSettings(); + tmp$=","; + tmp$=QueryDefaultsRules(); + tmp$=","; + tmp$=QueryDefaultsServer(); + Response.SendText(EncloseJSON(tmp)); + break; + case "/defaults_settings": + Response.SendText(EncloseJSON(QueryDefaultsSettings())); + break; + case "/defaults_rules": + Response.SendText(EncloseJSON(QueryDefaultsRules())); + break; + case "/defaults_server": + Response.SendText(EncloseJSON(QueryDefaultsServer())); + break; + default: + Response.SendText("ERROR: Page not found or enabled."); + break; + } +} + +function string EncloseJSON(String _input) +{ + return "{"$_input$"}"; +} + +function string QueryDefaultsServer() +{ + local String tmp; + + tmp$="\"ServerName\":\""$class'Engine.GameReplicationInfo'.default.ServerName$"\","; + tmp$="\"AdminName\":\""$class'Engine.GameReplicationInfo'.default.AdminName$"\","; + tmp$="\"AdminEmail\":\""$class'Engine.GameReplicationInfo'.default.AdminEmail$"\","; + tmp$="\"MOTDLine1\":\""$class'Engine.GameReplicationInfo'.default.MOTDLine1$"\","; + tmp$="\"MOTDLine2\":\""$class'Engine.GameReplicationInfo'.default.MOTDLine2$"\","; + tmp$="\"MOTDLine3\":\""$class'Engine.GameReplicationInfo'.default.MOTDLine3$"\","; + tmp$="\"MOTDLine4\":\""$class'Engine.GameReplicationInfo'.default.MOTDLine4$"\","; + tmp$="\"bWorldLog\":"$Level.Game.Default.bWorldLog; + + return tmp; +} + +function string QueryDefaultsRules() +{ + + local float FriendlyFireScale; + local class GameClass; + local String tmp; + + GameClass = Level.Game.Class; + + tmp$="\"MaxPlayers\":"$class(GameClass).Default.MaxPlayers$","; + tmp$="\"MaxSpectators\":"$class(GameClass).Default.MaxSpectators$","; + tmp$="\"bMultiWeaponStay\":"$class(GameClass).Default.bMultiWeaponStay$","; + tmp$="\"bTournament\":"$class(GameClass).Default.bTournament; + + if( class(GameClass) != None ) + { + tmp$=",\"bPlayersBalanceTeams\":"$class(GameClass).Default.bPlayersBalanceTeams; + } + + if( class(GameClass) == None ) + { + tmp$=",\"bForceRespawn\":"$class(GameClass).Default.bForceRespawn; + } + + if (class(GameClass) != None && class(GameClass) == None) + { + if (class(GameClass) != None) + { + tmp$=",\"GoalTeamScore\":"$class(GameClass).Default.GoalTeamScore; + } + else + { + tmp$=",\"FragLimit\":"$class(GameClass).Default.FragLimit; + } + + if(class(GameClass) == None) + { + tmp$=",\"TimeLimit\":"$class(GameClass).Default.TimeLimit; + } + } + + if( class(GameClass) != None && + !ClassIsChildOf( GameClass, class'CTFGame' ) && + !ClassIsChildOf( GameClass, class'Assault' ) ) + { + tmp$=",\"MaxTeams\":"$class(GameClass).Default.MaxTeams; + } + + if (class(GameClass) != None) + { + FriendlyFireScale = class(GameClass).Default.FriendlyFireScale * 100; + tmp$=",\"FriendlyFireScale\":"$FriendlyFireScale; + } + + return tmp; +} + +function string QueryDefaultsSettings() +{ + local class GameClass; + local int GameStyle; + local float GameSpeed, AirControl; + local String tmp; + + GameClass = Level.Game.Class; + + if (class(GameClass).Default.bMegaSpeed == true) + GameStyle=1; + if (class(GameClass).Default.bHardCoreMode == true) + GameStyle+=1; + + switch (GameStyle) { + case 0: + tmp$="\"GameStyle\":\"Normal\","; + break; + case 1: + tmp$="\"GameStyle\":\"HardCore\","; + case 2: + tmp$="\"GameStyle\":\"Turbo\","; + } + + GameSpeed = class(GameClass).Default.GameSpeed * 100.0; + tmp$="\"GameSpeed\":"$GameSpeed$","; + + AirControl = class(GameClass).Default.AirControl * 100.0; + tmp$="\"AirControl\":"$AirControl$","; + + tmp$="\"UseTranslocator\":"$class(GameClass).Default.bUseTranslocator; + + return tmp; +} + +function string QueryMapList() +{ + local string tmp; + local ListItem ExcludeMaps, IncludeMaps; + ReloadExcludeMaps(ExcludeMaps, String(Level.Game.Class)); + ReloadIncludeMaps(ExcludeMaps, IncludeMaps, String(Level.Game.Class)); + tmp$=RenderDataList("Maplist",IncludeMaps); + return tmp; +} + +function string QueryCurrentGame() +{ + local string tmp,LevelFullName,LevelName; + local int k; + + tmp$="\"GameName\":\""$Level.Game.GameReplicationInfo.GameName$"\","; + tmp$="\"GameClass\":\""$String(Level.Game.Class)$"\","; + + tmp$="\"LevelTitle\":\""$Level.Title$"\","; + + LevelFullName=String(Level); + k = InStr(LevelFullName, "."); + LevelName = Left(LevelFullName, k); + tmp$="\"Level\":\""$LevelName$"\","; + + if (Level.Game != None && DeathMatchPlus(Level.Game) != None && + DeathMatchPlus(Level.Game).TimeLimit > 0.0) + { + tmp$="\"TimeLimit\":"$DeathMatchPlus(Level.Game).TimeLimit$","; + } + tmp$=RenderDataList("Mutators",IncludeMutators); + + return tmp; +} + +function string QueryCurrentPlayers() +{ + local string tmp; + local Pawn P; + local string IP; + local bool bFirst; + + tmp$="\"player_list\":["; + bFirst=True; + for (P=Level.PawnList; P!=None; P=P.NextPawn) { + if (P.bIsPlayer + && !P.bDeleteMe + && UTServerAdminSpectator(P) == None + && P.PlayerReplicationInfo != None) + { + if(!bFirst) + tmp$=","; + bFirst=False; + tmp$="{"; + tmp$="\"PlayerName\":\""$P.PlayerReplicationInfo.PlayerName$"\","; + tmp$="\"Ping\":"$max(P.PlayerReplicationInfo.Ping, 0)$","; + tmp$="\"Score\":"$P.PlayerReplicationInfo.Score$","; + if (P.PlayerReplicationInfo.bIsABot) + { + tmp$="\"bIsABot\":true,"; + tmp$="\"bIsSpectator\":false,"; + } + else + { + tmp$="\"bIsABot\":false,"; + if (P.PlayerReplicationInfo.bIsSpectator) + { + tmp$="\"bIsSpectator\":true,"; + } + else + { + tmp$="\"bIsSpectator\":false,"; + } + } + IP = ""; + if ( PlayerPawn(P) != None ) + { + IP = PlayerPawn(P).GetPlayerNetworkAddress(); + IP = class'InternetInfo'.static.StripPort(IP); + } + tmp$="\"IP\":\""$IP$"\""; + tmp$="}"; + } + } + tmp$="]"; + return tmp; +} + +function string RenderDataList(String name,ListItem _list) +{ + local string tmp; + local ListItem TempItem; + local bool bFirst; + tmp$="\""$name$"\":["; + bFirst = true; + for (TempItem = _list; TempItem != None; TempItem = TempItem.Next) { + if(!bFirst) + tmp$=","; + tmp$="\""$TempItem.Data$"\""; + bFirst=false; + } + tmp$="]"; + return tmp; +} + +function ReloadExcludeMaps(out ListItem ExcludeMaps, String GameType) +{ + local class GameClass; + local string FirstMap, NextMap, TestMap; + local ListItem TempItem; + + GameClass = class(DynamicLoadObject(GameType, class'Class')); + + ExcludeMaps = None; + if(GameClass.Default.MapPrefix == "") + return; + FirstMap = Level.GetMapName(GameClass.Default.MapPrefix, "", 0); + NextMap = FirstMap; + while (!(FirstMap ~= TestMap) && FirstMap != "") + { + if(!(Left(NextMap, Len(NextMap) - 4) ~= (GameClass.Default.MapPrefix$"-tutorial"))) + { + // Add the map. + TempItem = new(None) class'ListItem'; + TempItem.Data = NextMap; + + if(Right(NextMap, 4) ~= ".unr") + TempItem.Tag = Left(NextMap, Len(NextMap) - 4); + else + TempItem.Tag = NextMap; + + if (ExcludeMaps == None) + ExcludeMaps = TempItem; + else + { + // Maplists returned by GetMapName get sorted in C++ as of the Unreal Tournament 469 patch + //ExcludeMaps.AddSortedElement(ExcludeMaps, TempItem); + ExcludeMaps.AddElement(TempItem); + } + } + + NextMap = Level.GetMapName(GameClass.Default.MapPrefix, NextMap, 1); + TestMap = NextMap; + } +} + +function ReloadIncludeMaps(out ListItem ExcludeMaps, out ListItem IncludeMaps, String GameType) +{ + local class GameClass; + local ListItem TempItem; + local int i; + + GameClass = class(DynamicLoadObject(GameType, class'Class')); + if(GameClass.Default.MapListType == None) + return; + if (GameClass != None) + { + for (i=0; i