From 6e34041c5decbb35df84381826ef5ac72f8a235f Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 15 Jun 2026 22:24:49 +0200 Subject: [PATCH 01/34] Add token --- .customizations/setup_repos.py | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .customizations/setup_repos.py diff --git a/.customizations/setup_repos.py b/.customizations/setup_repos.py new file mode 100644 index 000000000..c38c76576 --- /dev/null +++ b/.customizations/setup_repos.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +BASE_URL = "ssh://ssh.sourcecraft.dev/universitybattle" +TMP_ROOT = Path(tempfile.gettempdir()) / "tower-repos" + +REPOS = [ + (1167, "fQRz3EVmbx-9K0-H0FDiUwBDUEn85eyW0G2Fh8Qyiis"), + (167, "uligelAoj-EJA54qnnXg9v7KAn__vVsYTm-an1KM5cY"), + (1175, "gubpUo3pUsS0m3PPcLYmvAVjoF1fOGxjHm-t6WffNT0"), + (1174, "WdFhXEbFgrAw6oVGopPoBVHVOH_lsuCjVcfqt2C_xYE"), + (1416, "5sy7gUusY4Fs8zScZ52RhaO4Ne0-PLKWGQqYS5ec8gc"), + (12656, "GgqBmlT5cOKXBojh6vBa83L_9djAc3S2T_UxenKwe0U"), + (9828, "RylHgDyaBXzM568gzUvDbGDyfE5Ml885A1vsYxG_jWY"), + (1172, "_RgNYPzhJL7Z36gNxLKpSs23xnkeDguiKk9sjzgEeJo"), + (8256, "bIabtEIoNRfzGU5hqmWRdsXs6LukoRLDbWnKrMr_8mY"), + (2909, "oF6s1zBTxXOxFQP0ynvy2AFydMkPV4TZbc8bss3hnc8"), + (801, "z84y49ppe36-1VF_kXaQbOmF9beZtBb4Lm91Xo8RW-c"), + (8208, "1oVSBWzUkUibUx9XYzpyJpcY3_VXSmbkY5iiRBOl40w"), + (12848, "w49EP7kh41YLbaWdnJSGlZ-tZ-5xnQsWemnvmdhOg7Q"), + (1378, "kE_9cjU0qQ6OdknFxwgdkqsfX-CU7WRP9mIqvAZx7vg"), + (135, "uHO-EuaroUX1hLQwVWhBjOSxDngcFOK2Ao49378o7Qs"), +] + + +def run(cmd: list[str], cwd: Path | None = None) -> None: + subprocess.run(cmd, cwd=cwd, check=True) + + +def main() -> None: + TMP_ROOT.mkdir(parents=True, exist_ok=True) + + for repo_id, token in REPOS: + repo_dir = TMP_ROOT / f"tower-{repo_id}" + repo_url = f"{BASE_URL}/tower-{repo_id}.git" + + if repo_dir.exists(): + shutil.rmtree(repo_dir) + + # Clone each repo into a clean temporary directory, then write the + # token locally so it never gets staged, committed, or pushed. + run(["git", "clone", repo_url, str(repo_dir)]) + + # The token is intentionally kept only in the working tree copy. + (repo_dir / ".env").write_text( + f"CODEBATTLE_AUTH_TOKEN={token}\n", + encoding="utf-8", + ) + + run(["git", "add", "."]) + run(["git", "commit", "-m", "Add token"]) + run(["git", "push"]) + + print(f"prepared {repo_dir}") + + print(f"done: {TMP_ROOT}") + + +if __name__ == "__main__": + main() From 65d669fcb6a4d1b8cbc2346e28c10096c6791cc2 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 16 Jun 2026 00:41:29 +0200 Subject: [PATCH 02/34] improve --- .../lib/codebattle/group_task/context.ex | 15 +++- .../codebattle/group_tournament/context.ex | 69 +++++++++++---- .../lib/codebattle/group_tournament/server.ex | 18 ++-- .../group_tournament/slice_runner.ex | 88 +++++++++++++++++-- .../group_task_solution_post_submit_worker.ex | 31 +++++++ 5 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 apps/codebattle/lib/codebattle/workers/group_task_solution_post_submit_worker.ex diff --git a/apps/codebattle/lib/codebattle/group_task/context.ex b/apps/codebattle/lib/codebattle/group_task/context.ex index 0b93186f4..01e827f77 100644 --- a/apps/codebattle/lib/codebattle/group_task/context.ex +++ b/apps/codebattle/lib/codebattle/group_task/context.ex @@ -415,7 +415,8 @@ defmodule Codebattle.GroupTask.Context do defp build_run_payload(%GroupTask{} = group_task, player_ids, attrs) do latest_solutions = do_list_latest_solutions(group_task.id, player_ids, - group_tournament_id: Map.get(attrs, :group_tournament_id) || Map.get(attrs, "group_tournament_id") + group_tournament_id: Map.get(attrs, :group_tournament_id) || Map.get(attrs, "group_tournament_id"), + before: Map.get(attrs, :solutions_before) || Map.get(attrs, "solutions_before") ) solution_user_ids = MapSet.new(Enum.map(latest_solutions, & &1.user_id)) @@ -502,10 +503,12 @@ defmodule Codebattle.GroupTask.Context do defp do_list_latest_solutions(group_task_id, player_ids, opts) do group_tournament_id = Keyword.get(opts, :group_tournament_id) + before = Keyword.get(opts, :before) GroupTaskSolution |> where([solution], solution.group_task_id == ^group_task_id and solution.user_id in ^player_ids) |> maybe_filter_by_group_tournament(group_tournament_id) + |> maybe_filter_by_inserted_before(before) |> preload(:user) |> distinct([solution], solution.user_id) |> order_by([solution], asc: solution.user_id, desc: solution.id) @@ -518,6 +521,16 @@ defmodule Codebattle.GroupTask.Context do where(query, [solution], solution.group_tournament_id == ^group_tournament_id) end + defp maybe_filter_by_inserted_before(query, nil), do: query + + defp maybe_filter_by_inserted_before(query, %NaiveDateTime{} = cutoff) do + where(query, [solution], solution.inserted_at <= ^cutoff) + end + + defp maybe_filter_by_inserted_before(query, %DateTime{} = cutoff) do + where(query, [solution], solution.inserted_at <= ^DateTime.to_naive(cutoff)) + end + defp normalize_player_ids(player_ids) do player_ids |> Enum.filter(&(is_integer(&1) and &1 > 0)) diff --git a/apps/codebattle/lib/codebattle/group_tournament/context.ex b/apps/codebattle/lib/codebattle/group_tournament/context.ex index 0915b52c7..03b3edc5b 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/context.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/context.ex @@ -8,6 +8,7 @@ defmodule Codebattle.GroupTournament.Context do alias Codebattle.GroupTournament alias Codebattle.GroupTournament.GlobalSupervisor alias Codebattle.GroupTournament.Server + alias Codebattle.GroupTournament.SliceRunner alias Codebattle.GroupTournamentPlayer alias Codebattle.GroupTournamentRoundScore alias Codebattle.Repo @@ -459,7 +460,7 @@ defmodule Codebattle.GroupTournament.Context do }) |> case do {:ok, solution} -> - maybe_run_after_solution_submission(solution.group_tournament_id, solution, async: true) + enqueue_post_submit(solution) {:ok, solution} {:error, _} = error -> @@ -471,6 +472,12 @@ defmodule Codebattle.GroupTournament.Context do end end + defp enqueue_post_submit(%GroupTaskSolution{id: solution_id}) do + %{solution_id: solution_id} + |> Codebattle.Workers.GroupTaskSolutionPostSubmitWorker.new() + |> Oban.insert() + end + @spec get_current(pos_integer()) :: GroupTournament.t() | nil def get_current(id) do case Server.get_group_tournament(id) do @@ -579,31 +586,55 @@ defmodule Codebattle.GroupTournament.Context do def maybe_run_after_solution_submission(nil, _submitted_solution, _opts), do: :ok - def maybe_run_after_solution_submission(group_tournament_id, submitted_solution, opts) do - case get_group_tournament(group_tournament_id) do - %{state: "active", group_task: group_task, include_bots: include_bots} = gt -> - attrs = %{ - group_tournament_id: group_tournament_id, - include_bots: include_bots, - round: gt.current_round_position || 1 - } - - # run_group_task[_async] emits per-user "run_updated" broadcasts — - # the async variant inserts a pending row and broadcasts "pending" - # synchronously, then runs the runner step in an Oban job. - if Keyword.get(opts, :async, false) do - GroupTaskContext.run_group_task_async(group_task, [submitted_solution.user_id], attrs) - else - GroupTaskContext.run_group_task(group_task, [submitted_solution.user_id], attrs) - end + def maybe_run_after_solution_submission(_group_tournament_id, nil, _opts), do: :ok - :ok + def maybe_run_after_solution_submission(group_tournament_id, submitted_solution, _opts) do + case get_group_tournament(group_tournament_id) do + %{state: "active"} = gt -> + run_per_submission_preview(gt, submitted_solution) _ -> :ok end end + # Per-submission preview: shows the submitter how their new code stacks up + # right now. Runs ALWAYS bypass tournament scoring — official scoring is + # the round-end slice run (see SliceRunner.run_all_slices). + # + # * Seed round (ranked + has_seed_round + round 1): solo vs bots so the + # player can iterate on a baseline. + # * Slice round (ranked, slice already assigned): run the whole slice + # using everyone's latest solution — head-to-head preview within the + # slice. + # * Anything else (non-ranked, or ranked without a slice yet): solo run + # using the tournament's `include_bots` setting. + defp run_per_submission_preview(gt, %{user_id: user_id} = _submitted_solution) do + cond do + GroupTournament.seeding_round?(gt) -> + run_solo_preview(gt, user_id, _include_bots = true) + + GroupTournament.ranked?(gt) -> + case SliceRunner.run_slice_preview(gt, user_id) do + :no_slice -> run_solo_preview(gt, user_id, gt.include_bots) + _ -> :ok + end + + true -> + run_solo_preview(gt, user_id, gt.include_bots) + end + + :ok + end + + defp run_solo_preview(%GroupTournament{} = gt, user_id, include_bots) do + GroupTaskContext.run_group_task(gt.group_task, [user_id], %{ + group_tournament_id: gt.id, + include_bots: include_bots, + round: gt.current_round_position || 1 + }) + end + def serialize_group_tournament(%GroupTournament{} = group_tournament) do group_tournament |> Map.take([ diff --git a/apps/codebattle/lib/codebattle/group_tournament/server.ex b/apps/codebattle/lib/codebattle/group_tournament/server.ex index 0387c82b0..5309ffb1c 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/server.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/server.ex @@ -366,7 +366,11 @@ defmodule Codebattle.GroupTournament.Server do end def handle_info(:finish_round, %{group_tournament: %{state: "active"} = group_tournament} = state) do - {slice_results, round_results} = run_round(group_tournament) + # Freeze the submission window at round-finish time. Any submission that + # lands after this timestamp is rolled into the next round, not this one. + solutions_cutoff = NaiveDateTime.utc_now(:second) + + {slice_results, round_results} = run_round(group_tournament, solutions_before: solutions_cutoff) log_slice_results(group_tournament, slice_results) @@ -500,19 +504,21 @@ defmodule Codebattle.GroupTournament.Server do end # `run_round` returns the slice-runner results AND the round_results array - # used by movement. Returns `[]` for the seeding round. - defp run_round(%GroupTournament{} = t) do + # used by movement. Returns `[]` for the seeding round. `opts` carries + # `:solutions_before` so submissions arriving after round-finish trigger + # are excluded from this round's runs. + defp run_round(%GroupTournament{} = t, opts) do cond do GroupTournament.seeding_round?(t) -> # Seeding round end: run each player's latest submission solo, no # bots (parallel pool inside SliceRunner) to produce seed scores # plus submission durations. No round_results — slicing happens in # apply_post_round_transitions. - SliceRunner.run_seeding(t) + SliceRunner.run_seeding(t, opts) {[], []} GroupTournament.ranked?(t) -> - slice_results = SliceRunner.run_all_slices(t) + slice_results = SliceRunner.run_all_slices(t, opts) round_results = Enum.flat_map(slice_results, fn @@ -523,7 +529,7 @@ defmodule Codebattle.GroupTournament.Server do {slice_results, round_results} true -> - slice_results = SliceRunner.run_all_slices(t) + slice_results = SliceRunner.run_all_slices(t, opts) {slice_results, []} end end diff --git a/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex b/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex index aad530bfe..c41e83c83 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex @@ -87,6 +87,61 @@ defmodule Codebattle.GroupTournament.SliceRunner do end) end + defp solutions_before_opt(opts), do: Keyword.get(opts, :solutions_before) + + @doc """ + Per-submission preview against the submitter's slice. Runs every + slice-mate's latest solution together with the submitter's so the + submitter sees how their new submission ranks within their slice in + real time. + + Returns `:no_slice` if the player has no `slice_index` yet — caller + should fall back to a solo run. + + Tournament scoring is intentionally skipped: this passes the run with + `slice_index: nil` so `apply_tournament_scoring` no-ops for ranked + tournaments. Official scoring still comes from `run_all_slices/2` at + round-end. + """ + @spec run_slice_preview(GroupTournament.t(), pos_integer()) :: + :ok | :skipped | :no_slice | {:error, term()} + def run_slice_preview(%GroupTournament{} = group_tournament, user_id) do + case get_player_slice_index(group_tournament.id, user_id) do + nil -> :no_slice + slice_index -> do_run_slice_preview(group_tournament, slice_index) + end + end + + defp do_run_slice_preview(%GroupTournament{} = group_tournament, slice_index) do + player_ids = list_slice_player_ids(group_tournament.id, slice_index) + submitted_ids = list_player_ids_with_solution(group_tournament, player_ids, nil) + + case submitted_ids do + [] -> + :skipped + + ids -> + result = + GroupTaskContext.run_group_task(group_tournament.group_task, ids, %{ + group_tournament_id: group_tournament.id, + include_bots: include_bots_for_slice?(group_tournament, ids, slice_index), + round: group_tournament.current_round_position || 1 + }) + + case result do + {:ok, _run} -> :ok + {:error, _} = err -> err + end + end + end + + defp get_player_slice_index(group_tournament_id, user_id) do + GroupTournamentPlayer + |> where([p], p.group_tournament_id == ^group_tournament_id and p.user_id == ^user_id) + |> select([p], p.slice_index) + |> Repo.one() + end + defp run_slice_with_results(group_tournament, slice_index, opts) do case run_slice(group_tournament, slice_index, opts) do {:ok, round_results} -> {slice_index, :ok, round_results} @@ -97,9 +152,10 @@ defmodule Codebattle.GroupTournament.SliceRunner do @spec run_slice(GroupTournament.t(), non_neg_integer(), keyword()) :: {:ok, list(map())} | :skipped | {:error, term()} - def run_slice(%GroupTournament{} = group_tournament, slice_index, _opts \\ []) do + def run_slice(%GroupTournament{} = group_tournament, slice_index, opts \\ []) do + cutoff = solutions_before_opt(opts) player_ids = list_slice_player_ids(group_tournament.id, slice_index) - submitted_ids = list_player_ids_with_solution(group_tournament, player_ids) + submitted_ids = list_player_ids_with_solution(group_tournament, player_ids, cutoff) case submitted_ids do [] -> @@ -112,7 +168,8 @@ defmodule Codebattle.GroupTournament.SliceRunner do include_bots: include_bots_for_slice?(group_tournament, ids, slice_index), slice_index: slice_index, kind: :slice, - round: group_tournament.current_round_position || 1 + round: group_tournament.current_round_position || 1, + solutions_before: cutoff }) # run_group_task now broadcasts a per-user "run_updated" event for @@ -139,14 +196,15 @@ defmodule Codebattle.GroupTournament.SliceRunner do @spec run_seeding(GroupTournament.t(), keyword()) :: [{integer(), :ok | :skipped | {:error, term()}}] def run_seeding(%GroupTournament{} = group_tournament, opts \\ []) do + cutoff = solutions_before_opt(opts) player_ids = list_active_player_ids(group_tournament.id) max_concurrency = Keyword.get(opts, :max_concurrency, configured_max_concurrency()) - submitted_ids = list_player_ids_with_solution(group_tournament, player_ids) + submitted_ids = list_player_ids_with_solution(group_tournament, player_ids, cutoff) submitted_ids |> Task.async_stream( - fn user_id -> {user_id, run_seed_for_player(group_tournament, user_id)} end, + fn user_id -> {user_id, run_seed_for_player(group_tournament, user_id, cutoff)} end, max_concurrency: max_concurrency, timeout: @slice_run_task_timeout_ms, on_timeout: :kill_task, @@ -315,13 +373,14 @@ defmodule Codebattle.GroupTournament.SliceRunner do end) end - defp run_seed_for_player(%GroupTournament{} = group_tournament, user_id) do + defp run_seed_for_player(%GroupTournament{} = group_tournament, user_id, cutoff) do result = GroupTaskContext.run_group_task(group_tournament.group_task, [user_id], %{ group_tournament_id: group_tournament.id, include_bots: false, kind: :seed, - round: 1 + round: 1, + solutions_before: cutoff }) case result do @@ -429,9 +488,9 @@ defmodule Codebattle.GroupTournament.SliceRunner do Application.get_env(:codebattle, :group_tournament_slice_run_concurrency, @default_max_concurrency) end - defp list_player_ids_with_solution(_group_tournament, []), do: [] + defp list_player_ids_with_solution(_group_tournament, [], _cutoff), do: [] - defp list_player_ids_with_solution(%GroupTournament{} = group_tournament, player_ids) do + defp list_player_ids_with_solution(%GroupTournament{} = group_tournament, player_ids, cutoff) do GroupTaskSolution |> where( [s], @@ -439,8 +498,19 @@ defmodule Codebattle.GroupTournament.SliceRunner do s.group_tournament_id == ^group_tournament.id and s.user_id in ^player_ids ) + |> maybe_filter_solutions_before(cutoff) |> distinct([s], s.user_id) |> select([s], s.user_id) |> Repo.all() end + + defp maybe_filter_solutions_before(query, nil), do: query + + defp maybe_filter_solutions_before(query, %NaiveDateTime{} = cutoff) do + where(query, [s], s.inserted_at <= ^cutoff) + end + + defp maybe_filter_solutions_before(query, %DateTime{} = cutoff) do + where(query, [s], s.inserted_at <= ^DateTime.to_naive(cutoff)) + end end diff --git a/apps/codebattle/lib/codebattle/workers/group_task_solution_post_submit_worker.ex b/apps/codebattle/lib/codebattle/workers/group_task_solution_post_submit_worker.ex new file mode 100644 index 000000000..f0968e9fb --- /dev/null +++ b/apps/codebattle/lib/codebattle/workers/group_task_solution_post_submit_worker.ex @@ -0,0 +1,31 @@ +defmodule Codebattle.Workers.GroupTaskSolutionPostSubmitWorker do + @moduledoc """ + Runs the post-submit pipeline for a group task solution off the request + path. The HTTP API inserts the `group_task_solutions` row, enqueues this + worker, and returns 201 immediately; the worker loads the solution and + drives `maybe_run_after_solution_submission/3` synchronously (pending + runs insert, "pending" broadcast, runner call, "finished" broadcast). + """ + + use Oban.Worker, max_attempts: 3 + + alias Codebattle.GroupTaskSolution + alias Codebattle.GroupTournament.Context, as: GroupTournamentContext + alias Codebattle.Repo + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"solution_id" => solution_id}}) do + case Repo.get(GroupTaskSolution, solution_id) do + nil -> + :ok + + solution -> + GroupTournamentContext.maybe_run_after_solution_submission( + solution.group_tournament_id, + solution + ) + + :ok + end + end +end From b9457bc84094bcab0491875affc428b4de500828 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 16 Jun 2026 11:28:16 +0200 Subject: [PATCH 03/34] Fix --- .../lib/codebattle/group_task/context.ex | 3 +- .../lib/codebattle/group_tournament/server.ex | 13 +- .../group_tournament/slice_runner.ex | 114 +++++++++++------- .../group_tournament/integration_test.exs | 23 ++++ 4 files changed, 102 insertions(+), 51 deletions(-) diff --git a/apps/codebattle/lib/codebattle/group_task/context.ex b/apps/codebattle/lib/codebattle/group_task/context.ex index 01e827f77..6181b568e 100644 --- a/apps/codebattle/lib/codebattle/group_task/context.ex +++ b/apps/codebattle/lib/codebattle/group_task/context.ex @@ -734,8 +734,7 @@ defmodule Codebattle.GroupTask.Context do defp submission_duration_ms(_, _), do: nil - defp round_position_for_run(%GroupTournament{current_round_position: pos}, kind) - when kind in ["slice", "seed"] and is_integer(pos), do: pos + defp round_position_for_run(%GroupTournament{current_round_position: pos}, _kind) when is_integer(pos), do: pos defp round_position_for_run(_tournament, _kind), do: nil diff --git a/apps/codebattle/lib/codebattle/group_tournament/server.ex b/apps/codebattle/lib/codebattle/group_tournament/server.ex index 5309ffb1c..20085e57c 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/server.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/server.ex @@ -608,17 +608,20 @@ defmodule Codebattle.GroupTournament.Server do end end - # Per-submission debug run: only the submitting player's solution is executed - # — never with bots — so the user can see their own output. The run is left - # untagged (slice_index nil). Scored runs fire on the round timer and carry - # the slice_index. + # Per-submission debug run: only the submitting player's solution is + # executed. In the seed round we deliberately include bots — the seed + # round IS the bot fight, and the score the player earns vs. bots here + # becomes their official seed score at round end (see + # `SliceRunner.run_seeding/2`). Outside the seed round the player runs + # solo so they see just their own output. Run is left untagged + # (slice_index nil); scored slice runs fire on the round timer. defp run_user_submission_sync(%GroupTournament{state: "active"} = group_tournament, submitted_solution) when not is_nil(submitted_solution) do # run_group_task itself emits per-user "run_updated" broadcasts (one # pending, one finished), so we don't broadcast again here. GroupTaskContext.run_group_task(group_tournament.group_task, [submitted_solution.user_id], %{ group_tournament_id: group_tournament.id, - include_bots: false, + include_bots: GroupTournament.seeding_round?(group_tournament), round: group_tournament.current_round_position || 1 }) diff --git a/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex b/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex index c41e83c83..bd8766c6e 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/slice_runner.ex @@ -20,9 +20,12 @@ defmodule Codebattle.GroupTournament.SliceRunner do For ranked (`type == "ranked"`) tournaments this module additionally provides: - * `run_seeding/1` — fans out one solo run per player (no bots) so each - player's baseline score and submission duration can be persisted - before slice assignment. + * `run_seeding/2` — reads each active player's latest successful + preview run for round 1 and persists its score / submission duration + as `seed_score` / `seed_duration_ms`. The seed round IS the bot + fight: the score the player earned against bots during the round + becomes the official seed score, with no extra runner work at + round end. * `apply_movement/2` — takes the round's per-player results and applies the tournament's configured movement strategy to update `slice_index` in a single transaction. @@ -37,6 +40,7 @@ defmodule Codebattle.GroupTournament.SliceRunner do alias Codebattle.GroupTournamentPlayer alias Codebattle.GroupTournamentRoundScore alias Codebattle.Repo + alias Codebattle.UserGroupTournamentRun require Logger @@ -186,36 +190,79 @@ defmodule Codebattle.GroupTournament.SliceRunner do end @doc """ - Runs the seeding round for a ranked tournament: each active player runs solo - (no bots) so we can persist their baseline score and submission duration. - Duration is measured from `tournament.started_at` to - `solution.inserted_at` — see `Codebattle.GroupTask.Context`. This is what - drives the initial slice assignment (via `assign_slices/1` with strategy - `"rating"`). + Closes the seed round: for each active player, reads their latest + successful preview run from round 1 and persists its `score` / + `duration_ms` as the player's `seed_score` / `seed_duration_ms`. The + seed round IS the bot fight — the score earned against bots during + the round becomes the official seed score, no re-run needed. + + Duration was already computed by `Codebattle.GroupTask.Context` when + the preview run was finalized (`tournament.started_at` → + `solution.inserted_at`), so we just copy it onto the player row. + + Active players with no successful run for round 1 (never submitted, + or only had failed/pending runs) are skipped — their `seed_score` + stays nil and `assign_slices/1` won't place them. + + `opts`: + * `:solutions_before` — `NaiveDateTime` / `DateTime` cutoff. Runs + inserted after the cutoff are ignored, matching the round-finish + fairness window used by slice rounds. + + Returns `[{user_id, :ok | :skipped}]` for every active player. """ @spec run_seeding(GroupTournament.t(), keyword()) :: - [{integer(), :ok | :skipped | {:error, term()}}] + [{integer(), :ok | :skipped}] | [{:unknown, {:error, term()}}] def run_seeding(%GroupTournament{} = group_tournament, opts \\ []) do cutoff = solutions_before_opt(opts) player_ids = list_active_player_ids(group_tournament.id) - max_concurrency = Keyword.get(opts, :max_concurrency, configured_max_concurrency()) + latest_runs = list_latest_seed_runs(group_tournament.id, player_ids, cutoff) - submitted_ids = list_player_ids_with_solution(group_tournament, player_ids, cutoff) + case Repo.transaction(fn -> apply_seed_results(group_tournament.id, player_ids, latest_runs) end) do + {:ok, results} -> results + {:error, reason} -> [{:unknown, {:error, reason}}] + end + end - submitted_ids - |> Task.async_stream( - fn user_id -> {user_id, run_seed_for_player(group_tournament, user_id, cutoff)} end, - max_concurrency: max_concurrency, - timeout: @slice_run_task_timeout_ms, - on_timeout: :kill_task, - ordered: false + defp apply_seed_results(group_tournament_id, player_ids, latest_runs) do + Enum.map(player_ids, &apply_seed_result(group_tournament_id, &1, Map.get(latest_runs, &1))) + end + + defp apply_seed_result(_group_tournament_id, user_id, nil), do: {user_id, :skipped} + + defp apply_seed_result(group_tournament_id, user_id, %{score: score, duration_ms: duration_ms}) do + persist_seed(group_tournament_id, user_id, score, duration_ms) + {user_id, :ok} + end + + defp list_latest_seed_runs(_group_tournament_id, [], _cutoff), do: %{} + + defp list_latest_seed_runs(group_tournament_id, player_ids, cutoff) do + UserGroupTournamentRun + |> join(:inner, [r], ugt in assoc(r, :user_group_tournament)) + |> where( + [r, ugt], + ugt.group_tournament_id == ^group_tournament_id and + ugt.user_id in ^player_ids and + r.round_position == 1 and + r.status == "success" and + not is_nil(r.score) ) - |> Enum.map(fn - {:ok, result} -> result - {:exit, reason} -> {:unknown, {:error, {:exit, reason}}} - end) + |> maybe_filter_runs_before(cutoff) + |> order_by([r, ugt], asc: ugt.user_id, desc: r.inserted_at, desc: r.id) + |> distinct([_r, ugt], ugt.user_id) + |> select([r, ugt], {ugt.user_id, %{score: r.score, duration_ms: r.duration_ms}}) + |> Repo.all() + |> Map.new() end + defp maybe_filter_runs_before(query, nil), do: query + + defp maybe_filter_runs_before(query, %NaiveDateTime{} = cutoff), do: where(query, [r, _ugt], r.inserted_at <= ^cutoff) + + defp maybe_filter_runs_before(query, %DateTime{} = cutoff), + do: where(query, [r, _ugt], r.inserted_at <= ^DateTime.to_naive(cutoff)) + @doc """ Persists the seed-round (round 1) score row for each seeded player, capturing the slice they were assigned to during seeding. @@ -373,27 +420,6 @@ defmodule Codebattle.GroupTournament.SliceRunner do end) end - defp run_seed_for_player(%GroupTournament{} = group_tournament, user_id, cutoff) do - result = - GroupTaskContext.run_group_task(group_tournament.group_task, [user_id], %{ - group_tournament_id: group_tournament.id, - include_bots: false, - kind: :seed, - round: 1, - solutions_before: cutoff - }) - - case result do - {:ok, run} -> - persist_seed(group_tournament.id, user_id, run.score, run.duration_ms) - # run_group_task already emits a per-user "run_updated" broadcast. - :ok - - {:error, _} = err -> - err - end - end - defp persist_seed(group_tournament_id, user_id, score, duration_ms) do GroupTournamentPlayer |> where([p], p.group_tournament_id == ^group_tournament_id and p.user_id == ^user_id) diff --git a/apps/codebattle/test/codebattle/group_tournament/integration_test.exs b/apps/codebattle/test/codebattle/group_tournament/integration_test.exs index 96e437e19..8191fe82e 100644 --- a/apps/codebattle/test/codebattle/group_tournament/integration_test.exs +++ b/apps/codebattle/test/codebattle/group_tournament/integration_test.exs @@ -38,7 +38,12 @@ defmodule Codebattle.GroupTournament.IntegrationTest do put_scores(seed_scores) # === ROUND 1: SEEDING === + # Seed scoring no longer re-runs at round end — it reads each player's + # latest preview run for round 1. Materialise those rows by running a + # preview per player against the deterministic runner. tournament = set_round(tournament, 1) + simulate_seed_previews(tournament, players) + seed_results = SliceRunner.run_seeding(tournament) ok_count = Enum.count(seed_results, fn {_uid, status} -> status == :ok end) assert ok_count == 16 @@ -171,6 +176,7 @@ defmodule Codebattle.GroupTournament.IntegrationTest do put_scores(seed_scores) tournament = set_round(tournament, 1) + simulate_seed_previews(tournament, players) _ = SliceRunner.run_seeding(tournament) tournament_for_seed = %{tournament | slice_strategy: "rating", slice_count: 4} {:ok, _} = SliceRunner.assign_slices(tournament_for_seed) @@ -471,6 +477,23 @@ defmodule Codebattle.GroupTournament.IntegrationTest do |> Repo.preload([:group_task]) end + # Produce a `UserGroupTournamentRun` per player for the current round so + # `SliceRunner.run_seeding/2` (which now reads instead of re-runs) has + # something to pick up. Scores come from `put_scores`. + defp simulate_seed_previews(%GroupTournament{} = tournament, players) do + tournament = Repo.preload(tournament, :group_task) + + Enum.each(players, fn p -> + {:ok, _} = + GroupTaskContext.run_group_task(tournament.group_task, [p.user_id], %{ + group_tournament_id: tournament.id, + include_bots: true + }) + end) + + tournament + end + defp reload_tournament(%GroupTournament{id: id}) do GroupTournament |> Repo.get!(id) From 53fec83fceed4dd405a64127a50377784281841c Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 17 Jun 2026 15:27:49 +0200 Subject: [PATCH 04/34] Add tournaments socket --- .../channels/streamer_socket.ex | 35 +++ .../channels/tournament_streamer_channel.ex | 215 ++++++++++++++++++ .../codebattle/lib/codebattle_web/endpoint.ex | 2 + .../channels/streamer_socket_test.exs | 34 +++ .../tournament_streamer_channel_test.exs | 178 +++++++++++++++ docs/009-streamer-websocket.md | 194 ++++++++++++++++ 6 files changed, 658 insertions(+) create mode 100644 apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex create mode 100644 apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex create mode 100644 apps/codebattle/test/codebattle_web/channels/streamer_socket_test.exs create mode 100644 apps/codebattle/test/codebattle_web/channels/tournament_streamer_channel_test.exs create mode 100644 docs/009-streamer-websocket.md diff --git a/apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex b/apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex new file mode 100644 index 000000000..8017099ee --- /dev/null +++ b/apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex @@ -0,0 +1,35 @@ +defmodule CodebattleWeb.StreamerSocket do + @moduledoc false + use Phoenix.Socket + + channel("tournament_streamer", CodebattleWeb.TournamentStreamerChannel) + + def connect(%{"token" => token, "tournament_id" => raw_id}, socket) do + api_key = Application.get_env(:codebattle, :api_key) + + with true <- is_binary(api_key) and api_key != "" and api_key == token, + {:ok, tournament_id} <- parse_tournament_id(raw_id) do + {:ok, + socket + |> assign(:streamer?, true) + |> assign(:tournament_id, tournament_id)} + else + _ -> :error + end + end + + def connect(_params, _socket), do: :error + + def id(_socket), do: nil + + defp parse_tournament_id(id) when is_integer(id) and id > 0, do: {:ok, id} + + defp parse_tournament_id(id) when is_binary(id) do + case Integer.parse(id) do + {n, ""} when n > 0 -> {:ok, n} + _ -> :error + end + end + + defp parse_tournament_id(_), do: :error +end diff --git a/apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex b/apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex new file mode 100644 index 000000000..0a213fb89 --- /dev/null +++ b/apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex @@ -0,0 +1,215 @@ +defmodule CodebattleWeb.TournamentStreamerChannel do + @moduledoc false + use CodebattleWeb, :channel + + alias Codebattle.Game.Context, as: GameContext + alias Codebattle.Tournament + alias Codebattle.Tournament.Helpers + alias CodebattleWeb.TournamentAdminChannel + + require Logger + + def join("tournament_streamer", _payload, socket) do + with true <- socket.assigns[:streamer?] == true, + tournament_id when not is_nil(tournament_id) <- socket.assigns[:tournament_id], + tournament when not is_nil(tournament) <- Tournament.Context.get(tournament_id) do + Codebattle.PubSub.subscribe("tournament:#{tournament.id}:stream") + Codebattle.PubSub.subscribe("tournament:#{tournament.id}:common") + Codebattle.PubSub.subscribe("tournament:#{tournament.id}") + Codebattle.PubSub.subscribe("game:tournament:#{tournament.id}") + + active_game_id = + TournamentAdminChannel.get_active_game(tournament.id) || + first_playing_game_id(tournament) + + active_game = active_game_id && fetch_active_game(active_game_id) + + if active_game, do: Codebattle.PubSub.subscribe("game:#{active_game.id}") + + socket = assign(socket, :active_game_id, active_game && active_game.id) + + {:ok, + %{ + tournament: tournament_state(tournament), + active_game: active_game && render_active_game(active_game) + }, socket} + else + false -> {:error, %{reason: "unauthorized"}} + nil -> {:error, %{reason: "not_found"}} + end + end + + # Streamers don't push anything to the server. + def handle_in(_topic, _payload, socket), do: {:noreply, socket} + + # Admin selected a new active game — re-subscribe and push fresh game info. + def handle_info(%{event: "tournament:stream:active_game", payload: %{game_id: game_id}}, socket) do + socket = switch_active_game(socket, game_id) + {:noreply, socket} + end + + # Any game in this tournament finished — push winner summary. + def handle_info(%{event: "game:tournament:finished", payload: payload}, socket) do + push(socket, "tournament:game:finished", %{ + game_id: payload.game_id, + task_id: payload.task_id, + game_state: payload.game_state, + game_level: payload.game_level, + duration_sec: payload.duration_sec, + player_results: payload.player_results + }) + + {:noreply, socket} + end + + # Test-run results changed on the currently active game. + def handle_info(%{event: "game:check_completed", payload: payload}, socket) do + if payload.game_id == socket.assigns[:active_game_id] do + push(socket, "active_game:check_result", payload) + end + + {:noreply, socket} + end + + # Active game finished (push full payload + game-level player results). + def handle_info(%{event: "game:finished", payload: %{game_id: game_id, game_state: state}}, socket) do + if game_id == socket.assigns[:active_game_id] do + case GameContext.fetch_game(game_id) do + {:ok, game} -> + push(socket, "active_game:finished", %{ + game_id: game_id, + game_state: state, + players: render_players(game) + }) + + _ -> + push(socket, "active_game:finished", %{game_id: game_id, game_state: state}) + end + end + + {:noreply, socket} + end + + # Tournament-level lifecycle events — forward as compact state updates. + def handle_info(%{event: event, payload: %{tournament: tournament}}, socket) + when event in [ + "tournament:updated", + "tournament:round_created", + "tournament:round_finished", + "tournament:finished", + "tournament:restarted" + ] do + push(socket, event, %{tournament: tournament_state(tournament)}) + {:noreply, socket} + end + + def handle_info(message, socket) do + Logger.debug("Skip streamer message: " <> inspect(message)) + {:noreply, socket} + end + + defp switch_active_game(socket, nil), do: socket + + defp switch_active_game(%{assigns: %{active_game_id: same}} = socket, same), do: socket + + defp switch_active_game(socket, new_game_id) do + if old = socket.assigns[:active_game_id] do + Codebattle.PubSub.unsubscribe("game:#{old}") + end + + case fetch_active_game(new_game_id) do + nil -> + push(socket, "active_game:set", %{game_id: new_game_id, game: nil}) + assign(socket, :active_game_id, new_game_id) + + game -> + Codebattle.PubSub.subscribe("game:#{game.id}") + push(socket, "active_game:set", %{game_id: game.id, game: render_active_game(game)}) + assign(socket, :active_game_id, game.id) + end + end + + defp fetch_active_game(nil), do: nil + + defp fetch_active_game(game_id) do + case GameContext.fetch_game(game_id) do + {:ok, game} -> game + _ -> nil + end + end + + defp first_playing_game_id(tournament) do + case Helpers.get_matches(tournament, "playing") do + [%{game_id: game_id} | _] when is_integer(game_id) -> game_id + _ -> nil + end + end + + defp tournament_state(tournament) do + Map.take(tournament, [ + :id, + :name, + :type, + :state, + :break_state, + :show_results, + :players_count, + :current_round_position, + :last_round_started_at, + :last_round_ended_at, + :starts_at, + :finished_at + ]) + end + + defp render_active_game(game) do + %{ + id: Map.get(game, :id), + level: Map.get(game, :level), + state: Map.get(game, :state), + starts_at: Map.get(game, :starts_at), + finishes_at: Map.get(game, :finishes_at), + timeout_seconds: Map.get(game, :timeout_seconds), + duration_sec: Map.get(game, :duration_sec), + tournament_id: Map.get(game, :tournament_id), + task: render_task(game), + players: render_players(game) + } + end + + defp render_task(%{task_type: "sql", sql_task: task}) when not is_nil(task), do: task_payload(task) + defp render_task(%{task_type: "css", css_task: task}) when not is_nil(task), do: task_payload(task) + defp render_task(%{task: task}) when not is_nil(task), do: task_payload(task) + defp render_task(_), do: nil + + defp task_payload(task) do + %{ + id: Map.get(task, :id), + name: Map.get(task, :name), + level: Map.get(task, :level), + description_en: Map.get(task, :description_en), + description_ru: Map.get(task, :description_ru), + examples: Map.get(task, :examples), + asserts_examples: Map.get(task, :asserts_examples, []), + input_signature: Map.get(task, :input_signature, []), + output_signature: Map.get(task, :output_signature, %{}) + } + end + + defp render_players(game) do + game + |> Map.get(:players, []) + |> Enum.map(fn p -> + %{ + id: p.id, + name: p.name, + is_bot: Map.get(p, :is_bot, false), + lang: Map.get(p, :editor_lang), + rank: Map.get(p, :rank), + rating: Map.get(p, :rating), + result: Map.get(p, :result), + check_result: Map.get(p, :check_result) + } + end) + end +end diff --git a/apps/codebattle/lib/codebattle_web/endpoint.ex b/apps/codebattle/lib/codebattle_web/endpoint.ex index 971ca26e5..841140b06 100644 --- a/apps/codebattle/lib/codebattle_web/endpoint.ex +++ b/apps/codebattle/lib/codebattle_web/endpoint.ex @@ -21,6 +21,8 @@ defmodule CodebattleWeb.Endpoint do socket("/ws", CodebattleWeb.UserSocket, websocket: [timeout: :infinity, compress: true]) + socket("/ws-streamer", CodebattleWeb.StreamerSocket, websocket: [timeout: :infinity, compress: true]) + socket("/live", Phoenix.LiveView.Socket, websocket: [ connect_info: [ diff --git a/apps/codebattle/test/codebattle_web/channels/streamer_socket_test.exs b/apps/codebattle/test/codebattle_web/channels/streamer_socket_test.exs new file mode 100644 index 000000000..9e861d1cf --- /dev/null +++ b/apps/codebattle/test/codebattle_web/channels/streamer_socket_test.exs @@ -0,0 +1,34 @@ +defmodule CodebattleWeb.StreamerSocketTest do + use CodebattleWeb.ChannelCase + + alias CodebattleWeb.StreamerSocket + + describe "connect/2" do + test "accepts connection with valid token and integer tournament_id (string)" do + assert {:ok, socket} = connect(StreamerSocket, %{"token" => "x-key", "tournament_id" => "42"}) + assert socket.assigns.streamer? == true + assert socket.assigns.tournament_id == 42 + end + + test "accepts connection when tournament_id is already an integer" do + assert {:ok, socket} = connect(StreamerSocket, %{"token" => "x-key", "tournament_id" => 7}) + assert socket.assigns.tournament_id == 7 + end + + test "rejects connection with a wrong token" do + assert :error = connect(StreamerSocket, %{"token" => "nope", "tournament_id" => "1"}) + end + + test "rejects connection with no tournament_id" do + assert :error = connect(StreamerSocket, %{"token" => "x-key"}) + end + + test "rejects connection with non-numeric tournament_id" do + assert :error = connect(StreamerSocket, %{"token" => "x-key", "tournament_id" => "abc"}) + end + + test "rejects connection with no params" do + assert :error = connect(StreamerSocket, %{}) + end + end +end diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_streamer_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_streamer_channel_test.exs new file mode 100644 index 000000000..8a656c51a --- /dev/null +++ b/apps/codebattle/test/codebattle_web/channels/tournament_streamer_channel_test.exs @@ -0,0 +1,178 @@ +defmodule CodebattleWeb.TournamentStreamerChannelTest do + use CodebattleWeb.ChannelCase + + alias Codebattle.Tournament + alias CodebattleWeb.StreamerSocket + alias CodebattleWeb.TournamentStreamerChannel + + defp create_tournament(creator, attrs \\ %{}) do + base = %{ + "starts_at" => "2026-02-24T06:00", + "name" => "Stream Tournament", + "description" => "Stream Tournament", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => creator, + "break_duration_seconds" => 0, + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 200 + } + + {:ok, tournament} = Tournament.Context.create(Map.merge(base, attrs)) + tournament + end + + defp streamer_socket(tournament_id) do + {:ok, socket} = connect(StreamerSocket, %{"token" => "x-key", "tournament_id" => tournament_id}) + socket + end + + describe "join/3" do + test "joins with token-authed socket and returns short tournament state" do + creator = insert(:user) + tournament = create_tournament(creator) + + assert {:ok, payload, _socket} = + subscribe_and_join( + streamer_socket(tournament.id), + TournamentStreamerChannel, + "tournament_streamer", + %{} + ) + + assert %{tournament: t, active_game: nil} = payload + assert t.id == tournament.id + assert t.name == "Stream Tournament" + assert t.type == "swiss" + assert t.state == "waiting_participants" + end + + test "rejects join when socket is not streamer-authed" do + socket = socket(StreamerSocket, "streamer_unauth", %{streamer?: false, tournament_id: 1}) + + assert {:error, %{reason: "unauthorized"}} = + subscribe_and_join( + socket, + TournamentStreamerChannel, + "tournament_streamer", + %{} + ) + end + + test "rejects join when tournament does not exist" do + missing_id = System.unique_integer([:positive]) + + assert {:error, %{reason: "not_found"}} = + subscribe_and_join( + streamer_socket(missing_id), + TournamentStreamerChannel, + "tournament_streamer", + %{} + ) + end + end + + describe "handle_info/2" do + setup do + creator = insert(:user) + tournament = create_tournament(creator) + + {:ok, _payload, socket} = + subscribe_and_join( + streamer_socket(tournament.id), + TournamentStreamerChannel, + "tournament_streamer", + %{} + ) + + %{tournament: tournament, socket: socket} + end + + test "forwards tournament:updated as compact state", %{socket: socket, tournament: tournament} do + send(socket.channel_pid, %{ + event: "tournament:updated", + payload: %{tournament: %{id: tournament.id, name: "x", state: "active", type: "swiss"}} + }) + + assert_push("tournament:updated", %{tournament: %{id: _, state: "active"}}) + end + + test "forwards tournament:round_created", %{socket: socket, tournament: tournament} do + send(socket.channel_pid, %{ + event: "tournament:round_created", + payload: %{tournament: %{id: tournament.id, current_round_position: 1}} + }) + + assert_push("tournament:round_created", %{tournament: %{current_round_position: 1}}) + end + + test "pushes tournament:game:finished on game:tournament:finished", %{socket: socket} do + payload = %{ + game_id: 42, + task_id: 7, + game_state: "game_over", + game_level: "easy", + duration_sec: 120, + player_results: %{1 => %{result: "won"}, 2 => %{result: "lost"}} + } + + send(socket.channel_pid, %{event: "game:tournament:finished", payload: payload}) + + assert_push("tournament:game:finished", ^payload) + end + + test "forwards check_completed only for the active game", %{socket: socket} do + :sys.replace_state(socket.channel_pid, fn s -> + %{s | assigns: Map.put(s.assigns, :active_game_id, 100)} + end) + + send(socket.channel_pid, %{ + event: "game:check_completed", + payload: %{game_id: 100, user_id: 1, check_result: %{status: "ok"}} + }) + + assert_push("active_game:check_result", %{game_id: 100}) + + send(socket.channel_pid, %{ + event: "game:check_completed", + payload: %{game_id: 999, user_id: 1, check_result: %{status: "ok"}} + }) + + refute_push("active_game:check_result", %{game_id: 999}) + end + + test "ignores game:finished for non-active game", %{socket: socket} do + send(socket.channel_pid, %{ + event: "game:finished", + payload: %{game_id: 12_345, game_state: "game_over"} + }) + + refute_push("active_game:finished", _) + end + + test "ignores unknown events", %{socket: socket} do + send(socket.channel_pid, %{event: "tournament:totally:unknown", payload: %{}}) + + refute_push("tournament:totally:unknown", _) + end + end + + describe "handle_in/3" do + test "ignores all incoming messages from FE" do + creator = insert(:user) + tournament = create_tournament(creator) + + {:ok, _payload, socket} = + subscribe_and_join( + streamer_socket(tournament.id), + TournamentStreamerChannel, + "tournament_streamer", + %{} + ) + + ref = push(socket, "anything", %{"foo" => "bar"}) + refute_reply(ref, _, _) + end + end +end diff --git a/docs/009-streamer-websocket.md b/docs/009-streamer-websocket.md new file mode 100644 index 000000000..781102393 --- /dev/null +++ b/docs/009-streamer-websocket.md @@ -0,0 +1,194 @@ +# Streamer WebSocket (канал для стримеров турниров) + +Отдельный WebSocket-эндпоинт, через который стример получает реалтайм-инфу +о турнире и активной игре. Канал read-only: стример только слушает, ничего +не отправляет на сервер. + +## Подключение + +- **URL:** `wss:///ws-streamer/websocket?token=&tournament_id=&vsn=2.0.0` +- **Параметры URL:** + - `token` — значение из `Application.get_env(:codebattle, :api_key)` + (переменная окружения `CODEBATTLE_API_AUTH_KEY`, тот же ключ, что + использует `CodebattleWeb.Plugs.TokenAuth`). + - `tournament_id` — id турнира (положительное целое; принимается строкой или числом). +- Если токен невалидный, отсутствует или `tournament_id` не парсится — + сокет отвечает `:error` и соединение не устанавливается. +- При успешном подключении в assigns сокета пишется + `streamer?: true` и `tournament_id: `. + +Реализация: `apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex`. + +## Канал + +- **Топик:** `tournament_streamer` (без id — он уже в `socket.assigns`) +- Перед джойном канал проверяет `socket.assigns.streamer?`. Если флага нет — + ответ `{:error, %{reason: "unauthorized"}}`. +- Если турнира с таким id нет — `{:error, %{reason: "not_found"}}`. + +Реализация: `apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex`. + +### Что приходит при джойне + +```json +{ + "tournament": { + "id": 123, + "name": "...", + "type": "swiss", + "state": "active", + "break_state": "off", + "show_results": true, + "players_count": 42, + "current_round_position": 2, + "last_round_started_at": "...", + "last_round_ended_at": null, + "starts_at": "...", + "finished_at": null + }, + "active_game": { + "id": 456, + "level": "easy", + "state": "playing", + "starts_at": "...", + "finishes_at": "...", + "timeout_seconds": 300, + "duration_sec": null, + "tournament_id": 123, + "task": { + "id": 7, + "name": "asc-sort", + "level": "easy", + "description_en": "...", + "description_ru": "...", + "examples": "...", + "asserts_examples": [...], + "input_signature": [...], + "output_signature": {...} + }, + "players": [ + { + "id": 1, "name": "alice", + "is_bot": false, "lang": "ruby", + "rank": 10, "rating": 1500, + "result": null, "check_result": null + }, + ... + ] + } +} +``` + +`active_game` берётся из `TournamentAdminChannel.get_active_game/1` +(агент, в котором админ хранит текущую "трансляционную" игру). Если админ +ещё ничего не выбрал — берётся первая `playing` игра турнира. Если игр нет, +поле приходит как `null`. + +## Подписки на PubSub + +Сразу после джойна канал подписывается на: + +| Топик | Зачем | +| ------------------------------------ | -------------------------------------- | +| `tournament::stream` | смена активной игры админом | +| `tournament::common` | старт/конец раунда, финиш турнира | +| `tournament:` | общие апдейты турнира | +| `game:tournament:` | финиши **всех** игр этого турнира | +| `game:` *(если есть)*| проверки тестов и финиш активной игры | + +При смене активной игры старая подписка `game:` отписывается, на +`game:` подписываемся. + +## События, которые получает стример + +### `active_game:set` + +Админ переключил трансляционную игру. + +```json +{ "game_id": 789, "game": { ... как active_game из джойна ... } } +``` + +Если игру не удалось загрузить — `"game": null`. + +### `active_game:check_result` + +Прогон тестов на **текущей** активной игре (события для других игр +фильтруются и не приходят). + +```json +{ + "game_id": 789, + "user_id": 1, + "check_result": { "asserts_count": 10, "success_count": 7, "status": "failure" } +} +``` + +### `active_game:finished` + +Активная игра закончилась (кто-то выиграл / таймаут). + +```json +{ + "game_id": 789, + "game_state": "game_over", + "players": [ + { "id": 1, "name": "alice", "result": "won", ... }, + { "id": 2, "name": "bob", "result": "lost", ... } + ] +} +``` + +### `tournament:game:finished` + +Любая игра турнира финишировала — нужно, чтобы стример мог показывать +ленту побед по всему турниру, а не только по активной игре. + +```json +{ + "game_id": 456, + "task_id": 7, + "game_state": "game_over", + "game_level": "easy", + "duration_sec": 124, + "player_results": { "1": { "result": "won", ... }, "2": { "result": "lost", ... } } +} +``` + +### События уровня турнира + +Пробрасываются как есть с укороченным payload (только поля из +`tournament_state/1`): + +- `tournament:updated` +- `tournament:round_created` +- `tournament:round_finished` +- `tournament:finished` +- `tournament:restarted` + +Формат: `{ "tournament": { id, name, type, state, ... } }`. + +## Что **не** приходит + +- Изменения кода в редакторе игроков (editor diffs, текущий текст решения). +- События чата. +- Внутренние ивенты движка игры, кроме `check_completed`/`finished` + активной игры. + +То есть лента стримера — это «эссеншл»: статус турнира, кто что прислал +в активной игре (упало/прошло — сколько ассертов), кто победил. + +## Сообщения от клиента + +`handle_in/3` молча игнорирует любые входящие пуши — стример ничего на +сервер слать не должен. + +## Файлы + +- Сокет: `apps/codebattle/lib/codebattle_web/channels/streamer_socket.ex` +- Канал: `apps/codebattle/lib/codebattle_web/channels/tournament_streamer_channel.ex` +- Регистрация: `apps/codebattle/lib/codebattle_web/endpoint.ex` + (`socket("/ws-streamer", CodebattleWeb.StreamerSocket, ...)`) +- Тесты: + - `apps/codebattle/test/codebattle_web/channels/streamer_socket_test.exs` + - `apps/codebattle/test/codebattle_web/channels/tournament_streamer_channel_test.exs` From 6991c8b486a525312f36be814fed92854ac87ba3 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 18 Jun 2026 13:21:54 +0200 Subject: [PATCH 05/34] Minor updates --- .../pages/groupTournament/EvolutionPanel.jsx | 36 +++++++++--------- .../group_tournament/group_tournament.ex | 37 ++++++++++++++++--- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx b/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx index bcdddb07e..5d5ec6cb0 100644 --- a/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx +++ b/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx @@ -8,26 +8,26 @@ const getStubRoundPosition = (groupTournament) => { return roundPosition ?? 2; }; -const getExternalUrl = (url) => { - if (!url) { - return null; - } +// const getExternalUrl = (url) => { +// if (!url) { +// return null; +// } - try { - const externalUrl = new URL(`${url.replace(/\/$/, "")}/browse/README.md`); +// try { +// const externalUrl = new URL(`${url.replace(/\/$/, "")}/browse/README.md`); - externalUrl.searchParams.set("rev", "main"); - externalUrl.searchParams.set( - "chatMessage", - "Это ИИ-ассистент, который поможет тебе решить задачу.", - ); +// externalUrl.searchParams.set("rev", "main"); +// externalUrl.searchParams.set( +// "chatMessage", +// "Это ИИ-ассистент, который поможет тебе решить задачу.", +// ); - return externalUrl.toString(); - } catch (error) { - console.error("group_tournament: invalid repo url", url, error); - return null; - } -}; +// return externalUrl.toString(); +// } catch (error) { +// console.error("group_tournament: invalid repo url", url, error); +// return null; +// } +// }; function EvolutionPanel({ items, @@ -42,7 +42,7 @@ function EvolutionPanel({ }) { const isFinished = tournamentStatus === "finished"; const isWaiting = tournamentStatus === "waiting_participants"; - const externalUrl = !isFinished && !isWaiting ? getExternalUrl(repoUrl) : null; + const externalUrl = !isFinished && !isWaiting ? repoUrl : null; const canAddSolutionInternal = !isFinished && !isWaiting && !externalUrl && !!onAddSolution; const onBreak = isOnBreak(groupTournament); diff --git a/apps/codebattle/lib/codebattle/group_tournament/group_tournament.ex b/apps/codebattle/lib/codebattle/group_tournament/group_tournament.ex index a9ffc5df4..7b626526b 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/group_tournament.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/group_tournament.ex @@ -13,7 +13,7 @@ defmodule Codebattle.GroupTournament do @states ~w(waiting_participants active finished canceled) @slice_strategies ~w(random rating) - @types ~w(individual ranked) + @types ~w(individual ranked seed_only) @scoring_strategies ~w(diagonal_quadratic diagonal_linear global_linear flat_linear) @movement_strategies ~w(mirrored_cascade global_rerank neighbor_ladder) @@ -57,7 +57,8 @@ defmodule Codebattle.GroupTournament do :break_duration_seconds, :has_seed_round, :show_leaderboard, - :visible_to_users + :visible_to_users, + :is_infinite ]} schema "group_tournaments" do @@ -95,6 +96,7 @@ defmodule Codebattle.GroupTournament do field(:has_seed_round, :boolean, default: false) field(:show_leaderboard, :boolean, default: true) field(:visible_to_users, :boolean, default: true) + field(:is_infinite, :boolean, default: false) field(:last_round_started_at, :naive_datetime) field(:last_round_ended_at, :naive_datetime) field(:meta, :map, default: %{}) @@ -146,7 +148,8 @@ defmodule Codebattle.GroupTournament do :break_duration_seconds, :has_seed_round, :show_leaderboard, - :visible_to_users + :visible_to_users, + :is_infinite ]) |> validate_required([ :group_task_id, @@ -154,9 +157,9 @@ defmodule Codebattle.GroupTournament do :slug, :description, :starts_at, - :rounds_count, - :round_timeout_seconds + :rounds_count ]) + |> validate_round_timeout() |> update_change(:slug, &normalize_slug/1) |> update_change(:template_id, &normalize_optional_string/1) |> update_change(:task_description, &normalize_optional_string/1) @@ -198,6 +201,7 @@ defmodule Codebattle.GroupTournament do a slice round and round 1 is no different from the rest. """ def seeding_round?(%__MODULE__{type: "ranked", has_seed_round: true, current_round_position: 1}), do: true + def seeding_round?(%__MODULE__{type: "seed_only"}), do: true def seeding_round?(_), do: false @@ -207,6 +211,21 @@ defmodule Codebattle.GroupTournament do def ranked?(%__MODULE__{type: "ranked"}), do: true def ranked?(_), do: false + @doc """ + Seed-only tournaments are a single bot-fight pass — players submit a solution, + it runs against bots, results are recorded, and the tournament finishes. No + slice rounds, no timer-driven round transitions. + """ + def seed_only?(%__MODULE__{type: "seed_only"}), do: true + def seed_only?(_), do: false + + @doc """ + Infinite tournaments hide the round timer in the UI and do not auto-advance + rounds — the creator ends the round manually. + """ + def infinite?(%__MODULE__{is_infinite: true}), do: true + def infinite?(_), do: false + defp normalize_slug(nil), do: nil defp normalize_slug(slug), do: slug |> String.trim() |> String.downcase() @@ -221,6 +240,14 @@ defmodule Codebattle.GroupTournament do defp normalize_optional_string(value), do: value + defp validate_round_timeout(changeset) do + if get_field(changeset, :is_infinite) do + changeset + else + validate_required(changeset, [:round_timeout_seconds]) + end + end + defp validate_template_id(changeset) do if get_field(changeset, :run_on_external_platform) do validate_required(changeset, [:template_id]) From 4d7f8f114a51a547a1aa8e594d6924629005d2b4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 18 Jun 2026 13:46:40 +0200 Subject: [PATCH 06/34] Improve clipboard --- apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx b/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx index 587f97d5b..d3a657c69 100644 --- a/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx +++ b/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx @@ -327,6 +327,7 @@ function EditorContainer({ theme, ...userSettings, editable, + allowClipboard: isAdmin, loading: isPreview || editorCurrent.value === "loading", }; From ca5db9be996ca4609c8e064d3a0963fc9ad37b77 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 18 Jun 2026 13:56:15 +0200 Subject: [PATCH 07/34] Fix --- ...0618120000_add_is_infinite_to_group_tournaments.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/codebattle/priv/repo/migrations/20260618120000_add_is_infinite_to_group_tournaments.exs diff --git a/apps/codebattle/priv/repo/migrations/20260618120000_add_is_infinite_to_group_tournaments.exs b/apps/codebattle/priv/repo/migrations/20260618120000_add_is_infinite_to_group_tournaments.exs new file mode 100644 index 000000000..78c319862 --- /dev/null +++ b/apps/codebattle/priv/repo/migrations/20260618120000_add_is_infinite_to_group_tournaments.exs @@ -0,0 +1,10 @@ +defmodule Codebattle.Repo.Migrations.AddIsInfiniteToGroupTournaments do + @moduledoc false + use Ecto.Migration + + def change do + alter table(:group_tournaments) do + add(:is_infinite, :boolean, default: false, null: false) + end + end +end From 75417d960f8498f1ba4fdcf5a83298ca1c70addb Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 18 Jun 2026 16:03:42 +0200 Subject: [PATCH 08/34] fix --- .../pages/groupTournament/EvolutionPanel.jsx | 4 +-- .../widgets/pages/groupTournament/Header.jsx | 31 ++++++++++++------- .../widgets/pages/groupTournament/RunItem.jsx | 19 ++++++------ .../codebattle/group_tournament/context.ex | 3 +- .../lib/codebattle/group_tournament/server.ex | 11 ++++++- .../api/v1/group_tournament_controller.ex | 23 +------------- .../admin/group_tournament/_form.html.heex | 9 ++++++ 7 files changed, 52 insertions(+), 48 deletions(-) diff --git a/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx b/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx index 5d5ec6cb0..a36aab17a 100644 --- a/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx +++ b/apps/codebattle/assets/js/widgets/pages/groupTournament/EvolutionPanel.jsx @@ -76,7 +76,7 @@ function EvolutionPanel({ )} {items?.length > 0 && (
- {!isFinished && !onBreak && ( + {/* {!isFinished && !onBreak && !groupTournament?.isInfinite && ( - )} + )}*/} {items.map((item) => ( 0; - if (groupTournament?.state !== "active" || !target || (!seconds && !time)) { + if ( + groupTournament?.isInfinite || + groupTournament?.state !== "active" || + !target || + (!seconds && !time) + ) { return null; } @@ -197,16 +202,18 @@ function Header({ name, status, groupTournament }) { return (

{name || i18n.t("Group Tournament")}

-
- {isWaiting ? ( - - ) : ( - - )} -
+ {!groupTournament?.isInfinite && groupTournament?.type !== "seed_only" && ( +
+ {isWaiting ? ( + + ) : ( + + )} +
+ )}
- {isPipSupported && ( + {isPipSupported && !groupTournament?.isInfinite && groupTournament?.type !== "seed_only" && ( setIsPipActive(false)} diff --git a/apps/codebattle/assets/js/widgets/pages/groupTournament/RunItem.jsx b/apps/codebattle/assets/js/widgets/pages/groupTournament/RunItem.jsx index 4ff8fc284..6ddfac9bd 100644 --- a/apps/codebattle/assets/js/widgets/pages/groupTournament/RunItem.jsx +++ b/apps/codebattle/assets/js/widgets/pages/groupTournament/RunItem.jsx @@ -65,16 +65,15 @@ const RunItem = ({ item, items, runId, setRunId, leaderboard, currentUserId }) =
- {item.isStub ? ( - - {item.kind === "seed" - ? i18n.t("Group assignment soon") - : i18n.t("Group contest soon")} - - ) : pending ? ( + {item.isStub ? // + // {item.kind === "seed" + // ? i18n.t("Group assignment soon") + // : i18n.t("Group contest soon")} + // + null : pending ? ( i18n.t("Running…") ) : ( <> diff --git a/apps/codebattle/lib/codebattle/group_tournament/context.ex b/apps/codebattle/lib/codebattle/group_tournament/context.ex index 03b3edc5b..2c23acb39 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/context.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/context.ex @@ -660,7 +660,8 @@ defmodule Codebattle.GroupTournament.Context do :group_task_id, :template_id, :meta, - :show_leaderboard + :show_leaderboard, + :is_infinite ]) |> Map.put(:group_task_slug, group_tournament.group_task && group_tournament.group_task.slug) end diff --git a/apps/codebattle/lib/codebattle/group_tournament/server.ex b/apps/codebattle/lib/codebattle/group_tournament/server.ex index 20085e57c..3c84dfff0 100644 --- a/apps/codebattle/lib/codebattle/group_tournament/server.ex +++ b/apps/codebattle/lib/codebattle/group_tournament/server.ex @@ -509,6 +509,9 @@ defmodule Codebattle.GroupTournament.Server do # are excluded from this round's runs. defp run_round(%GroupTournament{} = t, opts) do cond do + GroupTournament.seed_only?(t) -> + {[], []} + GroupTournament.seeding_round?(t) -> # Seeding round end: run each player's latest submission solo, no # bots (parallel pool inside SliceRunner) to produce seed scores @@ -659,7 +662,11 @@ defmodule Codebattle.GroupTournament.Server do defp schedule_start(state, _group_tournament), do: state - defp schedule_round_finish(state, group_tournament, break_seconds \\ 0) do + defp schedule_round_finish(state, group_tournament, break_seconds \\ 0) + + defp schedule_round_finish(state, %GroupTournament{is_infinite: true}, _break_seconds), do: state + + defp schedule_round_finish(state, group_tournament, break_seconds) do timeout_seconds = current_round_timeout(group_tournament) total = timeout_seconds + max(break_seconds, 0) @@ -682,6 +689,8 @@ defmodule Codebattle.GroupTournament.Server do # init/1. If a round was in progress (last_round_started_at set, no end), # compute the remaining seconds and (re)schedule :finish_round. If the round # window has already elapsed, fire immediately so the round can advance. + defp maybe_resume_round_finish(state, %GroupTournament{is_infinite: true}), do: state + defp maybe_resume_round_finish(state, %GroupTournament{state: "active", last_round_started_at: started_at} = t) when not is_nil(started_at) do timeout_seconds = current_round_timeout(t) diff --git a/apps/codebattle/lib/codebattle_web/controllers/api/v1/group_tournament_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/api/v1/group_tournament_controller.ex index 94736c98e..cc442fcee 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/api/v1/group_tournament_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/api/v1/group_tournament_controller.ex @@ -132,28 +132,7 @@ defmodule CodebattleWeb.Api.V1.GroupTournamentController do defp list_runs_opts(_group_tournament, current_user), do: [limit: :infinity, visible_for_user_id: current_user.id] defp serialize_group_tournament(group_tournament) do - %{ - id: group_tournament.id, - name: group_tournament.name, - slug: group_tournament.slug, - description: group_tournament.description, - state: group_tournament.state, - starts_at: group_tournament.starts_at, - started_at: group_tournament.started_at, - finished_at: group_tournament.finished_at, - current_round_position: group_tournament.current_round_position, - rounds_count: group_tournament.rounds_count, - round_timeout_seconds: group_tournament.round_timeout_seconds, - include_bots: group_tournament.include_bots, - last_round_started_at: group_tournament.last_round_started_at, - last_round_ended_at: group_tournament.last_round_ended_at, - players_count: group_tournament.players_count, - group_task_id: group_tournament.group_task_id, - group_task_slug: group_tournament.group_task && group_tournament.group_task.slug, - template_id: Map.get(group_tournament, :template_id), - meta: group_tournament.meta, - max_score: Map.get(group_tournament, :max_score) - } + Context.serialize_group_tournament(group_tournament) end defp serialize_current_player(nil), do: nil diff --git a/apps/codebattle/lib/codebattle_web/templates/admin/group_tournament/_form.html.heex b/apps/codebattle/lib/codebattle_web/templates/admin/group_tournament/_form.html.heex index b88a20a33..04e3e148b 100644 --- a/apps/codebattle/lib/codebattle_web/templates/admin/group_tournament/_form.html.heex +++ b/apps/codebattle/lib/codebattle_web/templates/admin/group_tournament/_form.html.heex @@ -213,6 +213,15 @@ {error_tag(f, :show_leaderboard)}
+
+ {checkbox(f, :is_infinite, class: "form-check-input")} + + {error_tag(f, :is_infinite)} +
+
{checkbox(f, :visible_to_users, class: "form-check-input")}