Skip to content

Commit 287f46c

Browse files
committed
Fix
1 parent 41fc9d3 commit 287f46c

10 files changed

Lines changed: 247 additions & 67 deletions

File tree

apps/codebattle/lib/codebattle/tournament/helpers.ex

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ defmodule Codebattle.Tournament.Helpers do
365365
def get_player_ranking_stats(tournament) do
366366
players = get_players(tournament)
367367

368-
max_draw_index = get_max_draw_index(players)
368+
max_draw_index = get_max_draw_index(tournament)
369369

370370
top_8_ids =
371371
players
@@ -421,10 +421,19 @@ defmodule Codebattle.Tournament.Helpers do
421421
}
422422
end
423423

424-
def get_max_draw_index(players) do
425-
case players do
426-
[%{max_draw_index: i} | _] -> i
427-
[] -> 0
428-
end
424+
# «Живой» максимум draw_index — индикатор глубочайшей волны победителей сетки. Имеет
425+
# смысл ТОЛЬКО для top200: там draw_index бампается за каждый плей-офф раунд. Для
426+
# остальных типов draw_index не используется как маркер сетки, поэтому возвращаем 0
427+
# (никто не «в сетке») — это сохраняет прежнее поведение endpoint'а для не-top200.
428+
#
429+
# Раньше читали хранимое поле player.max_draw_index, но оно нигде не обновляется
430+
# (всегда 0). Теперь для top200 считаем максимум по факту — как stream_controller.
431+
def get_max_draw_index(%{type: "top200"} = tournament) do
432+
tournament
433+
|> get_players()
434+
|> Enum.map(&(&1.draw_index || 0))
435+
|> Enum.max(fn -> 0 end)
429436
end
437+
438+
def get_max_draw_index(_tournament), do: 0
430439
end

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,12 @@ defmodule Codebattle.Tournament.Base do
879879
if get_matches(tournament, "playing") == [] do
880880
tournament
881881
|> update_struct(%{state: "finished", finished_at: DateTime.utc_now(:second)})
882+
# Гарантируем, что результаты последнего раунда записаны ДО подсчёта финальных
883+
# мест. На force-пути (:finish_tournament_force) раунд не проходит через
884+
# prepare_round_finish, поэтому без этого upsert строк раунда в TournamentResult
885+
# может не быть (top200 определяет победителей финалов по очкам раунда).
886+
# Идемпотентно: на штатном пути просто перезапишет уже посчитанные строки.
887+
|> Tournament.TournamentResult.upsert_results()
882888
|> compute_final_standings()
883889
|> set_stats()
884890
|> maybe_save_event_results()

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,11 @@ defmodule Codebattle.Tournament.Top200 do
221221
# поэтому их сумма = очки за 5 раундов и они стоят на 9..N. Полный пересчёт результатов
222222
# (reset+rebuild) не нужен — переставляем ТОЛЬКО топ-8: их места задаёт сетка финалов.
223223
#
224-
# upsert_results обязателен ПЕРВЫМ шагом: при завершении через finish_tournament_force
225-
# последний раунд не проходит через prepare_round_finish, и строк раунда в
226-
# TournamentResult может не быть. Без них assign_final_bracket_places определял бы
227-
# победителей пар по tie-break (id), а не по реальным очкам. upsert_results читает
228-
# результаты из таблицы games (они есть на любом пути) и идемпотентен.
224+
# Результаты раунда 7 здесь уже в TournamentResult: на любом пути завершения их пишет
225+
# finish_tournament (base) через upsert_results перед вызовом compute_final_standings.
226+
# Поэтому assign_final_bracket_places определяет победителей пар по реальным очкам.
229227
def compute_final_standings(tournament) do
230228
tournament
231-
|> TournamentResult.upsert_results()
232229
|> assign_final_bracket_places()
233230
|> recalculate_player_wins_count()
234231
end

