Skip to content

Commit

Permalink
add API endpoints for scoreboard_v2
Browse files Browse the repository at this point in the history
  • Loading branch information
nename0 authored and rudis committed Sep 14, 2023
1 parent 4f1d9e9 commit 8887b07
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 0 deletions.
154 changes: 154 additions & 0 deletions src/ctf_gameserver/web/scoreboard_v2/calculations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from collections import defaultdict, OrderedDict

from django.core.cache import cache

from django.db.models import F, Max

from . import models


def scores(tick):
"""
Returns the scores as currently stored in the database as an OrderedDict
and the attackers/victims per service in this format:
{team_id: {
'services': {
service_id: {
offense_score, offense_delta,
defense_score, defense_delta,
sla_score, sla_delta,
flags_captured, flags_captured_delta,
flags_lost, flags_lost_delta
}}
'total': {
total_score,
offense_score, offense_delta,
defense_score, defense_delta,
sla_score, sla_delta
}
}},
{service_id: {
attackers,
victims
}}
The scores are sorted by the total_score.
"""

team_scores = defaultdict(lambda: {
'services': defaultdict(lambda: {
'offense_score': 0, 'offense_delta': 0,
'defense_score': 0, 'defense_delta': 0,
'sla_score': 0, 'sla_delta': 0,
'flags_captured': 0, 'flags_captured_delta': 0,
'flags_lost': 0, 'flags_lost_delta': 0,
}),
'total': {
'total_score': 0,
'offense_score': 0, 'offense_delta': 0,
'defense_score': 0, 'defense_delta': 0,
'sla_score': 0, 'sla_delta': 0,
}
})

for score in models.Board.objects.filter(tick=tick).all():
srv = team_scores[score.team_id]['services'][score.service_id]
srv['offense_score'] = srv['offense_delta'] = score.attack
srv['defense_score'] = srv['defense_delta'] = score.defense
srv['sla_score'] = srv['sla_delta'] = score.sla
srv['flags_captured'] = srv['flags_captured_delta'] = score.flags_captured
srv['flags_lost'] = srv['flags_lost_delta'] = score.flags_lost
total = team_scores[score.team_id]['total']
total['offense_score'] += score.attack
total['defense_score'] += score.defense
total['sla_score'] += score.sla
total['total_score'] += score.attack + score.defense + score.sla

# calculate the difference to the previous tick (if any)
for score in models.Board.objects.filter(tick=tick - 1).all():
srv = team_scores[score.team_id]['services'][score.service_id]
srv['offense_delta'] -= score.attack
srv['defense_delta'] -= score.defense
srv['sla_delta'] -= score.sla
srv['flags_captured_delta'] -= score.flags_captured
srv['flags_lost_delta'] -= score.flags_lost
total = team_scores[score.team_id]['total']
total['offense_delta'] += srv['offense_delta']
total['defense_delta'] += srv['defense_delta']
total['sla_delta'] += srv['sla_delta']

attackers_victims = defaultdict(lambda: {'attackers': 0, 'victims': 0})
for team in team_scores.values():
for service_id, service in team['services'].items():
attackers_victims[service_id]['attackers'] += int(service['flags_captured_delta'] > 0)
attackers_victims[service_id]['victims'] += int(service['flags_lost_delta'] > 0)

sorted_team_scores = OrderedDict(sorted(team_scores.items(),
key=lambda kv: kv[1]['total']['total_score'], reverse=True))

return sorted_team_scores, attackers_victims

def get_scoreboard_tick():
"""
Get the maximum tick to display on the scoreboard. Usually equal current_tick - 1
"""
# max tick of scoreboard
scoreboard_tick = models.Board.objects.aggregate(max_tick=Max('tick'))['max_tick']
if scoreboard_tick is None:
# game has not started: current_tick < 0
# return -1 so scoreboard already shows services when they are public
return -1
return scoreboard_tick

def get_firstbloods(scoreboard_tick):
"""
Get the first bloods for each service (if any).
"""

