PHP - Interfict inventories

From LXF Wiki

Table of contents

Practical PHP Programming

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


All SQL and no PHP makes us dull boys, but we're making up for it this month...


Although you might have thought that this PHP tutorial was pretty much never going to end - after all, the interactive fiction project we're working towards is extensive in its reach - but after this issue you'll be pleased to know that we're about half-way complete already. Last issue the rush was towards getting enough SQL in the database that we can create a character and have them stand in an example room. If you followed everything step-by-step, you should have your "game" working, with the players essentially permanently located in The King's Head pub.

What we're going to be working on this month is adding some more locations, linking up those locations so we have a working world, and maybe even putting objects into the world that can be picked up - we'll see how we get on. As the site is data-centric in every way, we'll keep on returning to SQL: I recommend you keep the table schema to hand.


Adding more rooms

While it's true that journalists enjoy whiling away many an hour in pubs, the same can't always be said for those playing our game. So, while the King's Head could well be an exciting location, it's not going to keep the attention span of most players more than a few seconds. The solution is clear: to please the punters we need to add more rooms, so it's back to the SQL drawing board temporarily.

Planning out how rooms should be described and how they should interact with the story is an intricate and involving process requiring a lot of creativity, fore-thought, design skill, and strategy. That said, it's also a very /time-consuming/ process, so here are three I made earlier:

INSERT INTO rooms (Game, Name, Info, SafeToRest, SafeFromEncounters, SafeToAttack TINYINT,
CallTrigger, CallTriggerOnce) VALUES (1, 'The main street', 'This is the main street of Beastieland.', 1, 1, 1, 0, 0);

