--- /dev/null
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Round;
+use Illuminate\Console\Command;
+
+class RecalculateRoundPlacements extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'round:recalc {round}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Recalculate round placements';
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle()
+ {
+ $round = Round::findOrFail($this->argument('round'));
+
+ $round->updatePlacement();
+
+ return 0;
+ }
+}
--- /dev/null
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Tournament;
+use Illuminate\Console\Command;
+
+class RecalculateTournamentPlacements extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'tournament:recalc {tournament}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Recalculate tournament placements';
+
+ /**
+ * Execute the console command.
+ *
+ * @return int
+ */
+ public function handle()
+ {
+ $tournament = Tournament::findOrFail($this->argument('tournament'));
+
+ foreach ($tournament->rounds as $round) {
+ $round->updatePlacement();
+ }
+ $tournament->updatePlacement();
+
+ return 0;
+ }
+}
--- /dev/null
+<?php
+
+namespace App\Events;
+
+use App\Models\Participant;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ParticipantChanged implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct(Participant $participant)
+ {
+ $this->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;
+
+}
--- /dev/null
+<?php
+
+namespace App\Events;
+
+use App\Models\Result;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ResultChanged implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct(Result $result)
+ {
+ $this->result = $result;
+ }
+
+ /**
+ * Get the channels the event should broadcast on.
+ *
+ * @return \Illuminate\Broadcasting\Channel|array
+ */
+ public function broadcastOn()
+ {
+ return new Channel('Tournament.'.$this->result->round->tournament_id);
+ }
+
+ public $result;
+
+}
+++ /dev/null
-<?php
-
-namespace App\Events;
-
-use App\Models\Result;
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class ResultReported implements ShouldBroadcast
-{
- use Dispatchable, InteractsWithSockets, SerializesModels;
-
- /**
- * Create a new event instance.
- *
- * @return void
- */
- public function __construct(Result $result)
- {
- $this->result = $result;
- }
-
- /**
- * Get the channels the event should broadcast on.
- *
- * @return \Illuminate\Broadcasting\Channel|array
- */
- public function broadcastOn()
- {
- return new Channel('Tournament.'.$this->result->round->tournament_id);
- }
-
- public $result;
-
-}
namespace App\Http\Controllers;
-use App\Events\ResultReported;
+use App\Events\ResultChanged;
use App\Models\Participant;
use App\Models\Protocol;
use App\Models\Result;
'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,
$request->user(),
);
- ResultReported::dispatch($result);
-
return $result->toJson();
}
namespace App\Models;
+use App\Events\ParticipantChanged;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\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);
}
return $this->belongsTo(User::class);
}
+
protected $casts = [
'roles' => 'array',
];
namespace App\Models;
+use App\Events\ResultChanged;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\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);
}
return $this->time > 0 || $this->forfeit;
}
+
protected $appends = [
'has_finished',
];
{
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);
}
return $this->belongsTo(Tournament::class);
}
+
protected $casts = [
'code' => 'array',
'locked' => 'boolean',
namespace App\Models;
+use App\Events\ParticipantChanged;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\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);
}
--- /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.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('results', function(Blueprint $table) {
+ $table->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');
+ });
+ }
+};
--- /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.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('participants', function(Blueprint $table) {
+ $table->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');
+ });
+ }
+};
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();
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));
}
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 <Icon.PENDING className="text-muted" size="lg" />;
}
if (result.forfeit && maySee) {
return <Icon.FORFEIT className="text-danger" size="lg" />;
}
- if (index === 0) {
+ if (result.placement === 1) {
return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
}
- if (index === 1) {
+ if (result.placement === 2) {
return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
}
- if (index === 2) {
+ if (result.placement === 3) {
return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
}
return <Icon.FINISHED className="text-success" size="lg" />;
};
const Item = ({
- index,
participant,
round,
tournament,
<span className="time">
{getTime(result, maySee)}
</span>
- {getIcon(result, index, maySee)}
+ {getIcon(result, maySee)}
</div>
</div>;
};
Item.propTypes = {
- index: PropTypes.number,
participant: PropTypes.shape({
user: PropTypes.shape({
}),
import { getRunners } from '../../helpers/Tournament';
const List = ({ round, tournament }) => <div className="results d-flex flex-wrap">
- {sortByResult(getRunners(tournament), round).map((participant, index) =>
+ {sortByResult(getRunners(tournament), round).map(participant =>
<Item
- index={index}
key={participant.id}
participant={participant}
round={round}
},
mapPropsToValues: ({ participant, round }) => {
const result = findResult(participant, round);
- console.log(result);
return {
forfeit: result ? !!result.forfeit : false,
participant_id: participant.id,
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 <Icon.FIRST_PLACE className="text-gold" size="lg" />;
}
- if (score.placement === 2) {
+ if (participant.placement === 2) {
return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
}
- if (score.placement === 3) {
+ if (participant.placement === 3) {
return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
}
- return score.placement;
+ return participant.placement;
};
const Scoreboard = ({ tournament, user }) =>
</tr>
</thead>
<tbody>
- {calculateScores(tournament).map(score =>
- <tr className={getRowClassName(tournament, score, user)} key={score.participant.id}>
+ {getRunners(tournament).sort(comparePlacement).map(participant =>
+ <tr className={getRowClassName(tournament, participant, user)} key={participant.id}>
<td className="text-center">
- {getPlacementDisplay(score)}
+ {getPlacementDisplay(participant)}
</td>
<td>
<div className="d-flex align-items-center justify-content-between">
- <Box user={score.participant.user} />
- {score.participant.user.stream_link ?
+ <Box user={participant.user} />
+ {participant.user.stream_link ?
<Button
- href={score.participant.user.stream_link}
+ href={participant.user.stream_link}
size="sm"
target="_blank"
title={i18n.t('users.stream')}
: null}
</div>
</td>
- <td className="text-end">{score.score}</td>
+ <td className="text-end">{participant.score}</td>
</tr>
)}
</tbody>
+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);
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;
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 {
};
export default {
- calculateScores,
compareScore,
findParticipant,
getRunners,