Search This Blog

Exploring Monster Taming Mechanics in Final Fantasy XIII-2: Finding Monster Materials

We've started asking deeper questions of the monster taming data in this Exploring Monster Taming Mechanics series, but so far we've ignored the fact that these monsters can level up once we've tamed them. Their starting stats and abilities will not stay the same while the monsters develop, so we should take that into account when we're trying to find the strongest monsters. Monsters that can level up to higher levels are going to end up stronger than other monsters that may start out stronger but can't level up as much. And the way these monsters level up is by applying certain materials dropped by defeating other monsters in battle. We need to know when and where we can get those materials, so that is the subject of this post.

Final Fantasy XIII-2 Battle Scene

Where Are The Monster Materials?

We have this monster materials table that lists all of the materials used to level up our monsters. The table has each material's name, its type, and its grade where higher grades of material are used on higher level monsters. This table does not, however, tell you where to find these materials. Since these materials are dropped by defeating monsters in battle, we could add a list of monsters that drop each material to this material table. Adding the data this way would require us to add some number of monsters to each material record, making sure that there's room in the table for the material dropped by the most monsters. The alternative is to add a material drop attribute to each monster. Since each monster drops at most one type of material, we only need to add one more attribute to the monster table to get this info, and then we can add references to and from the monster material table, just like we've done for other attributes. Conveniently, the monster table also contains the location of each monster, so it will be easy to see where we need to go for each material once this data is linked together. This approach seems to be a bit more sane, so let's go with it.

The first thing we need to do is figure out which monsters drop which materials, and to do that we're going to have to go back to parsing an FAQ text file. The goal is to grab the drop data out of the FAQ, add it to the monster CSV file, and reload the monster data into the database. The FAQ that we'll use here is the same one we've been using for most of this series, the Monster Infusion FAQ by BMSirius and sakurayule. It happens to also have the material drop information we need in the (shock!) monster locations/drops section.

Now that we've found the data, we just need to extract it, and to do that we can modify the monster FAQ parser state machine we built way back in the Data Collection episode. Since the drop section in the FAQ appears before the tamable monster list section that we've already written states for, we'll use the strategy of inserting states for collecting the drop data before the states for collecting the monster attributes. Where are we going to store this drop data so we can merge it with the monster data? Well, the data array that already exists is a fine place because it's already maintained through the state machine, and it happens to be empty while we're parsing through the drop section so let's just use what we've got. We'll make a hash table the first item in the array with the monster names as keys to hold the dropped items.

Before we start adding states, though, we'll need some constants for the section tags and the regexes that we'll use. These constants will be added to the beginning of monster_taming_parser.rb. Let's go with tags for the start and end of the drop section and regexes for the monster name, normal drop, and rare drop:
DROP_SECTION_TAG = "MLDETAV"
END_DROP_SECTION_TAG = "|~|~~~~~~~~~"
NEW_DROP_MONSTER_REGEX = /^([\w\-]+(?:\s\S+)?)/
NEW_DROP_REGEX = /^Normal Drop: (?:\d\d\d% - )?(\S+(?:\s\S+)*)/
NEW_RARE_DROP_REGEX = /^Rare Drop--: (?:\d[\d\.]\d% - )?(\S+(?:\s\S+)*)/
The tags are pretty self-explanatory since "MLDETAV" is literally the section tag in the FAQ for the desired section, and the END_DROP_SECTION_TAG is the first unique character string that occurs after the end of that section. Using the end tag allows us to be a little looser with our regexes because we won't be accidentally matching them in an intermediate section after the drop section and before we find the tamable monster list section. 

The NEW_DROP_MONSTER_REGEX will match on and capture any one or two words, including hyphenated words, that start at the beginning of a line. Most of the stuff in the drop section that we don't want is indented, so this regex will skip over those indented lines. It would however match on the same lines as NEW_DROP_REGEX and NEW_RARE_DROP_REGEX, so we'll need to be careful to run the drop regexes first. The drop regexes match when the line starts with "Normal Drop:" or "Rare Drop--:". It will then accept an optional three digit percentage or an "x.x%" before finally capturing any words that appear at the end of the line, which would be the name of the item. The percentage is how often the drop occurs, but we don't care about that for this purpose, so the regex doesn't capture it. 

With these regexes defined, we can add the states that we need to find and parse the drop section. The regexes are going to go between the section_tag_found state and the start state. Remember, the states are defined in reverse order so that state names that are used in other states are already defined.
section_tag_found = lambda do |line, data|
  if line.include? SECTION_TAG
    return find_separator, data
  end
  return section_tag_found, data
end

