Skip to content

Commit

Permalink
Added Trackmania rankings app to contrib. (#1157)
Browse files Browse the repository at this point in the history
* Added Trackmania rankings app to contrib.

* Added /list worstrank & /list bestrank to the rankings app.

* Implemented pull request feedback.

* Implemented pull request feedback (2).

* Rankings: raise error if running on PostgreSQL.
  • Loading branch information
TheMaximum committed Apr 13, 2022
1 parent 2f36bb8 commit 3b0f628
Show file tree
Hide file tree
Showing 7 changed files with 471 additions and 4 deletions.
11 changes: 11 additions & 0 deletions pyplanet/apps/contrib/jukebox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def __init__(self, *args, **kwargs):
async def on_start(self):
# Register permissions + commands.
await self.instance.permission_manager.register('clear', 'Clear the jukebox', app=self, min_level=1)

if 'rankings' in self.instance.apps.apps:
await self.instance.command_manager.register(
Command(command='norank', namespace='list', target=self.instance.apps.apps['rankings'].chat_norank,
description='Displays all maps where you have no ranking local record.'),
Command(command='bestrank', namespace='list', target=self.instance.apps.apps['rankings'].chat_bestrank,
description='Displays all maps where you have ranking local record, ordered by best rank.'),
Command(command='worstrank', namespace='list', target=self.instance.apps.apps['rankings'].chat_worstrank,
description='Displays all maps where you have ranking local record, ordered by worst rank.'),
)

await self.instance.command_manager.register(
Command(command='clearjukebox', aliases=['cjb'], target=self.clear_jukebox, perms='jukebox:clear', admin=True,
description='Clears the current maps from the jukebox.'),
Expand Down
15 changes: 11 additions & 4 deletions pyplanet/apps/contrib/players/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,20 @@ async def player_connect(self, player, **kwargs):
return

if player.flow.is_spectator:
message = '$ff0{} $fff{}$z$s$ff0 joined the server as spectator! {}'
message = '$ff0{} $fff{}$z$s$ff0 joined as spectator{}'
else:
message = '$ff0{} $fff{}$z$s$ff0 joined the server! {}'
message = '$ff0{} $fff{}$z$s$ff0 joined{}'

additional_information = ' from $fff{}$ff0!'.format(player.flow.zone.country) if player.flow.zone else '! '
if 'rankings' in self.instance.apps.apps:
player_rank = await self.instance.apps.apps['rankings'].get_player_rank(player)
additional_information += ' $ff0[Rank: $fff{}$ff0/$fff{} $ff0(average: $fff{}$ff0)]'.format(
player_rank['rank'], player_rank['total_ranked_players'], player_rank['average']
)

await self.instance.chat(
message.format(
player.get_level_string(), player.nickname,
'Nation: $fff{}'.format(player.flow.zone.country) if player.flow.zone else ''
player.get_level_string(), player.nickname, additional_information
)
)

Expand Down
244 changes: 244 additions & 0 deletions pyplanet/apps/contrib/rankings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import logging
import math
from peewee import RawQuery

from pyplanet.apps.contrib.rankings.models.ranked_map import RankedMap
from pyplanet.apps.contrib.rankings.models import Rank
from pyplanet.apps.contrib.rankings.views import TopRanksView, MapListView
from pyplanet.apps.config import AppConfig
from pyplanet.apps.core.maniaplanet.models import Player
from pyplanet.apps.core.maniaplanet import callbacks as mp_signals
from pyplanet.contrib.command import Command
from pyplanet.contrib.setting import Setting

logger = logging.getLogger(__name__)


class Rankings(AppConfig):
# Rankings can only be calculated on Trackmania games.
game_dependencies = ['trackmania', 'trackmania_next']

# Rankings depend on the local records.
app_dependencies = ['core.maniaplanet', 'core.trackmania', 'local_records']

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.setting_records_required = Setting(
'minimum_records_required', 'Minimum records to acquire ranking', Setting.CAT_BEHAVIOUR, type=int,
description='Minimum of records required to acquire a rank (minimum 3 records).',
default=5
)

self.setting_chat_announce = Setting(
'rank_chat_announce', 'Display server ranks on map start', Setting.CAT_BEHAVIOUR, type=bool,
description='Whether to display the server rank on every map start.',
default=True
)

self.setting_topranks_limit = Setting(
'topranks_limit', 'Maximum rank to display in topranks', Setting.CAT_BEHAVIOUR, type=int,
description='Amount of ranks to display in the topranks view.',
default=100
)

async def on_start(self):
if self.instance.db.engine.__class__.__name__.lower().find('postgresql') != -1:
raise NotImplementedError("Rankings app only works on PyPlanet instances running on MySQL.")

# Listen to signals.
self.context.signals.listen(mp_signals.map.map_end, self.map_end)
self.context.signals.listen(mp_signals.player.player_connect, self.player_connect)

# Register commands.
await self.instance.command_manager.register(
Command('rank', target=self.chat_rank, description='Displays your current server rank.'),
Command('nextrank', target=self.chat_nextrank, description='Displays the player ahead of you in the server ranking.'),
Command('topranks', target=self.chat_topranks, description='Displays a list of top ranked players.'),
)

# Register settings
await self.context.setting.register(self.setting_records_required, self.setting_chat_announce, self.setting_topranks_limit)

async def map_end(self, map):
# Calculate server ranks.
await self.calculate_server_ranks()

# Display the server rank for all players on the server after calculation, if enabled.
if await self.setting_chat_announce.get_value():
for player in self.instance.player_manager.online:
await self.chat_rank(player)

async def player_connect(self, player, is_spectator, source, signal):
if await self.setting_chat_announce.get_value():
await self.chat_rank(player)

async def calculate_server_ranks(self):
maps_on_server = [map_on_server.id for map_on_server in self.instance.map_manager.maps]

minimum_records_required_setting = await self.setting_records_required.get_value()
minimum_records_required = minimum_records_required_setting if minimum_records_required_setting >= 3 else 3

maximum_record_rank = await self.get_maximum_record_rank()

query = RawQuery(Rank, """
-- Reset the current ranks to insert new ones later one.
TRUNCATE TABLE rankings_rank;
-- Limit on maximum ranked records.
SET @ranked_record_limit = {};
-- Minimum amount of ranked records required to acquire a rank.
SET @minimum_ranked_records = {};
-- Total amount of maps active on the server.
SET @active_map_count = {};
-- Set the rank/current rank variables to ensure correct first calculation
SET @player_rank = 0;
SET @current_rank = 0;
INSERT INTO rankings_rank (player_id, average, calculated_at)
SELECT
player_id, average, calculated_at
FROM (
SELECT
player_id,
-- Calculation: the sum of the record ranks is combined with the ranked record limit times the amount of unranked maps.
-- Divide this summed ranking by the amount of active maps on the server, and an average calculated rank will be returned.
ROUND((SUM(player_rank) + (@active_map_count - COUNT(player_rank)) * @ranked_record_limit) / @active_map_count * 10000, 0) AS average,
NOW() AS calculated_at,
COUNT(player_rank) AS ranked_records_count
FROM
(
SELECT
id,
map_id,
player_id,
score,
@player_rank := IF(@current_rank = map_id, @player_rank + 1, 1) AS player_rank,
@current_rank := map_id
FROM localrecord
WHERE map_id IN ({})
ORDER BY map_id, score ASC
) AS ranked_records
WHERE player_rank <= @ranked_record_limit
GROUP BY player_id
) grouped_ranks
WHERE ranked_records_count >= @minimum_ranked_records
""".format(maximum_record_rank, minimum_records_required, str(len(maps_on_server)), ", ".join(str(map_id) for map_id in maps_on_server)))

await Rank.execute(query)

async def chat_topranks(self, player, *args, **kwargs):
top_ranks_limit = await self.setting_topranks_limit.get_value()
top_ranks = await Rank.execute(Rank.select(Rank, Player).join(Player).order_by(Rank.average.asc()).limit(top_ranks_limit))
view = TopRanksView(self, player, top_ranks)
await view.display(player)

async def chat_rank(self, player, *args, **kwargs):
player_rank = await self.get_player_rank(player)
if player_rank is None:
await self.instance.chat('$f00$iYou do not have a server rank yet!', player)
return

await self.instance.chat('$f80Your server rank is $fff{}$f80 of $fff{}$f80, average: $fff{}$f80'.format(
player_rank['rank'], player_rank['total_ranked_players'], player_rank['average']), player)

async def chat_nextrank(self, player, *args, **kwargs):
player_ranks = await Rank.execute(Rank.select().where(Rank.player == player.get_id()))

if len(player_ranks) == 0:
await self.instance.chat('$f00$iYou do not have a server rank yet!', player)
return

player_rank = player_ranks[0]
next_ranked_players = await Rank.execute(
Rank.select(Rank, Player)
.join(Player)
.where(Rank.average < player_rank.average)
.order_by(Rank.average.desc())
.limit(1))

if len(next_ranked_players) == 0:
await self.instance.chat('$f00$iThere is no better ranked player than you!', player)
return

next_ranked = next_ranked_players[0]
next_player_rank_average = '{:0.2f}'.format((next_ranked.average / 10000))
next_player_rank_index = (await Rank.objects.count(Rank.select(Rank).where(Rank.average < next_ranked.average)) + 1)
next_player_rank_difference = math.ceil((player_rank.average - next_ranked.average) / 10000 * len(self.instance.map_manager.maps))

await self.instance.chat('$f80The next ranked player is $<$fff{}$>$f80 ($fff{}$f80), average: $fff{}$f80 [$fff-{} $f80RP]'.format(
next_ranked.player.nickname, next_player_rank_index, next_player_rank_average, next_player_rank_difference), player)

async def chat_norank(self, player, *args, **kwargs):
ranked_maps = await self.get_player_map_ranks(player)
non_ranked_maps = [map for map in self.instance.map_manager.maps if
map.id not in [ranked_map.id for ranked_map in ranked_maps]]

view = MapListView(self, player, maps=non_ranked_maps, title='Your non-ranked maps on this server', show_rank=False)
await view.display(player)

async def chat_bestrank(self, player, *args, **kwargs):
ranked_maps = await self.get_player_map_ranks(player, 'ORDER BY player_rank ASC')
view = MapListView(self, player, maps=ranked_maps, title='Your best ranked maps on this server', show_rank=True)
await view.display(player)

async def chat_worstrank(self, player, *args, **kwargs):
ranked_maps = await self.get_player_map_ranks(player, 'ORDER BY player_rank DESC')
view = MapListView(self, player, maps=ranked_maps, title='Your worst ranked maps on this server', show_rank=True)
await view.display(player)

async def get_player_map_ranks(self, player, sort_query = ''):
maximum_record_rank = await self.get_maximum_record_rank()

query = '''SELECT
map.id,
map.name,
map.uid,
map.author_login,
ranked_records.player_rank
FROM
(
SELECT
id,
map_id,
player_id,
score,
@player_rank := IF(@current_rank = map_id, @player_rank + 1, 1) AS player_rank,
@current_rank := map_id
FROM localrecord r,
(SELECT @player_rank := 0) pr,
(SELECT @current_rank := 0) cr
ORDER BY map_id, score ASC
) AS ranked_records
INNER JOIN map
ON map.id = map_id
WHERE player_rank <= {}
AND player_id = {} {}'''.format(maximum_record_rank, player.id, sort_query)

select_query = RawQuery(RankedMap, query)
ranked_maps = [map for map in await RankedMap.execute(select_query) if
map.id in [server_map.id for server_map in self.instance.map_manager.maps]]

return ranked_maps

async def get_player_rank(self, player):
player_ranks = await Rank.execute(Rank.select().where(Rank.player == player.get_id()))

if len(player_ranks) == 0:
return None

player_rank = player_ranks[0]
player_rank_average = '{:0.2f}'.format((player_rank.average / 10000))
player_rank_index = (await Rank.objects.count(Rank.select(Rank).where(Rank.average < player_rank.average)) + 1)
total_ranked_players = await Rank.objects.count(Rank.select(Rank))

return {'rank': player_rank_index, 'average': player_rank_average, 'total_ranked_players': total_ranked_players}

async def get_maximum_record_rank(self):
# Determine the maximum record rank that is included in the locals.
# The rank calculation requires a non-zero value, so if none is provided it'll default to 1000.
maximum_record_rank = await self.instance.apps.apps['local_records'].setting_record_limit.get_value()
if maximum_record_rank == 0:
maximum_record_rank = 1000

return maximum_record_rank
5 changes: 5 additions & 0 deletions pyplanet/apps/contrib/rankings/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .rank import Rank

__all__ = [
'Rank',
]
26 changes: 26 additions & 0 deletions pyplanet/apps/contrib/rankings/models/rank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import datetime
from peewee import *
from pyplanet.apps.core.maniaplanet.models import Player
from pyplanet.core.db import Model


class Rank(Model):
player = ForeignKeyField(Player, index=True)
"""
Player that has the rank.
"""

average = IntegerField()
"""
Average map ranking.
"""

calculated_at = DateTimeField(
default=datetime.datetime.now,
)
"""
When was the rank calculated?
"""

class Meta:
db_table = 'rankings_rank'
29 changes: 29 additions & 0 deletions pyplanet/apps/contrib/rankings/models/ranked_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from peewee import *
from pyplanet.core.db import Model


class RankedMap(Model):
id = IntegerField()
"""
ID of the map on which the player has the rank.
"""

name = CharField(max_length=150)
"""
Name of the map on which the player has the rank.
"""

uid = CharField(max_length=50)
"""
UID of the map on which the player has the rank.
"""

author_login = CharField(max_length=100)
"""
Author login of the map on which the player has the rank.
"""

player_rank = IntegerField()
"""
Rank the player has on the map.
"""

0 comments on commit 3b0f628

Please sign in to comment.