Skip to content

Commit e3053d9

Browse files
committed
Improve tournament ranking
1 parent d663c73 commit e3053d9

20 files changed

Lines changed: 599 additions & 1019 deletions

apps/codebattle/lib/codebattle/tournament/server.ex

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ defmodule Codebattle.Tournament.Server do
176176
Process.send_after(self(), :start_grade_tournament, max(time_diff_ms, 0))
177177
end
178178

179-
{:ok, %{tournament: tournament, frozen: false}}
179+
{:ok, %{tournament: tournament, frozen: false, break_timer_expired: false}}
180180
end
181181

182182
def handle_cast({:fire_event, event_type, params}, state) do
@@ -313,10 +313,7 @@ defmodule Codebattle.Tournament.Server do
313313
if tournament.current_round_position == round_position and
314314
in_break?(tournament) and
315315
not finished?(tournament) do
316-
new_tournament = tournament.module.start_round_force(tournament)
317-
update_tournament_info_cache(new_tournament)
318-
319-
{:noreply, %{state | tournament: new_tournament}}
316+
process_stop_round_break(state, tournament)
320317
else
321318
{:noreply, %{state | tournament: tournament}}
322319
end
@@ -389,14 +386,7 @@ defmodule Codebattle.Tournament.Server do
389386
defer_message({:round_finish_completed, op_id, finished_tournament}, "round_finish_completed", state)
390387
else
391388
if tournament.round_op_id == op_id and tournament.round_state == "round_finishing" do
392-
new_tournament =
393-
finished_tournament
394-
|> tournament.module.complete_round_finish()
395-
|> Map.put(:round_op_status, "done")
396-
|> Map.put(:round_op_id, nil)
397-
398-
update_tournament_info_cache(new_tournament)
399-
{:noreply, %{state | tournament: new_tournament}}
389+
process_round_finish_completed(state, tournament, finished_tournament)
400390
else
401391
{:noreply, state}
402392
end
@@ -564,6 +554,48 @@ defmodule Codebattle.Tournament.Server do
564554

565555
defp restore_info_cache(_tournament_id, _value), do: :ok
566556

557+
defp process_stop_round_break(state, tournament) do
558+
if tournament.round_op_status == "done" do
559+
# Computation already finished — start next round now
560+
new_tournament = tournament.module.start_round_force(tournament)
561+
update_tournament_info_cache(new_tournament)
562+
563+
{:noreply, %{state | tournament: new_tournament, break_timer_expired: false}}
564+
else
565+
# Computation still running — mark break as expired, wait for {:round_finish_completed}
566+
{:noreply, %{state | break_timer_expired: true}}
567+
end
568+
end
569+
570+
defp process_round_finish_completed(state, tournament, finished_tournament) do
571+
if_result =
572+
if tournament.module.finish_tournament?(finished_tournament) do
573+
tournament.module.maybe_finish_tournament(finished_tournament)
574+
else
575+
tournament.module.broadcast_round_finished(finished_tournament)
576+
end
577+
578+
new_tournament =
579+
if_result
580+
|> Map.put(:round_op_status, "done")
581+
|> Map.put(:round_op_id, nil)
582+
583+
if state.break_timer_expired do
584+
# Break already expired while we were computing — start next round now
585+
new_tournament =
586+
if finished?(new_tournament), do: new_tournament, else: tournament.module.start_round_force(new_tournament)
587+
588+
update_tournament_info_cache(new_tournament)
589+
{:noreply, %{state | tournament: new_tournament, break_timer_expired: false}}
590+
else
591+
# Break still running — wait for {:stop_round_break} to start next round
592+
new_tournament = Map.put(new_tournament, :round_state, "break")
593+
broadcast_tournament_update(new_tournament)
594+
update_tournament_info_cache(new_tournament)
595+
{:noreply, %{state | tournament: new_tournament}}
596+
end
597+
end
598+
567599
defp defer_message(message, reason, state) do
568600
Logger.info(
569601
"[handoff] #{inspect(%{phase: "tournament_defer", reason: reason, tournament_id: state.tournament.id}, limit: :infinity, printable_limit: :infinity)}"
@@ -587,11 +619,22 @@ defmodule Codebattle.Tournament.Server do
587619
op_id = System.unique_integer([:positive, :monotonic])
588620
server_pid = self()
589621

622+
# Start break timer immediately alongside computation
623+
min_break = Application.get_env(:codebattle, :min_break_duration_seconds, 5)
624+
break_seconds = max(tournament.break_duration_seconds || min_break, min_break)
625+
626+
Process.send_after(
627+
self(),
628+
{:stop_round_break, tournament.current_round_position},
629+
to_timeout(second: break_seconds)
630+
)
631+
590632
in_progress_tournament =
591633
tournament
592634
|> Map.put(:round_state, "round_finishing")
593635
|> Map.put(:round_op_status, "running")
594636
|> Map.put(:round_op_id, op_id)
637+
|> Map.put(:break_state, "on")
595638

596639
update_tournament_info_cache(in_progress_tournament)
597640

@@ -603,7 +646,7 @@ defmodule Codebattle.Tournament.Server do
603646
end
604647
)
605648

