Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 124 additions & 76 deletions goonservers/goonservers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import discord.errors
from redbot.core.bot import Red
from typing import *
from enum import Enum
import socket
import re
import datetime
Expand All @@ -13,6 +14,7 @@
import functools
import json
import aiohttp
from dataclasses import dataclass


class UnknownServerError(Exception):
Expand Down Expand Up @@ -44,6 +46,73 @@ async def channel_broadcast(
]
await asyncio.gather(*tasks)

# Representation of a status query as a dataclass
# Frozen because the status of the server at any given moment is immutable
@dataclass(frozen=True, kw_only=True, match_args=False)
class StatusInfo:
class GameState(int, Enum):
Invalid = 0
PreMapLoad = 1
MapLoad = 2
WorldInit = 3
WorldNew = 4
PreGame = 5
SettingUp = 6
Playing = 7
Finished = 8

class ShuttleLocation(float, Enum):
CentCom = 0
Station = 1
Transit = 1.5
Returned = 2

class ShuttleDirection(int, Enum):
Station = 1
CentCom = -1

server_info: OrderedDict

# The names of the below properties are expected to be 1:1 with the status topic's response

version: str = None
host: Optional[str] = None
respawn: bool = None
enter: bool = None
ai: bool = None

round_id: str = None
gamestate: GameState = GameState.Invalid
mode: Union[Literal["secret"], str] = "secret"
players: int = None
round_duration: int = None

station_name: str = None
map_name: str = None
map_id: str = None

shuttle_online: Optional[bool] = None
shuttle_timer: Optional[float] = None
shuttle_location: Optional[ShuttleLocation] = None
shuttle_direction: Optional[ShuttleDirection] = None

def __post_init__(self):
if self.server_info.get("error") is not None: return

# this pattern has to be done to get around frozen dataclasses
# i'm sorry. i really am.
object.__setattr__(self, 'players', int(self.players))
object.__setattr__(self, 'round_duration', int(self.round_duration))
object.__setattr__(self, 'gamestate', int(self.gamestate))

object.__setattr__(self, 'respawn', bool(self.respawn))
object.__setattr__(self, 'enter', bool(self.enter))
object.__setattr__(self, 'ai', bool(self.ai))
if self.shuttle_timer:
object.__setattr__(self, 'shuttle_online', bool(self.shuttle_online))
object.__setattr__(self, 'shuttle_timer', float(self.shuttle_timer))
object.__setattr__(self, 'shuttle_location', float(self.shuttle_location))
object.__setattr__(self, 'shuttle_direction', int(self.shuttle_direction))

class Server:
def __init__(self, data, cog):
Expand Down Expand Up @@ -196,7 +265,6 @@ async def send_to_server(self, server, message, to_dict=False):
async def send_to_server_safe(
self, server, message, messageable, to_dict=False, react_success=False
):
worldtopic = self.bot.get_cog("WorldTopic")
error_fn = None
if hasattr(messageable, "reply"):
error_fn = messageable.reply
Expand Down Expand Up @@ -241,110 +309,90 @@ def seconds_to_hhmmss(self, input_seconds):
minutes, seconds = divmod(remainder, 60)
return "{:02}:{:02}:{:02}".format(int(hours), int(minutes), int(seconds))

def status_format_elapsed(self, status):
elapsed = (
status.get("elapsed")
or status.get("round_duration")
or status.get("stationtime")
)
if elapsed == "pre":
elapsed = "preround"
elif elapsed == "post":
elapsed = "finished"
elif elapsed is not None:
try:
elapsed = self.seconds_to_hhmmss(int(elapsed))
except ValueError:
pass
return elapsed

async def get_status_info(self, server, worldtopic):
def status_format_eta(self, status: StatusInfo) -> str:
if status.shuttle_location >= StatusInfo.ShuttleLocation.Returned:
return ""
elif status.shuttle_location == StatusInfo.ShuttleLocation.Station:
return "ETD"
elif status.shuttle_location == StatusInfo.ShuttleLocation.Transit:
return "ESC"
elif status.shuttle_direction == StatusInfo.ShuttleDirection.CentCom:
return "RCL"
else:
return "ETA"


