Update Version (Beta): login + leaderboard

This commit is contained in:
Zakaria
2026-05-12 19:00:46 -04:00
parent 3175a4dbf1
commit 66f8207985
10 changed files with 17132 additions and 232 deletions
+642 -164
View File
@@ -5,138 +5,32 @@ create table if not exists public.wordle_words (
word text not null unique check (word ~ '^[a-z]{5}$')
);
insert into public.wordle_words (position, word) values
(1, 'cigar'),
(2, 'rebut'),
(3, 'sissy'),
(4, 'humph'),
(5, 'awake'),
(6, 'blush'),
(7, 'focal'),
(8, 'evade'),
(9, 'naval'),
(10, 'serve'),
(11, 'heath'),
(12, 'dwarf'),
(13, 'model'),
(14, 'karma'),
(15, 'stink'),
(16, 'grade'),
(17, 'quiet'),
(18, 'bench'),
(19, 'abate'),
(20, 'feign'),
(21, 'major'),
(22, 'death'),
(23, 'fresh'),
(24, 'crust'),
(25, 'stool'),
(26, 'colon'),
(27, 'abase'),
(28, 'marry'),
(29, 'react'),
(30, 'batty'),
(31, 'pride'),
(32, 'floss'),
(33, 'helix'),
(34, 'croak'),
(35, 'staff'),
(36, 'paper'),
(37, 'unfed'),
(38, 'whelp'),
(39, 'trawl'),
(40, 'outdo'),
(41, 'adobe'),
(42, 'crazy'),
(43, 'sower'),
(44, 'repay'),
(45, 'digit'),
(46, 'crate'),
(47, 'cluck'),
(48, 'spike'),
(49, 'mimic'),
(50, 'pound'),
(51, 'maxim'),
(52, 'linen'),
(53, 'unmet'),
(54, 'flesh'),
(55, 'booby'),
(56, 'forth'),
(57, 'first'),
(58, 'stand'),
(59, 'belly'),
(60, 'ivory'),
(61, 'seedy'),
(62, 'print'),
(63, 'yearn'),
(64, 'drain'),
(65, 'bribe'),
(66, 'stout'),
(67, 'panel'),
(68, 'crass'),
(69, 'flume'),
(70, 'offal'),
(71, 'agree'),
(72, 'error'),
(73, 'swirl'),
(74, 'argue'),
(75, 'bleed'),
(76, 'delta'),
(77, 'flick'),
(78, 'totem'),
(79, 'wooer'),
(80, 'front'),
(81, 'shrub'),
(82, 'parry'),
(83, 'biome'),
(84, 'lapel'),
(85, 'start'),
(86, 'greet'),
(87, 'goner'),
(88, 'golem'),
(89, 'lusty'),
(90, 'loopy'),
(91, 'round'),
(92, 'audit'),
(93, 'lying'),
(94, 'gamma'),
(95, 'labor'),
(96, 'islet'),
(97, 'civic'),
(98, 'forge'),
(99, 'corny'),
(100, 'moult'),
(101, 'basic'),
(102, 'salad'),
(103, 'agate'),
(104, 'spicy'),
(105, 'spray'),
(106, 'essay'),
(107, 'fjord'),
(108, 'spend'),
(109, 'kebab'),
(110, 'guild'),
(111, 'aback'),
(112, 'motor'),
(113, 'alone'),
(114, 'hatch'),
(115, 'hyper'),
(116, 'thumb'),
(117, 'dowry'),
(118, 'ought'),
(119, 'belch'),
(120, 'dutch')
on conflict (position) do nothing;
create table if not exists public.wordle_dictionary (
word text primary key check (word ~ '^[a-z]{5}$')
);
alter table public.wordle_words enable row level security;
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text,
display_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles
add column if not exists username text;
create unique index if not exists profiles_username_key
on public.profiles (lower(username))
where username is not null;
create table if not exists public.wordle_rounds (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
word text not null check (word ~ '^[a-z]{5}$'),
hour_start timestamptz,
hour_start timestamptz not null,
started_at timestamptz not null default now(),
next_playable_at timestamptz not null default (now() + interval '1 hour'),
next_playable_at timestamptz not null,
completed_at timestamptz,
won boolean,
guess_count integer check (guess_count between 1 and 6),
@@ -144,14 +38,31 @@ create table if not exists public.wordle_rounds (
);
alter table public.wordle_rounds
add column if not exists hour_start timestamptz;
add column if not exists hour_start timestamptz,
add column if not exists next_playable_at timestamptz;
update public.wordle_rounds
set hour_start = date_trunc('hour', started_at)
where hour_start is null;
update public.wordle_rounds
set next_playable_at = hour_start + interval '1 hour'
where next_playable_at is null;
alter table public.wordle_rounds
alter column hour_start set not null;
alter column hour_start set not null,
alter column next_playable_at set not null;
create table if not exists public.wordle_guesses (
id uuid primary key default gen_random_uuid(),
round_id uuid not null references public.wordle_rounds(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
guess text not null check (guess ~ '^[a-z]{5}$'),
row_index integer not null check (row_index between 1 and 6),
states jsonb not null,
created_at timestamptz not null default now(),
unique (round_id, row_index)
);
create index if not exists wordle_rounds_user_started_idx
on public.wordle_rounds (user_id, started_at desc);
@@ -159,7 +70,27 @@ create index if not exists wordle_rounds_user_started_idx
create unique index if not exists wordle_rounds_user_hour_idx
on public.wordle_rounds (user_id, hour_start);
create index if not exists wordle_guesses_round_row_idx
on public.wordle_guesses (round_id, row_index);
alter table public.wordle_words enable row level security;
alter table public.wordle_dictionary enable row level security;
alter table public.profiles enable row level security;
alter table public.wordle_rounds enable row level security;
alter table public.wordle_guesses enable row level security;
drop policy if exists "Users can read their own profile" on public.profiles;
create policy "Users can read their own profile"
on public.profiles
for select
using (auth.uid() = id);
drop policy if exists "Users can update their own profile" on public.profiles;
create policy "Users can update their own profile"
on public.profiles
for update
using (auth.uid() = id)
with check (auth.uid() = id);
drop policy if exists "Users can read their own rounds" on public.wordle_rounds;
create policy "Users can read their own rounds"
@@ -167,12 +98,113 @@ create policy "Users can read their own rounds"
for select
using (auth.uid() = user_id);
drop policy if exists "Users can read their own guesses" on public.wordle_guesses;
create policy "Users can read their own guesses"
on public.wordle_guesses
for select
using (auth.uid() = user_id);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, username, display_name)
values (
new.id,
nullif(lower(regexp_replace(coalesce(new.raw_user_meta_data->>'username', split_part(new.email, '@', 1)), '[^a-z0-9_]', '', 'g')), ''),
coalesce(new.raw_user_meta_data->>'display_name', new.raw_user_meta_data->>'username', split_part(new.email, '@', 1))
)
on conflict (id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
create or replace function public.score_guess(guess text, answer text)
returns jsonb
language plpgsql
immutable
as $$
declare
states text[] := array['wrong', 'wrong', 'wrong', 'wrong', 'wrong'];
remaining_letters text := '';
guess_letter text;
answer_letter text;
match_index integer;
index integer;
begin
for index in 1..5 loop
guess_letter := substr(guess, index, 1);
answer_letter := substr(answer, index, 1);
if guess_letter = answer_letter then
states[index] := 'correct';
else
remaining_letters := remaining_letters || answer_letter;
end if;
end loop;
for index in 1..5 loop
if states[index] = 'correct' then
continue;
end if;
guess_letter := substr(guess, index, 1);
match_index := strpos(remaining_letters, guess_letter);
if match_index > 0 then
states[index] := 'wrong-position';
remaining_letters := overlay(remaining_letters placing '' from match_index for 1);
end if;
end loop;
return to_jsonb(states);
end;
$$;
create or replace function public.get_hourly_word(hour_start timestamptz)
returns text
language plpgsql
stable
security definer
set search_path = public
as $$
declare
word_count integer;
word_offset integer;
hourly_word text;
begin
select count(*) into word_count from public.wordle_words;
if word_count = 0 then
raise exception 'No hourly words are configured';
end if;
word_offset := (floor(extract(epoch from hour_start) / 3600)::bigint % word_count)::integer;
select public.wordle_words.word
into hourly_word
from public.wordle_words
order by position
limit 1 offset word_offset;
return hourly_word;
end;
$$;
drop function if exists public.start_hourly_round(text);
create or replace function public.start_hourly_round()
returns table (
round_id uuid,
word text,
hour_start timestamptz,
started_at timestamptz,
next_playable_at timestamptz,
@@ -180,7 +212,9 @@ returns table (
won boolean,
guess_count integer,
server_now timestamptz,
is_existing boolean
is_existing boolean,
revealed_word text,
guesses jsonb
)
language plpgsql
security definer
@@ -190,27 +224,12 @@ declare
existing_round public.wordle_rounds%rowtype;
new_round public.wordle_rounds%rowtype;
current_hour timestamptz := date_trunc('hour', now());
word_count integer;
word_offset integer;
hourly_word text;
hourly_word text := public.get_hourly_word(date_trunc('hour', now()));
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
select count(*) into word_count from public.wordle_words;
if word_count = 0 then
raise exception 'No hourly words are configured';
end if;
word_offset := (floor(extract(epoch from current_hour) / 3600)::bigint % word_count)::integer;
select public.wordle_words.word
into hourly_word
from public.wordle_words
order by position
limit 1 offset word_offset;
select *
into existing_round
from public.wordle_rounds
@@ -222,7 +241,6 @@ begin
if found then
return query select
existing_round.id,
existing_round.word,
existing_round.hour_start,
existing_round.started_at,
existing_round.next_playable_at,
@@ -230,7 +248,17 @@ begin
existing_round.won,
existing_round.guess_count,
now(),
true;
true,
case when existing_round.completed_at is null then null else existing_round.word end,
coalesce((
select jsonb_agg(jsonb_build_object(
'guess', wordle_guesses.guess,
'rowIndex', wordle_guesses.row_index,
'states', wordle_guesses.states
) order by wordle_guesses.row_index)
from public.wordle_guesses
where wordle_guesses.round_id = existing_round.id
), '[]'::jsonb);
return;
end if;
@@ -240,7 +268,6 @@ begin
return query select
new_round.id,
new_round.word,
new_round.hour_start,
new_round.started_at,
new_round.next_playable_at,
@@ -248,10 +275,461 @@ begin
new_round.won,
new_round.guess_count,
now(),
false;
false,
null::text,
'[]'::jsonb;
end;
$$;
drop function if exists public.submit_guess(uuid, text);
create or replace function public.submit_guess(target_round_id uuid, submitted_guess text)
returns table (
guess text,
row_index integer,
states jsonb,
completed boolean,
won boolean,
guess_count integer,
revealed_word text,
next_playable_at timestamptz,
server_now timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
active_round public.wordle_rounds%rowtype;
normalized_guess text := lower(trim(submitted_guess));
next_row integer;
scored_states jsonb;
did_win boolean;
did_complete boolean;
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
if normalized_guess !~ '^[a-z]{5}$' then
raise exception 'Guess must be five letters';
end if;
if not exists (select 1 from public.wordle_dictionary where word = normalized_guess) then
raise exception 'Not in word list';
end if;
select *
into active_round
from public.wordle_rounds rounds
where rounds.id = target_round_id
and rounds.user_id = auth.uid()
limit 1;
if not found then
raise exception 'Round not found';
end if;
if active_round.completed_at is not null then
raise exception 'Round already complete';
end if;
select (count(*) + 1)::integer
into next_row
from public.wordle_guesses
where wordle_guesses.round_id = active_round.id;
if next_row > 6 then
raise exception 'No guesses remaining';
end if;
scored_states := public.score_guess(normalized_guess, active_round.word);
did_win := normalized_guess = active_round.word;
did_complete := did_win or next_row = 6;
insert into public.wordle_guesses (round_id, user_id, guess, row_index, states)
values (active_round.id, auth.uid(), normalized_guess, next_row, scored_states);
if did_complete then
update public.wordle_rounds
set completed_at = now(),
won = did_win,
guess_count = next_row
where id = active_round.id;
end if;
return query select
normalized_guess,
next_row,
scored_states,
did_complete,
did_win,
case when did_complete then next_row else null end,
case when did_complete then active_round.word else null end,
active_round.next_playable_at,
now();
end;
$$;
create or replace function public.get_user_stats()
returns table (
played integer,
wins integer,
current_streak integer,
max_streak integer,
distribution integer[]
)
language plpgsql
security definer
set search_path = public
as $$
declare
round_record record;
running_streak integer := 0;
previous_hour timestamptz;
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
played := 0;
wins := 0;
current_streak := 0;
max_streak := 0;
distribution := array[0, 0, 0, 0, 0, 0];
for round_record in
select won, guess_count, hour_start
from public.wordle_rounds
where user_id = auth.uid()
and completed_at is not null
order by hour_start asc
loop
played := played + 1;
if round_record.won then
wins := wins + 1;
running_streak := case
when running_streak > 0 and previous_hour is not null and round_record.hour_start = previous_hour + interval '1 hour'
then running_streak + 1
else 1
end;
max_streak := greatest(max_streak, running_streak);
if round_record.guess_count between 1 and 6 then
distribution[round_record.guess_count] := distribution[round_record.guess_count] + 1;
end if;
else
running_streak := 0;
end if;
previous_hour := round_record.hour_start;
end loop;
previous_hour := null;
for round_record in
select won, hour_start
from public.wordle_rounds
where user_id = auth.uid()
and completed_at is not null
order by hour_start desc
loop
if not round_record.won then
exit;
end if;
if previous_hour is not null and round_record.hour_start <> previous_hour - interval '1 hour' then
exit;
end if;
current_streak := current_streak + 1;
previous_hour := round_record.hour_start;
end loop;
return next;
end;
$$;
create or replace function public.is_username_available(candidate_username text)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
normalized_username text := nullif(lower(regexp_replace(trim(candidate_username), '[^a-z0-9_]', '', 'g')), '');
begin
if normalized_username is null then
return false;
end if;
return not exists (
select 1
from public.profiles
where lower(username) = normalized_username
);
end;
$$;
create or replace function public.get_player_current_streak(target_user_id uuid)
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
round_record record;
streak integer := 0;
previous_hour timestamptz;
begin
for round_record in
select won, hour_start
from public.wordle_rounds
where user_id = target_user_id
and completed_at is not null
order by hour_start desc
loop
if not round_record.won then
exit;
end if;
if previous_hour is not null and round_record.hour_start <> previous_hour - interval '1 hour' then
exit;
end if;
streak := streak + 1;
previous_hour := round_record.hour_start;
end loop;
return streak;
end;
$$;
create or replace function public.get_hourly_leaderboard()
returns table (
username text,
won boolean,
guess_count integer,
completed_at timestamptz,
rank integer
)
language plpgsql
security definer
set search_path = public
as $$
begin
return query
select
coalesce(profiles.username, profiles.display_name, 'player') as username,
rounds.won,
rounds.guess_count,
rounds.completed_at,
row_number() over (
order by
rounds.won desc,
rounds.guess_count asc nulls last,
rounds.completed_at asc
)::integer as rank
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.hour_start = date_trunc('hour', now())
and rounds.completed_at is not null
order by rank
limit 25;
end;
$$;
create or replace function public.get_leaderboard(board_scope text default 'hour')
returns table (
username text,
won boolean,
guess_count integer,
completed_at timestamptz,
rank integer,
current_streak integer,
is_current_user boolean,
wins integer,
played integer,
average_guesses numeric
)
language plpgsql
security definer
set search_path = public
as $$
declare
normalized_scope text := lower(coalesce(board_scope, 'hour'));
begin
if normalized_scope = 'today' then
return query
with aggregate_rows as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
count(*)::integer as played,
count(*) filter (where rounds.won)::integer as wins,
round(avg(rounds.guess_count) filter (where rounds.won), 2) as average_guesses,
min(rounds.completed_at) filter (where rounds.won) as first_win_at,
max(rounds.completed_at) as latest_completed_at
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.completed_at is not null
and rounds.completed_at >= date_trunc('day', now())
group by rounds.user_id, profiles.username, profiles.display_name
), ranked as (
select
aggregate_rows.*,
row_number() over (
order by aggregate_rows.wins desc, aggregate_rows.average_guesses asc nulls last, aggregate_rows.first_win_at asc nulls last
)::integer as row_rank
from aggregate_rows
)
select
ranked.username,
ranked.wins > 0,
null::integer,
ranked.latest_completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
ranked.wins,
ranked.played,
ranked.average_guesses
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
return;
end if;
if normalized_scope = 'all' then
return query
with aggregate_rows as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
count(*)::integer as played,
count(*) filter (where rounds.won)::integer as wins,
round(avg(rounds.guess_count) filter (where rounds.won), 2) as average_guesses,
min(rounds.completed_at) filter (where rounds.won) as first_win_at,
max(rounds.completed_at) as latest_completed_at
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.completed_at is not null
group by rounds.user_id, profiles.username, profiles.display_name
), ranked as (
select
aggregate_rows.*,
row_number() over (
order by aggregate_rows.wins desc, public.get_player_current_streak(aggregate_rows.user_id) desc, aggregate_rows.average_guesses asc nulls last, aggregate_rows.first_win_at asc nulls last
)::integer as row_rank
from aggregate_rows
)
select
ranked.username,
ranked.wins > 0,
null::integer,
ranked.latest_completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
ranked.wins,
ranked.played,
ranked.average_guesses
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
return;
end if;
return query
with ranked as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
rounds.won,
rounds.guess_count,
rounds.completed_at,
row_number() over (
order by rounds.won desc, rounds.guess_count asc nulls last, rounds.completed_at asc
)::integer as row_rank
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.hour_start = date_trunc('hour', now())
and rounds.completed_at is not null
)
select
ranked.username,
ranked.won,
ranked.guess_count,
ranked.completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
case when ranked.won then 1 else 0 end,
1,
ranked.guess_count::numeric
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
end;
$$;
create or replace function public.get_player_history(history_limit integer default 10)
returns table (
hour_start timestamptz,
word text,
won boolean,
guess_count integer,
completed_at timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
return query
select
rounds.hour_start,
rounds.word,
rounds.won,
rounds.guess_count,
rounds.completed_at
from public.wordle_rounds rounds
where rounds.user_id = auth.uid()
and rounds.completed_at is not null
order by rounds.hour_start desc
limit greatest(1, least(coalesce(history_limit, 10), 25));
end;
$$;
create or replace function public.get_hourly_summary()
returns table (
completed_count integer,
win_rate integer,
average_guesses numeric
)
language plpgsql
security definer
set search_path = public
as $$
begin
return query
select
count(*)::integer,
coalesce(round((count(*) filter (where won)::numeric / nullif(count(*), 0)) * 100), 0)::integer,
round(avg(guess_count) filter (where won), 2)
from public.wordle_rounds
where hour_start = date_trunc('hour', now())
and completed_at is not null;
end;
$$;
drop function if exists public.get_email_for_username(text);
create or replace function public.complete_hourly_round(
round_id uuid,
did_win boolean,
@@ -263,25 +741,25 @@ security definer
set search_path = public
as $$
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
if guess_total < 1 or guess_total > 6 then
raise exception 'Guess total must be between 1 and 6';
end if;
update public.wordle_rounds
set completed_at = coalesce(completed_at, now()),
won = did_win,
guess_count = guess_total
where id = round_id
and user_id = auth.uid();
raise exception 'complete_hourly_round is deprecated; use submit_guess';
end;
$$;
revoke all on public.wordle_rounds from anon, authenticated;
revoke all on public.wordle_words from anon, authenticated;
revoke all on public.wordle_dictionary from anon, authenticated;
revoke all on public.profiles from anon, authenticated;
revoke all on public.wordle_rounds from anon, authenticated;
revoke all on public.wordle_guesses from anon, authenticated;
grant select, update on public.profiles to authenticated;
grant select on public.wordle_rounds to authenticated;
grant select on public.wordle_guesses to authenticated;
grant execute on function public.is_username_available(text) to anon, authenticated;
grant execute on function public.start_hourly_round() to authenticated;
grant execute on function public.submit_guess(uuid, text) to authenticated;
grant execute on function public.get_user_stats() to authenticated;
grant execute on function public.get_hourly_leaderboard() to anon, authenticated;
grant execute on function public.get_leaderboard(text) to anon, authenticated;
grant execute on function public.get_player_history(integer) to authenticated;
grant execute on function public.get_hourly_summary() to anon, authenticated;
grant execute on function public.complete_hourly_round(uuid, boolean, integer) to authenticated;