PHP - Finishing Interfict

From LXF Wiki

Table of contents

Practical PHP Programming

(Original version written by Paul Hudson for LXF issue 62.)

Ending the mini-series on interactive fiction, we add in the world-creation toolkit and tidy up...


Yes, this month we wrap up Interfict by looking at the world-creation toolkit, and also cleaning up with various other bits and pieces that will make the whole system better. Then, with much fanfare, the site gets put online.

Read on!


Making games

So far we've concentrated solely on allowing players to walk through worlds and interact with them. There's no fighting in there, but do you see an 18 rating on the front of this magazine? Of course, so far we've been writing our SQL by hand and injecting it directly into the database, which is a bit clumsy. This month the first thing we're going to do is go through and create all the forms necessary to let players create their adventure.

Our current game system allows anyone to play games, even if they haven't registered with the site. However, that just doesn't work for /creating/ games, because it's unlikely anyone is going to log in, create an entire adventure, then never want to come back and edit it. So, by forcing people to sign up to the system to create adventures, we are also slyly giving them the means to come back in and edit those adventures and monitor their popularity.

The script createaccount.php will handle people signing up to the system. As you can see, looking at the source code, we're only capturing the username, password, and email address of people. This isn't much information, really - lots of free services ask for real name, age, location, sex, hobbies, salary, and other quite intrusive things. Although you're welcome to add such evil demographic collection yourself, I'd rather not inflict it on others when I know for a fact how much it irritates me!

Once logged in, the main world creation script is wc_index.php - note that I've prefixed the creation tools with "wc" for, well, "world creation". From this screen players should be able to create new adventures and also edit existing adventures of theirs. These are pretty much the same - once the base of a game is defined, you are just editing it.

In order to create a new adventure, we need a selection of basic information: the name, difficulty, length, minimum character level, points adjustment, random encounter strength, and random encounter chance. Of those, the only thing likely to cause any clash is the name - if several people name their adventures the same thing it could cause confusion. So, the way this page needs to work is to create the new game, enter in all the details of it, /then/ check for name collisions. At that point we can prompt for a new name, with all the other data safely stored away.

Here's how that looks in code:

<?php
  $IF_MUSTBELOGGEDIN = true;
  include 'stdlib.php';
  $IF_PAGETITLE = "Create a new game"; 
  unset($_SESSION['IF_CURRENTGID']);
  unset($sess_IFCURRENTGID);  
  if (isset($_POST['Name'])) {
  $result = interfict_query("INSERT INTO games (Name, Owner, Difficulty, DateCreated, Info, MinCharLevel, PointsAdjust, RandomEncounters, RandomEncounterStrength, Status, Length) VALUES ('', $sess_ID, {$_POST['Difficulty']}, $IF_CURRENTTIME, '', {$_POST['MinCharLevel']}, {$_POST['PointsAdjust']}, {$_POST['RandomEncounters']}, {$_POST['RandomEncounterStrength']}, 1, {$_POST['Length']});");

    if ($result) { // game created successfully
      $newgame = interfict_insert_id();
      $_POST['Name'] = smart_addslashes(trim($_POST['Name']));    
      $result = interfict_query("SELECT ID FROM games WHERE Name = '{$_POST['Name']}';");

      if (interfict_num_rows($result) || $_POST['Name'] == "") {
        $BADNAME = true;
      } else {
        interfict_query("UPDATE games SET Name = '{$_POST['Name']}' WHERE ID = $newgame;");
        header("location: wc_editgame.php?GID=$newgame");
      } 
    } else {
      safe_quit();
    }   
  }

  if (isset($_POST['NewName'])) {
    $result = interfict_query("SELECT ID FROM games WHERE Owner = $sess_ID ORDER BY ID DESC LIMIT 1;"); 
    if (interfict_num_rows($result)) {
      extract(interfict_fetch_assoc($result), EXTR_PREFIX_ALL, 'game');
       $_POST['Name'] = trim($_POST['NewName']);  // copy this across for easier printing in the form on failure 
      $result = interfict_query("SELECT ID FROM games WHERE Name = '{$_POST['Name']}';");
      if (interfict_num_rows($result) || $_POST['Name'] == "") {
        $BADNAME = true;
      } else {  
        interfict_query("UPDATE games SET Name = '{$_POST['Name']}' WHERE ID = $game_ID;");
        header("location: wc_editgame.php?GID=$game_ID");
      }
    } else {
      safe_quit();
    }
  }  
  include 'header.php';  
