From d32516335ea2534e15256c948e9c38d3de40794b Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 25 Mar 2022 09:44:20 +0100 Subject: [PATCH] server calculated scoring --- .../Commands/RecalculateRoundPlacements.php | 37 +++++++++ .../RecalculateTournamentPlacements.php | 40 ++++++++++ app/Events/ParticipantChanged.php | 40 ++++++++++ .../{ResultReported.php => ResultChanged.php} | 2 +- app/Http/Controllers/ResultController.php | 10 ++- app/Models/Participant.php | 75 +++++++++++++++++++ app/Models/Result.php | 22 ++++++ app/Models/Round.php | 45 +++++++++++ app/Models/Tournament.php | 52 +++++++++++++ .../2022_03_23_164955_result_placement.php | 34 +++++++++ ...022_03_23_175706_participant_placement.php | 34 +++++++++ resources/js/components/pages/Tournament.js | 15 +++- resources/js/components/results/Item.js | 12 ++- resources/js/components/results/List.js | 3 +- resources/js/components/results/ReportForm.js | 1 - .../js/components/tournament/Scoreboard.js | 31 ++++---- resources/js/helpers/Participant.js | 33 ++++---- resources/js/helpers/Tournament.js | 71 ++++++------------ 18 files changed, 458 insertions(+), 99 deletions(-) create mode 100644 app/Console/Commands/RecalculateRoundPlacements.php create mode 100644 app/Console/Commands/RecalculateTournamentPlacements.php create mode 100644 app/Events/ParticipantChanged.php rename app/Events/{ResultReported.php => ResultChanged.php} (94%) create mode 100644 database/migrations/2022_03_23_164955_result_placement.php create mode 100644 database/migrations/2022_03_23_175706_participant_placement.php diff --git a/app/Console/Commands/RecalculateRoundPlacements.php b/app/Console/Commands/RecalculateRoundPlacements.php new file mode 100644 index 0000000..ca4548b --- /dev/null +++ b/app/Console/Commands/RecalculateRoundPlacements.php @@ -0,0 +1,37 @@ +argument('round')); + + $round->updatePlacement(); + + return 0; + } +} diff --git a/app/Console/Commands/RecalculateTournamentPlacements.php b/app/Console/Commands/RecalculateTournamentPlacements.php new file mode 100644 index 0000000..c1f7974 --- /dev/null +++ b/app/Console/Commands/RecalculateTournamentPlacements.php @@ -0,0 +1,40 @@ +argument('tournament')); + + foreach ($tournament->rounds as $round) { + $round->updatePlacement(); + } + $tournament->updatePlacement(); + + return 0; + } +} diff --git a/app/Events/ParticipantChanged.php b/app/Events/ParticipantChanged.php new file mode 100644 index 0000000..2e43437 --- /dev/null +++ b/app/Events/ParticipantChanged.php @@ -0,0 +1,40 @@ +participant = $participant; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new Channel('Tournament.'.$this->participant->tournament_id); + } + + public $participant; + +} diff --git a/app/Events/ResultReported.php b/app/Events/ResultChanged.php similarity index 94% rename from app/Events/ResultReported.php rename to app/Events/ResultChanged.php index 61ec98b..4a62d9d 100644 --- a/app/Events/ResultReported.php +++ b/app/Events/ResultChanged.php @@ -11,7 +11,7 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ResultReported implements ShouldBroadcast +class ResultChanged implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Http/Controllers/ResultController.php b/app/Http/Controllers/ResultController.php index d72a100..5102c8a 100644 --- a/app/Http/Controllers/ResultController.php +++ b/app/Http/Controllers/ResultController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Events\ResultReported; +use App\Events\ResultChanged; use App\Models\Participant; use App\Models\Protocol; use App\Models\Result; @@ -39,6 +39,12 @@ class ResultController extends Controller 'forfeit' => $validatedData['forfeit'], 'time' => isset($validatedData['time']) ? $validatedData['time'] : 0, ]); + if ($result->wasChanged()) { + ResultChanged::dispatch($result); + } + $round->load('results'); + $round->updatePlacement(); + $round->tournament->updatePlacement(); Protocol::resultReported( $round->tournament, @@ -46,8 +52,6 @@ class ResultController extends Controller $request->user(), ); - ResultReported::dispatch($result); - return $result->toJson(); } diff --git a/app/Models/Participant.php b/app/Models/Participant.php index 8aac88c..91b946d 100644 --- a/app/Models/Participant.php +++ b/app/Models/Participant.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\ParticipantChanged; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,79 @@ class Participant extends Model { use HasFactory; + + public static function compareResult(Round $round) { + return function (Participant $a, Participant $b) use ($round) { + $a_result = $a->findResult($round); + $b_result = $b->findResult($round); + $a_time = $a_result && !$a_result->forfeit ? $a_result->time : 0; + $b_time = $b_result && !$b_result->forfeit ? $b_result->time : 0; + if ($a_time) { + if ($b_time) { + if ($a_time < $b_time) return -1; + if ($b_time < $a_time) return 1; + return static::compareUsername($a, $b); + } + return -1; + } + if ($b_time) { + return 1; + } + $a_forfeit = $a_result ? $a_result->forfeit : false; + $b_forfeit = $b_result ? $b_result->forfeit : false; + if ($a_forfeit) { + if ($b_forfeit) { + return static::compareUsername($a, $b); + } + return -1; + } + if ($b_forfeit) { + return 1; + } + return static::compareUsername($a, $b); + }; + } + + public static function compareScore(Participant $a, Participant $b) { + $a_score = $a->isRunner() ? ($a->score ? $a->score : 0) : -1; + $b_score = $b->isRunner() ? ($b->score ? $b->score : 0) : -1; + if ($a_score < $b_score) return -1; + if ($b_score < $a_score) return 1; + return static::compareUsername($a, $b); + } + + public static function compareUsername(Participant $a, Participant $b) { + return strcasecmp($a->user->username, $b->user->username); + } + + + public function updatePlacement($score, $placement) { + $this->score = $score; + $this->placement = $placement; + $this->save(); + if ($this->wasChanged()) { + ParticipantChanged::dispatch($this); + } + } + + public function findResult(Round $round) { + foreach ($round->results as $result) { + if ($this->user_id == $result->user_id) { + return $result; + } + } + return null; + } + + public function isRunner() { + return in_array('runner', $this->roles); + } + + public function isTournamentAdmin() { + return in_array('admin', $this->roles); + } + + public function tournament() { return $this->belongsTo(Tournament::class); } @@ -17,6 +91,7 @@ class Participant extends Model return $this->belongsTo(User::class); } + protected $casts = [ 'roles' => 'array', ]; diff --git a/app/Models/Result.php b/app/Models/Result.php index 3ced393..cdb86c7 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\ResultChanged; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,26 @@ class Result extends Model { use HasFactory; + + public function updateResult($time, $forfeit) { + $this->time = $time; + $this->forfeit = $forfeit; + $this->save(); + if ($this->wasChanged()) { + ResultChanged::dispatch($this); + } + } + + public function updatePlacement($score, $placement) { + $this->score = $score; + $this->placement = $placement; + $this->save(); + if ($this->wasChanged()) { + ResultChanged::dispatch($this); + } + } + + public function round() { return $this->belongsTo(Round::class); } @@ -21,6 +42,7 @@ class Result extends Model return $this->time > 0 || $this->forfeit; } + protected $appends = [ 'has_finished', ]; diff --git a/app/Models/Round.php b/app/Models/Round.php index 71a4273..3777df9 100644 --- a/app/Models/Round.php +++ b/app/Models/Round.php @@ -9,6 +9,50 @@ class Round extends Model { use HasFactory; + + public function updatePlacement() { + $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); + }); + $reversed = array_reverse($filtered); + + $running = 0; + $bonus = 1; + $lastResult = null; + foreach ($reversed as $r) { + $betterThanLast = is_null($lastResult) || $r['result']->time < $lastResult; + if (!$r['result']->forfeit && $betterThanLast) { + $running += $bonus; + $lastResult = $r['result']->time; + $bonus = 1; + } else { + ++$bonus; + } + if (!$r['result']->forfeit) { + $r['result']->updatePlacement($running, count($filtered) - $running + 1); + } else { + $r['result']->updatePlacement(0, count($filtered)); + } + } + } + + public function results() { return $this->hasMany(Result::class); } @@ -17,6 +61,7 @@ class Round extends Model return $this->belongsTo(Tournament::class); } + protected $casts = [ 'code' => 'array', 'locked' => 'boolean', diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index 217622b..a38e9bd 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\ParticipantChanged; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,57 @@ class Tournament extends Model { use HasFactory; + + public function getRunners() { + $runners = []; + foreach ($this->participants as $participant) { + if (in_array('runner', $participant->roles)) { + $runners[] = $participant; + } + } + return $runners; + } + + public function updatePlacement() { + $runners = []; + foreach ($this->participants as $p) { + if ($p->isRunner()) { + $p->score = 0; + $runners[] = $p; + } else { + $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; + } + } + } + + usort($runners, [Participant::class, 'compareScore']); + $reversed = array_reverse($runners); + $placement = count($runners); + $skipped = 0; + $lastScore = $runners[0]->score; + foreach ($runners as $p) { + if ($p->score > $lastScore) { + $placement -= $skipped; + $skipped = 1; + $lastScore = $p->score; + } else { + ++$skipped; + } + $p->updatePlacement($p->score, $placement); + } + } + + public function participants() { return $this->hasMany(Participant::class); } diff --git a/database/migrations/2022_03_23_164955_result_placement.php b/database/migrations/2022_03_23_164955_result_placement.php new file mode 100644 index 0000000..15dcab7 --- /dev/null +++ b/database/migrations/2022_03_23_164955_result_placement.php @@ -0,0 +1,34 @@ +integer('placement')->nullable()->default(null); + $table->integer('score')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('results', function(Blueprint $table) { + $table->dropColumn('placement'); + $table->dropColumn('score'); + }); + } +}; diff --git a/database/migrations/2022_03_23_175706_participant_placement.php b/database/migrations/2022_03_23_175706_participant_placement.php new file mode 100644 index 0000000..ef8aad0 --- /dev/null +++ b/database/migrations/2022_03_23_175706_participant_placement.php @@ -0,0 +1,34 @@ +integer('placement')->nullable()->default(null); + $table->integer('score')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('participants', function(Blueprint $table) { + $table->dropColumn('placement'); + $table->dropColumn('score'); + }); + } +}; diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js index 6e32227..bc1282e 100644 --- a/resources/js/components/pages/Tournament.js +++ b/resources/js/components/pages/Tournament.js @@ -7,7 +7,13 @@ import ErrorMessage from '../common/ErrorMessage'; import Loading from '../common/Loading'; import NotFound from '../pages/NotFound'; import Detail from '../tournament/Detail'; -import { patchResult, patchRound, patchUser, sortParticipants } from '../../helpers/Tournament'; +import { + patchParticipant, + patchResult, + patchRound, + patchUser, + sortParticipants, +} from '../../helpers/Tournament'; const Tournament = () => { const params = useParams(); @@ -35,7 +41,12 @@ const Tournament = () => { useEffect(() => { window.Echo.channel(`Tournament.${id}`) - .listen('ResultReported', e => { + .listen('ParticipantChanged', e => { + if (e.participant) { + setTournament(tournament => patchParticipant(tournament, e.participant)); + } + }) + .listen('ResultChanged', e => { if (e.result) { setTournament(tournament => patchResult(tournament, e.result)); } diff --git a/resources/js/components/results/Item.js b/resources/js/components/results/Item.js index d99d93f..8439522 100644 --- a/resources/js/components/results/Item.js +++ b/resources/js/components/results/Item.js @@ -9,20 +9,20 @@ import { findResult } from '../../helpers/Participant'; import { maySeeResults } from '../../helpers/permissions'; import { withUser } from '../../helpers/UserContext'; -const getIcon = (result, index, maySee) => { +const getIcon = (result, maySee) => { if (!result || !result.has_finished) { return ; } if (result.forfeit && maySee) { return ; } - if (index === 0) { + if (result.placement === 1) { return ; } - if (index === 1) { + if (result.placement === 2) { return ; } - if (index === 2) { + if (result.placement === 3) { return ; } return ; @@ -42,7 +42,6 @@ const getTime = (result, maySee) => { }; const Item = ({ - index, participant, round, tournament, @@ -56,13 +55,12 @@ const Item = ({ {getTime(result, maySee)} - {getIcon(result, index, maySee)} + {getIcon(result, maySee)} ; }; Item.propTypes = { - index: PropTypes.number, participant: PropTypes.shape({ user: PropTypes.shape({ }), diff --git a/resources/js/components/results/List.js b/resources/js/components/results/List.js index cdd90e8..f4d4ff1 100644 --- a/resources/js/components/results/List.js +++ b/resources/js/components/results/List.js @@ -6,9 +6,8 @@ import { sortByResult } from '../../helpers/Participant'; import { getRunners } from '../../helpers/Tournament'; const List = ({ round, tournament }) =>
- {sortByResult(getRunners(tournament), round).map((participant, index) => + {sortByResult(getRunners(tournament), round).map(participant => { const result = findResult(participant, round); - console.log(result); return { forfeit: result ? !!result.forfeit : false, participant_id: participant.id, diff --git a/resources/js/components/tournament/Scoreboard.js b/resources/js/components/tournament/Scoreboard.js index cbc73a3..40ad82f 100644 --- a/resources/js/components/tournament/Scoreboard.js +++ b/resources/js/components/tournament/Scoreboard.js @@ -5,29 +5,30 @@ import { withTranslation } from 'react-i18next'; import Icon from '../common/Icon'; import Box from '../users/Box'; -import { calculateScores } from '../../helpers/Tournament'; +import { comparePlacement } from '../../helpers/Participant'; +import { getRunners } from '../../helpers/Tournament'; import { withUser } from '../../helpers/UserContext'; import i18n from '../../i18n'; -const getRowClassName = (tournament, score, user) => { +const getRowClassName = (tournament, participant, user) => { const classNames = ['score']; - if (score && user && score.participant && score.participant.user_id == user.id) { + if (participant && user && participant.user_id == user.id) { classNames.push('is-self'); } return classNames.join(' '); }; -const getPlacementDisplay = score => { - if (score.placement === 1) { +const getPlacementDisplay = participant => { + if (participant.placement === 1) { return ; } - if (score.placement === 2) { + if (participant.placement === 2) { return ; } - if (score.placement === 3) { + if (participant.placement === 3) { return ; } - return score.placement; + return participant.placement; }; const Scoreboard = ({ tournament, user }) => @@ -40,17 +41,17 @@ const Scoreboard = ({ tournament, user }) => - {calculateScores(tournament).map(score => - + {getRunners(tournament).sort(comparePlacement).map(participant => + - {getPlacementDisplay(score)} + {getPlacementDisplay(participant)}
- - {score.participant.user.stream_link ? + + {participant.user.stream_link ?
- {score.score} + {participant.score} )} diff --git a/resources/js/helpers/Participant.js b/resources/js/helpers/Participant.js index d79349b..8545e03 100644 --- a/resources/js/helpers/Participant.js +++ b/resources/js/helpers/Participant.js @@ -1,28 +1,23 @@ +export const comparePlacement = (a, b) => { + if (a.placement < b.placement) return -1; + if (b.placement < a.placement) return 1; + return compareUsername(a, b); +}; + export const compareResult = round => (a, b) => { const a_result = findResult(a, round); const b_result = findResult(b, round); - const a_time = a_result && !a_result.forfeit ? a_result.time : 0; - const b_time = b_result && !b_result.forfeit ? b_result.time : 0; - if (a_time) { - if (b_time) { - if (a_time < b_time) return -1; - if (b_time < a_time) return 1; - return 0; - } - return -1; - } - if (b_time) { - return 1; - } - const a_forfeit = a_result && a_result.forfeit; - const b_forfeit = b_result && b_result.forfeit; - if (a_forfeit) { - if (b_forfeit) { - return 0; + const a_placement = a_result && a_result.placement ? a_result.placement : 0; + const b_placement = b_result && b_result.placement ? b_result.placement : 0; + if (a_placement) { + if (b_placement) { + if (a_placement < b_placement) return -1; + if (b_placement < a_placement) return 1; + return compareUsername(a, b); } return -1; } - if (b_forfeit) { + if (b_placement) { return 1; } return compareUsername(a, b); diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index 7dffebb..ec141d1 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -1,54 +1,6 @@ import Participant from './Participant'; import Round from './Round'; -export const calculateScores = tournament => { - const runners = getRunners(tournament); - const scores = runners.map(participant => ({ participant, score: 0 })); - if (!scores.length) return scores; - if (!tournament.rounds || !tournament.rounds.length) return scores; - tournament.rounds.forEach(round => { - const filtered = Participant - .sortByResult(runners, round) - .map(p => ({ participant: p, result: Participant.findResult(p, round) })) - .filter(r => r.result && (r.result.time || r.result.forfeit)) - .reverse(); - let running = 0; - let bonus = 1; - let lastResult = null; - for (let i = 0; i < filtered.length; ++i) { - const score = scores.find(s => s.participant.id === filtered[i].participant.id); - if (!score) return; - const result = filtered[i].result; - const betterThanLast = lastResult === null || result.time < lastResult; - if (!result.forfeit && betterThanLast) { - running += bonus; - lastResult = result.time; - bonus = 1; - } else { - ++bonus; - } - if (!result.forfeit) { - score.score += running; - } - } - }); - const sorted = scores.sort(compareScore); - let placement = scores.length; - let skipped = 0; - let lastScore = sorted[0].score; - for (let i = 0; i < sorted.length; ++i) { - if (sorted[i].score > lastScore) { - placement -= skipped; - skipped = 1; - lastScore = sorted[i].score; - } else { - ++skipped; - } - sorted[i].placement = placement; - } - return sorted.reverse(); -}; - export const compareScore = (a, b) => { const a_score = a && a.score ? a.score : 0; const b_score = b && b.score ? b.score : 0; @@ -85,6 +37,28 @@ export const hasTournamentAdmins = tournament => { return getTournamentAdmins(tournament).length > 0; }; +export const patchParticipant = (tournament, participant) => { + if (!tournament) return tournament; + if (!tournament.participants || !tournament.participants.length) { + return { + ...tournament, + participants: [participant], + }; + } + if (!tournament.participants.find(p => p.id === participant.id)) { + return { + ...tournament, + participants: [...tournament.participants, participant], + }; + } + return { + ...tournament, + participants: tournament.participants.map( + p => p.id === participant.id ? participant : p, + ), + }; +}; + export const patchResult = (tournament, result) => { if (!tournament || !tournament.rounds) return tournament; return { @@ -125,7 +99,6 @@ export const sortParticipants = tournament => { }; export default { - calculateScores, compareScore, findParticipant, getRunners, -- 2.39.2