apps/codebattle/lib/codebattle_web/channels/game_channel.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ defmodule CodebattleWeb.GameChannel do
7272
%{entries: ranking}
7373
end)
7474

75-
in_main_draw =
76-
match?(%{draw_index: draw_index, max_draw_index: draw_index}, current_player)
75+
in_main_draw = player_in_main_draw?(tournament, current_player)
7776

7877
game_params =
7978
game
@@ -134,6 +133,15 @@ defmodule CodebattleWeb.GameChannel do
134133
end
135134
end
136135

136+
# Игрок в главной сетке плей-офф = его draw_index равен максимальному среди всех
137+
# игроков (самая глубокая «волна» победителей). get_max_draw_index считает максимум
138+
# вживую только для top200 (для остальных типов вернёт 0). Зритель (nil) — не в сетке.
139+
defp player_in_main_draw?(_tournament, nil), do: false
140+
141+
defp player_in_main_draw?(tournament, %{draw_index: draw_index}) do
142+
(draw_index || 0) == Tournament.Helpers.get_max_draw_index(tournament)
143+
end
144+
137145
def terminate(_reason, socket) do
138146
{:noreply, socket}
139147
end

apps/codebattle/lib/codebattle_web/channels/tournament_admin_channel.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ defmodule CodebattleWeb.TournamentAdminChannel do
5151
end)
5252
end
5353

54-
# Get the auto-select delay (ms) for a tournament. Defaults to 0 (instant).
54+
# Get the auto-select delay (ms) for a tournament. Defaults to 5500 (5.5s).
5555
def get_autoselect_delay(tournament_id) do
5656
if Process.whereis(__MODULE__.GamesAgent) == nil do
5757
start_games_agent()
5858
end
5959

6060
Agent.get(__MODULE__.GamesAgent, fn games_map ->
61-
Map.get(games_map, {:autoselect_delay, tournament_id}, 0)
61+
Map.get(games_map, {:autoselect_delay, tournament_id}, 5500)
6262
end)
6363
end
6464

apps/codebattle/lib/codebattle_web/controllers/tournament/stream_controller.ex

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ defmodule CodebattleWeb.Tournament.StreamController do
7474
players = safe_get_players(tournament)
7575
user_history = safe_get_users_history(tournament, players)
7676
active_ids = active_player_ids(tournament, players)
77-
win_probs = compute_win_probs(user_history)
77+
win_probs = compute_win_probs(tournament, user_history, active_ids)
7878