# cache based on scoreboard_tick which invalidates the cache
# when update_scoring() ran in the controller
cache_key = 'scoreboard_v2_firstbloods_{:d}'.format(scoreboard_tick)
cached_firstbloods = cache.get(cache_key)

if cached_firstbloods is not None:
return cached_firstbloods

firstbloods = models.FirstBloods.objects.only('service_id', 'team_id', 'tick').all()

cache.set(cache_key, firstbloods, 90)

return firstbloods

def per_team_scores(team_id, service_ids_order):
"""
Get the point development of a team during all past ticks.
Returns an array of arrays.
The first index is the service in the order given by "service_ids_order".
The second index is the tick.
So result[0][0] is the score from service with id service_ids_order[0] and tick 0.
"""
scoreboard_tick = get_scoreboard_tick()

# cache based on team_id and scoreboard_tick which invalidates the cache
# when update_scoring() ran in the controller
cache_key = 'scoreboard_v2_team_scores_{:d}_{:d}'.format(team_id, scoreboard_tick)
cached_team_scores = cache.get(cache_key)

if cached_team_scores is not None:
return cached_team_scores

team_total_scores = models.Board.objects \
.annotate(points=F('attack')+F('defense')+F('sla')) \
.filter(team_id = team_id, tick__lte = scoreboard_tick) \
.order_by('tick') \
.values('service_id', 'points')

result = list([] for _ in service_ids_order)

for total_score in team_total_scores:
result[service_ids_order.index(total_score['service_id'])].append(total_score['points'])

cache.set(cache_key, result, 90)

return result
168 changes: 168 additions & 0 deletions src/ctf_gameserver/web/scoreboard_v2/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import datetime

from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.views.decorators.cache import cache_page

import ctf_gameserver.web.registration.models as registration_models

from ctf_gameserver.web.scoring.views import _get_status_descriptions
from ctf_gameserver.web.scoring.decorators import registration_closed_required, services_public_required
from ctf_gameserver.web.scoring import models as scoring_models
from ctf_gameserver.web.scoring import calculations as scoring_calculations

from . import calculations

# old scoreboard currently uses [] so database defined order
SCOREBOARD_SERVICE_ORDER = ['name']

# data per tick does not change so can use longer caching
@cache_page(90)
@services_public_required('json')
def round_json(_, tick=-1):
tick = int(tick)

scoreboard_tick = calculations.get_scoreboard_tick()

if tick > scoreboard_tick or tick < -1:
raise PermissionDenied()

scores, attackers_victims = calculations.scores(tick)
statuses = scoring_calculations.team_statuses(tick - 3, tick, only_team_fields=['user_id'])
# convert team-keys to team_id-keys
statuses = {team.user_id: v for team, v in statuses.items()}

services = scoring_models.Service.objects.order_by(*SCOREBOARD_SERVICE_ORDER).only('name').all()
service_ids = []
services_json = []
for service in services:
service_ids.append(service.id)
services_json.append({
"name": service.name,
"first_blood": [], # this is an array for multiple flag stores
"attackers": attackers_victims[service.id]["attackers"],
"victims": attackers_victims[service.id]["victims"],
})

firstbloods = calculations.get_firstbloods(scoreboard_tick)
for firstblood in firstbloods:
if firstblood.tick <= tick:
service_idx = service_ids.index(firstblood.service_id)
services_json[service_idx]["first_blood"] = [firstblood.team_id]

response = {
'tick': tick,
'scoreboard': [],
'status-descriptions': _get_status_descriptions(),
'services': services_json
}

for rank, (team_id, points) in enumerate(scores.items(), start=1):
team_entry = {
'rank': rank,
'team_id': team_id,
'services': [],
'points': points['total']['total_score'],
'o': points['total']['offense_score'],
'do': points['total']['offense_delta'],
'd': points['total']['defense_score'],
'dd': points['total']['defense_delta'],
's': points['total']['sla_score'],
'ds': points['total']['sla_delta'],
}

for service in services:
service_statuses = []
for status_tick in range(tick - 3, tick + 1):
try:
service_statuses.insert(0, statuses[team_id][status_tick][service.id])
except KeyError:
service_statuses.insert(0, -1)

