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
|
|
|
|
# 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
|