6

Estoy intentando diseñar un sistema de logros en Ruby on Rails y me he encontrado con un problema con mi diseño/código.RoR Achievement System - Asociación polimórfica y problemas de diseño

El intento de utilizar las asociaciones polimórficas:

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

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

Migraciones:

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 

Entonces, cuando intento esta prueba siguiente unidad de usar y tirar, se produce un error porque se dice que el logro es nulo.

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 

Y mis accesorios: 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 

Aunque, no puedo conseguir esto para ejecutar, tal vez en el gran esquema de cosas, es un problema de diseño malo. Lo que intento hacer es tener una sola tabla con todos los logros y su información base (nombre y descripción). Utilizando esa tabla y las asociaciones polimórficas, deseo vincular a otras tablas que contendrán los criterios para completar ese logro, p. la tabla WeightAchievement tendrá el peso requerido y la id del ejercicio. Luego, el progreso de un usuario se almacenará en un modelo UserProgress, donde se vincula con el Logro real (en lugar de WeightAchievement).

La razón por la que necesito los criterios en tablas separadas es porque los criterios variarán enormemente entre diferentes tipos de logros y se agregarán dinámicamente posteriormente, por lo que no estoy creando un modelo separado para cada logro.

¿Esto tiene sentido? ¿Debería simplemente fusionar la tabla de Logros con el tipo específico de logro como Logro de Peso (por lo que la tabla es nombre, descripción, peso_recomendado, ejercicio_id), cuando un usuario consulta los logros, en mi código simplemente busco todos los logros? (Por ejemplo, WeightAchievement, EnduranceAchievement, RepAchievement, etc.)

Respuesta

13

La forma en que generalmente funcionan los sistemas de logros es que hay una gran cantidad de logros diferentes que se pueden desencadenar, y hay un conjunto de factores desencadenantes que se pueden usar para probar si un logro debe ser activado.

Usar una asociación polimórfica es probablemente una mala idea porque cargar todos los logros para ejecutarlos y probarlos podría terminar siendo un ejercicio complicado. También está el hecho de que tendrás que averiguar cómo expresar las condiciones de éxito o fracaso en algún tipo de tabla, pero en muchos casos podrías terminar con una definición que no se correlaciona de manera tan nítida. Puede terminar teniendo sesenta tablas diferentes para representar todos los diferentes tipos de disparadores y eso suena como una pesadilla para mantener.

Un enfoque alternativo sería definir sus logros en términos de nombre, valor, etc., y tener una tabla constante que actúe como almacén de clave/valor.

He aquí una muestra de la migración:

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 

La columna achievements.proc contiene el código Ruby a evaluar para determinar si el logro se debe activar o no.Normalmente, esto se carga en, envuelto, y termina como un método de utilidad se le puede llamar:

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 

La clase TriggerConstant define varios parámetros que se pueden modificar:

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 

Tener el código Ruby crudo en su DB significa que es más fácil ajustar las reglas sobre la marcha sin tener que volver a implementar la aplicación, pero esto podría dificultar las pruebas.

Una muestra proc podría ser:

user.max_weight_lifted > TriggerConstant[:brickhouse_weight_required] 

Si desea simplificar sus reglas, es posible crear algo que se expande en $brickhouse_weight_requiredTriggerConstant[:brickhouse_weight_required] automáticamente. Eso lo haría más legible por personas no técnicas.

Para evitar poner el código en su DB, que algunas personas pueden encontrar de mal gusto, tendrá que definir estos procedimientos de forma independiente en algún archivo de procedimiento masivo, y pasar los distintos parámetros de ajuste por algún tipo de definición . Este enfoque se vería así:

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

Ajustar la mesa Achievement de modo que almacena información sobre las opciones para pasar en:

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

En este caso trigger_options es una tabla de asignación que se almacena en serie. Un ejemplo podría ser:

{ :weight_required => :brickhouse_weight_required } 

Combinando esto se obtiene un tanto simplificada, menos eval resultado feliz:

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 

A menudo se tiene que seleccionar la señal a través de toda una pila de Achievement registros para ver si Se ha logrado a menos que tenga una tabla de mapeo que pueda definir, en términos generales, qué tipo de registros prueban los factores desencadenantes. Una implementación más robusta de este sistema le permitiría definir clases específicas para observar para cada Logro, pero este enfoque básico debería al menos servir como base.

+1

Gracias - esto es esencialmente lo que estaba buscando pero no pude entender. – MunkiPhD

Cuestiones relacionadas