async def get_status_info(self, server, worldtopic) -> StatusInfo:
result = OrderedDict()
result["full_name"] = server.full_name
result["url"] = server.url
result["type"] = server.type
result["error"] = None
response = None
try:
response = await worldtopic.send((server.host, server.port), "status")
except (asyncio.exceptions.TimeoutError, TimeoutError) as e:
except (asyncio.exceptions.TimeoutError, TimeoutError):
result["error"] = "Server not responding."
return result
except (socket.gaierror, ConnectionRefusedError) as e:
except (socket.gaierror, ConnectionRefusedError):
result["error"] = "Unable to connect."
return result
except ConnectionResetError:
result["error"] = "Connection reset by server (possibly just restarted)."
return result
if response is None:
result["error"] = "Invalid server response."
return result
status = worldtopic.params_to_dict(response)
if len(response) < 20 or ("players" in status and len(status["players"]) > 5):
response = await worldtopic.send((server.host, server.port), "status&format=json")
status = json.loads(response)
result["station_name"] = status.get("station_name")
try:
result["players"] = int(status["players"]) if "players" in status else None
except ValueError:
result["players"] = None
result["map"] = status.get("map_name")
result["mode"] = status.get("mode")
result["time"] = self.status_format_elapsed(status)
result["shuttle"] = None
result["shuttle_eta"] = None
if "shuttle_time" in status and status["shuttle_time"] != "welp":
shuttle_time = int(status["shuttle_time"])
if shuttle_time != 360:
eta = "ETA" if shuttle_time >= 0 else "ETD"
shuttle_time = abs(shuttle_time)
result["shuttle"] = self.seconds_to_hhmmss(shuttle_time)
result["shuttle_eta"] = eta
return result

def status_result_parts(self, status_info):
if result["error"] is not None:
return StatusInfo(server_info = result)

status = StatusInfo(**worldtopic.params_to_dict(response), server_info = result)
return status

def status_result_parts(self, status_info: StatusInfo):
result_parts = []
if status_info["station_name"]:
result_parts.append(status_info["station_name"])
if status_info["players"] is not None:
if status_info.station_name:
result_parts.append(status_info.station_name)
if status_info.players is not None:
result_parts.append(
f"{status_info['players']} player"
+ ("s" if status_info["players"] != 1 else "")
f"{status_info.players} player"
+ ("s" if status_info.players != 1 else "")
)
if status_info["map"]:
result_parts.append(f"map: {status_info['map']}")
if status_info["mode"] and status_info["mode"] != "secret":
result_parts.append(f"mode: {status_info['mode']}")
if status_info["time"]:
result_parts.append(f"time: {status_info['time']}")
if status_info["shuttle_eta"]:
if status_info.map_name:
result_parts.append(f"map: {status_info.map_name}")
if status_info.mode not in (None, "secret"):
result_parts.append(f"mode: {status_info.mode}")

time_part = f"time: {self.seconds_to_hhmmss(status_info.round_duration)}"
if status_info.gamestate <= StatusInfo.GameState.PreGame: time_part += " (preround)"
elif status_info.gamestate == StatusInfo.GameState.Finished: time_part += " (finished)"
result_parts.append(time_part)

if status_info.shuttle_online and status_info.shuttle_location not in (None, StatusInfo.ShuttleLocation.Returned):
result_parts.append(
f"shuttle {status_info['shuttle_eta']}: {status_info['shuttle']}"
f"shuttle {self.status_format_eta(status_info)}: {self.seconds_to_hhmmss(status_info.shuttle_timer)}"
)
return result_parts

