]> git.localhorst.tv Git - alttp.git/commitdiff
group assignment self sevice
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 20 Nov 2025 15:23:15 +0000 (16:23 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 20 Nov 2025 15:23:15 +0000 (16:23 +0100)
(untested)

app/Http/Controllers/TournamentController.php
app/Models/Protocol.php
app/Models/Tournament.php
app/Policies/TournamentPolicy.php
resources/js/components/tournament/Detail.jsx
resources/js/components/tournament/GroupInterface.jsx [new file with mode: 0644]
resources/js/helpers/Tournament.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tournament.jsx
routes/api.php

index b29cb2ecc407df96e8f6714fbb399119d463e168..2f88af748c8b96019c931e4674b4d6b3259dbaef 100644 (file)
@@ -167,6 +167,48 @@ class TournamentController extends Controller
                return $tournament->toJson();
        }
 
+       public function selfAssignGroups(Request $request, Tournament $tournament) {
+               $this->authorize('selfAssignGroups', $tournament);
+               $user = $request->user();
+
+               $existing = GroupAssignment::query()
+                       ->whereBelongsTo($tournament)
+                       ->whereBelongsTo($user)
+                       ->get()
+                       ->pluck('round_number');
+
+               $round_numbers = $tournament->rounds->pluck('number')->unique();
+
+               $picks = [];
+               foreach ($round_numbers as $number) {
+                       if (!$existing->contains($number)) {
+                               $group = $tournament->pickGroup($number, $user);
+                               $picks[] = [
+                                       'number' => $number,
+                                       'group' => $group,
+                               ];
+                       }
+               }
+
+               if (!empty($picks)) {
+                       foreach ($picks as $pick) {
+                               GroupAssignment::create([
+                                       'tournament_id' => $tournament->id,
+                                       'user_id' => $user->id,
+                                       'round_number' => $pick['number'],
+                                       'group' => $pick['group'],
+                               ]);
+                               Protocol::groupAssignment($tournament, $user, $picks, $user);
+                       }
+               }
+
+               return GroupAssignment::query()
+                       ->whereBelongsTo($tournament)
+                       ->whereBelongsTo($$user)
+                       ->get()
+                       ->toJson();
+       }
+
        public function web(Request $request, Tournament $tournament) {
                $view = view('app')
                        ->with('title', $tournament->getTranslatedTitle())
index 4eddf715be2bf7ea8b98675eed0e97c04e02a6a9..5c317a301807792e627969bf5513b9ce701a5bdd 100644 (file)
@@ -52,6 +52,20 @@ class Protocol extends Model
                ProtocolAdded::dispatch($protocol);
        }
 
+       public static function groupAssignment(Tournament $tournament, User $assignee, $picks, User $user) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user->id,
+                       'type' => 'group.assign',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                               'assignee' => static::userMemo($assignee),
+                               'picks' => $picks,
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
        public static function resultCommented(Tournament $tournament, Result $result, User $user) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
index c2697a05e84ef1f4972b56d76a98b58c8d17e81d..a317c5c9ef9909a592096d7c9b15b88134f1353d 100644 (file)
@@ -69,6 +69,26 @@ class Tournament extends Model {
                }
        }
 
+       public function pickGroup($number, User $user) {
+               $available_rounds = $this->round()->where('number', '=', $number)->get();
+               $assigned_groups = $this->group_assignments()->where('round_number', '=', $number)->get();
+               $weights = array();
+               foreach ($available_rounds as $round) {
+                       $weights[$round->group] = $assigned_groups->count() + 1;
+               }
+               foreach ($assigned_groups as $assignment) {
+                       --$weights[$assignment->group];
+               }
+               $rand = random_int(1, array_sum($weights));
+               foreach ($weights as $group => $weight) {
+                       $rand -= $weight;
+                       if ($rand <= 0) {
+                               return $group;
+                       }
+               }
+               return 'A';
+       }
+
 
        public function applications() {
                return $this->hasMany(Application::class);
@@ -78,6 +98,10 @@ class Tournament extends Model {
                return $this->belongsTo(Technique::class);
        }
 
+       public function group_assignments() {
+               return $this->hasMany(GroupAssignment::class);
+       }
+
        public function participants() {
                return $this->hasMany(Participant::class);
        }
index f58eee76dc12be424ddf5c26f89d49a57a80d676..f8e448b425de5a223c17b62ea6fa31937e9720a5 100644 (file)
@@ -128,4 +128,16 @@ class TournamentPolicy
                return $user->isTournamentCrew($tournament);
        }
 
