Friday, October 19, 2012

Minecraft Server Stuff

A friend of mine expressed desire for me to start up my Minecraft server again, so I generated a fresh (seeded) world on 1.3.2 and it's up once more.

I like to make little PHP scripts to output information about the server, like whether it's up or down, who's whitelisted, and various server options.  I figured this time around I'd implement a "last seen" feature that would tell you if someone on the whitelist had ever been on the server, when they last logged off, or if they're currently on.

I sat down with PHP and victory was had, but not without some shell script shenanigans with PHP's shell_exec() function.

Basically, the core of "last seen" functionality involves reading the server log looking for the most recent connection and disconnection notices for a given player, and comparing the timestamps to see if they're currently online.  In bash shell script, this is balls easy to do:
#!/bin/bash MINECRAFT_SERVER_LOG="/path/to/minecraft/server.log" LOGOFF_TIME=`grep -Pi "^.{19} \[INFO\] $1 lost connection" "$MINECRAFT_SERVER_LOG" | tail -n1 | sed -r 's/^(.{19}).*$/\1/'` LOGON_TIME=`grep -Pi "^.{19} \[INFO\] $1\[/[^]]+\] logged in" "$MINECRAFT_SERVER_LOG" | tail -n1 | sed -r 's/^(.{19}).*$/\1/'` if [ "$LOGON_TIME" = "" ]; then echo "Never" else if [ "$LOGON_TIME" '>' "$LOGOFF_TIME" ]; then echo "Online now" else echo "$LOGOFF_TIME" fi fi
Usage, if you save it as, would be ./ PLAYER_NAME

However, I'm working with PHP, not bash shell script.  Originally, I tried to dynamically generate a shell command to pass to shell_exec(), but a) it never worked right, and b) it ended up being more complex than I originally thought.  I was originally just getting each user's last logoff time, neglecting the fact that they could be presently on the server.

The "solution" was to copy/paste my shell script I was trying to shell_exec() into an external file and chmod +x the fucker.  Then my shell_exec() call became simply running that shell script with the name of the player on the command line.  It works, but using shell_exec()... yeah...

However, the functionality for grep (preg_grep()) and what I'm using sed for (preg_replace()) basically already exists in PHP.  While I'm a fan of writing things in the languages that make them the easiest to write, the result looks rather cobbled together and tends to break when you think about portability.  All PHP really needs is a native implementation of tail -n and we're good.

Which it doesn't have.  My next step was to sift through Google search results and see if anything of use popped up.

Initially, all I could find were tail -f implementations.  That allows live monitoring of a log file, which admittedly can be useful, but wasn't what I needed.  I just need one stinkin' line out of the file.  After sifting through the results, I noticed that one of them was indeed an implementation of tail -n.  However, it didn't operate in the way I needed it to.  Instead of taking in data and returning the last few lines of it, it required the data to be in a file.  Being that I'm passing the file through grep first, it wouldn't work.

Then I derped and realized that it's way easier than I thought.  If you read in the complete file with the file() function, you get an array of strings where every string is a single line of the file.  preg_grep() operates on an array.  So, basically, all I needed to do was this:
function tail( $data, $lines = 10 ) {return array_slice( $data, count( $data ) - $lines, $lines ); }
*nix tail defaults to returning 10 lines, so that's why the $lines parameter has a default value.  Even though I only need it to return one line.  The solution could actually get simpler still, but I'm leaving it this way.  preg_grep() leaves the array indices the way they were, but array_slice() renumbers them and makes dealing with the result a lot easier.

So if you need a PHP tail -n implementation and you're reading a file in via file(), just use array_slice() to get the lines you need.

All in all, I ended up with this:
function tail( $data, $lines = 10 ) {return array_slice( $data, count( $data ) - $lines, $lines ); } // getting the timestamp of the most recent line in the server.log that matches a given regex // $BASE_DIRECTORY is the absolute path of directory containing minecraft-server.jar, slash-terminated // $SERVER_LOG is the Minecraft server log, read in via file() // Reading the server log only once per request makes a lot more sense than reading it once for every person on the whitelist. function get_timestamp( $regex ) {global $BASE_DIRECTORY, $SERVER_LOG; $ret = preg_replace( "/^(.{19}).*$/", "$1", tail( preg_grep( $regex, file( $BASE_DIRECTORY . 'server.log' ) ), 1 ) ); return @$ret[0]; } function get_last_seen( $username ) {$logoff_time = get_timestamp( "/^.{19} \[INFO\] " . $username . " lost connection/i" ); $logon_time = get_timestamp( "/^.{19} \[INFO\] " . $username . "\[\/[^]]+\] logged in/i" ); if ( $logon_time == '' ) // user has never logged on return 'Never'; else {$cmp = strcmp( $logoff_time, $logon_time ); if ( $cmp < 0 ) // logon time is greater than logoff time (user is on now) return 'Online now'; elseif( $cmp > 0 ) // logoff time is greater than logon time (user has logged off) return $logoff_time; else // mystery case return 'Mystery case!'; } }
Two things:  First, the reason that calling strcmp() on the two timestamps actually works to see which one is more recent is entirely due to the timestamp format that the Minecraft server uses.  It orders the fields from least-updated to most-updated, which means that to strcmp(), a more recent date is always going to be "bigger".  Second, that line in get_timestamp(), return @$ret[0];...  The @ is a PHP operator that suppresses error output.  PHP bitches about that line, saying "invalid index", yet, all of my debug output shows a one-element array with the sole element having an index of 0, and if the error output is suppressed, it works properly.  I'm not sure exactly what PHP has stuck up its ass, but it needs to remove it.

Also, yes, technically speaking, the mystery case can be triggered.  The user's logon time has to be equal to their logoff time.  That's fairly difficult to achieve with Minecraft, since the server considers you logged in before you're even able to bring up the menu and hit disconnect.  Hence why I'm not actually bothering to write code to handle it.

No comments:

Post a Comment

I moderate comments because when Blogger originally implemented a spam filter it wouldn't work without comment moderation enabled. So if your comment doesn't show up right away, that would be why.