115 lines
10 KiB
Python
115 lines
10 KiB
Python
"""Explicit UTC activity and reported-sales metric definitions."""
|
|
import statistics
|
|
|
|
|
|
def engagement(conn,game_id,days,player_ids=None):
|
|
daily=conn.execute("""
|
|
WITH calendar AS (SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
|
|
selected_players AS (SELECT id,first_seen FROM players WHERE game_id=%(gid)s AND (%(player_ids)s::bigint[] IS NULL OR id=ANY(%(player_ids)s))),
|
|
active AS (SELECT player_id,active_day FROM player_active_days WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s+29)),
|
|
first_sessions AS (SELECT DISTINCT ON(player_id) player_id,started_at,ended_at FROM sessions WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) ORDER BY player_id,started_at,id),
|
|
visits AS (SELECT a.active_day AS day,count(*) dau,count(*) FILTER(WHERE (p.first_seen AT TIME ZONE 'UTC')::date=a.active_day) new_users
|
|
FROM active a JOIN selected_players p ON p.id=a.player_id GROUP BY a.active_day),
|
|
times AS (SELECT active_day AS day,sum(playtime) total,count(*) observed,count(playtime) known FROM session_day_playtime
|
|
WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s-1) GROUP BY active_day),
|
|
starts AS (SELECT (s.started_at AT TIME ZONE 'UTC')::date AS day,count(*) sessions,
|
|
avg(extract(epoch FROM ended_at-started_at))::double precision avg_session,
|
|
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date)::double precision new_time,
|
|
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date) new_count,
|
|
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date)::double precision returning_time,
|
|
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date) returning_count
|
|
FROM sessions s JOIN selected_players p ON p.id=s.player_id
|
|
WHERE s.game_id=%(gid)s AND s.started_at>=current_date-(%(days)s-1) GROUP BY day),
|
|
first5 AS (SELECT (started_at AT TIME ZONE 'UTC')::date AS day,count(ended_at) known,
|
|
count(*) FILTER(WHERE ended_at-started_at>=interval '5 minutes') stayed5 FROM first_sessions GROUP BY day)
|
|
SELECT c.day,coalesce(v.dau,0)::int dau,coalesce(v.new_users,0)::int new_users,
|
|
(coalesce(v.dau,0)-coalesce(v.new_users,0))::int returning_users,
|
|
(SELECT count(DISTINCT player_id)::int FROM active a WHERE a.active_day BETWEEN c.day-29 AND c.day) mau,
|
|
coalesce(s.sessions,0)::int sessions,s.avg_session,
|
|
CASE WHEN t.known>0 THEN t.total WHEN t.observed>0 THEN NULL ELSE 0 END total_playtime,
|
|
s.new_time,s.new_count,s.returning_time,s.returning_count,f.known first_known,f.stayed5 first_stayed5
|
|
FROM calendar c LEFT JOIN visits v ON v.day=c.day LEFT JOIN times t ON t.day=c.day
|
|
LEFT JOIN starts s ON s.day=c.day LEFT JOIN first5 f ON f.day=c.day ORDER BY c.day
|
|
""",{"gid":game_id,"days":days,"player_ids":player_ids}).fetchall()
|
|
for r in daily:
|
|
r['average_playtime']=r['total_playtime']/r['dau'] if r['total_playtime'] is not None and r['dau'] else None
|
|
r['new_session_time']=r['new_time']/r['new_count'] if r['new_count'] else None
|
|
r['returning_session_time']=r['returning_time']/r['returning_count'] if r['returning_count'] else None
|
|
r['first_session_5min']=100*r['first_stayed5']/r['first_known'] if r['first_known'] else None
|
|
latest=daily[-1]
|
|
new_count=sum(r['new_count'] or 0 for r in daily)
|
|
returning_count=sum(r['returning_count'] or 0 for r in daily)
|
|
first_known=sum(r['first_known'] or 0 for r in daily)
|
|
known_time=[r['total_playtime'] for r in daily if r['total_playtime'] is not None]
|
|
active_users=conn.execute("SELECT count(DISTINCT player_id) total FROM player_active_days WHERE game_id=%s AND active_day>=current_date-(%s-1) AND (%s::bigint[] IS NULL OR player_id=ANY(%s))",(game_id,days,player_ids,player_ids)).fetchone()['total']
|
|
total=sum(known_time) if known_time else None
|
|
metrics={'dau':latest['dau'],'mau':latest['mau'],'newUsersToday':latest['new_users'],'returningUsersToday':latest['returning_users'],
|
|
'totalPlaytime':total,'averagePlaytime':total/active_users if total is not None and active_users else None,
|
|
'newSessionTime':sum(r['new_time'] or 0 for r in daily)/new_count if new_count else None,
|
|
'returningSessionTime':sum(r['returning_time'] or 0 for r in daily)/returning_count if returning_count else None,
|
|
'firstSession5min':100*sum(r['first_stayed5'] or 0 for r in daily)/first_known if first_known else None,
|
|
'activeUsers':active_users}
|
|
return {'metrics':metrics,'daily':daily}
|
|
|
|
|
|
def monetization(conn,game_id,days):
|
|
totals=conn.execute("""SELECT coalesce(sum(unit_price_robux*quantity),0)::double precision revenue,
|
|
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int paying_users,
|
|
coalesce(sum(quantity) FILTER(WHERE unit_price_robux>0),0)::int paid_products,
|
|
count(*) FILTER(WHERE player_id IS NULL AND unit_price_robux>0)::int unattributed_sales,
|
|
count(*)::int transactions FROM product_sales WHERE game_id=%s AND purchased_at>=current_date-(%s-1)""",(game_id,days)).fetchone()
|
|
active=conn.execute("""SELECT count(DISTINCT player_id)::int users,count(*)::int user_days FROM player_active_days
|
|
WHERE game_id=%s AND active_day>=current_date-(%s-1)""",(game_id,days)).fetchone()
|
|
valid=totals['unattributed_sales']==0
|
|
metrics={'revenue':totals['revenue'],'payingUsers':totals['paying_users'] if valid else None,
|
|
'payerConversion':100*totals['paying_users']/active['users'] if active['users'] and valid else None,
|
|
'arppu':totals['revenue']/totals['paying_users'] if totals['paying_users'] and valid else None,
|
|
'productsPerPayer':totals['paid_products']/totals['paying_users'] if totals['paying_users'] and valid else None,
|
|
'arpdau':totals['revenue']/active['user_days'] if active['user_days'] and valid else None,
|
|
'transactions':totals['transactions'],'unattributedSales':totals['unattributed_sales']}
|
|
daily=conn.execute("""WITH calendar AS(SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
|
|
sales AS(SELECT (purchased_at AT TIME ZONE 'UTC')::date AS day,sum(unit_price_robux*quantity)::double precision robux,
|
|
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int payers FROM product_sales
|
|
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY day),
|
|
active AS(SELECT active_day AS day,count(*)::int dau FROM player_active_days WHERE game_id=%(gid)s AND active_day>=current_date-(%(days)s-1) GROUP BY day)
|
|
SELECT c.day,coalesce(s.robux,0) robux,coalesce(s.payers,0) payers,coalesce(a.dau,0) dau FROM calendar c LEFT JOIN sales s ON s.day=c.day LEFT JOIN active a ON a.day=c.day ORDER BY c.day""",{'gid':game_id,'days':days}).fetchall()
|
|
for r in daily:
|
|
r['arpdau']=r['robux']/r['dau'] if r['dau'] and valid else None
|
|
r['conversion']=100*r['payers']/r['dau'] if r['dau'] and valid else None
|
|
if not valid:
|
|
r['payers']=None
|
|
hourly=conn.execute("""WITH hours AS(SELECT generate_series(date_trunc('day',now())-(%(days)s-1)*interval '1 day',date_trunc('hour',now()),interval '1 hour') AS hour),
|
|
sales AS(SELECT date_trunc('hour',purchased_at) AS hour,sum(unit_price_robux*quantity)::double precision robux FROM product_sales
|
|
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY hour)
|
|
SELECT h.hour,coalesce(s.robux,0) robux FROM hours h LEFT JOIN sales s ON s.hour=h.hour ORDER BY h.hour""",{'gid':game_id,'days':days}).fetchall()
|
|
products=conn.execute("""SELECT p.id,p.roblox_product_id,p.name,p.current_price_robux,
|
|
coalesce(sum(s.unit_price_robux*s.quantity),0)::double precision robux,
|
|
coalesce(sum(s.quantity) FILTER(WHERE s.unit_price_robux>0),0)::int quantity,
|
|
count(DISTINCT s.player_id) FILTER(WHERE s.unit_price_robux>0)::int buyers
|
|
FROM products p LEFT JOIN product_sales s ON s.game_id=p.game_id AND s.product_id=p.id AND s.purchased_at>=current_date-(%s-1)
|
|
WHERE p.game_id=%s GROUP BY p.id ORDER BY robux DESC,p.id""",(days,game_id)).fetchall()
|
|
for p in products:
|
|
p['perPayer']=p['quantity']/totals['paying_users'] if totals['paying_users'] and valid else None
|
|
return {'metrics':metrics,'daily':daily,'hourly':hourly,'products':products}
|
|
|
|
|
|
def gamepasses(conn,game_id):
|
|
totals=conn.execute("""SELECT count(*)::int players,count(*) FILTER(WHERE EXISTS(
|
|
SELECT 1 FROM product_sales s WHERE s.game_id=p.game_id AND s.player_id=p.id AND s.unit_price_robux>0))::int payers
|
|
FROM players p WHERE p.game_id=%s""",(game_id,)).fetchone()
|
|
passes=conn.execute("""WITH payers AS(SELECT DISTINCT player_id FROM product_sales WHERE game_id=%s AND unit_price_robux>0 AND player_id IS NOT NULL)
|
|
SELECT gp.id,gp.name,gp.roblox_gamepass_id,
|
|
count(o.player_id)::int checked,count(o.player_id) FILTER(WHERE o.is_owned)::int owners,
|
|
count(o.player_id) FILTER(WHERE p.player_id IS NOT NULL)::int checked_payers,
|
|
count(o.player_id) FILTER(WHERE o.is_owned AND p.player_id IS NOT NULL)::int paying_owners,
|
|
min(o.checked_at) oldest_check,max(o.checked_at) newest_check
|
|
FROM gamepasses gp LEFT JOIN gamepass_ownership o ON o.game_id=gp.game_id AND o.gamepass_id=gp.id
|
|
LEFT JOIN payers p ON p.player_id=o.player_id WHERE gp.game_id=%s GROUP BY gp.id ORDER BY gp.id""",(game_id,game_id)).fetchall()
|
|
for gp in passes:
|
|
gp['playerPercent']=100*gp['owners']/totals['players'] if totals['players'] and gp['checked']==totals['players'] else None
|
|
gp['payerPercent']=100*gp['paying_owners']/totals['payers'] if totals['payers'] and gp['checked_payers']==totals['payers'] else None
|
|
gp['coverage']=100*gp['checked']/totals['players'] if totals['players'] else None
|
|
return {'metrics':{'gamepasses':len(passes),'trackedPlayers':totals['players'],'lifetimePayers':totals['payers']},
|
|
'gamepasses':passes,'denominators':totals,
|
|
'definition':'Current reported ownership across all tracked players and lifetime positive-price paying users. Unknown ownership is not treated as not owned.'}
|