service_points = points['services'][service.id]
team_entry['services'].append({
'c': service_statuses[0],
'dc': service_statuses[1:4],
'o': service_points['offense_score'],
'do': service_points['offense_delta'],
'd': service_points['defense_score'],
'dd': service_points['defense_delta'],
's': service_points['sla_score'],
'ds': service_points['sla_delta'],
'cap': service_points['flags_captured'],
'dcap': service_points['flags_captured_delta'],
'st': service_points['flags_lost'],
'dst': service_points['flags_lost_delta'],
})

response['scoreboard'].append(team_entry)

return JsonResponse(response)

# Short cache timeout only, because there is already caching going on in calculations
@cache_page(5)
@services_public_required('json')
def per_team_json(_, team=-1):
team = int(team)

# get service ids in scoreboard order
service_ids = list(scoring_models.Service.objects \
.order_by(*SCOREBOARD_SERVICE_ORDER) \
.values_list('id', flat=True))

team_scores = calculations.per_team_scores(team, service_ids)

response = {
'points': team_scores
}

return JsonResponse(response)

# every scoreboard UI will query this every 2-10 sec so better cache this
# but don't cache it too long to avoid long wait times after tick increment
# it's not expensive anyway (two single row queries)
@cache_page(2)
@registration_closed_required
def current_json(_):
game_control = scoring_models.GameControl.get_instance()
current_tick = game_control.current_tick

scoreboard_tick = calculations.get_scoreboard_tick()

next_tick_start_offset = (current_tick + 1) * game_control.tick_duration
current_tick_until = game_control.start + datetime.timedelta(seconds=next_tick_start_offset)
unix_epoch = datetime.datetime(1970,1,1,tzinfo=datetime.timezone.utc)
current_tick_until_unix = (current_tick_until-unix_epoch).total_seconds()

state = int(not game_control.competition_started() or game_control.competition_over())

result = {
"state": state,
"current_tick": current_tick,
"current_tick_until": current_tick_until_unix,
"scoreboard_tick": scoreboard_tick
}
return JsonResponse(result, json_dumps_params={'indent': 2})

@cache_page(60)
# This is essentially just a registered teams list so could be made public even earlier
@registration_closed_required
def teams_json(_):

teams = registration_models.Team.active_not_nop_objects \
.select_related('user') \
.only('user__username', 'affiliation', 'country', 'image') \
.order_by('user_id') \
.all()

result = {}
for team in teams:
team_json = {
"name": team.user.username,
"aff": team.affiliation,
"country": team.country,
"logo": None if not team.image else team.image.get_thumbnail_url()
}
result[team.user_id] = team_json

return JsonResponse(result, json_dumps_params={'indent': 2})
18 changes: 18 additions & 0 deletions src/ctf_gameserver/web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .registration import views as registration_views
from .scoring import views as scoring_views
from .scoreboard_v2 import views as scoreboard_v2_views
from .flatpages import views as flatpages_views
from .vpnstatus import views as vpnstatus_views
from .admin import admin_site
Expand Down Expand Up @@ -107,6 +108,23 @@
name='get_team_download'
),

url(r'^competition/scoreboard-v2/scoreboard_round_(?P<tick>-?\d+)\.json$',
scoreboard_v2_views.round_json,
name='scoreboard_v2_round_json'
),
url(r'^competition/scoreboard-v2/scoreboard_team_(?P<team>\d+)\.json$',
scoreboard_v2_views.per_team_json,
name='scoreboard_v2_team_json'
),
url(r'^competition/scoreboard-v2/scoreboard_current\.json$',
scoreboard_v2_views.current_json,
name='scoreboard_v2_current_json'
),
url(r'^competition/scoreboard-v2/scoreboard_teams\.json$',
scoreboard_v2_views.teams_json,
name='scoreboard_v2_teams_json'
),

url(r'^internal/mail-teams/$',
registration_views.mail_teams,
name='mail_teams'
Expand Down

0 comments on commit 8887b07

Please sign in to comment.