1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2024-12-26 05:42:22 +00:00

Draw dices

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
Emmy D'Anello 2023-03-23 16:17:29 +01:00
parent eb8ad4e771
commit 5399a875c6
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
6 changed files with 418 additions and 30 deletions

View File

@ -1,4 +1,7 @@
import json # Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from random import randint
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
@ -24,14 +27,16 @@ def ensure_orga(f):
class DrawConsumer(AsyncJsonWebsocketConsumer): class DrawConsumer(AsyncJsonWebsocketConsumer):
async def connect(self): async def connect(self):
tournament_id = self.scope['url_route']['kwargs']['tournament_id'] tournament_id = self.scope['url_route']['kwargs']['tournament_id']
self.tournament = await sync_to_async(Tournament.objects.get)(pk=tournament_id) self.tournament = await Tournament.objects.filter(pk=tournament_id)\
.prefetch_related('draw__current_round__current_pool__current_team').aget()
self.participations = await sync_to_async(lambda: list(Participation.objects\ self.participations = []
.filter(tournament=self.tournament, valid=True)\ async for participation in Participation.objects.filter(tournament=self.tournament, valid=True)\
.prefetch_related('team').all()))() .prefetch_related('team'):
self.participations.append(participation)
user = self.scope['user'] user = self.scope['user']
reg = await sync_to_async(Registration.objects.get)(user=user) reg = await Registration.objects.aget(user=user)
self.registration = reg self.registration = reg
if reg.is_volunteer and not reg.is_admin and self.tournament not in reg.interesting_tournaments \ if reg.is_volunteer and not reg.is_admin and self.tournament not in reg.interesting_tournaments \
or not reg.is_volunteer and reg.team.participation.tournament != self.tournament: or not reg.is_volunteer and reg.team.participation.tournament != self.tournament:
@ -41,11 +46,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.accept() await self.accept()
await self.channel_layer.group_add(f"tournament-{self.tournament.id}", self.channel_name) await self.channel_layer.group_add(f"tournament-{self.tournament.id}", self.channel_name)
if not self.registration.is_volunteer:
await self.channel_layer.group_add(f"team-{self.registration.team.trigram}", self.channel_name)
else:
await self.channel_layer.group_add(f"volunteer-{self.tournament.id}", self.channel_name)
async def disconnect(self, close_code): async def disconnect(self, close_code):
await self.channel_layer.group_discard(f"tournament-{self.tournament.id}", self.channel_name) await self.channel_layer.group_discard(f"tournament-{self.tournament.id}", self.channel_name)
if not self.registration.is_volunteer:
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
else:
await self.channel_layer.group_discard(f"volunteer-{self.tournament.id}", self.channel_name)
async def alert(self, message: str, alert_type: str = 'info'): async def alert(self, message: str, alert_type: str = 'info', **kwargs):
return await self.send_json({'type': 'alert', 'alert_type': alert_type, 'message': str(message)}) return await self.send_json({'type': 'alert', 'alert_type': alert_type, 'message': str(message)})
async def receive_json(self, content, **kwargs): async def receive_json(self, content, **kwargs):
@ -54,6 +67,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
match content['type']: match content['type']:
case 'start_draw': case 'start_draw':
await self.start_draw(**content) await self.start_draw(**content)
case 'dice':
await self.process_dice(**content)
@ensure_orga @ensure_orga
async def start_draw(self, fmt, **kwargs): async def start_draw(self, fmt, **kwargs):
@ -70,21 +85,204 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
_("The sum must be equal to the number of teams: expected {len}, got {sum}")\ _("The sum must be equal to the number of teams: expected {len}, got {sum}")\
.format(len=len(self.participations), sum=sum(fmt)), 'danger') .format(len=len(self.participations), sum=sum(fmt)), 'danger')
draw = await sync_to_async(Draw.objects.create)(tournament=self.tournament) draw = await Draw.objects.acreate(tournament=self.tournament)
r1 = None
for i in [1, 2]: for i in [1, 2]:
r = await sync_to_async(Round.objects.create)(draw=draw, number=i) r = await Round.objects.acreate(draw=draw, number=i)
if i == 1:
r1 = r
for j, f in enumerate(fmt): for j, f in enumerate(fmt):
await sync_to_async(Pool.objects.create)(round=r, letter=j + 1, size=f) await Pool.objects.acreate(round=r, letter=j + 1, size=f)
for participation in self.participations: for participation in self.participations:
await sync_to_async(TeamDraw.objects.create)(participation=participation) await TeamDraw.objects.acreate(participation=participation, round=r)
draw.current_round = r1
await sync_to_async(draw.save)()
await self.alert(_("Draw started!"), 'success') await self.alert(_("Draw started!"), 'success')
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.start', 'fmt': fmt, 'draw': draw}) {'type': 'draw.start', 'fmt': fmt, 'draw': draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': draw})
async def draw_start(self, content): async def draw_start(self, content):
await self.alert(_("The draw for the tournament {tournament} will start.")\ await self.alert(_("The draw for the tournament {tournament} will start.")\
.format(tournament=self.tournament.name), 'warning') .format(tournament=self.tournament.name), 'warning')
await self.send_json({'type': 'draw_start', 'fmt': content['fmt'], await self.send_json({'type': 'draw_start', 'fmt': content['fmt'],
'trigrams': [p.team.trigram for p in self.participations]}) 'trigrams': [p.team.trigram for p in self.participations]})
async def process_dice(self, trigram: str | None = None, **kwargs):
if self.registration.is_volunteer:
participation = await Participation.objects.filter(team__trigram=trigram).prefetch_related('team').aget()
else:
participation = await Participation.objects.filter(team__participants=self.registration)\
.prefetch_related('team').aget()
trigram = participation.team.trigram
team_draw = await TeamDraw.objects.filter(participation=participation,
round_id=self.tournament.draw.current_round_id).aget()
state = await sync_to_async(self.tournament.draw.get_state)()
match state:
case 'DICE_SELECT_POULES':
if team_draw.last_dice is not None:
return await self.alert(_("You've already launched the dice."), 'danger')
case 'DICE_ORDER_POULE':
if team_draw.last_dice is not None:
return await self.alert(_("You've already launched the dice."), 'danger')
if not await self.tournament.draw.current_round.current_pool.teamdraw_set\
.filter(participation=participation).aexists():
return await self.alert(_("It is not your turn."), 'danger')
case _:
return await self.alert(_("This is not the time for this."), 'danger')
res = randint(1, 100)
team_draw.last_dice = res
await sync_to_async(team_draw.save)()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'type': 'draw.dice', 'team': trigram, 'result': res})
if state == 'DICE_SELECT_POULES' and \
not await TeamDraw.objects.filter(round_id=self.tournament.draw.current_round_id,
last_dice__isnull=True).aexists():
tds = []
async for td in TeamDraw.objects.filter(round_id=self.tournament.draw.current_round_id)\
.prefetch_related('participation__team'):
tds.append(td)
dices = {td: td.last_dice for td in tds}
values = list(dices.values())
error = False
for v in set(values):
if values.count(v) > 1:
dups = [td for td in tds if td.last_dice == v]
for dup in dups:
dup.last_dice = None
await sync_to_async(dup.save)()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': dup.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'alert_type': 'warning'})
error = True
if error:
return
tds.sort(key=lambda td: td.last_dice)
tds_copy = tds.copy()
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
while (c := await TeamDraw.objects.filter(pool=p).acount()) < p.size:
td = tds_copy.pop(0)
td.pool = p
td.passage_index = c
await sync_to_async(td.save)()
if self.tournament.draw.current_round.number == 2 \
and await self.tournament.draw.current_round.pool_set.acount() >= 2:
# Check that we don't have a same pool as the first day
async for p1 in Pool.objects.filter(round__draw=self.tournament.draw, number=1).all():
async for p2 in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).all():
if set(await p1.teamdraw_set.avalues('id')) \
== set(await p2.teamdraw_set.avalues('id')):
await TeamDraw.objects.filter(round=self.tournament.draw.current_round)\
.aupdate(last_dice=None, pool=None, passage_index=None)
for td in tds:
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': td.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Two pools are identical. Please relaunch your dices.'),
'alert_type': 'warning'})
return
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
self.tournament.draw.current_round.current_pool = pool
await sync_to_async(self.tournament.draw.current_round.save)()
await TeamDraw.objects.filter(round=self.tournament.draw.current_round).aupdate(last_dice=None)
for td in tds:
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': td.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': False})
async for td in pool.teamdraw_set.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
elif state == 'DICE_ORDER_POULE' and \
not await TeamDraw.objects.filter(pool=self.tournament.draw.current_round.current_pool,
last_dice__isnull=True).aexists():
pool = self.tournament.draw.current_round.current_pool
tds = []
async for td in TeamDraw.objects.filter(pool=pool)\
.prefetch_related('participation__team'):
tds.append(td)
dices = {td: td.last_dice for td in tds}
values = list(dices)
error = False
for v in set(values):
if values.count(v) > 1:
dups = [td for td in tds if td.last_dice == v]
for dup in dups:
dup.last_dice = None
await sync_to_async(dup.save)()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': dup.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'alert_type': 'warning'})
error = True
if error:
return
tds.sort(key=lambda x: -x.last_dice)
for i, td in enumerate(tds):
td.choose_index = i
await sync_to_async(td.save)()
pool.current_team = tds[0]
await sync_to_async(pool.save)()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
async def draw_alert(self, content):
return await self.alert(**content)
async def draw_notify(self, content):
await self.send_json({'type': 'notification', 'title': content['title'], 'body': content['body']})
async def draw_set_info(self, content):
await self.send_json({'type': 'set_info', 'information': await content['draw'].ainformation()})
async def draw_dice(self, content):
await self.send_json({'type': 'dice', 'team': content['team'], 'result': content['result']})
async def draw_dice_visibility(self, content):
await self.send_json({'type': 'dice_visibility', 'visible': content['visible']})