?>

With the rest of the world creation form filled in, we're editing the adventure. In order to keep down the amount of button-clicking required to edit games, one screen is used to link to all the other screens. The main page is wc_editgame.php, and shows at a glance all the items, rooms, races, classes, triggers, events, links, and NPCs in the game. From there, players can click through to add, edit, or delete things and see them updated live.

The actual code to generate wc_editgame is largely obvious, except for the manner in which things are deleted. As object deletion is really just "are you sure?" followed by deletion, I chose to implement it in JavaScript - clicking "Delete" pops up a box prompting the user to confirm, then deletes. If you look over the code you'll see I've tried to group items together logically - links are shown grouped by the room that they connect from, events are grouped by their triggers, etc.


= The changes =

Again, creating and editing things is largely a matter of having long, dull HTML forms and PHP validation wrapping around it. You'll be glad, then, that I've done all the hard work for you and created all the dull forms! Some minor changes were made to the SQL along the way:

* ConditionTypes has been expanded to offer tests for "has player got item", "has player not got item", and "is player over level".
* Healing and spellcasting values for classes now have tables of their own - this makes editing much easier. Previously we simply hard-coded the values for what healing level 4 means, but there's a table, "healing" for this now.
* Game states have been binned. These proved too difficult to explain to our testers. Game.php now recognises new players by the fact that they have no location.
* The itemtype field has now been split off into a table to give more flexibility

The biggest change, though, was the mass elimination of the mysql_query() function and its friends - if you look through the pages, you'll notice they now use interfict_query(), interfict_num_rows(), etc. The reason for this is because MySQL 4.1 has now been declared production quality, and so is likely to get rolled out everywhere over the next few months. MySQL 4.1 is incompatible with MySQL 4.0, as they put in a lot of work to improve the client protocol that connects PHP to a MySQL server. It's thanks to this that we now have the MySQLi extension as well as the MySQL extension to PHP, and by using functions like interfict_query() we can abstract the database to work with both 4.0 and 4.1.

More importantly, though, is that by using interfict_query() we can add some extra functionality, such as the ability to store all the queries that go by in a special table, sqllog. This has been implemented, but the way I've done it might at first strike you as odd - if you look in sql.txt you'll notice there's an ID field, marked BIGINT, and not using AUTO_INCREMENT. Also, if you look at the way I've written the interfict_insert_id() function you'll notice it doesn't just call mysql_insert_id() or mysqli_insert_id(). The reason for this is because the mysql_insert_id() (and mysqli_insert_id() like it) returns the auto-increment value for the last insert query, or, if the last insert query didn't generate an auto-increment ID, returns nothing.

The problem? Several times in Interfict we want to insert a field then grab the ID of that new field - creating characters, for example. Now, if we're using interfict_query() to do that, it will automatically insert a row into sqllog for each query executed, which means it will overwrite the last insert ID if sqllog had an auto-increment value. It doesn't, so instead it will just replace the last insert ID with an empty string. D'oh! However, there is a special MySQL function called LAST_INSERT_ID() that doesn't get overwritten if the last insert didn't create an auto-incrementing value, which means we can use that as long as sqllog doesn't have an auto-incrementing ID. Make sense? If not, don't worry about it - if you don't want to log the SQL queries whizzing by, none of this is important.

There's another minor tweak with the inclusion of the smart_addslashes() function. This is something I had hoped to see in the PHP core - it checks whether PHP is automatically adding slashes to GET and POST data, and, if not, adds slashes itself. This is also not really necessary if you plan to run the code only on one server (and you have full control over that server), but as we're programming for portability we don't want to rely on people having their magic_quotes_gpc switch enabled in php.ini.

Finally, there's some "unique" SQL mixed in there that has quite complex behaviour. The easiest of these is the REPLACE INTO command found in the wc_edit pages, which works like the INSERT INTO command except that you also specify an ID number for the record. If MySQL finds that a row with that ID number exists already, it replaces it with the new information - perfect for what we want.

The other hard SQL lies in game.php, where I've used a three-table join to selectively show links where the condition is that players must be in possession of an item. This could be done with two queries: select all the links that require items, then check whether each item is owned by the player. However, I've squeezed it all into one query, which looks like this:

<pre>
SELECT r.ID, r.Name from rooms r, links l, itemslive i where l.FromRoom = $sess_IF_CURRENTLOCATION and r.ID = l.ToRoom AND l.ConditionType = 3 AND l.ConditionVar = i.Item AND i.CharacterOwner = $sess_IF_CURRENTCHAR AND i.LocationCurrent = -1;

Yes, that's an absolute horror of an SQL statement, but then querying against three tables is never going to be pretty! Notice that I've used table aliases so that the rooms table can be referred to just as "r". What that query does it grab all the IDs and Names of rooms that are linked to from the current room, where the link condition is 3 ("player must have picked up object") and that object has a LocationCurrent value of -1, meaning "picked up". To check for the object not being picked up, we simply use "!= -1".


Making things better

Although we've really only scratched the roleplaying surface so far, it's already quite a complicated code base - over 4000 lines of code across more than 30 files. It's not huge by any means, but it's big enough that you may already be struggling to remember where certain functions are kept. This is a veritable Petri dish for programming bugs to develop, which means we need to try to crack down a bit to get things cleaned up.

There are two options here:

Rewrite the game system from scratch using object-orientation (Please, no! - Ed) so that you just call methods on game objects, such as $room->print(); Smarten up the code we have already so that it's well commented, looks cleaner, runs faster, and is easier to remember.

What we want to do is get things moved out from from game.php into stdlib.php. This might sound a little off-kilter at first: stdlib.php is included in every script on the site, so adding code into there that's only used in one script might seem to make things harder rather than easier - and potentially slower, too! However, there are two things that offset that belief in its entirety:

When you use a PHP accelerator, the code is cached and so doesn't require re-parsing every time. Game.php will be the most-used script on the entire site, which means we really want to get as much complexity out of there as possible to make it as transparent as possible.

The #1 culprits for complexity in the script right now are the drop, use, examine, and take options, all of which really clutter the top of the code. We can isolate these into functions inside stdlib.php quite easily: item_take(), item_use(), and item_drop() can be made largely by copying and pasting the code into stdlib.php. Some minor changes are needed, such as declaring $IF_WARNINGS as global, but other than that it's a walk in the park. Here's the new code for item_use() so you can see how similar it is:

function item_use($item_ID) {
    GLOBAL $IF_WARNINGS;
    GLOBAL $sess_IF_CURRENTCHAR;
    GLOBAL $sess_IF_CURRENTLOCATION;  

    $result = interfict_query("SELECT Item FROM itemslive WHERE ID = $item_ID AND LocationCurrent = -1 AND CharacterOwner = $sess_IF_CURRENTCHAR;");  
    if (!interfict_num_rows($result)) {
      safe_quit();
    } else {
      $result = interfict_query("SELECT l.Item, i.UseRight, i.UseWrong, i.UseRightTrigger, i.LocationUsed, i.DeleteOnUse, i.DropOnUse FROM itemslive l, items i WHERE l.ID = $item_ID AND i.ID = l.Item;");
      if (!interfict_num_rows($result)) safe_quit;
   
      extract(interfict_fetch_assoc($result), EXTR_PREFIX_ALL, 'use');   

      if ($use_LocationUsed == -1 || $use_LocationUsed == $sess_IF_CURRENTLOCATION) { // this item is successfully used here!
        $IF_WARNINGS .= $use_UseRight . '<BR /><BR />';

        if ($use_DeleteOnUse) {
          interfict_query("DELETE FROM itemslive WHERE ID = $item_ID;");
        } else {
          if ($use_DropOnUse) {
            interfict_query("UPDATE itemslive SET LocationCurrent = $sess_IF_CURRENTLOCATION WHERE ID = $item_ID;");
          }
        }

        if ($use_UseRightTrigger) {
          call_trigger($use_UseRightTrigger);
        }
      } else { // unsuccessful
        $IF_WARNINGS .= $use_UseWrong . '<BR /><BR />';

        if ($use_DropOnUse) {
          interfict_query("UPDATE itemslive SET LocationCurrent = $sess_IF_CURRENTLOCATION WHERE ID = $item_ID;");	  
        }
      }
    }
  }

