Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
53bf86e
bug: let round robin and swiss stages advance any next-stage size
lukepolo Sep 16, 2026
ac1d659
bug: no stand-ins in duel tournaments, add tournament substitutes set…
lukepolo Sep 16, 2026
8b50c3f
bug: count hand-granted awards on the medal leaderboard
lukepolo Sep 16, 2026
7260ba5
bug: stop orphaning on-demand match server jobs
lukepolo Sep 16, 2026
6c9c9bc
bug: stream playcast from the configured relay domain
lukepolo Sep 16, 2026
8f989cc
bug: finish played maps instead of cancelling them during the tv delay
lukepolo Sep 16, 2026
b52db12
bug: validate stages after a valve swiss by the teams that reach 3 wins
lukepolo Sep 16, 2026
c186d07
bug: count a medal tier once per tournament and stop organizers award…
lukepolo Sep 16, 2026
0b7e730
bug: send a tournament match whose map started to an organizer, not a…
lukepolo Sep 16, 2026
6f50e9f
bug: move a match off a hand-picked or wrong-region on-demand server
lukepolo Sep 16, 2026
1bc9598
bug: keep an assigned on-demand server when its boot check can't be q…
lukepolo Sep 16, 2026
9ee00af
chore: list only labelled match server jobs once no unlabelled one is…
lukepolo Sep 16, 2026
64c02d5
bug: give a short group's missing seed to the best wildcard
lukepolo Sep 16, 2026
a30e8fc
bug: seat every 3-win team after an odd valve swiss
lukepolo Sep 16, 2026
622caeb
bug: only allow turning substitutes off before registration closes
lukepolo Sep 16, 2026
f6847ec
bug: only parse the numeric playcast fields as numbers
lukepolo Sep 16, 2026
0657910
chore: skip the tournament lookup for a match without substitutes
lukepolo Sep 16, 2026
63fdf29
wip
lukepolo Sep 17, 2026
cfab9e6
test: cover a tournament stage overriding its region
lukepolo Sep 17, 2026
51ba8fa
bug: parse a fractional playcast tps as a number
lukepolo Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: "24"
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
Expand All @@ -45,7 +45,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: "24"
cache: yarn
- name: Install dependencies (pre-pulling the database image)
run: |
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
FROM node:22-alpine AS deps
FROM node:24-alpine AS deps

WORKDIR /build
COPY package*.json ./
COPY yarn.lock ./

RUN yarn install

FROM node:22-alpine AS builder
FROM node:24-alpine AS builder

WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY . .

RUN yarn build

FROM node:22-alpine
FROM node:24-alpine

WORKDIR /opt/5stack

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
FROM node:22-alpine AS deps
FROM node:24-alpine AS deps

WORKDIR /build
COPY package*.json ./
COPY yarn.lock ./

RUN yarn install

FROM node:22-alpine
FROM node:24-alpine

WORKDIR /opt/5stack