View File

@ -0,0 +1,24 @@
# Generated by Django 4.1.7 on 2023-03-22 21:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("draw", "0002_pool_size_alter_pool_letter_alter_round_number_and_more"),
]
operations = [
migrations.AddField(
model_name="teamdraw",
name="round",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="draw.round",
verbose_name="round",
),
preserve_default=False,
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.1.7 on 2023-03-22 23:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("draw", "0003_teamdraw_round"),
]
operations = [
migrations.RemoveField(
model_name="teamdraw",
name="index",
),
migrations.AddField(
model_name="teamdraw",
name="choose_index",
field=models.PositiveSmallIntegerField(
choices=[(1, 1), (2, 2), (3, 3), (4, 4)],
default=None,
null=True,
verbose_name="choose index",
),
),
migrations.AddField(
model_name="teamdraw",
name="passage_index",
field=models.PositiveSmallIntegerField(
choices=[(1, 1), (2, 2), (3, 3), (4, 4)],
default=None,
null=True,
verbose_name="passage index",
),
),
]

View File

@ -1,5 +1,6 @@
# Copyright (C) 2023 by Animath # Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from asgiref.sync import sync_to_async
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils.text import format_lazy from django.utils.text import format_lazy
@ -24,6 +25,52 @@ class Draw(models.Model):
verbose_name=_('current round'), verbose_name=_('current round'),
) )
def get_state(self):
if self.current_round.current_pool is None:
return 'DICE_SELECT_POULES'
elif self.current_round.current_pool.current_team is None:
return 'DICE_ORDER_POULE'
elif self.current_round.current_pool.current_team.purposed is None:
return 'WAITING_DRAW_PROBLEM'
elif self.current_round.current_pool.current_team.accepted is None:
return 'WAITING_CHOOSE_PROBLEM'
else:
return 'DRAW_ENDED'
@property
def information(self):
s = ""
match self.get_state():
case 'DICE_SELECT_POULES':
if self.current_round.number == 1:
s += """Nous allons commencer le tirage des problèmes.<br>
Vous pouvez à tout moment poser toute question si quelque chose
n'est pas clair ou ne va pas.<br><br>
Nous allons d'abord tirer les poules et l'ordre de passage
pour le premier tour avec toutes les équipes puis pour chaque poule,
nous tirerons l'ordre de tirage pour le tour et les problèmes.<br><br>"""
s += """
Les capitaines, vous pouvez désormais toustes lancer un 100,
en cliquant sur le gros bouton. Les poules et l'ordre de passage
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
que le plus petit lancer sera le premier à passer dans la poule A."""
case 'DICE_ORDER_POULE':
s += f"""Nous passons au tirage des problèmes pour la poule
<strong>{self.current_round.current_pool}</strong>, entre les équipes
<strong>{', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
Les capitaines peuvent lancer un 100 en cliquant sur le gros bouton
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
tirer en premier."""
s += """<br><br>Pour plus de détails sur le déroulement du tirage au sort,
le règlement est accessible sur
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
return s
async def ainformation(self):
return await sync_to_async(lambda: self.information)()
class Meta: class Meta:
verbose_name = _('draw') verbose_name = _('draw')
verbose_name_plural = _('draws') verbose_name_plural = _('draws')
@ -89,8 +136,12 @@ class Pool(models.Model):
verbose_name=_('current team'), verbose_name=_('current team'),
) )
@property
def trigrams(self):
return set(td.participation.team.trigram for td in self.teamdraw_set.all())
def __str__(self): def __str__(self):
return f"{self.letter}{self.round}" return f"{self.get_letter_display()}{self.round.number}"
class Meta: class Meta:
verbose_name = _('pool') verbose_name = _('pool')
@ -104,6 +155,12 @@ class TeamDraw(models.Model):
verbose_name=_('participation'), verbose_name=_('participation'),
) )
round = models.ForeignKey(
Round,
on_delete=models.CASCADE,
verbose_name=_('round'),
)
pool = models.ForeignKey( pool = models.ForeignKey(
Pool, Pool,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -112,11 +169,18 @@ class TeamDraw(models.Model):
verbose_name=_('pool'), verbose_name=_('pool'),
) )
index = models.PositiveSmallIntegerField( passage_index = models.PositiveSmallIntegerField(
choices=zip(range(1, 6), range(1, 6)), choices=zip(range(1, 5), range(1, 5)),
null=True, null=True,
default=None, default=None,
verbose_name=_('index'), verbose_name=_('passage index'),
)
choose_index = models.PositiveSmallIntegerField(
choices=zip(range(1, 5), range(1, 5)),
null=True,
default=None,
verbose_name=_('choose index'),
) )
accepted = models.PositiveSmallIntegerField( accepted = models.PositiveSmallIntegerField(
@ -149,6 +213,9 @@ class TeamDraw(models.Model):
verbose_name=_('rejected problems'), verbose_name=_('rejected problems'),
) )
def current(self):
return TeamDraw.objects.get(participation=self.participation, round=self.round.draw.current_round)
class Meta: class Meta:
verbose_name = _('team draw') verbose_name = _('team draw')
verbose_name_plural = _('team draws') verbose_name_plural = _('team draws')

View File

@ -1,8 +1,23 @@
(async () => {
// check notification permission
await Notification.requestPermission()
})()
const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent) const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent)
const sockets = {} const sockets = {}
const messages = document.getElementById('messages') const messages = document.getElementById('messages')
function drawDice(tid, trigram = null) {
sockets[tid].send(JSON.stringify({'type': 'dice', 'trigram': trigram}))
}
function showNotification(title, body, timeout = 5000) {
let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm.svg"})
if (timeout)
setTimeout(() => notif.close(), timeout)
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
if (document.location.hash) { if (document.location.hash) {
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(elem => { document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(elem => {
@ -18,7 +33,8 @@ document.addEventListener('DOMContentLoaded', () => {
for (let tournament of tournaments) { for (let tournament of tournaments) {
let socket = new WebSocket( let socket = new WebSocket(
'ws://' + window.location.host + '/ws/draw/' + tournament.id + '/' (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host
+ '/ws/draw/' + tournament.id + '/'
) )
sockets[tournament.id] = socket sockets[tournament.id] = socket
@ -35,11 +51,37 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => wrapper.remove(), timeout) setTimeout(() => wrapper.remove(), timeout)
} }
function draw_start(data) { function setInfo(info) {
document.getElementById(`messages-${tournament.id}`).innerHTML = info
}
function drawStart() {
document.getElementById(`banner-not-started-${tournament.id}`).classList.add('d-none') document.getElementById(`banner-not-started-${tournament.id}`).classList.add('d-none')
document.getElementById(`draw-content-${tournament.id}`).classList.remove('d-none') document.getElementById(`draw-content-${tournament.id}`).classList.remove('d-none')
} }
function updateDiceInfo(trigram, result) {
let elem = document.getElementById(`dice-${tournament.id}-${trigram}`)
if (result === null) {
elem.classList.remove('text-bg-success')
elem.classList.add('text-bg-warning')
elem.innerText = `${trigram} 🎲 ??`
}
else {
elem.classList.remove('text-bg-warning')
elem.classList.add('text-bg-success')
elem.innerText = `${trigram} 🎲 ${result}`
}
}
function updateDiceVisibility(visible) {
let div = document.getElementById(`launch-dice-${tournament.id}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
socket.addEventListener('message', e => { socket.addEventListener('message', e => {
const data = JSON.parse(e.data) const data = JSON.parse(e.data)
console.log(data) console.log(data)
@ -48,8 +90,20 @@ document.addEventListener('DOMContentLoaded', () => {
case 'alert': case 'alert':
addMessage(data.message, data.alert_type) addMessage(data.message, data.alert_type)
break break
case 'notification':
showNotification(data.title, data.body)
case 'set_info':
setInfo(data.information)
break
case 'draw_start': case 'draw_start':
draw_start(data) drawStart()
break
case 'dice':
updateDiceInfo(data.team, data.result)
break
case 'dice_visibility':
updateDiceVisibility(data.visible)
break
} }
}) })
@ -59,8 +113,9 @@ document.addEventListener('DOMContentLoaded', () => {
socket.addEventListener('open', e => {}) socket.addEventListener('open', e => {})
document.getElementById('format-form-' + tournament.id) let format_form = document.getElementById('format-form-' + tournament.id)
.addEventListener('submit', function (e) { if (format_form !== null) {
format_form.addEventListener('submit', function (e) {
e.preventDefault() e.preventDefault()
socket.send(JSON.stringify({ socket.send(JSON.stringify({
@ -69,4 +124,5 @@ document.addEventListener('DOMContentLoaded', () => {
})) }))
}) })
} }
}
}) })

View File

@ -31,7 +31,13 @@
<div class="row"> <div class="row">
{% for participation in tournament.participations.all %} {% for participation in tournament.participations.all %}
<div class="col-md-1"> <div class="col-md-1">
<div class="badge rounded-pill text-bg-warning">{{ participation.team.trigram }} 🎲 ??</div> <div id="dice-{{ tournament.id }}-{{ participation.team.trigram }}"
class="badge rounded-pill text-bg-{% if participation.teamdraw_set.all.first.current.last_dice %}success{% else %}warning{% endif %}"
{% if request.user.registration.is_volunteer %}
onclick="drawDice({{ tournament.id }}, '{{ participation.team.trigram }}')"
{% endif %}>
{{ participation.team.trigram }} 🎲 {{ participation.teamdraw_set.all.first.current.last_dice|default:'??' }}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -149,12 +155,13 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div id="messages-{{ tournament.id }}" class="alert alert-info"> <div id="messages-{{ tournament.id }}" class="alert alert-info">
Information {{ tournament.draw.information|safe }}
</div> </div>
<div id="launch-dice-{{ tournament.id }}"> <div id="launch-dice-{{ tournament.id }}"
{% if tournament.draw.get_state != 'DICE_SELECT_POULES' %}{% if tournament.draw.get_state != 'DICE_ORDER_POULE' or user.registration.team.trigram not in tournament.draw.current_round.current_pool.trigrams %}class="d-none"{% endif %}{% endif %}>
<div class="text-center"> <div class="text-center">
<button class="btn btn-lg disabled" style="font-size: 100pt"> <button class="btn btn-lg" style="font-size: 100pt" onclick="drawDice({{ tournament.id }})">
🎲 🎲
</button> </button>
</div> </div>