Files
Stat-Dashboard/mockups/scenario_seed.py
T

272 lines
21 KiB
Python

"""Coherent synthetic histories: stable social game, declining racer, breakout RPG."""
from bisect import bisect_right
from collections import Counter,defaultdict
from datetime import datetime,timedelta,timezone
import math
from pathlib import Path
import random
from psycopg.types.json import Jsonb
from sample_scenarios import SCENARIOS,PERSONAS,acquisition_weight,retention_factor
DAY=timedelta(days=1)
def populate(conn,players_per_game=None):
from realistic_seed import copy_rows
now=datetime.now(timezone.utc)
midnight=now.replace(hour=0,minute=0,second=0,microsecond=0)
origin=midnight-89*DAY
conn.execute((Path(__file__).parent/'db'/'seed.sql').read_text(encoding='utf-8'))
for game in conn.execute('SELECT * FROM games WHERE sample_key=ANY(%s) ORDER BY id',(list(SCENARIOS),)).fetchall():
gid=game['id'];key=game['sample_key'];cfg=SCENARIOS[key]
rng=random.Random(20261104+list(SCENARIOS).index(key)*1009)
n=players_per_game or cfg['players']
# Retain internal game identities, but replace generic catalogs/definitions.
conn.execute('DELETE FROM activity_definitions WHERE game_id=%s',(gid,))
definitions={}
for activity,title,start_event,end_event,steps in cfg['activities']:
row=conn.execute('''INSERT INTO activity_definitions(game_id,key,name,start_event,end_event,configuration)
VALUES(%s,%s,%s,%s,%s,%s) RETURNING *''',(gid,activity,title,start_event,end_event,
Jsonb({'steps':steps,'fields':['score','reward','difficulty']}))).fetchone()
definitions[activity]=row
products=conn.execute('SELECT * FROM products WHERE game_id=%s ORDER BY id',(gid,)).fetchall()
for product,(title,price) in zip(products,cfg['products']):
conn.execute('UPDATE products SET name=%s,current_price_robux=%s WHERE id=%s',(title,price,product['id']))
product.update(name=title,current_price_robux=price)
conn.execute("DELETE FROM player_fields WHERE game_id=%s AND key IN ('quests','roundWins','achievements')",(gid,))
field_rows=[('playStyle','Playstyle','text'),('acquisitionSource','Acquisition source','text')]
field_rows += [(field,title,'decimal' if field=='bestLapSeconds' else 'number') for field,title in cfg['fields']]
for field,title,fmt in field_rows:
conn.execute('''INSERT INTO player_fields(game_id,key,label,format,source) VALUES(%s,%s,%s,%s,'snapshot')
ON CONFLICT(game_id,key) DO UPDATE SET label=excluded.label,format=excluded.format''',(gid,field,title,fmt))
conn.execute("UPDATE player_fields SET label=%s,format='number' WHERE game_id=%s AND key='cash'",
({'willow-v1':'Coins balance','orbit-v1':'Credits balance','dungeon-v1':'Gold balance'}[key],gid))
templates={'overview':['level','playStyle','cash','playtime','sessions','lastSeen'],
'behavior':['sessions','playStyle','avgSession','playtime','robux','purchases'],
'progress':['level','cash']+[f for f,_ in cfg['fields']]+['playtime']}
for t,columns in templates.items():
conn.execute('''INSERT INTO column_templates(game_id,key,label,columns) VALUES(%s,%s,%s,%s)
ON CONFLICT(game_id,key) DO UPDATE SET columns=excluded.columns''',(gid,t,t.capitalize(),Jsonb(columns)))
# Persist scenario provenance before the legacy seed helper runs.
props={'sampleVersion':3,'sampleScenario':cfg['scenario'],'genre':cfg['genre'],
'sampleAsOf':now.isoformat(),'sampleData':'fictional scenario/personality simulation; not real Roblox users'}
conn.execute('UPDATE games SET properties=properties||%s WHERE id=%s',(Jsonb(props),gid))
weights=[]
for day in range(90):
date=origin+day*DAY
weight=acquisition_weight(cfg,day)*(1.15 if date.weekday()>=5 else 1)
weight*=rng.uniform(.94,1.06)
if day==89: weight*=max(.01,(now-midnight).total_seconds()/86400)
weights.append(weight)
players=[]
for i in range(n):
cohort=rng.choices(range(90),weights)[0]
hour=rng.gauss(18,4)%24 if rng.random()<.72 else rng.uniform(0,24)
start=origin+cohort*DAY+timedelta(hours=hour)
if cohort==89:start=midnight+timedelta(seconds=rng.uniform(0,max(1,(now-midnight).total_seconds()-20)))
persona_weights=list(cfg['weights'])
if key=='dungeon-v1' and cohort>=85:persona_weights[0]*=1.35
if key=='orbit-v1' and cohort>=65:persona_weights[0]*=1.25
kind=rng.choices(list(PERSONAS),persona_weights)[0]
source=rng.choices(['home_recommendation','search','friends','sponsored'],
[.82,.07,.08,.03] if key=='dungeon-v1' and cohort>=85 else [.12,.42,.38,.08] if key=='willow-v1' else [.30,.32,.15,.23])[0]
name=rng.choice(['Luna','Pixel','Maple','Nova','Echo','Moss','Aero','Cloud','Fern','Mochi'])+rng.choice(['Fox','_','Plays','Cat','Builder','Runner'])+str(rng.randrange(10,9999))
players.append((gid,1000000000+gid*100000+i,name if rng.random()>.005 else None,start,start,
{'seedVersion':3,'seedIndex':i,'simulationGroup':kind,'personaLabel':cfg['labels'][kind],
'acquisitionSource':source,'cohortDay':cohort}))
copy_rows(conn,'players',['game_id','roblox_user_id','display_name','first_seen','last_seen','metadata'],players)
stored=conn.execute('SELECT * FROM players WHERE game_id=%s ORDER BY id',(gid,)).fetchall()
plans=[];traits={};states={}
for player in stored:
pid=player['id'];kind=player['metadata']['simulationGroup'];spec=PERSONAS[kind]
is_payer=rng.random()<spec['payer']
budget=round(rng.lognormvariate(math.log(max(1,spec['budget'])),.6)) if is_payer else 0
if kind=='whale':budget=min(250000,budget*({'willow-v1':.5,'orbit-v1':2,'dungeon-v1':3}[key]))
traits[pid]={'kind':kind,'budget':budget,'source':player['metadata']['acquisitionSource'],
'device':rng.choices(['phone','desktop','tablet'],[.69,.23,.08] if key=='willow-v1' else [.57,.37,.06])[0]}
state=defaultdict(int);state['level']=1;state['rating']=900
states[pid]=state
dates=[player['first_seen']]
base=player['first_seen'].replace(hour=0,minute=0,second=0,microsecond=0)
affinity=rng.uniform(.75,1.25)
for age in range(1,(midnight-base).days+1):
if kind=='tourist':break
date=base+age*DAY;day=(date-origin).days
fast_decay=5 if kind in ('social','grinder') else 2.5
chance=(spec['fast']*math.exp(-(age-1)/fast_decay)+spec['tail']*math.exp(-(age-1)/spec['decay']))
chance*=cfg['retention']*affinity*retention_factor(cfg,day)*(1.08 if date.weekday()>=5 else .97)
if rng.random()<min(.94,chance):dates.append(date+timedelta(hours=rng.gauss(18,4)%24))
previous_end=None
for visit,date in enumerate(dates):
repeat=1+(rng.choices([1,2],[.85,.15])[0] if rng.random()<spec['repeat'] else 0)
for j in range(repeat):
start=date if j==0 else previous_end+timedelta(minutes=rng.uniform(20,150))
if previous_end and start<previous_end:start=previous_end+timedelta(minutes=20)
if start>now or start.date()!=date.date():continue
day=(start-origin).days
mean=spec['minutes']*cfg['minutes']*(1.15 if visit else 1)
if key=='orbit-v1' and day>=57:mean*=.58+.42*math.exp(-(day-57)/18)
if key=='dungeon-v1' and day>=80 and kind!='tourist':mean*=1.12
seconds=max(18,min(10800,rng.lognormvariate(math.log(mean*60),.68)))
actual_end=start+timedelta(seconds=seconds)
end=None if actual_end>now or rng.random()<.002 else actual_end
row=[gid,pid,start,end,rng.choice(cfg['places']),{'device':traits[pid]['device'],'source':'scenario-v3','playStyle':cfg['labels'][kind]}]
plans.append((row,min(actual_end,now)));previous_end=actual_end
plans.sort(key=lambda item:item[0][2])
ids=[r['id'] for r in conn.execute("SELECT nextval(pg_get_serial_sequence('sessions','id')) id FROM generate_series(1,%s)",(len(plans),))]
copy_rows(conn,'sessions',['id','game_id','player_id','started_at','ended_at','place_name','stats'],[[sid]+row for sid,(row,_) in zip(ids,plans)])
events=[];sales=[];snapshots=[];runs=[];prompts=[];visits=Counter();intervals=[];purchase_times=defaultdict(list)
def event(name,pid,sid,at,props=None,category='events'):
events.append((gid,category,name,pid,sid,at,props or {}))
def activity(code,pid,sid,start,end,seconds,kind,day):
definition=definitions[code];steps=definition['configuration']['steps']
progress=[definition['start_event']];completed=0
skill={'competitor':.96,'grinder':.94,'collector':.86,'social':.8,'whale':.86,'budget':.74,'impulse':.6,'tourist':.45}[kind]
for i,step in enumerate(steps):
probability=min(.97,skill+(.04 if i==0 else -.05*i))
if code=='race' and i==1 and day>=57:probability*=.48+.52*math.exp(-(day-57)/18)
if code=='dungeon' and i==0 and day>=80:probability=min(.96,probability+.12)
if code=='crafting' and i==1 and states[pid]['materials']<8:probability*=.45
if rng.random()>probability:break
progress.append(step);completed+=1
progress.append(definition['end_event'])
state=states[pid];outcome='completed' if completed==len(steps) else 'abandoned'
score=round(seconds*rng.uniform(.3,2)*(1.5 if kind=='competitor' else 1))
if code=='hangout':
state['friendsMet']+=int(completed>=2);state['socialMinutes']+=round(seconds/60)
elif code=='collection' and completed:
state['collectionCount']+=rng.randint(1,4);event('collectible_found',pid,sid,start+(end-start)*.5,{'set':'forest' if rng.random()<.6 else 'festival'})
elif code=='race':
state['raceStarts']+=1
if completed==len(steps):
won=rng.random()<(.52 if kind=='competitor' else .16)
state['roundWins']+=int(won);outcome='won' if won else 'finished'
lap=round(rng.uniform(78,180)*( .82 if kind=='competitor' else 1),2)
state['bestLapSeconds']=min(state.get('bestLapSeconds') or lap,lap)
state['rating']+=rng.randint(12,25) if won else rng.randint(-12,8)
else:state['rating']=max(300,state['rating']-rng.randint(3,12))
elif code=='garage' and completed==len(steps):state['upgradeLevel']+=1
elif code=='dungeon' and completed==len(steps):
state['dungeonClears']+=1;state['bossKills']+=1;state['quests']+=1;state['materials']+=rng.randint(8,25)
elif code=='crafting' and completed==len(steps):
state['crafts']+=1;state['materials']=max(0,state['materials']-8)
captured={'score':score,'reward':100 if outcome not in ('abandoned',) else 0,'difficulty':'ranked' if code=='race' else 'normal'}
runs.append((gid,definition['id'],pid,sid,start,end,outcome,completed,0,captured,progress))
for i,name in enumerate(progress):event(name,pid,sid,start+(end-start)*i/max(1,len(progress)-1),captured,'activities')
for sid,(row,actual_end) in zip(ids,plans):
_,pid,start,end,place,stats=row;trait=traits[pid];kind=trait['kind'];spec=PERSONAS[kind];state=states[pid]
day=(start-origin).days;visits[start.replace(hour=0,minute=0,second=0,microsecond=0)]+=1;intervals.append((start,actual_end))
event('session_start',pid,sid,start,{'device':trait['device'],'acquisition_source':trait['source']})
if end is None:continue
seconds=(end-start).total_seconds();state['playtime']+=seconds;state['sessionCount']+=1
income=round(seconds*({'willow-v1':.65,'orbit-v1':1.4,'dungeon-v1':2.1}[key])*(1.7 if kind=='grinder' else .55 if kind=='social' else 1)*rng.uniform(.8,1.2))
if key=='orbit-v1' and day>=65:income=round(income*2.7)
state['cashEarned']+=income;state['cash']+=income
sink=spec['sink']*(.24 if key=='orbit-v1' and day>=65 else 1)
expense=round(state['cash']*rng.uniform(sink*.4,sink*1.5))
state['cash']-=expense;state['cashSpent']+=expense
old_level=state['level'];state['level']=1+int((state['playtime']*spec['xp']/300)**.68)
if state['level']>old_level:event('level_up',pid,sid,start+(end-start)*.5,{'level':state['level']})
# Sources are drawn before outcomes. Personality, price, budget and intent drive decisions.
player_chance={'tourist':.025,'social':.09,'collector':.45,'competitor':.1,'grinder':.035,'budget':.30,'impulse':.38,'whale':.85}[kind]
player_count=int(rng.random()<player_chance)
if kind=='whale' and player_count:player_count=rng.randint(2,6) if key!='willow-v1' else rng.randint(1,3)
automatic=cfg['automatic']*(1.6 if key=='orbit-v1' and day>=65 else 1)
sources=['player']*player_count+(['game'] if rng.random()<automatic else [])
if seconds<35:sources=[]
slot=min(45,seconds*.65/max(1,len(sources)))
for j,source in enumerate(sources):
product_weights=[.04,.12,.28,.56] if kind=='whale' else [.45,.4,.12,.03] if kind in ('budget','impulse') else [.18,.4,.32,.1]
if key=='willow-v1' and kind=='social':product_weights=[.8,.15,.04,.01]
product=rng.choices(products,product_weights)[0]
price=product['current_price_robux']
if key=='orbit-v1' and day>=65 and product is products[2]:price=round(price*1.25)
if key=='dungeon-v1' and day>=85 and product is products[0]:price=39
shown=start+timedelta(seconds=seconds*.15+j*slot)
delay=min(slot*.85,max(.5,rng.lognormvariate(math.log(8 if source=='player' else 2),.6)))
resolved=shown+timedelta(seconds=delay)
budget_left=trait['budget']-state['robux']
intent=.92 if kind=='whale' else .68 if source=='player' else .10
if key=='orbit-v1' and day>=65:intent*=.55
bought=budget_left>=price and rng.random()<intent
unresolved=rng.random()<.004
outcome=None if unresolved else 'bought' if bought else 'closed'
receipt=f'scenario-v3-{sid}-{j}' if outcome=='bought' else None
reported_source='unknown' if rng.random()<.009 else source
prompts.append((gid,'product_prompt','receipt-'+receipt if receipt else f'prompt-{sid}-{j}',pid,sid,product['id'],receipt,shown,None if unresolved else resolved,outcome,
{'synthetic':True,'sourceSeeded':True,'placement':'shop.buy_button' if source=='player' else 'automatic_offer'},reported_source))
if outcome=='bought':
sales.append((gid,product['id'],pid,sid,receipt,resolved,price,1,{'source':'scenario-v3','trigger_source':source}))
state['robux']+=price;state['purchases']+=1;purchase_times[pid].append(resolved)
if key=='willow-v1':
state['giftsSent']+=int(product is products[0]);state['cosmeticsOwned']+=int(product is not products[0])
elif key=='orbit-v1':
state['garageCars']+=int(product is products[3]);state['upgradeLevel']+=int(product is products[2])
else:
state['materials']+=50 if product is products[2] else 0
state['powerScore']+=100 if product is products[3] else 10
if product is products[3]:
grant=5000 if key!='willow-v1' else 1000
state['cashEarned']+=grant;state['cash']+=grant
if seconds>150:
if key=='willow-v1':codes=['hangout'] if kind=='social' else ['collection']
elif key=='orbit-v1':codes=['race']*(min(5,max(1,int(seconds/300))))+(['garage'] if rng.random()<.25 else [])
else:codes=['dungeon']+(['crafting'] if rng.random()<.6 else [])
length=seconds*.72/max(1,len(codes))
for j,code in enumerate(codes):
a_start=start+timedelta(seconds=seconds*.1+j*length)
activity(code,pid,sid,a_start,a_start+timedelta(seconds=length*.92),length,kind,day)
if state['sessionCount']>1 and rng.random()<.35:event('daily_reward_claimed',pid,sid,start+timedelta(seconds=min(45,seconds*.2)),{'reward':100})
event('session_end',pid,sid,end,{'duration':round(seconds),'level':state['level']})
values={k:state[k] for k in ['level','cash','cashEarned','cashSpent','robux','purchases']}
values.update(playStyle=cfg['labels'][kind],acquisitionSource=trait['source'],levelsGained=state['level']-1)
values.update({field:state.get(field,0) for field,_ in cfg['fields']})
if key=='orbit-v1' and not state.get('bestLapSeconds'):values['bestLapSeconds']=None
if pid%47==0:values.pop('cash',None)
if pid%61:snapshots.append((gid,pid,end,values))
copy_rows(conn,'events',['game_id','category','name','player_id','session_id','occurred_at','properties'],events)
copy_rows(conn,'product_sales',['game_id','product_id','player_id','session_id','receipt_id','purchased_at','unit_price_robux','quantity','properties'],sales)
copy_rows(conn,'interaction_events',['game_id','kind','report_key','player_id','session_id','product_id','receipt_id','started_at','ended_at','outcome','properties','trigger_source'],prompts)
copy_rows(conn,'player_snapshots',['game_id','player_id','captured_at','data'],snapshots)
copy_rows(conn,'activity_runs',['game_id','definition_id','player_id','session_id','started_at','ended_at','outcome','step_count','prompt_count','captured_values','stage_progress'],runs)
conn.execute('''UPDATE players p SET first_seen=s.first_seen,last_seen=s.last_seen FROM
(SELECT player_id,min(started_at) first_seen,max(coalesce(ended_at,started_at)) last_seen FROM sessions
WHERE game_id=%s GROUP BY player_id) s WHERE p.game_id=%s AND p.id=s.player_id''',(gid,gid))
starts=sorted(s for s,e in intervals);ends=sorted(e for s,e in intervals);metrics=[]
for hour in range(2160):
at=origin+timedelta(hours=hour)
if at>now:break
ccu=bisect_right(starts,at)-bisect_right(ends,at)
metrics.append((gid,'ccu','concurrent_players',at,None if rng.random()<.002 else ccu,{'source':'simulated_server_presence'}))
if hour%3==0:metrics.append((gid,'performance','server_fps',at,round(max(30,min(60,59-ccu*.04+rng.gauss(0,1))),1),{}))
metrics.extend((gid,'visits','daily_visits',day,count,{'source':'session_starts'}) for day,count in visits.items())
copy_rows(conn,'game_metrics',['game_id','category','name','recorded_at','value','properties'],metrics)
root_events=[]
for day in range(90):
at=origin+day*DAY+timedelta(hours=17)
if at>now:continue
name='community_event_started' if key=='willow-v1' else 'race_rotation_changed' if key=='orbit-v1' else 'dungeon_rotation_changed'
if day%7==4:root_events.append((gid,'events',name,at,{'rotation':day//7}))
if key=='orbit-v1':root_events.append((gid,'updates','ranked_economy_update',origin+65*DAY,{'version':'2.4'}))
if key=='dungeon-v1':root_events.append((gid,'acquisition','discovery_wave_started',origin+85*DAY,{'source':'simulated_home_recommendation'}))
copy_rows(conn,'events',['game_id','category','name','occurred_at','properties'],root_events)
for i,title in enumerate(cfg['passes'],1):conn.execute('INSERT INTO gamepasses(game_id,roblox_gamepass_id,name) VALUES(%s,%s,%s)',(gid,9000000+gid*100+i,title))
passes=conn.execute('SELECT * FROM gamepasses WHERE game_id=%s ORDER BY id',(gid,)).fetchall();ownership=[]
for player in stored:
pid=player['id'];trait=traits[pid];state=states[pid]
for i,gp in enumerate(passes):
if i==3 and rng.random()<.025:continue
owned=rng.random()<(.65 if trait['kind']=='whale' else .22 if state['robux'] else .006)/(1+i*.35)
acquired=(rng.choice(purchase_times[pid]) if purchase_times[pid] else player['first_seen']) if owned else None
ownership.append((gid,gp['id'],pid,owned,now,acquired))
copy_rows(conn,'gamepass_ownership',['game_id','gamepass_id','player_id','is_owned','checked_at','purchased_at'],ownership)
print(f"Generated {cfg['name']} ({cfg['genre']}, {cfg['scenario']}): {len(stored):,} players, {len(plans):,} sessions, {len(sales):,} sales, {len(prompts):,} prompts.",flush=True)