From 133bc6ce3f65263414308305b9afa8cb0d45ffc7 Mon Sep 17 00:00:00 2001 From: maxwes08 Date: Wed, 4 Nov 2026 09:53:53 +0100 Subject: [PATCH] Add sample scenario data and reporting updates --- PRODUCTION_IMPLEMENTATION.md | 1 + PROJECT.md | 2 + mockups/README.md | 2 +- mockups/SAMPLE_DATA.md | 34 +++- mockups/data-ui.js | 4 +- mockups/db/seed_interactions.sql | 12 +- mockups/manage_database.py | 17 +- mockups/overview_analysis.py | 11 +- mockups/realistic_seed.py | 220 +------------------------ mockups/sample_api.py | 5 +- mockups/sample_report.py | 28 +++- mockups/sample_scenarios.py | 56 +++++++ mockups/scenario_seed.py | 271 +++++++++++++++++++++++++++++++ mockups/summary_reports.py | 3 + mockups/tests/test_database.py | 16 ++ 15 files changed, 444 insertions(+), 238 deletions(-) create mode 100644 mockups/sample_scenarios.py create mode 100644 mockups/scenario_seed.py diff --git a/PRODUCTION_IMPLEMENTATION.md b/PRODUCTION_IMPLEMENTATION.md index f97d011..cb2eff0 100644 --- a/PRODUCTION_IMPLEMENTATION.md +++ b/PRODUCTION_IMPLEMENTATION.md @@ -30,6 +30,7 @@ This document resolves earlier undecided implementation details with proposed v1 | `mockups/db/001_initial.sql` through `006_daily_health_reports.sql` | Starting relational model and historical migrations. Extend it deliberately; do not assume the sample schema is sufficient for live ingestion. | | `mockups/tests/` | Existing calculation, isolation, retry-adjacent integrity, pagination, and missing-data examples to preserve as production contract tests. | | `mockups/realistic_seed.py`, `db/seed*.sql`, `SAMPLE_DATA.md` | Development fixtures only. Never seed synthetic players, inferred sample source labels, or invented pass purchase dates into production. | +| `mockups/scenario_seed.py`, `mockups/sample_scenarios.py` | Current three-genre scenario generator: stable social game, declining racer, and breakout co-op RPG with distinct latent personalities and catalogs. Playstyle/acquisition tags are fictional fixture metadata; preserve the ability to inspect custom fields without pretending this is a production behavior classifier. | | `start_dashboard.py`, `start-dashboard.bat` | Windows development convenience only; not the production process supervisor. | The temporary Python HTTP server, unprotected local endpoints, sample credentials, prototype API-key controls, SVG chart implementation, in-memory scheduler, and simulated Join button are **not production implementations**. diff --git a/PROJECT.md b/PROJECT.md index bdc37a8..5e21f3e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -178,6 +178,8 @@ In addition to raw snapshot fields, useful sortable or trendable measures includ Prefer clear time windows and definitions for derived measures; for example, show whether playtime is lifetime or selected-period, and calculate rates only when the denominator is nonzero and the underlying snapshot history is available. ## Analytics UI refinements +- Mockup sample version 3 contains three different playable genres and trajectories: stable social/collection Willow's Park, declining competitive racer Orbit Racers, and discovery-surge co-op RPG/crafting Dungeon Workshop. Each has its own activity definitions, events, products, custom player fields, and personality mixture. Playstyle is visible in player columns/profile tags; Behavior is a premade column template. The fictional personality labels and acquisition-source tags are simulation metadata, not a production classification/discovery integration. + - Overview starts with one expandable Everything at a glance analysis, replacing the Game health section header. It interprets seven-day trends versus the prior week with latest-day/weekday context, then discusses strengths, player-experience weaknesses, activity/progression friction, monetization quality, and test priorities. It uses selected evidence rather than repeating the daily/weekly metric recaps below. Matched returning-player snapshot changes can inform economy/progression observations without assuming causation. - Remove the Summaries workspace/mobile navigation and standalone summary UI. Existing summary-page bookmarks redirect to Overview with their game query preserved. Daily/weekly numerical reports remain below the broad analysis. Analysis stays game-specific, cached daily, and labeled as sample text. diff --git a/mockups/README.md b/mockups/README.md index e129f99..be345ca 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -25,7 +25,7 @@ Open . The game selector swi ## Sample data -The benchmark-informed generator creates 90 days of synthetic records relative to setup time, with 18,000 players across three games. Most players have one session; return rates decline sharply over time. Session lengths, progression, purchases, activity funnels, and gamepass ownership vary by player behavior. A small heavy-spending group accounts for a large share of revenue. Visits and CCU come from the same session histories. +The benchmark-informed generator creates 90 days of synthetic records relative to setup time, with 33,000 players across three games. Most players have one session; return rates decline sharply over time. The stable social/collection game, declining racing game, and discovery-surge co-op RPG have different catalogs, events, activities, player fields, and personality mixes. Session lengths, progression, purchases, activity funnels, and gamepass ownership vary by player behavior. A small heavy-spending group accounts for a large share of revenue. Visits and CCU come from the same session histories. See [SAMPLE_DATA.md](SAMPLE_DATA.md) for public Roblox references and model assumptions. These are synthetic histories, not imported real player data. Run `manage_database.py status` for current record counts. diff --git a/mockups/SAMPLE_DATA.md b/mockups/SAMPLE_DATA.md index ccffcac..5586cdb 100644 --- a/mockups/SAMPLE_DATA.md +++ b/mockups/SAMPLE_DATA.md @@ -9,15 +9,36 @@ All users, names, receipts, and histories are synthetic. No private Roblox analy - [A creator's report of high session length and low retention](https://devforum.roblox.com/t/high-session-length-but-low-retention-rate/1752184): approximately 20-minute sessions with 12% D1 retention. This is self-reported, not audited or a platform benchmark. It illustrates that long sessions and strong retention are separate properties. - [A creator's report of weak onboarding](https://devforum.roblox.com/t/how-do-i-improve-average-playtime-and-day-1-retention/4565940): approximately 5.4-minute sessions with 0.58% D1 retention over a week. Another self-reported example; it establishes possible variation rather than a calibration target for all games. -## Model assumptions +## Current scenarios (sample version 3) -`realistic_seed.py` uses a fixed random seed and the current UTC time. Each game gets 6,000 players acquired over 90 days, with gradual growth, weekend lifts, an acquisition campaign, and an update bump. Orbit has shorter sessions and lower returns; Dungeon has longer sessions and stronger returns; Willow sits between them. These genre differences are illustrative assumptions, not measured claims about named games. +The generator is `scenario_seed.py`, with configuration and personality traits in `sample_scenarios.py`; `realistic_seed.py` retains shared COPY/reset helpers. It uses fixed per-game random seeds and the current UTC time. The three games preserve their internal IDs and have different actual catalogs, events, activities, player fields, and acquisition curves: -The latent population includes 55% one-visit players, 32% casual players, 12.7% regular players, and 0.3% high-spending candidates. Casual return probability decays quickly; regular players have a longer tail; heavy spenders have a persistent but diminishing return probability. Some players skip several days and return. Observed exact-day retention can fluctuate between adjacent days or small cohorts; it is not forced into an artificially monotonic chart. +| Game | Genre | Trajectory | Players | +| --- | --- | --- | ---: | +| Willow's Park | Social hangout and collecting | Stable acquisition and returning core, weekend variation | 8,000 | +| Orbit Racers | Round-based competitive racing | Acquisition and returning activity decline after the middle of the window; later sessions shorten, checkpoint completion falls, automatic offers increase, and currency rewards outpace sinks | 9,000 | +| Dungeon Workshop | Co-op dungeon RPG and crafting | Small earlier audience, then an accelerating Home-recommendation acquisition wave over the last four complete days; the new audience has more one-visit newcomers | 16,000 | -Session durations are skewed rather than uniform: many short first visits, longer repeat visits, and occasional long sessions. Mobile/desktop/tablet proportions, payer propensity, spending intensity, and catalog prices are simulation assumptions. Most players never buy. Purchases occur within actual sessions; heavy spenders buy more frequently and favor larger bundles. A promotion changes historical prices without rewriting catalog prices. Gamepass observations are correlated with actual spending; some passes were previously owned or gifted, so ownership does not imply a receipt within this window. +These are deliberately fictional scenarios, not claims about real Roblox rankings. `acquisitionSource=home_recommendation` is simulated provenance, not data fetched from the Roblox discovery algorithm. Stable does not mean perfectly flat: natural daily/weekday variation and payer bursts remain. The current UTC day is partial and is excluded from the daily/weekly reports. -Snapshots track progression and cumulative recorded purchases. Sessions, activity stages, events, product receipts, daily visits, and hourly CCU share the same histories. CCU counts intervals at each hourly sample; unended sessions use a short observation window for the synthetic CCU report while stored session duration remains unknown. Rare missing fields, missing pass checks, and missing end reports exercise N/A handling. The current UTC day is partial; its totals should be lower than full days. +Eight latent personalities have different session duration, return frequency, soft-currency spending, progression, purchase intent, budget, and product preference. Their weights and labels differ per genre: + +- Tourists/newcomers visit once and usually leave quickly. +- Social regulars spend longer with other players, progress more slowly, and rarely pay. +- Collectors accumulate sets/cosmetics and show more deliberate shopping. +- Competitors have stronger race results and more repeat rounds; spending is independent of their skill. +- Free-to-play grinders accumulate time, progression, and currency while recording zero Robux spend. +- Budget buyers favor low-price purchases and have a finite lifetime budget. +- Impulse buyers can spend during a short visit without becoming regular players. +- Rare whales spend in bursts with much larger budgets and a preference for bigger products; cozy-game cosmetic whales and racing/RPG power spenders have different budgets and behavior. + +The Players table includes Playstyle by default, a Behavior column template, and genre-specific fields. Profiles display the playstyle tag. Text fields remain sortable/displayable but are excluded from numeric graphs and Hall of Fame selectors. Labels are simulation ground truth for the mockup, not an implemented behavioral classifier for production. + +Prompt source is selected before the outcome. Product, source, persona intent, remaining budget, and the current scenario affect the outcome. Each bought prompt links to one real synthetic receipt at the same time; closed and unresolved interactions have no invented receipt. Prompt timings stay inside the parent session. Activity runs have their own ordered progression and never stand in for product-prompt records. + +Player snapshots preserve full copies, including cash-earned/spent balance identity, exact cumulative receipt totals, and meaningful per-genre values. CCU is counted from the same simulated session intervals. Missing reports/checks are rare and remain in the Settings diagnostic log. Known zeros remain zero; absent data displays N/A. + +`sample_report.py` validates the resulting stable/declining/surge trajectories against the preceding seven complete days, eight personalities per game, free-to-play spending, no session overlaps, sale timing, snapshot balances/receipt totals, and descending mature retention. Do this before generating the final reports. ## Rebuild only the bundled sample games @@ -27,9 +48,12 @@ Snapshots track progression and cumulative recorded purchases. Sessions, activit This deletes and replaces child datasets only for `willow-v1`, `orbit-v1`, and `dungeon-v1`, in one transaction. Internal game IDs are preserved, unrelated games are untouched, and ordinary `init`/`seed` calls keep existing data. Player and record IDs change, so old player-detail links may no longer resolve. +After rebuilding, validate first, then refresh reports, and finally start the dashboard. Stop an existing dashboard worker while replacing the samples so it cannot cache a half-finished scenario. Rebuilding is transactional, preserves internal game IDs, and clears sample report caches. + Run the validation report and tests: ```powershell .venv/Scripts/python.exe mockups/sample_report.py +.venv/Scripts/python.exe mockups/manage_database.py refresh-reports .venv/Scripts/python.exe -m unittest discover -s mockups/tests -v ``` diff --git a/mockups/data-ui.js b/mockups/data-ui.js index 21571ac..b72f315 100644 --- a/mockups/data-ui.js +++ b/mockups/data-ui.js @@ -343,7 +343,7 @@ async function loadPlayers() { playerData=data; fillMetrics(data.metrics); const ranking=$('[data-leaderboard-value]'); if (!ranking.options.length) { - data.fields.filter(f=>!['robux','playtime','cash'].includes(f.key)).forEach(f=>ranking.add(new Option(f.label,f.key))); + data.fields.filter(f=>!['robux','playtime','cash'].includes(f.key) && !['text','json'].includes(f.format)).forEach(f=>ranking.add(new Option(f.label,f.key))); ranking.value='level'; } $('[data-leaderboards]').innerHTML=data.leaderboards.map(board=>`
${cell(board.label,'text')}
    ${board.players.map((p,i)=>`
  1. ${i+1}
    ${playerLink(p.playerId,p.name,p.robloxUserId)}
    ${cell(p.value,board.format)}
  2. `).join('') || '
  3. N/A
  4. '}
`).join(''); @@ -458,7 +458,7 @@ async function loadProfile() { const p=data.player; $('[data-profile-name]').textContent=format(p.name); $('[data-profile-id]').textContent=`Internal player ${p.playerId} · Roblox user ${p.robloxUserId} · First seen ${format(p.firstSeen,'date')} · Snapshot ${format(p.snapshotAt,'date')}`; - $('[data-profile-tags]').innerHTML=(data.groups || []).map(v=>`${escape(v)}`).join(''); + $('[data-profile-tags]').innerHTML=[p.playStyle,...(data.groups || [])].filter(Boolean).map(v=>`${escape(v)}`).join(''); fillMetrics(p); const chooser=$('[data-profile-value]'); if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key))); diff --git a/mockups/db/seed_interactions.sql b/mockups/db/seed_interactions.sql index bffc041..6e3775a 100644 --- a/mockups/db/seed_interactions.sql +++ b/mockups/db/seed_interactions.sql @@ -2,12 +2,12 @@ SET search_path TO analytics, public; -- Only enrich the bundled synthetic histories; unknown real purchase dates stay NULL. UPDATE gamepass_ownership o SET purchased_at=p.first_seen+(p.last_seen-p.first_seen)*((p.id*31+o.gamepass_id)%100/100.0) FROM players p,games g WHERE p.id=o.player_id AND p.game_id=o.game_id AND g.id=o.game_id -AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND o.is_owned AND o.purchased_at IS NULL; +AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 AND o.is_owned AND o.purchased_at IS NULL; INSERT INTO interaction_events(game_id,kind,report_key,player_id,session_id,product_id,receipt_id,started_at,ended_at,outcome,properties) SELECT s.game_id,'product_prompt','receipt-'||s.receipt_id,s.player_id,s.session_id,s.product_id,s.receipt_id, greatest(se.started_at,s.purchased_at-(3+s.id%29)*interval '1 second'),s.purchased_at,'bought','{"synthetic":true}' FROM product_sales s JOIN sessions se ON se.game_id=s.game_id AND se.id=s.session_id JOIN games g ON g.id=s.game_id -WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') ON CONFLICT DO NOTHING; +WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 ON CONFLICT DO NOTHING; INSERT INTO interaction_events(game_id,kind,report_key,player_id,session_id,product_id,started_at,ended_at,outcome,properties) SELECT s.game_id,'product_prompt','closed-session-'||s.id,s.player_id,s.id,p.id, s.started_at+(s.ended_at-s.started_at)*.3, @@ -15,7 +15,7 @@ SELECT s.game_id,'product_prompt','closed-session-'||s.id,s.player_id,s.id,p.id, CASE WHEN s.id%97=0 THEN NULL ELSE 'closed' END,'{"synthetic":true}' FROM sessions s JOIN games g ON g.id=s.game_id JOIN products p ON p.game_id=s.game_id AND p.roblox_product_id=8000000+s.game_id*100+1+s.id%4 -WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND s.ended_at-s.started_at>interval '1 minute' AND s.id%3=0 +WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 AND s.ended_at-s.started_at>interval '1 minute' AND s.id%3=0 ON CONFLICT DO NOTHING; -- Product prompt telemetry is separate from the longer shop/activity journey. -- Source labels below are synthetic examples, never inferred for real reports. @@ -23,13 +23,13 @@ UPDATE interaction_events i SET trigger_source=CASE WHEN i.id%100<5 THEN 'unknow WHEN i.outcome='bought' AND i.id%100<85 THEN 'player' WHEN i.outcome IS DISTINCT FROM 'bought' AND i.id%100<30 THEN 'player' ELSE 'game' END, properties=i.properties||'{"sourceSeeded":true}'::jsonb -FROM games g WHERE i.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') +FROM games g WHERE i.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 AND i.properties->>'synthetic'='true' AND NOT(i.properties ? 'sourceSeeded'); UPDATE activity_runs a SET prompt_count=0,outcome=CASE WHEN outcome='purchased' THEN 'closed' ELSE outcome END, step_count=CASE WHEN stage_progress ? 'product_viewed' THEN 1 ELSE 0 END, captured_values=captured_values-'product_id'-'purchase_amount', stage_progress=(SELECT coalesce(jsonb_agg(e),'[]'::jsonb) FROM jsonb_array_elements(a.stage_progress) e WHERE e NOT IN ('"buy_prompt_shown"'::jsonb,'"purchase_completed"'::jsonb)) FROM activity_definitions d,games g WHERE d.id=a.definition_id AND d.game_id=a.game_id AND g.id=a.game_id -AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND d.key='shop'; +AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 AND d.key='shop'; UPDATE activity_definitions d SET configuration=jsonb_build_object('steps',jsonb_build_array('product_viewed'),'fields',jsonb_build_array('currency_balance')) -FROM games g WHERE d.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND d.key='shop'; +FROM games g WHERE d.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND coalesce((g.properties->>'sampleVersion')::int,2)<3 AND d.key='shop'; diff --git a/mockups/manage_database.py b/mockups/manage_database.py index c0d8952..6305e54 100644 --- a/mockups/manage_database.py +++ b/mockups/manage_database.py @@ -67,7 +67,20 @@ def status(): print("PostgreSQL sample records: " + ", ".join(f"{k}={v:,}" for k, v in counts.items())) +def refresh_reports(): + """Regenerate today's bundled sample reports only, after a successful data rebuild.""" + from health_reports import reports_for_game + from realistic_seed import SAMPLE_KEYS + with connect() as conn: + conn.execute('SET LOCAL statement_timeout=0') + gids=[r['id'] for r in conn.execute('SELECT id FROM games WHERE sample_key=ANY(%s)',(list(SAMPLE_KEYS),))] + conn.execute('DELETE FROM daily_health_reports WHERE game_id=ANY(%s) AND report_date=current_date',(gids,)) + for gid in gids: + reports=reports_for_game(conn,gid)['reports'] + print(f"Refreshed {len(reports)} reports for game {gid}.",flush=True) + + if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("command", choices=["init", "seed", "status", "reset-sample"], default="init", nargs="?") - globals()[{"init": "initialize", "seed": "seed", "status": "status", "reset-sample": "reseed"}[parser.parse_args().command]]() + parser.add_argument("command", choices=["init", "seed", "status", "reset-sample", "refresh-reports"], default="init", nargs="?") + globals()[{"init": "initialize", "seed": "seed", "status": "status", "reset-sample": "reseed", "refresh-reports":"refresh_reports"}[parser.parse_args().command]]() diff --git a/mockups/overview_analysis.py b/mockups/overview_analysis.py index cb12650..684d624 100644 --- a/mockups/overview_analysis.py +++ b/mockups/overview_analysis.py @@ -42,7 +42,14 @@ def deep_analysis(conn,report,start,end,evidence): recent=m if report['periodDays']==1 else window_metrics(conn,report['gameId'],end-timedelta(days=1),end) weekday=window_metrics(conn,report['gameId'],end-timedelta(days=8),end-timedelta(days=7)) momentum=direction(recent['active_players'],weekday['active_players']) - if reach==-1 and ccu==1: + sources_by_name={r['source']:r['players'] for r in evidence.get('acquisitionSources',[])} + home_share=sources_by_name.get('home_recommendation',0)/max(1,sum(sources_by_name.values())) + surge=p['active_players']>0 and m['active_players']>p['active_players']*2 and home_share>.5 + if surge: + overall=f"{name} is in a discovery-led surge: reported Home-recommendation arrivals now dominate new acquisition. This is a large expansion in exposure, while the newest cohorts still need time to establish repeat-play habits. Judge the next phase by whether those arrivals become returning players rather than expecting acquisition-driven CCU to remain at its peak indefinitely." + elif reach==0: + overall=f"{name} has a broadly stable active audience. Treat movement in session intensity and spending as separate questions from audience growth: preserve the returning-player loop, then check whether the game is creating enough reasons for new visitors to establish the same habit." + elif reach==-1 and ccu==1: overall=f"{name} is drawing more concurrent presence from a smaller active audience. That suggests more intense use among remaining players; check longer visits and repeat use before treating the CCU rise as acquisition growth." elif reach==-1 and spending==1: overall=f"{name} is monetizing a narrower audience more heavily. Revenue resilience is useful, but it can mask weakening player renewal; the next improvement should broaden engagement rather than rely only on extracting more from existing buyers." @@ -58,6 +65,8 @@ def deep_analysis(conn,report,start,end,evidence): overall+=' The latest complete day was also below the same weekday last week for active reach, so the softness is visible beyond a simple weekday/weekend comparison.' elif momentum==1 and reach==-1: overall+=' The latest complete day beat its same-weekday audience comparison, offering an early recovery signal that still needs several more days of follow-through.' + elif momentum==1 and reach==0: + overall+=' The latest complete day beat its same-weekday comparison, while the full-week audience remains broadly flat.' elif momentum==1: overall+=' The latest same-weekday comparison supports the broader reach improvement.' diff --git a/mockups/realistic_seed.py b/mockups/realistic_seed.py index 3550d68..14cb831 100644 --- a/mockups/realistic_seed.py +++ b/mockups/realistic_seed.py @@ -35,219 +35,7 @@ def reset_sample(conn): conn.execute(sql.SQL('DELETE FROM {} WHERE game_id=ANY(%s)').format(sql.Identifier(table)), (gids,)) -def populate(conn, players_per_game=6000): - rng = random.Random(20261103) - now = datetime.now(timezone.utc) - midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) - first_day = midnight - 89 * DAY - conn.execute((Path(__file__).parent / 'db' / 'seed.sql').read_text(encoding='utf-8')) - games = conn.execute('SELECT * FROM games WHERE sample_key=ANY(%s) ORDER BY id', (list(SAMPLE_KEYS),)).fetchall() - for game in games: - gid = game['id'] - # Different genres, not three copies of the same graph. - scale, session_scale = {'willow-v1': (1., 1.), 'orbit-v1': (.78, .78), 'dungeon-v1': (1.35, 1.35)}[game['sample_key']] - defs = {r['key']: r for r in conn.execute('SELECT * FROM activity_definitions WHERE game_id=%s', (gid,))} - products = conn.execute('SELECT * FROM products WHERE game_id=%s ORDER BY id', (gid,)).fetchall() - weights = [] - for day in range(90): - date = first_day + day * DAY - # Gradual growth, weekend lift, acquisition campaign, and update bump. - weight = (.7 + day / 140) * (1.28 if date.weekday() >= 5 else 1) - weight *= 1 + .75 * math.exp(-((day-61)/3)**2) + .4 * math.exp(-((day-79)/2)**2) - if day == 89: - weight *= max(.02, (now-midnight).total_seconds()/86400) - weights.append(weight) - players = [] - for index in range(players_per_game): - cohort = rng.choices(range(90), weights=weights)[0] - date = first_day + cohort * DAY - # Global audience with an evening peak, not a uniform hourly trace. - hour = rng.gauss(18, 4) % 24 if rng.random() < .7 else rng.uniform(0, 24) - if cohort == 89: - hour = min(hour, max(0, (now-midnight).total_seconds()/3600-.02)) * rng.uniform(.1, 1) - start = date + timedelta(hours=hour) - kind = rng.choices(['bounce', 'casual', 'regular', 'whale'], [.55, .32, .127, .003])[0] - name = rng.choice(['Pixel','Cloud','Nova','Maple','Moss','Luna','Echo','Aero','Fern','Snow']) - name += rng.choice(['Fox','Builder','Runner','Cat','Plays','_']) + str(rng.randrange(10, 9999)) - players.append((gid, 1000000000+gid*100000+index, name if rng.random()>.008 else None, - start, start, {'seedVersion': 2, 'seedIndex': index, 'simulationGroup': kind})) - 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() - session_rows, traits = [], {} - for player in stored: - pid, start = player['id'], player['first_seen'] - kind = player['metadata']['simulationGroup'] - affinity = rng.uniform(.7, 1.3) - payer = rng.random() < {'bounce': .0015, 'casual': .035, 'regular': .18, 'whale': 1}[kind] - traits[pid] = {'kind':kind, 'payer':payer, 'device':rng.choices(['phone','desktop','tablet'],[.65,.28,.07])[0], - 'spendIntensity':min(12, rng.paretovariate(1.7)) if kind=='whale' else 1} - dates = [start] - age = (midnight - start.replace(hour=0, minute=0, second=0, microsecond=0)).days - for offset in range(1, age+1): - if kind == 'bounce': - break - if kind == 'casual': - probability = .20 * math.exp(-(offset-1)/1.8) - elif kind == 'regular': - probability = .40*math.exp(-(offset-1)/2.7) + .10*math.exp(-(offset-1)/15) - else: - probability = .85*math.exp(-(offset-1)/70) - date = start.replace(hour=0, minute=0, second=0, microsecond=0) + offset * DAY - weekend = 1.12 if date.weekday() >= 5 else .96 - if rng.random() < min(.96, probability * affinity * scale * weekend): - dates.append(date + timedelta(hours=rng.gauss(18,4)%24)) - last_end = None - for day_index, date in enumerate(dates): - count = 1 - if kind != 'bounce' and rng.random() < (.65 if kind=='whale' else .18): - count += rng.choices([1,2], [.85,.15])[0] - previous_end = None - for number in range(count): - current = date if previous_end is None else previous_end+timedelta(minutes=rng.uniform(25,180)) - if last_end is not None and current < last_end: - current = last_end + timedelta(minutes=20) - if current > now or current.date() != date.date(): - continue - median = {'bounce':2.5,'casual':6,'regular':11,'whale':28}[kind] - if day_index: - median *= 1.2 - duration = max(15, min(3*3600, rng.lognormvariate(math.log(median*60*session_scale), .85))) - end = current+timedelta(seconds=duration) - # Preserve genuinely open sessions, plus rare lost end reports. - recorded_end = None if end>now or rng.random()<.003 else end - session_rows.append([gid,pid,current,recorded_end, - rng.choice(['Main Island','Forest Arena','Trading Hub']), - {'device':traits[pid]['device'], 'source':'synthetic-v2'}]) - previous_end = end - last_end = end - session_rows.sort(key=lambda row: row[2]) - ids = [r['id'] for r in conn.execute("SELECT nextval(pg_get_serial_sequence('sessions','id')) id FROM generate_series(1,%s)", (len(session_rows),))] - copy_rows(conn, 'sessions', ['id','game_id','player_id','started_at','ended_at','place_name','stats'], - [[sid]+row for sid,row in zip(ids,session_rows)]) - events, sales, snapshots, runs = [], [], [], [] - balances, spent, units, playtime, levels, visits = defaultdict(int), defaultdict(int), defaultdict(int), defaultdict(float), defaultdict(lambda:1), Counter() - cash_earned, cash_spent = defaultdict(int), defaultdict(int) - intervals = [] - - def event(name, pid, sid, at, props=None, category='events'): - events.append((gid,category,name,pid,sid,at,props or {})) - - def activity(key, pid, sid, start, end, outcome, progress, captured, prompts=0): - definition=defs[key] - runs.append((gid,definition['id'],pid,sid,start,end,outcome, - sum(e in definition['configuration']['steps'] for e in progress),prompts,captured,progress)) - # The ordered observations also appear as individually inspectable events. - for i, name in enumerate(progress): - at=start+(end-start)*(i/max(1,len(progress)-1)) - event(name,pid,sid,at,captured,'activities') - - for sid, row in zip(ids, session_rows): - _,pid,start,end,place,stats=row - trait=traits[pid] - visits[start.replace(hour=0,minute=0,second=0,microsecond=0)] += 1 - intervals.append((start,end or min(now,start+timedelta(minutes=10)))) - event('session_start',pid,sid,start,{'place':place,'device':trait['device']}) - if end is None: - continue - seconds=(end-start).total_seconds() - playtime[pid] += seconds - earned=round(seconds*rng.uniform(1.2,3.5)) - cash_earned[pid] += earned - balances[pid] += earned - expense=round(balances[pid]*rng.uniform(0,.25)) - cash_spent[pid] += expense - balances[pid] -= expense - before=levels[pid] - levels[pid]=1+int((playtime[pid]/240)**.72) - if levels[pid]>before: - event('level_up',pid,sid,start+(end-start)*.6,{'level':levels[pid],'levelsGained':levels[pid]-before}) - if rng.random()<.18 and seconds>300: - event('daily_reward_claimed',pid,sid,start+timedelta(seconds=50),{'reward':100}) - buys=[] - if trait['payer'] and seconds>90 and rng.random()<(.82 if trait['kind']=='whale' else .35): - n=rng.randint(1,max(2,round(2*trait['spendIntensity']))) if trait['kind']=='whale' else 1 - for j in range(n): - product=rng.choices(products,weights=[.03,.12,.3,.55] if trait['kind']=='whale' else [.52,.27,.16,.05])[0] - quantity=rng.randint(1,4) if trait['kind']=='whale' else 1 - # A temporary promotion changes historical sale prices. - price=round(product['current_price_robux']*(.8 if (start-first_day).days in range(60,64) else 1)) - at=start+(end-start)*(.2+.5*(j+1)/(n+1)) - sales.append((gid,product['id'],pid,sid,f'synthetic-v2-{sid}-{j}',at,price,quantity,{'source':'synthetic_receipt'})) - spent[pid] += price*quantity - units[pid] += quantity - buys.append((product,quantity,price,at)) - if seconds>60 and (buys or rng.random()<.23): - definition=defs['shop'] - progress=[definition['start_event']] - if buys or rng.random()<.85: - progress.append('product_viewed') - activity('shop',pid,sid,start+(end-start)*.15,start+(end-start)*.3,'closed', - progress+[definition['end_event']],{'currency_balance':balances[pid]}) - for key, chance in [('minigame',.6),('quest',.35)]: - if seconds<180 or rng.random()>chance: - continue - definition=defs[key] - maximum=len(definition['configuration']['steps']) - count=maximum if rng.random()<(.7 if seconds>600 else .4) else rng.randrange(maximum) - progress=[definition['start_event']]+definition['configuration']['steps'][:count]+[definition['end_event']] - outcome=('won' if rng.random()<.4 else 'lost') if key=='minigame' and count==maximum else 'completed' if count==maximum else 'abandoned' - captured={'score':round(seconds*rng.uniform(.3,2)),'rounds':1} if key=='minigame' else {'quest_id':f'quest_{rng.randrange(12)}','reward':100 if count==maximum else 0} - activity(key,pid,sid,start+(end-start)*.35,start+(end-start)*.9,outcome,progress,captured) - event('session_end',pid,sid,end,{'playtime':round(seconds),'level':levels[pid]}) - data={'level':levels[pid],'cash':balances[pid], 'cashEarned':cash_earned[pid], - 'cashSpent':cash_spent[pid], 'robux':spent[pid],'purchases':units[pid], - 'levelsGained':levels[pid]-1,'quests':int(playtime[pid]/1800),'achievements':int(math.sqrt(playtime[pid]/600))} - if game['sample_key']=='orbit-v1': - data['roundWins']=int(playtime[pid]/1200) - # Realistic partial payloads: a small stable set lacks a field or all snapshots. - if pid%47==0: - data.pop('cash') - if pid%61: - snapshots.append((gid,pid,end,data)) - 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,'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)) - # CCU and visits come from these same sessions, instead of unrelated sine-wave values. - starts=sorted(s for s,e in intervals) - ends=sorted(e for s,e in intervals) - metrics=[] - from bisect import bisect_right - for hour in range(90*24): - at=first_day+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()<.004 else ccu,{'source':'synthetic_session_intervals'})) - if hour%3==0: - metrics.append((gid,'performance','server_fps',at,round(max(35,min(60,59-ccu*.12+rng.gauss(0,1.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=first_day+day*DAY+timedelta(hours=17) - if at<=now: - root_events.append((gid,'server','weather_changed',at,{'weather':rng.choice(['clear','rain','fog'])})) - if day%7==4: - root_events.append((gid,'events','global_announcement',at,{'message':'Weekend event'})) - copy_rows(conn,'events',['game_id','category','name','occurred_at','properties'],root_events) - conn.execute('''INSERT INTO gamepasses(game_id,roblox_gamepass_id,name) - SELECT %s,9000000+%s*100+n,(ARRAY['VIP','Double cash','Extra inventory','Early access'])[n] - FROM generate_series(1,4) n''',(gid,gid)) - passes=conn.execute('SELECT * FROM gamepasses WHERE game_id=%s ORDER BY id',(gid,)).fetchall() - ownership=[] - for player in stored: - pid=player['id'] - for index,gp in enumerate(passes): - # Previously owned / gifted passes do not require a receipt in this 90-day window. - probability=([.82,.92,.78,.6] if traits[pid]['kind']=='whale' else [.25,.4,.2,.08] if spent[pid] else [.012,.018,.009,.003])[index] - if rng.random() < (.04 if index==3 else 0): - continue - ownership.append((gid,gp['id'],pid,rng.random()0) sp WHERE ps.game_id=%s AND ps.player_id=%s ORDER BY ps.captured_at,ps.id""",(gid,pid)) supported={'sessions','playtime','avgSession','medianSession','activeDays','robuxPerHour','robux','purchases'} - graph_fields=[dict(f) for f in fields if f['source']=='snapshot' or f['key'] in supported] + graph_fields=[dict(f) for f in fields if (f['source']=='snapshot' or f['key'] in supported) and f['format'] not in ('text','json')] for key,title,fmt in [('sessions','Sessions','number'),('playtime','Lifetime playtime','duration'), ('avgSession','Average session','duration'),('medianSession','Median session','duration'), ('activeDays','Active days (30d)','number'),('robux','Robux spent','robux'), diff --git a/mockups/sample_report.py b/mockups/sample_report.py index a252688..ec3083a 100644 --- a/mockups/sample_report.py +++ b/mockups/sample_report.py @@ -2,11 +2,13 @@ import math from database import connect +from health_reports import window_metrics +from datetime import timedelta def report(): with connect() as conn: - for game in conn.execute("SELECT id,name FROM games WHERE properties->>'sampleVersion'='2' ORDER BY id").fetchall(): + for game in conn.execute("SELECT id,name,sample_key,properties FROM games WHERE properties->>'sampleVersion'='3' ORDER BY id").fetchall(): gid=game['id'] summary=conn.execute('''WITH counts AS(SELECT player_id,count(*) n FROM sessions WHERE game_id=%s GROUP BY player_id) SELECT count(*) players,100.0*count(*) FILTER(WHERE n=1)/count(*) one_session FROM counts''',(gid,)).fetchone() @@ -31,13 +33,33 @@ def report(): AND ((latest_data->>'robux')::numeric<>robux_recorded OR (latest_data->>'purchases')::numeric<>products_bought)''',(gid,)).fetchone()['n'] assert returns[0]>returns[1]>returns[2], (game,returns) assert 350, (game,summary) + assert summary['one_session']>40, (game,summary) assert len(spending)10, (game,top_share) + assert top_share>5, (game,top_share) assert bad_sales==0, (game,bad_sales) assert overlaps==0, (game,overlaps) assert bad_balances==0, (game,bad_balances) assert bad_snapshots==0, (game,bad_snapshots) + today=conn.execute('SELECT current_date today').fetchone()['today'] + current=window_metrics(conn,gid,today-timedelta(days=7),today) + previous=window_metrics(conn,gid,today-timedelta(days=14),today-timedelta(days=7)) + audience_ratio=current['average_dau']/previous['average_dau'] + ccu_ratio=current['average_ccu']/previous['average_ccu'] + scenario=game['properties']['sampleScenario'] + if scenario=='stable': + assert .802,(game['name'],audience_ratio) + assert ccu_ratio>2,(game['name'],ccu_ratio) + groups=conn.execute("SELECT metadata->>'simulationGroup' persona,count(*) n FROM players WHERE game_id=%s GROUP BY persona",(gid,)).fetchall() + assert len(groups)==8,(game['name'],groups) + grinder_spend=conn.execute("SELECT coalesce(sum(s.unit_price_robux*s.quantity),0) robux FROM product_sales s JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id WHERE s.game_id=%s AND p.metadata->>'simulationGroup'='grinder'",(gid,)).fetchone()['robux'] + assert grinder_spend==0,(game['name'],grinder_spend) + print(f" Scenario {scenario}: DAU {100*(audience_ratio-1):+.1f}%, CCU {100*(ccu_ratio-1):+.1f}% week-on-week; eight personalities present.") print(f"{game['name']}: {summary['players']:,} players | one session {summary['one_session']:.1f}% | average session {minutes:.1f} min") print(f" D1/D7/D30: {' / '.join(f'{r:.2f}%' for r in returns)} | payers {len(spending):,} ({100*len(spending)/summary['players']:.1f}%) | top 1% payer revenue share {top_share:.1f}%") print('Sample distribution and timeline checks passed.') diff --git a/mockups/sample_scenarios.py b/mockups/sample_scenarios.py new file mode 100644 index 0000000..7361c89 --- /dev/null +++ b/mockups/sample_scenarios.py @@ -0,0 +1,56 @@ +"""Three deliberately different fictional games and latent player personalities.""" +import math + +PERSONAS={ + 'tourist':dict(minutes=2.2,fast=0,tail=0,decay=1,payer=0,budget=0,repeat=0,sink=.06,xp=.65), + 'social':dict(minutes=18,fast=.36,tail=.16,decay=65,payer=.10,budget=500,repeat=.12,sink=.08,xp=.45), + 'collector':dict(minutes=10,fast=.38,tail=.10,decay=40,payer=.32,budget=2400,repeat=.18,sink=.22,xp=.9), + 'competitor':dict(minutes=9,fast=.42,tail=.12,decay=35,payer=.12,budget=1500,repeat=.38,sink=.32,xp=1.15), + 'grinder':dict(minutes=19,fast=.43,tail=.13,decay=45,payer=0,budget=0,repeat=.28,sink=.035,xp=1.45), + 'budget':dict(minutes=7,fast=.24,tail=.025,decay=18,payer=.45,budget=220,repeat=.10,sink=.17,xp=.8), + 'impulse':dict(minutes=3.5,fast=.05,tail=.003,decay=10,payer=.32,budget=300,repeat=.02,sink=.15,xp=.7), + 'whale':dict(minutes=23,fast=.62,tail=.20,decay=85,payer=1,budget=25000,repeat=.55,sink=.25,xp=1.0), +} + +SCENARIOS={ + 'willow-v1':dict(name="Willow's Park",genre='Social hangout and collecting',scenario='stable',players=8000, + weights=[.43,.18,.15,.015,.10,.07,.05,.005],minutes=.9,retention=.9,automatic=.08, + labels={'social':'Social regular','collector':'Set collector','competitor':'Challenge chaser','grinder':'F2P collector','budget':'Occasional gifter','impulse':'Impulse decorator','whale':'Cosmetic whale','tourist':'One-visit explorer'}, + products=[('Friend gift',29),('Decoration pack',79),('Festival cosmetic',199),('Collector crate',499)], + passes=['VIP lounge','Extra display slots','Garden expansion','Festival access'], + places=['Town square','Community garden','Collection trail'], + fields=[('friendsMet','Friends met'),('giftsSent','Gifts sent'),('collectionCount','Collection discoveries'),('cosmeticsOwned','Cosmetics owned'),('socialMinutes','Social minutes')], + activities=[('hangout','Social hangout','hangout_joined','hangout_left',['area_joined','interaction_started','interaction_completed']), + ('collection','Collection trail','collection_started','collection_ended',['item_found','set_progress_saved'])]), + 'orbit-v1':dict(name='Orbit Racers',genre='Round-based competitive racing',scenario='declining',players=9000, + weights=[.48,.025,.075,.20,.12,.055,.037,.008],minutes=.8,retention=.9,automatic=.20, + labels={'social':'Social racer','collector':'Garage collector','competitor':'Ranked competitor','grinder':'Time-trial grinder','budget':'Budget tuner','impulse':'Impulse upgrader','whale':'Garage whale','tourist':'First-race dropout'}, + products=[('Race retry',19),('Paint bundle',99),('Upgrade kit',249),('Credit vault',799)], + passes=['VIP garage','Extra car slots','Track pack','Custom paint studio'], + places=['Neon circuit','Mountain sprint','Ranked lobby'], + fields=[('raceStarts','Races entered'),('roundWins','Race wins'),('bestLapSeconds','Best lap (seconds)'),('rating','Ranked rating'),('garageCars','Garage cars'),('upgradeLevel','Upgrade level')], + activities=[('race','Ranked race','race_entered','race_left',['starting_grid_ready','checkpoint_reached','finish_line_crossed']), + ('garage','Garage tuning','garage_opened','garage_closed',['car_selected','upgrade_applied'])]), + 'dungeon-v1':dict(name='Dungeon Workshop',genre='Co-op dungeon RPG and crafting',scenario='discovery_surge',players=16000, + weights=[.48,.065,.12,.025,.18,.075,.05,.005],minutes=1.05,retention=1.05,automatic=.11, + labels={'social':'Co-op regular','collector':'Loot collector','competitor':'Dungeon speedrunner','grinder':'Progression grinder','budget':'Starter-pack buyer','impulse':'Impulse reviver','whale':'Power spender','tourist':'Curious newcomer'}, + products=[('Adventurer starter',49),('Revive token',29),('Crafting supplies',149),('Royal gem chest',999)], + passes=['VIP guild','Extra inventory','Crafting queue','Companion slot'], + places=['Guild village','Crystal depths','Ancient forge'], + fields=[('quests','Quests completed'),('crafts','Items crafted'),('dungeonClears','Dungeon clears'),('bossKills','Boss kills'),('materials','Materials held'),('powerScore','Power score')], + activities=[('dungeon','Co-op dungeon','dungeon_entered','dungeon_left',['first_room_cleared','boss_reached','boss_defeated']), + ('crafting','Crafting attempt','crafting_opened','crafting_closed',['recipe_selected','materials_committed','item_crafted'])]), +} + + +def acquisition_weight(config,day): + if config['scenario']=='stable': return 1+.045*math.sin(day/8) + if config['scenario']=='declining': return 1 if day<52 else math.exp(-(day-52)/16) + if day<80:return .75+.1*math.sin(day/9) + return {80:1,81:1.2,82:1.4,83:1.8,84:2.5,85:6,86:12,87:22,88:36,89:44}[day] + + +def retention_factor(config,day): + if config['scenario']=='declining': return .22+.78*math.exp(-max(0,day-57)/14) + if config['scenario']=='discovery_surge' and day>=80:return 1.12 + return 1 diff --git a/mockups/scenario_seed.py b/mockups/scenario_seed.py new file mode 100644 index 0000000..389a12e --- /dev/null +++ b/mockups/scenario_seed.py @@ -0,0 +1,271 @@ +"""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) diff --git a/mockups/summary_reports.py b/mockups/summary_reports.py index 92a864f..e2665cb 100644 --- a/mockups/summary_reports.py +++ b/mockups/summary_reports.py @@ -105,6 +105,9 @@ def extend_report(conn,report,start,end): evidence={'mau':extra['mau'],'d7':retained[7],'d30':retained[30],'arppu':arppu,'arpdau':arpdau, 'newSession':extra['new_session'],'returningSession':extra['returning_session'], 'largestPayerShare':largest,'topProducts':products,'promptSources':prompt_by_source,'largestDrop':biggest} + evidence['acquisitionSources']=conn.execute('''SELECT metadata->>'acquisitionSource' source,count(*)::int players + FROM players WHERE game_id=%(gid)s AND first_seen>=%(start)s::date AND first_seen<%(end)s::date + AND metadata ? 'acquisitionSource' GROUP BY source''',scope).fetchall() from overview_analysis import deep_analysis executive=deep_analysis(conn,report,start,end,evidence) return {'summaryVersion':4,'executive':executive,'summaries':sections,'summaryEvidence':evidence} diff --git a/mockups/tests/test_database.py b/mockups/tests/test_database.py index 6fe0c8f..7464f5b 100644 --- a/mockups/tests/test_database.py +++ b/mockups/tests/test_database.py @@ -274,6 +274,22 @@ class SeededApi(unittest.TestCase): if report['periodDays']==7: texts.append(report['executive']['paragraphs'][0]['text']) self.assertEqual(len(texts),len(set(texts))) + def test_sample_genres_personalities_and_graphable_fields(self): + with connect() as conn: + games=conn.execute("SELECT id,properties FROM games WHERE properties->>'sampleVersion'='3'").fetchall() + self.assertEqual(len({g['properties']['genre'] for g in games}),3) + self.assertEqual({g['properties']['sampleScenario'] for g in games},{'stable','declining','discovery_surge'}) + for game in games: + kinds=conn.execute("SELECT DISTINCT metadata->>'simulationGroup' kind FROM players WHERE game_id=%s",(game['id'],)).fetchall() + self.assertEqual(len(kinds),8) + data=dispatch(f"/api/games/{game['id']}/players",{}) + self.assertTrue(any(f['key']=='playStyle' for f in data['fields'])) + self.assertTrue(all(p['playStyle'] for p in data['playerRows'])) + pid=data['playerRows'][0]['playerId'] + profile=dispatch(f"/api/games/{game['id']}/players/{pid}",{}) + self.assertNotIn('playStyle',{f['key'] for f in profile['historyFields']}) + self.assertNotIn('acquisitionSource',{f['key'] for f in profile['historyFields']}) + def test_daily_event_lines_match_summary(self): data=dispatch(f'/api/games/{self.game}/events',{'days':['7']}) self.assertNotIn('events',data)