Commit 41180847 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test: a production replica, a PostgREST-shaped shim, and the hosting migrations in the suite

tests/schema.sql is now generated from read-only dumps of production: the 35
tables the tournament system touches, with their checks, keys, indexes, RLS,
policies and grants, and the production functions the hosting packages build
on (tournament_register, _withdraw, _refund_entry, _refund_due, _settle,
_redeem_tournament, redeem_fold, complete_match, award_coins). It used to be a
hand-written subset without any of those functions.

tests/run.sh loads it strictly, applies every 2026-10-27-hosting-*.sql (the
schema file first), proves a second run leaves the schema identical and that
the hosting tables stay locked to the service role, then runs every
tests/test_hosting_*.php after the existing suites.

PgShim now behaves like PostgREST where the engine relies on it: rpc() runs the
real SQL function with named arguments and the client's return shapes, upsert()
exists, jsonb, arrays and numerics come back typed, and a PHP list written to
an array column becomes a Postgres array. The test router's supabaseRpc() goes
through it.

The suite was red before this (3 passed, 2 failed): registration and
settlement are SQL functions now, and the shim answered every rpc with []. The
end-to-end test also crashed at section 9, so sections 9 to 21 never ran; its
heartbeat checks still looked in game_state, while heartbeats moved to
match_presence. Now: 8 passed, 0 failed.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 873278e4
......@@ -81,18 +81,100 @@ class PgShim extends SupabaseClient {
return $clauses ? ('WHERE ' . implode(' AND ', $clauses)) : '';
}
private function decodeRow(array $row): array {
/**
* Mirror PostgREST's JSON: jsonb and json come back decoded, Postgres arrays
* as lists, numeric as a number. The column's real type decides (PDO's
* native_type); the named jsonb columns are decoded as before when no type
* is known.
*/
private function decodeRow(array $row, array $types = []): array {
foreach ($row as $k => $v) {
// Mirror PostgREST: jsonb comes back decoded, not as a string.
if (is_string($v) && in_array($k, ['pairings', 'results', 'moves', 'metadata', 'game_state', 'tiebreak_rules', 'payload'], true)) {
if (!is_string($v)) continue;
$t = $types[$k] ?? null;
if ($t === 'json' || $t === 'jsonb'
|| ($t === null && in_array($k, ['pairings', 'results', 'moves', 'metadata', 'game_state', 'tiebreak_rules', 'payload'], true))) {
$d = json_decode($v, true);
$row[$k] = $d === null ? $v : $d;
$row[$k] = ($d === null && $v !== 'null') ? $v : $d;
} elseif ($t === 'numeric') {
$row[$k] = $v + 0;
} elseif ($t !== null && str_starts_with($t, '_')) {
$row[$k] = self::parsePgArray($v, substr($t, 1));
}
}
return $row;
}
private function encodeValue($v) {
/** Column name => Postgres type name (pdo_pgsql native_type), per result column. */
private static function columnTypes(PDOStatement $st): array {
$types = [];
for ($i = 0, $n = $st->columnCount(); $i < $n; $i++) {
$m = $st->getColumnMeta($i);
if ($m && isset($m['name'])) $types[$m['name']] = $m['native_type'] ?? null;
}
return $types;
}
private function fetchRows(PDOStatement $st): array {
$types = self::columnTypes($st);
return array_map(fn($r) => $this->decodeRow($r, $types), $st->fetchAll(PDO::FETCH_ASSOC));
}
/** A one-dimensional Postgres array literal, '{a,"b c",NULL}', as a PHP list. */
private static function parsePgArray(string $lit, string $elemType): array {
$inner = substr(trim($lit), 1, -1);
if ($inner === '') return [];
$out = [];
$len = strlen($inner);
for ($i = 0; $i < $len;) {
if ($inner[$i] === '"') {
$buf = '';
for ($i++; $i < $len && $inner[$i] !== '"'; $i++) {
if ($inner[$i] === '\\') $i++;
$buf .= $inner[$i];
}
$i += 2; // closing quote and comma
$out[] = $buf;
} else {
$j = strpos($inner, ',', $i);
$tok = $j === false ? substr($inner, $i) : substr($inner, $i, $j - $i);
$i = $j === false ? $len : $j + 1;
$out[] = $tok === 'NULL' ? null : $tok;
}
}
return array_map(function ($x) use ($elemType) {
if ($x === null) return null;
if (in_array($elemType, ['int2', 'int4', 'int8'], true)) return (int)$x;
if ($elemType === 'numeric' || $elemType === 'float4' || $elemType === 'float8') return $x + 0;
if ($elemType === 'bool') return $x === 't' || $x === 'true';
return $x;
}, $out);
}
/** Columns of a Postgres array type in a table (uuid[], int[], text[]...). */
private array $arrayCols = [];
private function arrayColumns(string $table): array {
if (!isset($this->arrayCols[$table])) {
$st = $this->pdo->prepare("SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ? AND data_type = 'ARRAY'");
$st->execute([$table]);
$this->arrayCols[$table] = array_flip($st->fetchAll(PDO::FETCH_COLUMN));
}
return $this->arrayCols[$table];
}
/** A PHP list as a Postgres array literal: PostgREST takes a JSON list for an array column. */
private static function pgArrayLiteral(array $list): string {
return '{' . implode(',', array_map(function ($x) {
if ($x === null) return 'NULL';
if (is_bool($x)) return $x ? 'true' : 'false';
return '"' . addcslashes((string)$x, '"\\') . '"';
}, $list)) . '}';
}
private function encodeValue($v, ?string $table = null, ?string $col = null) {
if (is_array($v) && $table !== null && array_is_list($v) && isset($this->arrayColumns($table)[$col])) {
return self::pgArrayLiteral($v);
}
if (is_array($v) || is_object($v)) return json_encode($v);
if (is_bool($v)) return $v ? 'true' : 'false';
return $v;
......@@ -113,7 +195,7 @@ class PgShim extends SupabaseClient {
try {
$st = $this->pdo->prepare($sql);
$st->execute($vals);
$rows = array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
$rows = $this->fetchRows($st);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
......@@ -138,8 +220,8 @@ class PgShim extends SupabaseClient {
. ' VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ') RETURNING *';
try {
$st = $this->pdo->prepare($sql);
$st->execute(array_map([$this, 'encodeValue'], array_values($data)));
return array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
$st->execute(array_map(fn($c) => $this->encodeValue($data[$c], $table, $c), $cols));
return $this->fetchRows($st);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 409];
}
......@@ -148,30 +230,136 @@ class PgShim extends SupabaseClient {
public function update(string $table, array $data, array $params = []): array {
$vals = [];
$sets = [];
foreach ($data as $c => $v) { $sets[] = "\"$c\" = ?"; $vals[] = $this->encodeValue($v); }
foreach ($data as $c => $v) { $sets[] = "\"$c\" = ?"; $vals[] = $this->encodeValue($v, $table, $c); }
$whereVals = [];
$where = $this->where($params, $whereVals);
$sql = "UPDATE \"$table\" SET " . implode(',', $sets) . " $where RETURNING *";
try {
$st = $this->pdo->prepare($sql);
$st->execute(array_merge($vals, $whereVals));
return array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
return $this->fetchRows($st);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
}
/**
* PostgREST's upsert (Prefer: resolution=merge-duplicates): insert one row
* or a list of rows; on a conflict over $onConflict (default: the primary
* key) update the columns sent. Returns the rows written.
*/
public function upsert(string $table, array $data, string $onConflict = ''): array {
$rows = (array_is_list($data) && $data && is_array($data[0])) ? $data : [$data];
if (!$rows || !$rows[0]) return [];
$cols = array_keys($rows[0]);
if ($onConflict !== '') {
$target = array_map('trim', explode(',', $onConflict));
} else {
$st = $this->pdo->prepare("SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
WHERE i.indrelid = ('public.' || quote_ident(?))::regclass AND i.indisprimary");
$st->execute([$table]);
$target = $st->fetchAll(PDO::FETCH_COLUMN);
}
$quote = fn($c) => '"' . str_replace('"', '""', $c) . '"';
$vals = [];
$tuples = [];
foreach ($rows as $r) {
$tuples[] = '(' . implode(',', array_fill(0, count($cols), '?')) . ')';
foreach ($cols as $c) $vals[] = $this->encodeValue($r[$c] ?? null, $table, $c);
}
$updates = array_values(array_diff($cols, $target));
$sql = "INSERT INTO \"$table\" (" . implode(',', array_map($quote, $cols)) . ') VALUES ' . implode(',', $tuples)
. ' ON CONFLICT (' . implode(',', array_map($quote, $target)) . ') '
. ($updates ? 'DO UPDATE SET ' . implode(',', array_map(fn($c) => $quote($c) . ' = EXCLUDED.' . $quote($c), $updates)) : 'DO NOTHING')
. ' RETURNING *';
try {
$st = $this->pdo->prepare($sql);
$st->execute($vals);
return $this->fetchRows($st);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 409];
}
}
public function delete(string $table, array $params = []): array {
$vals = [];
$where = $this->where($params, $vals);
try {
$st = $this->pdo->prepare("DELETE FROM \"$table\" $where RETURNING *");
$st->execute($vals);
return $st->fetchAll(PDO::FETCH_ASSOC);
return $this->fetchRows($st);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
}
public function rpc(string $fn, array $data = []): array { return []; }
/**
* Call a SQL function the way PostgREST's /rpc/<fn> does: named arguments,
* the overload whose parameter names fit, and the client's return shapes
* (config/database.php): a json/jsonb result decoded, a scalar as
* ['result' => v], a set as a list, void as [], a failure as
* ['error' => message, 'code' => http status]. A function this replica
* does not have answers [] as it always did, so tests that never needed one
* are unaffected.
*/
public function rpc(string $fn, array $data = []): array {
$st = $this->pdo->prepare("SELECT p.proname, p.proretset, p.pronargs, p.pronargdefaults,
format_type(p.prorettype, NULL) AS rettype,
coalesce(array_to_json(p.proargnames), '[]') AS names,
coalesce(array_to_json(p.proargmodes), 'null') AS modes,
coalesce(array_to_json(ARRAY(SELECT format_type(t, NULL) FROM unnest(coalesce(p.proallargtypes, p.proargtypes::oid[])) WITH ORDINALITY u(t, o) ORDER BY o)), '[]') AS types
FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace AND p.proname = ?");
$st->execute([$fn]);
$candidates = $st->fetchAll(PDO::FETCH_ASSOC);
if (!$candidates) return [];
$given = array_keys($data);
$pick = null;
foreach ($candidates as $c) {
$names = json_decode($c['names'], true) ?: [];
$modes = json_decode($c['modes'], true);
$types = json_decode($c['types'], true) ?: [];
$in = [];
foreach ($types as $i => $t) {
$mode = $modes[$i] ?? 'i';
if (in_array($mode, ['i', 'b', 'v'], true)) $in[] = ['name' => $names[$i] ?? null, 'type' => $t];
}
$required = array_slice($in, 0, count($in) - (int)$c['pronargdefaults']);
$inNames = array_column($in, 'name');
if (array_diff($given, $inNames)) continue;
if (array_diff(array_column($required, 'name'), $given)) continue;
$pick = $c + ['in' => $in];
break;
}
if (!$pick) return ['error' => "Could not find the function public.$fn with the given arguments", 'code' => 404];
$args = []; $vals = [];
foreach ($pick['in'] as $a) {
if (!array_key_exists($a['name'], $data)) continue;
$v = $data[$a['name']];
$args[] = '"' . $a['name'] . '" => CAST(? AS ' . $a['type'] . ')';
if ($v === null) $vals[] = null;
elseif (is_bool($v)) $vals[] = $v ? 'true' : 'false';
elseif (is_array($v) && str_ends_with($a['type'], '[]') && array_is_list($v)) $vals[] = self::pgArrayLiteral($v);
elseif (is_array($v) || is_object($v)) $vals[] = json_encode($v);
else $vals[] = (string)$v;
}
$call = '"' . $fn . '"(' . implode(', ', $args) . ')';
$sql = $pick['proretset']
? "SELECT coalesce(json_agg(t), '[]'::json)::text FROM $call AS t"
: ($pick['rettype'] === 'void' ? "SELECT $call::text" : "SELECT to_json($call)::text");
try {
$q = $this->pdo->prepare($sql);
$q->execute($vals);
$out = $q->fetchColumn();
} catch (PDOException $e) {
$state = $e->errorInfo[0] ?? '';
return ['error' => $e->errorInfo[2] ?? $e->getMessage(), 'code' => in_array($state, ['23505', '23503'], true) ? 409 : 400];
}
if ($pick['rettype'] === 'void' || $out === null || $out === false) return [];
$decoded = json_decode($out, true);
if ($decoded !== null && !is_array($decoded)) return ['result' => $decoded];
return $decoded ?? [];
}
}
......@@ -35,7 +35,10 @@ function supabaseRpc(string $fn, array $params = []): ?array {
$__pg->update($params['p_table'], ['game_state' => $gs], ['id' => 'eq.' . $params['p_match_id']]);
return ['ok' => true];
}
return [];
// Anything else runs the real function when schema.sql carries it
// (complete_match, the tournament functions); otherwise [] as before.
global $__pg;
return $__pg->rpc($fn, $params);
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
......
......@@ -5,7 +5,11 @@
# ./tests/run.sh
#
# Everything runs against a throwaway local Postgres carrying a replica of the
# production types and tables. No production data is read or written.
# production types and tables (schema.sql), with every
# migrations/2026-10-27-hosting-*.sql applied on top, the schema file first.
# No production data is read or written.
#
# Engine tests for the hosting packages are picked up as test_hosting_*.php.
#
# Requires: php (8.1+), node (18+), postgres client tools (initdb, pg_ctl, psql).
......@@ -63,9 +67,89 @@ for _ in $(seq 1 20); do
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -tAc 'select 1' >/dev/null 2>&1 && break
sleep 0.5
done
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -q -f schema.sql >/dev/null 2>&1
PSQL=(psql -h 127.0.0.1 -p "$PGPORT" -U postgres -v ON_ERROR_STOP=1 -q)
if ! "${PSQL[@]}" -f schema.sql > /tmp/el3ab_schema_out 2>&1; then
echo " the replica schema failed to load:"
sed 's/^/ /' /tmp/el3ab_schema_out | tail -20
exit 1
fi
echo " schema loaded"
# The big-tournament hosting migrations, in the order production gets them:
# the shared schema first, then each package's functions file.
HOSTING=()
[ -f ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=(../migrations/2026-10-27-hosting-schema.sql)
for f in ../migrations/2026-10-27-hosting-*.sql; do
[ -f "$f" ] && [ "$f" != ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=("$f")
done
apply_hosting() {
local f
for f in "${HOSTING[@]}"; do
echo "-- $f"
"${PSQL[@]}" -f "$f" || return 1
done
}
schema_dump() {
pg_dump -h 127.0.0.1 -p "$PGPORT" -U postgres --schema-only -n public -n storage | grep -v -E '^\\(un)?restrict '
}
# A second run of every hosting migration must leave the schema byte-identical.
hosting_idempotent() {
local a b rc
a=$(mktemp -t el3ab_schema_a) b=$(mktemp -t el3ab_schema_b)
schema_dump > "$a"
apply_hosting > /dev/null 2>&1 || { apply_hosting; rm -f "$a" "$b"; return 1; }
schema_dump > "$b"
diff "$a" "$b"; rc=$?
rm -f "$a" "$b"
return $rc
}
# The tables the hosting schema locks: RLS on, the service role only, and the
# two read policies Realtime needs left in place.
hosting_locked() {
"${PSQL[@]}" -tA <<'SQL'
DO $$
DECLARE
locked text[] := ARRAY['tournament_private_settings','tournament_teams','tournament_team_matches',
'tournament_stage_entries','tournament_form_fields','tournament_registration_answers',
'tournament_prize_categories','tournament_disputes','tournament_certificates','tournament_notices_sent',
'tournament_forbidden_pairs','tournament_phases','tournament_brackets','bracket_matches',
'tournament_registrations','tournament_media','tournament_ad_slots','tournament_announcements','sponsors',
'tournament_sponsorships','tournament_prize_payouts'];
bad text; n int;
BEGIN
SELECT count(*) INTO n FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relname = ANY (locked);
IF n <> 21 THEN RAISE EXCEPTION 'expected 21 locked tables, found %', n; END IF;
SELECT string_agg(relname, ', ') INTO bad FROM pg_class
WHERE relnamespace = 'public'::regnamespace AND relname = ANY (locked) AND NOT relrowsecurity;
IF bad IS NOT NULL THEN RAISE EXCEPTION 'RLS is off on: %', bad; END IF;
SELECT string_agg(c.relname || ' ' || CASE a.grantee WHEN 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END
|| ' ' || a.privilege_type, ', ') INTO bad
FROM pg_class c CROSS JOIN LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (locked)
AND a.grantee IN (0, 'anon'::regrole, 'authenticated'::regrole);
IF bad IS NOT NULL THEN RAISE EXCEPTION 'still granted: %', bad; END IF;
SELECT string_agg(t, ', ') INTO bad FROM unnest(locked) t
WHERE NOT (has_table_privilege('service_role', 'public.' || t, 'SELECT')
AND has_table_privilege('service_role', 'public.' || t, 'INSERT')
AND has_table_privilege('service_role', 'public.' || t, 'UPDATE')
AND has_table_privilege('service_role', 'public.' || t, 'DELETE'));
IF bad IS NOT NULL THEN RAISE EXCEPTION 'service_role cannot write: %', bad; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.el3ab_tournaments'::regclass
AND polname = 'Tournaments viewable by everyone' AND polcmd = 'r')
OR NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.el3ab_tournament_rounds'::regclass
AND polname = 'tournament_rounds_viewable_by_everyone' AND polcmd = 'r') THEN
RAISE EXCEPTION 'a Realtime read policy is missing';
END IF;
END $$;
SQL
}
step "Hosting migrations (${#HOSTING[@]} file(s))"
run "Every 2026-10-27-hosting-*.sql applies, the schema file first" apply_hosting
run "A second run of them leaves the schema identical" hosting_idempotent
run "Hosting tables: RLS on, service role only, read policies kept" hosting_locked
step "Starting the API under test"
php -S 127.0.0.1:$AUTHPORT authstub.php > .auth.log 2>&1 &
AUTH_PID=$!
......@@ -86,5 +170,12 @@ run "Tournament engine against real Postgres" php test_integra
run "Local JWT verification (signature, expiry, alg=none)" php test_auth.php
run "Full API end-to-end: matchmaking, a game, a tournament" node test_e2e.mjs
# Each hosting package adds its engine tests as tests/test_hosting_<name>.php.
for t in test_hosting_*.php; do
[ -f "$t" ] || continue
name=${t#test_hosting_}
run "Hosting: ${name%.php}" php "$t"
done
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
-- Replica of the production types and tables the tournament engine touches.
-- Enum values copied verbatim from the live PostgREST OpenAPI spec.
CREATE TYPE match_status AS ENUM ('waiting','ready','in_progress','paused','completed','aborted','abandoned');
CREATE TYPE match_result AS ENUM ('white_wins','black_wins','draw','white_timeout','black_timeout','white_resign','black_resign','white_abandon','black_abandon','stalemate','insufficient_material','threefold_repetition','fifty_moves','mutual_draw','aborted');
CREATE TYPE time_control AS ENUM ('bullet_1_0','bullet_1_1','bullet_2_1','blitz_3_0','blitz_3_2','blitz_5_0','blitz_5_3','rapid_10_0','rapid_10_5','rapid_15_10','rapid_30_0','classical_60_0','classical_90_30','custom');
CREATE TYPE tournament_format AS ENUM ('swiss','round_robin','single_elimination','double_elimination','swiss_to_bracket','arena','team_battle');
CREATE TABLE profiles (
id uuid PRIMARY KEY,
display_name text, username text, avatar_url text,
elo_rapid int DEFAULT 1200, elo_blitz int DEFAULT 1200,
elo_bullet int DEFAULT 1200, elo_classical int DEFAULT 1200,
games_played int DEFAULT 0, total_games_played int DEFAULT 0,
total_wins int DEFAULT 0, total_losses int DEFAULT 0, total_draws int DEFAULT 0,
win_streak int DEFAULT 0, best_win_streak int DEFAULT 0,
xp int DEFAULT 0, level int DEFAULT 1,
is_banned bool DEFAULT false, ban_expires_at timestamptz, is_admin bool DEFAULT false,
created_at timestamptz DEFAULT now()
);
CREATE TABLE el3ab_tournaments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
swiss_api_tournament_id uuid, org_id uuid,
game_key text DEFAULT 'chess', name text NOT NULL, name_ar text,
description text, format tournament_format DEFAULT 'swiss',
time_control time_control DEFAULT 'rapid_10_0',
rounds_total int, swiss_rounds int,
min_players int DEFAULT 4, max_players int DEFAULT 64,
bye_value numeric DEFAULT 1.0,
registration_closes_at timestamptz, starts_at timestamptz NOT NULL,
status text DEFAULT 'draft', current_round int DEFAULT 0,
is_rated bool DEFAULT true, auto_start bool DEFAULT true,
created_by uuid, tiebreak_rules jsonb DEFAULT '[]'::jsonb,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
CREATE TABLE el3ab_tournament_rounds (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id uuid REFERENCES el3ab_tournaments(id),
round_number int, status text,
started_at timestamptz, completed_at timestamptz,
pairings jsonb DEFAULT '[]'::jsonb, results jsonb DEFAULT '[]'::jsonb,
created_at timestamptz DEFAULT now(), swiss_round_id uuid
);
CREATE TABLE tournament_registrations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id uuid REFERENCES el3ab_tournaments(id),
player_id uuid, status text DEFAULT 'registered',
seed int, final_standing int, is_bot bool DEFAULT false,
registered_at timestamptz DEFAULT now(), withdrawn_at timestamptz
);
CREATE TABLE matches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
game_key text, white_player_id uuid, black_player_id uuid,
match_type text, tournament_id uuid, tournament_round int, pairing_id uuid,
status match_status DEFAULT 'waiting', result match_result,
time_control time_control, initial_time_ms int, increment_ms int DEFAULT 0,
white_time_remaining_ms int, black_time_remaining_ms int,
starting_fen text, current_fen text, pgn text,
moves jsonb DEFAULT '[]'::jsonb, move_count int DEFAULT 0,
game_state jsonb DEFAULT '{}'::jsonb,
white_rating_before int, black_rating_before int,
white_rating_after int, black_rating_after int,
rating_change_white int, rating_change_black int,
bot_id text, is_rated bool DEFAULT false,
started_at timestamptz, completed_at timestamptz,
-- Local replica of the production tables the tournament engine and the
-- big-tournament hosting packages touch, as they stood on 2026-10-26, before
-- migrations/2026-10-27-hosting-schema.sql. tests/run.sh loads this file into a
-- throwaway Postgres, then applies every migrations/2026-10-27-hosting-*.sql on
-- top of it (the schema file first), the way production receives them.
--
-- Generated from read-only dumps of the live database (pg_dump --schema-only of
-- these tables, pg_get_functiondef for the functions) and kept verbatim, so the
-- columns, defaults, NOT NULLs, checks, keys, indexes, RLS, policies, grants and
-- enum values match production. The differences, all because a bare Postgres is
-- not a Supabase install:
-- * the roles anon, authenticated and service_role, auth.uid(), and
-- storage.buckets / storage.objects are minimal stand-ins;
-- * no foreign key from profiles.id to auth.users, nor from
-- org_members.application_id to org_membership_applications (not replicated);
-- * the profiles_guard and redeem_codes guard triggers are left out;
-- * economy_transactions.id defaults to gen_random_uuid() (no uuid-ossp);
-- * the "Members read their groups" policy is left out (group_members is not
-- replicated);
-- * profiles.is_admin exists only here: tests/test_e2e.mjs seeds it.
--
-- When production gains a column, a check or an enum value the engine relies on,
-- re-dump and update this file. A hosting package changes the schema through its
-- own migration only, never here.
SET client_min_messages = warning;
DO $$ BEGIN CREATE ROLE anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE service_role NOLOGIN BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE SCHEMA IF NOT EXISTS auth;
CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE
AS $$ SELECT nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$;
CREATE SCHEMA IF NOT EXISTS storage;
CREATE TABLE storage.buckets (
id text PRIMARY KEY, name text NOT NULL, owner uuid,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
metadata jsonb DEFAULT '{}'::jsonb
public boolean DEFAULT false, avif_autodetection boolean DEFAULT false,
file_size_limit bigint, allowed_mime_types text[], owner_id text
);
CREATE TABLE rating_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
player_id uuid, game_key text, time_control_type text,
rating_before int, rating_after int, rating_change int,
match_id uuid, opponent_id uuid, opponent_rating int,
result text, k_factor int, created_at timestamptz DEFAULT now()
CREATE TABLE storage.objects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), bucket_id text REFERENCES storage.buckets(id),
name text, owner uuid, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
last_accessed_at timestamptz DEFAULT now(), metadata jsonb
);
CREATE TABLE mp_log (
id bigserial PRIMARY KEY, ts timestamptz DEFAULT now(),
match_id uuid, game_key text, player_id uuid, event text, payload jsonb
CREATE TYPE public.match_status AS ENUM ('waiting','ready','in_progress','paused','completed','aborted','abandoned');
CREATE TYPE public.match_result AS ENUM ('white_wins','black_wins','draw','white_timeout','black_timeout','white_resign','black_resign','white_abandon','black_abandon','stalemate','insufficient_material','threefold_repetition','fifty_moves','mutual_draw','aborted');
CREATE TYPE public.time_control AS ENUM ('bullet_1_0','bullet_1_1','bullet_2_1','blitz_3_0','blitz_3_2','blitz_5_0','blitz_5_3','rapid_10_0','rapid_10_5','rapid_15_10','rapid_30_0','classical_60_0','classical_90_30','custom');
CREATE TYPE public.tournament_format AS ENUM ('swiss','round_robin','single_elimination','double_elimination','swiss_to_bracket','arena','team_battle');
CREATE TYPE public.platform_role AS ENUM ('superadmin','platform_admin','org_owner','org_admin','tournament_director','arbiter','content_manager','match_moderator','sponsor_admin','player','spectator');
-- Functions the indexes and the trigger below depend on (verbatim).
CREATE OR REPLACE FUNCTION public.redeem_fold(p_input text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE PARALLEL SAFE
AS $function$
declare
s text;
begin
if p_input is null then
return null;
end if;
s := normalize(p_input, NFKC);
s := translate(s, '٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹', '01234567890123456789');
s := regexp_replace(s,
'[ \t\n\r\v\f\u0085   -‏
-  ⁠⁦-⁩ ؜ـ‐-―−_.-]',
'', 'g');
s := translate(s, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ');
s := translate(s, 'OILSZBGU', '0115286V');
s := regexp_replace(s, '[ً-ْٰ]', '', 'g');
s := translate(s, 'أإآٱةىؤئیک', 'ااااهيوييك');
return s;
end $function$
;
CREATE OR REPLACE FUNCTION public.redeem_canonical(p_input text)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
AS $function$
select case when f ~ '^[0-9A-Zء-ي]{4,16}$' then f end
from (select public.redeem_fold(p_input) as f) x
$function$
;
CREATE OR REPLACE FUNCTION public.org_members_sync()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_old boolean := false;
v_new boolean := false;
begin
if tg_op in ('UPDATE','DELETE') then v_old := old.status = 'active'; end if;
if tg_op in ('INSERT','UPDATE') then v_new := new.status = 'active'; end if;
if tg_op = 'UPDATE' and v_old = v_new
and old.org_id = new.org_id and old.player_id = new.player_id then
return null;
end if;
if v_old then
update el3ab_organizations set member_count = greatest(coalesce(member_count, 0) - 1, 0)
where id = old.org_id;
update profiles p
set primary_org_id = (select m.org_id from org_members m
where m.player_id = old.player_id and m.status = 'active'
and m.org_id <> old.org_id
order by m.joined_at limit 1)
where p.id = old.player_id and p.primary_org_id = old.org_id;
end if;
if v_new then
update el3ab_organizations set member_count = coalesce(member_count, 0) + 1
where id = new.org_id;
update profiles set primary_org_id = new.org_id
where id = new.player_id and primary_org_id is null;
end if;
return null;
end $function$
;
-- Tables, as they stood before 2026-10-27-hosting-schema.sql ------------------
CREATE TABLE public.el3ab_organizations (
id uuid DEFAULT gen_random_uuid() NOT NULL,
swiss_api_org_id uuid,
name text NOT NULL,
name_ar text,
slug text NOT NULL,
logo_url text,
banner_url text,
description text,
description_ar text,
website text,
contact_email text,
country_code character(3),
city text,
tier text DEFAULT 'free'::text,
max_members integer DEFAULT 500,
max_concurrent_tournaments integer DEFAULT 3,
features_enabled jsonb DEFAULT '{}'::jsonb,
custom_branding jsonb DEFAULT '{}'::jsonb,
revenue_share_percent numeric(5,2) DEFAULT 0,
charity_percent numeric(5,2) DEFAULT 0,
is_verified boolean DEFAULT false,
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
primary_color text,
secondary_color text DEFAULT '#8b5cf6'::text,
social_links jsonb DEFAULT '{}'::jsonb,
custom_css text,
tier_points integer DEFAULT 0,
member_count integer DEFAULT 0,
total_tournaments integer DEFAULT 0,
total_matches integer DEFAULT 0,
founded_at date,
features jsonb DEFAULT '{}'::jsonb,
tag text,
join_policy text DEFAULT 'approval'::text NOT NULL,
games text[] DEFAULT '{chess}'::text[] NOT NULL,
hue text DEFAULT 'purple'::text NOT NULL,
suspended_at timestamp with time zone,
suspended_reason text,
suspended_by_admin uuid,
CONSTRAINT el3ab_org_games_check CHECK ((games <@ ARRAY['chess'::text, 'ludo'::text, 'domino'::text, 'backgammon'::text])),
CONSTRAINT el3ab_org_hue_check CHECK ((hue = ANY (ARRAY['gold'::text, 'silver'::text, 'red'::text, 'green'::text, 'blue'::text, 'purple'::text, 'orange'::text, 'cream'::text, 'teal'::text, 'wood'::text, 'pink'::text]))),
CONSTRAINT el3ab_org_join_policy_check CHECK ((join_policy = ANY (ARRAY['open'::text, 'approval'::text, 'code_only'::text, 'closed'::text]))),
CONSTRAINT el3ab_org_tag_check CHECK (((tag IS NULL) OR (((char_length(tag) >= 2) AND (char_length(tag) <= 5)) AND (tag !~ '\s'::text))))
);
CREATE TABLE public.admin_users (
id uuid DEFAULT gen_random_uuid() NOT NULL,
username character varying(100) NOT NULL,
email character varying(255) NOT NULL,
password_hash character varying(255) NOT NULL,
display_name character varying(200),
display_name_ar character varying(200),
role character varying(50) DEFAULT 'viewer'::character varying NOT NULL,
org_id uuid,
is_active boolean DEFAULT true,
last_login_at timestamp with time zone,
last_login_ip character varying(45),
login_count integer DEFAULT 0,
avatar_url text,
phone character varying(30),
notes text,
permissions jsonb DEFAULT '{}'::jsonb,
deleted_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.backgammon_matches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
status text DEFAULT 'in_progress'::text NOT NULL,
players jsonb DEFAULT '[]'::jsonb NOT NULL,
colors jsonb DEFAULT '[]'::jsonb NOT NULL,
white_player_id uuid,
black_player_id uuid,
variant text DEFAULT 'sheshbesh'::text NOT NULL,
match_length integer DEFAULT 1 NOT NULL,
scores jsonb DEFAULT '[0, 0]'::jsonb NOT NULL,
current_turn integer DEFAULT 0 NOT NULL,
game_number integer DEFAULT 1 NOT NULL,
game_state jsonb DEFAULT '{}'::jsonb NOT NULL,
cube_state jsonb,
last_emote jsonb,
host_id uuid,
created_by uuid,
winner_id uuid,
result text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now(),
last_activity timestamp with time zone DEFAULT now(),
completed_at timestamp with time zone
);
CREATE TABLE public.bracket_matches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
bracket_id uuid NOT NULL,
tournament_id uuid NOT NULL,
phase_id uuid NOT NULL,
round_number integer NOT NULL,
match_number integer NOT NULL,
player_a_id text,
player_a_name text,
player_a_seed integer,
player_b_id text,
player_b_name text,
player_b_seed integer,
result text,
score_a text,
score_b text,
winner_id text,
next_match_id uuid,
next_match_slot text,
loser_next_match_id uuid,
loser_next_match_slot text,
source_match_a_id uuid,
source_match_b_id uuid,
scheduled_at timestamp with time zone,
started_at timestamp with time zone,
completed_at timestamp with time zone,
status text DEFAULT 'pending'::text,
metadata jsonb DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT bracket_matches_loser_next_match_slot_check CHECK ((loser_next_match_slot = ANY (ARRAY['player_a'::text, 'player_b'::text]))),
CONSTRAINT bracket_matches_next_match_slot_check CHECK ((next_match_slot = ANY (ARRAY['player_a'::text, 'player_b'::text]))),
CONSTRAINT bracket_matches_result_check CHECK ((result = ANY (ARRAY['player_a_wins'::text, 'player_b_wins'::text, 'draw'::text, 'not_played'::text, 'forfeit_a'::text, 'forfeit_b'::text, 'bye'::text]))),
CONSTRAINT bracket_matches_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'ready'::text, 'in_progress'::text, 'completed'::text, 'bye'::text])))
);
CREATE TABLE public.domino_matches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
room_code character varying(6),
status character varying(20) DEFAULT 'waiting'::character varying,
mode character varying(20) DEFAULT '2p'::character varying,
player_count integer DEFAULT 2,
players jsonb DEFAULT '[]'::jsonb,
current_turn integer DEFAULT 0,
board jsonb DEFAULT '[]'::jsonb,
hands jsonb DEFAULT '{}'::jsonb,
boneyard jsonb DEFAULT '[]'::jsonb,
spinner jsonb,
scores jsonb DEFAULT '{}'::jsonb,
round_number integer DEFAULT 1,
round_history jsonb DEFAULT '[]'::jsonb,
target_score integer DEFAULT 100,
moves jsonb DEFAULT '[]'::jsonb,
game_state jsonb DEFAULT '{}'::jsonb,
chat jsonb DEFAULT '[]'::jsonb,
host_id uuid,
result character varying(50),
created_at timestamp with time zone DEFAULT now(),
completed_at timestamp with time zone,
winner_id uuid,
updated_at timestamp with time zone DEFAULT now(),
created_by uuid,
last_activity timestamp with time zone DEFAULT now()
);
CREATE TABLE public.el3ab_tournament_rounds (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
round_number integer NOT NULL,
status text DEFAULT 'pending'::text,
started_at timestamp with time zone,
completed_at timestamp with time zone,
pairings jsonb DEFAULT '[]'::jsonb,
results jsonb DEFAULT '[]'::jsonb,
created_at timestamp with time zone DEFAULT now(),
swiss_round_id uuid
);
CREATE TABLE public.el3ab_tournaments (
id uuid DEFAULT gen_random_uuid() NOT NULL,
swiss_api_tournament_id uuid,
org_id uuid,
game_key text DEFAULT 'chess'::text NOT NULL,
name text NOT NULL,
name_ar text,
description text,
description_ar text,
banner_url text,
format public.tournament_format NOT NULL,
time_control public.time_control NOT NULL,
custom_time_initial_ms integer,
custom_time_increment_ms integer,
rounds_total integer,
swiss_rounds integer,
bracket_size integer,
bracket_best_of integer DEFAULT 1,
entry_fee_coins integer DEFAULT 0,
entry_fee_gems integer DEFAULT 0,
min_rating integer,
max_rating integer,
min_players integer DEFAULT 4,
max_players integer DEFAULT 256,
prize_pool_coins integer DEFAULT 0,
prize_pool_gems integer DEFAULT 0,
prize_distribution jsonb DEFAULT '[]'::jsonb,
cosmetic_rewards jsonb DEFAULT '[]'::jsonb,
registration_opens_at timestamp with time zone,
registration_closes_at timestamp with time zone,
starts_at timestamp with time zone NOT NULL,
status text DEFAULT 'draft'::text,
current_round integer DEFAULT 0,
charity_id uuid,
charity_percent numeric(5,2) DEFAULT 0,
sponsor_id uuid,
sponsor_branding jsonb DEFAULT '{}'::jsonb,
is_rated boolean DEFAULT true,
is_fide_rated boolean DEFAULT false,
allow_berserk boolean DEFAULT false,
auto_start boolean DEFAULT true,
created_by uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
tournament_mode text DEFAULT 'single'::text,
phase_config jsonb DEFAULT '[]'::jsonb,
current_phase integer DEFAULT 1,
total_phases integer DEFAULT 1,
tiebreak_rules jsonb DEFAULT '["buchholz_cut_1", "buchholz", "sonneborn_berger"]'::jsonb,
acceleration_method text,
acceleration_rounds integer DEFAULT 0,
slug text,
live_enabled boolean DEFAULT false,
live_theme text DEFAULT 'default'::text,
live_custom_css text,
live_visibility jsonb DEFAULT '{"rules": true, "stats": true, "prizes": true, "ticker": true, "bracket": true, "gallery": true, "players": true, "pairings": true, "schedule": true, "standings": true, "announcements": true}'::jsonb,
live_branding jsonb DEFAULT '{}'::jsonb,
view_count integer DEFAULT 0,
unique_visitors integer DEFAULT 0,
members_only boolean DEFAULT false NOT NULL,
CONSTRAINT el3ab_tournaments_tournament_mode_check CHECK ((tournament_mode = ANY (ARRAY['single'::text, 'multi_phase'::text])))
);
CREATE TABLE public.groups (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
avatar_url text,
owner_id uuid NOT NULL,
max_members integer DEFAULT 20 NOT NULL,
member_count integer DEFAULT 1 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
invite_code text DEFAULT translate(upper(substr(md5(((random())::text || (clock_timestamp())::text)), 1, 6)), 'B'::text, '7'::text),
CONSTRAINT groups_name_length CHECK (((char_length(name) >= 2) AND (char_length(name) <= 50)))
);
CREATE TABLE public.ludo_matches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
room_code text,
status text DEFAULT 'waiting'::text NOT NULL,
player_count integer DEFAULT 4 NOT NULL,
players jsonb DEFAULT '[]'::jsonb NOT NULL,
current_turn integer DEFAULT 0 NOT NULL,
dice_value integer,
positions jsonb DEFAULT '{}'::jsonb NOT NULL,
moves jsonb DEFAULT '[]'::jsonb NOT NULL,
winners jsonb DEFAULT '[]'::jsonb NOT NULL,
game_state jsonb DEFAULT '{}'::jsonb NOT NULL,
chat jsonb DEFAULT '[]'::jsonb NOT NULL,
host_id uuid,
created_at timestamp with time zone DEFAULT now(),
completed_at timestamp with time zone,
result text,
last_activity timestamp with time zone DEFAULT now()
);
CREATE TABLE public.matches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
game_key text DEFAULT 'chess'::text NOT NULL,
white_player_id uuid,
black_player_id uuid,
match_type text NOT NULL,
tournament_id uuid,
tournament_round integer,
pairing_id uuid,
status public.match_status DEFAULT 'waiting'::public.match_status,
result public.match_result,
time_control public.time_control NOT NULL,
initial_time_ms integer NOT NULL,
increment_ms integer DEFAULT 0,
white_time_remaining_ms integer,
black_time_remaining_ms integer,
starting_fen text DEFAULT 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'::text,
current_fen text,
pgn text,
moves jsonb DEFAULT '[]'::jsonb,
move_count integer DEFAULT 0,
game_state jsonb DEFAULT '{}'::jsonb,
white_rating_before integer,
black_rating_before integer,
white_rating_after integer,
black_rating_after integer,
rating_change_white integer,
rating_change_black integer,
bot_id text,
bot_difficulty text,
is_flagged boolean DEFAULT false,
cheat_score numeric(5,2) DEFAULT 0,
analysis_complete boolean DEFAULT false,
is_rated boolean DEFAULT true,
is_rematch boolean DEFAULT false,
rematch_of uuid,
started_at timestamp with time zone,
completed_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
metadata jsonb DEFAULT '{}'::jsonb
);
CREATE TABLE public.org_rosters (
id uuid DEFAULT gen_random_uuid() NOT NULL,
org_id uuid NOT NULL,
name text NOT NULL,
name_ar text,
game_key text NOT NULL,
status text DEFAULT 'active'::text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT org_rosters_status_check CHECK ((status = ANY (ARRAY['active'::text, 'archived'::text, 'draft'::text])))
);
CREATE TABLE public.profiles (
id uuid NOT NULL,
username text NOT NULL,
display_name text NOT NULL,
display_name_ar text,
avatar_url text,
banner_url text,
bio text,
bio_ar text,
country_code character(3),
city text,
preferred_language text DEFAULT 'ar'::text,
elo_bullet integer DEFAULT 1200,
elo_blitz integer DEFAULT 1200,
elo_rapid integer DEFAULT 1200,
elo_classical integer DEFAULT 1200,
fide_id text,
fide_rating_standard integer,
fide_rating_rapid integer,
fide_rating_blitz integer,
fide_title text,
xp integer DEFAULT 0,
level integer DEFAULT 1,
coins integer DEFAULT 0,
gems integer DEFAULT 0,
is_online boolean DEFAULT false,
last_seen_at timestamp with time zone,
is_banned boolean DEFAULT false,
ban_reason text,
ban_expires_at timestamp with time zone,
total_games_played integer DEFAULT 0,
total_wins integer DEFAULT 0,
total_draws integer DEFAULT 0,
total_losses integer DEFAULT 0,
total_tournaments_played integer DEFAULT 0,
total_tournaments_won integer DEFAULT 0,
win_streak integer DEFAULT 0,
best_win_streak integer DEFAULT 0,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
premium_currency integer DEFAULT 0,
last_daily_reward timestamp with time zone,
daily_streak integer DEFAULT 0,
banned_by uuid,
current_game text,
games_played integer DEFAULT 0,
avatar_frame_id uuid,
avatar_border_color text,
active_org_frame_id uuid,
puzzle_rating integer DEFAULT 1200,
puzzle_streak integer DEFAULT 0,
best_puzzle_streak integer DEFAULT 0,
elo_domino integer DEFAULT 1200,
abandon_count integer DEFAULT 0,
last_abandon_at timestamp with time zone,
primary_org_id uuid,
is_test boolean DEFAULT false NOT NULL
);
CREATE TABLE public.redeem_redemptions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
code_id uuid NOT NULL,
player_id uuid NOT NULL,
status text NOT NULL,
source text NOT NULL,
was_new_signup boolean DEFAULT false NOT NULL,
results jsonb DEFAULT '[]'::jsonb NOT NULL,
ip_hash text,
device_id text,
reviewed_by uuid,
reviewed_at timestamp with time zone,
reversed_by uuid,
reversed_at timestamp with time zone,
reverse_reason text,
reversal jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT redeem_redemptions_source_check CHECK ((source = ANY (ARRAY['signup'::text, 'link'::text, 'qr'::text, 'redeem_screen'::text, 'org_page'::text, 'guest_upgrade'::text, 'admin'::text]))),
CONSTRAINT redeem_redemptions_status_check CHECK ((status = ANY (ARRAY['applied'::text, 'partial'::text, 'pending_approval'::text, 'rejected'::text, 'reversed'::text])))
);
CREATE TABLE public.sponsors (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
name_ar text,
logo_url text,
website text,
contact_email text,
tier text DEFAULT 'bronze'::text,
banner_728x90_url text,
banner_300x250_url text,
interstitial_url text,
brand_color text,
is_active boolean DEFAULT true,
contract_starts_at timestamp with time zone,
contract_ends_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.tournament_ad_slots (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
"position" text NOT NULL,
slot_type text DEFAULT 'image'::text,
content text NOT NULL,
link_url text,
alt_text text,
is_active boolean DEFAULT true,
display_order integer DEFAULT 0,
impressions integer DEFAULT 0,
clicks integer DEFAULT 0,
starts_at timestamp with time zone,
ends_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT tournament_ad_slots_position_check CHECK (("position" = ANY (ARRAY['hero_top'::text, 'hero_bottom'::text, 'sidebar_top'::text, 'sidebar_bottom'::text, 'between_sections'::text, 'footer'::text, 'ticker'::text]))),
CONSTRAINT tournament_ad_slots_slot_type_check CHECK ((slot_type = ANY (ARRAY['image'::text, 'html'::text, 'script'::text])))
);
CREATE TABLE public.tournament_announcements (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
title text,
content text NOT NULL,
type text DEFAULT 'info'::text,
is_pinned boolean DEFAULT false,
created_by text,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT tournament_announcements_type_check CHECK ((type = ANY (ARRAY['info'::text, 'result'::text, 'alert'::text, 'commentary'::text, 'milestone'::text])))
);
CREATE TABLE public.tournament_brackets (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
phase_id uuid NOT NULL,
bracket_type text NOT NULL,
group_name text,
total_rounds integer NOT NULL,
current_round integer DEFAULT 0,
config jsonb DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT tournament_brackets_bracket_type_check CHECK ((bracket_type = ANY (ARRAY['winners'::text, 'losers'::text, 'consolation'::text, 'group'::text])))
);
CREATE TABLE public.tournament_media (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
media_type text DEFAULT 'image'::text,
url text NOT NULL,
thumbnail_url text,
caption text,
display_order integer DEFAULT 0,
is_featured boolean DEFAULT false,
uploaded_by text,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT tournament_media_media_type_check CHECK ((media_type = ANY (ARRAY['image'::text, 'video_url'::text])))
);
CREATE TABLE public.tournament_phases (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid NOT NULL,
phase_number integer NOT NULL,
name text NOT NULL,
name_ar text,
type text NOT NULL,
config jsonb DEFAULT '{}'::jsonb,
swiss_api_tournament_id uuid,
status text DEFAULT 'pending'::text,
advancement_rule jsonb DEFAULT '{}'::jsonb,
started_at timestamp with time zone,
completed_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT tournament_phases_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'in_progress'::text, 'completed'::text]))),
CONSTRAINT tournament_phases_type_check CHECK ((type = ANY (ARRAY['swiss'::text, 'round_robin'::text, 'single_elimination'::text, 'double_elimination'::text, 'arena'::text, 'group_stage'::text])))
);
CREATE TABLE public.tournament_prize_payouts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid,
player_id uuid,
place integer NOT NULL,
coins_awarded integer DEFAULT 0,
gems_awarded integer DEFAULT 0,
cosmetic_awarded text,
paid_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.tournament_registrations (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tournament_id uuid,
player_id uuid,
entry_fee_paid_coins integer DEFAULT 0,
entry_fee_paid_gems integer DEFAULT 0,
status text DEFAULT 'registered'::text,
seed integer,
final_standing integer,
registered_at timestamp with time zone DEFAULT now(),
withdrawn_at timestamp with time zone,
is_bot boolean DEFAULT false,
bot_metadata jsonb
);
CREATE TABLE public.tournament_sponsorships (
id uuid DEFAULT gen_random_uuid() NOT NULL,
sponsor_id uuid,
tournament_id uuid,
contribution_coins integer DEFAULT 0,
branding_placement jsonb DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.redeem_codes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
code text NOT NULL,
code_display text NOT NULL,
kind text NOT NULL,
scope text NOT NULL,
org_id uuid,
grants jsonb NOT NULL,
label text,
campaign text,
max_uses integer,
uses integer DEFAULT 0 NOT NULL,
reserved integer DEFAULT 0 NOT NULL,
starts_at timestamp with time zone,
expires_at timestamp with time zone,
revoked_at timestamp with time zone,
revoked_by uuid,
revoke_reason text,
requires_approval boolean DEFAULT false NOT NULL,
eligibility jsonb DEFAULT '{}'::jsonb NOT NULL,
owner_player_id uuid,
created_by_player uuid,
created_by_admin uuid,
welcome_message text,
welcome_message_ar text,
link_opens integer DEFAULT 0 NOT NULL,
previews integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT redeem_codes_eligibility_check CHECK ((jsonb_typeof(eligibility) = 'object'::text)),
CONSTRAINT redeem_codes_grants_check CHECK (((jsonb_typeof(grants) = 'array'::text) AND ((jsonb_array_length(grants) >= 1) AND (jsonb_array_length(grants) <= 6)))),
CONSTRAINT redeem_codes_kind_check CHECK ((kind = ANY (ARRAY['generated'::text, 'vanity'::text, 'numeric'::text]))),
CONSTRAINT redeem_codes_max_uses_check CHECK (((max_uses IS NULL) OR (max_uses > 0))),
CONSTRAINT redeem_codes_reserved_check CHECK ((reserved >= 0)),
CONSTRAINT redeem_codes_scope_check CHECK ((scope = ANY (ARRAY['org'::text, 'tournament'::text, 'group'::text, 'platform'::text]))),
CONSTRAINT redeem_codes_uses_check CHECK ((uses >= 0)),
CONSTRAINT redeem_org_scope CHECK (((scope <> 'org'::text) OR (org_id IS NOT NULL))),
CONSTRAINT redeem_uses_cap CHECK (((max_uses IS NULL) OR ((uses + reserved) <= max_uses))),
CONSTRAINT redeem_window CHECK (((starts_at IS NULL) OR (expires_at IS NULL) OR (starts_at < expires_at)))
);
CREATE TABLE public.cheat_reports (
id uuid DEFAULT gen_random_uuid() NOT NULL,
reported_player_id uuid,
reported_by uuid,
match_id uuid,
reason text NOT NULL,
description text,
engine_correlation numeric(5,2),
avg_centipawn_loss numeric(5,2),
move_time_pattern text,
status text DEFAULT 'pending'::text,
reviewed_by uuid,
reviewed_at timestamp with time zone,
action_taken text,
created_at timestamp with time zone DEFAULT now(),
reviewed_by_admin uuid
);
CREATE TABLE public.economy_transactions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
player_id uuid NOT NULL,
type text NOT NULL,
currency text NOT NULL,
amount integer NOT NULL,
balance_after integer NOT NULL,
reason text NOT NULL,
source_id text,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT economy_transactions_currency_check CHECK ((currency = ANY (ARRAY['coins'::text, 'gems'::text, 'premium_currency'::text]))),
CONSTRAINT economy_transactions_type_check CHECK ((type = ANY (ARRAY['credit'::text, 'debit'::text, 'game_reward'::text, 'level_up_reward'::text, 'daily_reward'::text, 'purchase'::text, 'refund'::text, 'admin_grant'::text, 'redeem_grant'::text, 'tournament_entry'::text, 'tournament_prize'::text])))
);
CREATE TABLE public.match_presence (
match_id uuid NOT NULL,
player_id uuid NOT NULL,
game_key text NOT NULL,
last_ping timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.mp_log (
id bigserial NOT NULL,
ts timestamp with time zone DEFAULT now(),
match_id uuid,
game_key text,
player_id uuid,
event text NOT NULL,
payload jsonb DEFAULT '{}'::jsonb
);
CREATE TABLE public.notifications (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid,
type text NOT NULL,
title text NOT NULL,
title_ar text,
body text,
body_ar text,
data jsonb DEFAULT '{}'::jsonb,
is_read boolean DEFAULT false,
read_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.org_members (
id uuid DEFAULT gen_random_uuid() NOT NULL,
org_id uuid NOT NULL,
player_id uuid NOT NULL,
role text DEFAULT 'member'::text,
joined_via text DEFAULT 'invite'::text,
application_id uuid,
joined_at timestamp with time zone DEFAULT now(),
display_name text,
custom_title text,
permissions jsonb DEFAULT '{}'::jsonb,
is_muted boolean DEFAULT false,
muted_until timestamp with time zone,
contribution_points integer DEFAULT 0,
last_active_at timestamp with time zone,
invited_by uuid,
invite_link_id uuid,
status text DEFAULT 'active'::text NOT NULL,
left_at timestamp with time zone,
ban_reason text,
redemption_id uuid,
CONSTRAINT org_members_status_check CHECK ((status = ANY (ARRAY['active'::text, 'suspended'::text, 'banned'::text, 'left'::text])))
);
CREATE TABLE public.platform_roles (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid,
role public.platform_role NOT NULL,
granted_by uuid,
granted_at timestamp with time zone DEFAULT now(),
expires_at timestamp with time zone,
scope_org_id uuid,
is_active boolean DEFAULT true
);
CREATE TABLE public.rating_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
player_id uuid,
game_key text DEFAULT 'chess'::text NOT NULL,
time_control_type text NOT NULL,
rating_before integer NOT NULL,
rating_after integer NOT NULL,
rating_change integer NOT NULL,
match_id uuid,
opponent_id uuid,
opponent_rating integer,
result text,
k_factor integer,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.player_game_ratings (
id uuid DEFAULT gen_random_uuid() NOT NULL,
player_id uuid NOT NULL,
game_key text NOT NULL,
mode text DEFAULT 'default'::text NOT NULL,
rating integer DEFAULT 1200 NOT NULL,
rating_deviation integer DEFAULT 350,
games_played integer DEFAULT 0 NOT NULL,
wins integer DEFAULT 0 NOT NULL,
losses integer DEFAULT 0 NOT NULL,
draws integer DEFAULT 0 NOT NULL,
win_streak integer DEFAULT 0 NOT NULL,
best_win_streak integer DEFAULT 0 NOT NULL,
last_played_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.reward_config (
key text NOT NULL,
value text NOT NULL,
description text,
updated_by uuid,
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.season_progress (
season_id text NOT NULL,
player_id uuid NOT NULL,
xp_baseline integer DEFAULT 0 NOT NULL,
first_seen_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.seasons (
id text NOT NULL,
name text NOT NULL,
name_ar text,
starts_at timestamp with time zone NOT NULL,
ends_at timestamp with time zone NOT NULL,
total_tiers integer DEFAULT 30,
xp_per_tier integer DEFAULT 100,
tiers_config jsonb DEFAULT '[]'::jsonb,
is_active boolean DEFAULT false,
created_at timestamp with time zone DEFAULT now()
);
-- Keys and checks ------------------------------------------------------------
ALTER TABLE ONLY public.admin_users
ADD CONSTRAINT admin_users_email_key UNIQUE (email);
ALTER TABLE ONLY public.admin_users
ADD CONSTRAINT admin_users_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.admin_users
ADD CONSTRAINT admin_users_username_key UNIQUE (username);
ALTER TABLE ONLY public.backgammon_matches
ADD CONSTRAINT backgammon_matches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.domino_matches
ADD CONSTRAINT domino_matches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.el3ab_organizations
ADD CONSTRAINT el3ab_organizations_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.el3ab_organizations
ADD CONSTRAINT el3ab_organizations_slug_key UNIQUE (slug);
ALTER TABLE ONLY public.el3ab_tournament_rounds
ADD CONSTRAINT el3ab_tournament_rounds_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.el3ab_tournament_rounds
ADD CONSTRAINT el3ab_tournament_rounds_tournament_id_round_number_key UNIQUE (tournament_id, round_number);
ALTER TABLE ONLY public.el3ab_tournaments
ADD CONSTRAINT el3ab_tournaments_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.el3ab_tournaments
ADD CONSTRAINT el3ab_tournaments_slug_key UNIQUE (slug);
ALTER TABLE ONLY public.groups
ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.ludo_matches
ADD CONSTRAINT ludo_matches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.ludo_matches
ADD CONSTRAINT ludo_matches_room_code_key UNIQUE (room_code);
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.org_rosters
ADD CONSTRAINT org_rosters_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_username_key UNIQUE (username);
ALTER TABLE ONLY public.redeem_redemptions
ADD CONSTRAINT redeem_redemptions_code_player_uq UNIQUE (code_id, player_id);
ALTER TABLE ONLY public.redeem_redemptions
ADD CONSTRAINT redeem_redemptions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.sponsors
ADD CONSTRAINT sponsors_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_ad_slots
ADD CONSTRAINT tournament_ad_slots_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_announcements
ADD CONSTRAINT tournament_announcements_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_brackets
ADD CONSTRAINT tournament_brackets_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_media
ADD CONSTRAINT tournament_media_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_phases
ADD CONSTRAINT tournament_phases_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_phases
ADD CONSTRAINT tournament_phases_tournament_id_phase_number_key UNIQUE (tournament_id, phase_number);
ALTER TABLE ONLY public.tournament_prize_payouts
ADD CONSTRAINT tournament_prize_payouts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_registrations
ADD CONSTRAINT tournament_registrations_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.tournament_registrations
ADD CONSTRAINT tournament_registrations_tournament_id_player_id_key UNIQUE (tournament_id, player_id);
ALTER TABLE ONLY public.tournament_sponsorships
ADD CONSTRAINT tournament_sponsorships_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.economy_transactions
ADD CONSTRAINT economy_transactions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.match_presence
ADD CONSTRAINT match_presence_pkey PRIMARY KEY (match_id, player_id);
ALTER TABLE ONLY public.mp_log
ADD CONSTRAINT mp_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.org_members
ADD CONSTRAINT org_members_org_id_player_id_key UNIQUE (org_id, player_id);
ALTER TABLE ONLY public.org_members
ADD CONSTRAINT org_members_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.platform_roles
ADD CONSTRAINT platform_roles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.platform_roles
ADD CONSTRAINT platform_roles_user_id_role_scope_org_id_key UNIQUE (user_id, role, scope_org_id);
ALTER TABLE ONLY public.rating_history
ADD CONSTRAINT rating_history_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.redeem_codes
ADD CONSTRAINT redeem_codes_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.player_game_ratings
ADD CONSTRAINT player_game_ratings_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.player_game_ratings
ADD CONSTRAINT player_game_ratings_player_id_game_key_mode_key UNIQUE (player_id, game_key, mode);
ALTER TABLE ONLY public.reward_config
ADD CONSTRAINT reward_config_pkey PRIMARY KEY (key);
ALTER TABLE ONLY public.season_progress
ADD CONSTRAINT season_progress_pkey PRIMARY KEY (season_id, player_id);
ALTER TABLE ONLY public.seasons
ADD CONSTRAINT seasons_pkey PRIMARY KEY (id);
-- Indexes --------------------------------------------------------------------
CREATE UNIQUE INDEX el3ab_org_tag_uq ON public.el3ab_organizations USING btree (upper(tag)) WHERE (tag IS NOT NULL);
CREATE INDEX groups_invite_code_canon_idx ON public.groups USING btree (public.redeem_canonical(invite_code));
CREATE UNIQUE INDEX groups_invite_code_canonical_uq ON public.groups USING btree (public.redeem_canonical(invite_code)) WHERE (invite_code IS NOT NULL);
CREATE UNIQUE INDEX groups_invite_code_key ON public.groups USING btree (invite_code);
CREATE INDEX idx_ad_slots_tournament ON public.tournament_ad_slots USING btree (tournament_id);
CREATE INDEX idx_announcements_tournament ON public.tournament_announcements USING btree (tournament_id, created_at DESC);
CREATE INDEX idx_backgammon_last_activity ON public.backgammon_matches USING btree (status, last_activity) WHERE (status = 'in_progress'::text);
CREATE INDEX idx_backgammon_matches_status ON public.backgammon_matches USING btree (status);
CREATE INDEX idx_bracket_matches_bracket ON public.bracket_matches USING btree (bracket_id);
CREATE INDEX idx_bracket_matches_phase ON public.bracket_matches USING btree (phase_id);
CREATE INDEX idx_bracket_matches_tournament ON public.bracket_matches USING btree (tournament_id);
CREATE INDEX idx_domino_last_activity ON public.domino_matches USING btree (status, last_activity) WHERE ((status)::text = 'in_progress'::text);
CREATE INDEX idx_domino_matches_room_code ON public.domino_matches USING btree (room_code);
CREATE INDEX idx_domino_matches_status ON public.domino_matches USING btree (status);
CREATE INDEX idx_ludo_last_activity ON public.ludo_matches USING btree (status, last_activity) WHERE (status = 'in_progress'::text);
CREATE INDEX idx_ludo_matches_status ON public.ludo_matches USING btree (status);
CREATE INDEX idx_matches_black_player ON public.matches USING btree (black_player_id);
CREATE INDEX idx_matches_status ON public.matches USING btree (status);
CREATE INDEX idx_matches_white_player ON public.matches USING btree (white_player_id);
CREATE INDEX idx_profiles_is_test ON public.profiles USING btree (id) WHERE is_test;
CREATE INDEX idx_profiles_last_seen ON public.profiles USING btree (last_seen_at DESC);
CREATE INDEX idx_tournament_brackets_phase ON public.tournament_brackets USING btree (phase_id);
CREATE INDEX idx_tournament_brackets_tournament ON public.tournament_brackets USING btree (tournament_id);
CREATE INDEX idx_tournament_media_tournament ON public.tournament_media USING btree (tournament_id);
CREATE INDEX idx_tournament_phases_tournament ON public.tournament_phases USING btree (tournament_id);
CREATE INDEX idx_tournament_registrations_bot ON public.tournament_registrations USING btree (tournament_id) WHERE (is_bot = true);
CREATE INDEX idx_tournament_regs_paid ON public.tournament_registrations USING btree (tournament_id) WHERE (entry_fee_paid_coins > 0);
CREATE INDEX idx_tournaments_live ON public.el3ab_tournaments USING btree (live_enabled) WHERE (live_enabled = true);
CREATE INDEX idx_tournaments_slug ON public.el3ab_tournaments USING btree (slug) WHERE (slug IS NOT NULL);
CREATE INDEX redeem_redemptions_code_idx ON public.redeem_redemptions USING btree (code_id, created_at DESC);
CREATE INDEX redeem_redemptions_player_idx ON public.redeem_redemptions USING btree (player_id, created_at DESC);
CREATE INDEX idx_economy_transactions_player ON public.economy_transactions USING btree (player_id, created_at DESC);
CREATE INDEX idx_match_presence_ping ON public.match_presence USING btree (last_ping);
CREATE INDEX idx_match_presence_player ON public.match_presence USING btree (player_id);
CREATE INDEX idx_mp_log_match ON public.mp_log USING btree (match_id, ts);
CREATE INDEX idx_mp_log_ts ON public.mp_log USING btree (ts);
CREATE INDEX idx_notifications_user_read ON public.notifications USING btree (user_id, is_read);
CREATE INDEX org_members_org_status_idx ON public.org_members USING btree (org_id, status);
CREATE INDEX org_members_player_active_idx ON public.org_members USING btree (player_id) WHERE (status = 'active'::text);
CREATE INDEX redeem_codes_campaign_idx ON public.redeem_codes USING btree (campaign);
CREATE UNIQUE INDEX redeem_codes_code_uq ON public.redeem_codes USING btree (code);
CREATE INDEX redeem_codes_org_idx ON public.redeem_codes USING btree (org_id) WHERE (revoked_at IS NULL);
CREATE INDEX redeem_codes_owner_idx ON public.redeem_codes USING btree (owner_player_id) WHERE (owner_player_id IS NOT NULL);
CREATE INDEX idx_pgr_game_mode ON public.player_game_ratings USING btree (game_key, mode);
CREATE INDEX idx_pgr_leaderboard ON public.player_game_ratings USING btree (game_key, mode, rating DESC);
CREATE INDEX idx_pgr_player ON public.player_game_ratings USING btree (player_id);
-- Foreign keys ---------------------------------------------------------------
ALTER TABLE ONLY public.admin_users
ADD CONSTRAINT admin_users_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.el3ab_organizations(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_bracket_id_fkey FOREIGN KEY (bracket_id) REFERENCES public.tournament_brackets(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_loser_next_match_id_fkey FOREIGN KEY (loser_next_match_id) REFERENCES public.bracket_matches(id);
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_next_match_id_fkey FOREIGN KEY (next_match_id) REFERENCES public.bracket_matches(id);
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_phase_id_fkey FOREIGN KEY (phase_id) REFERENCES public.tournament_phases(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_source_match_a_id_fkey FOREIGN KEY (source_match_a_id) REFERENCES public.bracket_matches(id);
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_source_match_b_id_fkey FOREIGN KEY (source_match_b_id) REFERENCES public.bracket_matches(id);
ALTER TABLE ONLY public.bracket_matches
ADD CONSTRAINT bracket_matches_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.el3ab_organizations
ADD CONSTRAINT el3ab_org_suspended_by_admin_fkey FOREIGN KEY (suspended_by_admin) REFERENCES public.admin_users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.el3ab_tournament_rounds
ADD CONSTRAINT el3ab_tournament_rounds_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.el3ab_tournaments
ADD CONSTRAINT el3ab_tournaments_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.el3ab_tournaments
ADD CONSTRAINT el3ab_tournaments_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.el3ab_organizations(id);
ALTER TABLE ONLY public.groups
ADD CONSTRAINT groups_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_black_player_id_fkey FOREIGN KEY (black_player_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_rematch_of_fkey FOREIGN KEY (rematch_of) REFERENCES public.matches(id);
ALTER TABLE ONLY public.matches
ADD CONSTRAINT matches_white_player_id_fkey FOREIGN KEY (white_player_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.org_rosters
ADD CONSTRAINT org_rosters_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.el3ab_organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_primary_org_id_fkey FOREIGN KEY (primary_org_id) REFERENCES public.el3ab_organizations(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.redeem_redemptions
ADD CONSTRAINT redeem_redemptions_code_id_fkey FOREIGN KEY (code_id) REFERENCES public.redeem_codes(id);
ALTER TABLE ONLY public.redeem_redemptions
ADD CONSTRAINT redeem_redemptions_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_ad_slots
ADD CONSTRAINT tournament_ad_slots_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_announcements
ADD CONSTRAINT tournament_announcements_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_brackets
ADD CONSTRAINT tournament_brackets_phase_id_fkey FOREIGN KEY (phase_id) REFERENCES public.tournament_phases(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_brackets
ADD CONSTRAINT tournament_brackets_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_media
ADD CONSTRAINT tournament_media_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_phases
ADD CONSTRAINT tournament_phases_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_prize_payouts
ADD CONSTRAINT tournament_prize_payouts_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.tournament_prize_payouts
ADD CONSTRAINT tournament_prize_payouts_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_registrations
ADD CONSTRAINT tournament_registrations_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.tournament_sponsorships
ADD CONSTRAINT tournament_sponsorships_sponsor_id_fkey FOREIGN KEY (sponsor_id) REFERENCES public.sponsors(id);
ALTER TABLE ONLY public.tournament_sponsorships
ADD CONSTRAINT tournament_sponsorships_tournament_id_fkey FOREIGN KEY (tournament_id) REFERENCES public.el3ab_tournaments(id);
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_match_id_fkey FOREIGN KEY (match_id) REFERENCES public.matches(id);
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_reported_by_fkey FOREIGN KEY (reported_by) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_reported_player_id_fkey FOREIGN KEY (reported_player_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_reviewed_by_admin_fkey FOREIGN KEY (reviewed_by_admin) REFERENCES public.admin_users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.cheat_reports
ADD CONSTRAINT cheat_reports_reviewed_by_fkey FOREIGN KEY (reviewed_by) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.economy_transactions
ADD CONSTRAINT economy_transactions_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.org_members
ADD CONSTRAINT org_members_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.el3ab_organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.org_members
ADD CONSTRAINT org_members_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.org_members
ADD CONSTRAINT org_members_redemption_id_fkey FOREIGN KEY (redemption_id) REFERENCES public.redeem_redemptions(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE ONLY public.platform_roles
ADD CONSTRAINT platform_roles_granted_by_fkey FOREIGN KEY (granted_by) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.platform_roles
ADD CONSTRAINT platform_roles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.rating_history
ADD CONSTRAINT rating_history_match_id_fkey FOREIGN KEY (match_id) REFERENCES public.matches(id);
ALTER TABLE ONLY public.rating_history
ADD CONSTRAINT rating_history_opponent_id_fkey FOREIGN KEY (opponent_id) REFERENCES public.profiles(id);
ALTER TABLE ONLY public.rating_history
ADD CONSTRAINT rating_history_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.redeem_codes
ADD CONSTRAINT redeem_codes_created_by_admin_fkey FOREIGN KEY (created_by_admin) REFERENCES public.admin_users(id);
ALTER TABLE ONLY public.redeem_codes
ADD CONSTRAINT redeem_codes_created_by_player_fkey FOREIGN KEY (created_by_player) REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.redeem_codes
ADD CONSTRAINT redeem_codes_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.el3ab_organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.redeem_codes
ADD CONSTRAINT redeem_codes_owner_player_id_fkey FOREIGN KEY (owner_player_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.season_progress
ADD CONSTRAINT season_progress_player_id_fkey FOREIGN KEY (player_id) REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.season_progress
ADD CONSTRAINT season_progress_season_id_fkey FOREIGN KEY (season_id) REFERENCES public.seasons(id) ON DELETE CASCADE;
-- Triggers -------------------------------------------------------------------
CREATE TRIGGER org_members_sync AFTER INSERT OR DELETE OR UPDATE OF status, org_id, player_id ON public.org_members FOR EACH ROW EXECUTE FUNCTION public.org_members_sync();
-- Row level security, policies and grants, as in production -------------------
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.backgammon_matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.bracket_matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.domino_matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.el3ab_organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.el3ab_tournament_rounds ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.el3ab_tournaments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ludo_matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.org_rosters ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.redeem_redemptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sponsors ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_ad_slots ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_announcements ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_brackets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_media ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_phases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_prize_payouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_registrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tournament_sponsorships ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.cheat_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.economy_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.match_presence ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.mp_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.org_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.platform_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rating_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.redeem_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.player_game_ratings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.reward_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.season_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.seasons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Matches viewable by everyone" ON public.matches FOR SELECT USING (true);
CREATE POLICY "Org admins can update their org" ON public.el3ab_organizations FOR UPDATE USING ((EXISTS ( SELECT 1
FROM public.platform_roles
WHERE ((platform_roles.user_id = auth.uid()) AND ((platform_roles.role = ANY (ARRAY['superadmin'::public.platform_role, 'platform_admin'::public.platform_role])) OR ((platform_roles.role = ANY (ARRAY['org_owner'::public.platform_role, 'org_admin'::public.platform_role])) AND (platform_roles.scope_org_id = el3ab_organizations.id))) AND (platform_roles.is_active = true)))));
CREATE POLICY "Orgs are viewable by everyone" ON public.el3ab_organizations FOR SELECT USING ((is_active = true));
CREATE POLICY "Profiles are viewable by everyone" ON public.profiles FOR SELECT USING (true);
CREATE POLICY "Service manages groups" ON public.groups TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "Tournament directors can create" ON public.el3ab_tournaments FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
FROM public.platform_roles
WHERE ((platform_roles.user_id = auth.uid()) AND (platform_roles.role = ANY (ARRAY['superadmin'::public.platform_role, 'platform_admin'::public.platform_role, 'tournament_director'::public.platform_role, 'org_owner'::public.platform_role, 'org_admin'::public.platform_role])) AND (platform_roles.is_active = true)))));
CREATE POLICY "Tournaments viewable by everyone" ON public.el3ab_tournaments FOR SELECT USING (true);
CREATE POLICY "Users can update their own profile" ON public.profiles FOR UPDATE USING ((auth.uid() = id)) WITH CHECK ((auth.uid() = id));
CREATE POLICY authenticated_read_admin_users ON public.admin_users FOR SELECT TO authenticated USING (true);
CREATE POLICY backgammon_read ON public.backgammon_matches FOR SELECT USING (true);
CREATE POLICY domino_matches_read ON public.domino_matches FOR SELECT USING (true);
CREATE POLICY ludo_read ON public.ludo_matches FOR SELECT USING (true);
CREATE POLICY service_role_all_admin_users ON public.admin_users TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_bracket_matches ON public.bracket_matches TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_org_rosters ON public.org_rosters TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_ad_slots ON public.tournament_ad_slots TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_announcements ON public.tournament_announcements TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_brackets ON public.tournament_brackets TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_media ON public.tournament_media TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_phases ON public.tournament_phases TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_tournament_rounds ON public.el3ab_tournament_rounds TO service_role USING (true) WITH CHECK (true);
CREATE POLICY tournament_rounds_viewable_by_everyone ON public.el3ab_tournament_rounds FOR SELECT USING (true);
CREATE POLICY "Players see their own transactions" ON public.economy_transactions FOR SELECT TO authenticated USING ((player_id = auth.uid()));
CREATE POLICY "Rating history viewable by everyone" ON public.rating_history FOR SELECT USING (true);
CREATE POLICY "Service role full access" ON public.platform_roles TO service_role USING (true);
CREATE POLICY "Users can mark their notifications read" ON public.notifications FOR UPDATE TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY "Users can read own role" ON public.platform_roles FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY "Users see their own notifications" ON public.notifications FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY pgr_select_policy ON public.player_game_ratings FOR SELECT USING (true);
CREATE POLICY pgr_service_policy ON public.player_game_ratings TO service_role USING (true) WITH CHECK (true);
CREATE POLICY reward_config_viewable_by_everyone ON public.reward_config FOR SELECT USING (true);
CREATE POLICY seasons_viewable_by_everyone ON public.seasons FOR SELECT USING (true);
CREATE POLICY service_role_all_reward_config ON public.reward_config TO service_role USING (true) WITH CHECK (true);
CREATE POLICY service_role_all_seasons ON public.seasons TO service_role USING (true) WITH CHECK (true);
GRANT ALL ON TABLE public.el3ab_organizations TO anon;
GRANT ALL ON TABLE public.el3ab_organizations TO authenticated;
GRANT ALL ON TABLE public.el3ab_organizations TO service_role;
GRANT ALL ON TABLE public.admin_users TO anon;
GRANT ALL ON TABLE public.admin_users TO authenticated;
GRANT ALL ON TABLE public.admin_users TO service_role;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.backgammon_matches TO anon;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.backgammon_matches TO authenticated;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.backgammon_matches TO service_role;
GRANT ALL ON TABLE public.bracket_matches TO anon;
GRANT ALL ON TABLE public.bracket_matches TO authenticated;
GRANT ALL ON TABLE public.bracket_matches TO service_role;
GRANT ALL ON TABLE public.domino_matches TO anon;
GRANT ALL ON TABLE public.domino_matches TO authenticated;
GRANT ALL ON TABLE public.domino_matches TO service_role;
GRANT SELECT,REFERENCES,TRIGGER ON TABLE public.el3ab_tournament_rounds TO anon;
GRANT SELECT,REFERENCES,TRIGGER ON TABLE public.el3ab_tournament_rounds TO authenticated;
GRANT ALL ON TABLE public.el3ab_tournament_rounds TO service_role;
GRANT ALL ON TABLE public.el3ab_tournaments TO anon;
GRANT ALL ON TABLE public.el3ab_tournaments TO authenticated;
GRANT ALL ON TABLE public.el3ab_tournaments TO service_role;
GRANT ALL ON TABLE public.groups TO anon;
GRANT ALL ON TABLE public.groups TO authenticated;
GRANT ALL ON TABLE public.groups TO service_role;
GRANT ALL ON TABLE public.ludo_matches TO anon;
GRANT ALL ON TABLE public.ludo_matches TO authenticated;
GRANT ALL ON TABLE public.ludo_matches TO service_role;
GRANT ALL ON TABLE public.matches TO anon;
GRANT ALL ON TABLE public.matches TO authenticated;
GRANT ALL ON TABLE public.matches TO service_role;
GRANT ALL ON TABLE public.org_rosters TO anon;
GRANT ALL ON TABLE public.org_rosters TO authenticated;
GRANT ALL ON TABLE public.org_rosters TO service_role;
GRANT ALL ON TABLE public.profiles TO anon;
GRANT ALL ON TABLE public.profiles TO authenticated;
GRANT ALL ON TABLE public.profiles TO service_role;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.redeem_redemptions TO service_role;
GRANT ALL ON TABLE public.sponsors TO anon;
GRANT ALL ON TABLE public.sponsors TO authenticated;
GRANT ALL ON TABLE public.sponsors TO service_role;
GRANT ALL ON TABLE public.tournament_ad_slots TO anon;
GRANT ALL ON TABLE public.tournament_ad_slots TO authenticated;
GRANT ALL ON TABLE public.tournament_ad_slots TO service_role;
GRANT ALL ON TABLE public.tournament_announcements TO anon;
GRANT ALL ON TABLE public.tournament_announcements TO authenticated;
GRANT ALL ON TABLE public.tournament_announcements TO service_role;
GRANT ALL ON TABLE public.tournament_brackets TO anon;
GRANT ALL ON TABLE public.tournament_brackets TO authenticated;
GRANT ALL ON TABLE public.tournament_brackets TO service_role;
GRANT ALL ON TABLE public.tournament_media TO anon;
GRANT ALL ON TABLE public.tournament_media TO authenticated;
GRANT ALL ON TABLE public.tournament_media TO service_role;
GRANT ALL ON TABLE public.tournament_phases TO anon;
GRANT ALL ON TABLE public.tournament_phases TO authenticated;
GRANT ALL ON TABLE public.tournament_phases TO service_role;
GRANT ALL ON TABLE public.tournament_prize_payouts TO anon;
GRANT ALL ON TABLE public.tournament_prize_payouts TO authenticated;
GRANT ALL ON TABLE public.tournament_prize_payouts TO service_role;
GRANT ALL ON TABLE public.tournament_registrations TO anon;
GRANT ALL ON TABLE public.tournament_registrations TO authenticated;
GRANT ALL ON TABLE public.tournament_registrations TO service_role;
GRANT ALL ON TABLE public.tournament_sponsorships TO anon;
GRANT ALL ON TABLE public.tournament_sponsorships TO authenticated;
GRANT ALL ON TABLE public.tournament_sponsorships TO service_role;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.redeem_codes TO service_role;
GRANT ALL ON TABLE public.cheat_reports TO anon;
GRANT ALL ON TABLE public.cheat_reports TO authenticated;
GRANT ALL ON TABLE public.cheat_reports TO service_role;
GRANT ALL ON TABLE public.economy_transactions TO anon;
GRANT ALL ON TABLE public.economy_transactions TO authenticated;
GRANT ALL ON TABLE public.economy_transactions TO service_role;
GRANT ALL ON TABLE public.match_presence TO service_role;
GRANT ALL ON TABLE public.mp_log TO anon;
GRANT ALL ON TABLE public.mp_log TO authenticated;
GRANT ALL ON TABLE public.mp_log TO service_role;
GRANT ALL ON TABLE public.notifications TO anon;
GRANT ALL ON TABLE public.notifications TO authenticated;
GRANT ALL ON TABLE public.notifications TO service_role;
GRANT ALL ON TABLE public.org_members TO anon;
GRANT ALL ON TABLE public.org_members TO authenticated;
GRANT ALL ON TABLE public.org_members TO service_role;
GRANT ALL ON TABLE public.platform_roles TO anon;
GRANT ALL ON TABLE public.platform_roles TO authenticated;
GRANT ALL ON TABLE public.platform_roles TO service_role;
GRANT ALL ON TABLE public.rating_history TO anon;
GRANT ALL ON TABLE public.rating_history TO authenticated;
GRANT ALL ON TABLE public.rating_history TO service_role;
GRANT ALL ON TABLE public.player_game_ratings TO anon;
GRANT ALL ON TABLE public.player_game_ratings TO authenticated;
GRANT ALL ON TABLE public.player_game_ratings TO service_role;
GRANT ALL ON TABLE public.reward_config TO anon;
GRANT ALL ON TABLE public.reward_config TO authenticated;
GRANT ALL ON TABLE public.reward_config TO service_role;
GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.season_progress TO service_role;
GRANT ALL ON TABLE public.seasons TO anon;
GRANT ALL ON TABLE public.seasons TO authenticated;
GRANT ALL ON TABLE public.seasons TO service_role;
-- Tournament and redeem functions the hosting packages build on (verbatim) -----
CREATE OR REPLACE FUNCTION public.tournament_refund_entry(p_registration_id uuid, p_reason text DEFAULT 'refund'::text)
RETURNS integer
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
v_tid uuid;
v_player uuid;
v_amt int;
v_name text;
v_bal int;
BEGIN
SELECT r.tournament_id, r.player_id, coalesce(r.entry_fee_paid_coins, 0)
INTO v_tid, v_player, v_amt
FROM tournament_registrations r
WHERE r.id = p_registration_id
FOR UPDATE;
IF NOT FOUND OR v_amt <= 0 THEN RETURN 0; END IF;
-- Zeroed first, in the same transaction: the lock above plus this write is
-- what makes a second refund of the same entry pay nothing.
UPDATE tournament_registrations SET entry_fee_paid_coins = 0 WHERE id = p_registration_id;
SELECT name INTO v_name FROM el3ab_tournaments WHERE id = v_tid;
UPDATE profiles SET coins = coalesce(coins, 0) + v_amt WHERE id = v_player RETURNING coins INTO v_bal;
IF FOUND THEN
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
VALUES (v_player, 'refund', 'coins', v_amt, v_bal,
'Tournament entry refund (' || coalesce(p_reason, 'refund') || '): '
|| coalesce(nullif(trim(v_name), ''), v_tid::text),
v_tid::text);
END IF;
RETURN v_amt;
END $function$
;
CREATE OR REPLACE FUNCTION public.tournament_register(p_tournament_id uuid, p_player_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
v_t el3ab_tournaments%ROWTYPE;
v_rid uuid;
v_rst text;
v_paid int := 0;
v_found boolean;
v_n int;
v_fee int;
v_charge int := 0;
v_bal int;
BEGIN
-- The row lock serialises every registration for this tournament, so the
-- capacity count below cannot be overtaken by a concurrent request.
SELECT * INTO v_t FROM el3ab_tournaments WHERE id = p_tournament_id FOR UPDATE;
IF NOT FOUND OR v_t.status = 'draft' THEN
RETURN jsonb_build_object('ok', false, 'code', 'not_found');
END IF;
IF v_t.status <> 'registration' THEN
RETURN jsonb_build_object('ok', false, 'code', 'registration_closed', 'status', v_t.status);
END IF;
IF v_t.registration_opens_at IS NOT NULL AND v_t.registration_opens_at > now() THEN
RETURN jsonb_build_object('ok', false, 'code', 'not_open_yet', 'opens_at', v_t.registration_opens_at);
END IF;
IF v_t.registration_closes_at IS NOT NULL AND v_t.registration_closes_at < now() THEN
RETURN jsonb_build_object('ok', false, 'code', 'registration_closed', 'closes_at', v_t.registration_closes_at);
END IF;
SELECT id, status, coalesce(entry_fee_paid_coins, 0) INTO v_rid, v_rst, v_paid
FROM tournament_registrations
WHERE tournament_id = p_tournament_id AND player_id = p_player_id
FOR UPDATE;
v_found := FOUND;
IF v_found AND v_rst = 'registered' THEN
RETURN jsonb_build_object('ok', true, 'already', true, 'registration_id', v_rid);
END IF;
IF NOT v_found THEN v_paid := 0; END IF;
SELECT count(*) INTO v_n FROM tournament_registrations
WHERE tournament_id = p_tournament_id AND status = 'registered';
IF coalesce(v_t.max_players, 0) > 0 AND v_n >= v_t.max_players THEN
RETURN jsonb_build_object('ok', false, 'code', 'full', 'max_players', v_t.max_players);
END IF;
-- A revived row may still hold a fee that was never refunded (staff removed
-- the player and the sweep has not run yet): only the difference is due.
v_fee := greatest(coalesce(v_t.entry_fee_coins, 0), 0);
v_charge := greatest(v_fee - v_paid, 0);
IF v_charge > 0 THEN
UPDATE profiles SET coins = coins - v_charge
WHERE id = p_player_id AND coalesce(coins, 0) >= v_charge
RETURNING coins INTO v_bal;
IF NOT FOUND THEN
SELECT coins INTO v_bal FROM profiles WHERE id = p_player_id;
RETURN jsonb_build_object('ok', false, 'code', 'insufficient_coins',
'fee', v_fee, 'coins', coalesce(v_bal, 0));
END IF;
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
VALUES (p_player_id, 'tournament_entry', 'coins', -v_charge, v_bal,
'Tournament entry: ' || coalesce(nullif(trim(v_t.name), ''), v_t.id::text), v_t.id::text);
END IF;
IF v_found THEN
UPDATE tournament_registrations
SET status = 'registered', withdrawn_at = NULL,
entry_fee_paid_coins = greatest(v_paid, v_fee)
WHERE id = v_rid;
ELSE
INSERT INTO tournament_registrations (tournament_id, player_id, status, entry_fee_paid_coins)
VALUES (p_tournament_id, p_player_id, 'registered', v_fee)
RETURNING id INTO v_rid;
END IF;
IF v_bal IS NULL THEN SELECT coins INTO v_bal FROM profiles WHERE id = p_player_id; END IF;
RETURN jsonb_build_object('ok', true, 'registration_id', v_rid, 'player_count', v_n + 1,
'fee_charged', v_charge, 'coins', v_bal);
END $function$
;
CREATE OR REPLACE FUNCTION public.tournament_withdraw(p_tournament_id uuid, p_player_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
v_status text;
v_rid uuid;
v_rst text;
v_ref int := 0;
BEGIN
-- Same lock as tournament_register and tournamentStart's claim, so a
-- withdrawal cannot be refunded in the instant the tournament starts.
SELECT status INTO v_status FROM el3ab_tournaments WHERE id = p_tournament_id FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'not_found'); END IF;
SELECT id, status INTO v_rid, v_rst FROM tournament_registrations
WHERE tournament_id = p_tournament_id AND player_id = p_player_id
FOR UPDATE;
IF NOT FOUND OR v_rst <> 'registered' THEN
RETURN jsonb_build_object('ok', false, 'code', 'not_registered');
END IF;
IF v_status = 'completed' THEN
RETURN jsonb_build_object('ok', false, 'code', 'finished');
END IF;
IF v_status IN ('in_progress', 'paused') THEN
-- The row is kept so finished games keep counting in opponents' tiebreaks.
UPDATE tournament_registrations SET status = 'withdrawn', withdrawn_at = now() WHERE id = v_rid;
RETURN jsonb_build_object('ok', true, 'status', 'withdrawn', 'refunded', 0);
END IF;
UPDATE tournament_registrations SET status = 'cancelled', withdrawn_at = now() WHERE id = v_rid;
v_ref := tournament_refund_entry(v_rid, 'withdrawn before the start');
RETURN jsonb_build_object('ok', true, 'status', 'cancelled', 'refunded', v_ref);
END $function$
;
CREATE OR REPLACE FUNCTION public.tournament_refund_due()
RETURNS jsonb
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
r record;
v_n int := 0;
v_coins int := 0;
v_amt int;
BEGIN
FOR r IN
SELECT reg.id, t.status AS t_status
FROM tournament_registrations reg
JOIN el3ab_tournaments t ON t.id = reg.tournament_id
WHERE reg.entry_fee_paid_coins > 0
AND (t.status = 'cancelled'
OR (reg.status = 'cancelled' AND t.status IN ('draft', 'registration', 'cancelled')))
LOOP
v_amt := tournament_refund_entry(r.id,
CASE WHEN r.t_status = 'cancelled' THEN 'tournament cancelled' ELSE 'entry cancelled' END);
IF v_amt > 0 THEN v_n := v_n + 1; v_coins := v_coins + v_amt; END IF;
END LOOP;
RETURN jsonb_build_object('refunds', v_n, 'coins', v_coins);
END $function$
;
CREATE OR REPLACE FUNCTION public.tournament_settle(p_tournament_id uuid, p_results jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
v_t el3ab_tournaments%ROWTYPE;
x record;
v_rid uuid;
v_bal int;
v_settled int := 0;
v_paid int := 0;
v_coins int := 0;
v_label text;
BEGIN
SELECT * INTO v_t FROM el3ab_tournaments WHERE id = p_tournament_id FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'not_found'); END IF;
IF v_t.status <> 'completed' THEN
RETURN jsonb_build_object('ok', false, 'code', 'not_completed', 'status', v_t.status);
END IF;
v_label := coalesce(nullif(trim(v_t.name), ''), v_t.id::text);
FOR x IN
SELECT * FROM jsonb_to_recordset(coalesce(p_results, '[]'::jsonb)) AS j(player_id uuid, rank int, coins int)
LOOP
CONTINUE WHEN x.player_id IS NULL OR x.rank IS NULL;
UPDATE tournament_registrations SET final_standing = x.rank
WHERE tournament_id = p_tournament_id AND player_id = x.player_id
AND status = 'registered' AND final_standing IS NULL
RETURNING id INTO v_rid;
CONTINUE WHEN NOT FOUND; -- already settled, or not in the tournament
v_settled := v_settled + 1;
-- Bots registered by the manager's simulator have no profile: they get a
-- placing and nothing else.
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM profiles WHERE id = x.player_id);
UPDATE profiles
SET total_tournaments_played = coalesce(total_tournaments_played, 0) + 1,
total_tournaments_won = coalesce(total_tournaments_won, 0) + CASE WHEN x.rank = 1 THEN 1 ELSE 0 END
WHERE id = x.player_id;
IF coalesce(x.coins, 0) > 0 THEN
UPDATE profiles SET coins = coalesce(coins, 0) + x.coins WHERE id = x.player_id RETURNING coins INTO v_bal;
INSERT INTO tournament_prize_payouts (tournament_id, player_id, place, coins_awarded)
VALUES (p_tournament_id, x.player_id, x.rank, x.coins);
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
VALUES (x.player_id, 'tournament_prize', 'coins', x.coins, v_bal,
'Tournament prize, place ' || x.rank || ': ' || v_label, p_tournament_id::text);
v_paid := v_paid + 1;
v_coins := v_coins + x.coins;
END IF;
END LOOP;
RETURN jsonb_build_object('ok', true, 'settled', v_settled, 'paid', v_paid, 'coins', v_coins);
END $function$
;
CREATE OR REPLACE FUNCTION public._redeem_tournament(p_player uuid, p_tid uuid, p_waive boolean)
RETURNS jsonb
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_t el3ab_tournaments%rowtype;
v_rid uuid;
v_rst text;
v_found boolean;
v_n int;
r jsonb;
base jsonb := jsonb_build_object('type', 'tournament_register', 'tournament_id', p_tid,
'fee_waived', coalesce(p_waive, false), 'waitlisted', false);
begin
if not coalesce(p_waive, false) then
r := tournament_register(p_tid, p_player);
return base || jsonb_build_object(
'ok', coalesce((r->>'ok')::boolean, false),
'applied', coalesce((r->>'ok')::boolean, false) and not coalesce((r->>'already')::boolean, false),
'already', coalesce((r->>'already')::boolean, false),
'registration_id', r->'registration_id',
'fee_charged', coalesce(r->'fee_charged', '0'::jsonb),
'reason', case r->>'code'
when 'not_found' then case when exists (select 1 from el3ab_tournaments where id = p_tid)
then 'not_open_yet' else 'tournament_not_found' end
when 'full' then 'tournament_full'
else r->>'code' end);
end if;
-- Fee waived (pre-paid lists): the same gates as tournament_register, no charge.
select * into v_t from el3ab_tournaments where id = p_tid for update;
if not found then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'tournament_not_found');
end if;
if v_t.status = 'draft' or (v_t.registration_opens_at is not null and v_t.registration_opens_at > now()) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'not_open_yet');
end if;
if v_t.status <> 'registration'
or (v_t.registration_closes_at is not null and v_t.registration_closes_at < now()) then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'registration_closed');
end if;
select id, status into v_rid, v_rst from tournament_registrations
where tournament_id = p_tid and player_id = p_player for update;
v_found := found;
if v_found and v_rst = 'registered' then
return base || jsonb_build_object('ok', true, 'applied', false, 'already', true, 'registration_id', v_rid);
end if;
select count(*) into v_n from tournament_registrations where tournament_id = p_tid and status = 'registered';
if coalesce(v_t.max_players, 0) > 0 and v_n >= v_t.max_players then
return base || jsonb_build_object('ok', false, 'applied', false, 'reason', 'tournament_full');
end if;
if v_found then
update tournament_registrations set status = 'registered', withdrawn_at = null where id = v_rid;
else
insert into tournament_registrations (tournament_id, player_id, status, entry_fee_paid_coins)
values (p_tid, p_player, 'registered', 0) returning id into v_rid;
end if;
return base || jsonb_build_object('ok', true, 'applied', true, 'registration_id', v_rid,
'revived', v_found, 'player_count', v_n + 1);
end $function$
;
CREATE OR REPLACE FUNCTION public.award_coins(p_player_id uuid, p_amount integer, p_reason text)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE new_balance INT;
BEGIN
UPDATE profiles SET coins = coins + p_amount WHERE id = p_player_id
RETURNING coins INTO new_balance;
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason)
VALUES (p_player_id, 'game_reward', 'coins', p_amount, new_balance, p_reason);
RETURN new_balance;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.complete_match(p_game_key text, p_match_id uuid, p_winners jsonb, p_reason text DEFAULT 'normal'::text)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
DECLARE
v_player_id uuid;
v_placement int;
v_coins int;
v_xp int;
v_total_players int;
v_rewards jsonb := '[]'::jsonb;
v_item jsonb;
v_raw text;
v_result text;
v_win int;
v_loss int;
v_draw int;
v_label text;
BEGIN
IF p_winners IS NULL OR jsonb_typeof(p_winners) <> 'array' THEN
RETURN json_build_object('success', false, 'error', 'p_winners must be an array', 'rewards', '[]'::json);
END IF;
v_total_players := jsonb_array_length(p_winners);
FOR v_placement IN 0..(v_total_players - 1) LOOP
v_item := p_winners->v_placement;
v_result := NULL;
IF jsonb_typeof(v_item) = 'object' THEN
v_raw := v_item->>'id';
v_result := lower(coalesce(v_item->>'result', ''));
CONTINUE WHEN v_result NOT IN ('win', 'loss', 'draw');
ELSE
v_raw := p_winners->>v_placement;
END IF;
-- Bots, empty seats and anything that is not a player's id.
CONTINUE WHEN v_raw IS NULL OR v_raw LIKE 'bot_%'
OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
v_player_id := v_raw::uuid;
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM profiles WHERE id = v_player_id);
v_coins := NULL;
v_xp := NULL;
IF v_result IS NOT NULL THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_' || v_result || '_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_' || v_result || '_xp';
IF v_result = 'draw' THEN
IF v_coins IS NULL THEN SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_loss_coins'; END IF;
IF v_xp IS NULL THEN SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_loss_xp'; END IF;
END IF;
v_win := CASE WHEN v_result = 'win' THEN 1 ELSE 0 END;
v_loss := CASE WHEN v_result = 'loss' THEN 1 ELSE 0 END;
v_draw := CASE WHEN v_result = 'draw' THEN 1 ELSE 0 END;
v_label := p_game_key || ' ' || v_result;
ELSE
IF v_placement = 0 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_win_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_win_xp';
ELSIF v_placement = 1 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_2nd_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_2nd_xp';
ELSIF v_placement = 2 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_3rd_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_3rd_xp';
ELSE
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_4th_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_4th_xp';
IF v_coins IS NULL THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_loss_coins';
END IF;
IF v_xp IS NULL THEN
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_loss_xp';
END IF;
END IF;
v_win := CASE WHEN v_placement = 0 THEN 1 ELSE 0 END;
v_loss := 1 - v_win;
v_draw := 0;
v_label := p_game_key || ' ' ||
CASE v_placement WHEN 0 THEN 'win' WHEN 1 THEN '2nd place' WHEN 2 THEN '3rd place' ELSE 'loss' END;
END IF;
-- Fallbacks
v_coins := COALESCE(v_coins, 5);
v_xp := COALESCE(v_xp, 5);
-- Zero rewards for abandons: the player who left.
IF p_reason = 'abandon'
AND ((v_result IS NULL AND v_placement = v_total_players - 1) OR v_result = 'loss') THEN
v_coins := 0; v_xp := 0;
UPDATE profiles SET abandon_count = coalesce(abandon_count, 0) + 1, last_abandon_at = now() WHERE id = v_player_id;
END IF;
PERFORM award_coins(v_player_id, v_coins, v_label);
-- Battle pass: baseline for the running season, before this game's XP.
IF to_regclass('public.season_progress') IS NOT NULL THEN
INSERT INTO season_progress (season_id, player_id, xp_baseline)
SELECT s.id, v_player_id, coalesce(p.xp, 0)
FROM seasons s JOIN profiles p ON p.id = v_player_id
WHERE s.is_active AND now() >= s.starts_at AND (s.ends_at IS NULL OR now() < s.ends_at)
ON CONFLICT (season_id, player_id) DO NOTHING;
END IF;
UPDATE profiles SET xp = xp + v_xp, level = GREATEST(COALESCE(level, 1), floor((COALESCE(xp, 0) + v_xp) / 500)::int + 1) WHERE id = v_player_id;
UPDATE profiles SET
total_games_played = total_games_played + 1,
games_played = games_played + 1,
total_wins = total_wins + v_win,
total_losses = total_losses + v_loss,
total_draws = coalesce(total_draws, 0) + v_draw,
win_streak = CASE WHEN v_win = 1 THEN win_streak + 1 ELSE 0 END,
best_win_streak = CASE WHEN v_win = 1 AND win_streak + 1 > best_win_streak THEN win_streak + 1 ELSE best_win_streak END
WHERE id = v_player_id;
INSERT INTO player_game_ratings (player_id, game_key, mode, games_played, wins, losses, draws, last_played_at)
VALUES (v_player_id, p_game_key, 'default', 1, v_win, v_loss, v_draw, now())
ON CONFLICT (player_id, game_key, mode) DO UPDATE SET
games_played = player_game_ratings.games_played + 1,
wins = player_game_ratings.wins + v_win,
losses = player_game_ratings.losses + v_loss,
draws = coalesce(player_game_ratings.draws, 0) + v_draw,
win_streak = CASE WHEN v_win = 1 THEN player_game_ratings.win_streak + 1 ELSE 0 END,
best_win_streak = CASE WHEN v_win = 1 AND player_game_ratings.win_streak + 1 > player_game_ratings.best_win_streak
THEN player_game_ratings.win_streak + 1 ELSE player_game_ratings.best_win_streak END,
last_played_at = now(),
updated_at = now();
v_rewards := v_rewards || (jsonb_build_object('player_id', v_player_id, 'placement', v_placement + 1, 'coins', v_coins, 'xp', v_xp)
|| CASE WHEN v_result IS NOT NULL THEN jsonb_build_object('result', v_result) ELSE '{}'::jsonb END);
END LOOP;
RETURN json_build_object('success', true, 'rewards', v_rewards, 'game_key', p_game_key, 'match_id', p_match_id);
END;
$function$
;
-- Test only ------------------------------------------------------------------
-- tests/test_e2e.mjs seeds an admin flag that production does not have.
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS is_admin boolean DEFAULT false;
......@@ -352,9 +352,11 @@ await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control
const hm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[3])).data;
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[2]);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('both players\' heartbeats are recorded', !!hs[`hb_${P[2]}`] && !!hs[`hb_${P[3]}`], JSON.stringify(hs));
check('one heartbeat does not overwrite the other', Object.keys(hs).filter(k => k.startsWith('hb_')).length === 2, JSON.stringify(Object.keys(hs)));
// One match_presence row per player (api/game.php handleChessHeartbeat); the
// heartbeat no longer writes into game_state, where it raced the moves.
const beats = sql(`SELECT player_id FROM match_presence WHERE match_id='${hm.match_id}' ORDER BY player_id`).split('\n').filter(Boolean);
check('both players\' heartbeats are recorded', beats.includes(P[2]) && beats.includes(P[3]), JSON.stringify(beats));
check('one heartbeat does not overwrite the other', beats.length === 2, JSON.stringify(beats));
// A draw offer must survive a heartbeat landing on the same row.
sql(`UPDATE matches SET game_state = game_state || '{"draw_offer":"${P[2]}"}'::jsonb WHERE id='${hm.match_id}'`);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment