Requesting Attributes – Roguelike Design part 6 2


According to my post history, I started writing on this project 12 days ago. I had a working prototype a full week before that. I started taking notes on this idea at least 10 days prior to that. All-in, I’ve devoted a month to this project and all I have to show for it is a routine for generating a random weapon… that’s only about 2% of the final game!

A month’s worth of little moments here and there for a tiny fraction of a complete game makes it seem like this project is insurmountable. I don’t keep track of my development time, but if I had to guess, I’d say that I’ve put a good 20 hours into the project…. that’s hands on keyboard time. It would probably be more like 30+ if you count the time I’ve just pondered the details.

What these numbers don’t reveal, however, is how much of that time is setting up the outline for the website itself. Remember, I’m doing all this in vanilla PHP with an HTML/CSS front end. That means I don’t have a framework to handle my database for me… or a routing engine… or a template engine… or even a “normal” file structure. I’ve had to create all these things myself… and truthfully, I love this part of development. Many of these things have been borrowed from previous projects and adapted to fit this environment, but I’ve implemented quite a few new systems too that have been great learning experiences.

So, while I’ve invested 30+ hours into being able to generate a random weapon, I’d say only about 5 hours of that was the weapon creation routine itself… and all the lessons I’ve learned along the way means I’ll be able to spin up the next few generators (armor/items/trinkets/etc) much faster. But, that will be next post, for now, let’s talk about the final component of the weapon creation engine I wanted to solve.

Demanding an Attribute

I have all the randomness dialed in to what feels right to me. I can request any weapon and let the RNG decide what I get back, or I can request a weapon from a specific tier. I can let the default RNG decide what attributes I get for my new weapon, or I can fudge the numbers a bit by adding a multiplier to my RNG which can result in statistically more or less powerful weapons based on my multiplier. The last thing I want is the ability to demand a specific weapon or a specific attribute… or everything!

I started with the weapon first because we will always return a weapon when this routine is called. For reference, here is the weapon generation code before we start.

  // weapon
  if ($args['tier'] == 0) {  // select from a specific tier or range of tiers
    $sql = $pdo->query("SELECT * FROM weapon_type WHERE active = 1 ORDER BY RAND() LIMIT 1");
    $weapon_type = $sql->fetchAll(PDO::FETCH_ASSOC);
  } else {
    if(!empty($args['tier-variance']) && $args['tier-variance'] > 0) {  // tier may be higher or lower
      $swing = rand(0,100);
      switch (true) {  // determine how much variance
        case ($swing >= 80):
          $variance = 2; // occasionally, tier will be adjusted by 2 levels
          break;
        case ($swing >= 50):
          $variance = 1;
          break;
        default:
          $variance = 0;
      }
      $min_tier = $args['tier'] - $variance;
      $max_tier = $args['tier'] + $variance;
      $sql = $pdo->prepare("SELECT * FROM weapon_type WHERE active = 1 AND tier >= ? AND tier <= ? ORDER BY RAND() LIMIT 1");
      $sql->execute([$min_tier, $max_tier]);
    } else {
      $sql = $pdo->prepare("SELECT * FROM weapon_type WHERE active = 1 AND tier = ? ORDER BY RAND() LIMIT 1");
      $sql->execute([$args['tier']]);

    }
    $weapon_type = $sql->fetchAll(PDO::FETCH_ASSOC);
  }
  $weapon = sumWeaponAttributes($weapon, $weapon_type[0]);

