# Gameplay analytics for the admin dashboard.
|
|
#
|
|
# Everything is derived from the `players` table -- there is no event log. Two
|
|
# sources are combined:
|
|
#
|
|
# * `progress` (JSONB) -- the answer a player picked on each stage. This is
|
|
# the record of what people *did*.
|
|
# * `furthest_step` -- the furthest screen a player reached, recorded by
|
|
# GameController#track_step. This is the record of where people *stopped*,
|
|
# including the ones who quit before answering anything.
|
|
#
|
|
# Reach for a stage deliberately does NOT use `furthest_step` ranking: the
|
|
# last-save early exit jumps a player straight to the end of the game, so a
|
|
# rank comparison would credit them with stages they never saw. Instead a stage
|
|
# counts as reached if the player answered it, or is sitting on it right now.
|
|
class GameAnalytics
|
|
include GameHelper
|
|
|
|
# Screens outside the stage loop, in the order they appear. The last-save
|
|
# branch is reported on its own rather than in the funnel -- it is an early
|
|
# exit, not a step everybody walks through.
|
|
PRE_STAGE_STEPS = %w[facts intro].freeze
|
|
POST_STAGE_STEPS = %w[done results].freeze
|
|
|
|
PERIODS = {
|
|
"all" => nil,
|
|
"24h" => 1.day,
|
|
"7d" => 7.days,
|
|
"30d" => 30.days
|
|
}.freeze
|
|
|
|
attr_reader :period
|
|
|
|
def initialize(period: "all")
|
|
@period = PERIODS.key?(period.to_s) ? period.to_s : "all"
|
|
end
|
|
|
|
|
|
def players
|
|
@players ||= begin
|
|
window = PERIODS[period]
|
|
window ? Player.where(created_at: window.ago..) : Player.all
|
|
end
|
|
end
|
|
|
|
|
|
def total_players = @total_players ||= players.count
|
|
def completed_players = @completed_players ||= players.where(is_done: true).count
|
|
def completion_rate = percent(completed_players, total_players)
|
|
|
|
|
|
# ---------------------------------------------------------------- ratings
|
|
|
|
def ratings
|
|
@ratings ||= begin
|
|
counts = players.rated.group(:rating).count
|
|
up = counts[Player::RATINGS[:up]].to_i
|
|
down = counts[Player::RATINGS[:down]].to_i
|
|
|
|
{
|
|
up: up,
|
|
down: down,
|
|
total: up + down,
|
|
up_share: percent(up, up + down),
|
|
down_share: percent(down, up + down),
|
|
# Share of people who saw the results screen and bothered to vote. A vote
|
|
# proves the player got there, so it also acts as the floor on reach --
|
|
# players backfilled from before step tracking stop at "done".
|
|
response_rate: percent(up + down, [ reached("results"), up + down ].max)
|
|
}
|
|
end
|
|
end
|
|
|
|
|
|
# ----------------------------------------------------------------- funnel
|
|
|
|
# One row per screen, with how many players got that far and how many were
|
|
# lost since the previous row.
|
|
def funnel
|
|
@funnel ||= begin
|
|
rows = PRE_STAGE_STEPS.map { |step| { key: step, label: step_label(step), reached: reached(step) } }
|
|
|
|
(1..n_stages).each do |i|
|
|
rows << { key: "stage_#{i}", label: "Stage #{i}: #{stage_nodes[i - 1].title}", reached: reached_stage(i) }
|
|
end
|
|
|
|
rows += POST_STAGE_STEPS.map { |step| { key: step, label: step_label(step), reached: reached(step) } }
|
|
|
|
# Clamped at zero: a row can read higher than the one above it when older
|
|
# players predate step tracking and only have `progress` to go on.
|
|
previous = total_players
|
|
rows.map do |row|
|
|
lost = [ previous - row[:reached], 0 ].max
|
|
row.merge(
|
|
share: percent(row[:reached], total_players),
|
|
lost: lost,
|
|
lost_share: percent(lost, previous)
|
|
).tap { previous = row[:reached] }
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
# Where players who never finished gave up. Sorted worst-first so the
|
|
# stickiest screen is obvious.
|
|
def drop_off_points
|
|
@drop_off_points ||= players.where(is_done: false)
|
|
.where.not(furthest_step: nil)
|
|
.group(:furthest_step)
|
|
.count
|
|
.map { |step, count| { key: step, label: step_label(step), count: count } }
|
|
.sort_by { |row| -row[:count] }
|
|
end
|
|
|
|
|
|
# ---------------------------------------------------------------- answers
|
|
|
|
# Per stage, how the answers were split. `progress` stores the node the
|
|
# player landed on, so a chance answer is recorded as one of its *outcomes* --
|
|
# those are rolled back up under the chance node they came from.
|
|
def stages
|
|
@stages ||= stage_nodes.each_with_index.map do |node, index|
|
|
counts = answer_counts(index + 1)
|
|
answers = node.children.ordered.map { |answer| answer_row(answer, counts) }
|
|
total = answers.sum { |a| a[:count] }
|
|
|
|
{
|
|
index: index + 1,
|
|
node: node,
|
|
total: total,
|
|
answers: answers.map { |a| a.merge(share: percent(a[:count], total)) }
|
|
}
|
|
end
|
|
end
|
|
|
|
|
|
# The compost-vs-landfill choice. Only players who took the early exit ever
|
|
# see it, so `total` is much smaller than the stage totals.
|
|
def last_save
|
|
return { node: nil, total: 0, answers: [] } unless last_save_node
|
|
|
|
@last_save ||= begin
|
|
node = last_save_node
|
|
counts = players.where("jsonb_exists(progress, ?)", Player::LAST_SAVE_KEY)
|
|
.group(Arel.sql("progress->'#{Player::LAST_SAVE_KEY}'->>'answer_id'"))
|
|
.count
|
|
answers = node.children.ordered.map do |answer|
|
|
{ node: answer, label: answer.title, count: counts[answer.id.to_s].to_i }
|
|
end
|
|
total = answers.sum { |a| a[:count] }
|
|
|
|
{ node: node, total: total, answers: answers.map { |a| a.merge(share: percent(a[:count], total)) } }
|
|
end
|
|
end
|
|
|
|
|
|
# ----------------------------------------------------------------- scores
|
|
|
|
# Which ending finished players landed on. Early exits are excluded -- their
|
|
# headline comes from the last-save choice, not from the score, and they are
|
|
# already broken out in #last_save.
|
|
def result_bands
|
|
@result_bands ||= begin
|
|
scored = players.where(is_done: true)
|
|
.where("NOT jsonb_exists(progress, ?)", Player::LAST_SAVE_KEY)
|
|
.select(:score, :scores, :progress)
|
|
counts = scored.group_by { |player| result_state(player) }.transform_values(&:size)
|
|
total = counts.values.sum
|
|
|
|
%i[best balanced worst].map do |band|
|
|
{ band: band, count: counts[band].to_i, share: percent(counts[band].to_i, total) }
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
def average_score
|
|
@average_score ||= players.where(is_done: true).average(:score)&.round(1)
|
|
end
|
|
|
|
|
|
# -------------------------------------------------------------- who plays
|
|
|
|
def by_locale
|
|
@by_locale ||= breakdown(players.group(:locale).count)
|
|
end
|
|
|
|
|
|
def by_country
|
|
@by_country ||= breakdown(players.where.not(country: nil).group(:country).count)
|
|
end
|
|
|
|
|
|
# Rough time-on-task: a player row is touched on every screen, so the gap
|
|
# between created_at and updated_at is how long they were playing.
|
|
def median_duration
|
|
@median_duration ||= players.where(is_done: true).pick(
|
|
Arel.sql("percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (updated_at - created_at)))")
|
|
)&.round
|
|
end
|
|
|
|
|
|
private
|
|
|
|
|
|
def root_node
|
|
@root_node ||= Node.roots.viewable.first
|
|
end
|
|
|
|
|
|
def stage_nodes
|
|
@stage_nodes ||= root_node ? root_node.children.ordered.stage.to_a : []
|
|
end
|
|
|
|
|
|
def last_save_node
|
|
@last_save_node ||= root_node&.children&.last_save&.first
|
|
end
|
|
|
|
|
|
def n_stages = stage_nodes.size
|
|
|
|
|
|
# Cumulative reach for the fixed screens: anyone whose furthest step ranks at
|
|
# or above this one passed through it.
|
|
def reached(step)
|
|
rank = Player.step_rank(step)
|
|
@reach_by_rank ||= players.where.not(furthest_step: nil)
|
|
.group(:furthest_step)
|
|
.count
|
|
.transform_keys { |s| Player.step_rank(s) }
|
|
@reach_by_rank.sum { |r, count| r >= rank ? count : 0 }
|
|
end
|
|
|
|
|
|
def reached_stage(index)
|
|
players.where("jsonb_exists(progress, ?) OR furthest_step = ?", index.to_s, "stage_#{index}").count
|
|
end
|
|
|
|
|
|
def answer_counts(stage_index)
|
|
players.where("jsonb_exists(progress, ?)", stage_index.to_s)
|
|
.group(Arel.sql("progress->'#{stage_index.to_i}'->>'answer_id'"))
|
|
.count
|
|
end
|
|
|
|
|
|
def answer_row(answer, counts)
|
|
if answer.chance?
|
|
outcomes = answer.children.ordered.map do |outcome|
|
|
{ node: outcome, label: outcome.title, count: counts[outcome.id.to_s].to_i }
|
|
end
|
|
total = outcomes.sum { |o| o[:count] }
|
|
|
|
{ node: answer, label: answer.title, chance: true, count: total,
|
|
outcomes: outcomes.map { |o| o.merge(share: percent(o[:count], total)) } }
|
|
else
|
|
{ node: answer, label: answer.title, chance: false,
|
|
count: counts[answer.id.to_s].to_i, outcomes: [] }
|
|
end
|
|
end
|
|
|
|
|
|
def breakdown(counts)
|
|
total = counts.values.sum
|
|
counts.sort_by { |_key, count| -count }
|
|
.map { |key, count| { key: key, count: count, share: percent(count, total) } }
|
|
end
|
|
|
|
|
|
def step_label(step)
|
|
case step
|
|
when "facts" then "Facts"
|
|
when "intro" then "Intro"
|
|
when "last_save" then "Last save"
|
|
when "done" then "Final screen"
|
|
when "results" then "Results"
|
|
when /\Astage_(\d+)_result\z/ then "Stage #{$1} result"
|
|
when /\Astage_(\d+)\z/ then "Stage #{$1}"
|
|
else step.to_s.humanize
|
|
end
|
|
end
|
|
|
|
|
|
def percent(part, whole)
|
|
return 0.0 if whole.to_i.zero?
|
|
|
|
(part.to_f / whole * 100).round(1)
|
|
end
|
|
end
|