RoR成就系统 - 多态协会&设计问题

时间:2010-08-21 17:06:57

标签: ruby-on-rails polymorphic-associations achievements

我正在尝试在Ruby on Rails中设计一个成就系统,并且遇到了我的设计/代码。

尝试使用多态关联:

class Achievement < ActiveRecord::Base
  belongs_to :achievable, :polymorphic => true
end

class WeightAchievement < ActiveRecord::Base
  has_one :achievement, :as => :achievable
end

迁移:

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

然后,当我尝试以下抛弃单元测试时,它会失败,因为它表示成就为空。

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

我的装置: 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

尽管如此,我无法让这个运行起来,也许在宏伟的计划中,这是一个糟糕的设计问题。我正在尝试做的是拥有一张包含所有成就及其基本信息(名称和描述)的表格。使用该表和多态关联,我想链接到其他表,这些表将包含完成该成就的标准,例如: WeightAchievement表将具有所需的重量和运动ID。然后,用户的进度将存储在UserProgress模型中,它将链接到实际的Achievement(而不是WeightAchievement)。

我需要单独表格中的标准的原因是因为不同类型的成就之间的标准会有很大差异,之后会动态添加,这就是为什么我不为每个成就创建单独的模型。

这甚至有意义吗?我是否应该将Achievement表与WeightAchievement等特定类型的成就合并(因此表格是name,description,weight_required,exercise_id),然后当用户查询成就时,在我的代码中我只搜索所有成就? (例如WeightAchievement,EnduranceAchievement,RepAchievement等)

1 个答案:

答案 0 :(得分:13)

成就系统通常的工作方式是可以触发大量不同的成就,并且有一组触发器可用于测试是否应该触发成就。

使用多态关联可能是一个坏主意,因为加载所有要完成的成就并测试它们都可能最终成为一项复杂的练习。还有一个事实是你必须弄清楚如何在某种表格中表达成功或失败的条件,但在很多情况下,你最终可能会得到一个没有如此整齐地映射的定义。你最终可能会有60个不同的表来表示所有不同类型的触发器,这听起来像是一个需要维护的噩梦。

另一种方法是根据名称,值等来定义您的成就,并使用一个常量表作为键/值存储。

以下是迁移示例:

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

achievements.proc列包含您评估的Ruby代码,用于确定是否应触发成就。通常,这可以作为您可以调用的实用程序方法加载,包装和结束:

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

TriggerConstant类定义了可以调整的各种参数:

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

在数据库中使用原始Ruby代码意味着无需重新部署应用程序就可以更轻松地动态调整规则,但这可能会使测试更加困难。

示例proc可能如下所示:

user.max_weight_lifted > TriggerConstant[:brickhouse_weight_required]

如果您想简化规则,可以创建一些可以自动将$brickhouse_weight_required扩展为TriggerConstant[:brickhouse_weight_required]的内容。这将使非技术人员更容易阅读。

为了避免将代码放入数据库中,有些人可能会觉得这些代码质量很差,您必须在某个批量过程文件中独立定义这些过程,并通过某种定义传递各种调整参数。这种方法看起来像:

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

调整Achievement表,以便存储有关要传递的选项的信息:​​

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

在这种情况下,trigger_options是一个序列化存储的映射表。一个例子可能是:

{ :weight_required => :brickhouse_weight_required }

结合这一点,你会得到一些简化的,eval快乐的结果:

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

你经常需要选择一堆Achievement个记录来查看它们是否已经实现,除非你有一个映射表,可以用松散的术语定义触发器测试的记录类型。更加强大的系统实现将允许您定义要为每个成就观察的特定类,但这种基本方法至少应该作为基础。