Starting at the top, if($args['tier'] == 0 means we aren’t asking for anything specific, and we should just return a random weapon. So, we do, and exit that whole block.

If we are requesting a specific tier, we need to check and see if we requested variance $args['variance']. If so, we roll some random numbers and then select a weapon within a range of tiers with $min_tier and $max_tier being the bounds. Otherwise, we just get a random weapon from our requested tier.

Last, we add whatever weapon_type we retrieved to our $weapon with the sumWeaponAttributes() function.

What happens if we want a specific weapon? First we need to pass a new argument to our createWeapon() function and I chose to make it an associative array called $attribute[]. Each individual demand will have an entry in that array with the key being the attribute and the value being the name of the attribute we want. So, if we want to generate a sword, we would call our function like this

createWeapon([
  "tier" => 0, 
  "power" => 1.0,
  "attribute" => [
    "type" => "sword"
  ]
]);

If you’ve not done this with PHP before, on your front end… an HTML form for me… you pass your input with name="attribute[name]" where name is whatever it is you want to request: element/prefix/type/adjective/abstract. You can request one or all of those things with separate inputs for each of them and PHP will put everything with the name attribute[] into an associative array for you.

(HINT: the code for this is live, if you want to modify the form on https://noroguesallowed.com and play around with attribute requests, you can!)

There are a few ways to go about handling this. We could wrap the function from earlier in an outer IF/THEN where we check to see if $args['attribute']['type'] is set and if it is, we use that to get our weapon.

if (array_key_exists('type', $args['attribute']) {
  // Go get the weapon name stored in $args['attribute']['type']
} else {
  // Do the normal weapon selection routine
}

This was my initial approach, but it comes with a bit of a caveat: I’m checking for these attributes by name, which is stored in the word column in our database. The word column is an INDEX and it is UNIQUE so this shouldn’t be a problem… but, what if someone modifies the form and asks for a weapon type that doesn’t exist?

I spent a while on that question. I tried to come up with some clever SQL that would first check to see if the requested attribute exists and if not, it would return a random weapon. This approach works fine for weapons, as you want a weapon 100% of the time, but in the case of the other attributes, if you request something that doesn’t exist, and I return an attribute anyways, you are avoiding the RNG completely.

Also, it’s just ugly. Nested IFs get annoying very quickly when reading code. Especially when the procedure inside the IF/ELSE is long. Just keeping track of which IF you are reading under is difficult, so I chose a different path instead.

A Quick Aside

Before I show you the new function, I want to explain dbQuery() quickly. Before writing this article, I went through a good deal of code cleanup and tried to remove a whole bunch of copy/pasted code. Previously, I was handling queries like this:

$sql = $pdo->query("SELECT * FROM weapon_type WHERE active = 1 ORDER BY RAND() LIMIT 1");
 $weapon_type = $sql->fetchAll(PDO::FETCH_ASSOC);

This isn’t too heinous when it’s just a raw query, but when you start taking into account user input, you MUST ALWAYS sanitize your inputs to protect from someone injecting code into your SQL database. After adding all these attribute requests, I was copy/pasting this a bunch:

$sql = $pdo->prepare("SELECT * FROM weapon_type WHERE word = ? LIMIT 1");
$sql->execute([$args['attribute']['type']]);
$weapon_type = $sql->fetchAll(PDO::FETCH_ASSOC);

This is a PDO prepared statement and it takes three lines of code to execute. This is a lot of duplication to have to skip over while trying to read through a procedure with a good number of database calls. So, I use the following function to handle all my PDO SELECT queries and you will see this in the next part about attributes.

/**
 * Query the database and return a set of rows|null or the PDO object.
 *
 * @param   string     $query    PDO style query string with escaped inputs
 * @param   array      $params   Optional. Default empty array.
 *                               Array of inputs for the PDO Prepared statement.
 * @param   bool       $results  Optional. Default TRUE.
 *                               TRUE returns results as associative array
 *                               FALSE returns PDO object
 * @return  array|obj            Associative array of query results or PDO object.
 */
function dbQuery($query, $params=array(), $results=TRUE) {
   global $pdo;

   $sql = $pdo->prepare($query);
   $sql->execute($params);

   if($results) {
     return $sql->fetchAll(PDO::FETCH_ASSOC);
   } else {
     return $sql;
   }
}

Okay, So This is How I Do It

  //////////////////////////////////////////////////////////////////////////////
  // weapon
  //////////////////////////////////////////////////////////////////////////////
  if(!empty($args['attribute']) && array_key_exists('type', $args['attribute'])) { // try to fetch the requested weapon
    $weapon_type = dbQuery("SELECT * FROM weapon_type WHERE word = ? LIMIT 1", [$args['attribute']['type']]);
  }

  if(empty($weapon_type)) {
    // Do the normal weapon creation routine shown above.
  }

  $weapon = sumWeaponAttributes($weapon, $weapon_type[0]);

Instead of nesting the IF statements, I stack them. First, we try to get the requested weapon_type. If it doesn’t exist, dbQuery returns NULL and if(empty($weapon_type)) takes over, executing the normal routine.

Yes, it’s that easy. Yes, I spent Memorial Day working this out and refactoring the entire weapon creation routine. I also re-worked all the comments to be more DocBlock friendly, added more functionality to other parts of the site (like alert messages), and cleaned up a few more details… but as of right now, I’m calling this weapon routine “done” for now. It is very likely that at some point in the future I’ll have to tweak the numbers, but I’ll worry about that if and when there is a playable game.

https://noroguesallowed.com

The next steps will be to create routines to generate armor, shields, and trinkets… which are the other 3 random things a player might find in this game. They pose a few new special cases that we will discuss in the next post, but for the most part, they will function just like the weapon generator… which means, I’m almost done with those parts too!

Then the fun bits that will tie into this post tangentially: I want to allow you to input your own, new attributes into the database! Yeah, I’d like to crowd source my work a bit on generating these tables… so once the other generators are done, I’ll work on that part before moving forward with the actual game engine. Why not procrastinate for as long as possible!?

The Final Routine

And why not. Here is the final.final2.final.yes.final.php version of the weapon creation routine in it’s entirety. I’m quite certain there are ways of reducing this even more and making it cleaner… I just don’t want to put in the effort right now. If you have ideas about how to make this better, please let me know!

Also, I have the whole site hosted on a private github repo. If you want to take a look at the rest of the source, just let me know.

/**
 * Functions for creating and working with weapon items
 *
 * @author     Chevee Dodd
 * @copyright  2020 - Chevee Dodd, LLC
 * @license    https://noroguesallowed.com/LICENSE.md
 * @package    noRoguesAllowed
 * @since      0.1.0
 */

/**
 * Creates a fantasy weapon for the game No Rogues Allowed.
 *
 * @uses     sumWeaponAttributes()
 * @param    array  $args {
 *     Optional. An associative array of arguments. All arguments are optional.
 *
 *     @type int    'tier' Desired weapon tier. Default 0. Accepts 0-6.
 *     @type int    'power' Randomness modifier for assiging attributes. Default 1.
 *     @type array  'attributes' {
 *       An associative array of requested attributes.
 *
 *       @type string  'element' Desired element outcome.
 *       @type string  'prefix' Desired prefix outcome.
 *       @type string  'type' Desired type choice outcome.
 *       @type string  'adjective' Desired adjective outcome.
 *                     note: choosing an adjective will force an abstract
 *       @type string  'abstract' Desired abstract outcome.
 *    }
 * }
 * @return   array  Associative array of weapon stats.
 */
function createWeapon($args = ["tier" => 0, "power" => 1]) {
  global $pdo;
  // set defaults
  $weapon = [
    'name'=>'',
    'hands'=>0,
    'multiplier'=>0.0,
    'attack'=>0,
    'defense'=>0,
    'magic'=>0,
    'flaming'=>0.0,
    'freezing'=>0.0,
    'shocking'=>0.0,
    'holy'=>0.0,
    'evil'=>0.0,
    'sneak'=>0.0,
    'crit'=>0.0,
    'str'=>0
  ];

  //////////////////////////////////////////////////////////////////////////////
  // Element
  //////////////////////////////////////////////////////////////////////////////
  if(!empty($args['attribute']) && array_key_exists('element', $args['attribute'])) { // try to fetch the requested element
    $weapon_element = dbQuery("SELECT * FROM weapon_element WHERE word = ? LIMIT 1", [$args['attribute']['element']]);
    if(!empty($weapon_element)) {
      if ($weapon_element['word'] == 'crippling') {
        $weapon = sumWeaponAttributes($weapon, $weapon_element[0], 12);
      } else {
        $weapon = sumWeaponAttributes($weapon, $weapon_element[0], 6);
      }
    }
  }

  if (empty($weapon_element) && (rand(0, 100) * $args['power'])  >= 95) {   // 5% of all weapons
    // a very small percent will be crippling
    if ((rand(0, 100) * $args['power'])  >= 99) {  // 1% of the 5% with elements
      $weapon_element = dbQuery("SELECT * FROM weapon_element WHERE word = 'crippling' LIMIT 1");
      $weapon = sumWeaponAttributes($weapon, $weapon_element[0], 12);
    } else {
      $weapon_element_1 = dbQuery("SELECT * FROM weapon_element WHERE active = 1 ORDER BY RAND() LIMIT 1");
      $weapon = sumWeaponAttributes($weapon, $weapon_element_1[0], 6);
      // some weapons may have two elements
      if ((rand(0, 100) * $args['power'])  >= 98) {  // 3% of the 5% with elements receive a second
        $weapon['name'] .= '& ';
        $weapon_element_2 = dbQuery("SELECT * FROM weapon_element WHERE active = 1 AND id != ".$weapon_element_1[0]['id']. " ORDER BY RAND() LIMIT 1");
        $weapon = sumWeaponAttributes($weapon, $weapon_element_2[0], 11);
      }
    }
  }

  //////////////////////////////////////////////////////////////////////////////
  // prefix
  //////////////////////////////////////////////////////////////////////////////
  if(!empty($args['attribute']) && array_key_exists('prefix', $args['attribute'])) {  // try to fetch the requested prefix
    $weapon_prefix = dbQuery("SELECT * FROM weapon_prefix WHERE word = ? LIMIT 1", [$args['attribute']['prefix']]);
  }
  // roll for prefix
  if (empty($weapon_prefix) && (rand(0, 100) * $args['power'])  >= 75) {   // 25% of all weapons
    $weapon_prefix = dbQuery("SELECT * FROM weapon_prefix WHERE active = 1 ORDER BY RAND() LIMIT 1");
  }
  // add prefix
  if(!empty($weapon_prefix)) {
    $weapon = sumWeaponAttributes($weapon, $weapon_prefix[0], 3);
  }

  //////////////////////////////////////////////////////////////////////////////
  // weapon
  //////////////////////////////////////////////////////////////////////////////
  if(!empty($args['attribute']) && array_key_exists('type', $args['attribute'])) { // try to fetch the requested weapon
    $weapon_type = dbQuery("SELECT * FROM weapon_type WHERE word = ? LIMIT 1", [$args['attribute']['type']]);
  }

  if(empty($weapon_type)) {
    if ($args['tier'] == 0) {  // select from a specific tier or range of tiers
      $weapon_type = dbQuery("SELECT * FROM weapon_type WHERE active = 1 ORDER BY RAND() LIMIT 1");
    } else {
      if(!empty($args['tier-variance']) && $args['tier-variance'] > 0) {  // tier may be higher or lower
        $swing = rand(0,100);
        switch (true) {  // determine how much variance
          case ($swing >= 80):
            $variance = 2; // occasionally, tier will be adjusted by 2 levels
            break;
          case ($swing >= 50):
            $variance = 1;
            break;
          default:
            $variance = 0;
        }
        $min_tier = $args['tier'] - $variance;
        $max_tier = $args['tier'] + $variance;
        $weapon_type = dbQuery("SELECT * FROM weapon_type WHERE active = 1 AND tier >= ? AND tier <= ? ORDER BY RAND() LIMIT 1", [$min_tier, $max_tier]);
      } else {
        $weapon_type = dbQuery("SELECT * FROM weapon_type WHERE active = 1 AND tier = ? ORDER BY RAND() LIMIT 1", [$args['tier']]);
      }
    }
  }

  $weapon = sumWeaponAttributes($weapon, $weapon_type[0]);

  // weapons might be better or worse than normal condition.
  $condition = rand(0, round($weapon['attack'] * .4));
  if((rand(0, 100) * $args['power'])  >= 98) {
    // some weapons might be amazing
    $weapon['attack'] = $weapon['attack'] * 2;
  } else if((rand(0, 100) * $args['power'])  > 38) {
    $weapon['attack'] = $weapon['attack'] + $condition;
  } else {
    $weapon['attack'] = $weapon['attack'] - $condition;
  }

  //////////////////////////////////////////////////////////////////////////////
  // attribute and possibly adjective
  //////////////////////////////////////////////////////////////////////////////
  if(!empty($args['attribute']) && (array_key_exists('abstract', $args['attribute']) || array_key_exists('adjective', $args['attribute']))) { // try to fetch the requested weapon
    if (array_key_exists('adjective', $args['attribute'])) {
      $weapon_adjective = dbQuery("SELECT * FROM weapon_adjective WHERE word = ? LIMIT 1", [$args['attribute']['adjective']]);
    }

    if (array_key_exists('abstract', $args['attribute'])) {
      $weapon_abstract = dbQuery("SELECT * FROM weapon_abstract WHERE word = ? LIMIT 1", [$args['attribute']['abstract']]);
    }
  }

  if ((rand(0, 100) * $args['power'])  >= 65 || !empty($weapon_adjective) || !empty($weapon_abstract)) {   // 35% of all weapons receive an attribute
    $weapon['name'] .= 'of ';
    // roll for adjective
    if (empty($weapon_adjective) && (rand(0, 100) * $args['power'])  >= 60) {   // 40% of the 35% recieve an adjective
      $weapon_adjective = dbQuery("SELECT * FROM weapon_adjective WHERE active = 1 ORDER BY RAND() LIMIT 1");
    }
    // add adjective to weapon
    if (!empty($weapon_adjective)) {
      $weapon = sumWeaponAttributes($weapon, $weapon_adjective[0], 4);
    }
    // roll for abstract
    if (empty($weapon_abstract)) {
      $weapon_abstract = dbQuery("SELECT * FROM weapon_abstract WHERE active = 1 ORDER BY RAND() LIMIT 1");
    }
    // add abstract to weapon
    $weapon = sumWeaponAttributes($weapon, $weapon_abstract[0], 2);
  }

  //////////////////////////////////////////////////////////////////////////////
  // power modifier
  //////////////////////////////////////////////////////////////////////////////
  if ((rand(0, 100) * $args['power'])  >= 90) {
    $modifier = rand(1, 100);

    switch (true) {
      case ($modifier >= 98):
        $weapon['name'] .= '+5';
        $weapon['multiplier'] += 1;
        $weapon['str'] += 10;
        break;

      case ($modifier >= 92):
        $weapon['name'] .= '+4';
        $weapon['multiplier'] += .8;
        $weapon['str'] += 9;
        break;

      case ($modifier >= 85):
        $weapon['name'] .= '+3';
        $weapon['multiplier'] += .6;
        $weapon['str'] += 8;
        break;

      case ($modifier >= 70):
        $weapon['name'] .= '+2';
        $weapon['multiplier'] += .4;
        $weapon['str'] += 7;
        break;

      default:
        $weapon['name'] .= '+1';
        $weapon['multiplier'] += .2;
        $weapon['str'] += 5;
    }
  }
  $weapon['name'] = trim($weapon['name']); // strip trailing spaces
  if($weapon['hands'] < 1) $weapon['hands'] = 1;  // can't have a zero-handed weapon
  if($weapon['hands'] > 2) $weapon['hands'] = 2;  // can't use more than 2 hands
  return $weapon;
}


/**
 * Sums array keys for weapon attributes.
 *
 * @param   array  $weapon Weapon which attriute stats will be added.
 * @param   array  $array  Attribute array.
 * @param   int    $str How many points to add to the total weapon strength.
 * @return  array  The combined array with all values summed.
 */
function sumWeaponAttributes($weapon, $array, $str = 1) {
  foreach($array as $key=>$value) {
    if (array_key_exists($key, $weapon)) {
      $weapon[$key] += $value;
    }
  }
  $weapon['str'] += $str;
  $weapon['name'] .= ucwords(strtolower($array['word'].' ')) ;
  return $weapon;
}

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 thoughts on “Requesting Attributes – Roguelike Design part 6