Expand Down
2 changes: 1 addition & 1 deletion codepier.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
namespaces: [5stack]
image: node:22
image: node:24
deployment: api
workdir: /opt/5stack
sync:
Expand Down
55 changes: 42 additions & 13 deletions hasura/functions/leaderboard/get_leaderboard.sql
Original file line number Diff line number Diff line change
Expand Up @@ -795,8 +795,8 @@ $$;
-- ============================================================
-- Awards leaderboard
-- value = gold count, secondary = silver count, tertiary = bronze count
-- matches_played = total medals. Olympic medal-table ordering. Only tournament
-- placements count; hand-granted awards must not move rankings.
-- matches_played = mvp count. Olympic medal-table ordering, MVP first.
-- Hand-granted awards count the same as calculated ones.
-- ============================================================
CREATE OR REPLACE FUNCTION public._leaderboard_awards(
_window_days INT,
Expand Down Expand Up @@ -825,23 +825,35 @@ BEGIN
END IF;

RETURN QUERY
WITH counts AS (
SELECT
-- A tier counts once per player per tournament, season, event or league season,
-- however often it was granted there. Only unscoped grants, which need the grant
-- role, count every row.
WITH medals AS (
SELECT DISTINCT
ar.player_steam_id,
SUM(CASE WHEN ar.placement = 0 THEN 1 ELSE 0 END)::int as mvp,
SUM(CASE WHEN ar.placement = 1 THEN 1 ELSE 0 END)::int as gold,
SUM(CASE WHEN ar.placement = 2 THEN 1 ELSE 0 END)::int as silver,
SUM(CASE WHEN ar.placement = 3 THEN 1 ELSE 0 END)::int as bronze,
COUNT(*)::int as total
medal.tier,
COALESCE(ar.tournament_id, ar.season_id, ar.event_id, ar.league_season_id, ar.id) AS occasion
FROM award_recipients ar
JOIN awards a ON a.id = ar.award_id
CROSS JOIN LATERAL (
SELECT COALESCE(ar.placement_tier, a.tier) AS tier
) medal
-- Season placements have no match_options, so a match-type filter has to
-- exclude them rather than drop them for a missing join.
LEFT JOIN tournaments t ON t.id = ar.tournament_id
LEFT JOIN match_options mo ON mo.id = t.match_options_id
LEFT JOIN seasons s ON s.id = ar.season_id
WHERE ar.player_steam_id IS NOT NULL
AND ar.placement IS NOT NULL
AND (ar.tournament_id IS NOT NULL OR ar.season_id IS NOT NULL)
AND medal.tier IN ('mvp', 'gold', 'silver', 'bronze')
AND (
ar.tournament_id IS NOT NULL
OR ar.season_id IS NOT NULL
OR (
_match_type IS NULL
AND (_from IS NULL OR ar.created_at >= _from)
AND (_to IS NULL OR ar.created_at < _to)
)
)
-- Awards hang off a tournament, so there is no matchmaking bucket: any
-- non-Overall source keeps only tournament-backed awards of that kind,
-- and Matchmaking correctly comes back empty.
Expand Down Expand Up @@ -875,7 +887,17 @@ BEGIN
)
)
)
GROUP BY ar.player_steam_id
),
counts AS (
SELECT
m.player_steam_id,
COUNT(*) FILTER (WHERE m.tier = 'mvp')::int as mvp,
COUNT(*) FILTER (WHERE m.tier = 'gold')::int as gold,
COUNT(*) FILTER (WHERE m.tier = 'silver')::int as silver,
COUNT(*) FILTER (WHERE m.tier = 'bronze')::int as bronze,
COUNT(*)::int as total
FROM medals m
GROUP BY m.player_steam_id
)
SELECT
c.player_steam_id::text as player_steam_id,
Expand Down Expand Up @@ -938,7 +960,14 @@ BEGIN
SELECT
le.player_steam_id,
le.value,
(RANK() OVER (ORDER BY le.value DESC))::int AS rank,
-- Must match web/pages/leaderboard.vue's order_by or jump-to-player opens the wrong page.
(RANK() OVER (
ORDER BY
CASE WHEN _category = 'awards' THEN le.matches_played END DESC,
le.value DESC,
CASE WHEN _category = 'awards' THEN le.secondary_value END DESC,
CASE WHEN _category = 'awards' THEN le.tertiary_value END DESC
))::int AS rank,
(COUNT(*) OVER ())::int AS total
-- Pass all 7 args explicitly. A shorter call binds ambiguously if a stale
-- overload still exists; exact arity always resolves the 7-arg one.
Expand Down
22 changes: 20 additions & 2 deletions hasura/functions/match/match_players_per_lineup.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
-- Inlined, not is_tournament_match(): functions/tournaments/ is applied after
-- this directory and LANGUAGE sql bodies are validated at create.
CREATE OR REPLACE FUNCTION public.match_max_players_per_lineup(match matches)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
SELECT get_match_type_min_players(mo.type) + COALESCE(mo.number_of_substitutes, 0)
SELECT get_match_type_min_players(mo.type) + CASE
-- A computed field on every match list row: without substitutes there is nothing to look up.
WHEN COALESCE(mo.number_of_substitutes, 0) = 0 THEN 0
WHEN EXISTS (
SELECT 1
FROM tournament_brackets tb
INNER JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id
INNER JOIN tournaments t ON t.id = ts.tournament_id
WHERE tb.match_id = match.id
AND (mo.type = 'Duel' OR NOT t.substitutes_enabled)
) THEN 0
ELSE mo.number_of_substitutes
END
FROM match_options mo
WHERE mo.id = match.match_options_id;
$$;
Expand All @@ -18,12 +32,16 @@ AS $$
WHERE mo.id = match.match_options_id;
$$;

-- Duel is checked per tournament: draft games and one-off Duels still take substitutes.
CREATE OR REPLACE FUNCTION public.tournament_max_players_per_lineup(tournament tournaments)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
SELECT get_match_type_min_players(mo.type) + COALESCE(mo.number_of_substitutes, 0)
SELECT get_match_type_min_players(mo.type) + CASE
WHEN mo.type = 'Duel' OR NOT tournament.substitutes_enabled THEN 0
ELSE COALESCE(mo.number_of_substitutes, 0)
END
FROM match_options mo
WHERE mo.id = tournament.match_options_id;
$$;
Expand Down
15 changes: 10 additions & 5 deletions hasura/functions/tournaments/check_team_eligibility.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
CREATE OR REPLACE FUNCTION public.check_team_eligibility(roster tournament_team_roster) RETURNS VOID
-- Without the drop the one-argument version survives as an ambiguous overload.
DROP FUNCTION IF EXISTS public.check_team_eligibility(tournament_team_roster);

CREATE OR REPLACE FUNCTION public.check_team_eligibility(roster tournament_team_roster, enforce_max_players boolean DEFAULT true) RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
Expand All @@ -18,10 +21,12 @@ BEGIN
RETURN;
END IF;

max_players := tournament_max_players_per_lineup(tournament);
IF enforce_max_players THEN
max_players := tournament_max_players_per_lineup(tournament);

IF roster_count > max_players THEN
RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'Roster has too many players';
IF roster_count > max_players THEN
RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'Roster has too many players';
END IF;
END IF;

