return $channel->toJson();
}
+ public function controlGuessingGame(Request $request, Channel $channel, $name) {
+ $this->authorize('editRestream', $channel);
+
+ $validatedData = $request->validate([
+ 'action' => 'required|in:cancel,solve,start,stop',
+ 'solution' => '',
+ ]);
+
+ switch ($validatedData['action']) {
+ case 'cancel':
+ $channel->clearGuessing();
+ $msg = $channel->getGuessingSetting('cancel_message');
+ if (!empty($msg)) {
+ TwitchBotCommand::chat($channel->twitch_chat, $msg);
+ }
+ break;
+ case 'solve':
+ if ($channel->hasActiveGuessing() && $channel->isValidGuess($validatedData['solution'])) {
+ $winners = $channel->solveGuessing($validatedData['solution']);
+ $names = [];
+ foreach ($winners as $winner) {
+ if ($winner->score > 0) {
+ $names[] = $winner->uname;
+ }
+ }
+ if (empty($names)) {
+ $msg = $channel->getGuessingSetting('no_winners_message');
+ } else {
+ $msg = $channel->getGuessingSetting('winners_message');
+ $msg = str_replace('{names}', $channel->listAnd($names), $msg);
+ }
+ if (!empty($msg)) {
+ TwitchBotCommand::chat($channel->twitch_chat, $msg);
+ }
+ $channel->clearGuessing();
+ }
+ break;
+ case 'start':
+ if (!$channel->hasActiveGuessing()) {
+ $channel->startGuessing($name);
+ $msg = $channel->getGuessingSetting('start_message');
+ if (!empty($msg)) {
+ TwitchBotCommand::chat($channel->twitch_chat, $msg);
+ }
+ }
+ break;
+ case 'stop':
+ if ($channel->hasActiveGuessing()) {
+ $channel->stopGuessing();
+ $msg = $channel->getGuessingSetting('stop_message');
+ if (!empty($msg)) {
+ TwitchBotCommand::chat($channel->twitch_chat, $msg);
+ }
+ }
+ break;
+ }
+
+ return $channel->toJson();
+ }
+
+ public function getGuessingGame(Channel $channel, $name) {
+ $this->authorize('editRestream', $channel);
+
+ $cutoff = $channel->guessing_start;
+ if (is_null($cutoff)) {
+ $last = $channel->winners()->latest()->first();
+ $cutoff = $last->pod;
+ }
+ $guesses = $channel->guesses()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+ $winners = $channel->winners()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+ return [
+ 'guesses' => $guesses->toArray(),
+ 'winners' => $winners->toArray(),
+ ];
+ }
+
public function saveGuessingGame(Request $request, Channel $channel, $name) {
$this->authorize('editRestream', $channel);
'points_exact_first' => 'numeric|min:1|max:5',
'points_exact_other' => 'numeric|min:0|max:5',
'points_close_first' => 'numeric|min:0|max:5',
- 'points_close_min' => 'integer|min:0',
+ 'points_close_max' => 'integer|min:0',
'points_close_other' => 'numeric|min:0|max:5',
'start_message' => 'string',
'stop_message' => 'string',
namespace App\Models;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Arr;
-class Channel extends Model
-{
+class Channel extends Model {
+
+ use BroadcastsEvents;
use HasFactory;
+ public function broadcastOn($event) {
+ $channels = [
+ new PrivateChannel('Channel.'.$this->id),
+ ];
+ return $channels;
+ }
+
public function getCurrentEpisode() {
return $this->episodes()
->where('start', '<', now()->subMinutes(10))
return false;
}
+ public function listAnd($entries) {
+ $lang = empty($this->languages) ? 'en' : $this->languages[0];
+ if ($lang == 'de') {
+ return Arr::join($entries, ', ', ' und ');
+ }
+ return Arr::join($entries, ', ', ' and ');
+ }
+
public function crews() {
return $this->hasMany(ChannelCrew::class);
}
namespace App\Models;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GuessingGuess extends Model {
+ use BroadcastsEvents;
use HasFactory;
public function channel() {
return $this->belongsTo(Channel::class);
}
+ public function broadcastOn($event) {
+ $channels = [
+ new PrivateChannel('Channel.'.$this->channel_id),
+ ];
+ return $channels;
+ }
+
}
namespace App\Models;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GuessingWinner extends Model {
+ use BroadcastsEvents;
use HasFactory;
public function channel() {
return $this->belongsTo(Channel::class);
}
+ public function broadcastOn($event) {
+ $channels = [
+ new PrivateChannel('Channel.'.$this->channel_id),
+ ];
+ return $channels;
+ }
+
}
namespace App\TwitchBot;
use App\Models\Channel;
-use Illuminate\Support\Arr;
abstract class ChatCommand {
}
protected function listAnd($entries) {
- $lang = empty($this->channels->languages) ? 'en' : $this->channel->languages[0];
- if ($lang == 'de') {
- return Arr::join($entries, ', ', ' und ');
- }
- return Arr::join($entries, ', ', ' and ');
+ return $this->channel->listAnd($entries);
}
protected function messageChannel($str) {
class GuessingCancelCommand extends ChatCommand {
public function execute($args) {
- if ($this->chanel->hasActiveGuessing()) {
+ if ($this->channel->hasActiveGuessing()) {
$this->channel->clearGuessing();
}
$msg = $this->channel->getGuessingSetting('cancel_message');
'../pages/DoorsTracker'
)}
/>
+ <Route path="guessing-game">
+ <Route
+ path="controls"
+ lazy={() => import(
+ /* webpackChunkName: "guessing" */
+ '../pages/GuessingGameControls'
+ )}
+ />
+ </Route>
</Route>
)
);
import Icon from './Icon';
import debounce from '../../helpers/debounce';
-const ChannelSelect = ({ joinable, manageable, onChange, value }) => {
+const ChannelSelect = ({ autoSelect, joinable, manageable, onChange, value }) => {
const [resolved, setResolved] = useState(null);
const [results, setResults] = useState([]);
const [search, setSearch] = useState('');
});
ctrl = null;
setResults(response.data);
+ if (autoSelect && !phrase && response.data.length === 1) {
+ onChange({
+ channel: response.data[0],
+ target: { value: response.data[0].id },
+ });
+ }
} catch (e) {
ctrl = null;
console.error(e);
}
- }, 300), [manageable]);
+ }, 300), [autoSelect, joinable, manageable]);
useEffect(() => {
fetch(search);
};
ChannelSelect.propTypes = {
+ autoSelect: PropTypes.bool,
joinable: PropTypes.bool,
manageable: PropTypes.bool,
onChange: PropTypes.func,
Icon.MICROPHONE = makePreset('MicrophoneIcon', 'microphone');
Icon.MONITOR = makePreset('MonitorIcon', 'tv');
Icon.MOUSE = makePreset('MouseIcon', 'arrow-pointer');
+Icon.OPEN = makePreset('OpenIcon', 'arrow-up-right-from-square');
Icon.PAUSE = makePreset('PauseIcon', 'pause');
Icon.PENDING = makePreset('PendingIcon', 'clock');
Icon.PIN = makePreset('PinIcon', 'location-pin');
import Commands from './Commands';
import GuessingSettingsForm from './GuessingSettingsForm';
import ChannelSelect from '../common/ChannelSelect';
+import Icon from '../common/Icon';
import ToggleSwitch from '../common/ToggleSwitch';
const Controls = () => {
<Form.Label>{t('twitchBot.channel')}</Form.Label>
<Form.Control
as={ChannelSelect}
+ autoSelect
joinable
manageable
onChange={({ channel }) => { setChannel(channel); }}
</div>
</Col>
<Col className="mt-5" md={12}>
- <h3>{t('twitchBot.guessingGame.settings')}</h3>
+ <div className="d-flex align-items-end justify-content-between">
+ <h3>{t('twitchBot.guessingGame.settings')}</h3>
+ <Button
+ onClick={() => {
+ window.open(
+ '/guessing-game/controls',
+ '',
+ 'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
+ );
+ }}
+ title={t('twitchBot.guessingGame.popoutControls')}
+ variant="outline-secondary"
+ >
+ <Icon.OPEN title="" />
+ </Button>
+ </div>
<GuessingSettingsForm
name="gtbk"
onSubmit={saveGuessingGame}
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import {
+ hasActiveGuessing,
+ isAcceptingGuesses,
+} from '../../helpers/Channel';
+
+const GuessingGameControls = ({
+ channel,
+ onCancel,
+ onSolve,
+ onStart,
+ onStop,
+}) => {
+ const { t } = useTranslation();
+
+ const solutions = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
+ 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
+ ];
+
+ return <div>
+ <div className="button-bar mt-3">
+ <Button
+ onClick={onStart}
+ variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
+ >
+ {t('button.start')}
+ </Button>
+ <Button
+ onClick={onStop}
+ variant={
+ hasActiveGuessing(channel) && isAcceptingGuesses(channel)
+ ? 'danger' : 'outline-danger'
+ }
+ >
+ {t('button.stop')}
+ </Button>
+ <Button
+ className="ms-3"
+ onClick={onCancel}
+ variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
+ >
+ {t('button.cancel')}
+ </Button>
+ </div>
+ {hasActiveGuessing(channel) ?
+ <div className="bkgg-buttons d-grid gap-3 my-3">
+ {solutions.map(solution =>
+ <Button
+ key={solution}
+ onClick={() => onSolve(solution)}
+ size="lg"
+ variant="outline-secondary"
+ >
+ {solution}
+ </Button>
+ )}
+ </div>
+ : null}
+ </div>;
+};
+
+GuessingGameControls.propTypes = {
+ channel: PropTypes.shape({
+ }),
+ onCancel: PropTypes.func,
+ onSolve: PropTypes.func,
+ onStart: PropTypes.func,
+ onStop: PropTypes.func,
+};
+
+export default GuessingGameControls;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingGuess = ({ guess }) => {
+ const { t } = useTranslation();
+
+ return <div className="my-2 p-2 border rounded">
+ <Row>
+ <Col xs={6}>
+ <div>{guess.uname}</div>
+ <div className="text-muted">
+ {t('twitchBot.guessingGame.guessTimestamp', {
+ timestamp: new Date(guess.created_at),
+ })}
+ </div>
+ </Col>
+ <Col xs={6}>
+ <div className="fs-3 text-end">{guess.guess}</div>
+ </Col>
+ </Row>
+ </div>;
+};
+
+GuessingGuess.propTypes = {
+ guess: PropTypes.shape({
+ created_at: PropTypes.string,
+ guess: PropTypes.string,
+ uname: PropTypes.string,
+ }),
+};
+
+export default GuessingGuess;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingWinner = ({ winner }) => {
+ const { t } = useTranslation();
+
+ const classNames = ['guessing-game-winner', 'my-2', 'p-2', 'border', 'rounded'];
+ if (!winner.score) {
+ classNames.push('no-points');
+ }
+
+ return <div className={classNames.join(' ')}>
+ <Row>
+ <Col xs={6}>
+ <div>{winner.uname}</div>
+ <div>{t('twitchBot.guessingGame.winnerScore', { score: winner.score })}</div>
+ </Col>
+ <Col xs={6}>
+ <div className="fs-3 text-end">{winner.guess}</div>
+ </Col>
+ </Row>
+ </div>;
+};
+
+GuessingWinner.propTypes = {
+ winner: PropTypes.shape({
+ guess: PropTypes.string,
+ score: PropTypes.number,
+ uname: PropTypes.string,
+ }),
+};
+
+export default GuessingWinner;
--- /dev/null
+export const hasActiveGuessing = channel =>
+ channel && channel.guessing_start;
+
+export const isAcceptingGuesses = channel =>
+ channel && channel.guessing_start && !channel.guessing_end;
+
+export const patchGuess = (guesses, guess) =>
+ [guess, ...(guesses || []).filter(g => g.uid !== guess.uid)];
+
+export const patchWinner = (winners, winner) =>
+ [winner, ...(winners || []).filter(w => w.uid !== winner.uid)];
send: 'Senden',
settings: 'Einstellungen',
signUp: 'Anmelden',
+ start: 'Start',
stop: 'Stop',
unconfirm: 'Zurückziehen',
unset: 'Zurücksetzen',
defaultStartMessage: 'Haut jetzt eure Zahlen raus!',
defaultStopMessage: 'Annahme geschlossen',
defaultWinnersMessage: 'Glückwunsch {names}!',
+ guesses: 'Tipps',
+ guessTimestamp: '{{ timestamp, LT }}',
invalidSolutionMessage: 'Nachricht bei ungültiger (oder fehlender) Lösung',
noWinnersMessage: 'Nachricht, falls keine Gewinner',
notActiveMessage: 'Nachricht, wenn kein Spiel läuft',
pointsCloseOther: 'Punkte für weitere nächste Treffer',
pointsExactFirst: 'Punkte für den ersten exakten Treffer',
pointsExactOther: 'Punkte für weitere exakte Treffer',
+ popoutControls: 'Steuerung öffnen',
settings: 'Guessing Game Einstellungen',
startMessage: 'Nachricht bei Start',
stopMessage: 'Nachricht bei Einsendeschluss',
+ winners: 'Gewinner',
+ winnerScore: '{{ score }} Punkte',
+ winnerScore_one: '{{ score }} Punkt',
winnersMessage: 'Ankündigung der Gewinner',
winnersMessageHint: '{names} wird durch die Namen der Gewinner ersetzt',
},
send: 'Send',
settings: 'Settings',
signUp: 'Sign up',
+ start: 'Start',
stop: 'Stop',
unconfirm: 'Retract',
unset: 'Unset',
defaultStartMessage: 'Get your guesses in',
defaultStopMessage: 'Guessing closed',
defaultWinnersMessage: 'Congrats {names}!',
+ guesses: 'Guesses',
+ guessTimestamp: '{{ timestamp, LT }}',
invalidSolutionMessage: 'Message for invalid (or missing) solution',
noWinnersMessage: 'Announcement for no winners',
notActiveMessage: 'Message when no game is currently active',
pointsCloseOther: 'Points for further close matches',
pointsExactFirst: 'Points for first exact match',
pointsExactOther: 'Points for further exact matches',
+ popoutControls: 'Popout controls',
settings: 'Guessing game settings',
startMessage: 'Starting announcement',
stopMessage: 'Closing announcement',
+ winners: 'Winners',
+ winnerScore: '{{ score }} points',
+ winnerScore_one: '{{ score }} point',
winnersMessage: 'Winners announcement',
winnersMessageHint: '{names} will be replaced with a list of winners\' names',
},
--- /dev/null
+import axios from 'axios';
+import React from 'react';
+import { Alert, Col, Container, Form, Navbar, Row } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import User from '../app/User';
+import ChannelSelect from '../components/common/ChannelSelect';
+import GuessingGameControls from '../components/twitch-bot/GuessingGameControls';
+import GuessingGuess from '../components/twitch-bot/GuessingGuess';
+import GuessingWinner from '../components/twitch-bot/GuessingWinner';
+import { patchGuess, patchWinner } from '../helpers/Channel';
+
+export const Component = () => {
+ const [channel, setChannel] = React.useState(null);
+ const [guesses, setGuesses] = React.useState([]);
+ const [winners, setWinners] = React.useState([]);
+
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ if (!channel) {
+ setGuesses([]);
+ setWinners([]);
+ return;
+ }
+ if (channel.guessing_type) {
+ axios.get(`/api/channels/${channel.id}/guessing-game/${channel.guessing_type}`)
+ .then(res => {
+ res.data.guesses.forEach(g => {
+ setGuesses(gs => patchGuess(gs, g));
+ });
+ res.data.winners.forEach(w => {
+ setWinners(ws => patchGuess(ws, w));
+ });
+ });
+ }
+ window.Echo.private(`Channel.${channel.id}`)
+ .listen('.GuessingGuessCreated', (e) => {
+ setGuesses(gs => patchGuess(gs, e.model));
+ })
+ .listen('.GuessingWinnerCreated', (e) => {
+ setWinners(ws => patchWinner(ws, e.model));
+ })
+ .listen('.ChannelUpdated', (e) => {
+ setChannel(c => ({ ...c, ...e.model }));
+ });
+ return () => {
+ window.Echo.leave(`Channel.${channel.id}`);
+ };
+ }, [channel && channel.id]);
+
+ React.useEffect(() => {
+ const cutoff = channel && channel.guessing_start;
+ if (cutoff) {
+ setGuesses(gs => gs.filter(g => g.created_at >= cutoff));
+ setWinners(ws => ws.filter(w => w.created_at >= cutoff));
+ }
+ }, [channel && channel.guessing_start]);
+
+ const onCancel = React.useCallback(async () => {
+ try {
+ const rsp = await axios.post(
+ `/api/channels/${channel.id}/guessing-game/gtbk`,
+ { action: 'cancel' },
+ );
+ setChannel(rsp.data);
+ } catch (e) {
+ toastr.error(t('twitchBot.controlError'));
+ }
+ }, [channel]);
+
+ const onSolve = React.useCallback(async (solution) => {
+ try {
+ const rsp = await axios.post(
+ `/api/channels/${channel.id}/guessing-game/gtbk`,
+ { action: 'solve', solution },
+ );
+ setChannel(rsp.data);
+ } catch (e) {
+ toastr.error(t('twitchBot.controlError'));
+ }
+ }, [channel]);
+
+ const onStart = React.useCallback(async () => {
+ try {
+ const rsp = await axios.post(
+ `/api/channels/${channel.id}/guessing-game/gtbk`,
+ { action: 'start' },
+ );
+ setChannel(rsp.data);
+ } catch (e) {
+ toastr.error(t('twitchBot.controlError'));
+ }
+ }, [channel]);
+
+ const onStop = React.useCallback(async () => {
+ try {
+ const rsp = await axios.post(
+ `/api/channels/${channel.id}/guessing-game/gtbk`,
+ { action: 'stop' },
+ );
+ setChannel(rsp.data);
+ } catch (e) {
+ toastr.error(t('twitchBot.controlError'));
+ }
+ }, [channel]);
+
+ return <>
+ <Helmet>
+ <title>Guessing Game Controls</title>
+ </Helmet>
+ <Navbar id="header" bg="dark" variant="dark">
+ <Container fluid>
+ <Form.Control
+ as={ChannelSelect}
+ autoSelect
+ joinable
+ manageable
+ onChange={({ channel }) => { setChannel(channel); }}
+ value={channel ? channel.id : ''}
+ />
+ <User />
+ </Container>
+ </Navbar>
+ <Container fluid>
+ {channel ? <Row>
+ <Col md={12} lg={6}>
+ <GuessingGameControls
+ channel={channel}
+ onCancel={onCancel}
+ onSolve={onSolve}
+ onStart={onStart}
+ onStop={onStop}
+ />
+ </Col>
+ <Col md={6} lg={3}>
+ <h3 className="mt-3">{t('twitchBot.guessingGame.winners')}</h3>
+ {winners.map(winner =>
+ <GuessingWinner key={winner.id} winner={winner} />
+ )}
+ </Col>
+ <Col md={6} lg={3}>
+ <h3 className="mt-3">{t('twitchBot.guessingGame.guesses')}</h3>
+ {guesses.map(guess =>
+ <GuessingGuess guess={guess} key={guess.id} />
+ )}
+ </Col>
+ </Row> :
+ <Alert variant="info">
+ {t('twitchBot.selectChannel')}
+ </Alert>
+ }
+ </Container>
+ </>;
+};
// Custom
@import 'common';
+@import 'channels';
@import 'discord';
@import 'doors';
@import 'episodes';
--- /dev/null
+.bkgg-buttons {
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+}
+.guessing-game-winner.no-points {
+ opacity: .6;
+ background: repeating-linear-gradient(150deg, #444, #444 1ex, transparent 1ex, transparent 1em);
+}
Route::post('channels/{channel}/part', 'App\Http\Controllers\ChannelController@part');
Route::delete('channels/{channel}/commands/{command}', 'App\Http\Controllers\ChannelController@deleteCommand');
Route::put('channels/{channel}/commands/{command}', 'App\Http\Controllers\ChannelController@saveCommand');
+Route::get('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@getGuessingGame');
+Route::post('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@controlGuessingGame');
Route::put('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@saveGuessingGame');
Route::get('content', 'App\Http\Controllers\TechniqueController@search');
<?php
+use App\Models\Channel;
use App\Models\Tournament;
use Illuminate\Support\Facades\Broadcast;
return true;
});
+Broadcast::channel('Channel.{id}', function ($user, $id) {
+ $channel = Channel::findOrFail($id);
+ return $user->can('editRestream', $channel);
+});
+
Broadcast::channel('Protocol.{id}', function ($user, $id) {
$tournament = Tournament::findOrFail($id);
return $user->can('viewProtocol', $tournament);