Commit aeeb1740 authored by Mahmoud Aglan's avatar Mahmoud Aglan

docs: playbook for running a Swiss + top-8 knockout championship

Covers hosting, announcing and monetising the format end to end, verified
against the live code and database rather than inferred from column names.

The headline finding is that the built-in multi-phase path — the preset
literally labelled سويسري ← إقصاء — cannot work: startSwissPhase reads a
swiss_event_id column that does not exist, startPhase merges that error into a
success envelope so it fails silently, getQualifiedPlayers can only read the
external Swiss service, and tournamentFinish completes the tournament before
the advance buttons can be pressed. tournament_phases, tournament_brackets and
bracket_matches all hold zero rows in production; it has never run.

The document gives the path that does work — the Swiss natively, a manual cut,
and the knockout as a second tournament whose phase 1 is the bracket, which is
reachable because getPhasePlayers reads registrations directly for phase 1 —
and is blunt about entry fees and prizes being displayed but never moved.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent bc768f8b
# EL3AB Tournament Playbook — Swiss + Top-8 Knockout
> How to host, announce and monetise a full-scale EL3AB championship: a Swiss
> qualifying stage, a cut to the top 8, and a single-elimination bracket to a winner.
>
> **Verified against production on 2026-09-01.** Every route, column, field name and
> Arabic label below was read out of the live code or queried against the live
> database. Where something does not work, this document says so and gives the path
> that does.
---
## Reality check — read this first
The single most important thing to know before you plan an event:
> **The one-click "Swiss → top 8 → bracket" pipeline is built but not wired up.**
> The configuration UI exists, the schema exists, the bracket engine is good — and the
> chain between them is severed. You can still run this exact format today, and this
> playbook shows you how, but the cut to 8 is a **manual step you perform**, not
> something the system does for you.
| Capability | State | Notes |
|---|---|---|
| Swiss stage, fully automatic | **Works** | Pairing, colours, byes, tiebreaks, auto-advance between rounds |
| Public live page, leaderboard, boards | **Works** | Real-time, shareable, QR, embeddable |
| Knockout bracket generation & propagation | **Works** | Good engine — but must be fed players by hand |
| Swiss standings → top 8 → bracket, automatically | **Broken** | See [The cut](#16-the-cut-to-8) for the reason and the workaround |
| Players playing a bracket match in-app | **Broken** | `bracket_matches` has no link to a playable game |
| Ad slots on the live page | **Works** | Full admin CRUD; the one monetisation feature that is real |
| Entry fees charged / prizes paid | **Broken** | Displayed everywhere, deducted and paid nowhere. Settle by hand |
**Production evidence:** `tournament_phases`, `tournament_brackets`, `bracket_matches`
and `tournament_prize_payouts` all hold **0 rows**. All 8 tournaments that have ever
existed are `tournament_mode='single'`, `format='swiss'`. The multi-phase path has
never once been run end to end.
---
## Contents
1. [The shape of the event](#1-the-shape-of-the-event)
2. [Before you start](#2-before-you-start)
3. [Part 1 — Host](#part-1--host)
4. [Part 2 — Announce](#part-2--announce)
5. [Part 3 — Monetise](#part-3--monetise)
6. [Run-day runbook](#6-run-day-runbook)
7. [Troubleshooting](#7-troubleshooting)
8. [Known gaps](#8-known-gaps)
---
## 1. The shape of the event
```
REGISTRATION SWISS STAGE CUT KNOCKOUT
──────────── ─────────────────── ───────── ─────────────────
QF SF F
N players → R rounds, everyone → top 8 → ●─┐
register plays every round by ●─┘─┐
(automatic) standings ●─┐ ├─┐
(manual) ●─┘─┘ │
●─┐ ├── 🏆
●─┘─┐ │
●─┐ ├─┘
●─┘─┘
```
### How many Swiss rounds?
The engine's own rule (`tournamentSuggestedRounds`, `includes/tournament-engine.php:774`):
```php
max(3, min(11, ceil(log2(N)) + 1))
```
| Players | Rounds | Players | Rounds |
|---|---|---|---|
| 8–16 | 5 | 33–64 | 7 |
| 17–32 | 7 | 65–128 | 8 |
For a top-8 cut, do not go below this. Fewer rounds and the 8th and 12th place
finishers will be separated only by tiebreaks, which makes the cut feel arbitrary and
invites disputes. **7 rounds for a field of 32–64 is the sweet spot** for a one-day event.
### Time control
Set once, applies to the whole Swiss stage. `blitz_5_0` (5 minutes, no increment) keeps
a 7-round event inside one afternoon. `rapid_10_5` is more serious chess but roughly
doubles the day. The knockout can use a different time control because you run it as a
separate tournament (see [1.7](#17-run-the-knockout)).
---
## 2. Before you start
| What | Where |
|---|---|
| Manager (admin) | `https://el3ab-management.caprover.al-arcade.com` |
| Player app | `https://el3ab-player.caprover.al-arcade.com` |
| Live page (public) | `https://el3ab-management.caprover.al-arcade.com/live/{slug}` |
Log in at `/login`. There is a single production operator account, username `admin`,
role `superadmin`, stored in the `admin_users` table.
> ⚠️ **Security note before a public event.** `config/app.php` carries live credentials
> as inline defaults committed to git — the Supabase **service key**, `SWISS_API_PASSWORD`,
> the Stockfish API key and the admin password hash. Anyone with repo access has
> production. Move these to environment variables and rotate them before you publicise
> anything.
**Do a dress rehearsal.** There is a script that runs an entire tournament end to end
across both apps and cleans up after itself:
```bash
export SUPABASE_SERVICE_KEY=...
node tools/verify-cycle.mjs # 25 checks against production
```
Run it the day before. If it passes, the Swiss stage will work on the day.
---
# Part 1 — Host
## 1.1 Create the tournament
`/tournaments/create` — a 5-step wizard that posts 15 fields to `/tournaments/store`.
**Set `وضع البطولة *` (tournament mode) to the FIRST option — single, not multi-phase.**
This is counter-intuitive, because the second option is literally labelled
`متعدد المراحل` with the sub-label `مراحل متتالية (مثال: سويسري ← إقصاء)` — "sequential
phases (example: Swiss → elimination)", which is exactly the format you want. Choosing
it unlocks a phase designer with a ready-made preset button `سويسري ← إقصاء` that builds
a 7-round Swiss feeding a top-8 single elimination.
**Do not use it.** It is broken end to end — see [1.6](#16-the-cut-to-8). Worse, it fails
*silently*: the tournament will report its Swiss phase as running while nothing was
actually configured. Run the Swiss as a plain single-mode tournament instead.
### Fields that matter
| Field | Label | Accepts | Note |
|---|---|---|---|
| `name` | اسم البطولة | text | Appears in the hero, the share text and the tab title |
| `game_key` | اللعبة | `chess` | |
| `format` | نظام البطولة | `swiss` | |
| `time_control` | — | `blitz_5_0`, `rapid_10_5`, … | See [§1](#time-control) |
| `rounds_count` | عدد الجولات | integer | **The number that governs play.** Writes both `swiss_rounds` and `rounds_total` |
| `max_players` | الحد الأقصى للاعبين | integer | Enforced at registration |
| `starts_at` | — | datetime | Drives the countdown and `auto_start` |
| `entry_fee_coins` | — | integer | **Displayed only — never charged.** See [Part 3](#part-3--monetise) |
| `prize_pool_coins` | — | integer | **Displayed only — never paid.** See [Part 3](#part-3--monetise) |
### Fields the form never asks for
These come from database defaults and can only be changed by direct update:
`min_players` (defaults to **4**), `is_rated`, `auto_start`,
`registration_opens_at`, `registration_closes_at`, `min_rating`, `max_rating`.
`min_players` is the hidden gate that decides whether `auto_start` ever fires — a
tournament with 3 registrations and `auto_start` on will simply never start, reporting
`Not enough players (3/4)` on every scheduler tick.
> `min_rating` / `max_rating` exist as columns but the player app **never reads them**.
> A rating-restricted event has to be policed by hand.
## 1.2 Open registration
`POST /tournaments/{id}/open-registration` — the `فتح التسجيل` button.
Be aware this is **cosmetic as far as players are concerned**. The player app's
`api/tournaments.php` accepts registration on `draft` *and* `registration` status, and
the tournament hub shows `سجّل الآن` on both. **The moment you create the tournament,
players can already register.** If you need a hard embargo, create it with
`max_players` set to 0 or keep `starts_at` far in the future until you are ready.
**What registration actually enforces:** status in (registration, draft),
`registration_closes_at`, `max_players`, and not-already-registered. That is all.
## 1.3 Start the Swiss
`POST /tournaments/{id}/start` — the Start button.
This flips the status to `in_progress` **and** asks the player app's engine to pair
round 1, authenticating with the Supabase service key both apps hold. The success
message tells you how many boards were created:
> `تم بدء البطولة وتم إنشاء الجولة الأولى (16 مباراة)`
If you instead see `— لكن لم تُنشأ أي مباريات، راجع عدد اللاعبين المسجلين`, you have
fewer registrations than `min_players`.
## 1.4 During the Swiss — what runs itself
| Behaviour | Detail |
|---|---|
| Pairing | Backtracking matcher, no rematches, FIDE colour allocation, automatic byes |
| Round advance | **Automatic** the moment every board in the round has a result |
| Tiebreaks | Buchholz cut-1, Buchholz, Sonneborn-Berger — computed live |
| No-show forfeit | Default **600 seconds** (`TOURNAMENT_NO_SHOW_SECONDS`) |
| Stuck clocks | Swept and flagged so a dead board cannot stall the round |
Players get into their game by pressing **`العب`** in the app — it is never automatic.
Tell them this in your briefing; a player sitting on the tournament screen waiting to be
teleported into a game will time out.
If a round looks stuck, press **Generate Round** (`POST /tournaments/{id}/generate-round`).
For a natively-run tournament this asks the engine to sweep: it will close out dead
clocks, forfeit no-shows and advance if the round is genuinely finished. If the round is
still live it tells you so rather than doing damage:
> `الجولة 4 ما زالت جارية — الجولة التالية تُنشأ تلقائيًا فور انتهاء كل مبارياتها`
## 1.5 When the Swiss ends
When the final round completes, `tournamentMaybeAdvance()` calls `tournamentFinish()`,
which writes every player's `final_standing` into `tournament_registrations` and sets the
tournament to `completed`.
**This is your qualifying table.** Read the top 8 off the live page
(`/live/{slug}`) or from the API:
```bash
curl -s "https://el3ab-management.caprover.al-arcade.com/api/live/{slug}/standings" \
| python3 -c "import json,sys;[print(f\"{p['rank']:>2} {p['name']:<28} {p['points']:>4} TB {p.get('buchholzCut1','—')}\") for p in json.load(sys.stdin)[:8]]"
```
Settle ties **before** you announce the 8. The engine ranks by points, then Buchholz
cut-1, then Buchholz, then Sonneborn-Berger. Publish that order of precedence in your
rules so an 8th/9th place tie is decided by a rule everyone saw in advance.
## 1.6 The cut to 8
### Why the automatic path does not work
Four independent breaks, any one of which is fatal:
1. **`PhaseManager::startSwissPhase()` reads a column that does not exist.**
`modules/tournaments/services/PhaseManager.php:198` reads
`$tournament['swiss_event_id']`. There is no `swiss_event_id` column on
`el3ab_tournaments` — PostgREST returns `42703, column does not exist`. So the
function always returns `['error' => 'No Swiss API event linked']`.
2. **`startPhase()` reports that failure as a success.** After the switch,
`PhaseManager.php:79-90` unconditionally marks the phase `in_progress`, sets
`current_phase`, and returns `array_merge(['success' => true], $result)` — merging
the error *into* a success envelope. The database says the phase is running; nothing
was configured.
3. **`getQualifiedPlayers()` can only read the external Swiss service.**
`PhaseManager.php:321` gates on `$phase['swiss_api_tournament_id']`, which break #1
leaves permanently NULL, and it never reads the native engine's own standings from
`el3ab_tournament_rounds`. So the top 8 can never be computed, and
`startEliminationPhase()` returns `No players for elimination phase`.
4. **The tournament closes before you can advance it.** `tournamentFinish()`
(`includes/tournament-engine.php:781`) sets the tournament to `completed` when the
Swiss rounds run out and never consults `tournament_phases`. The `إكمال المرحلة` and
`ترقية اللاعبين` buttons are gated on the tournament being `in_progress`, so they
vanish at exactly the moment you need them.
### What works instead
`getPhasePlayers()` has a special case: **for `phase_number === 1` it reads
`tournament_registrations` directly** and hands them straight to the bracket engine.
That is the door.
**Run the knockout as a second tournament whose phase 1 IS the elimination phase.**
## 1.7 Run the knockout
**Step 1 — create a second tournament.** Name it clearly (`… — Knockout Stage`), set
`وضع البطولة` to `متعدد المراحل` this time, and in the phase designer build a **single
phase** of type `إقصاء مباشر` (single elimination). Delete the Swiss phase the preset
adds — you want exactly one phase, and it must be phase 1.
**Step 2 — register exactly the 8 qualifiers into it.** Use
`POST /tournaments/{id}/players` (the admin add-player path), in **finishing order**
seed 1 first, seed 8 last.
**Step 3 — start phase 1.** `POST /tournaments/{id}/phases/{phaseId}/start`. This calls
`BracketEngine::generateSingleElimination()` with your 8 players and creates:
- one `tournament_brackets` row (`bracket_type: 'winners'`, `total_rounds: 3`)
- 7 `bracket_matches` rows across QF / SF / F, wired together by `next_match_id`
- standard seeding order — 1v8, 4v5, 2v7, 3v6
**Seeding method** comes from `config.seed_method` and defaults to **`rating`**, which
would re-sort your 8 by Elo and throw away the Swiss result. Set it to `manual` in the
phase config so the registration order you set in step 2 is honoured. `random` is also
available if you want a drawn bracket.
Byes are handled automatically (`advanceByes`) if you cut to a number that is not a
power of two — but cut to 8 and you will not need them.
**Step 4 — play the games.** This is the manual part. `bracket_matches` has **no column
linking to the `matches` table**, so a bracket match is an administrative record, not a
playable game. Your 8 players play their quarter-finals as ordinary games in the app —
a direct challenge at the agreed time control — and you record the outcome.
**Step 5 — record each result.**
```
POST /tournaments/{id}/bracket/matches/{matchId}/result
result = player_a_wins | player_b_wins | draw
score_a, score_b (optional)
```
`BracketEngine::submitMatchResult()` writes the winner **and propagates them into the
next match automatically** via `next_match_id` / `next_match_slot`. Record the four
quarter-finals and the semi-finals populate themselves.
**Step 6 — the bracket is public.** It renders at `/live/{slug}/bracket` and inside the
live page when `live_visibility.bracket` is true, with real connector lines, winner
highlighting and the final given a gold treatment.
> **Best-of matches:** `bracket_best_of` is a column nobody reads. If you want best-of-3
> quarter-finals, play them and enter the aggregate into `score_a` / `score_b` yourself.
---
# Part 2 — Announce
## 2.1 Turn the live page on
`/tournaments/{id}/live-settings` is the control room. Four things to do:
| Action | Route |
|---|---|
| Set the URL slug | `POST /tournaments/{id}/live-settings/slug` |
| Turn the public page on | `POST /tournaments/{id}/live-settings/toggle` |
| Section visibility, theme, custom CSS | `POST /tournaments/{id}/live-settings/update` |
| Traffic | `GET /tournaments/{id}/live-settings/analytics` |
**Set the slug before you publicise anything.** It is the whole shareable identity:
`https://el3ab-management.caprover.al-arcade.com/live/world-championship-2026`.
Keep it short, lowercase and hyphenated — it goes on posters and into QR codes, and
changing it later breaks every link already in the wild.
## 2.2 Choose what the world sees
`live_visibility` is a JSON object; every key is a boolean gating one section:
```json
{
"standings": true, "pairings": true, "bracket": true, "players": true,
"stats": true, "schedule": true, "prizes": true, "rules": true,
"announcements": true, "gallery": true, "ticker": true
}
```
For a Swiss stage turn on `standings`, `pairings`, `schedule`, `stats`, `players`.
For the knockout, turn on `bracket`. Sections with nothing in them are hidden
automatically, so leaving a key on costs you nothing.
## 2.3 Theme and banner
`live_theme` accepts exactly one of: **`default`**, `minimal`, `neon`, `royal`, `arena`.
Each is a pure palette override — every layout, component and behaviour is identical, so
switching themes is zero-risk. `live_custom_css` is injected after the theme if you need
organiser colours.
**Upload a banner.** `banner_url` fills the hero background, and — critically —
it is the **only** source for `og:image`.
## 2.4 Make the link preview properly
`layouts/public.php` emits a full social card: `og:title`, `og:description`, `og:image`,
`og:url`, `twitter:card = summary_large_image`, plus JSON-LD `SportsEvent`.
> ⚠️ **`og:image` is `banner_url` with an empty-string fallback.** With no banner
> uploaded, the tag is emitted empty and WhatsApp, X and Telegram will show your link
> as a bare grey rectangle. **Upload a 1200×630 banner before you share the link
> anywhere.** This is the single highest-leverage five minutes in this entire document.
>
> `og:description` comes from the tournament's `description` field — write one.
>
> The JSON-LD `startDate` reads `start_date`, a column that does not exist (the real one
> is `starts_at`), so it always serialises empty. Harmless, but it means you get no
> rich event result in search.
## 2.5 Announcements
`POST /tournaments/{id}/live-settings/announcements/store`. They appear on the live page
and are served at `/api/live/{slug}/announcements`. There is an `is_pinned` flag and
pinned items sort first.
Use them for exactly the things spectators cannot infer from the board: *"Round 4 starts
at 16:30 after a 15-minute break"*, *"Board 1 is being reviewed by the arbiter"*,
*"Top 8 confirmed — knockout draw at 18:00"*.
## 2.6 QR, embed and the venue screen
- **QR code** — generated automatically and displayed at the top of the share card on
every live page. Screenshot it for posters and the room.
- **Embed**`/live/{slug}/embed` is a self-contained view designed to be dropped into
someone else's page in an iframe. Ready-made snippet under `كود التضمين`.
- **Venue screen** — open `/live/{slug}` on the hall display. The leaderboard, podium
and board cards are sized to be read from several metres away.
## 2.7 The announce timeline
| When | Do |
|---|---|
| T−14 days | Create the tournament, set slug, upload banner, write description, publish the link |
| T−7 days | Announcement: format, rounds, time control, prize breakdown, tiebreak rules |
| T−2 days | Registration reminder + current entrant count (the live page shows it) |
| T−1 day | Run `node tools/verify-cycle.mjs`. Post the final field |
| T−2 hours | Announcement: start time, how to join, the `العب` instruction |
| Round starts | Pin an announcement per round with its start time |
| The cut | Announce the 8 with their scores and tiebreaks **before** the draw |
| After | Leave the page up — it is your archive and your promo for the next one |
---
# Part 3 — Monetise
## 3.1 Be honest with yourself about what is wired
| Surface | Stored | Displayed | Actually moves money |
|---|---|---|---|
| `entry_fee_coins` / `entry_fee_gems` | ✅ | ✅ join button, hero | ❌ **never deducted** |
| `prize_pool_coins` / `prize_pool_gems` | ✅ | ✅ hero, prizes section | ❌ **never paid** |
| `prize_distribution` | ✅ | ✅ | ❌ nothing reads it to pay |
| Ad slots | ✅ | ✅ | ✅ **this one works** |
| `sponsor_id` / `sponsor_branding` | ✅ | ❌ stripped by the live whitelist | ❌ |
| `charity_id` / `charity_percent` | ✅ | ❌ | ❌ dead column |
**Evidence.** `api/tournaments.php` mentions `entry_fee_coins` exactly once, in a
`SELECT` list for display. `tournamentFinish()` writes `final_standing` and flips status —
nothing else. The dedicated `tournament_prize_payouts` table has **0 rows** and is
referenced only in documentation, never in executable code. Production
`economy_transactions` contains only `game_reward` and `daily_reward`**no tournament
has ever moved a coin.**
## 3.2 The model that works today
Treat EL3AB as the **competition platform** and keep the money **outside** it. This is
not a workaround so much as how most grassroots events actually run.
**1. Entry fees — collect out of band.**
Set `entry_fee_coins` so the app displays the price, then collect by your own means
(InstaPay, Vodafone Cash, a link, cash at the venue) and add players to the tournament
yourself once paid. Because registration enforces neither the fee nor a rating range,
**your paid list is the source of truth** — reconcile it against
`tournament_registrations` before you start. Do this the night before, not on the day.
**2. Prizes — publish, then pay by hand.**
Fill `prize_pool_coins` and `prize_distribution` so the live page advertises the pot.
When the event finishes, read the final table from `/api/live/{slug}/standings` and pay
out yourself. Keep your own ledger; the platform will not keep one for you.
**3. Sponsorship — this is where the real money is, and it works.**
Ad slots are the one monetisation feature that is genuinely built, with full admin CRUD:
```
GET /tournaments/{id}/live-settings/ads list
POST /tournaments/{id}/live-settings/ads/store create
POST /tournaments/{id}/live-settings/ads/{adId}/toggle
POST /tournaments/{id}/live-settings/ads/{adId}/delete
```
Valid `position` values, in descending order of what you can charge for them:
| Position | Where it renders | Sell it as |
|---|---|---|
| `hero_top` | Above the tournament title, first thing on the page | **Title sponsor** |
| `ticker` | Scrolling results bar at the very top | Presenting partner |
| `between_sections` | Between the boards and the leaderboard | Mid-tier |
| `sidebar_top` | Top of the desktop sidebar | Mid-tier |
| `sidebar_bottom` | Below the share card | Supporting |
| `footer` | Page foot | Supporting |
> Two caveats. `tournament_ad_slots` currently has **0 rows** — nobody has used this yet,
> so run a rehearsal ad before selling one. And the `impressions`/`clicks` counters on the
> table are **never incremented**, so you cannot report delivery numbers from the
> platform. Use the live-settings **analytics** page (`view_count`, `unique_visitors`)
> for the numbers you put in a sponsor report instead.
**4. The audience is the product.** The live page tracks `view_count` and
`unique_visitors`. Run three events, keep the pages up as an archive, and you have a
media-kit number to sell against — which is worth considerably more than an entry fee
from 32 players.
## 3.3 A worked example: 64-player championship
| Line | Amount |
|---|---|
| 64 entries × 50 EGP (collected out of band) | 3,200 |
| Title sponsor, `hero_top` | 5,000 |
| Two mid-tier slots (`ticker`, `between_sections`) | 3,000 |
| **Gross** | **11,200** |
| Prize pool (published up front, paid by hand) | −6,000 |
| Arbiter / venue / promo | −2,000 |
| **Net** | **3,200** |
Publish the prize pool and the split before registration opens. It is the single biggest
driver of entries, and on a platform that will not enforce it for you, your reputation
for paying out *is* the product.
---
## 6. Run-day runbook
| Time | Action |
|---|---|
| T−60m | Open `/live/{slug}` on the hall screen. Confirm the leaderboard renders |
| T−45m | Reconcile paid list against `tournament_registrations`. Add stragglers |
| T−30m | Post the pinned announcement with start time and the `العب` instruction |
| T−5m | Final entrant count. Confirm `min_players` is satisfied |
| T−0 | Press **Start**. Confirm the message says round 1 was created with N boards |
| Each round | Watch the boards section. Live boards carry a red stripe |
| Stuck board | Wait out the 600s no-show window; the sweep forfeits and advances |
| Round ends | Advances automatically. If not, press **Generate Round** |
| Swiss ends | Tournament flips to `completed`. Screenshot the final table |
| The cut | Read the top 8. Settle ties on published tiebreaks. Announce |
| Knockout | Create the second tournament, register the 8 in order, start phase 1 |
| Each KO game | Players play in-app; you record via the bracket result route |
| Final | Announce the winner. Leave the page up |
---
## 7. Troubleshooting
**"Start succeeded but no boards were created."**
Fewer registrations than `min_players` (default 4). Check `tournament_registrations`.
**"The tournament is `in_progress` but there is no round 1."**
The engine self-heals — any player action or a call to the sweep opens the round. Press
**Generate Round**, or `POST /api/cron.php` on the player app with the service key.
**"A round will not advance."**
One board has no result. Either a player is still playing, or a board was never opened.
The 600-second no-show window closes it automatically. To force a sweep, press
**Generate Round**.
**"A player says they registered but are not in the pairings."**
They registered after round 1 was paired. The Swiss engine pairs from the registration
list at the moment the round opens. They will be paired into round 2.
**"The live page shows nothing."**
Check `live_enabled` is true and `slug` is set. Then check `live_visibility` — a section
whose key is false renders nothing at all.
**"Our link shows no preview image on WhatsApp."**
`banner_url` is empty. Upload a 1200×630 banner. See [2.4](#24-make-the-link-preview-properly).
**"The `المراحل` tab shows 0 / 0 مباراة and 0%."**
Expected. The phase progress bar counts `bracket_matches` rows, and a Swiss phase has
none. It is not a sign anything is wrong with your Swiss.
**"I clicked `ترقية اللاعبين` and got `No qualified players found`."**
This is [the broken cut](#16-the-cut-to-8). Use the second-tournament path in
[1.7](#17-run-the-knockout).
---
## 8. Known gaps
The engineering that would make this a one-button format, in the order I would do it:
1. **`PhaseManager::startSwissPhase()` reads `swiss_event_id`, a column that does not
exist.** Either create the column or — better — make the Swiss phase use the native
engine, which is what actually runs the games.
2. **`startPhase()` merges an error into a success envelope** (`PhaseManager.php:90`).
It should return the error. This is what makes the whole path fail silently.
3. **`getQualifiedPlayers()` cannot read native standings.** Teach it to read
`el3ab_tournament_rounds` via the same path `NativeTournamentService` already uses.
With 1–3 fixed, the automatic cut works.
4. **`bracket_matches` needs a `match_id` linking to `matches`** so a knockout game is
playable in the app instead of an admin record.
5. **`tournamentFinish()` should consult `tournament_phases`** and advance to the next
phase instead of completing a multi-phase tournament at the end of its first phase.
6. **Nothing charges an entry fee or pays a prize.** `tournament_prize_payouts` exists
and is unused; wire `tournamentFinish()` to write payouts and debit
`economy_transactions`.
7. **The edit form writes six columns that do not exist** and reports success anyway, and
`store()` uses `swiss_org_id` where the real column is `swiss_api_org_id`.
8. **`min_rating` / `max_rating` are never enforced** by the player app.
9. **`CRON_SECRET` is unset in production**, so `auto_start` only fires when something
else happens to trigger a sweep. Set it and point a scheduler at
`/api/cron.php?secret=…` every minute.
---
*Written against EL3AB as deployed on 2026-09-01. Routes, columns and labels verified
against the live code and database. Where this document says something is broken, it was
tested and found broken — not inferred from a name.*
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment