Skip to content

Commit 3a2efff

Browse files
committed
Fix tournaments
1 parent 3923058 commit 3a2efff

18 files changed

Lines changed: 243 additions & 155 deletions

File tree

CLAUDE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What is Codebattle
6+
7+
Open-source competitive programming platform where users solve coding tasks head-to-head in real-time. Built by the Hexlet community. Supports 20+ programming languages via containerized execution.
8+
9+
## Tech Stack
10+
11+
- **Backend:** Elixir 1.19.4 / OTP 28.2, Phoenix ~1.8 with LiveView
12+
- **Frontend:** React + Redux Toolkit, Vite, Monaco Editor
13+
- **Database:** PostgreSQL
14+
- **Code Execution:** Docker/Podman containers per language (runner service)
15+
- **Package Manager:** pnpm (not npm) for frontend
16+
17+
## Project Structure
18+
19+
Elixir umbrella project with three apps under `apps/`:
20+
- `codebattle` — Main Phoenix web app (backend + frontend assets)
21+
- `runner` — HTTP service that executes user code in isolated containers; language images in `apps/runner/images/`
22+
- `phoenix_gon` — Shared library for passing server data to frontend
23+
24+
Frontend source lives in `apps/codebattle/assets/js/` with React widgets, Redux slices, and XState machines.
25+
26+
See `AGENTS.md` for detailed module organization and core domain contexts.
27+
28+
## Common Commands
29+
30+
### Development
31+
```bash
32+
make compose # Start app + db via Docker Compose
33+
make server # Local: iex -S mix phx.server
34+
make console # Local: iex -S mix
35+
cd apps/codebattle && pnpm run dev # Vite dev server with HMR (port 8080)
36+
```
37+
38+
### Testing
39+
```bash
40+
make test # ExUnit + coverage (excludes image_executor)
41+
make test-code-checkers # Image executor tests (CODEBATTLE_EXECUTOR=local)
42+
make compose-test # Tests in Docker
43+
cd apps/codebattle && pnpm test # Jest frontend tests
44+
45+
# Single Elixir test file:
46+
mix test apps/codebattle/test/codebattle/game/context_test.exs
47+
48+
# Single frontend test:
49+
cd apps/codebattle && pnpm test UserStats.test.jsx
50+
```
51+
52+
### Linting & Formatting
53+
```bash
54+
make format # mix format
55+
make lint # mix format --check-formatted
56+
make credo # Credo static analysis
57+
make dialyzer # Type checking
58+
make lint-js # OXLint + stylelint
59+
make lint-js-fix # Auto-fix JS lint issues
60+
```
61+
62+
### Setup
63+
```bash
64+
make setup # Full first-time setup (Docker)
65+
make setup-env-local # Local setup without Docker (requires asdf)
66+
make compose-db-setup # Create + migrate database
67+
make compose-db-migrate # Apply pending migrations
68+
```
69+
70+
## Code Style
71+
72+
- Elixir: enforced by `mix format` and Credo (120-char line limit)
73+
- JavaScript: OXLint (`.oxlintrc.json`), Prettier, Stylelint
74+
- Coverage threshold: 60% minimum (ExCoveralls)
75+
76+
## CI Pipeline
77+
78+
GitHub Actions (`.github/workflows/master.yml`): runs ExUnit, Credo, Dialyzer, format check, frontend lint + tests, then builds and pushes container images to `ghcr.io/hexlet-codebattle/`.

apps/codebattle/assets/js/widgets/middlewares/Tournament.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,11 @@ export const connectToTournament = (newTournamentId) => (dispatch) => {
137137
playersPageSize: 16,
138138
matches: {},
139139
players: {},
140+
ranking: { entries: [] },
140141
}),
141142
);
143+
144+
dispatch(actions.updateTournamentPlayers(compact(response.players || [])));
142145
};
143146

144147
const handlePlayerJoined = (response) => {

apps/codebattle/assets/js/widgets/middlewares/TournamentAdmin.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,11 @@ export const connectToTournament =
145145
playersPageSize: 16,
146146
matches: {},
147147
players: {},
148+
ranking: { entries: [] },
148149
}),
149150
);
151+
152+
dispatch(actions.updateTournamentPlayers(compact(response.players || [])));
150153
};
151154

