private function handleGuild(DiscordGuild $guild): void {
$from = now()->sub(1, 'hour');
+ $until = now()->add(14, 'days');
$eventIDs = $guild->event_subscriptions->pluck('event_id')->toArray();
+ $modeIDs = $guild->ladder_subscriptions->pluck('step_ladder_mode_id')->toArray();
$userIDs = $guild->user_subscriptions->pluck('user_id')->toArray();
- if (empty($eventIDs) && empty($userIDs)) {
+ if (empty($eventIDs) && empty($modeIDs) && empty($userIDs)) {
return;
}
$query = Episode::with(['channels', 'event', 'players'])
->where('episodes.start', '>', $from)
+ ->where('episodes.start', '<', $until)
->orderBy('episodes.start', 'ASC')
->limit(20);
- $query->where(function ($subquery) use ($eventIDs, $userIDs) {
+ $query->where(function ($subquery) use ($eventIDs, $modeIDs, $userIDs) {
if (!empty($eventIDs)) {
$subquery->whereIn('episodes.event_id', $eventIDs);
}
+ if (!empty($modeIDs)) {
+ $subquery->orWhereIn('episodes.step_ladder_mode_id', $modeIDs);
+ }
if (!empty($userIDs)) {
$subquery->orWhereHas('players', function ($builder) use ($userIDs) {
$builder->whereIn('episode_players.user_id', $userIDs);
}
private function handleEpisode(DiscordGuild $guild, Episode $episode): void {
- $mtime = $episode->updated_at;
- foreach ($episode->channels as $channel) {
- if ($mtime < $channel->updated_at) {
- $mtime = $channel->updated_at;
- }
- }
- foreach ($episode->confirmedCrew() as $crew) {
- if ($mtime < $crew->updated_at) {
- $mtime = $crew->updated_at;
- }
- if ($crew->user && $mtime < $crew->user->updated_at) {
- $mtime = $crew->user->updated_at;
- }
- }
- foreach ($episode->players as $player) {
- if ($mtime < $player->updated_at) {
- $mtime = $player->updated_at;
- if ($player->user && $mtime < $player->user->updated_at) {
- $mtime = $crew->user->updated_at;
- }
- }
- }
+ $mtime = $episode->getMTime();
$memo = $this->getMemo($guild, $episode);
if ((is_null($memo->synced_at) && $episode->start > now()) || (!is_null($memo->synced_at) && $memo->synced_at < $mtime)) {
$this->line('pushing '.$episode->id.' '.$episode->getScheduledEventName());
$guild = DiscordGuild::with([
'event_subscriptions',
'event_subscriptions.event',
+ 'ladder_subscriptions',
+ 'ladder_subscriptions.step_ladder_mode',
'user_subscriptions',
'user_subscriptions.user',
])->where('guild_id', '=', $guild_id)->firstOrFail();
$validatedData = $request->validate([
'add_event' => 'numeric|exists:App\Models\Event,id',
+ 'add_ladder' => 'numeric|exists:App\Models\StepLadderMode,id',
'add_user' => 'numeric|exists:App\Models\User,id',
'remove_event' => 'numeric|exists:App\Models\Event,id',
+ 'remove_ladder' => 'numeric|exists:App\Models\StepLadderMode,id',
'remove_user' => 'numeric|exists:App\Models\User,id',
]);
if (isset($validatedData['add_event'])) {
$guild->event_subscriptions()->create(['event_id' => $validatedData['add_event']]);
}
+ if (isset($validatedData['add_ladder'])) {
+ $guild->ladder_subscriptions()->create(['step_ladder_mode_id' => $validatedData['add_ladder']]);
+ }
if (isset($validatedData['add_user'])) {
$guild->user_subscriptions()->create(['user_id' => $validatedData['add_user']]);
}
$sub = $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->firstOrFail();
$sub->delete();
}
+ if (isset($validatedData['remove_ladder'])) {
+ $sub = $guild->ladder_subscriptions()->where('step_ladder_mode_id', '=', $validatedData['remove_ladder'])->firstOrFail();
+ $sub->delete();
+ }
if (isset($validatedData['remove_user'])) {
$sub = $guild->user_subscriptions()->where('user_id', '=', $validatedData['remove_user'])->firstOrFail();
$sub->delete();
$guild->load([
'event_subscriptions',
'event_subscriptions.event',
+ 'ladder_subscriptions',
+ 'ladder_subscriptions.step_ladder_mode',
'user_subscriptions',
'user_subscriptions.user',
]);
namespace App\Http\Controllers;
use App\Models\Event;
-use Carbon\Carbon;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
--- /dev/null
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\StepLadderMode;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+use Illuminate\Http\Request;
+
+class StepLadderModeController extends Controller
+{
+
+ public function search(Request $request) {
+ $validatedData = $request->validate([
+ 'exclude_ids' => 'array|nullable',
+ 'exclude_ids.*' => 'int',
+ 'limit' => 'nullable|int',
+ 'phrase' => 'nullable|string',
+ ]);
+ $modes = StepLadderMode::query();
+ if (!empty($validatedData['exclude_ids'])) {
+ $modes->whereNotIn('id', $validatedData['exclude_ids']);
+ }
+ if (isset($validatedData['limit'])) {
+ $modes->limit($validatedData['limit']);
+ }
+ if (isset($validatedData['phrase'])) {
+ $modes->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
+ }
+ $modes->orderBy('name');
+ return $modes->get()->toJson();
+ }
+
+ public function single(Request $request, StepLadderMode $mode) {
+ $mode->load('description');
+ return $mode->toJson();
+ }
+
+}
return $this->hasMany(DiscordGuildEventSubscription::class);
}
+ public function ladder_subscriptions() {
+ return $this->hasMany(DiscordGuildLadderSubscription::class);
+ }
+
public function roles() {
return $this->hasMany(DiscordRole::class)->orderBy('position');
}
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
+use Illuminate\Database\Eloquent\Model;
+
+class DiscordGuildLadderSubscription extends Model {
+
+ use BroadcastsEvents;
+
+ public function broadcastOn(string $event): array {
+ $channels = [];
+ if ($this->discord_guild_id) {
+ $channels[] = new PrivateChannel('DiscordGuild.'.$this->discord_guild_id);
+ }
+ return $channels;
+ }
+
+ public function guild() {
+ return $this->belongsTo(DiscordGuild::class);
+ }
+
+ public function step_ladder_mode() {
+ return $this->belongsTo(StepLadderMode::class);
+ }
+
+ protected $fillable = [
+ 'discord_guild_id',
+ 'step_ladder_mode_id',
+ ];
+
+ protected $with = [
+ 'step_ladder_mode',
+ ];
+
+}
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Str;
class Episode extends Model
{
return $this->belongsTo(StepLadderMode::class);
}
+ public function getMTime() {
+ $mtime = $this->updated_at;
+ foreach ($this->channels as $channel) {
+ if ($mtime < $channel->updated_at) {
+ $mtime = $channel->updated_at;
+ }
+ }
+ foreach ($this->confirmedCrew as $crew) {
+ if ($mtime < $crew->updated_at) {
+ $mtime = $crew->updated_at;
+ }
+ if ($crew->user && $mtime < $crew->user->updated_at) {
+ $mtime = $crew->user->updated_at;
+ }
+ }
+ foreach ($this->players as $player) {
+ if ($mtime < $player->updated_at) {
+ $mtime = $player->updated_at;
+ if ($player->user && $mtime < $player->user->updated_at) {
+ $mtime = $crew->user->updated_at;
+ }
+ }
+ }
+ return $mtime;
+ }
+
public function getScheduledEventName(): string {
$parts = [];
if (count($this->players) == 4) {
}
}
}
+ if ($this->raceroom) {
+ $description .= "\nRaceroom: [".Str::afterLast($this->raceroom, '/').']('.$this->raceroom.")\n";
+ }
return $description;
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void {
+ Schema::create('discord_guild_ladder_subscriptions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('discord_guild_id')->constrained();
+ $table->foreignId('step_ladder_mode_id')->constrained();
+ $table->timestamps();
+ $table->unique(['discord_guild_id', 'step_ladder_mode_id'], 'guild_mode_unique');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void {
+ Schema::dropIfExists('discord_guild_ladder_subscriptions');
+ }
+};
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Button, Form, ListGroup } from 'react-bootstrap';
+
+import Icon from './Icon';
+import debounce from '../../helpers/debounce';
+
+const StepLadderModeSelect = ({ excludeIds = [], name, onChange, value }) => {
+ const [resolved, setResolved] = useState(null);
+ const [results, setResults] = useState([]);
+ const [search, setSearch] = useState('');
+ const [showResults, setShowResults] = useState(false);
+
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handleEventOutside = e => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setShowResults(false);
+ }
+ };
+ document.addEventListener('mousedown', handleEventOutside, true);
+ document.addEventListener('focus', handleEventOutside, true);
+ return () => {
+ document.removeEventListener('mousedown', handleEventOutside, true);
+ document.removeEventListener('focus', handleEventOutside, true);
+ };
+ }, []);
+
+ let ctrl = null;
+ const fetch = useCallback(debounce(async phrase => {
+ if (ctrl) {
+ ctrl.abort();
+ }
+ ctrl = new AbortController();
+ try {
+ const response = await axios.get(`/api/step-ladder-modes`, {
+ params: {
+ exclude_ids: excludeIds,
+ limit: 5,
+ phrase,
+ },
+ signal: ctrl.signal,
+ });
+ ctrl = null;
+ setResults(response.data);
+ if (phrase) {
+ setShowResults(true);
+ }
+ } catch (e) {
+ ctrl = null;
+ console.error(e);
+ }
+ }, 300), [excludeIds]);
+
+ useEffect(() => {
+ fetch(search);
+ }, [search]);
+
+ useEffect(() => {
+ if (value) {
+ axios
+ .get(`/api/step-ladder-modes/${value}`)
+ .then(response => {
+ setResolved(response.data);
+ });
+ } else {
+ setResolved(null);
+ }
+ }, [value]);
+
+ if (value) {
+ return <div className="d-flex justify-content-between">
+ {resolved ? <span>{resolved.name} ({resolved.ext_id.substr(11)})</span> : <span>value</span>}
+ <Button
+ onClick={() => onChange({ target: { name, value: null }})}
+ size="sm"
+ variant="outline-danger"
+ >
+ <Icon.REMOVE />
+ </Button>
+ </div>;
+ }
+ return <div className={`model-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+ <Form.Control
+ className="search-input"
+ name={Math.random().toString(20).substr(2, 10)}
+ onChange={e => setSearch(e.target.value)}
+ onFocus={() => setShowResults(true)}
+ type="search"
+ value={search}
+ />
+ <div className="search-results-holder">
+ <ListGroup className="search-results">
+ {results.map(result =>
+ <ListGroup.Item
+ action
+ key={result.id}
+ onClick={() => {
+ onChange({
+ target: { name, value: result.id },
+ });
+ setSearch('');
+ setShowResults(false);
+ }}
+ >
+ <span>{result.name} ({result.ext_id.substr(11)})</span>
+ </ListGroup.Item>
+ )}
+ </ListGroup>
+ </div>
+ </div>;
+};
+
+StepLadderModeSelect.propTypes = {
+ excludeIds: PropTypes.arrayOf(PropTypes.number),
+ name: PropTypes.string,
+ onChange: PropTypes.func,
+ value: PropTypes.string,
+};
+
+export default StepLadderModeSelect;
import EventSubscriptions from './EventSubscriptions';
import GuildCrew from './GuildCrew';
import GuildProtocol from './GuildProtocol';
+import LadderSubscriptions from './LadderSubscriptions';
import UserSubscriptions from './UserSubscriptions';
import ErrorBoundary from '../common/ErrorBoundary';
import { compareTitle } from '../../helpers/Event';
}
}, [guild.guild_id, patchGuild, t]);
+ const addLadderSub = React.useCallback(async (mode_id) => {
+ try {
+ const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+ add_ladder: mode_id,
+ });
+ patchGuild(response.data);
+ } catch (error) {
+ toastr.error(t('discordBot.ladderSubError', { error }));
+ }
+ }, [guild.guild_id, patchGuild, t]);
+
+ const removeLadderSub = React.useCallback(async (mode_id) => {
+ try {
+ const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+ remove_ladder: mode_id,
+ });
+ patchGuild(response.data);
+ } catch (error) {
+ toastr.error(t('discordBot.ladderUnsubError', { error }));
+ }
+ }, [guild.guild_id, patchGuild, t]);
const addUserSub = React.useCallback(async (user_id) => {
try {
patchGuild(g => ({ ...g, event_subscriptions: (g.event_subscriptions || []).filter(c => c.id !== e.model.id) }));
}
})
+ .listen('.DiscordGuildLadderSubscriptionCreated', e => {
+ if (e.model) {
+ patchGuild(g => ({ ...g, ladder_subscriptions: [...g.ladder_subscriptions || [], e.model] }));
+ }
+ })
+ .listen('.DiscordGuildLadderSubscriptionUpdated', e => {
+ if (e.model) {
+ patchGuild(g => ({ ...g, ladder_subscriptions: (g.ladder_subscriptions || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) }));
+ }
+ })
+ .listen('.DiscordGuildLadderSubscriptionDeleted', e => {
+ if (e.model) {
+ patchGuild(g => ({ ...g, ladder_subscriptions: (g.ladder_subscriptions || []).filter(c => c.id !== e.model.id) }));
+ }
+ })
.listen('.DiscordGuildUserSubscriptionCreated', e => {
if (e.model) {
patchGuild(g => ({ ...g, user_subscriptions: [...g.user_subscriptions || [], e.model] }));
</Col>
</Row>
<Row>
- <Col md={6}>
+ <Col className="my-5" md={6}>
<h3>{t('discordBot.eventSubscriptions')}</h3>
<p style={{ minHeight: '3.5em' }}>{t('discordBot.eventSubscriptionDescription')}</p>
<ErrorBoundary>
/>
</ErrorBoundary>
</Col>
- <Col md={6}>
+ <Col className="my-5" md={6}>
<h3>{t('discordBot.userSubscriptions')}</h3>
<p style={{ minHeight: '3.5em' }}>{t('discordBot.userSubscriptionDescription')}</p>
<ErrorBoundary>
/>
</ErrorBoundary>
</Col>
+ <Col className="my-5" md={6}>
+ <h3>{t('discordBot.ladderSubscriptions')}</h3>
+ <p style={{ minHeight: '3.5em' }}>{t('discordBot.ladderSubscriptionDescription')}</p>
+ <ErrorBoundary>
+ <LadderSubscriptions
+ addMode={addLadderSub}
+ guild={guild}
+ removeMode={removeLadderSub}
+ subs={guild.ladder_subscriptions || []}
+ />
+ </ErrorBoundary>
+ </Col>
</Row>
</section>
<section className="mt-5">
})),
id: PropTypes.number,
guild_id: PropTypes.string,
+ ladder_subscriptions: PropTypes.arrayOf(PropTypes.shape({
+ })),
user_subscriptions: PropTypes.arrayOf(PropTypes.shape({
})),
}),
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import StepLadderModeSelect from '../common/StepLadderModeSelect';
+import Icon from '../common/Icon';
+import { mayManageGuild } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const LadderSubscriptions = ({ addMode, guild, removeMode, subs }) => {
+ const { t } = useTranslation();
+ const { user } = useUser();
+
+ const mayManage = React.useMemo(() => mayManageGuild(user, guild), [guild, user]);
+
+ return <div>
+ {mayManage ?
+ <Form.Group controlId="lsubs.addMode">
+ <Form.Label>{t('discordBot.addLadderMode')}</Form.Label>
+ <Form.Control
+ as={StepLadderModeSelect}
+ excludeIds={subs.map(s => s.step_ladder_mode_id)}
+ onChange={e => addMode(e.target.value)}
+ value=""
+ />
+ </Form.Group>
+ : null}
+ {subs.map((lsub) => (
+ <div className="d-flex align-items-center justify-content-between my-2" key={lsub.id}>
+ <div>{lsub.step_ladder_mode.name} ({lsub.step_ladder_mode.ext_id.substr(11)})</div>
+ {mayManage ?
+ <div className="button-bar">
+ <Button
+ onClick={() => removeMode(lsub.step_ladder_mode_id)}
+ size="sm"
+ title={t('button.remove')}
+ variant="outline-danger"
+ >
+ <Icon.DELETE title="" />
+ </Button>
+ </div>
+ : null}
+ </div>
+ ))}
+ </div>;
+};
+
+LadderSubscriptions.propTypes = {
+ addMode: PropTypes.func,
+ guild: PropTypes.shape({
+ }),
+ removeMode: PropTypes.func,
+ subs: PropTypes.arrayOf(PropTypes.shape({
+ })),
+};
+
+export default LadderSubscriptions;
discordBot: {
addCrew: 'User hinzufügen',
addEvent: 'Event abonnieren',
+ addLadderMode: 'Ladder Mode abonnieren',
addUser: 'User abonnieren',
channel: 'Kanal',
channelControls: 'Kanal-Steuerung',
guildProtocol: 'Command Protokoll',
heading: 'Discord Bot',
invite: 'Bot einladen',
+ ladderSubscriptionDescription: 'Races abonnierter Ladder Modi werden als Discord Event angelegt. Einige der Einträge tauchen von Ladderseite aus mehrfach auf. In dem Fall bitte alle Doubletten hinzufügen, sonst wird möglicherweise nur jedes 2. oder 3. Race erkannt.',
+ ladderSubscriptions: 'Abonnierte Ladder Modi',
message: 'Nachricht',
messageError: 'Fehler beim Senden',
messageSuccess: 'Nachricht in Warteschlange',
discordBot: {
addCrew: 'Add user',
addEvent: 'Subscribe to event',
+ addLadderMode: 'Subscribe to ladder mode',
addUser: 'Subscribe to user',
channel: 'Channel',
channelControls: 'Channel controls',
guildProtocol: 'Command protocol',
heading: 'Discord Bot',
invite: 'Invite bot',
+ ladderSubscriptionDescription: 'Races of subscribed modes will be added as Discord events. Some of the modes have multiple entries on ladder. Please select all dupes, otherwise you may be missing every other or third race in some cases.',
+ ladderSubscriptions: 'Ladder mode subscriptions',
message: 'Message',
messageError: 'Error sending message',
messageSuccess: 'Message queued',
Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed');
Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock');
+Route::get('step-ladder-modes', 'App\Http\Controllers\StepLadderModeController@search');
+Route::get('step-ladder-modes/{mode}', 'App\Http\Controllers\StepLadderModeController@single');
+
Route::get('tech', 'App\Http\Controllers\TechniqueController@search');
Route::get('tech/{tech:name}', 'App\Http\Controllers\TechniqueController@single');