new_drop = lambda do |line, data|
  if NEW_DROP_REGEX =~ line
    drop_match = NEW_DROP_REGEX.match(line)
    data.first[data.last] = [drop_match[1]]
  elsif NEW_RARE_DROP_REGEX =~ line
    drop_match = NEW_RARE_DROP_REGEX.match(line)
    monster = data.pop
    data.first[monster] << drop_match[1]
  elsif NEW_DROP_MONSTER_REGEX =~ line
    monster_match = NEW_DROP_MONSTER_REGEX.match(line)
    return new_drop, data if monster_match[1] == 'Note'
    return new_drop, data << monster_match[1]
  elsif line.include? END_DROP_SECTION_TAG
    return section_tag_found, data
  end
  return new_drop, data
end

find_drop_separator = lambda do |line, data|
  if line.include? MONSTER_SEPARATOR
    return new_drop, data << {}
  end
  return find_drop_separator, data
end

drop_section_tag_found = lambda do |line, data|
  if line.include? DROP_SECTION_TAG
    return find_drop_separator, data
  end
  return drop_section_tag_found, data
end

start = lambda do |line, data|
  if line.include? DROP_SECTION_TAG
    return drop_section_tag_found, data
  end
  return start, data
end
Alright, let's walk through this backwards, starting at start. Instead of looking for the tamable monster section tag, we'll look for the DROP_SECTION_TAG. When we find it, it's actually the tag in the table of contents, so we need to look for it again. When we find it the second time, we know we're actually in the drop section, so we can look for the MONSTER_SEPARATOR. This string happens to be the same separator for both sections, so we can reuse it to find the first monster, and then we're into the table of monster drops themselves. We push the hash table onto the data array first so it'll be available to use in the next state.

That next state is new_drop, which looks for all of the pieces of the record for a drop: the normal drop item, the rare drop item, and the monster's name – in that order so we don't match on the monster's name for everything. During execution the NEW_DROP_MONSTER_REGEX is still going to match on the monster's name because the other two regexes will not match. We push this monster name onto the data array so we can use it as a new key in the hash table for when the normal and rare drops match. Also, notice that we're careful to not push the 'Note' string onto the data list because there are some 'Note:' lines in this table that will match the NEW_DROP_MONSTER_REGEX, but we don't want them clogging up the data array.

Then, when the NEW_DROP_REGEX matches, we get the name of the item and create a new hash entry using the last thing in the data array, which is the monster's name, as a key, and a new array with the first element as the dropped item. Luckily, monsters only have up to one normal drop and one rare drop, so we can make this a fixed array of two elements, a 2-tuple if you will. When the NEW_RARE_DROP_REGEX matches, we do a similar exercise, except we actually pop the monster's name off of the data array because we don't need it anymore, and we push the rare drop item name onto the array that already exists for that monster name key, making it a 2-tuple. Finally, when we reach the end of the section by finding the END_DROP_SECTION_TAG, we can move on to searching for the tamable monster section, and we're done finding all of the monster drops. Whew.

After this state machine runs, we have a data array that consists of a hash table of monster names as keys and 2-tuples of normal and rare drops as values, followed by a list of all of the monster records. We want to merge the hash table and subsequent list of records, and we can easily do this like so:
drops, *monsters = data
drops.default = ['None', 'None']
data = monsters.map do |monster|
  name = monster['Name']
  monster['Normal Drop'] = drops[name].first
  monster['Rare Drop'] = drops[name].last
  monster
end
The first line neatly separates the hash table into drops and the rest of the data array into monsters. Then we set a default 2-tuple for the drops hash table so that any monsters that don't exist in the drop table will have 'None' assigned for its drops. Finally, we run through the list of monsters and pull out the normal and rare drops from the hash table to assign them to attributes for the correct monster. 

One thing I did when running this script was to remove the default hash value so that I could verify that all of the missing monsters were, in fact, really missing. I did find a number of legitimately missing monsters for which we don't care about their drops: Chichu, Golden Chocobo, Silver Chocobo, Rangda, Leyak, Nanochu, Cactuarama, Cactuarina, Valfodr, and Sazh. I also found ten monsters that had mysteriously changed names:
  • Feral Behemoth 🠊 Behemoth
  • Greater Behemoth 🠊 Grand Behemoth
  • Munchkin Maestro 🠊 Munchkin Boss
  • Zwerg Metrodroid 🠊 Zwerg Metro
  • Metal Gigantuar 🠊 Metalligantuar
  • Pulsework Gladiator 🠊 Pulse Gladiator
  • Pulsework Knight 🠊 Pulse Knight
  • Pulsework Soldier 🠊 Pulse Soldier
  • Flowering Cactuar 🠊 Cactrot
  • Lieutenant Amodar 🠊 Amodar
