Add sample scenario data and reporting updates

This commit is contained in:
maxwes08 committed 2026-11-04 09:53:53 +01:00
1 parent aa0028e7e0
commit 133bc6ce3f
15 files changed
+444 -238

No files matched your search

+1
View File
@@ -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/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/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/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. | | `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**. 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**.
+2
View File
@@ -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. 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 ## 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. - 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. - 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.
+1 -1
View File
@@ -25,7 +25,7 @@ Open <http://127.0.0.1:8011/mockups/overview-mockup.html>. The game selector swi
## Sample data ## 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. 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.
+29 -5
View File
@@ -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 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. - [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 ## 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. 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: Run the validation report and tests:
```powershell ```powershell
.venv/Scripts/python.exe mockups/sample_report.py .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 .venv/Scripts/python.exe -m unittest discover -s mockups/tests -v
``` ```
+2 -2
View File
@@ -343,7 +343,7 @@ async function loadPlayers() {
playerData=data; fillMetrics(data.metrics); playerData=data; fillMetrics(data.metrics);
const ranking=$('[data-leaderboard-value]'); const ranking=$('[data-leaderboard-value]');
if (!ranking.options.length) { 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'; ranking.value='level';
} }
$('[data-leaderboards]').innerHTML=data.leaderboards.map(board=>`<article class="card card-pad"><div class="card-title">${cell(board.label,'text')}</div><ol class="fame-list">${board.players.map((p,i)=>`<li><span class="fame-rank">${i+1}</span><div class="avatar-placeholder" role="img" aria-label="Avatar placeholder for ${cell(p.name,'text')}"><span>${cell((p.name || '?').slice(0,2).toUpperCase(),'text')}</span></div><div class="fame-player">${playerLink(p.playerId,p.name,p.robloxUserId)}</div><strong>${cell(p.value,board.format)}</strong></li>`).join('') || '<li class="empty">N/A</li>'}</ol></article>`).join(''); $('[data-leaderboards]').innerHTML=data.leaderboards.map(board=>`<article class="card card-pad"><div class="card-title">${cell(board.label,'text')}</div><ol class="fame-list">${board.players.map((p,i)=>`<li><span class="fame-rank">${i+1}</span><div class="avatar-placeholder" role="img" aria-label="Avatar placeholder for ${cell(p.name,'text')}"><span>${cell((p.name || '?').slice(0,2).toUpperCase(),'text')}</span></div><div class="fame-player">${playerLink(p.playerId,p.name,p.robloxUserId)}</div><strong>${cell(p.value,board.format)}</strong></li>`).join('') || '<li class="empty">N/A</li>'}</ol></article>`).join('');
@@ -458,7 +458,7 @@ async function loadProfile() {
const p=data.player; const p=data.player;
$('[data-profile-name]').textContent=format(p.name); $('[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-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=>`<span class="pill">${escape(v)}</span>`).join(''); $('[data-profile-tags]').innerHTML=[p.playStyle,...(data.groups || [])].filter(Boolean).map(v=>`<span class="pill">${escape(v)}</span>`).join('');
fillMetrics(p); fillMetrics(p);
const chooser=$('[data-profile-value]'); const chooser=$('[data-profile-value]');
if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key))); if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key)));
+6 -6
View File
@@ -2,12 +2,12 @@ SET search_path TO analytics, public;
-- Only enrich the bundled synthetic histories; unknown real purchase dates stay NULL. -- 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) 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 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) 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, 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}' 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 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) 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, 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, 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}' 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 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 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; ON CONFLICT DO NOTHING;
-- Product prompt telemetry is separate from the longer shop/activity journey. -- Product prompt telemetry is separate from the longer shop/activity journey.
-- Source labels below are synthetic examples, never inferred for real reports. -- 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='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, WHEN i.outcome IS DISTINCT FROM 'bought' AND i.id%100<30 THEN 'player' ELSE 'game' END,
properties=i.properties||'{"sourceSeeded":true}'::jsonb 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'); 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, 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, step_count=CASE WHEN stage_progress ? 'product_viewed' THEN 1 ELSE 0 END,
captured_values=captured_values-'product_id'-'purchase_amount', 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)) 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 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')) 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';
+15 -2
View File
@@ -67,7 +67,20 @@ def status():
print("PostgreSQL sample records: " + ", ".join(f"{k}={v:,}" for k, v in counts.items())) 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["init", "seed", "status", "reset-sample"], default="init", nargs="?") 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"}[parser.parse_args().command]]() globals()[{"init": "initialize", "seed": "seed", "status": "status", "reset-sample": "reseed", "refresh-reports":"refresh_reports"}[parser.parse_args().command]]()
+10 -1
View File
@@ -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) 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)) weekday=window_metrics(conn,report['gameId'],end-timedelta(days=8),end-timedelta(days=7))
momentum=direction(recent['active_players'],weekday['active_players']) 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." 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: 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." 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.' 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: 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.' 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: elif momentum==1:
overall+=' The latest same-weekday comparison supports the broader reach improvement.' overall+=' The latest same-weekday comparison supports the broader reach improvement.'
+4 -216
View File
@@ -35,219 +35,7 @@ def reset_sample(conn):
conn.execute(sql.SQL('DELETE FROM {} WHERE game_id=ANY(%s)').format(sql.Identifier(table)), (gids,)) conn.execute(sql.SQL('DELETE FROM {} WHERE game_id=ANY(%s)').format(sql.Identifier(table)), (gids,))
def populate(conn, players_per_game=6000): def populate(conn,players_per_game=None):
rng = random.Random(20261103) """Generate the current three-genre scenario fixtures; preserve shared COPY/reset helpers."""
now = datetime.now(timezone.utc) from scenario_seed import populate as generate_scenarios
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) return generate_scenarios(conn,players_per_game)
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()<probability,now))
copy_rows(conn,'gamepass_ownership',['game_id','gamepass_id','player_id','is_owned','checked_at'],ownership)
conn.execute("UPDATE games SET properties=properties || %s WHERE id=%s",
(Jsonb({'sampleAsOf':now.isoformat(),'sampleVersion':2,'sampleData':'benchmark-informed synthetic; not actual Roblox users'}),gid))
print(f"Generated {game['name']}: {len(stored):,} players, {len(session_rows):,} sessions, {len(sales):,} receipts.",flush=True)
+3 -2
View File
@@ -55,6 +55,7 @@ def fields_templates(conn, game_id):
def player_values(r): def player_values(r):
now=datetime.now(timezone.utc) now=datetime.now(timezone.utc)
p=dict(r["latest_data"] or {}) p=dict(r["latest_data"] or {})
p.setdefault('playStyle',(r.get('metadata') or {}).get('personaLabel'))
p.update({"playerId":r["id"],"gameId":r["game_id"],"robloxUserId":r["roblox_user_id"],"name":r["display_name"], p.update({"playerId":r["id"],"gameId":r["game_id"],"robloxUserId":r["roblox_user_id"],"name":r["display_name"],
"id":f"User ID {r['roblox_user_id']}","firstSeen":r["first_seen"],"lastSeenAt":r["last_seen"],"snapshotAt":r["snapshot_at"], "id":f"User ID {r['roblox_user_id']}","firstSeen":r["first_seen"],"lastSeenAt":r["last_seen"],"snapshotAt":r["snapshot_at"],
"firstSeenDays":(now-r["first_seen"]).total_seconds()/86400, "firstSeenDays":(now-r["first_seen"]).total_seconds()/86400,
@@ -84,7 +85,7 @@ def player_list(conn,gid,params):
fields,templates=fields_templates(conn,gid) fields,templates=fields_templates(conn,gid)
players=all_players(conn,gid) players=all_players(conn,gid)
ranking=params.get('ranking',['level'])[0] ranking=params.get('ranking',['level'])[0]
if ranking not in {f['key'] for f in fields}: raise ApiError(400,'Unknown ranking.') if ranking not in {f['key'] for f in fields if f['format'] not in ('text','json')}: raise ApiError(400,'Unknown ranking.')
boards=[] boards=[]
for key in dict.fromkeys(['robux','playtime','cash',ranking]): for key in dict.fromkeys(['robux','playtime','cash',ranking]):
field=next((f for f in fields if f['key']==key),None) field=next((f for f in fields if f['key']==key),None)
@@ -281,7 +282,7 @@ def player_history(conn,gid,pid,fields,params):
WHERE game_id=ps.game_id AND player_id=ps.player_id AND purchased_at<=ps.captured_at AND unit_price_robux>0) sp WHERE game_id=ps.game_id AND player_id=ps.player_id AND purchased_at<=ps.captured_at AND unit_price_robux>0) sp
WHERE ps.game_id=%s AND ps.player_id=%s ORDER BY ps.captured_at,ps.id""",(gid,pid)) 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'} 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'), for key,title,fmt in [('sessions','Sessions','number'),('playtime','Lifetime playtime','duration'),
('avgSession','Average session','duration'),('medianSession','Median session','duration'), ('avgSession','Average session','duration'),('medianSession','Median session','duration'),
('activeDays','Active days (30d)','number'),('robux','Robux spent','robux'), ('activeDays','Active days (30d)','number'),('robux','Robux spent','robux'),
+25 -3
View File
@@ -2,11 +2,13 @@
import math import math
from database import connect from database import connect
from health_reports import window_metrics
from datetime import timedelta
def report(): def report():
with connect() as conn: 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'] gid=game['id']
summary=conn.execute('''WITH counts AS(SELECT player_id,count(*) n FROM sessions WHERE game_id=%s GROUP BY player_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() 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'] 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 returns[0]>returns[1]>returns[2], (game,returns)
assert 3<float(minutes)<30, (game,minutes) assert 3<float(minutes)<30, (game,minutes)
assert summary['one_session']>50, (game,summary) assert summary['one_session']>40, (game,summary)
assert len(spending)<summary['players']*.1, (game,len(spending)) assert len(spending)<summary['players']*.1, (game,len(spending))
assert top_share>10, (game,top_share) assert top_share>5, (game,top_share)
assert bad_sales==0, (game,bad_sales) assert bad_sales==0, (game,bad_sales)
assert overlaps==0, (game,overlaps) assert overlaps==0, (game,overlaps)
assert bad_balances==0, (game,bad_balances) assert bad_balances==0, (game,bad_balances)
assert bad_snapshots==0, (game,bad_snapshots) 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 .80<audience_ratio<1.20,(game['name'],audience_ratio)
assert .70<ccu_ratio<1.30,(game['name'],ccu_ratio)
elif scenario=='declining':
assert audience_ratio<.80,(game['name'],audience_ratio)
assert ccu_ratio<.80,(game['name'],ccu_ratio)
else:
assert audience_ratio>2,(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"{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(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.') print('Sample distribution and timeline checks passed.')
+56
View File
@@ -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
+271
View File
@@ -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()<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)
+3
View File
@@ -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, evidence={'mau':extra['mau'],'d7':retained[7],'d30':retained[30],'arppu':arppu,'arpdau':arpdau,
'newSession':extra['new_session'],'returningSession':extra['returning_session'], 'newSession':extra['new_session'],'returningSession':extra['returning_session'],
'largestPayerShare':largest,'topProducts':products,'promptSources':prompt_by_source,'largestDrop':biggest} '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 from overview_analysis import deep_analysis
executive=deep_analysis(conn,report,start,end,evidence) executive=deep_analysis(conn,report,start,end,evidence)
return {'summaryVersion':4,'executive':executive,'summaries':sections,'summaryEvidence':evidence} return {'summaryVersion':4,'executive':executive,'summaries':sections,'summaryEvidence':evidence}
+16
View File
@@ -274,6 +274,22 @@ class SeededApi(unittest.TestCase):
if report['periodDays']==7: texts.append(report['executive']['paragraphs'][0]['text']) if report['periodDays']==7: texts.append(report['executive']['paragraphs'][0]['text'])
self.assertEqual(len(texts),len(set(texts))) 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): def test_daily_event_lines_match_summary(self):
data=dispatch(f'/api/games/{self.game}/events',{'days':['7']}) data=dispatch(f'/api/games/{self.game}/events',{'days':['7']})
self.assertNotIn('events',data) self.assertNotIn('events',data)