Spaces:
Sleeping
Sleeping
| -- Migration 009: 1v1 Mode | |
| -- Adds 4 minimal tables for the 1-vs-1 competitive mode. | |
| -- All tables reference the existing `users` table (auth.users) and reuse | |
| -- the per-type challenges tables (encryption_challenges, | |
| -- code_fixing_challenges, log_analysis_challenges). | |
| -- | |
| -- Design notes: | |
| -- * `onevone_rooms.code` is a 6-char base32 join code (unique index). | |
| -- * `onevone_matches.state` is a small enum: waiting | countdown | playing | | |
| -- overtime | finished. Timer ticks are derived from start_time + duration | |
| -- (client + server compute the same value), but server is the authority. | |
| -- * `onevone_submissions` is append-only. The first row with `is_correct=true` | |
| -- is the winner; an index on (match_id, is_correct) makes that O(1). | |
| -- * `winner_user_id` is set the moment a winning submission is accepted, | |
| -- atomically with `state = 'finished'` via a single UPDATE (see backend). | |
| -- * RLS is permissive (read/write for anon role) like the other pool tables; | |
| -- the backend is the gatekeeper via the anon key + room token check. | |
| BEGIN; | |
| CREATE EXTENSION IF NOT EXISTS pgcrypto; | |
| -- ---------------------------------------------------------------------------- | |
| -- 1v1 rooms — owner creates, picks side (red/blue), gets a code | |
| -- ---------------------------------------------------------------------------- | |
| CREATE TABLE IF NOT EXISTS public.onevone_rooms ( | |
| id uuid PRIMARY KEY DEFAULT gen_random_uuid(), | |
| code text NOT NULL UNIQUE, -- 6-char base32 (e.g. "K7P3QA") | |
| owner_user_id uuid NOT NULL, -- FK -> auth.users.id | |
| team_role text NOT NULL CHECK (team_role IN ('red','blue')), | |
| status text NOT NULL DEFAULT 'open' | |
| CHECK (status IN ('open','closed','abandoned')), | |
| challenge_source text NOT NULL DEFAULT 'random' -- 'random' | 'manual:<id>' | |
| CHECK (challenge_source LIKE 'random%' OR challenge_source LIKE 'manual:%'), | |
| challenge_id text, -- populated once the match starts | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| closed_at timestamptz | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_rooms_code ON public.onevone_rooms (code); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_rooms_owner ON public.onevone_rooms (owner_user_id); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_rooms_status ON public.onevone_rooms (status); | |
| DROP POLICY IF EXISTS "Allow all access to onevone_rooms" ON public.onevone_rooms; | |
| CREATE POLICY "Allow all access to onevone_rooms" | |
| ON public.onevone_rooms FOR ALL USING (true) WITH CHECK (true); | |
| ALTER TABLE public.onevone_rooms ENABLE ROW LEVEL SECURITY; | |
| -- ---------------------------------------------------------------------------- | |
| -- 1v1 players — exactly 2 rows per match (owner + joiner) | |
| -- ---------------------------------------------------------------------------- | |
| CREATE TABLE IF NOT EXISTS public.onevone_players ( | |
| id uuid PRIMARY KEY DEFAULT gen_random_uuid(), | |
| room_id uuid NOT NULL REFERENCES public.onevone_rooms(id) ON DELETE CASCADE, | |
| user_id uuid NOT NULL, -- FK -> auth.users.id | |
| slot smallint NOT NULL CHECK (slot IN (1,2)), -- 1 = owner, 2 = joiner | |
| display_name text NOT NULL, | |
| is_ready boolean NOT NULL DEFAULT false, | |
| joined_at timestamptz NOT NULL DEFAULT now(), | |
| UNIQUE (room_id, slot), | |
| UNIQUE (room_id, user_id) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_players_room ON public.onevone_players (room_id); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_players_user ON public.onevone_players (user_id); | |
| DROP POLICY IF EXISTS "Allow all access to onevone_players" ON public.onevone_players; | |
| CREATE POLICY "Allow all access to onevone_players" | |
| ON public.onevone_players FOR ALL USING (true) WITH CHECK (true); | |
| ALTER TABLE public.onevone_players ENABLE ROW LEVEL SECURITY; | |
| -- ---------------------------------------------------------------------------- | |
| -- 1v1 matches — exactly 1 per room; state machine | |
| -- ---------------------------------------------------------------------------- | |
| CREATE TABLE IF NOT EXISTS public.onevone_matches ( | |
| id uuid PRIMARY KEY DEFAULT gen_random_uuid(), | |
| room_id uuid NOT NULL UNIQUE REFERENCES public.onevone_rooms(id) ON DELETE CASCADE, | |
| challenge_id text NOT NULL, -- reuses existing per-type challenge id | |
| challenge_type text NOT NULL -- 'crypto' | 'web' | 'code-fixing' | 'log-analysis' | |
| CHECK (challenge_type IN ('crypto','web','code-fixing','log-analysis')), | |
| state text NOT NULL DEFAULT 'waiting' | |
| CHECK (state IN ('waiting','countdown','playing','overtime','finished')), | |
| -- server-authoritative timer: | |
| started_at timestamptz, | |
| ends_at timestamptz, -- main timer end | |
| overtime_ends_at timestamptz, -- overtime end (NULL until entered) | |
| main_duration_s integer NOT NULL DEFAULT 600, -- 10 min main round | |
| overtime_duration_s integer NOT NULL DEFAULT 120, -- 2 min overtime | |
| winner_user_id uuid, -- NULL while playing | |
| win_reason text -- 'flag' | 'fix' | 'timeout' | 'overtime_draw' | |
| CHECK (win_reason IS NULL OR win_reason IN ('flag','fix','timeout','overtime_draw','abandoned')), | |
| created_at timestamptz NOT NULL DEFAULT now() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_matches_room ON public.onevone_matches (room_id); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_matches_state ON public.onevone_matches (state); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_matches_winner ON public.onevone_matches (winner_user_id); | |
| DROP POLICY IF EXISTS "Allow all access to onevone_matches" ON public.onevone_matches; | |
| CREATE POLICY "Allow all access to onevone_matches" | |
| ON public.onevone_matches FOR ALL USING (true) WITH CHECK (true); | |
| ALTER TABLE public.onevone_matches ENABLE ROW LEVEL SECURITY; | |
| -- ---------------------------------------------------------------------------- | |
| -- 1v1 submissions — append-only audit log; first correct one wins | |
| -- ---------------------------------------------------------------------------- | |
| CREATE TABLE IF NOT EXISTS public.onevone_submissions ( | |
| id uuid PRIMARY KEY DEFAULT gen_random_uuid(), | |
| match_id uuid NOT NULL REFERENCES public.onevone_matches(id) ON DELETE CASCADE, | |
| user_id uuid NOT NULL, -- FK -> auth.users.id | |
| submission text NOT NULL, -- raw user input (flag text OR fixed code / ip / etc.) | |
| is_correct boolean NOT NULL DEFAULT false, | |
| is_final boolean NOT NULL DEFAULT false, -- true => accepted, match should end | |
| received_at timestamptz NOT NULL DEFAULT now() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_sub_match ON public.onevone_submissions (match_id); | |
| CREATE INDEX IF NOT EXISTS idx_onevone_sub_correct ON public.onevone_submissions (match_id, is_correct); | |
| DROP POLICY IF EXISTS "Allow all access to onevone_submissions" ON public.onevone_submissions; | |
| CREATE POLICY "Allow all access to onevone_submissions" | |
| ON public.onevone_submissions FOR ALL USING (true) WITH CHECK (true); | |
| ALTER TABLE public.onevone_submissions ENABLE ROW LEVEL SECURITY; | |
| COMMIT; | |
| -- --------------------------------------------------------------------------- | |
| -- Atomic "claim win" RPC. | |
| -- Only ONE concurrent call can succeed. The UPDATE in PostgreSQL acquires | |
| -- a row-level lock the instant the WHERE clause matches, so two parallel | |
| -- requests for the same match_id are serialized: the loser sees ROW_COUNT=0 | |
| -- and learns who actually won. | |
| -- --------------------------------------------------------------------------- | |
| CREATE OR REPLACE FUNCTION public.onevone_claim_win( | |
| p_match_id uuid, | |
| p_user_id uuid, | |
| p_win_reason text | |
| ) RETURNS TABLE(won boolean, winner_id uuid, final_state text) | |
| LANGUAGE plpgsql | |
| AS $$ | |
| DECLARE | |
| v_winner uuid; | |
| v_state text; | |
| BEGIN | |
| UPDATE public.onevone_matches m | |
| SET state = 'finished', | |
| winner_user_id = p_user_id, | |
| win_reason = p_win_reason | |
| WHERE m.id = p_match_id | |
| AND m.state IN ('playing', 'overtime') | |
| RETURNING m.winner_user_id, m.state | |
| INTO v_winner, v_state; | |
| IF FOUND THEN | |
| RETURN QUERY SELECT true, v_winner, v_state; | |
| ELSE | |
| SELECT m.winner_user_id, m.state | |
| INTO v_winner, v_state | |
| FROM public.onevone_matches m | |
| WHERE m.id = p_match_id; | |
| RETURN QUERY SELECT false, COALESCE(v_winner, NULL::uuid), COALESCE(v_state, 'unknown'); | |
| END IF; | |
| END; | |
| $$; | |
| -- Grant execute to the anon role (same RLS posture as the rest of the API). | |
| GRANT EXECUTE ON FUNCTION public.onevone_claim_win(uuid, uuid, text) TO anon, authenticated; | |
| -- Verification: | |
| -- SELECT table_name FROM information_schema.tables | |
| -- WHERE table_schema='public' AND table_name LIKE 'onevone_%' | |
| -- ORDER BY table_name; | |
| -- Expected: onevone_matches, onevone_players, onevone_rooms, onevone_submissions | |