The next big culprit for lots of code in game.php is the code that sets up the initial player details - starting items, etc. This is only called once ever per player, which means it's just adding clutter to an important file. So, this needs to get shunted out into stdlib.php also.

If you make those two changes, you'll see game.php come down in size by about 50%, which makes a huge difference in terms of readability - now, all we need to do is get rid of the bugs...


Where you can go from here...

What we've got at this point is a fully working game system where you can walk around virtual worlds created by others, pickup and use objects, and complete goals. Quite a few things are missing, though, that you can try adding yourself - here are some ideas, sorted by difficulty:

  • Allow GMs to set the start room for their adventure, as opposed to forcing them to use the first room they created.
  • Allow GMs to change the core game options they set up when creating the game - name, difficulty, etc.
  • Allow GMs to see how many characters are playing their game.
  • If you're really interested in making the game database-independent, you can switch it over to using PEAR::DB.
  • Implement hunger in the game. The infrastructure for this is already in place, you just need to subtract 1 from the current hunger value every so often, and write the code that implements food.
  • Items already have a SellWorth field that determine how much they can be sold for - you can try implementing shops where these items could be sold. Although it's possible to do this very easily, you could really add a great element to the game by having simple personalities for shopkeepers - some might be willing to pay more for weapons, others more for potions, etc. Similarly, it would be good if players could buy items - GMs would need to define the shops that are available and what they buy and sell.
  • Non-player characters are not implemented at all right now. You could start by making them accept fixed choices of questions and providing fixed answers.
  • Fighting is entirely absent right now. Again, the infrastructure for this is largely in place - items have bonuses, players have strength and agility, etc. You now need to aggregate these into overall scores that take into account the weapon the player has chosen to use, the armour they are wearing, etc, and give them a final score for attack and defence.

Good luck!


MySQL 4.0 is dead; long live MySQL 4.1!

Switching from mysql_query() to interfict_query() means we've also made the leap from MySQL 4.0 to MySQL 4.1, at least in terms of the PHP function calls. However, apart from introducing an improved client protocol that happens to be imcompatible with its predecessors, MySQL 4.1 also brings with it the power of subqueries and prepared statements. If your idea of SQL is the basic SELECT, INSERT, UPDATE, and DELETE queries that are the minimum to get by when using MySQL, you've got another think coming: subqueries will rock your world by allowing you to nest queries within queries. If this sounds crazy, you just need to see an example:

SELECT * FROM users WHERE ID in (SELECT UserID FROM competition_winners WHERE Year = 2003);

So, there MySQL will run the SELECT UserID query to pull out all the competition winners for 2003, and put the results of that query (all the user IDs) into the outer query ("SELECT * from users"). This, then, extracts all the information from the users table for all people who have won a competition during 2003. Currently this can be done using joins (merging two tables together through a SELECT) or simply by doing two queries and merging them with PHP, however neither is ideal: joins are hard to read, hard to write, hard to execute, and hard to optimise, and using PHP to filter your SQL data is a serious misuse of resources.

The problem with joins is that the SQL query optimiser parses the whole thing as one query, whereas with subselects you actually send two (or more) separate queries and then just bring together the results. Right now this has little difference in terms of optimisation, but remember that MySQL 4.0 introduced a query cache where queries that are the same get their results stored and therefore execute fast the next time around. For normal queries this works perfectly, but it doesn't quite work yet for subqueries - the query cache stores the query as a string, and only re-uses the result if the query is byte-for-byte the same. When it comes to subqueries, ideally you'd want to see the query cache remember each subquery individually, which would let you mix and match the subqueries while taking full advantage of the caching, but that's not possible yet.

Here's hoping!