]> git.localhorst.tv Git - alttp.git/commitdiff
tournament scoring settings
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sun, 1 Feb 2026 20:59:22 +0000 (21:59 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sun, 1 Feb 2026 20:59:22 +0000 (21:59 +0100)
16 files changed:
app/Events/ResultChanged.php
app/Http/Controllers/TournamentController.php
app/Models/Protocol.php
app/Models/Round.php
app/Models/Tournament.php
config/database.php
database/migrations/2026_02_01_140215_tournament_scoring_settings.php [new file with mode: 0644]
resources/js/components/rounds/Item.jsx
resources/js/components/tournament/ScoreChart.jsx
resources/js/components/tournament/Scoreboard.jsx
resources/js/components/tournament/SettingsDialog.jsx
resources/js/helpers/Participant.js
resources/js/helpers/Tournament.js
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

index 61207a0a06efaceba649e3b7c68fe40a60a5d6fb..92be10d3bed9e17d38b570233f80083b7fc7d7f4 100644 (file)
@@ -23,6 +23,7 @@ class ResultChanged implements ShouldBroadcast
        public function __construct(Result $result)
        {
                $this->result = $result;
+               $result->setRelations([]);
                $result->load(['user', 'verified_by']);
        }
 
index 3649ae87b1901bf8d4a50784ddc7eec18a3d48fa..e7fd6b55bc886c9c2e9d06172dd50811f8c41075 100644 (file)
@@ -111,30 +111,40 @@ class TournamentController extends Controller
                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();
        }
index 76b096fff4b089b0948675645a14e561383fc340..3ab422f0eb26719d1685dd7b927c7364b41cdd7e 100644 (file)
@@ -316,13 +316,14 @@ class Protocol extends Model
                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);
index 80e3beed78d597762e62cd1f775a5bb9dde8b59d..2088655b2a4cd94a41e6e58897372fdce5b166f2 100644 (file)
@@ -23,6 +23,10 @@ class Round extends Model
                ];
        }
 
+       public function broadcastWith($event) {
+               $this->setRelations([]);
+       }
+
 
        public function isComplete() {
                if (count($this->tournament->participants) == 0) return false;
@@ -46,7 +50,7 @@ class Round extends Model
                                if ($time > 0) {
                                        $reference_times[] = $time;
                                }
-                               if (count($reference_times) >= 5) {
+                               if (count($reference_times) >= $this->tournament->round_scoring_limit) {
                                        break;
                                }
                        }
@@ -73,7 +77,7 @@ class Round extends Model
                                        $result->updatePlacement($score, count($results) - $running + 1);
                                }
                        }
-               } elseif (!$this->tournament->hasFixedRunners()) {
+               } else {
                        $results = $this->results->sort([Result::class, 'compareResult']);
                        $reversed = $results->reverse();
 
@@ -94,49 +98,10 @@ class Round extends Model
                                } 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);
                                }
                        }
                }
index ce12ace8f8d6aeaf1f001215b14bf0185307f19e..dad2522309330000978239265d97c40ba06b8f92 100644 (file)
@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Collection;
 
 class Tournament extends Model {
 
@@ -26,7 +27,7 @@ class Tournament extends Model {
        }
 
        public function hasTimeBasedScoring(): bool {
-               return in_array($this->type, ['open-grouped-async']);
+               return $this->round_scoring == 'time';
        }
 
        public function getRunners() {
@@ -39,37 +40,52 @@ class Tournament extends Model {
                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;
@@ -81,6 +97,18 @@ class Tournament extends Model {
                }
        }
 
+       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)
index a61db88a8a742acd527b4c824ee835ff58f31414..4dc79018268ad17d509ee6fda14f8787d37e848a 100644 (file)
@@ -59,9 +59,6 @@ return [
             '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' => [
diff --git a/database/migrations/2026_02_01_140215_tournament_scoring_settings.php b/database/migrations/2026_02_01_140215_tournament_scoring_settings.php
new file mode 100644 (file)
index 0000000..6828a70
--- /dev/null
@@ -0,0 +1,38 @@
+<?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');
+               });
+       }
+};
index b2e26cea262356f8aba5e4b5e1576869f1826cff..70a66bae880ef18d8c5b8029688959b9a9a7a1e6 100644 (file)
@@ -41,7 +41,7 @@ const getClassName = (round, tournament, user) => {
        }
        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(' ');