INSERT INTO rooms (Game, Name, Info, SafeToRest, SafeFromEncounters, SafeToAttack TINYINT,
CallTrigger, CallTriggerOnce) VALUES (1, 'The palace', 'Guards block your way ahead, which leads to the
main palace entrance.  To the west, however, is a small walk-way leading alongside one of the smaller
palace buildings.', 1, 1, 1, 0, 0);

INSERT INTO rooms (Game, Name, Info, SafeToRest, SafeFromEncounters, SafeToAttack TINYINT,
CallTrigger, CallTriggerOnce) VALUES (1, 'Your house', 'It\'s small, leaks water, and is plague-ridden, but it\'s home.', 1, 1, 1, 0, 0);

Paste that directly into the MySQL Monitor for your game database and our world will have expanded a little. However, there are still two things that stop players actually getting to these exotic new locations:

  • We haven't put links between the rooms
  • Even if we had put in some links, we don't print links out in the HTML for players to click on

Two problems require two solutions, so we need to add the SQL code to link the rooms together. If you recall, links are one way only: we need to add a link from A to B and from B to A in order to allow players to travel between rooms freely.

So, here's the SQL code to add these new links:

INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (2, 1, 0);
INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (2, 3, 0);
INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (2, 4, 0);
INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (1, 2, 0);
INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (3 ,2, 0);
INSERT INTO links (FromRoom, ToRoom, Condition) VALUES (4, 2, 0);

So, they can travel from room 2 to room 1 ("The main street" to "The King's Head pub"), from room 2 to room 3 ("The main street" to "The palace"), etc. Of course, that's all technical: player still can't actually make the move because we don't present the movement options along with room descriptions. That's easy enough to fix - after the code to print out the room information, add this:

  echo "<BR /><BR /><CENTER>";

  $result = mysql_query("SELECT r.ID, r.Name FROM links l, rooms r WHERE
  fromroom = $sess_IF_CURRENTLOCATION AND r.ID = l.ToRoom AND l.ConditionType = 0;");

  while ($r = mysql_fetch_array($result)) {
    extract($r, EXTR_PREFIX_ALL, 'link');
    echo "<A HREF=\"game.php?RID=$link_ID\">$link_Name</A><BR />";
  }

  echo "</CENTER>";

Although the PHP there is quite easy, the SQL might be more than you're used to - depending on your knowledge of SQL, that is. What it does is query from two tables simultaneously because the links table doesn't include the names of rooms and the rooms table doesn't include the link information. This could be rewritten in two queries, but it's much slower. The data is linked together so that we get the name and ID of table that are linked to from this room, which is just what we want - each link is printed out.

When a player clicks a movement link, they reload game.php with the RID value set to the ID number of the room they want to move to. This alone isn't enough to make the move, though: we need to check that the new value is actually an acceptable move for the player to make, otherwise malicious users can easily edit the URL in their browser. If the move checks out, we need to commit the change. Here's how that looks in PHP:

if (isset($_REQUEST['RID'])) {
    if ($_SESSION['IF_CURRENTLOCATION'] != $_REQUEST['RID']) {
      $result = mysql_query("SELECT ID FROM links WHERE FromRoom = $sess_IF_CURRENTLOCATION
      AND ToRoom = {$_REQUEST['RID']};");

      if (!mysql_num_rows($result)) safe_quit();

      $_SESSION['IF_CURRENTLOCATION']= $_REQUEST['RID'];
      $sess_IF_CURRENTLOCATION = $_REQUEST['RID'];
      mysql_query("UPDATE characters SET Location = $sess_IF_CURRENTLOCATION WHERE
      ID = $sess_IF_CURRENTCHAR;");
    }
  }

So, if RID is set, the first thing that is done is to check whether the current location is the same as the new location - this is a possibility if our player just hit refresh, which really should never do anything in the game, but for now it just stops the movement code executing. Moving on, we verify that the move is valid by checking there is a row that allows movement from one room to another - the query is done, then we check whether there are any rows returned. Rows returned means that a row exists, and therefore that a link exists. Later on we'll be looking at how we can add variables to this so that a link is only active when certain things have been done - more on that in a subsequent issue.

If the move is valid, the session location is updated, the local copy of the location is updated (all the session variables are extracted every request to avoid constantly jumping into the array - it's slow and ugly!), and also the database is updated so that if the player quits their game is already saved.

This is one of those milestone points where the game is in a fully consistent state: you should be able to save all your work and go ahead and walk through your miniature world. I strongly recommend you try adding more rooms and links between them so you fully understand how those tables work together.


Itemised inventory

If you get in the right frame of mind, our game already has enough to make it quite fun. That is, if you pitch it as a riddle - answer this question and you'll know which way to go - it's already good enough. However, we're aiming more towards a role-playing game where objects that are picked up can then be used to alter the path of the adventure. Sure, it's within limits, but then even /Nethack/ is scripted to a degree.

Because there are quite a few permutations of how items can be used, the items table in our schema is quite complex. It's reproduced /// LOCATION ///, and I just want to walk you through it in a more comprehensive manner before we get on to the coding.

So, items have IDs, a Game they are attached to, and a Name. So far, so good. However, they also have a ShortDesc and an Info field, which gives them a total of three description fields: what's the difference, and why? Well, the Name of the item should always be its exact, canonical name: The Sword of Doom, etc. The ShortDesc of the item should be a rough description of the item /as it can be made out at a distance/. If you think about it, no one is going to be able to recognise a sword as being a particular type of sword just by seeing it across the room - they will only realise that when they have picked it up. So, before players have collected the item, they see only the ShortDesc description of if, but once they have picked it up, they see it as Name. The Info field contains a longer, descriptive explanation of the item, such as "The Sword of Doom was crafted deep in the fires of LXF Towers by the evil tyrant Nick the Miserly" - players will get this when they have picked up an item and examined it.

Moving on, the Type field is as described in LXF57, as is DamageMin and DamageMax. UseWrong and UseRight are both strings that contain the text to be printed out when the item is used incorrectly and correctly respectively. General items that can be used anywhere will not have a UseWrong field, because they can't help but be used right, and items that aren't designed to be "used", such as swords (which are fought with, not "used"), won't have a UseRight field.

The UseRightTrigger field is linked to the UseRight field in that when the item is used in the correct location (determined by the LocationUsed field), the trigger ID held in UseRightTrigger is executed. This allows the game to not only print out a "You used the item" message, but also take some action based on it that changes the world. The ExperienceUseRight field is also part of this: the value here is added to the player's experience level when they use the item correctly.

The next two are interesting, and are really what make the items system powerful: DeleteOnUse and DropOnUse. The first, when set to true, causes the item to be deleted from the system when used, which might make sense for vials of healing potion or a magical scroll that dissolves after being used. The second, when set to true, causes the item to be dropped when used, which makes sense for items such as rocks. Anyway, it's good to give GMs to the choice. LocationFound and LocationUsed are predictable enough - they are room IDs where this item is found and where it is used.

Most of the rest are irrelevant for now, except CreateWhenState and AutoStart, both of which dictate when the item is created. If AutoStart is set, the item is automatically given to the player when they start playing the adventure, which is perfect for giving players a basic weapon and maybe a little money to get started with. CreateWhenState is used with the GameStates table, and it allows the GM to set a game state (eg "Part two: assaulting the Dreaded Veitch Fortress") and have items auto-created for that state.

So, that's how items work. For now we're going to add a basic sword and set it to be found in the "Your house" room. We can then write the code to pick up the sword, examine it, and drop it.

Here's the SQL for our sword:

INSERT INTO items (Game, Name, ShortDesc, Type, DamageMin, DamageMax, Info, UseWrong, UseRight, UseRightTrigger,
LocationFound, LocationUsed, DeleteOnUse, DropOnUse, SellWorth, FightingAdjust, DefenceAdjust, MinLevel,
CreateWhenState, AutoStart) VALUES (1, 'Sword of Doom', 'A sword', 1, 1, 3, 'It\'s a big sword.', 'You can\'t use a
sword like that!', '', 0, 4, 0, 0, 0, 100, 1, 0, 1, 1, 0);

As the CreateWhenState value is set to 1, it should be created on game start. Furthermore, the AutoStart value is 0, which means it will be created in room 4 (LocationFound), and left there waiting for the player to find. Well, I say "created", but right now our code doesn't actually put out game items, so we need to make a minor adjustment so that all initial game items are created, and all AutoStart items are given to the player.

We already have the code block that starts with "if ($char_GameState == -1)" - this is where the initial game setup is done, but we need to extend that so that the initial items are created.

Here's how that looks in PHP:

// create all starting game items
    $result = mysql_query("SELECT ID, LocationFound FROM items WHERE Game = $sess_IF_CURRENTGAME AND
    CreateWhenState = $char_GameState AND AutoStart = 0;");
    while ($r = mysql_fetch_assoc($result)) {
      extract($r, EXTR_PREFIX_ALL, 'item');
      mysql_query("INSERT INTO itemslive (Item, LocationCurrent, CharacterOwner) VALUES
      ($item_ID, $item_LocationFound, $sess_IF_CURRENTCHAR);");
    }

    // give player their starting game items
    $result = mysql_query("SELECT ID, LocationFound FROM items WHERE Game = $sess_IF_CURRENTGAME AND AutoStart = 1;");
    while ($r = mysql_fetch_assoc($result)) {
      extract($r, EXTR_PREFIX_ALL, 'item');
      mysql_query("INSERT INTO itemslive (Item, LocationCurrent, CharacterOwner, IsTaken) VALUES
      ($item_ID, -1, $sess_IF_CURRENTCHAR);");
    }

That translates to "create all world items that are in the current game state, then create all items and assign them to the player for the current game state". Perfect! Put that just after the rest of the code in the if statement.


Monkey see, monkey use

With that done, we're almost at our next milestone. What's missing, though, is the ability to see the items in-game, and also the ability to interact with the items. Solving the first problem is relatively simple: we've already added links to the current room, and we need to extend that by making the game print out a list of available items in the room.

$result = mysql_query("SELECT l.ID, i.ShortDesc FROM itemslive l, items i WHERE l.LocationCurrent =
$sess_IF_CURRENTLOCATION AND l.CharacterOwner = $sess_IF_CURRENTCHAR AND i.ID = l.Item ORDER BY i.Name ASC;");

  if (mysql_num_rows($result)) {
    echo "<B>Items here: </B>";
    echo "<UL>";

    while ($r = mysql_fetch_array($result)) {
      extract($r, EXTR_PREFIX_ALL, 'item');
      echo "<LI>$item_ShortDesc - [<A HREF=\"game.php?examine=$item_ID\">Examine</A> |
      <A HREF=\"game.php?take=$item_ID\">Pick up</A>]</LI>";
    }
  }

Yes, that's yet another very complex-looking SQL statement, but it just uses the same technique as before: combining two tables into one result. This time it's needed because the general items table only contains the list of possible items in the adventure, and the itemslive table only contains the list of items active currently in the adventure - neither of them alone contain enough information to tell what is currently in the room. So, only by merging the two tables together can we tell what items are available for pick up, along with their description.

Once we have the items retrieved, they get printed out. Note that both the Examine and Pick up links both link back to game.php, but they pass in GET parameters called "examine" and "take", but set to the ID of the item from the itemslive table. As with the RID move variable, these need to be caught on page load, verified as legal, then accepted into the game.

Have a think about how the code for all that might work- it's really not so hard. If you're stuck, here's how it works:

if (isset($_REQUEST['examine'])) {
    $result = mysql_query("SELECT Item FROM itemslive WHERE ID = {$_REQUEST['examine']} AND
   (LocationCurrent = $sess_IF_CURRENTLOCATION OR LocationCurrent = -1) AND CharacterOwner = $sess_IF_CURRENTCHAR;"); 

    if (!mysql_num_rows($result)) {
      safe_quit();
    } else {
      $result = mysql_query("SELECT l.Item, i.Info FROM itemslive l, items i WHERE l.ID =
      {$_REQUEST['examine']} AND i.ID = l.Item;");

      if (!mysql_num_rows($result)) safe_quit;

      extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'examine');

      $IF_WARNINGS .= $examine_Info; 
    }
  }

  if (isset($_REQUEST['take'])) {
    $result = mysql_query("SELECT Item FROM itemslive WHERE ID = {$_REQUEST['take']} AND LocationCurrent
    = $sess_IF_CURRENTLOCATION AND CharacterOwner = $sess_IF_CURRENTCHAR;");

    if (!mysql_num_rows($result)) {
      safe_quit();
    } else {
      $result = mysql_query("SELECT l.Item, i.ShortDesc FROM itemslive l, items i WHERE l.ID =
      {$_REQUEST['take']} AND i.ID = l.Item;");

      if (!mysql_num_rows($result)) safe_quit();

      extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'take');
      $IF_WARNINGS .= "You pick up " . strtolower($take_ShortDesc) . "<BR /><BR />";

      mysql_query("UPDATE itemslive SET LocationCurrent = -1 WHERE ID = {$_REQUEST['take']};");  
    }
  }

The majority of those two code blocks are the same: they both check whether the item is in the current room, and, if not, quit immediately. If so, the "examine" code grabs the description of the item and tags it onto the $IF_WARNINGS variable so that it gets printed out as the page loads. The "take" code prints out the description of the item (note that it's the ShortDesc description, because players now know what they have), then updates the database so that the item is marked as picked up by the player.

This is another milestone in our game: not only can players move now, but they can also find, examine, and pick up objects in the game world. Allowing them to use the objects isn't so far away, and then all that remains is creating the admin system and doing some cleanups and optimisation. First things first, though: we need to update the links and room description system so that different text can be displayed depending on the current state of the game. If you thought SQL was hard work, get ready for regular expressions: we need to parse some complex text!


Eureka!

While writing this piece I had an epiphany. Back at OSCon in July I attended Tim O'Reilly's keynote where he talked about how freedom of source code wasn't equal to true freedom of data, and it struck me that in terms of Interfict there was definitely a problem. That is, even though the Interfict source code is free, the data inside it is not: people might be able to fork the code base, but they would never be able to extract the data to recreate the accumulated games.

So, what we need to do if Interfict is ever going to be truly free is to allow adventure authors to export their adventures into SQL so they can take it elsewhere or convert it to a separate format. We'll get onto that in a later issue...

Opus Dei

After many months of work I have uploaded an online PHP tutorial that covers everything from getting to grips with basic PHP, through creating images and using XML, to writing extensions. Please do check it out and let me know what you think: http://www.hudzilla.org/php - feedback is very welcome!


PHP 5 - not finished yet!

Released, but still under development...

Although we waited many long months for PHP 5 to be released, fixes are still being produced for it as you read this. Of course, it's all in the maintenance cycle now: PHP 5 is a great deal more stable than PHP 4 ever was, and the new fixes coming in are surprisingly thin on the ground. Instead, the developers are able to focus on real improvements to the language, which means that if you upgrade from PHP 5.0.0 to 5.0.2 you get two rather smart new features: PCRE auto-cleaning and platform-independent line returns.

Now, you might not have realised this, but when you use preg_replace() or other Perl-Compatible Regular Expression (PCRE) functions, PHP internally parses the regular expression and caches the compiled version of it. As a result, if you execute the same PCRE match at a later date (and you usually will), the cached version is ready for execution. Previously, though, there were no checks in place relating to how much memory this cache used up, so sites that make heavy use of a variety of regular expressions could potentially have run out of RAM! This is all changed now in the latest release: PHP regularly cleans out the regular expression cache to avoid the problem entirely.

Also new in PHP 5.0.2 is the addition of the PHP_EOL constant, which came about because I was writing a script to print data to the error log, and found it only worked well on Unix because Windows has a different line return - \r\n rather than just \n. While it's possible to write a script that examines PHP_OS and sets the appropriate line return automatically, I figured it would be easier just to have a PHP_EOL constant that was defined and available for scripts, so I sent in a simple patch to do just that.

Of course, there have been numerous bug fixes in the two versions since the original release - a total of 61 as of the time of writing. That might sound like a lot, but that's much less than were found in just the time between 4.0.0 and 4.0.1 back when they were released! There was one fairly important fix, though: previously, destructors were called after request shutdown, which meant that if you had used dl() to load any modules, instantiated objects from those modules, then let PHP to free them, it would have leaked the memory. The reason for this was because it would unload the extension before freeing the memory, resulting in it no longer being able to free those variables properly. This is fixed as of PHP 5.0.2, so it's well worth upgrading - if only for that alone!