It kind of looks like the captured monsters have a 14 character name limit, but it is funny that the wild Feral Behemoth becomes a plain Behemoth and the Flowering Cactuar becomes Cactrot. Domestication at its finest. I changed the names in the FAQ to be the captured names so that these monsters would match up properly between the two tables. Anyway, with the merge done, the only thing left is to add the verification patterns, like what was done in the Data Validation and Database Import episode, for "Normal Drop" and "Rare Drop" attributes to the VALID_MONSTER hash:
VALID_MONSTER = {
  "Name" => PROPER_NAME_REGEX,
  "Role" => PROPER_NAME_REGEX,
  # ... All the other attributes. They aren't all PROPER_NAME_REGEX. Really ...
  "Normal Drop" => PROPER_NAME_REGEX,
  "Rare Drop" => PROPER_NAME_REGEX,
}
And we can run the script to regenerate monsters.csv with the drops included.

Adding the Drops to the Monster Table

We could do this addition one of two ways: we could create a new migration to add the drop attributes to the database model and create a new seed script to populate them or we could edit the migration and seed scripts we have and delete and recreate the database. Let's burn it down and rebuild.

First, like we did in the Relational Data episode, we need to add the new attributes to the monster schema in db/migrate/<long_datecode>_create_monsters.rb:
class CreateMonsters < ActiveRecord::Migration[6.0]
  def change
    create_table :monsters do |t|
      t.string :name
      t.string :role
      t.references :location, foreign_key: true
      t.references :location2, foreign_key: {to_table: :locations}
      t.references :location3, foreign_key: {to_table: :locations}
      t.integer :max_level
      t.integer :speed
      t.string :tame_rate
      t.string :growth
      t.string :immune
      t.string :resistant
      t.string :halved
      t.string :weak
      t.references :normal_drop, foreign_key: {to_table: :materials}
      t.references :rare_drop, foreign_key: {to_table: :materials}
      # ... many more attributes ...
    end
  end
end
Note that the normal_drop and rare_drop attributes are created as references to the materials table because we want to link them that way. Also note that I refrained from naming normal_drop just drop. It doesn't seem right to call an attribute in a database table drop, does it? Next, we can add the connections for these attributes to the Monster model in app/models/monster.rb:
class Monster < ApplicationRecord
  belongs_to :location, class_name: 'Location', optional: true
  belongs_to :location2, class_name: 'Location', optional: true
  belongs_to :location3, class_name: 'Location', optional: true
  belongs_to :normal_drop, class_name: 'Material', optional: true
  belongs_to :rare_drop, class_name: 'Material', optional: true
  # ... many more belongs_to declarations ...
end
These declarations are where we get the normal_drop and rare_drop method names that we can later use in the controller, view, and db seed script, so they really need to be there right away before we import the data into the database. I forgot this and was confused for a while when my seed script didn't work. We also add the connections in the opposite direction to the Material model in app/models/material.rb:
class Material < ApplicationRecord
  has_many :normal_drop_monsters, :class_name => 'Monster', :foreign_key => 'normal_drop'
  has_many :rare_drop_monsters, :class_name => 'Monster', :foreign_key => 'rare_drop'
  # ... no more has_many declarations because this is a small table and this is it!
end
Not much more to say about this code. It's pretty standard by now. As for the db/seeds.rb script, it's an easy addition of two lines at the end of the loop that creates all of the monsters:
csv_file_path = 'db/monsters.csv'

CSV.foreach(csv_file_path, {headers: true}) do |row|
  # ... all of the other monster attribute processing code ...

  monster['normal_drop'] = Material.find_by(name: monster['normal_drop'])
  monster['rare_drop'] = Material.find_by(name: monster['rare_drop'])

  Monster.create!(monster)
  puts "#{row['name']} added!"
end
Easy as pie. This code even takes care of all of those drops that are not monster materials because it won't find them in the Material table and just assigns the monster attributes to nil. It's so elegant and simple, just how I like my code. At this point we're ready to wipe it and redo it at the command prompt:
$ rails db:drop
$ rails db:migrate
$ rails db:seed
When those commands are done running, we're almost ready to see what we've done.

Adding Materials to the Monster View

This section will be quick, as we only need to add a couple of table entries to the monster detail view in app/views/monster/show.html.erb:
<h1><%= @monster.name %></h1>

<table id="monster-table" class="table table-striped table-sm">
  <!--many table rows-->
  <tr>
    <td><strong>Halved for</strong></td>
    <td><%= @monster.halved %></td>
    <td><strong>Resistant to</strong></td>
    <td><%= @monster.resistant %></td>
  </tr>
  <tr>
    <td><strong>Normal Drop</strong></td>
    <td>
      <% if @monster.normal_drop %>
        <%= @monster.normal_drop.name %>
      <% else %>
        --
      <% end %>
    </td>
    <td><strong>Rare Drop</strong></td>
    <td>
      <% if @monster.rare_drop %>
        <%= @monster.rare_drop.name %>
      <% else %>
        --
      <% end %>
    </td>
  </tr>
  <!--many more table rows-->
