You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

165 lines
4.6 KiB

class Player < ApplicationRecord
# progress shape:
# { "1" => { "answer_id" => 42, "result_id" => 99 }, "2" => { ... },
# "last_save" => { "answer_id" => 7 } }
LAST_SAVE_KEY = "last_save".freeze
SCORE_KEYS = %w[food_waste emissions income].freeze
RATINGS = { up: 1, down: -1 }.freeze
# Campaign tags carried in from the printed QR codes. See UtmTracking.
UTM_KEYS = %i[utm_source utm_medium utm_campaign utm_content].freeze
# Bucket for players who arrived without any campaign tags at all.
DIRECT_SOURCE_KEY = "direct".freeze
# Rank of every fixed screen, in the order the game walks them. Stage screens
# are dynamic ("stage_3", "stage_3_result") so they get a rank computed from
# the stage number instead of a fixed slot here.
STEP_RANKS = { "start" => 0, "facts" => 1, "intro" => 2,
"last_save" => 900, "done" => 901, "results" => 902 }.freeze
scope :rated, -> { where.not(rating: nil) }
scope :thumbs_up, -> { where(rating: RATINGS[:up]) }
scope :thumbs_down, -> { where(rating: RATINGS[:down]) }
# Stage screens interleave: stage 1 (10), its result (11), stage 2 (12)...
# Everything before stages sits below 10, everything after above 900.
def self.step_rank(step)
step = step.to_s
return STEP_RANKS[step] if STEP_RANKS.key?(step)
if (m = step.match(/\Astage_(\d+)(_result)?\z/))
10 + (m[1].to_i * 2) + (m[2] ? 1 : 0)
else
-1
end
end
# Never moves a player backwards -- replaying an earlier screen (or the
# browser back button) must not undo the funnel position they reached.
def record_step(step)
step = step.to_s
return if self.class.step_rank(step) <= self.class.step_rank(furthest_step)
update_columns(furthest_step: step, updated_at: Time.current)
end
def rate!(direction)
value = RATINGS[direction.to_s.to_sym]
return false unless value
update(rating: value, rated_at: Time.current)
end
def rating_direction
RATINGS.key(rating)
end
# ---------------------------------------------------------------- device
DEVICES = %w[mobile tablet desktop].freeze
# Tablets first: every tablet UA also carries a token that would read as a
# phone (Android tablets say "Android", the iPad used to say "like Mac OS X").
TABLET_PATTERN = /ipad|tablet|kindle|silk|playbook|android(?!.*mobi)/i
MOBILE_PATTERN = /mobi|iphone|ipod|phone|blackberry|iemobile|opera mini/i
# Crawlers advertise a browser engine, so they have to be turned away before
# the desktop catch-all claims them.
BOT_PATTERN = /bot|crawl|spider|slurp|preview|headless|monitor|curl|wget|python|ruby|http/i
scope :by_device, ->(device) { where(device: device) }
# Coarse device class from a User-Agent string. Deliberately crude -- we want
# the mobile/tablet/desktop split, not a device database. Anything we cannot
# place is nil rather than being dumped into "desktop", so the breakdown never
# pretends bots and oddities were people on laptops.
#
# Known blind spot: iPadOS 13+ sends a desktop Safari UA by default, so some
# iPads land in "desktop". Nothing short of client-side probing fixes that.
def self.device_from_user_agent(user_agent)
ua = user_agent.to_s
return nil if ua.blank?
return nil if ua.match?(BOT_PATTERN)
case ua
when TABLET_PATTERN then "tablet"
when MOBILE_PATTERN then "mobile"
when /mozilla|webkit|gecko|opera/i then "desktop"
end
end
# Normalises an arbitrary country code to one of the leaderboard's known
# countries, or nil if it isn't one we track. Shared by the game controller
# (?country= override) and ResolvePlayerCountryJob (IP geolocation).
def self.valid_country_code(code)
code.to_s.downcase.presence_in(I18n.t("countries").keys.map(&:to_s))
end
def record_answer(stage, answer_id)
stage_data(stage)["answer_id"] = answer_id
save
end
def add_to_score(overall:, impact: {})
self.score = score.to_i + overall.to_i
impact.each do |key, value|
key = key.to_s
next unless SCORE_KEYS.include?(key)
scores[key] = scores[key].to_i + value.to_i
end
save
end
def score_for(key)
scores[key.to_s].to_i
end
def record_result(stage, result_id)
stage_data(stage)["result_id"] = result_id
save
end
def answer_id_for(stage)
progress[stage.to_s]&.dig("answer_id")
end
def result_id_for(stage)
progress[stage.to_s]&.dig("result_id")
end
def record_last_save_answer(answer_id)
stage_data(LAST_SAVE_KEY)["answer_id"] = answer_id
save
end
def last_save_answer_id
progress[LAST_SAVE_KEY]&.dig("answer_id")
end
private
def stage_data(stage)
progress[stage.to_s] ||= {}
end
end