"""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()=5 else .97) if rng.random()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()=65 else 1) sources=['player']*player_count+(['game'] if rng.random()=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()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)