public function __construct(Result $result)
{
$this->result = $result;
+ $result->setRelations([]);
$result->load(['user', 'verified_by']);
}
return $rounds->toArray();
}
+ public function recalc(Request $request, Tournament $tournament) {
+ $this->authorize('update', $tournament);
+
+ foreach ($tournament->rounds as $round) {
+ $round->updatePlacement();
+ }
+ $tournament->updatePlacement();
+
+ return $tournament->toArray();
+ }
+
public function settings(Request $request, Tournament $tournament) {
$this->authorize('update', $tournament);
$validatedData = $request->validate([
'group_size' => 'integer|nullable',
'group_swap_style' => 'string|nullable|in:admin,always,finished,never',
+ 'limit_scoreboard' => 'integer|nullable',
'result_reveal' => 'string|nullable|in:always,finishers,never,participants',
+ 'round_scoring' => 'string|nullable|in:placement,relative,time',
+ 'round_scoring_limit' => 'integer|nullable',
'show_numbers' => 'boolean|nullable',
+ 'show_scoreboard' => 'boolean|nullable',
+ 'total_scoring' => 'string|nullable|in:avg,sum',
+ 'total_scoring_limit' => 'integer|nullable',
]);
- if (array_key_exists('group_size', $validatedData)) {
- $tournament->group_size = $validatedData['group_size'];
- }
- if (array_key_exists('group_swap_style', $validatedData)) {
- $tournament->group_swap_style = $validatedData['group_swap_style'];
- }
- if (isset($validatedData['result_reveal'])) {
- $tournament->result_reveal = $validatedData['result_reveal'];
- }
- if (array_key_exists('show_numbers', $validatedData)) {
- $tournament->show_numbers = $validatedData['show_numbers'];
+ foreach ($validatedData as $name => $value) {
+ if (!is_null($value)) {
+ $tournament->{$name} = $value;
+ }
}
$tournament->save();
if ($tournament->wasChanged()) {
TournamentChanged::dispatch($tournament);
- Protocol::tournamentSettings($tournament, $request->user());
+ Protocol::tournamentSettings($tournament, $request->user(), $validatedData);
}
return $tournament->toArray();
}
ProtocolAdded::dispatch($protocol);
}
- public static function tournamentSettings(Tournament $tournament, User $user = null) {
+ public static function tournamentSettings(Tournament $tournament, User $user = null, $changeset = null) {
$protocol = static::create([
'tournament_id' => $tournament->id,
'user_id' => $user ? $user->id : null,
'type' => 'tournament.settings',
'details' => [
'tournament' => static::tournamentMemo($tournament),
+ 'changeset' => $changeset,
],
]);
ProtocolAdded::dispatch($protocol);
];
}
+ public function broadcastWith($event) {
+ $this->setRelations([]);
+ }
+
public function isComplete() {
if (count($this->tournament->participants) == 0) return false;
if ($time > 0) {
$reference_times[] = $time;
}
- if (count($reference_times) >= 5) {
+ if (count($reference_times) >= $this->tournament->round_scoring_limit) {
break;
}
}
$result->updatePlacement($score, count($results) - $running + 1);
}
}
- } elseif (!$this->tournament->hasFixedRunners()) {
+ } else {
$results = $this->results->sort([Result::class, 'compareResult']);
$reversed = $results->reverse();
} elseif ($result->forfeit) {
$result->updatePlacement(0, count($results));
} else {
- $result->updatePlacement($running, count($results) - $running + 1);
- }
- }
- } else {
- $runners = [];
- foreach ($this->tournament->participants as $p) {
- if ($p->isRunner()) {
- $runners[] = $p;
- } else {
- $result = $p->findResult($this);
- if ($result) {
- $result->updatePlacement(null, null);
- }
- }
- }
-
- usort($runners, Participant::compareResult($this));
- $mapped = array_map(function ($p) {
- return ['participant' => $p, 'result' => $p->findResult($this)];
- }, $runners);
- $filtered = array_filter($mapped, function ($r) {
- return $r['result'] && ($r['result']->time || $r['result']->forfeit || $r['result']->disqualified);
- });
- $reversed = array_reverse($filtered);
-
- $running = 0;
- $bonus = 1;
- $lastResult = null;
- foreach ($reversed as $r) {
- $betterThanLast = is_null($lastResult) || $r['result']->getEffectiveTime() < $lastResult;
- if (!$r['result']->disqualified && !$r['result']->forfeit && $betterThanLast) {
- $running += $bonus;
- $lastResult = $r['result']->getEffectiveTime();
- $bonus = 1;
- } else {
- ++$bonus;
- }
- if ($r['result']->disqualified) {
- $r['result']->updatePlacement(0, count($filtered) + 1);
- } elseif ($r['result']->forfeit) {
- $r['result']->updatePlacement(0, count($filtered));
- } else {
- $r['result']->updatePlacement($running, count($filtered) - $running + 1);
+ $score = $this->tournament->round_scoring === 'relative'
+ ? $running / count($results) * 100
+ : $running;
+ $result->updatePlacement($score, count($results) - $running + 1);
}
}
}
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Collection;
class Tournament extends Model {
}
public function hasTimeBasedScoring(): bool {
- return in_array($this->type, ['open-grouped-async']);
+ return $this->round_scoring == 'time';
}
public function getRunners() {
return $runners;
}
+ public function getScorableRounds(): Collection {
+ return $this->rounds()->limit($this->total_scoring_limit)->get();
+ }
+
public function hasScoreboard(): bool {
- return $this->type == 'signup-async';
+ return $this->show_scoreboard;
}
public function updatePlacement() {
$runners = [];
+ foreach ($this->getScorableRounds() as $round) {
+ foreach ($round->results as $result) {
+ if (!isset($runners[$result->user_id])) {
+ $runners[$result->user_id] = [
+ 'runner' => $this->findOrCreateRunner($result->user_id),
+ 'rounds' => 0,
+ 'total' => 0,
+ ];
+ }
+ ++$runners[$result->user_id]['rounds'];
+ $runners[$result->user_id]['total'] += $result->score;
+ }
+ }
+
foreach ($this->participants as $p) {
- if ($p->isRunner()) {
- $p->score = 0;
- $runners[] = $p;
- } else {
+ if (!array_key_exists($p->user_id, $runners)) {
$p->updatePlacement(null, null);
}
}
- if (empty($runners)) {
- return;
- }
- foreach ($this->rounds as $round) {
- foreach ($runners as $p) {
- $result = $p->findResult($round);
- if ($result) {
- $p->score += $result->score;
- }
+
+ $results = [];
+ foreach ($runners as $entry) {
+ $result = $entry['runner'];
+ if ($this->total_scoring == 'avg') {
+ $result->score = $entry['total'] / $entry['rounds'];
+ } else {
+ $result ->score = $entry['total'];
}
+ $results[] = $result;
}
- usort($runners, [Participant::class, 'compareScore']);
- $placement = count($runners);
+ usort($results, [Participant::class, 'compareScore']);
+ $placement = count($results);
$skipped = 0;
- $lastScore = $runners[0]->score;
- foreach ($runners as $p) {
+ $lastScore = $results[0]->score;
+ foreach ($results as $p) {
if ($p->score > $lastScore) {
$placement -= $skipped;
$skipped = 1;
}
}
+ public function findOrCreateRunner($user_id) {
+ $runner = $this->participants()->firstOrNew(['user_id' => $user_id]);
+ if (is_null($runner->roles)) {
+ $runner->roles = ['runner'];
+ } elseif (!in_array('runner', $runner->roles)) {
+ $roles = $runner->roles;
+ $roles[] = 'runner';
+ $runner->roles = $roles;
+ }
+ return $runner;
+ }
+
public function pickGroup($number, User $user, $exclude = []) {
$available_rounds = $this->rounds()
->where('number', '=', $number)
'strict' => true,
'timezone' => '+00:00',
'engine' => null,
- 'options' => extension_loaded('pdo_mysql') ? array_filter([
- PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
- ]) : [],
],
'pgsql' => [
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('tournaments', function (Blueprint $table) {
+ $table->string('round_scoring')->default('placement');
+ $table->integer('round_scoring_limit')->default(5);
+ $table->string('total_scoring')->default('sum');
+ $table->integer('total_scoring_limit')->default(20);
+ $table->boolean('show_scoreboard')->default(false);
+ $table->integer('limit_scoreboard')->default(10);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('tournaments', function (Blueprint $table) {
+ $table->dropColumn('round_scoring');
+ $table->dropColumn('round_scoring_limit');
+ $table->dropColumn('total_scoring');
+ $table->dropColumn('total_scoring_limit');
+ $table->dropColumn('show_scoreboard');
+ $table->dropColumn('limit_scoreboard');
+ });
+ }
+};
}
if (hasFinishedRound(user, round)) {
classNames.push('has-finished');
- } else if (isRunner(user, tournament)) {
+ } else if (hasFixedRunners(tournament) && isRunner(user, tournament)) {
classNames.push('has-not-finished');
}
return classNames.join(' ');
{mayEditRound(user, tournament, round) ?
<EditButton round={round} tournament={tournament} />
: null}
- {maySeeGroups(user, tournament, round) ?
+ {hasAssignedGroups(tournament) && maySeeGroups(user, tournament, round) ?
<GroupsButton round={round} tournament={tournament} />
: null}
{mayViewProtocol(user, tournament, round) ?
import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { getUserName } from '../../helpers/Participant';
-import { getRunners, getScoreTable } from '../../helpers/Tournament';
+import { getScoreboardRunners, getScoreTable } from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
const COLORS = [
'#7cb5ec',
- '#434348',
+ '#d3d3d8',
'#90ed7d',
'#f7a35c',
'#8085e9',
const ScoreChart = ({
tournament,
-}) =>
-<ResponsiveContainer height="100%" width="100%">
- <LineChart data={getScoreTable(tournament)} height={720} width={1280}>
- <XAxis dataKey="number" />
- <YAxis />
- <Tooltip />
- <Legend />
- {getRunners(tournament).map((runner, index) =>
- <Line
- dataKey={getUserName(runner)}
- key={runner.id}
- stroke={COLORS[index % COLORS.length]}
- type="monotone"
- />
- )}
- </LineChart>
-</ResponsiveContainer>;
+}) => {
+ const { user } = useUser();
+
+ return (
+ <ResponsiveContainer height="100%" width="100%">
+ <LineChart data={getScoreTable(tournament)} height={720} width={1280}>
+ <XAxis dataKey="number" />
+ <YAxis />
+ <Tooltip contentStyle={{ backgroundColor: '#333' }} formatter={value => Math.round(value * 100) / 100} />
+ <Legend />
+ {getScoreboardRunners(tournament, user).map((runner, index) =>
+ <Line
+ dataKey={getUserName(runner)}
+ key={runner.id}
+ stroke={COLORS[index % COLORS.length]}
+ type="monotone"
+ />
+ )}
+ </LineChart>
+ </ResponsiveContainer>
+ );
+};
ScoreChart.propTypes = {
tournament: PropTypes.shape({
import Icon from '../common/Icon';
import Box from '../users/Box';
-import { comparePlacement } from '../../helpers/Participant';
-import { getRunners } from '../../helpers/Tournament';
+import { getScoreboardFormatOptions, getScoreboardRunners } from '../../helpers/Tournament';
import { useUser } from '../../hooks/user';
+import i18n from '../../i18n';
const getRowClassName = (tournament, participant, user) => {
const classNames = ['score'];
const { t } = useTranslation();
const { user } = useUser();
+ const entries = React.useMemo(() => getScoreboardRunners(tournament, user), [tournament, user]);
+
return <Table striped className="scoreboard align-middle">
<thead>
<tr>
</tr>
</thead>
<tbody>
- {getRunners(tournament).sort(comparePlacement).map(participant =>
+ {entries.map(participant =>
<tr className={getRowClassName(tournament, participant, user)} key={participant.id}>
<td className="text-center">
{getPlacementDisplay(participant)}
: null}
</div>
</td>
- <td className="text-end">{participant.score}</td>
+ <td className="text-end">
+ {participant.score !== null ?
+ i18n.number(participant.score, getScoreboardFormatOptions(tournament))
+ : null}
+ </td>
</tr>
)}
</tbody>
Scoreboard.propTypes = {
tournament: PropTypes.shape({
+ limit_scoreboard: PropTypes.number,
}),
};
}
};
+const recalc = async tournament => {
+ try {
+ await axios.post(`/api/tournaments/${tournament.id}/recalc`);
+ toastr.success(i18n.t('tournaments.recalcSuccess'));
+ } catch (e) {
+ toastr.error(i18n.t('tournaments.recalcError', { error: e.message }));
+ }
+};
+
const setDiscord = async (tournament, guild_id) => {
try {
await axios.post(`/api/tournaments/${tournament.id}/discord`, { guild_id });
)}
</Form.Select>
</div>
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>
+ {i18n.t('tournaments.roundScoring')}
+ </span>
+ <Form.Select
+ className="w-50"
+ onChange={({ target: { value } }) =>
+ settings(tournament, { round_scoring: value })}
+ value={tournament.round_scoring}
+ >
+ {['placement', 'relative', 'time'].map((key) =>
+ <option
+ key={key}
+ value={key}
+ >
+ {i18n.t(`tournaments.roundScoringOption.${key}`)}
+ </option>
+ )}
+ </Form.Select>
+ </div>
+ {tournament.round_scoring === 'time' ?
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>{i18n.t('tournaments.roundScoringLimit')}</span>
+ <Form.Control
+ className="text-end w-50"
+ max="10"
+ min="0"
+ onChange={({ target: { value } }) => {
+ const num = parseInt(value, 10);
+ if (value >= 0 && value <= 10) {
+ settings(tournament, { round_scoring_limit: num });
+ }
+ }}
+ type="number"
+ value={tournament.round_scoring_limit}
+ />
+ </div>
+ : null}
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>
+ {i18n.t('tournaments.totalScoring')}
+ </span>
+ <Form.Select
+ className="w-50"
+ onChange={({ target: { value } }) =>
+ settings(tournament, { total_scoring: value })}
+ value={tournament.total_scoring}
+ >
+ {['sum', 'avg'].map((key) =>
+ <option
+ key={key}
+ value={key}
+ >
+ {i18n.t(`tournaments.totalScoringOption.${key}`)}
+ </option>
+ )}
+ </Form.Select>
+ </div>
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>{i18n.t('tournaments.totalScoringLimit')}</span>
+ <Form.Control
+ className="text-end w-50"
+ max="50"
+ min="0"
+ onChange={({ target: { value } }) => {
+ const num = parseInt(value, 10);
+ if (value >= 0 && value <= 50) {
+ settings(tournament, { total_scoring_limit: num });
+ }
+ }}
+ type="number"
+ value={tournament.total_scoring_limit}
+ />
+ </div>
+ <div className="d-flex align-items-center justify-content-end mb-3">
+ <Button onClick={() => { recalc(tournament); }} variant="primary">
+ {i18n.t('tournaments.recalcButton')}
+ </Button>
+ </div>
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>{i18n.t('tournaments.showScoreboard')}</span>
+ <ToggleSwitch
+ onChange={({ target: { value } }) =>
+ settings(tournament, { show_scoreboard: value })}
+ value={tournament.show_scoreboard}
+ />
+ </div>
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>{i18n.t('tournaments.limitScoreboard')}</span>
+ <Form.Control
+ className="text-end w-50"
+ max="25"
+ min="3"
+ onChange={({ target: { value } }) => {
+ const num = parseInt(value, 10);
+ if (value >= 3 && value <= 25) {
+ settings(tournament, { limit_scoreboard: num });
+ }
+ }}
+ type="number"
+ value={tournament.limit_scoreboard}
+ />
+ </div>
<div className="d-flex align-items-center justify-content-between">
<div>
<p>{i18n.t('tournaments.discord')}</p>
discord: PropTypes.string,
group_size: PropTypes.number,
group_swap_style: PropTypes.string,
+ limit_scoreboard: PropTypes.number,
locked: PropTypes.bool,
result_reveal: PropTypes.string,
+ round_scoring: PropTypes.string,
+ round_scoring_limit: PropTypes.number,
show_numbers: PropTypes.bool,
+ show_scoreboard: PropTypes.bool,
+ total_scoring: PropTypes.string,
+ total_scoring_limit: PropTypes.number,
}),
};
export default {
compareFinished,
+ comparePlacement,
compareResult,
compareUsername,
findResult,
.sort(Participant.compareUsername);
};
+export const getScoreboardRunners = (tournament, user) => {
+ const runners = getRunners(tournament).filter(
+ (r) => r.placement != null && (r.placement <= tournament.limit_scoreboard || r.user_id === user?.id),
+ );
+ runners.sort(Participant.comparePlacement);
+ return runners;
+}
+
export const hasAssignedGroups = tournament => (tournament?.type === 'open-grouped-async');
export const hasFixedRunners = tournament => !['open-async', 'open-grouped-async'].includes(tournament?.type);
-export const hasScoreboard = tournament => !!(tournament && tournament.type === 'signup-async');
+export const hasFractionalScoring = tournament => tournament?.round_scoring !== 'placement';
+
+export const hasScoreboard = tournament => !!(tournament && tournament.show_scoreboard);
export const hasSignup = tournament => !!(tournament && tournament.type === 'signup-async');
-export const hasTimeBasedScoring = tournament => (tournament?.type === 'open-grouped-async');
+export const hasTimeBasedScoring = tournament => (tournament?.round_scoring === 'time');
+
+export const getScoreFormatOptions = tournament => (hasFractionalScoring(tournament)
+ ? { decimals: 2 }
+ : { decimals: 0 }
+);
-export const getScoreFormatOptions = tournament => (hasTimeBasedScoring(tournament)
+export const getScoreboardFormatOptions = tournament => (hasFractionalScoring(tournament) || tournament?.total_scoring === 'avg'
? { decimals: 2 }
: { decimals: 0 }
);
export const getScoreTable = tournament => {
if (!tournament || !tournament.rounds || !tournament.rounds.length) return [];
- const runners = getRunners(tournament);
+ const runners = getScoreboardRunners(tournament);
if (!runners.length) return [];
- const running = {};
+ const count = {};
+ const total = {};
runners.forEach(participant => {
- running[participant.id] = 0;
+ count[participant.id] = 0;
+ total[participant.id] = 0;
});
- const data = [...tournament.rounds, {}].reverse().map(round => {
+ const data = [...tournament.rounds, {}].slice(0, tournament.total_scoring_limit).reverse().map(round => {
const entry = { number: round.number ? `#${round.number}` : '' };
runners.forEach(participant => {
const result = Participant.findResult(participant, round);
- if (result && result.score) {
- running[participant.id] += result.score;
+ if (result) {
+ ++count[participant.id];
+ if (result.score) {
+ total[participant.id] += result.score;
+ }
}
- entry[Participant.getUserName(participant)] = running[participant.id];
+ entry[Participant.getUserName(participant)] = tournament.total_scoring === 'avg'
+ ? total[participant.id] / (count[participant.id] || 1)
+ : total[participant.id];
});
return entry;
});
never: 'Nie',
},
inviteBot: 'Bot einladen',
+ limitScoreboard: 'Scoreboard Größe',
locked: 'Turnier sperren',
lockError: 'Fehler beim Sperren',
lockSuccess: 'Turnier gesperrt',
open: 'Anmeldung geöffnet',
openError: 'Fehler beim Öffnen der Anmledung',
openSuccess: 'Anmeldung geöffnet',
+ recalcButton: 'Punkte neu berechnen',
+ recalcError: 'Fehler bei der Berechnung',
+ recalcSuccess: 'Punkte neu berechnet',
resultReveal: 'Frühzeitige Ergebnisse',
resultRevealDescription: 'Ob und wann Ergebnisse von offenen Runden angezeigt werden sollen.',
resultRevealOption: {
never: 'Ergebnis wird erst mit Abschluss der Runde veröffentlicht.',
participants: 'Ergebnis wird allen registrierten Teilnehmern des Turniers angezeigt.',
},
+ roundScoring: 'Punkte pro Runde',
+ roundScoringLimit: 'über N Ergebnisse',
+ roundScoringOption: {
+ placement: 'Platzierung',
+ relative: 'Relative Platzierung',
+ time: 'Zeit',
+ },
scoreboard: 'Scoreboard',
scoreChart: 'Turnierverlauf',
settings: 'Einstellungen',
settingsError: 'Fehler beim Speichern',
settingsSuccess: 'Einstellungen gespeichert',
showNumbers: 'Nummern einblenden',
+ showScoreboard: 'Scoreboard einblenden',
+ totalScoring: 'Gesamtwertung',
+ totalScoringLimit: 'über N Runden',
+ totalScoringOption: {
+ avg: 'Durchschnitt',
+ sum: 'Summe',
+ },
unlockError: 'Fehler beim Entsperren',
unlockSuccess: 'Turnier entsperrt',
},
never: 'Never',
},
inviteBot: 'Invite bot',
+ limitScoreboard: 'Scoreboard size',
locked: 'Lock rounds',
lockError: 'Error locking tournament',
lockSuccess: 'Tournament locked',
open: 'Open registration',
openError: 'Error opening registration',
openSuccess: 'Registration opened',
+ recalcButton: 'Recalculate scores',
+ recalcError: 'Error during calculation',
+ recalcSuccess: 'Scores updated',
resultReveal: 'Early results',
resultRevealDescription: 'When to show results for open rounds.',
resultRevealOption: {
never: 'Only reveal results once the round has closed down.',
participants: 'Show results to only registered tournament members.',
},
+ roundScoring: 'Points per round',
+ roundScoringLimit: 'across N results',
+ roundScoringOption: {
+ placement: 'Placement',
+ relative: 'Relative placement',
+ time: 'Time',
+ },
scoreboard: 'Scoreboard',
scoreChart: 'Score chart',
settings: 'Settings',
settingsError: 'Error saving settings',
settingsSuccess: 'Settings saved successfully',
showNumbers: 'Show numbers',
+ showScoreboard: 'Show scoreboard',
+ totalScoring: 'Total score',
+ totalScoringLimit: 'across N rounds',
+ totalScoringOption: {
+ avg: 'Average',
+ sum: 'Sum',
+ },
unlockError: 'Error unlocking tournaments',
unlockSuccess: 'Tournament unlocked',
},
Route::post('tournaments/{tournament}/discord-settings', 'App\Http\Controllers\TournamentController@discordSettings');
Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock');
Route::post('tournaments/{tournament}/open', 'App\Http\Controllers\TournamentController@open');
+Route::post('tournaments/{tournament}/recalc', 'App\Http\Controllers\TournamentController@recalc');
Route::post('tournaments/{tournament}/settings', 'App\Http\Controllers\TournamentController@settings');
Route::post('tournaments/{tournament}/self-assign-groups', 'App\Http\Controllers\TournamentController@selfAssignGroups');
Route::post('tournaments/{tournament}/swap-group', 'App\Http\Controllers\TournamentController@swapGroup');