min_players := tournament_min_players_per_lineup(tournament);
Expand All @@ -38,4 +43,4 @@ BEGIN
SET eligible_at = NOW()
WHERE id = roster.tournament_team_id;
END;
$$;
$$;
89 changes: 89 additions & 0 deletions hasura/functions/tournaments/get_stage_qualifier_seeds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
DROP FUNCTION IF EXISTS public.get_team_at_stage_rank(uuid, int, int);

-- Wildcards rank by win rate, not wins (uneven groups), and never head-to-head (they never met).
CREATE OR REPLACE FUNCTION public.get_stage_qualifier_seeds(
_stage_id uuid,
_seeds int
) RETURNS TABLE(seed int, tournament_team_id uuid)
LANGUAGE plpgsql STABLE
AS $$
#variable_conflict use_column
DECLARE
_groups int;
_whole_placements int;
BEGIN
SELECT GREATEST(COALESCE(ts.groups, 1), 1)
INTO _groups
FROM tournament_stages ts
WHERE ts.id = _stage_id;

_whole_placements := _seeds / _groups;

RETURN QUERY
WITH eligible AS (
SELECT
vtsr.tournament_team_id,
vtsr.group_number,
vtsr.wins,
vtsr.losses,
vtsr.maps_won,
vtsr.maps_lost,
vtsr.rounds_won,
vtsr.rounds_lost,
vtsr.team_kdr,
tt.seed AS tournament_seed,
ROW_NUMBER() OVER (
PARTITION BY vtsr.group_number
ORDER BY vtsr.rank
) AS placement
FROM v_team_stage_results vtsr
INNER JOIN tournament_teams tt
ON tt.id = vtsr.tournament_team_id
AND tt.eligible_at IS NOT NULL
WHERE vtsr.tournament_stage_id = _stage_id
),
wildcards AS (
SELECT
e.tournament_team_id,
ROW_NUMBER() OVER (
ORDER BY
e.placement,
CASE WHEN e.wins + e.losses > 0
THEN e.wins::float / (e.wins + e.losses)
ELSE 0
END DESC,
CASE WHEN e.maps_lost > 0
THEN e.maps_won::float / e.maps_lost
ELSE e.maps_won::float
END DESC,
CASE WHEN e.rounds_lost > 0
THEN e.rounds_won::float / e.rounds_lost
ELSE e.rounds_won::float
END DESC,
e.team_kdr DESC,
e.tournament_seed ASC NULLS LAST,
e.tournament_team_id ASC
) AS position
FROM eligible e
WHERE e.placement > _whole_placements
),
-- Numbered in order rather than by slot: a group short of its guaranteed places leaves its seed to the wildcards.
qualifiers AS (
SELECT 0 AS pool, (e.placement - 1) * _groups + e.group_number AS slot, e.tournament_team_id
FROM eligible e
WHERE e.placement <= _whole_placements
UNION ALL
SELECT 1, w.position, w.tournament_team_id
FROM wildcards w
),
seeded AS (
SELECT
ROW_NUMBER() OVER (ORDER BY q.pool, q.slot) AS seed,
q.tournament_team_id
FROM qualifiers q
)
SELECT s.seed::int, s.tournament_team_id
FROM seeded s
WHERE s.seed <= _seeds;
END;
$$;
18 changes: 13 additions & 5 deletions hasura/functions/tournaments/get_stage_team_counts.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,24 @@ BEGIN
DECLARE
previous_stage_type text;
previous_stage_max_teams int;
previous_stage_no_elimination boolean;
previous_stage_teams int;
BEGIN
SELECT type, max_teams INTO previous_stage_type, previous_stage_max_teams
SELECT type, max_teams, swiss_no_elimination
INTO previous_stage_type, previous_stage_max_teams, previous_stage_no_elimination
FROM tournament_stages
WHERE tournament_id = _tournament_id AND "order" = _stage_order - 1;

-- For RoundRobin stages, only the top teams advance, capped by this
-- stage's max_teams. Using the previous stage's max_teams here would
-- size brackets for every team in the RR, not just the qualifiers.
IF previous_stage_type = 'RoundRobin' THEN
IF previous_stage_type = 'RoundRobin'
OR (previous_stage_type = 'Swiss' AND previous_stage_no_elimination) THEN
effective_teams := LEAST(stage_max_teams, previous_stage_max_teams);
ELSIF previous_stage_type = 'Swiss' THEN
SELECT previous.effective_teams
INTO previous_stage_teams
FROM get_stage_team_counts(_tournament_id, _stage_order - 1, _tournament_status) previous;

-- Half the field reaches 3 wins, rounded up: an odd field's bye is a free win.
effective_teams := LEAST(stage_max_teams, CEIL(previous_stage_teams / 2.0)::int);
ELSE
-- get the number of matches from the last round of the previous stage
SELECT COUNT(*) INTO effective_teams
Expand Down
39 changes: 0 additions & 39 deletions hasura/functions/tournaments/get_team_at_stage_rank.sql

This file was deleted.

Loading
Loading