-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathschema.sql
More file actions
2476 lines (2252 loc) · 108 KB
/
Copy pathschema.sql
File metadata and controls
2476 lines (2252 loc) · 108 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ====================================================================
-- Zoo Empire — Supabase Schema
-- Generiert vom Live-DB-Stand: 2026-04-22
-- Ersetzt alle vorherigen Versionen vollständig.
-- ====================================================================
-- =====================================================================
-- TABLES
-- =====================================================================
-- profiles (favorite_animal_id kommt nach animals per ALTER)
create table if not exists public.profiles (
id uuid primary key references auth.users on delete cascade,
username text unique not null check (char_length(username) between 3 and 24),
coins bigint not null default 100 check (coins >= 0),
last_collected_at timestamptz not null default now(),
created_at timestamptz not null default now(),
is_admin boolean not null default false,
equip_slots int not null default 1 check (equip_slots between 1 and 20),
taps_slot timestamptz not null default '1970-01-01 00:00:00+00',
taps_used int not null default 0,
avatar_emoji text,
tap_level int not null default 1,
tap_cap_level int not null default 1,
offline_level int not null default 1 check (offline_level between 1 and 13),
newbie_gift_claimed boolean not null default false,
is_banned boolean not null default false,
friend_requests_enabled boolean not null default true
);
alter table public.profiles enable row level security;
-- animals
create table if not exists public.animals (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id) on delete cascade,
species text not null,
level int not null default 1,
acquired_at timestamptz not null default now(),
equipped boolean not null default false,
tier text not null default 'normal',
upgrade_ready_at timestamptz
);
alter table public.animals enable row level security;
-- zirkuläre FK (profiles ↔ animals)
alter table public.profiles
add column if not exists favorite_animal_id uuid references public.animals(id) on delete set null;
-- transactions
create table if not exists public.transactions (
id bigserial primary key,
from_user uuid references public.profiles(id) on delete set null,
to_user uuid references public.profiles(id) on delete set null,
amount bigint not null check (amount > 0),
kind text not null check (kind in ('send', 'trade', 'public_trade')),
meta jsonb default '{}'::jsonb,
created_at timestamptz not null default now()
);
alter table public.transactions enable row level security;
-- trade_offers (altes Ein-Tier-System, weiterhin aktiv)
create table if not exists public.trade_offers (
id uuid primary key default gen_random_uuid(),
seller_id uuid not null references public.profiles(id) on delete cascade,
animal_id uuid not null references public.animals(id) on delete cascade,
species text not null,
price bigint not null check (price >= 0),
to_user uuid references public.profiles(id) on delete set null,
status text not null default 'open' check (status in ('open', 'sold', 'cancelled')),
created_at timestamptz not null default now(),
closed_at timestamptz,
wanted_species text
);
alter table public.trade_offers enable row level security;
-- species_costs
create table if not exists public.species_costs (
species text primary key,
cost bigint not null check (cost > 0),
enabled boolean not null default true,
weight int not null default 10 check (weight > 0),
name text,
emoji text,
rate numeric
);
alter table public.species_costs enable row level security;
-- shop_state (Singleton id=1)
create table if not exists public.shop_state (
id int primary key check (id = 1),
rotates_at timestamptz not null default (now() + interval '4 hours'),
updated_at timestamptz not null default now(),
random_stock jsonb not null default '{}',
forced_stock jsonb not null default '{}'
);
alter table public.shop_state enable row level security;
-- shop_purchases
create table if not exists public.shop_purchases (
user_id uuid not null references auth.users on delete cascade,
slot_start timestamptz not null,
species text not null,
qty int not null default 0,
primary key (user_id, slot_start, species)
);
alter table public.shop_purchases enable row level security;
-- friendships
create table if not exists public.friendships (
id uuid primary key default gen_random_uuid(),
requester_id uuid not null references public.profiles(id) on delete cascade,
addressee_id uuid not null references public.profiles(id) on delete cascade,
status text not null default 'pending' check (status in ('pending', 'accepted', 'declined')),
created_at timestamptz not null default now(),
responded_at timestamptz,
constraint friendship_pair_unique unique (requester_id, addressee_id)
);
alter table public.friendships enable row level security;
-- pets
create table if not exists public.pets (
owner_id uuid primary key references public.profiles(id) on delete cascade,
pet_type text not null default 'dog',
boost_multiplier numeric not null default 1,
boost_until timestamptz not null default '1970-01-01 00:00:00+00',
last_fed_at timestamptz
);
alter table public.pets enable row level security;
-- food_costs
create table if not exists public.food_costs (
food text primary key,
emoji text not null,
name text not null,
cost bigint not null,
multiplier numeric not null,
duration_min int not null
);
alter table public.food_costs enable row level security;
-- tier_defs
create table if not exists public.tier_defs (
tier text primary key,
multiplier numeric not null,
required_qty int not null,
upgrade_minutes int not null,
"order" int not null
);
alter table public.tier_defs enable row level security;
-- trades (neues Multi-Tier-Handelssystem)
create table if not exists public.trades (
id uuid primary key default gen_random_uuid(),
requester_id uuid not null references public.profiles(id) on delete cascade,
addressee_id uuid references public.profiles(id) on delete cascade,
requester_animals uuid[] not null default '{}',
addressee_animals uuid[] not null default '{}',
requester_coins bigint not null default 0 check (requester_coins >= 0),
addressee_coins bigint not null default 0 check (addressee_coins >= 0),
note text,
status text not null default 'pending' check (status in ('pending', 'accepted', 'declined', 'cancelled')),
created_at timestamptz not null default now(),
closed_at timestamptz,
is_public boolean not null default false,
wanted_species text,
wanted_tier text,
wanted_qty int not null default 0 check (wanted_qty >= 0),
wanted_animals jsonb not null default '[]'::jsonb,
expires_at timestamptz
);
alter table public.trades enable row level security;
-- trade_hides
create table if not exists public.trade_hides (
user_id uuid not null references auth.users on delete cascade,
trade_id uuid not null references public.trades(id) on delete cascade,
hidden_at timestamptz not null default now(),
primary key (user_id, trade_id)
);
alter table public.trade_hides enable row level security;
-- broadcasts
create table if not exists public.broadcasts (
id bigserial primary key,
message text not null,
created_by uuid references auth.users on delete set null,
created_at timestamptz not null default now()
);
alter table public.broadcasts enable row level security;
-- species_index
create table if not exists public.species_index (
user_id uuid not null references public.profiles(id) on delete cascade,
species text not null,
tier text not null default 'normal',
first_at timestamptz not null default now(),
count int not null default 1,
primary key (user_id, species, tier)
);
alter table public.species_index enable row level security;
-- pending_gifts
create table if not exists public.pending_gifts (
id uuid primary key default gen_random_uuid(),
recipient_id uuid not null references auth.users on delete cascade,
created_by uuid references auth.users on delete set null,
coins bigint not null default 0 check (coins >= 0),
species text references public.species_costs(species),
tier text,
qty int not null default 1 check (qty between 1 and 50),
note text,
created_at timestamptz not null default now(),
claimed_at timestamptz
);
alter table public.pending_gifts enable row level security;
-- chest_config (Singleton id=1)
create table if not exists public.chest_config (
id int primary key check (id = 1),
price bigint not null default 500000,
daily_limit int not null default 5
);
alter table public.chest_config enable row level security;
-- chest_purchases
create table if not exists public.chest_purchases (
user_id uuid not null references auth.users on delete cascade,
slot_start timestamptz not null,
count int not null default 0,
primary key (user_id, slot_start)
);
alter table public.chest_purchases enable row level security;
-- =====================================================================
-- INDEXES
-- =====================================================================
create index if not exists animals_owner_idx on public.animals(owner_id);
create index if not exists animals_equipped_idx on public.animals(owner_id, equipped);
create index if not exists tx_from_idx on public.transactions(from_user);
create index if not exists tx_to_idx on public.transactions(to_user);
create index if not exists offers_status_idx on public.trade_offers(status);
create index if not exists offers_seller_idx on public.trade_offers(seller_id);
create unique index if not exists profiles_username_lower_unique on public.profiles(lower(username));
create index if not exists idx_shop_purchases_slot on public.shop_purchases(slot_start);
create index if not exists friendships_req_idx on public.friendships(requester_id);
create index if not exists friendships_add_idx on public.friendships(addressee_id);
create index if not exists trades_requester_idx on public.trades(requester_id);
create index if not exists trades_addressee_idx on public.trades(addressee_id);
create index if not exists trades_status_idx on public.trades(status);
create index if not exists species_index_user_idx on public.species_index(user_id);
create index if not exists species_index_tier_idx on public.species_index(tier);
create index if not exists pending_gifts_recipient_idx on public.pending_gifts(recipient_id) where claimed_at is null;
create index if not exists profiles_favorite_animal_id_idx on public.profiles(favorite_animal_id);
create index if not exists trade_offers_animal_id_idx on public.trade_offers(animal_id);
create index if not exists trade_offers_to_user_idx on public.trade_offers(to_user);
create index if not exists broadcasts_created_by_idx on public.broadcasts(created_by);
create index if not exists trade_hides_trade_id_idx on public.trade_hides(trade_id);
create index if not exists pending_gifts_created_by_idx on public.pending_gifts(created_by);
create index if not exists pending_gifts_species_idx on public.pending_gifts(species);
-- =====================================================================
-- RLS POLICIES
-- =====================================================================
drop policy if exists "profiles public read" on public.profiles;
create policy "profiles public read" on public.profiles for select using (true);
drop policy if exists "animals public read" on public.animals;
create policy "animals public read" on public.animals for select using (true);
drop policy if exists "tx self read" on public.transactions;
create policy "tx self read" on public.transactions for select
using ((select auth.uid()) = from_user or (select auth.uid()) = to_user);
drop policy if exists "offers public read" on public.trade_offers;
create policy "offers public read" on public.trade_offers for select using (true);
drop policy if exists "species_costs public read" on public.species_costs;
create policy "species_costs public read" on public.species_costs for select using (true);
drop policy if exists "shop_state public read" on public.shop_state;
create policy "shop_state public read" on public.shop_state for select using (true);
drop policy if exists "shop_purchases_self_read" on public.shop_purchases;
create policy "shop_purchases_self_read" on public.shop_purchases for select
using (user_id = (select auth.uid()));
drop policy if exists "friends self read" on public.friendships;
create policy "friends self read" on public.friendships for select
using ((select auth.uid()) = requester_id or (select auth.uid()) = addressee_id);
drop policy if exists "pets self read" on public.pets;
create policy "pets self read" on public.pets for select using ((select auth.uid()) = owner_id);
drop policy if exists "food_costs public read" on public.food_costs;
create policy "food_costs public read" on public.food_costs for select using (true);
drop policy if exists "tier_defs_read" on public.tier_defs;
create policy "tier_defs_read" on public.tier_defs for select using (true);
drop policy if exists "trades participants read" on public.trades;
create policy "trades participants read" on public.trades for select using (
(select auth.uid()) = requester_id
or (select auth.uid()) = addressee_id
or (is_public = true and status = 'pending')
);
drop policy if exists "hides owner all" on public.trade_hides;
create policy "hides owner all" on public.trade_hides for all
using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
drop policy if exists "broadcasts_auth_read" on public.broadcasts;
create policy "broadcasts_auth_read" on public.broadcasts for select
using ((select auth.uid()) is not null);
drop policy if exists "idx self read" on public.species_index;
create policy "idx self read" on public.species_index for select using ((select auth.uid()) = user_id);
drop policy if exists "idx public read" on public.species_index;
create policy "idx public read" on public.species_index for select using ((select auth.uid()) is not null);
drop policy if exists "gifts recipient read" on public.pending_gifts;
create policy "gifts recipient read" on public.pending_gifts for select
using ((select auth.uid()) = recipient_id);
drop policy if exists "chest cfg read" on public.chest_config;
create policy "chest cfg read" on public.chest_config for select using (true);
drop policy if exists "chest own read" on public.chest_purchases;
create policy "chest own read" on public.chest_purchases for select using ((select auth.uid()) = user_id);
-- =====================================================================
-- VIEWS
-- =====================================================================
create or replace view public.animals_public as
select id, owner_id, species, tier, equipped from public.animals;
alter view public.animals_public set (security_invoker = on);
grant select on public.animals_public to authenticated;
drop view if exists public.friends_view;
create view public.friends_view as
select f.id as friendship_id, f.status, f.created_at, f.responded_at,
case when f.requester_id = auth.uid() then f.addressee_id else f.requester_id end as friend_id,
case when f.requester_id = auth.uid() then pa.username else pr.username end as friend_username,
case when f.requester_id = auth.uid() then pa.coins else pr.coins end as friend_coins,
case when f.requester_id = auth.uid() then pa.avatar_emoji else pr.avatar_emoji end as friend_avatar,
case when f.requester_id = auth.uid() then 'outgoing'::text else 'incoming'::text end as direction
from public.friendships f
join public.profiles pr on pr.id = f.requester_id
join public.profiles pa on pa.id = f.addressee_id
where f.requester_id = auth.uid() or f.addressee_id = auth.uid();
alter view public.friends_view set (security_invoker = on);
grant select on public.friends_view to authenticated;
create or replace view public.trade_offers_with_names as
select o.id, o.seller_id, o.animal_id, o.species, o.price, o.status,
o.created_at, o.closed_at, o.to_user, o.wanted_species,
ps.username as seller_username,
pt.username as to_username
from public.trade_offers o
join public.profiles ps on ps.id = o.seller_id
left join public.profiles pt on pt.id = o.to_user;
alter view public.trade_offers_with_names set (security_invoker = on);
grant select on public.trade_offers_with_names to authenticated;
create or replace view public.trades_view as
select t.id, t.requester_id, t.addressee_id, t.is_public,
t.requester_animals, t.addressee_animals,
t.requester_coins, t.addressee_coins,
t.note, t.status, t.created_at, t.closed_at, t.expires_at,
t.wanted_species, t.wanted_tier,
pr.username as requester_username,
pa.username as addressee_username,
(select coalesce(jsonb_agg(
jsonb_build_object('id', a.id, 'species', a.species, 'tier', a.tier)
order by a.acquired_at), '[]'::jsonb)
from public.animals a where a.id = any(t.requester_animals)) as requester_animal_details,
(select coalesce(jsonb_agg(
jsonb_build_object('id', a.id, 'species', a.species, 'tier', a.tier)
order by a.acquired_at), '[]'::jsonb)
from public.animals a where a.id = any(t.addressee_animals)) as addressee_animal_details,
t.wanted_qty,
t.wanted_animals
from public.trades t
join public.profiles pr on pr.id = t.requester_id
left join public.profiles pa on pa.id = t.addressee_id;
alter view public.trades_view set (security_invoker = on);
grant select on public.trades_view to authenticated;
-- =====================================================================
-- FUNCTIONS — private helpers
-- =====================================================================
create or replace function public._current_slot()
returns timestamptz language sql stable parallel safe set search_path = public as $$
select to_timestamp(floor(extract(epoch from now()) / 300) * 300);
$$;
create or replace function public._offline_hours(p_level int)
returns numeric language sql immutable set search_path = public as $$
select least(8, 2 + (greatest(coalesce(p_level, 1), 1) - 1) * 0.5)::numeric;
$$;
create or replace function public._next_offline_cost(p_level int)
returns bigint language sql immutable set search_path = public as $$
select floor(500 * power(2.5, greatest(coalesce(p_level, 1), 1) - 1))::bigint;
$$;
create or replace function public._slot_cost(p_slot int)
returns bigint language sql immutable set search_path = public as $$
select case
when p_slot <= 1 then 0
when p_slot = 2 then 2500
when p_slot = 3 then 15000
when p_slot = 4 then 80000
when p_slot = 5 then 400000
when p_slot = 6 then 2000000
when p_slot = 7 then 10000000
when p_slot = 8 then 50000000
when p_slot = 9 then 250000000
when p_slot = 10 then 1000000000
else null
end::bigint;
$$;
create or replace function public._stock_qty(state public.shop_state, p_species text)
returns int language sql immutable set search_path = public as $$
select coalesce((state.random_stock->>p_species)::int, 0)
+ coalesce((state.forced_stock->>p_species)::int, 0);
$$;
create or replace function public._rotate_if_needed()
returns public.shop_state language plpgsql security definer set search_path = public as $$
declare
slot timestamptz := public._current_slot();
next_slot timestamptz := slot + interval '5 minutes';
state public.shop_state;
new_rand jsonb;
begin
select * into state from public.shop_state where id = 1 for update;
if state.updated_at < slot then
select coalesce(jsonb_object_agg(species, 1), '{}'::jsonb) into new_rand
from (
select sc.species
from public.species_costs sc
where sc.enabled
order by power(
greatest(
(abs(('x' || substr(md5(sc.species || extract(epoch from slot)::text), 1, 8))::bit(32)::int) % 1000000 + 1) / 1000001.0,
1e-9
),
1.0 / sc.weight
) desc
limit 5
) s;
update public.shop_state
set random_stock = new_rand,
rotates_at = next_slot,
updated_at = slot
where id = 1
returning * into state;
end if;
return state;
end $$;
create or replace function public._touch_species_index()
returns trigger language plpgsql security definer set search_path = public as $$
begin
if tg_op = 'INSERT' then
insert into public.species_index(user_id, species, tier, count)
values (new.owner_id, new.species, coalesce(new.tier, 'normal'), 1)
on conflict (user_id, species, tier) do update set count = public.species_index.count + 1;
return new;
elsif tg_op = 'UPDATE' then
if coalesce(new.tier, 'normal') is distinct from coalesce(old.tier, 'normal')
or new.owner_id is distinct from old.owner_id then
insert into public.species_index(user_id, species, tier, count)
values (new.owner_id, new.species, coalesce(new.tier, 'normal'), 1)
on conflict (user_id, species, tier) do update set count = public.species_index.count + 1;
end if;
return new;
end if;
return new;
end $$;
-- =====================================================================
-- FUNCTIONS — public RPCs
-- =====================================================================
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
u text;
begin
u := coalesce(new.raw_user_meta_data->>'username', split_part(new.email, '@', 1));
if exists (select 1 from public.profiles where username = u) then
u := u || substr(replace(new.id::text, '-', ''), 1, 4);
end if;
insert into public.profiles (id, username) values (new.id, u);
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 procedure public.handle_new_user();
-- tap
create or replace function public.get_tap_status(p_max int default 10)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
slot timestamptz := public._current_slot();
next_slot timestamptz := slot + interval '5 minutes';
used int; cap_lvl int; max_taps int;
b_mul numeric; b_until timestamptz;
begin
if uid is null then raise exception 'not authenticated'; end if;
update public.profiles set taps_slot = slot, taps_used = 0
where id = uid and taps_slot < slot;
select taps_used, tap_cap_level into used, cap_lvl from public.profiles where id = uid;
used := coalesce(used, 0);
cap_lvl := coalesce(cap_lvl, 1);
max_taps := greatest(coalesce(p_max, 10), 10 + (cap_lvl - 1) * 5);
select boost_multiplier, boost_until into b_mul, b_until from public.pets where owner_id = uid;
return jsonb_build_object(
'taps_used', used, 'taps_max', max_taps,
'next_reset', next_slot, 'server_now', now(),
'boost_multiplier', coalesce(b_mul, 1),
'boost_until', b_until
);
end $$;
grant execute on function public.get_tap_status(int) to authenticated;
create or replace function public.tap_earn(p_max int default 10)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
slot timestamptz := public._current_slot();
next_slot timestamptz := slot + interval '5 minutes';
base_rate numeric := 0; fav_rate numeric := 0;
fav_id uuid; boost numeric := 1;
rate numeric; tap_mul numeric; tap_lvl int; cap_lvl int; max_taps int;
earn bigint; new_used int; new_coins bigint;
begin
if uid is null then raise exception 'not authenticated'; end if;
update public.profiles set taps_slot = slot, taps_used = 0
where id = uid and taps_slot < slot;
select favorite_animal_id, tap_level, tap_cap_level
into fav_id, tap_lvl, cap_lvl
from public.profiles where id = uid;
tap_lvl := coalesce(tap_lvl, 1);
cap_lvl := coalesce(cap_lvl, 1);
tap_mul := 1 + (tap_lvl - 1) * 0.25;
max_taps := greatest(coalesce(p_max, 10), 10 + (cap_lvl - 1) * 5);
select coalesce(sum(sc.cost / 50.0 * coalesce(td.multiplier, 1)), 0) into base_rate
from public.animals a
join public.species_costs sc on sc.species = a.species
left join public.tier_defs td on td.tier = a.tier
where a.owner_id = uid and a.equipped = true
and (a.upgrade_ready_at is null or a.upgrade_ready_at <= now())
and a.id <> coalesce(fav_id, '00000000-0000-0000-0000-000000000000'::uuid);
if fav_id is not null then
select coalesce(sc.cost / 50.0 * coalesce(td.multiplier, 1), 0) into fav_rate
from public.animals a
join public.species_costs sc on sc.species = a.species
left join public.tier_defs td on td.tier = a.tier
where a.id = fav_id and a.owner_id = uid and a.equipped = true
and (a.upgrade_ready_at is null or a.upgrade_ready_at <= now());
fav_rate := coalesce(fav_rate, 0);
end if;
select case when boost_until > now() then boost_multiplier else 1 end into boost
from public.pets where owner_id = uid;
boost := coalesce(boost, 1);
rate := (base_rate + fav_rate * boost) * tap_mul;
earn := greatest(1, floor(rate)::bigint);
update public.profiles
set taps_used = taps_used + 1, coins = coins + earn
where id = uid and taps_used < max_taps
returning taps_used, coins into new_used, new_coins;
if new_used is null then raise exception 'tap limit reached'; end if;
return jsonb_build_object(
'coins', new_coins, 'earned', earn,
'taps_used', new_used, 'taps_max', max_taps,
'next_reset', next_slot, 'server_now', now()
);
end $$;
grant execute on function public.tap_earn(int) to authenticated;
drop function if exists public.upgrade_tap();
create or replace function public.upgrade_tap(p_kind text default 'mul')
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
cur_mul int; cur_cap int;
cost bigint; new_coins bigint; new_level int;
begin
if uid is null then raise exception 'not authenticated'; end if;
if p_kind not in ('mul', 'cap') then raise exception 'invalid upgrade kind'; end if;
select tap_level, tap_cap_level into cur_mul, cur_cap from public.profiles where id = uid;
cur_mul := coalesce(cur_mul, 1);
cur_cap := coalesce(cur_cap, 1);
if p_kind = 'mul' then
if cur_mul >= 25 then raise exception 'tap max level reached'; end if;
cost := (100 * power(3, cur_mul - 1))::bigint;
update public.profiles
set coins = coins - cost, tap_level = cur_mul + 1
where id = uid and coins >= cost
returning coins, tap_level into new_coins, new_level;
if new_coins is null then raise exception 'insufficient coins'; end if;
return jsonb_build_object(
'coins', new_coins, 'kind', 'mul',
'tap_level', new_level, 'tap_cap_level', cur_cap,
'next_cost', (100 * power(3, new_level - 1))::bigint
);
else
if cur_cap >= 20 then raise exception 'tap cap max level reached'; end if;
cost := (150 * power(3, cur_cap - 1))::bigint;
update public.profiles
set coins = coins - cost, tap_cap_level = cur_cap + 1
where id = uid and coins >= cost
returning coins, tap_cap_level into new_coins, new_level;
if new_coins is null then raise exception 'insufficient coins'; end if;
return jsonb_build_object(
'coins', new_coins, 'kind', 'cap',
'tap_level', cur_mul, 'tap_cap_level', new_level,
'next_cost', (150 * power(3, new_level - 1))::bigint,
'taps_max', 10 + (new_level - 1) * 5
);
end if;
end $$;
grant execute on function public.upgrade_tap(text) to authenticated;
-- offline
create or replace function public.collect_offline(p_coins bigint)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
elapsed_sec float; max_rate bigint; max_earn bigint;
cap_sec float; lvl int; new_balance bigint;
begin
if uid is null then raise exception 'not authenticated'; end if;
if p_coins <= 0 then
return jsonb_build_object('coins', (select coins from public.profiles where id = uid));
end if;
select offline_level into lvl from public.profiles where id = uid;
cap_sec := public._offline_hours(lvl) * 3600;
select extract(epoch from (now() - last_collected_at)) into elapsed_sec
from public.profiles where id = uid;
elapsed_sec := least(elapsed_sec, cap_sec);
select coalesce(sum(sc.cost / 50), 0) into max_rate
from public.animals a
join public.species_costs sc on sc.species = a.species
where a.owner_id = uid and a.equipped = true;
max_earn := ceil(max_rate * elapsed_sec);
p_coins := least(p_coins, (max_earn * 1.2)::bigint + 1);
update public.profiles
set coins = coins + p_coins, last_collected_at = now()
where id = uid
returning coins into new_balance;
return jsonb_build_object('coins', new_balance);
end $$;
grant execute on function public.collect_offline(bigint) to authenticated;
create or replace function public.upgrade_offline()
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
cur int; cost bigint; new_balance bigint;
begin
if uid is null then raise exception 'not authenticated'; end if;
select offline_level into cur from public.profiles where id = uid for update;
if cur is null then cur := 1; end if;
if public._offline_hours(cur) >= 8 then raise exception 'offline max level erreicht'; end if;
cost := public._next_offline_cost(cur);
update public.profiles
set coins = coins - cost, offline_level = cur + 1
where id = uid and coins >= cost
returning coins into new_balance;
if new_balance is null then raise exception 'nicht genug Muenzen'; end if;
return jsonb_build_object(
'coins', new_balance, 'offline_level', cur + 1,
'max_offline_hours', public._offline_hours(cur + 1),
'next_cost', case when public._offline_hours(cur + 1) >= 8 then null
else public._next_offline_cost(cur + 1) end
);
end $$;
grant execute on function public.upgrade_offline() to authenticated;
-- shop
create or replace function public.get_shop()
returns jsonb language plpgsql security definer set search_path = public as $$
declare
state public.shop_state;
uid uuid := auth.uid();
merged jsonb; mine jsonb;
begin
state := public._rotate_if_needed();
with combined as (
select key as species, sum(value::int) as qty
from (
select key, value from jsonb_each_text(state.random_stock)
union all
select key, value from jsonb_each_text(state.forced_stock)
) t
group by key having sum(value::int) > 0
)
select coalesce(jsonb_object_agg(species, qty), '{}') into merged from combined;
if uid is not null then
select coalesce(jsonb_object_agg(species, qty), '{}') into mine
from public.shop_purchases
where user_id = uid and slot_start = state.updated_at;
else
mine := '{}';
end if;
return jsonb_build_object(
'stock', merged,
'forced_stock', state.forced_stock,
'my_purchases', coalesce(mine, '{}'),
'slot_start', state.updated_at,
'rotates_at', state.rotates_at,
'server_now', now()
);
end $$;
grant execute on function public.get_shop() to anon, authenticated;
create or replace function public.buy_animal(p_species text, p_cost bigint)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
real_cost bigint; new_animal public.animals%rowtype;
new_balance bigint; state public.shop_state;
rand_qty int; force_qty int; mine_qty int; catalog_qty int;
slots int; equipped_cnt int; auto_equip boolean; current_fav uuid;
begin
if uid is null then raise exception 'not authenticated'; end if;
select cost into real_cost from public.species_costs where species = p_species;
if real_cost is null then raise exception 'unknown species'; end if;
state := public._rotate_if_needed();
rand_qty := coalesce((state.random_stock->>p_species)::int, 0);
force_qty := coalesce((state.forced_stock->>p_species)::int, 0);
catalog_qty := rand_qty + force_qty;
if catalog_qty <= 0 then raise exception 'species not available'; end if;
select qty into mine_qty from public.shop_purchases
where user_id = uid and slot_start = state.updated_at and species = p_species;
mine_qty := coalesce(mine_qty, 0);
if mine_qty >= catalog_qty then raise exception 'already bought your share this slot'; end if;
update public.profiles set coins = coins - real_cost
where id = uid and coins >= real_cost returning coins into new_balance;
if new_balance is null then raise exception 'insufficient coins'; end if;
insert into public.shop_purchases(user_id, slot_start, species, qty)
values (uid, state.updated_at, p_species, 1)
on conflict (user_id, slot_start, species) do update set qty = public.shop_purchases.qty + 1;
select equip_slots, favorite_animal_id into slots, current_fav from public.profiles where id = uid;
select count(*) into equipped_cnt from public.animals where owner_id = uid and equipped = true;
auto_equip := equipped_cnt < slots;
insert into public.animals(owner_id, species, equipped) values (uid, p_species, auto_equip)
returning * into new_animal;
if current_fav is null then
update public.profiles set favorite_animal_id = new_animal.id where id = uid;
end if;
return jsonb_build_object('coins', new_balance, 'animal', to_jsonb(new_animal));
end $$;
grant execute on function public.buy_animal(text, bigint) to authenticated;
-- chest
create or replace function public.get_chest_status()
returns jsonb language plpgsql security definer set search_path = public as $$
declare
cfg public.chest_config; state public.shop_state; bought int;
begin
select * into cfg from public.chest_config where id = 1;
state := public._rotate_if_needed();
select count into bought from public.chest_purchases
where user_id = auth.uid() and slot_start = state.updated_at;
return jsonb_build_object(
'price', cfg.price, 'slot_limit', cfg.daily_limit,
'bought_slot', coalesce(bought, 0), 'slot_start', state.updated_at
);
end $$;
grant execute on function public.get_chest_status() to authenticated;
create or replace function public.buy_chest(p_qty int default 1)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
cfg public.chest_config; state public.shop_state;
bought_slot int; total_cost bigint; balance bigint;
w_total int; r int; acc int; rec record;
picked_species text; new_ids uuid[] := '{}'; new_species text[] := '{}';
i int; new_animal public.animals%rowtype;
begin
if uid is null then raise exception 'not authenticated'; end if;
if p_qty is null or p_qty < 1 or p_qty > 5 then raise exception 'qty must be 1, 2 or 5'; end if;
select * into cfg from public.chest_config where id = 1;
if cfg is null then raise exception 'chest config missing'; end if;
state := public._rotate_if_needed();
select count into bought_slot from public.chest_purchases
where user_id = uid and slot_start = state.updated_at for update;
bought_slot := coalesce(bought_slot, 0);
if bought_slot + p_qty > cfg.daily_limit then
raise exception 'chest limit reached this rotation (% / %)', bought_slot, cfg.daily_limit;
end if;
total_cost := cfg.price * p_qty;
update public.profiles set coins = coins - total_cost
where id = uid and coins >= total_cost returning coins into balance;
if balance is null then raise exception 'insufficient coins'; end if;
insert into public.chest_purchases(user_id, slot_start, count) values (uid, state.updated_at, p_qty)
on conflict (user_id, slot_start) do update set count = public.chest_purchases.count + p_qty;
select coalesce(sum(weight), 0) into w_total from public.species_costs where enabled and weight > 0;
if w_total <= 0 then raise exception 'no species available'; end if;
for i in 1..p_qty loop
r := 1 + floor(random() * w_total)::int;
acc := 0; picked_species := null;
for rec in select species, weight from public.species_costs where enabled and weight > 0 order by species loop
acc := acc + rec.weight;
if r <= acc then picked_species := rec.species; exit; end if;
end loop;
if picked_species is null then
select species into picked_species from public.species_costs where enabled and weight > 0 order by species limit 1;
end if;
insert into public.animals(owner_id, species) values (uid, picked_species) returning * into new_animal;
new_ids := new_ids || new_animal.id;
new_species := new_species || picked_species;
end loop;
return jsonb_build_object(
'coins', balance, 'qty', p_qty,
'species', to_jsonb(new_species), 'animal_ids', to_jsonb(new_ids),
'bought_slot', bought_slot + p_qty, 'slot_limit', cfg.daily_limit,
'price', cfg.price, 'slot_start', state.updated_at
);
end $$;
grant execute on function public.buy_chest(int) to authenticated;
-- equip slots
create or replace function public.buy_equip_slot()
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid(); slots int; cost bigint; new_balance bigint;
begin
if uid is null then raise exception 'not authenticated'; end if;
select equip_slots into slots from public.profiles where id = uid for update;
cost := public._slot_cost(slots + 1);
if cost is null then raise exception 'max slots reached'; end if;
update public.profiles set coins = coins - cost, equip_slots = equip_slots + 1
where id = uid and coins >= cost returning coins, equip_slots into new_balance, slots;
if new_balance is null then raise exception 'insufficient coins'; end if;
return jsonb_build_object('coins', new_balance, 'equip_slots', slots, 'cost', cost);
end $$;
grant execute on function public.buy_equip_slot() to authenticated;
create or replace function public.get_next_slot_cost()
returns jsonb language sql security definer set search_path = public as $$
select jsonb_build_object(
'current_slots', (select equip_slots from public.profiles where id = auth.uid()),
'next_slot', (select equip_slots + 1 from public.profiles where id = auth.uid()),
'next_cost', public._slot_cost((select equip_slots + 1 from public.profiles where id = auth.uid()))
);
$$;
grant execute on function public.get_next_slot_cost() to authenticated;
-- animals
create or replace function public.equip_animal(p_animal_id uuid)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid(); slots int; equipped_cnt int; animal public.animals%rowtype;
begin
if uid is null then raise exception 'not authenticated'; end if;
select * into animal from public.animals where id = p_animal_id and owner_id = uid;
if not found then raise exception 'animal not found'; end if;
if animal.equipped then return jsonb_build_object('ok', true); end if;
select equip_slots into slots from public.profiles where id = uid;
select count(*) into equipped_cnt from public.animals where owner_id = uid and equipped = true;
if equipped_cnt >= slots then raise exception 'no free equip slots'; end if;
update public.animals set equipped = true where id = p_animal_id;
return jsonb_build_object('ok', true);
end $$;
grant execute on function public.equip_animal(uuid) to authenticated;
create or replace function public.unequip_animal(p_animal_id uuid)
returns jsonb language plpgsql security definer set search_path = public as $$
declare uid uuid := auth.uid();
begin
if uid is null then raise exception 'not authenticated'; end if;
update public.animals set equipped = false where id = p_animal_id and owner_id = uid;
if not found then raise exception 'animal not found'; end if;
return jsonb_build_object('ok', true);
end $$;
grant execute on function public.unequip_animal(uuid) to authenticated;
create or replace function public.set_favorite_animal(p_animal_id uuid)
returns jsonb language plpgsql security definer set search_path = public as $$
declare uid uuid := auth.uid(); ok boolean;
begin
if uid is null then raise exception 'not authenticated'; end if;
if p_animal_id is null then
update public.profiles set favorite_animal_id = null where id = uid;
return jsonb_build_object('favorite_animal_id', null);
end if;
select exists(select 1 from public.animals where id = p_animal_id and owner_id = uid) into ok;
if not ok then raise exception 'not your animal'; end if;
update public.profiles set favorite_animal_id = p_animal_id where id = uid;
return jsonb_build_object('favorite_animal_id', p_animal_id);
end $$;
grant execute on function public.set_favorite_animal(uuid) to authenticated;
create or replace function public.start_tier_upgrade(p_animal_ids uuid[], p_target_tier text)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid(); td public.tier_defs%rowtype;
species_key text; cnt int; new_id uuid; ready timestamptz;
begin
if uid is null then raise exception 'not authenticated'; end if;
select * into td from public.tier_defs where tier = p_target_tier;
if not found or td.required_qty <= 0 then raise exception 'invalid target tier'; end if;
p_animal_ids := coalesce(p_animal_ids, '{}');
cnt := cardinality(p_animal_ids);
if cnt <> td.required_qty then raise exception 'wrong number of animals (need %)', td.required_qty; end if;
select count(distinct species) into cnt from public.animals
where id = any(p_animal_ids) and owner_id = uid
and equipped = false and tier = 'normal'
and (upgrade_ready_at is null or upgrade_ready_at <= now());
if cnt <> 1 then raise exception 'animals must be yours, unequipped, normal tier and same species'; end if;
select species into species_key from public.animals where id = p_animal_ids[1];
delete from public.animals where id = any(p_animal_ids) and owner_id = uid;
ready := now() + make_interval(mins => td.upgrade_minutes);
insert into public.animals(owner_id, species, equipped, tier, upgrade_ready_at)
values (uid, species_key, false, p_target_tier, ready) returning id into new_id;
return jsonb_build_object('id', new_id, 'ready_at', ready, 'tier', p_target_tier);
end $$;
grant execute on function public.start_tier_upgrade(uuid[], text) to authenticated;
create or replace function public.start_tier_downgrade(p_animal_id uuid)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid();
a public.animals%rowtype;
td public.tier_defs%rowtype;
species_key text;
ready timestamptz;
i int;
new_id uuid;
new_ids uuid[] := '{}';
begin
if uid is null then raise exception 'not authenticated'; end if;
select * into a from public.animals where id = p_animal_id and owner_id = uid for update;
if not found then raise exception 'not your animal'; end if;
if a.equipped then raise exception 'animal is equipped'; end if;
if coalesce(a.tier, 'normal') = 'normal' then raise exception 'only higher tiers can be split'; end if;
if a.upgrade_ready_at is not null and a.upgrade_ready_at > now() then
raise exception 'animal is currently upgrading';
end if;
select * into td from public.tier_defs where tier = a.tier;
if not found or td.required_qty <= 0 then raise exception 'invalid tier'; end if;
species_key := a.species;
delete from public.animals where id = p_animal_id and owner_id = uid;
ready := now() + make_interval(mins => 1);
for i in 1..td.required_qty loop
insert into public.animals(owner_id, species, equipped, tier, upgrade_ready_at)
values (uid, species_key, false, 'normal', ready)
returning id into new_id;
new_ids := array_append(new_ids, new_id);
end loop;
return jsonb_build_object('ids', new_ids, 'ready_at', ready, 'count', td.required_qty);
end $$;
grant execute on function public.start_tier_downgrade(uuid) to authenticated;
-- pets / food
create or replace function public.feed_pet(p_food text)
returns jsonb language plpgsql security definer set search_path = public as $$
declare
uid uuid := auth.uid(); f public.food_costs%rowtype;
new_coins bigint; cur_until timestamptz; cur_mult numeric;
new_until timestamptz; new_mult numeric;
begin
if uid is null then raise exception 'not authenticated'; end if;
select * into f from public.food_costs where food = p_food;
if not found then raise exception 'unknown food'; end if;
update public.profiles set coins = coins - f.cost
where id = uid and coins >= f.cost returning coins into new_coins;