</table>
We'll add the drops right after the halved and resistant attributes, and we can see that all of our script changes worked:

 
Screenshot of Narasimha monster with drop attributes

Filtering Monsters on Materials

Before we go, let's bring this exercise to its logical conclusion by adding links from the monster details page to the materials page, highlighting the material that was clicked, and add links to the materials page that go back to a filtered list of monsters that drop that material sorted by location depth. That way we can easily find the first monsters that drop a given material. 

First, we can edit the table row we just added to app/views/monster/show.html.erb to add a link with a :highlight tag to the monster material names:
<h1><%= @monster.name %></h1>

<table id="monster-table" class="table table-striped table-sm">
  <!--many table rows-->
  <tr>
    <td><strong>Halved for</strong></td>
    <td><%= @monster.halved %></td>
    <td><strong>Resistant to</strong></td>
    <td><%= @monster.resistant %></td>
  </tr>
  <tr>
    <td><strong>Normal Drop</strong></td>
    <td>
      <% if @monster.normal_drop %>
        <%= link_to @monster.normal_drop.name, 
                       material_index_path(highlight: @monster.normal_drop.name) %>
      <% else %>
        --
      <% end %>
    </td>
    <td><strong>Rare Drop</strong></td>
    <td>
      <% if @monster.rare_drop %>
        <%= link_to @monster.rare_drop.name, 
                       material_index_path(highlight: @monster.rare_drop.name) %>
      <% else %>
        --
      <% end %>
    </td>
  </tr>
  <!--many more table rows-->
</table>
The code is now littered with these features, so it's pretty easy to copy other examples in the code to make these changes work. The mechanics are all explained in detail in the Viewing the Monster Data episode. The next change is to assign the :highlight parameter that was passed in the URL to a variable in app/controllers/material_controller.rb:
class MaterialController < ApplicationController
  def index
    @materials = Material.all
    @highlighted = params[:highlight]
  end
end
Then we can use the @highlight variable in app/views/material/index.html.erb:
<h1>Monster Materials</h1>

<table id="material-table" class="table table-striped table-sm">
  <thead class="thead-dark">
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Grade</th>
      <th scope="col">Type</th>
    </tr>
  </thead>

  <% @materials.each do |material| %>
    <tr <%= "class=table-primary" if material.name == @highlighted %>>
      <td><%= link_to material.name, 
                               monster_index_path(:drop_filter => material.name) %></td>
      <td><%= material.grade %></td>
      <td><%= material.material_type %></td>
    </tr>
  <% end %>
</table>
Like in the other tables, we simply add a class of table-primary to the highlighted row. I also went ahead and added the link back to the monster table with a drop_filter parameter. To use the filter parameter, we can modify app/controllers/monster_controller.rb:
class MonsterController < ApplicationController
  def index
    if params[:filter]
      location = Location.find_by(name: params[:filter])
      @monsters = location.location_monsters +
                  location.location2_monsters +
                  location.location3_monsters
    elsif params[:ability_filter]
      ability = Ability.find_by(name: params[:ability_filter])
      @monsters = ability.get_all_monsters.sort_by { |monster| monster.first_location_depth }
    elsif params[:skill_filter]
      ability = RoleAbility.find_by(name: params[:skill_filter])
      @monsters = ability.get_all_monsters.sort_by { |monster| monster.first_location_depth }
    elsif params[:drop_filter]
      material = Material.find_by(name: params[:drop_filter])
      @monsters = material.get_all_monsters.sort_by { |monster| monster.first_location_depth }
    else
      @monsters = Monster.all
    end
  end

  def show
    @monster = Monster.find(params[:id])
  end
end
Finally, we need to define the super simple get_all_monsters for the Material class in app/models/material.rb:
class Material < ApplicationRecord
  has_many :normal_drop_monsters, :class_name => 'Monster', :foreign_key => 'normal_drop'
  has_many :rare_drop_monsters, :class_name => 'Monster', :foreign_key => 'rare_drop'

  def get_all_monsters()
    normal_drop_monsters + rare_drop_monsters
  end
end
Amazingly, with this change we are done, and we have all of the functionality we were aiming for with the monster materials table. Here's a view of monsters that drop Potent Sliver and are sorted by location depth:

Screenshot of monsters filtered by potent sliver drop

We should take a moment to appreciate all that we've accomplished here. In one sweep of the code we've parsed out the normal and rare drops for all monsters; merged those dropped items with the existing list of monsters; added the new drop attributes to the database and models; seeded the database with the material drops; connected the monster and material models together by the materials in common; linked the monster detail and material views together; and showed a filtered and sorted list of monsters that drop a selected material. That's basically a full review of everything we've been doing in this series, all wrapped into one exercise, and it wasn't all that much code! Next time, we'll finally take care of the last straggling table of monster characteristics and do something a little different with it.

No comments:

Post a Comment