File size: 9,045 Bytes
a095c00
 
 
 
04fc815
a095c00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
-- 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