7979
%{
8080
tournament_id: tournament.id,
@@ -171,29 +171,52 @@ defmodule CodebattleWeb.Tournament.StreamController do
171171
_ -> %{}
172172
end
173173

174-
# Win probability = each top-8 player's share of the top-8 total history score.
175-
# Score for each player is summed from their full history. Players outside the
176-
# top 8 get no win_prob.
177-
defp compute_win_probs(user_history) do
178-
totals =
179-
Map.new(user_history, fn {user_id, rounds} ->
180-
{user_id, Enum.sum(Enum.map(rounds, &(&1.score || 0)))}
181-
end)
174+
# Win probability for the players still contending for 1st place: each remaining
175+
# main-net player's share of that pool's total history score (summed from their
176+
# full history).
177+
#
178+
# The pool is the bracket "active" set (same funnel as the `active` flag), which
179+
# narrows by draw_index as the playoff progresses:
180+
# * Swiss done / QF in progress → the top-8 entering the quarterfinals
181+
# * after QF → 4 main-net survivors, after SF → 2 finalists, then the champion
182+
#
183+
# Only shown in the playoff phase (top200, >=5 completed rounds); blank during the
184+
# Swiss stage and for non-bracket tournaments. Players outside the pool get no
185+
# win_prob.
186+
defp compute_win_probs(tournament, user_history, active_ids) do
187+
if win_prob_phase?(tournament) do
188+
user_history
189+
|> active_history_totals(active_ids)
190+
|> normalize_win_probs()
191+
else
192+
%{}
193+
end
194+
end
182195

183-
top_8 =
184-
totals
185-
|> Enum.sort_by(fn {_id, score} -> -score end)
186-
|> Enum.take(8)
196+
defp active_history_totals(user_history, active_ids) do
197+
user_history
198+
|> Enum.filter(fn {user_id, _rounds} -> MapSet.member?(active_ids, user_id) end)
199+
|> Map.new(fn {user_id, rounds} ->
200+
{user_id, Enum.sum(Enum.map(rounds, &(&1.score || 0)))}
201+
end)
202+
end
187203

188-
top_8_total = top_8 |> Enum.map(fn {_id, s} -> s end) |> Enum.sum()
204+
defp normalize_win_probs(totals) do
205+
total = totals |> Map.values() |> Enum.sum()
189206

190-
if top_8_total > 0 do
191-
Map.new(top_8, fn {id, score} -> {id, round(score * 100.0 / top_8_total)} end)
207+
if total > 0 do
208+
Map.new(totals, fn {id, score} -> {id, round(score * 100.0 / total)} end)
192209
else
193210
%{}
194211
end
195212
end
196213

214+
# Win probabilities are a playoff-bracket concept: only meaningful once the Swiss
215+
# stage is over and the top-8 bracket is set (top200, >=5 completed rounds). The
216+
# active set is driven by draw_index from the quarterfinals onward.
217+
defp win_prob_phase?(%{type: "top200"} = tournament), do: completed_rounds(tournament) >= 5
218+
defp win_prob_phase?(_tournament), do: false
219+
197220
defp format_clans(clans) do
198221
Map.new(clans, fn {id, clan} ->
199222
{to_string(id), %{name: clan[:name], long_name: clan[:long_name]}}

apps/codebattle/lib/codebattle_web/live/admin/tournament_stream_view.ex

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,34 @@ defmodule CodebattleWeb.Live.Admin.TournamentStreamView do
2828
Codebattle.PubSub.subscribe("tournament:#{tournament.id}")
2929
Codebattle.PubSub.subscribe("tournament:#{tournament.id}:common")
3030
Codebattle.PubSub.subscribe("tournament:#{tournament.id}:stream")
31-
# Drive the "round ends in" countdown.
32-
Process.send_after(self(), :tick, 1000)
3331
end
3432

35-
{:ok,
36-
socket
37-
|> assign(
38-
layout: {CodebattleWeb.LayoutView, :admin},
39-
tournament: tournament,
40-
current_user: current_user,
41-
active_game_id: TournamentAdminChannel.get_active_game(tournament.id),
42-
autoselect_delay_ms: TournamentAdminChannel.get_autoselect_delay(tournament.id),
43-
widgets: @widgets,
44-
filter: "current",
45-
now: NaiveDateTime.utc_now(:second),
46-
# The simulator panel is always available on the admin stream page, so bots
47-
# can be started for any tournament — even one created without meta.simulator.
48-
simulator_enabled: true
49-
)
50-
|> assign_matches_and_players()
51-
|> assign_simulator_state()}
33+
socket =
34+
socket
35+
|> assign(
36+
layout: {CodebattleWeb.LayoutView, :admin},
37+
tournament: tournament,
38+
current_user: current_user,
39+
active_game_id: TournamentAdminChannel.get_active_game(tournament.id),
40+
autoselect_delay_ms: TournamentAdminChannel.get_autoselect_delay(tournament.id),
41+
widgets: @widgets,
42+
filter: "current",
43+
now: NaiveDateTime.utc_now(:second),
44+
# Whether the per-second countdown tick loop is currently armed.
45+
ticking: false,
46+
# The simulator panel is always available on the admin stream page, so bots
47+
# can be started for any tournament — even one created without meta.simulator.
48+
simulator_enabled: true
49+
)
50+
|> assign_matches_and_players()
51+
|> assign_simulator_state()
52+
53+
# Only arm the "round ends in" countdown once connected, and only while a
54+
# round is actually counting down (see ensure_ticking/1). A finished/waiting
55+
# tournament shows a static label, so it must not push a diff every second.
56+
socket = if connected?(socket), do: ensure_ticking(socket), else: socket
57+
58+
{:ok, socket}
5259
end
5360

5461
defp assign_simulator_state(socket) do
@@ -142,19 +149,52 @@ defmodule CodebattleWeb.Live.Admin.TournamentStreamView do
142149
"tournament:updated",
143150
"tournament:finished"
144151
] do
145-
{:noreply, socket |> assign_matches_and_players() |> assign_simulator_state()}
152+
{:noreply,
153+
socket
154+
|> assign_matches_and_players()
155+
|> assign_simulator_state()
156+
|> ensure_ticking()}
146157
end
147158

148159
def handle_info(:tick, socket) do
149-
Process.send_after(self(), :tick, 1000)
150-
{:noreply, assign(socket, now: NaiveDateTime.utc_now(:second))}
160+
socket = assign(socket, now: NaiveDateTime.utc_now(:second))
161+
162+
# Keep the loop alive only while there is a moving countdown to refresh.
163+
# Otherwise stop, so a finished/waiting tournament no longer pushes a diff
164+
# to the client every second; a new round re-arms it via ensure_ticking/1.
165+
socket =
166+
if countdown_active?(socket.assigns.tournament) do
167+
Process.send_after(self(), :tick, 1000)
168+
socket
169+
else
170+
assign(socket, ticking: false)
171+
end
172+
173+
{:noreply, socket}
151174
end
152175

153176
def handle_info(msg, socket) do
154177
Logger.debug("Stream admin LV unexpected: #{inspect(msg)}")
155178
{:noreply, socket}
156179
end
157180

181+
# Arm the countdown tick loop if it isn't already running and the tournament is
182+
# in a state that needs a per-second countdown. Idempotent: safe to call from
183+
# mount and from any handler that may have changed the tournament state.
184+
defp ensure_ticking(socket) do
185+
if not socket.assigns.ticking and countdown_active?(socket.assigns.tournament) do
186+
Process.send_after(self(), :tick, 1000)
187+
assign(socket, ticking: true)
188+
else
189+
socket
190+
end
191+
end
192+
193+
# Only an active round (including its break) has a moving "ends in / next round
194+
# in" countdown. Finished/waiting tournaments show a static label.
195+
defp countdown_active?(%{state: "active"}), do: true
196+
defp countdown_active?(_), do: false
197+
158198
defp assign_matches_and_players(socket) do
159199
tournament =
160200
try do
@@ -519,7 +559,11 @@ defmodule CodebattleWeb.Live.Admin.TournamentStreamView do
519559
</div>
520560
<% end %>
521561
522-
<details class="cb-bg-panel cb-rounded cb-border-color border shadow-sm p-2 mb-3">
562+
<details
563+
id="obs-stream-urls"
564+
phx-update="ignore"
565+
class="cb-bg-panel cb-rounded cb-border-color border shadow-sm p-2 mb-3"
566+
>
523567
<summary class="text-white" style="cursor:pointer;font-size:14px;font-weight:600">
524568
OBS / stream URLs
525569
<span class="cb-text ml-1" style="font-size:12px;font-weight:400">({length(@widgets)})</span>

apps/codebattle/test/codebattle/tournament/entire/top200_test.exs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ defmodule Codebattle.Tournament.Entire.Top200Test do
253253
end
254254

255255
describe "compute_final_standings/1 — финальные места" do
256-
test "топ-8 расставляются по сетке финалов, места 9+ сохраняются по сумме 5 раундов" do
256+
test "топ-8 по результату финалов (очки раунда 7): place + draw_index, ровно 1 «живой»" do
257257
tournament = insert_top200_tournament()
258258
players_table = Tournament.Players.create_table(tournament.id)
259259

@@ -268,17 +268,30 @@ defmodule Codebattle.Tournament.Entire.Top200Test do
268268

269269
tournament = %{tournament | players_table: players_table, current_round_position: 7, matches: finals}
270270

271-
# Топ-8: {draw_index, score}. Победитель пары — больший draw_index (его поднял
272-
# calculate_round_results). Очки нарочно выше у проигравших — доказываем, что место
273-
# топ-8 определяет сетка, а не сумма очков.
271+
# Победитель каждой пары — по СУММЕ ОЧКОВ РАУНДА 7. Победители: 2, 4, 5, 8.
272+
record_scores(tournament.id, 7, [
273+
{1, 10},
274+
{2, 100},
275+
{3, 10},
276+
{4, 100},
277+
{5, 100},
278+
{6, 10},
279+
{7, 10},
280+
{8, 100}
281+
])
282+
283+
# Топ-8: {начальный draw_index, накопленный score}. Оба НАРОЧНО инвертированы — у
284+
# проигравших финал выше и draw_index (имитация force-финиша, где бамп не успел
285+
# развести финалистов), и накопленная сумма. Доказываем: место и draw_index задаёт
286+
# результат финала (очки раунда 7), а не прежний draw_index и не сумма очков.
274287
top8 = %{
275-
1 => {3, 999},
276-
2 => {4, 10},
277-
3 => {1, 999},
278-
4 => {2, 10},
279-
5 => {2, 10},
280-
6 => {1, 999},
281-
7 => {0, 999},
288+
1 => {9, 999},
289+
2 => {1, 10},
290+
3 => {9, 999},
291+
4 => {1, 10},
292+
5 => {1, 10},
293+
6 => {9, 999},
294+
7 => {9, 999},
282295
8 => {1, 10}
283296
}
284297

@@ -301,7 +314,7 @@ defmodule Codebattle.Tournament.Entire.Top200Test do
301314

302315
place_of = fn id -> Tournament.Players.get_player(tournament, id).place end
303316

304-
# Места топ-8 по сетке финалов (победитель пары — лучшее место).
317+
# Места топ-8 по результату финалов (победитель пары — лучшее место).
305318
assert place_of.(2) == 1
306319
assert place_of.(1) == 2
307320
assert place_of.(4) == 3
@@ -311,6 +324,23 @@ defmodule Codebattle.Tournament.Entire.Top200Test do
311324
assert place_of.(8) == 7
312325
assert place_of.(7) == 8
313326

327+
# draw_index = 9 - place: у чемпиона уникальный максимум (8), дальше по убыванию.
328+
assert draw_index_by_id(tournament, [1, 2, 3, 4, 5, 6, 7, 8]) == %{
329+
2 => 8,
330+
1 => 7,
331+
4 => 6,
332+
3 => 5,
333+
5 => 4,
334+
6 => 3,
335+
8 => 2,
336+
7 => 1
337+
}
338+
339+
# Игроки вне сетки (места 9+) — дефолтный draw_index (1), заведомо ниже максимума (8).
340+
# Значит «живой» (draw_index == max) ровно один — чемпион (id 2). Это и есть починка
341+
# «2 active после финала».
342+
assert draw_index_by_id(tournament, [9, 10, 11]) == %{9 => 1, 10 => 1, 11 => 1}
343+
314344
# Места 9+ без изменений.
315345
assert place_of.(9) == 9
316346
assert place_of.(10) == 10

apps/codebattle/test/codebattle/tournament/helpers_behavior_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ defmodule Codebattle.Tournament.HelpersBehaviorTest do
186186
tournament =
187187
build_ets_tournament(%{
188188
id: System.unique_integer([:positive]),
189+
# get_max_draw_index/«active»/top-8/win_prob — это статистика сетки top200.
190+
type: "top200",
189191
state: "active",
190192
current_round_position: 3,
191193
use_clan: true,

0 commit comments

Comments
 (0)