RoR Achievement System - Polymorphic Association and Design Challenges

I am trying to create an achievement system in Ruby on Rails and am confused with my design / code.

Attempting to use polymorphic associations:

class Achievement < ActiveRecord::Base belongs_to :achievable, :polymorphic => true end class WeightAchievement < ActiveRecord::Base has_one :achievement, :as => :achievable end 

Migrations:

 class CreateAchievements < ActiveRecord::Migration ... #code create_table :achievements do |t| t.string :name t.text :description t.references :achievable, :polymorphic => true t.timestamps end create_table :weight_achievements do |t| t.integer :weight_required t.references :exercises, :null => false t.timestamps end ... #code end 

Then, when I try to execute the next unit test reset, it fails because it says the achievement is null.

 test "parent achievement exists" do weightAchievement = WeightAchievement.find(1) achievement = weightAchievement.achievement assert_not_nil achievement assert_equal 500, weightAchievement.weight_required assert_equal achievement.name, "Brick House Baby!" assert_equal achievement.description, "Squat 500 lbs" end 

And my lights: achievements.yml ...

 BrickHouse: id: 1 name: Brick House description: Squat 500 lbs achievable: BrickHouseCriteria (WeightAchievement) 

weight_achievements.ym ...

  BrickHouseCriteria: id: 1 weight_required: 500 exercises_id: 1 

Despite the fact that I cannot get this to work, maybe in the great scheme of things, this is a bad design problem. I am trying to make one table with all the achievements and their basic information (name and description). Using this table and polymorphic associations, I want to bind to other tables that will contain criteria for completing this achievement, for example. The WeightAchievement table will have the required weight and exercise ID. Then the user's progress will be saved in the UserProgress model, where he will be tied to the actual achievement (as opposed to WeightAchievement).

The reason I need criteria in separate tables is because the criteria will vary greatly between different types of achievements and will be added dynamically afterwards, so I am not creating a separate model for each achievement.

Does that even make sense? Should I just merge the achievement table with a specific type of achievement, for example WeightAchievement (so that the table is the name, description, weight_required, exercise_id), and then when the user requests achievements, in my code I just look for all the achievements? (e.g. WeightAchievement, EnduranceAchievement, RepAchievement, etc.)

+6
design ruby-on-rails polymorphic-associations achievements
source share
1 answer

Typically, success systems are that there are many different achievements that can be triggered, and there must be a set of triggers that can be used to test or achieve success.

Using polymorphic association is probably a bad idea, because downloading all the achievements you need to go through and test them can be a difficult exercise. There is also the fact that you will need to figure out how to express the conditions of success or failure in some table, but in many cases you can get a definition that is not so well displayed. You can have sixty different tables to represent all the different triggers, and that sounds like a nightmare to maintain.

An alternative approach is to determine your achievements in terms of name, meaning, etc. and have a persistent table that acts as a key / value store.

Here's a migration example:

 create_table :achievements do |t| t.string :name t.integer :points t.text :proc end create_table :trigger_constants do |t| t.string :key t.integer :val end create_table :user_achievements do |t| t.integer :user_id t.integer :achievement_id end 

The achievements.proc column contains the Ruby code that you evaluate to determine whether the achievement should run or not. This usually loads, wrapped, and exits as a utility method that you can call:

 class Achievement < ActiveRecord::Base def proc @proc ||= eval("Proc.new { |user| #{read_attribute(:proc)} }") rescue nil # You might want to raise here, rescue in ApplicationController end def triggered_for_user?(user) # Double-negation returns true/false only, not nil proc and !!proc.call(user) rescue nil # You might want to raise here, rescue in ApplicationController end end 

The TriggerConstant class defines various parameters that you can configure:

 class TriggerConstant < ActiveRecord::Base def self.[](key) # Make a direct SQL call here to avoid the overhead of a model # that will be immediately discarded anyway. You can use # ActiveSupport::Memoizable.memoize to cache this if desired. connection.select_value(sanitize_sql(["SELECT val FROM `#{table_name}` WHERE key=?", key.to_s ])) end end 

Having the Ruby source code in your database means that it is easier to adjust the rules on the fly without having to redeploy the application, but this can make testing difficult.

A sample proc might look like this:

 user.max_weight_lifted > TriggerConstant[:brickhouse_weight_required] 

If you want to simplify your rules, you can create something that automatically extends $brickhouse_weight_required to TriggerConstant[:brickhouse_weight_required] . This would make it more understandable to non-technical people.

In order not to put the code in your database, which, according to some people, may be bad, you will have to independently determine these procedures in some kind of volumetric procedure file and transfer various settings using a specific definition. This approach would look like this:

 module TriggerConditions def max_weight_lifted(user, options) user.max_weight_lifted > options[:weight_required] end end 

Adjust the Achievement table so that it stores information about which parameters should be passed:

 create_table :achievements do |t| t.string :name t.integer :points t.string :trigger_type t.text :trigger_options end 

In this case, trigger_options is a mapping table that is stored in serialized form. An example would be:

 { :weight_required => :brickhouse_weight_required } 

Combining this, you get a slightly simplified, less eval happy result:

 class Achievement < ActiveRecord::Base serialize :trigger_options # Import the conditions which are defined in a separate module # to avoid cluttering up this file. include TriggerConditions def triggered_for_user?(user) # Convert the options into actual values by converting # the values into the equivalent values from `TriggerConstant` options = trigger_options.inject({ }) do |h, (k, v)| h[k] = TriggerConstant[v] h end # Return the result of the evaluation with these options !!send(trigger_type, user, options) rescue nil # You might want to raise here, rescue in ApplicationController end end 

You often have to draw the entire stack of Achievement records to make sure they are reached if you do not have a mapping table that can freely determine which records the triggers check. A more robust implementation of this system will allow you to define specific classes for monitoring each achievement, but this basic approach should at least serve as the basis.

+13
source share

All Articles