CREATE SCHEMA IF NOT EXISTS analytics; SET search_path TO analytics, public; CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now()); CREATE TABLE games ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, roblox_experience_id bigint, timezone text NOT NULL DEFAULT 'UTC', properties jsonb NOT NULL DEFAULT '{}', sample_key text UNIQUE, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE players ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), roblox_user_id bigint NOT NULL, display_name text, first_seen timestamptz NOT NULL, last_seen timestamptz, metadata jsonb NOT NULL DEFAULT '{}', UNIQUE (game_id, id), UNIQUE (game_id, roblox_user_id) ); CREATE TABLE sessions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), player_id bigint NOT NULL, started_at timestamptz NOT NULL, ended_at timestamptz, place_name text, stats jsonb NOT NULL DEFAULT '{}', UNIQUE (game_id, id), UNIQUE (game_id, player_id, id), FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id), CHECK (ended_at IS NULL OR ended_at >= started_at) ); CREATE TABLE player_snapshots ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), player_id bigint NOT NULL, captured_at timestamptz NOT NULL, data jsonb NOT NULL, FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id), CHECK (jsonb_typeof(data) = 'object') ); CREATE TABLE activity_definitions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), key text NOT NULL, name text NOT NULL, start_event text NOT NULL, end_event text, configuration jsonb NOT NULL DEFAULT '{}', UNIQUE (game_id, id), UNIQUE (game_id, key) ); CREATE TABLE activity_runs ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), definition_id bigint NOT NULL, player_id bigint NOT NULL, session_id bigint, started_at timestamptz NOT NULL, ended_at timestamptz, outcome text, step_count integer, prompt_count integer, captured_values jsonb NOT NULL DEFAULT '{}', FOREIGN KEY (game_id, definition_id) REFERENCES activity_definitions(game_id, id), FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id), FOREIGN KEY (game_id, player_id, session_id) REFERENCES sessions(game_id, player_id, id), CHECK (ended_at IS NULL OR ended_at >= started_at) ); CREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), category text NOT NULL DEFAULT 'events', name text NOT NULL, player_id bigint, session_id bigint, occurred_at timestamptz NOT NULL, properties jsonb NOT NULL DEFAULT '{}', FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id), FOREIGN KEY (game_id, player_id, session_id) REFERENCES sessions(game_id, player_id, id), CHECK (session_id IS NULL OR player_id IS NOT NULL) ); CREATE TABLE game_metrics ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, game_id bigint NOT NULL REFERENCES games(id), category text NOT NULL, name text NOT NULL, recorded_at timestamptz NOT NULL, value double precision, properties jsonb NOT NULL DEFAULT '{}', UNIQUE (game_id, category, name, recorded_at) ); CREATE TABLE player_fields ( game_id bigint NOT NULL REFERENCES games(id), key text NOT NULL, label text NOT NULL, format text NOT NULL DEFAULT 'number', source text NOT NULL DEFAULT 'snapshot', PRIMARY KEY (game_id, key) ); CREATE TABLE column_templates ( game_id bigint NOT NULL REFERENCES games(id), key text NOT NULL, label text NOT NULL, columns jsonb NOT NULL, PRIMARY KEY (game_id, key) ); CREATE INDEX sessions_game_time ON sessions(game_id, started_at DESC); CREATE INDEX sessions_player_time ON sessions(game_id, player_id, started_at DESC); CREATE INDEX snapshots_player_latest ON player_snapshots(game_id, player_id, captured_at DESC, id DESC); CREATE INDEX events_game_time ON events(game_id, occurred_at DESC); CREATE INDEX events_player_time ON events(game_id, player_id, occurred_at DESC); CREATE INDEX metrics_game_category_time ON game_metrics(game_id, category, name, recorded_at DESC); CREATE INDEX runs_game_definition ON activity_runs(game_id, definition_id, started_at DESC); CREATE INDEX runs_player ON activity_runs(game_id, player_id, started_at DESC); CREATE VIEW player_statistics AS WITH session_totals AS ( SELECT game_id, player_id, count(*)::int AS session_count, sum(extract(epoch FROM ended_at-started_at))::double precision AS playtime, avg(extract(epoch FROM ended_at-started_at))::double precision AS average_session, percentile_cont(.5) WITHIN GROUP (ORDER BY extract(epoch FROM ended_at-started_at))::double precision AS median_session, count(DISTINCT (started_at AT TIME ZONE 'UTC')::date) FILTER (WHERE started_at >= now()-interval '30 days')::int AS active_days FROM sessions GROUP BY game_id, player_id ) SELECT p.*, latest.data AS latest_data, latest.captured_at AS snapshot_at, coalesce(s.session_count,0) AS session_count, s.playtime, s.average_session, s.median_session, coalesce(s.active_days,0) AS active_days FROM players p LEFT JOIN session_totals s ON s.game_id=p.game_id AND s.player_id=p.id LEFT JOIN LATERAL (SELECT data,captured_at FROM player_snapshots ps WHERE ps.game_id=p.game_id AND ps.player_id=p.id ORDER BY captured_at DESC,id DESC LIMIT 1) latest ON true; INSERT INTO schema_migrations(version) VALUES ('001_initial');