606-
{:noreply, %{state | tournament: in_progress_tournament}}
649+
{:noreply, %{state | tournament: in_progress_tournament, break_timer_expired: false}}
607650
end
608651

609652
defp run_round_finish_job(server_pid, tournament, op_id) do

apps/codebattle/lib/codebattle/tournament/strategy/base.ex

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -509,11 +509,16 @@ defmodule Codebattle.Tournament.Base do
509509
end
510510

511511
def complete_round_finish(tournament) do
512-
tournament
513-
|> broadcast_round_finished()
514-
|> maybe_finish_tournament()
515-
|> maybe_start_round_or_break_or_finish()
516-
|> then(fn tournament ->
512+
if_result =
513+
if finish_tournament?(tournament) do
514+
maybe_finish_tournament(tournament)
515+
else
516+
tournament
517+
|> broadcast_round_finished()
518+
|> maybe_start_round_or_break_or_finish()
519+
end
520+
521+
then(if_result, fn tournament ->
517522
broadcast_tournament_update(tournament)
518523
tournament
519524
end)
@@ -548,27 +553,19 @@ defmodule Codebattle.Tournament.Base do
548553
update_struct(tournament, %{break_state: "on", round_state: "break"})
549554
end
550555

551-
defp maybe_start_round_or_break_or_finish(
552-
%{
553-
state: "active",
554-
break_duration_seconds: break_duration_seconds,
555-
current_round_position: current_round_position
556-
} = tournament
557-
)
558-
when break_duration_seconds not in [nil, 0] do
556+
defp maybe_start_round_or_break_or_finish(%{state: "active"} = tournament) do
557+
min_break = Application.get_env(:codebattle, :min_break_duration_seconds, 5)
558+
break_seconds = max(tournament.break_duration_seconds || min_break, min_break)
559+
559560
Process.send_after(
560561
self(),
561562
{:stop_round_break, tournament.current_round_position},
562-
to_timeout(second: break_duration_seconds)
563+
to_timeout(second: break_seconds)
563564
)
564565

565566
update_struct(tournament, %{break_state: "on", round_state: "break"})
566567
end
567568

568-
defp maybe_start_round_or_break_or_finish(tournament) do
569-
start_round_force(tournament)
570-
end
571-
572569
defp increment_current_round(tournament) do
573570
update_struct(tournament, %{
574571
current_round_position: tournament.current_round_position + 1
@@ -770,7 +767,7 @@ defmodule Codebattle.Tournament.Base do
770767
})
771768
end
772769

773-
defp maybe_finish_tournament(tournament) do
770+
def maybe_finish_tournament(tournament) do
774771
if finish_tournament?(tournament) do
775772
finish_tournament(tournament)
776773
else
@@ -922,7 +919,7 @@ defmodule Codebattle.Tournament.Base do
922919
tournament
923920
end
924921

925-
defp broadcast_round_finished(tournament) do
922+
def broadcast_round_finished(tournament) do
926923
Codebattle.PubSub.broadcast("tournament:round_finished", %{tournament: tournament})
927924
tournament
928925
end

apps/codebattle/lib/codebattle/tournament/tournament_result.ex

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ defmodule Codebattle.Tournament.TournamentResult do
6262
def upsert_results(%{type: type, ranking_type: "by_user", score_strategy: "75_percentile"} = tournament)
6363
when type in ["swiss", "top200"] do
6464
round_position = tournament.current_round_position || 0
65-
cheated_game_sql = cheated_game_sql("g", tournament)
65+
player_cheater_sql = player_cheater_sql(tournament)
66+
game_has_cheater_sql = game_has_cheater_sql("g", tournament)
6667
clean_results(tournament.id, round_position)
6768

6869
Repo.query!("""
@@ -82,7 +83,8 @@ defmodule Codebattle.Tournament.TournamentResult do
8283
and COALESCE(round_position, 0) = #{round_position}
8384
and state = 'game_over'
8485
and bot_won = FALSE
85-
and not (#{cheated_game_sql})
86+
and not COALESCE(g.was_cheated, FALSE)
87+
and not (#{cheater_won_game_sql("g", tournament)})
8688
GROUP BY
8789
task_id, level),
8890
stats as (
@@ -96,16 +98,21 @@ defmodule Codebattle.Tournament.TournamentResult do
9698
g.tournament_id,
9799
g.id as game_id,
98100
COALESCE(g.round_position, 0) as round_position,
99-
(#{cheated_game_sql}) AS was_cheated,
101+
(COALESCE(g.was_cheated, FALSE) OR (#{player_cheater_sql})) AS was_cheated,
100102
COALESCE(dt.base_score, 0) AS base_score,
101103
COALESCE(dt.min_duration, 0) AS min_duration,
102104
COALESCE(dt.max_duration, 0) AS max_duration,
103105
CASE
104-
WHEN (#{cheated_game_sql}) THEN
106+
-- Game-level cheated flag or this player is a cheater
107+
WHEN COALESCE(g.was_cheated, FALSE) OR (#{player_cheater_sql}) THEN
105108
0
106-
-- Handle timeout case where both players lost
109+
-- Opponent of cheater who lost (cheater won) — give base_score as compensation
110+
WHEN (#{game_has_cheater_sql}) AND g.state = 'game_over' AND (p.player_info->>'result_percent')::numeric < 100.0 THEN
111+
COALESCE(dt.base_score, 0)
112+
-- Handle timeout case where both players lost (including opponent of cheater in timeout)
107113
WHEN g.state = 'timeout' THEN
108114
0.5 * COALESCE(dt.base_score, 0) * COALESCE((p.player_info->>'result_percent')::numeric, 0) / 100.0
115+
-- Normal scoring (includes opponent of cheater who won — gets normal win score)
109116
ELSE
110117
COALESCE(dt.base_score, 0) * (
111118
2 - ((g.duration_sec - COALESCE(dt.min_duration, 0))::numeric / GREATEST(COALESCE(dt.max_duration - COALESCE(dt.min_duration, 0), 1), 1))
@@ -163,7 +170,8 @@ defmodule Codebattle.Tournament.TournamentResult do
163170
def upsert_results(%{type: type, ranking_type: "by_user", score_strategy: "win_loss"} = tournament)
164171
when type in ["swiss"] do
165172
round_position = tournament.current_round_position || 0
166-
cheated_game_sql = cheated_game_sql("g", tournament)
173+
player_cheater_sql = player_cheater_sql(tournament)
174+
game_has_cheater_sql = game_has_cheater_sql("g", tournament)
167175
clean_results(tournament.id, round_position)
168176

169177
Repo.query!("""
@@ -180,9 +188,11 @@ defmodule Codebattle.Tournament.TournamentResult do
180188
g.task_id,
181189
g.id as game_id,
182190
COALESCE(g.round_position, 0) as round_position,
183-
(#{cheated_game_sql}) AS was_cheated,
191+
(COALESCE(g.was_cheated, FALSE) OR (#{player_cheater_sql})) AS was_cheated,
184192
CASE
185-
WHEN (#{cheated_game_sql}) THEN 0
193+
WHEN COALESCE(g.was_cheated, FALSE) OR (#{player_cheater_sql}) THEN 0
194+
WHEN (#{game_has_cheater_sql}) AND g.state = 'game_over'
195+
THEN #{@win_score}
186196
WHEN (p.player_info->'result_percent')::numeric = 100.0
187197
THEN #{@win_score}
188198
ELSE #{@loss_score}
@@ -255,21 +265,41 @@ defmodule Codebattle.Tournament.TournamentResult do
255265
|> Repo.delete_all()
256266
end
257267

258-
defp cheated_game_sql(game_alias, %{cheater_ids: []}), do: "COALESCE(#{game_alias}.was_cheated, FALSE)"
259-
defp cheated_game_sql(game_alias, %{cheater_ids: nil}), do: "COALESCE(#{game_alias}.was_cheated, FALSE)"
268+
# Checks if the winner of the game is a cheater (for excluding from percentile calculation)
269+
defp cheater_won_game_sql(_game_alias, %{cheater_ids: ids}) when ids in [[], nil], do: "FALSE"
270+
271+
defp cheater_won_game_sql(game_alias, %{cheater_ids: cheater_ids}) do
272+
cheater_ids_sql = cheater_ids |> Enum.uniq() |> Enum.join(",")
273+
274+
"""
275+
exists (
276+
select 1
277+
from jsonb_array_elements(#{game_alias}.players) as wp(player_info)
278+
where (wp.player_info->>'result_percent')::numeric = 100.0
279+
and (wp.player_info->>'id')::integer = any(array[#{cheater_ids_sql}]::integer[])
280+
)
281+
"""
282+
end
283+
284+
# Checks if THIS specific player (from lateral join) is a cheater
285+
defp player_cheater_sql(%{cheater_ids: ids}) when ids in [[], nil], do: "FALSE"
286+
287+
defp player_cheater_sql(%{cheater_ids: cheater_ids}) do
288+
cheater_ids_sql = cheater_ids |> Enum.uniq() |> Enum.join(",")
289+
"(p.player_info->>'id')::integer = any(array[#{cheater_ids_sql}]::integer[])"
290+
end
291+
292+
# Checks if the game has any identified cheater (by cheater_ids only, not game-level flag)
293+
defp game_has_cheater_sql(_game_alias, %{cheater_ids: ids}) when ids in [[], nil], do: "FALSE"
260294

261-
defp cheated_game_sql(game_alias, %{cheater_ids: cheater_ids}) do
262-
cheater_ids_sql =
263-
cheater_ids
264-
|> Enum.uniq()
265-
|> Enum.join(",")
295+
defp game_has_cheater_sql(game_alias, %{cheater_ids: cheater_ids}) do
296+
cheater_ids_sql = cheater_ids |> Enum.uniq() |> Enum.join(",")
266297

267298
"""
268-
COALESCE(#{game_alias}.was_cheated, FALSE)
269-
or exists (
299+
exists (
270300
select 1
271-
from jsonb_array_elements(#{game_alias}.players) as cheater_player(player_info)
272-
where (cheater_player.player_info->>'id')::integer = any(array[#{cheater_ids_sql}]::integer[])
301+
from jsonb_array_elements(#{game_alias}.players) as cp(player_info)
302+
where (cp.player_info->>'id')::integer = any(array[#{cheater_ids_sql}]::integer[])
273303
)
274304
"""
275305
end

apps/codebattle/lib/codebattle_web/controllers/auth_bind_controller.ex

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ defmodule CodebattleWeb.AuthBindController do
4242
case_result =
4343
case params["provider"] do
4444
"github" ->
45-
{:ok, profile} = Github.github_auth(code)
46-
GithubUser.bind(current_user, profile)
45+
with {:ok, profile} <- Github.github_auth(code) do
46+
GithubUser.bind(current_user, profile)
47+
end
4748

4849
"discord" ->
4950
redirect_uri = Routes.auth_bind_url(conn, :callback, "discord")
50-
{:ok, profile} = Discord.discord_auth(code, redirect_uri)
51-
DiscordUser.bind(current_user, profile)
51+
52+
with {:ok, profile} <- Discord.discord_auth(code, redirect_uri) do
53+
DiscordUser.bind(current_user, profile)
54+
end
5255
end
5356

5457
case case_result do

apps/codebattle/lib/codebattle_web/controllers/auth_controller.ex

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,16 @@ defmodule CodebattleWeb.AuthController do
6363
case_result =
6464
case provider_name do
6565
"github" ->
66-
{:ok, profile} = Github.github_auth(code)
67-
Codebattle.Auth.User.GithubUser.find_or_create(profile)
66+
with {:ok, profile} <- Github.github_auth(code) do
67+
Codebattle.Auth.User.GithubUser.find_or_create(profile)
68+
end
6869

6970
"discord" ->
7071
redirect_uri = Routes.auth_url(conn, :callback, provider_name)
71-
{:ok, profile} = Discord.discord_auth(code, redirect_uri)
72-
Codebattle.Auth.User.DiscordUser.find_or_create(profile)
72+
73+
with {:ok, profile} <- Discord.discord_auth(code, redirect_uri) do
74+
Codebattle.Auth.User.DiscordUser.find_or_create(profile)
75+
end
7376

7477
"external" ->
7578
redirect_uri = Routes.auth_url(conn, :callback, provider_name)

apps/codebattle/lib/codebattle_web/controllers/ext_api/task_controller.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ defmodule CodebattleWeb.ExtApi.TaskController do
1010
origin = Map.get(params, "origin")
1111
visibility = Map.get(params, "visibility")
1212

13+
case payload do
14+
nil ->
15+
conn
16+
|> put_status(:not_found)
17+
|> json(%{errors: %{payload: "Payload not found"}})
18+
19+
_ ->
20+
process_payload(conn, payload, origin, visibility)
21+
end
22+
end
23+
24+
defp process_payload(conn, payload, origin, visibility) do
1325
with {:ok, gzipped_data} <- decode_base64(payload),
1426
{:ok, json_data} <- decompress_gzip(gzipped_data),
1527
{:ok, tasks_list} <- Jason.decode(json_data) do

apps/codebattle/lib/codebattle_web/controllers/ext_api/task_pack_controller.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,22 @@ defmodule CodebattleWeb.ExtApi.TaskPackController do
1111
plug(CodebattleWeb.Plugs.TokenAuth)
1212

1313
def create(conn, params) do
14+
case Map.get(params, "task_pack") do
15+
task_pack_params when is_map(task_pack_params) ->
16+
process_task_pack(conn, params, task_pack_params)
17+
18+
_ ->
19+
conn
20+
|> put_status(:unauthorized)
21+
|> json(%{error: "Invalid task_pack payload"})
22+
end
23+
end
24+
25+
defp process_task_pack(conn, params, task_pack_params) do
1426
visibility = Map.get(params, "visibility", "public")
15-
tp_params = Map.get(params, "task_pack", %{})
1627

1728
params =
18-
tp_params
29+
task_pack_params
1930
|> AtomizedMap.atomize()
2031
|> Map.put(:visibility, visibility)
2132
|> Map.put(:state, "active")

0 commit comments

Comments
 (0)