152155
const handlePlayerJoined = (response) => {

apps/codebattle/assets/js/widgets/pages/game/EditorToolbar.jsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,13 @@ function EditorToolbar({
5454
<div className={toolbarClassNames} role="toolbar">
5555
<div className="d-flex justify-content-between">
5656
<div className={editorSettingClassNames} role="group" aria-label="Editor settings">
57-
{!hideToolbarControls && <LanguagePicker editor={editor} status={langPickerStatus} />}
57+
<LanguagePicker editor={editor} status={langPickerStatus} />
5858
</div>
59-
{showControlBtns && !isHistory && !hideToolbarControls && (
60-
<ModeButtons player={player} />
61-
)}
59+
{showControlBtns && !isHistory && <ModeButtons player={player} />}
6260
</div>
6361

6462
<div className="d-flex justify-content-between">
65-
{showControlBtns && !isHistory && !hideToolbarControls && editorState !== "banned" && (
63+
{showControlBtns && !isHistory && editorState !== "banned" && (
6664
<GameActionButtons {...actionBtnsProps} />
6765
)}
6866
{!showControlBtns && !hideToolbarControls && (

apps/codebattle/assets/js/widgets/pages/tournament/PlayersRankingPanel.jsx

Lines changed: 83 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ function PlayersRankingPanel({ playersCount, ranking }) {
3232
const currentUserClanId = useSelector(currentUserClanIdSelector);
3333
const currentUserId = useSelector(currentUserIdSelector);
3434
const requestedNearestPage = useRef(false);
35-
const requestedFirstPage = useRef(false);
3635
const manualPageChange = useRef(false);
3736

3837
const rankingItems = useMemo(() => ranking?.entries || [], [ranking?.entries]);
@@ -85,12 +84,11 @@ function PlayersRankingPanel({ playersCount, ranking }) {
8584
}, [effectivePageNumber, isServerPaged, totalPages]);
8685

8786
useEffect(() => {
88-
if (requestedFirstPage.current || manualPageChange.current) {
87+
if (manualPageChange.current) {
8988
return;
9089
}
9190

9291
if (rankingItems.length === 0 && playersCount > 0) {
93-
requestedFirstPage.current = true;
9492
dispatch(requestRankingPage(1, effectivePageSize));
9593
}
9694
}, [dispatch, effectivePageSize, playersCount, rankingItems.length]);
@@ -136,96 +134,94 @@ function PlayersRankingPanel({ playersCount, ranking }) {
136134
{playersCount === 0 ? (
137135
<p className="text-nowrap text-muted">{i18next.t("No players yet")}.</p>
138136
) : (
139-
rankingItems.length !== 0 && (
140-
<div
141-
className={cn(
142-
"d-flex flex-column flex-grow-1 postion-relative py-2 mh-100 rounded-left",
143-
)}
144-
>
145-
<div className="d-flex justify-content-between border-bottom cb-border-color pb-2 px-3">
146-
<span className="font-weight-bold">{i18next.t("Ranking")}</span>
147-
<span className="text-muted small">
148-
{i18next.t("Page")} {effectivePageNumber} {i18next.t("of")} {totalPages}
149-
</span>
150-
</div>
151-
<div className="d-flex cb-overflow-x-auto">
152-
<table className="table cb-text-light table-striped cb-custom-event-table m-1">
153-
<colgroup>
154-
<col style={{ width: "12%" }} />
155-
<col style={{ width: "40%" }} />
156-
<col style={{ width: "30%" }} />
157-
<col style={{ width: "18%" }} />
158-
</colgroup>
159-
<thead>
160-
<tr>
161-
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Place")}</th>
162-
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Player")}</th>
163-
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Clan")}</th>
164-
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Score")}</th>
165-
</tr>
166-
</thead>
167-
<tbody>
168-
{pagedRankingItems.map((item) => (
169-
<React.Fragment key={item.id}>
170-
<tr className="cb-custom-event-empty-space-tr" />
171-
<tr className={getCustomEventTrClassName(item, currentUserClanId)}>
172-
<td
137+
<div
138+
className={cn(
139+
"d-flex flex-column flex-grow-1 postion-relative py-2 mh-100 rounded-left",
140+
)}
141+
>
142+
<div className="d-flex justify-content-between border-bottom cb-border-color pb-2 px-3">
143+
<span className="font-weight-bold">{i18next.t("Ranking")}</span>
144+
<span className="text-muted small">
145+
{i18next.t("Page")} {effectivePageNumber} {i18next.t("of")} {totalPages}
146+
</span>
147+
</div>
148+
<div className="d-flex cb-overflow-x-auto">
149+
<table className="table cb-text-light table-striped cb-custom-event-table m-1">
150+
<colgroup>
151+
<col style={{ width: "12%" }} />
152+
<col style={{ width: "40%" }} />
153+
<col style={{ width: "30%" }} />
154+
<col style={{ width: "18%" }} />
155+
</colgroup>
156+
<thead>
157+
<tr>
158+
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Place")}</th>
159+
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Player")}</th>
160+
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Clan")}</th>
161+
<th className="p-1 pl-4 font-weight-light border-0">{i18next.t("Score")}</th>
162+
</tr>
163+
</thead>
164+
<tbody>
165+
{pagedRankingItems.map((item) => (
166+
<React.Fragment key={item.id}>
167+
<tr className="cb-custom-event-empty-space-tr" />
168+
<tr className={getCustomEventTrClassName(item, currentUserClanId)}>
169+
<td
170+
style={{
171+
borderTopLeftRadius: "0.5rem",
172+
borderBottomLeftRadius: "0.5rem",
173+
}}
174+
className={tableDataCellClassName}
175+
>
176+
{item.place}
177+
</td>
178+
<td className={tableDataCellClassName}>
179+
<div
180+
title={item?.name}
181+
className="cb-custom-event-name"
173182
style={{
174-
borderTopLeftRadius: "0.5rem",
175-
borderBottomLeftRadius: "0.5rem",
183+
textOverflow: "ellipsis",
184+
overflow: "hidden",
185+
whiteSpace: "nowrap",
186+
maxWidth: "20ch",
176187
}}
177-
className={tableDataCellClassName}
178188
>
179-
{item.place}
180-
</td>
181-
<td className={tableDataCellClassName}>
182-
<div
183-
title={item?.name}
184-
className="cb-custom-event-name"
185-
style={{
186-
textOverflow: "ellipsis",
187-
overflow: "hidden",
188-
whiteSpace: "nowrap",
189-
maxWidth: "20ch",
190-
}}
191-
>
192-
{item?.lang && <LanguageIcon className="mr-1" lang={item.lang} />}
193-
{(item?.name ?? "").slice(0, 10) +
194-
((item?.name?.length ?? 0) > 10 ? ".." : "")}
195-
</div>
196-
</td>
197-
<td className={tableDataCellClassName}>
198-
<div
199-
title={item?.clan}
200-
className="cb-custom-event-name"
201-
style={{
202-
textOverflow: "ellipsis",
203-
overflow: "hidden",
204-
whiteSpace: "nowrap",
205-
maxWidth: "20ch",
206-
}}
207-
>
208-
{(item?.clan ?? "").slice(0, 10) +
209-
((item?.clan?.length ?? 0) > 10 ? "..." : "")}
210-
</div>
211-
</td>
212-
<td className={tableDataCellClassName}>{item.score}</td>
213-
<td
189+
{item?.lang && <LanguageIcon className="mr-1" lang={item.lang} />}
190+
{(item?.name ?? "").slice(0, 10) +
191+
((item?.name?.length ?? 0) > 10 ? ".." : "")}
192+
</div>
193+
</td>
194+
<td className={tableDataCellClassName}>
195+
<div
196+
title={item?.clan}
197+
className="cb-custom-event-name"
214198
style={{
215-
borderTopRightRadius: "0.5rem",
216-
borderBottomRightRadius: "0.5rem",
199+
textOverflow: "ellipsis",
200+
overflow: "hidden",
201+
whiteSpace: "nowrap",
202+
maxWidth: "20ch",
217203
}}
218-
className={tableDataCellClassName}
219-
aria-label={i18next.t("Row spacer")}
220-
/>
221-
</tr>
222-
</React.Fragment>
223-
))}
224-
</tbody>
225-
</table>
226-
</div>
204+
>
205+
{(item?.clan ?? "").slice(0, 10) +
206+
((item?.clan?.length ?? 0) > 10 ? "..." : "")}
207+
</div>
208+
</td>
209+
<td className={tableDataCellClassName}>{item.score}</td>
210+
<td
211+
style={{
212+
borderTopRightRadius: "0.5rem",
213+
borderBottomRightRadius: "0.5rem",
214+
}}
215+
className={tableDataCellClassName}
216+
aria-label={i18next.t("Row spacer")}
217+
/>
218+
</tr>
219+
</React.Fragment>
220+
))}
221+
</tbody>
222+
</table>
227223
</div>
228-
)
224+
</div>
229225
)}
230226
</div>
231227
<div className="d-flex align-items-center flex-wrap justify-content-start">

apps/codebattle/lib/codebattle/tournament/clans.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Codebattle.Tournament.Clans do
55

66
@spec add_players_clan(tournament :: Tournament.t(), player :: Tournament.Player.t()) :: :ok
77
def add_players_clan(tournament, %{clan_id: nil}) do
8-
Clans.put_clans(tournament, [%{id: -1, name: "UndifinedClan", long_name: "UndifinedClan"}])
8+
Clans.put_clans(tournament, [%{id: -1, name: "UndefinedClan", long_name: "UndefinedClan"}])
99
end
1010

1111
def add_players_clan(tournament, player) do

apps/codebattle/lib/codebattle/tournament/context.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ defmodule Codebattle.Tournament.Context do
459459
_ -> nil
460460
end
461461

462-
show_results = params[:show_results] || true
462+
show_results = if is_nil(params[:show_results]), do: true, else: params[:show_results]
463463

464464
Map.merge(params, %{
465465
access_token: access_token,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ defmodule Codebattle.Tournament.Helpers do
5252

5353
def get_matches(tournament, ids) when is_list(ids), do: Enum.map(ids, &get_match(tournament, &1))
5454

55-
def get_matches(tournament, state) when is_binary(state) do
55+
def get_matches(%{matches_table: nil} = tournament, state) when is_binary(state) do
5656
tournament |> get_matches() |> Enum.filter(&(&1.state == state))
5757
end
5858

@@ -386,7 +386,7 @@ defmodule Codebattle.Tournament.Helpers do
386386
total_top_8_score = top_8_players |> Enum.map(& &1.score) |> Enum.sum()
387387

388388
win_probs =
389-
if tournament.current_round_position >= 3 do
389+
if tournament.current_round_position >= 3 and total_top_8_score > 0 do
390390
Enum.reduce(top_8_players, %{}, fn player, acc ->
391391
win_prob = round((player.score || 0) * 100.0 / (total_top_8_score * 1.0))
392392
Map.put(acc, player.id, win_prob)

apps/codebattle/lib/codebattle/tournament/match.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ defmodule Codebattle.Tournament.Match do
3535
:finished_at,
3636
:game_id,
3737
:id,
38-
:integer,
3938
:level,
4039
:player_ids,
4140
:rematch,

apps/codebattle/lib/codebattle/tournament/ranking/by_clan.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ defmodule Codebattle.Tournament.Ranking.ByClan do
2121
tournament
2222
|> Ranking.get_by_id(get_clan_id(player))
2323
|> case do
24-
nil -> 0
25-
%{place: place} -> div(place, @page_size) + 1
24+
nil -> 1
25+
%{place: place} -> div(place - 1, @page_size) + 1
2626
end
2727
|> then(&get_page(tournament, &1, @page_size))
2828
end

0 commit comments

Comments
 (0)