@@ -111,7 +111,7 @@ const Item = ({
                                                {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) ?
index b63c81e12113007cdb9969691a55588e38c457f4..afd686143d011de307414c4d2d3309b91b49b1a1 100644 (file)
@@ -3,11 +3,12 @@ import React from 'react';
 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',
@@ -20,23 +21,28 @@ const COLORS = [
 
 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({
index 27bb0873a4d7c75d4de6a3b15f25f1d514a07713..5abbaa2836e253d457d0dd086bd292949e4bfa2e 100644 (file)
@@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next';
 
 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'];
@@ -61,6 +61,8 @@ const Scoreboard = ({ tournament }) => {
        const { t } = useTranslation();
        const { user } = useUser();
 
+       const entries = React.useMemo(() => getScoreboardRunners(tournament, user), [tournament, user]);
+
        return <Table striped className="scoreboard align-middle">
                <thead>
                        <tr>
@@ -70,7 +72,7 @@ const Scoreboard = ({ tournament }) => {
                        </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)}
@@ -91,7 +93,11 @@ const Scoreboard = ({ tournament }) => {
                                                : 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>
@@ -100,6 +106,7 @@ const Scoreboard = ({ tournament }) => {
 
 Scoreboard.propTypes = {
        tournament: PropTypes.shape({
+               limit_scoreboard: PropTypes.number,
        }),
 };
 
index ec38a9ab6a1fd99b767ca69f88f944adebc6fc53..092102ea10eb5e6b2a36640fb38bf8cadd0f806b 100644 (file)
@@ -49,6 +49,15 @@ const unlock = async tournament => {
        }
 };
 
+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 });
@@ -170,6 +179,109 @@ const SettingsDialog = ({
                                                )}
                                        </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>
@@ -215,9 +327,15 @@ SettingsDialog.propTypes = {
                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,
        }),
 };
 
index 1a24f8efef9ee242be68ec7e0af9a271fda6010a..fcfa31a71227581060071feee8daf5fd6b73aa38 100644 (file)
@@ -101,6 +101,7 @@ export const sortByUsername = (participants, round) => {
 
 export default {
        compareFinished,
+       comparePlacement,
        compareResult,
        compareUsername,
        findResult,
index 68ca767019ab9f89bae81c09e44634e9f04990cd..44207a2a23ad0b41f04718dc8c47c040db2ee536 100644 (file)
@@ -197,37 +197,59 @@ export const getRunners = tournament => {
                .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;
        });
index 4a6c24ef8d3942196cb487811f180e3ccfe64ff0..34f18c3d6ec38f13273982c1acaba4f51603095b 100644 (file)
@@ -953,6 +953,7 @@ export default {
                                never: 'Nie',
                        },
                        inviteBot: 'Bot einladen',
+                       limitScoreboard: 'Scoreboard Größe',
                        locked: 'Turnier sperren',
                        lockError: 'Fehler beim Sperren',
                        lockSuccess: 'Turnier gesperrt',
@@ -962,6 +963,9 @@ export default {
                        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: {
@@ -976,12 +980,26 @@ export default {
                                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',
                },
index 5f00ae0050a221d24b39fa903a9d6dacf2d44998..a4b681c673a7a1c34727020b2ffe776fe7f3c987 100644 (file)
@@ -954,6 +954,7 @@ export default {
                                never: 'Never',
                        },
                        inviteBot: 'Invite bot',
+                       limitScoreboard: 'Scoreboard size',
                        locked: 'Lock rounds',
                        lockError: 'Error locking tournament',
                        lockSuccess: 'Tournament locked',
@@ -963,6 +964,9 @@ export default {
                        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: {
@@ -977,12 +981,26 @@ export default {
                                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',
                },
index abe4e6d67f208a594f60df514ab45207bd25bfec..1cdb80b56ff29ff3df4939f22bc0f6ffe2f172b0 100644 (file)
@@ -116,6 +116,7 @@ Route::post('tournaments/{tournament}/discord', 'App\Http\Controllers\Tournament
 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');