def generate_status_text(self, status_info, embed_url=False):
result = status_info["full_name"]
if embed_url and status_info["url"]:
result = f"[{result}]({status_info['url']})"
def generate_status_text(self, status_info: StatusInfo, embed_url = False):
result = status_info.server_info["full_name"]
if embed_url and status_info.server_info["url"]:
result = f"[{result}]({status_info.server_info['url']})"
result = f"**{result}** "
if status_info["error"]:
return result + status_info["error"]
if status_info.server_info["error"]:
return result + status_info.server_info["error"]
result += " | ".join(self.status_result_parts(status_info))
if not embed_url and status_info["url"]:
result += " " + status_info["url"]
if not embed_url and status_info.server_info["url"]:
result += " " + status_info.server_info["url"]
return result

def generate_status_embed(self, status_info, embed=None):
def generate_status_embed(self, status_info: StatusInfo, embed=None):
if embed is None:
embed = discord.Embed()
embed.title = status_info["full_name"]
if status_info["url"]:
embed.url = status_info["url"]
if status_info["error"]:
embed.description = status_info["error"]
embed.title = status_info.server_info["full_name"]
embed.url = status_info.server_info["url"]
if status_info.server_info["error"]:
embed.description = status_info.server_info["error"]
embed.colour = self.COLOR_ERROR
return embed
if status_info["type"] == "goon":
if status_info.server_info["type"] == "goon":
embed.colour = self.COLOR_GOON
else:
embed.colour = self.COLOR_OTHER
Expand Down
26 changes: 7 additions & 19 deletions spacebeecommands/spacebeecommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def locate(self, ctx: commands.Context, *, who: str):
goonservers = self.bot.get_cog("GoonServers")
servers = [s for s in goonservers.servers if s.type == "goon"]
futures = [
asyncio.Task(goonservers.send_to_server(s, "status", to_dict=True))
asyncio.Task(goonservers.send_to_server(s, "who", to_dict=True))
for s in servers
]
message = None
Expand Down Expand Up @@ -162,18 +162,12 @@ async def whois(self, ctx: commands.Context, server_id: str, *, query: str):
async def players(self, ctx: commands.Context, server_id: str):
"""Lists players on a given Goonstation server."""
goonservers = self.bot.get_cog("GoonServers")
response = await goonservers.send_to_server_safe(
server_id, "status", ctx.message, to_dict=True
response: dict = await goonservers.send_to_server_safe(
server_id, "who", ctx.message, to_dict=True
)
if response is None:
return
players = []
try:
for i in range(int(response["players"])):
players.append(response[f"player{i}"])
except KeyError:
await ctx.message.reply("That server is not responding correctly.")
return
players = list(response.values())
players.sort()
if players:
await ctx.message.reply(", ".join(players))
Expand All @@ -187,18 +181,12 @@ async def playermentions(self, ctx: commands.Context, server_id: str):
goonservers = self.bot.get_cog("GoonServers")
spacebeecentcom = self.bot.get_cog("SpacebeeCentcom")
nightshadewhitelist = self.bot.get_cog("NightshadeWhitelist")
response = await goonservers.send_to_server_safe(
server_id, "status", ctx.message, to_dict=True
response: dict = await goonservers.send_to_server_safe(
server_id, "who", ctx.message, to_dict=True
)
if response is None:
return
players = []
try:
for i in range(int(response["players"])):
players.append(response[f"player{i}"])
except KeyError:
await ctx.message.reply("That server is not responding correctly.")
return
players = list(response.values())
players.sort()
if not players:
await ctx.message.reply("No players.")
Expand Down
1 change: 1 addition & 0 deletions worldtopic/worldtopic.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ async def _send(self, addr_port: Tuple[str, int], msg: str) -> str:
def params_to_dict(self, params: str):
result = OrderedDict()
for pair in params.split("&"):
if not pair: continue # pair == ""
key, *rest = pair.split("=")
value = urllib.parse.unquote_plus(rest[0]) if rest else None
result[key] = value
Expand Down