Commit 89610750 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(tournaments): big-tournament hosting (P1-P13) and club chat

Stages and mixed formats: Swiss, round robin and knockout chained in any
order (Swiss then a top-8 bracket, groups then knockout), with atomic
pairing, byes, tie-breaks and advancement between stages. Knockout brackets
with multi-game ties, third place and reopen. Teams and inter-club events,
with team match scoring.

Registration: extra fields per event (phone, country across the Arab world
with its governorates, uploads), review and waitlist, check-in, members-only
and late join, fee waivers. Presentation: media, sponsors and ad slots in
the app and on the public sign-up page, link previews, certificates and a
calendar feed. Prizes settle and release with the results; disputes get
their own desk.

Ludo, domino and backgammon play in tournaments too (heats for ludo), and the
tournament screens open each game's own scene. Clubs and tournaments get
their own chat, kept in step with membership from the app, the manager
(api/org-feed.php) and the cron tick. Only tournament staff can create
events.

Fixes: match chat lines were readable by any signed-in player; deleting an
org with a club chat failed; heat reports in the same second ranked wrong;
started events showed a relative date. Two unused scenes removed.

Tests: hosting logic suites, admin, registration, bracket and detail UI,
games, links and org feed, all green on a rehearsal of this tree.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 6340e392
......@@ -35,9 +35,35 @@ switch ($action) {
case 'heartbeat': handleBgHeartbeat($userId, $input); break;
case 'leave': handleBgLeave($userId, $input); break;
case 'emote': handleEmote($userId, $input); break;
// A tournament game (P12): how its result stands, for the result screen.
case 'tournament_state': handleBgTournamentState($userId, $input); break;
default: jsonError('Invalid action');
}
/**
* Only someone seated at the table writes to it (a move, a result, a leave,
* an emote). Anyone else, a stale tab of another account or a guessed id, is
* refused: before, any signed-in user could move the checkers of any match,
* settle it or abandon it. Returns the row with $select.
*/
function bgSeatRow($sdb, string $matchId, string $userId, string $select = 'id,players'): array {
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $matchId)) jsonError('Not found', 404);
$rows = supabaseService()->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => $select, 'limit' => 1]);
if (!is_array($rows) || isset($rows['error'])) jsonError('The table could not be read, try again', 503);
if (empty($rows)) jsonError('Not found', 404);
bgCheckSeat($rows[0], $userId);
return $rows[0];
}
function bgCheckSeat(array $row, string $userId): void {
if (!in_array($userId, array_map('strval', bgJson($row['players'] ?? null)), true)) jsonError('Not your table', 403);
}
/** The tournament adapter, loaded only for a tournament game (it brings the engine). */
function bgTournamentLib(): void {
require_once __DIR__ . '/../includes/tournament-games.php';
}
/**
* A game against the bot gets a real match row, players [me, 'bot_<level>'],
* so its win can be paid once by complete (as domino does).
......@@ -442,6 +468,7 @@ function handleMove(string $userId, array $input): void {
$update['updated_at'] = date('c');
if ($newSeq === null) {
bgSeatRow($sdb, $matchId, $userId);
$sdb->update('backgammon_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
}
......@@ -453,8 +480,10 @@ function handleMove(string $userId, array $input): void {
// not newer than the table's is refused as stale. Read and write are one
// compare-and-set on seq.
for ($attempt = 0; $attempt < 4; $attempt++) {
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state,players', 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
// Only a seated player moves the checkers.
if ($attempt === 0) bgCheckSeat($rows[0], $userId);
$raw = $rows[0]['game_state'] ?? null;
$cur = bgJson($raw);
$curSeq = isset($cur['seq']) ? intval($cur['seq']) : null;
......@@ -507,14 +536,24 @@ function handleComplete(string $userId, array $input): void {
$sdb = supabaseService();
// The client names the winner as its own id or as 'opponent'. Resolve that
// against the seats, so winner_id is always a seated player's id (or null)
// and never the string 'opponent', which a uuid column refuses.
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,status,created_by,created_at', 'limit' => 1]);
$row = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? $rows[0] : null;
$players = $row ? bgJson($row['players'] ?? null) : [];
// Only a seated player reports how the match ended. The client names the
// winner as its own id or as 'opponent'; that is resolved against the
// seats below, so winner_id is always a seated player's id (or null) and
// never the string 'opponent', which a uuid column refuses.
$row = bgSeatRow($sdb, (string)$matchId, $userId, 'id,players,status,created_by,created_at,completed_at,tournament_id,tournament_round,pairing_id,scores,game_state');
$players = bgJson($row['players'] ?? null);
$isBot = (bool) array_filter($players, fn($p) => is_string($p) && str_starts_with($p, 'bot_'));
// A tournament game: this is one player's report. The result counts once
// the other player confirms it (or has gone), and is paid then (P12).
if (!empty($row['tournament_id'])) {
if ($winner === 'opponent') $winner = array_values(array_filter($players, fn($p) => $p !== $userId))[0] ?? null;
bgTournamentLib();
$res = tournamentGameReport($sdb, 'backgammon', $row, ['reporter' => $userId, 'winner' => $winner, 'reason' => (string)$reason]);
[$body, $http] = tournamentGameApiAnswer(is_array($res) ? $res : ['ok' => false, 'code' => 'unavailable']);
jsonResponse($body, $http);
}
if ($isBot) {
// A bot match pays its creator once, while it is still being played.
if (!$row || ($row['status'] ?? '') !== 'in_progress') jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
......@@ -598,21 +637,33 @@ function handleBgLeave(string $userId, array $input): void {
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
// Seated players only: anyone else could abandon a match they are not in.
$row = bgSeatRow($sdb, (string)$matchId, $userId, 'id,players,game_state,created_at,completed_at,tournament_id,tournament_round,pairing_id');
$matches = [$row];
$gameState = bgJson($matches[0]['game_state'] ?? null);
if (array_is_list($gameState) && !empty($gameState)) $gameState = [];
$gameState['left_by'] = $userId;
$gameState['left_at'] = date('c');
$filter = ['id' => 'eq.' . $matchId];
$tournament = null;
if (!empty($row['tournament_id'])) {
// Leaving a tournament game concedes it: the other player wins (P12).
// A game the tournament already closed stays closed.
$filter['completed_at'] = 'is.null';
$other = array_values(array_filter(bgJson($row['players'] ?? null), fn($p) => $p !== $userId))[0] ?? null;
bgTournamentLib();
$tournament = tournamentGameReport($sdb, 'backgammon', $row, ['reporter' => $userId, 'winner' => $other, 'reason' => 'resign']);
}
$sdb->update('backgammon_matches', [
'game_state' => json_encode($gameState),
'status' => 'abandoned',
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
], $filter);
jsonResponse(['success' => true]);
jsonResponse(['success' => true] + ($tournament !== null ? ['tournament' => $tournament] : []));
}
function handleEmote(string $userId, array $input): void {
......@@ -620,6 +671,7 @@ function handleEmote(string $userId, array $input): void {
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
bgSeatRow($sdb, (string)$matchId, $userId);
$sdb->update('backgammon_matches', [
'last_emote' => json_encode([
'user_id' => $userId,
......@@ -631,3 +683,13 @@ function handleEmote(string $userId, array $input): void {
jsonResponse(['success' => true]);
}
/** How a tournament game's result stands for the caller; decides it when the rules now allow (P12). */
function handleBgTournamentState(string $userId, array $input): void {
$sdb = supabaseService();
$row = bgSeatRow($sdb, (string)($input['match_id'] ?? ''), $userId, '*');
if (empty($row['tournament_id'])) jsonResponse(['success' => false, 'error' => 'not_tournament'], 422);
bgTournamentLib();
[$body, $http] = tournamentGameApiAnswer(tournamentGameState($sdb, 'backgammon', $row, $userId));
jsonResponse($body, $http);
}
<?php
/**
* A tournament's times as an iCalendar file (hosting spec F5, 5.2 "Links and assets").
*
* GET /api/calendar.php?tournament_id=<uuid|slug>
*
* One event per stage, one per round whose start is known (scheduled, started,
* or fixed in round_schedule), and the check-in window when check-in is
* required. Times are written in Africa/Cairo (TZID, with a VTIMEZONE built
* from PHP's tz database, so Egypt's summer time is right in every year the
* file covers); calendars that ignore the VTIMEZONE still know the Olson name.
* The UIDs are stable, so importing the file again updates the events.
*
* Public and read-only, like api/swiss.php. A draft is 404.
*/
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-public.php';
const ICS_TZ = 'Africa/Cairo';
function icsFail(string $key, int $status, string $message): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $key, 'code' => $key, 'message_key' => 'tournament.err_' . $key, 'message' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
$ref = (string)($_GET['tournament_id'] ?? $_GET['id'] ?? $_GET['slug'] ?? '');
if (trim($ref) === '') icsFail('bad_request', 422, 'الطلب ناقص حاجة');
$db = supabaseService();
$t = tournamentPublicFind($db, $ref);
if (!tournamentPublicVisible($t)) icsFail('not_found', 404, 'البطولة دي مش موجودة');
$ics = icsBuild($t, tournamentPublicPhaseRows($db, $t), tournamentPublicRounds($db, $t['id']));
$file = 'el3ab-' . preg_replace('/[^a-z0-9-]/', '', strtolower((string)(tpText($t['slug'] ?? null) ?? substr($t['id'], 0, 8)))) . '.ics';
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $file . '"');
header('Cache-Control: no-cache');
echo $ics;
exit;
// ─────────────────────────────────────────────────────────────────────────
/** TEXT value escaping (RFC 5545 3.3.11). */
function icsText(?string $s): string {
return str_replace(["\\", ';', ',', "\r\n", "\n", "\r"], ["\\\\", '\;', '\,', '\n', '\n', '\n'], (string)$s);
}
/** One content line, folded at 75 octets without splitting a UTF-8 character (RFC 5545 3.1). */
function icsLine(string $line): string {
$out = '';
$limit = 75;
while (strlen($line) > $limit) {
$cut = $limit;
while ($cut > 0 && (ord($line[$cut]) & 0xC0) === 0x80) $cut--;
$out .= substr($line, 0, $cut) . "\r\n ";
$line = substr($line, $cut);
$limit = 74; // the leading space of a continuation counts
}
return $out . $line . "\r\n";
}
function icsUtc(int $ts): string { return gmdate('Ymd\THis\Z', $ts); }
function icsLocal(int $ts): string {
$d = new DateTime('@' . $ts);
$d->setTimezone(new DateTimeZone(ICS_TZ));
return $d->format('Ymd\THis');
}
function icsOffset(int $seconds): string {
$sign = $seconds < 0 ? '-' : '+';
$s = abs($seconds);
return $sign . sprintf('%02d%02d', intdiv($s, 3600), intdiv($s % 3600, 60));
}
/**
* VTIMEZONE for Africa/Cairo between two instants: one observance per
* transition, each starting at the wall time it begins from (RFC 5545 3.6.5).
*/
function icsTimezone(int $from, int $to): array {
$tz = new DateTimeZone(ICS_TZ);
$tr = $tz->getTransitions($from, $to) ?: [];
$lines = ['BEGIN:VTIMEZONE', 'TZID:' . ICS_TZ, 'X-LIC-LOCATION:' . ICS_TZ];
$prev = null;
foreach ($tr as $i => $x) {
$offset = (int)$x['offset'];
$before = $i === 0 ? $offset : (int)$prev['offset'];
$kind = !empty($x['isdst']) ? 'DAYLIGHT' : 'STANDARD';
array_push($lines, 'BEGIN:' . $kind, 'DTSTART:' . gmdate('Ymd\THis', (int)$x['ts'] + $before),
'TZOFFSETFROM:' . icsOffset($before), 'TZOFFSETTO:' . icsOffset($offset), 'TZNAME:' . $x['abbr'], 'END:' . $kind);
$prev = $x;
}
$lines[] = 'END:VTIMEZONE';
return $lines;
}
/** How long a round takes: both clocks, 40 moves of increment each, and a short break. */
function icsRoundSeconds(?string $tc, string $game): int {
if ($game !== 'chess' && $game !== '') return 30 * 60;
$ms = chessTimeControlMs(chessNormaliseTimeControl($tc));
$s = (int)(($ms['initial'] * 2 + $ms['increment'] * 80) / 1000) + 5 * 60;
return max(15 * 60, (int)ceil($s / 300) * 300);
}
function icsEvent(array $p): array {
$lines = ['BEGIN:VEVENT', 'UID:' . $p['uid'], 'DTSTAMP:' . icsUtc(time()), 'SEQUENCE:' . $p['seq']];
if ($p['modified']) $lines[] = 'LAST-MODIFIED:' . icsUtc($p['modified']);
array_push($lines,
'DTSTART;TZID=' . ICS_TZ . ':' . icsLocal($p['start']),
'DTEND;TZID=' . ICS_TZ . ':' . icsLocal($p['end']),
'SUMMARY:' . icsText($p['summary']),
'DESCRIPTION:' . icsText($p['description']),
'LOCATION:' . icsText('العب'),
'URL:' . $p['url'],
'STATUS:' . $p['status'],
'TRANSP:OPAQUE'
);
if (!empty($p['alarm'])) {
array_push($lines, 'BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:-PT30M', 'DESCRIPTION:' . icsText($p['summary']), 'END:VALARM');
}
$lines[] = 'END:VEVENT';
return $lines;
}
function icsBuild(array $t, array $stages, array $rounds): string {
$name = tpAr($t, 'name') ?? 'بطولة';
$game = (string)($t['game_key'] ?? 'chess');
$url = tournamentLink($t);
$host = (string)parse_url(EL3AB_PLAYER_URL, PHP_URL_HOST);
$status = ($t['status'] ?? '') === 'cancelled' ? 'CANCELLED' : 'CONFIRMED';
$modified = !empty($t['updated_at']) ? (strtotime((string)$t['updated_at']) ?: null) : null;
$seq = $modified ? intdiv($modified, 60) % 2147483647 : 0;
$about = "اللينك: {$url}\nالأوقات بتوقيت القاهرة.";
$schedule = tournamentPublicSchedule($t, $stages, $rounds);
$ranged = tpStagesWithRanges($stages);
$events = [];
$timeOf = [];
foreach ($schedule as $row) if ($row['starts_at']) $timeOf[(int)$row['round']] = strtotime($row['starts_at']);
// Stages.
$first = true;
foreach ($ranged as $s) {
$n = (int)$s['phase_number'];
$start = !empty($s['starts_at']) ? strtotime((string)$s['starts_at']) : ($timeOf[$s['_first']] ?? null);
if ($start === null && $n === 1 && !empty($t['starts_at'])) $start = strtotime((string)$t['starts_at']);
if (!$start) continue;
$tc = tpText($s['time_control'] ?? null) ?? tpText($t['time_control'] ?? null);
$per = icsRoundSeconds($tc, $game);
$planned = (int)($s['_planned'] ?? 0) ?: 1;
$break = 60 * (int)($t['round_break_minutes'] ?? 0);
// The last round with a time, then the rounds after it at the usual pace.
$last = $s['_last'] ?? ($s['_first'] + $planned - 1);
$end = $start + $planned * ($per + $break) - $break;
for ($r = $last; $r >= $s['_first']; $r--) {
if (!isset($timeOf[$r]) || $timeOf[$r] < $start) continue;
$end = max($end, $timeOf[$r] + $per + ($last - $r) * ($per + $break));
break;
}
$stageName = tpAr($s, 'name');
$single = count($ranged) === 1;
$events[] = icsEvent([
'uid' => $t['id'] . '-stage-' . $n . '@' . $host, 'seq' => $seq, 'modified' => $modified,
'start' => $start, 'end' => max($end, $start + $per),
'summary' => $single ? $name : $name . ' · ' . ($stageName ?? 'المرحلة ' . $n),
'description' => $about, 'url' => $url, 'status' => $status, 'alarm' => $first,
]);
$first = false;
}
// Rounds with a known start.
foreach ($schedule as $row) {
if (!$row['starts_at']) continue;
$start = strtotime($row['starts_at']);
$stage = tpStageByNumber($stages, (int)$row['phase_number']);
$tc = tpText($stage['time_control'] ?? null) ?? tpText($t['time_control'] ?? null);
$events[] = icsEvent([
'uid' => $t['id'] . '-round-' . (int)$row['round'] . '@' . $host, 'seq' => $seq, 'modified' => $modified,
'start' => $start, 'end' => $start + icsRoundSeconds($tc, $game),
'summary' => $name . ' · الجولة ' . (int)$row['round'],
'description' => $about, 'url' => $url, 'status' => $status, 'alarm' => false,
]);
}
// The check-in window.
$ci = tpCheckIn($t);
if ($ci['required'] && $ci['opens_at'] && $ci['closes_at']) {
$events[] = icsEvent([
'uid' => $t['id'] . '-check-in@' . $host, 'seq' => $seq, 'modified' => $modified,
'start' => strtotime($ci['opens_at']), 'end' => strtotime($ci['closes_at']),
'summary' => $name . ' · تأكيد الحضور', 'description' => "أكد حضورك من التطبيق قبل ما يقفل.\n" . $about,
'url' => $url, 'status' => $status, 'alarm' => true,
]);
}
// The time zone covers every event, with a year either side.
$times = [time()];
foreach ($events as $e) foreach ($e as $l) if (preg_match('/^DT(START|END);TZID=[^:]+:(\d{8}T\d{6})$/', $l, $m)) {
$d = DateTime::createFromFormat('Ymd\THis', $m[2], new DateTimeZone(ICS_TZ));
if ($d) $times[] = $d->getTimestamp();
}
$lines = array_merge([
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//EL3AB//Tournaments 1.0//AR', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
'X-WR-CALNAME:' . icsText($name), 'X-WR-TIMEZONE:' . ICS_TZ,
], icsTimezone(min($times) - 366 * 86400, max($times) + 366 * 86400));
foreach ($events as $e) $lines = array_merge($lines, $e);
$lines[] = 'END:VCALENDAR';
return implode('', array_map('icsLine', $lines));
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -77,7 +77,14 @@ if ($method === 'GET') {
$query = preg_replace('/[^a-zA-Z0-9\x{0600}-\x{06FF}\s_.-]/u', '', $query);
if (strlen($query) < 2) jsonError('Query too short');
// Real players and test accounts never see each other (includes/match-pool.php).
require_once __DIR__ . '/../includes/match-pool.php';
$searcherIsTest = matchPoolFlags($sdb, [$userId])[$userId] ?? false;
$results = $sdb->get('profiles', [
'is_test' => $searcherIsTest ? 'is.true' : 'is.false',
'or' => "(username.ilike.*{$query}*,display_name.ilike.*{$query}*)",
'id' => 'neq.' . $userId,
'select' => 'id,username,display_name,avatar_url,level,is_online,last_seen_at,current_game',
......
This diff is collapsed.
......@@ -27,7 +27,9 @@ if ($gameKey === 'chess') {
'order' => "{$ratingCol}.desc.nullslast",
'limit' => $limit,
$ratingCol => 'not.is.null',
// Test accounts are named ZZZ-... (tests/, tools/); they never rank.
// Test accounts never rank: the is_test marker, and the ZZZ-... names
// older runs left behind.
'is_test' => 'is.false',
'or' => '(display_name.is.null,display_name.not.like.ZZZ-*)'
]);
......@@ -49,7 +51,7 @@ if ($gameKey === 'chess') {
'limit' => $limit
];
// Test accounts (ZZZ-...) never rank.
$tests = $db->get('profiles', ['display_name' => 'like.ZZZ-*', 'select' => 'id', 'limit' => 500]);
$tests = $db->get('profiles', ['or' => '(is_test.is.true,display_name.like.ZZZ-*)', 'select' => 'id', 'limit' => 500]);
if (is_array($tests) && !isset($tests['error']) && $tests) {
$params['player_id'] = 'not.in.(' . implode(',', array_column($tests, 'id')) . ')';
}
......
......@@ -37,9 +37,16 @@ switch ($action) {
case 'complete': handleLudoComplete($userId, $input); break;
case 'heartbeat': handleHeartbeat($userId, $input); break;
case 'leave': handleLudoLeave($userId, $input); break;
// A tournament heat (P12): how its result stands, for the result screen.
case 'tournament_state': handleLudoTournamentState($userId, $input); break;
default: jsonError('Invalid action');
}
/** The tournament adapter, loaded only for a tournament game (it brings the engine). */
function ludoTournamentLib(): void {
require_once __DIR__ . '/../includes/tournament-games.php';
}
/** players/game_state arrive as a JSON string (text column) or an array (jsonb). */
function ludoJson($value, array $default = []): array {
if (is_array($value)) return $value;
......@@ -390,8 +397,8 @@ function ludoGhostTable($sdb, string $userId, array $own, int $count): ?array {
* account, a guessed id) is refused, where before any signed-in user could
* move tokens on, leave or settle any match.
*/
function ludoRequireSeat($sdb, string $matchId, string $userId): array {
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'id,players,host_id,status', 'limit' => 1]);
function ludoRequireSeat($sdb, string $matchId, string $userId, string $select = 'id,players,host_id,status,tournament_id'): array {
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => $select, 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
ludoCheckSeat($rows[0], $userId);
return $rows[0];
......@@ -434,7 +441,9 @@ function handleLudoMove(string $userId, array $input): void {
$update['last_activity'] = date('c');
if ($newTc === null) {
ludoRequireSeat($sdb, $matchId, $userId);
$seatRow = ludoRequireSeat($sdb, $matchId, $userId);
// A tournament heat is closed by its confirmed result only (P12), never by a move.
if (!empty($seatRow['tournament_id'])) unset($update['status']);
$sdb->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
}
......@@ -446,9 +455,10 @@ function handleLudoMove(string $userId, array $input): void {
// table's is refused as stale, and the client lays the row out instead.
// Read and write are one compare-and-set on the step number.
for ($attempt = 0; $attempt < 4; $attempt++) {
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state,players,host_id', 'limit' => 1]);
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state,players,host_id,tournament_id', 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
if ($attempt === 0) ludoCheckSeat($rows[0], $userId);
if (!empty($rows[0]['tournament_id'])) unset($update['status']);
$cur = ludoJson($rows[0]['game_state'] ?? null);
$curTc = isset($cur['turn_count']) ? intval($cur['turn_count']) : null;
if ($curTc !== null && $newTc <= $curTc) jsonResponse(['success' => true, 'stale' => true, 'turn_count' => $curTc]);
......@@ -581,9 +591,20 @@ function handleLudoComplete(string $userId, array $input): void {
$sdb = supabaseService();
// A game against bots has its own rules for paying.
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'id,host_id,created_at,game_state,status,players', 'limit' => 1]);
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'id,host_id,created_at,game_state,status,players,winners,positions,tournament_id,tournament_round,pairing_id', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && !empty($rows)) {
ludoCheckSeat($rows[0], $userId);
// A tournament heat: this is one player's report of the places. The
// places count once two players agree (or the others have gone), and
// are paid then (P12).
if (!empty($rows[0]['tournament_id'])) {
if (!in_array($userId, array_map('strval', ludoJson($rows[0]['players'] ?? null)), true)) jsonError('Not at this table', 403);
ludoTournamentLib();
$res = tournamentGameReport($sdb, 'ludo', $rows[0], ['reporter' => $userId, 'order' => is_array($winners) ? array_values($winners) : [],
'reason' => in_array($reason, ['abandon', 'resign'], true) ? $reason : 'normal']);
[$body, $http] = tournamentGameApiAnswer(is_array($res) ? $res : ['ok' => false, 'code' => 'unavailable']);
jsonResponse($body, $http);
}
$gs = ludoJson($rows[0]['game_state'] ?? null);
// A bot-fill table (includes/ghost.php) pays by the same rules.
if (in_array($gs['mode'] ?? '', ['bot', 'ghost'], true)) {
......@@ -677,3 +698,15 @@ function handleLudoLeave(string $userId, array $input): void {
}
jsonError('The table is busy, try again', 503);
}
/** How a tournament heat's result stands for the caller; decides it when the rules now allow (P12). */
function handleLudoTournamentState(string $userId, array $input): void {
$sdb = supabaseService();
$matchId = (string)($input['match_id'] ?? '');
if (!presenceValidId($matchId)) jsonError('Not found', 404);
$row = ludoRequireSeat($sdb, $matchId, $userId, '*');
if (empty($row['tournament_id'])) jsonResponse(['success' => false, 'error' => 'not_tournament'], 422);
ludoTournamentLib();
[$body, $http] = tournamentGameApiAnswer(tournamentGameState($sdb, 'ludo', $row, $userId));
jsonResponse($body, $http);
}
This diff is collapsed.
......@@ -186,7 +186,11 @@ if ($method === 'GET' && $action === 'detail') {
jsonResponse(['ok' => true, 'org' => orgPublic($org),
'members' => array_map('orgMemberRow', array_merge($staffRows, $rest)),
'members_total' => (int)$org['member_count'], 'my' => $my, 'join' => $join,
'announcements' => $announcements]);
'announcements' => $announcements,
// The club chat, for active members only (never created here).
'chat_group_id' => (($my['status'] ?? null) === 'active')
? (function () use ($sdb, $org) { require_once __DIR__ . '/../includes/comms-hooks.php'; return commsOrgChatId($sdb, $org['id']); })()
: null]);
}
if ($method === 'GET' && $action === 'members') {
......@@ -238,7 +242,10 @@ $c = orgsCaller();
$uid = $c['id'];
if ($action === 'join') {
orgsOut(orgJoin($uid, orgIdParam($in['org_id'] ?? null), redeemIpHash()));
$orgId = orgIdParam($in['org_id'] ?? null);
$r = orgJoin($uid, $orgId, redeemIpHash());
if (!empty($r['ok'])) { require_once __DIR__ . '/../includes/comms-hooks.php'; commsOrgMember($sdb, $orgId, $uid); }
orgsOut($r);
}
if ($action === 'apply') {
......@@ -246,6 +253,8 @@ if ($action === 'apply') {
$file = $multipart ? ($_FILES['proof_document'] ?? null) : null;
$r = orgApply($uid, $orgId, isset($in['document_type']) ? (string)$in['document_type'] : null,
isset($in['notes']) ? (string)$in['notes'] : null, $file, redeemIpHash());
// An open org joins on apply: seat the player in the club chat too.
if (!empty($r['ok']) && ($r['status'] ?? '') !== 'pending') { require_once __DIR__ . '/../includes/comms-hooks.php'; commsOrgMember($sdb, $orgId, $uid); }
orgsOut($r, ($r['status'] ?? '') === 'pending' ? 202 : 200);
}
......@@ -262,6 +271,8 @@ if ($action === 'leave') {
$u = $sdb->update('org_members', ['status' => 'left', 'left_at' => gmdate('c')], ['id' => 'eq.' . $me['id'], 'status' => 'eq.active']);
if (isset($u['error'])) orgsFail('server_error');
orgLog($sdb, $orgId, $uid, 'member.left', 'org_member', $me['id'], ['player_id' => $uid, 'role' => $me['role']]);
require_once __DIR__ . '/../includes/comms-hooks.php';
commsOrgMember($sdb, $orgId, $uid);
jsonResponse(['ok' => true, 'status' => 'left']);
}
......
......@@ -118,7 +118,14 @@ if ($method === 'GET') {
$q = ltrim($q, '@');
if (mb_strlen($q) < 2) jsonResponse(['players' => []]);
// Real players and test accounts never see each other (includes/match-pool.php).
require_once __DIR__ . '/../includes/match-pool.php';
$searcherIsTest = matchPoolFlags($sdb, [$userId])[$userId] ?? false;
$rows = $sdb->get('profiles', [
'is_test' => $searcherIsTest ? 'is.true' : 'is.false',
'or' => "(username.ilike.*{$q}*,display_name.ilike.*{$q}*)",
'id' => 'neq.' . $userId,
'select' => PLAYER_COLS,
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
/**
* Club chat and tournament lobby upkeep after a membership or registration
* change (the managed groups live in api/groups.php, loaded in library mode).
*
* Best effort by design: a failure here never fails the join, leave,
* registration or staff action that called it, and the cron tick
* (groupsCommsTick) repairs anything a hook missed.
*/
function commsLoad(): bool {
if (!function_exists('groupsOrgSyncMember')) {
if (!defined('EL3AB_GROUPS_LIB')) define('EL3AB_GROUPS_LIB', true);
require_once __DIR__ . '/../api/groups.php';
}
return function_exists('groupsOrgSyncMember');
}
function commsOrgMember($sdb, ?string $orgId, ?string $playerId): void {
if (!$orgId || !$playerId) return;
try { if (commsLoad()) groupsOrgSyncMember($sdb, $orgId, $playerId); }
catch (\Throwable $e) { error_log('[comms] org member sync: ' . $e->getMessage()); }
}
function commsTournamentPlayer($sdb, ?string $tid, ?string $playerId): void {
if (!$tid || !$playerId) return;
try { if (commsLoad()) groupsTournamentSyncPlayer($sdb, $tid, $playerId); }
catch (\Throwable $e) { error_log('[comms] lobby player sync: ' . $e->getMessage()); }
}
function commsTournament($sdb, ?string $tid): void {
if (!$tid) return;
try { if (commsLoad()) groupsTournamentSync($sdb, $tid); }
catch (\Throwable $e) { error_log('[comms] lobby sync: ' . $e->getMessage()); }
}
/** The club chat id for an active member's view of the org, or null (never creates). */
function commsOrgChatId($sdb, ?string $orgId): ?string {
if (!$orgId) return null;
try { return commsLoad() ? groupsOrgGroupId($sdb, $orgId) : null; }
catch (\Throwable $e) { return null; }
}
/** Cron: everything, idempotent. */
function commsTick($sdb): array {
try { return commsLoad() ? groupsCommsTick($sdb) : ['skipped' => 'groups library missing']; }
catch (\Throwable $e) { error_log('[comms] tick: ' . $e->getMessage()); return ['error' => $e->getMessage()]; }
}
This diff is collapsed.
......@@ -524,6 +524,15 @@ function redeemAfterApply(string $player, array $r): void {
foreach (array_unique($groups) as $gid) redeemGroupJoined($sdb, $gid, $player);
// Club chats and tournament lobbies (api/groups.php): seat the player now;
// the cron tick repairs anything this misses.
require_once __DIR__ . '/comms-hooks.php';
foreach ($results as $g) {
if (($g['type'] ?? '') !== 'org_join' || empty($g['org_id']) || empty($g['applied']) || !empty($g['pending'])) continue;
commsOrgMember($sdb, $g['org_id'], $player);
}
foreach (array_unique($tournaments) as $tid) commsTournamentPlayer($sdb, $tid, $player);
// Staff hear about joins and requests once a day per org, updated in place.
foreach (array_keys($joinedOrgs) as $oid) supabaseRpc('org_admin_digest', ['p_org' => $oid]);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -58,8 +58,8 @@ async function poll() {
if (!still) { goneFor(current.inv); }
else current.left = Math.min(current.left, Number(still.expires_in) || current.left);
}
// The friends screen lists challenges with its own accept button: no
// second copy over it.
// A screen that lists a challenge with its own accept button
// ([data-accept-invite]) gets no second copy over it.
const next = invites.find(i => !document.querySelector(`[data-accept-invite="${CSS.escape(i.match_id)}"]`));
if (!current && next && !inGame()) show(next);
} catch (e) {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
export { mountTournamentBracket } from '../../rank/scenes/tournament-bracket.js';
This diff is collapsed.
export { mountTournamentLive } from '../../rank/scenes/tournament-live.js';
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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