+       /**
+        * Determine whether the user self assign groups within the tournament.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Tournament  $tournament
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function selfAssignGroups(User $user, Tournament $tournament)
+       {
+               return !!$user;
+       }
+
 }
index a931865710da948ab0413a615bb150615c696956..241c2f02dfec817001c8c4a4b37efed2ab63ef42 100644 (file)
@@ -4,6 +4,7 @@ import { Button, Col, Container, Row } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
 import ApplyButton from './ApplyButton';
+import GroupInterface from './GroupInterface';
 import Scoreboard from './Scoreboard';
 import ScoreChartButton from './ScoreChartButton';
 import SettingsButton from './SettingsButton';
@@ -23,6 +24,7 @@ import { getTranslation } from '../../helpers/Technique';
 import {
        getTournamentAdmins,
        getTournamentMonitors,
+       hasAssignedGroups,
        hasRunners,
        hasScoreboard,
        hasTournamentAdmins,
@@ -125,6 +127,12 @@ const Detail = ({
                                </div>
                        </Col>
                        <Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
+                               {hasAssignedGroups(tournament) ?
+                                       <GroupInterface
+                                               selfAssign={actions.selfAssignGroups}
+                                               tournament={tournament}
+                                       />
+                               : null}
                                <div className="d-flex align-items-center justify-content-between">
                                        <h2>{t('rounds.heading')}</h2>
                                        {actions.addRound && mayAddRounds(user, tournament) ?
@@ -150,6 +158,7 @@ Detail.propTypes = {
                addRound: PropTypes.func,
                editContent: PropTypes.func,
                moreRounds: PropTypes.func,
+               selfAssignGroups: PropTypes.func,
        }).isRequired,
        tournament: PropTypes.shape({
                description: PropTypes.shape({
diff --git a/resources/js/components/tournament/GroupInterface.jsx b/resources/js/components/tournament/GroupInterface.jsx
new file mode 100644 (file)
index 0000000..aed89d2
--- /dev/null
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { missingGroupAssignment } from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
+
+const GroupInterface = ({ selfAssign, tournament }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       if (!user) {
+               return <div><p>{t('groups.loginRequired')}</p></div>
+       }
+
+       if (missingGroupAssignment(tournament, user)) {
+               return <div>
+                       <p>{t('groups.missingAssignments')}</p>
+                       <Button onClick={selfAssign}>
+                               {t('groups.selfAssignButton')}
+                       </Button>
+               </div>
+       }
+
+       return <div>
+               Groups here
+       </div>;
+};
+
+GroupInterface.propTypes = {
+       selfAssign: PropTypes.func,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default GroupInterface;
index 7270351512938a2f3481244d9dca91bf0c8679dd..93657ffff3233bfa7c945ec0338ca909bd78f4cd 100644 (file)
@@ -40,6 +40,8 @@ export const canLoadMoreRounds = tournament => {
        return last_round && last_round.number > 1;
 };
 
+export const hasAssignedGroups = tournament => (tournament?.type === 'open-grouped-async');
+
 export const hasScoreboard = tournament => !!(tournament && tournament.type === 'signup-async');
 
 export const hasSignup = tournament => !!(tournament && tournament.type === 'signup-async');
@@ -103,6 +105,22 @@ export const hasTournamentMonitors = tournament => {
        return getTournamentMonitors(tournament).length > 0;
 };
 
+const unique = (value, index, array) => array.indexOf(value) === index;
+
+export const missingGroupAssignment = (tournament, user) => {
+       if (!user) return true;
+       if (!tournament?.group_assignments?.length) return false;
+       if (!tournament.rounds?.length) return false;
+       const gas = tournament.group_assignments;
+       const rns = tournament.rounds.map(r => r.number).filter(unique);
+       for (let i = 0; i < rns.length; ++i) {
+               if (!gas.find(ga => ga.round_number === rns[i])) {
+                       return true;
+               }
+       }
+       return false;
+}
+
 export const patchApplication = (tournament, application) => {
        if (!tournament) return tournament;
        if (!tournament.applications || !tournament.applications.length) {
index 461aa50eebb17fef5b703ad232a6d5c2b8e123c9..dfe73e0fc0729045ec23bfb01100a419b2b3d704 100644 (file)
@@ -374,6 +374,13 @@ export default {
                        uploadError: 'Fehler beim Hochladen',
                        uploading: 'Am Hochladen...',
                },
+               groups: {
+                       loginRequired: 'Dieses Turnier nutzt Gruppenzuweisung. Bitte melde dich an, um deine Seeds zu laden.',
+                       missingAssignments: 'Dieses Turnier nutzt Gruppenzuweisung. Falls du teilnehmen möchtest, hol dir bitter hier deine Zuweisungen ab.',
+                       selfAssignButton: 'Gruppen zuweisen',
+                       selfAssignError: 'Fehler beim Zuweisen',
+                       selfAssignSuccess: 'Gruppen zugewiesen',
+               },
                icon: {
                        AddIcon: 'Hinzufügen',
                        AllowedIcon: 'Erlaubt',
index 0cba64a497ff1a74db2041e82fe8697c97a7bd3f..abdd40ec4a88ff71b0801b53e7db85add0e1441b 100644 (file)
@@ -374,6 +374,13 @@ export default {
                        uploadError: 'Error uploading',
                        uploading: 'Uploading...',
                },
+               groups: {
+                       loginRequired: 'This tournament uses assigned groups. Please sign in to obtain your seeds.',
+                       missingAssignments: 'This tournament uses assigned groups. If you want to participate, please grab your assignments here.',
+                       selfAssignButton: 'Assign groups',
+                       selfAssignError: 'Error assigning groups',
+                       selfAssignSuccess: 'Groups assigned',
+               },
                icon: {
                        AddIcon: 'Add',
                        AllowedIcon: 'Allowed',
index b86753d387eedaa2ecd6367f69adfb1c9f364007..8d0a9541be8de9fb9aa1fd4f50e1572ede447332 100644 (file)
@@ -159,6 +159,19 @@ export const Component = () => {
                }
        }, [tournament && tournament.description_id]);
 
+       const selfAssignGroups = React.useCallback(async () => {
+               try {
+                       const response = await axios.post(`/api/tournaments/${id}/self-assign-groups`);
+                       toastr.success(t('groups.selfAssignSuccess'));
+                       setTournament(tournament => ({
+                               ...tournament,
+                               group_assignments: response.data,
+                       }));
+               } catch (e) {
+                       toastr.error(t('groups.selfAssignError', e));
+               }
+       }, [id, t]);
+
        const actions = React.useMemo(() => ({
                addRound,
                editContent: mayEditContent(user) ? content => {
@@ -166,7 +179,8 @@ export const Component = () => {
                        setShowContentDialog(true);
                } : null,
                moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
-       }), [addRound, moreRounds, tournament, user]);
+               selfAssignGroups,
+       }), [addRound, moreRounds, selfAssignGroups, tournament, user]);
 
        useEffect(() => {
                const cb = (e) => {
index 86e4aac2e5e1bf5e0b8593416661b4248de68eed..400f678360e7bd766833010826e7718256cf25c0 100644 (file)
@@ -109,6 +109,7 @@ Route::post('tournaments/{tournament}/discord-settings', 'App\Http\Controllers\T
 Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock');
 Route::post('tournaments/{tournament}/open', 'App\Http\Controllers\TournamentController@open');
 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}/unlock', 'App\Http\Controllers\TournamentController@unlock');
 
 Route::get('users', 'App\Http\Controllers\UserController@search');