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