272 lines
21 KiB
Python
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)
|