Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue/#482 improve documentation #753

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[scripts]
devserver = "python server.py --configuration-file dev-config.yml"
tests = "py.test --cov-report term-missing --cov=server --mysql_database=faf -o testpaths=tests -m 'not rabbitmq'"
tests = "py.test --doctest-modules --doctest-continue-on-failure --cov-report term-missing --cov=server --mysql_database=faf -o testpaths=tests -m 'not rabbitmq'"
integration = "py.test -o testpaths=integration_tests"
vulture = "vulture server.py server/ --sort-by-size"
doc = "pdoc3 --html --force server"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# FA Forever - Server
![Build Status](https://github.com/FAForever/server/actions/workflows/test.yml/badge.svg?branch=develop)
[![codecov](https://codecov.io/gh/FAForever/server/branch/develop/graph/badge.svg?token=55ndgNQdUv)](https://codecov.io/gh/FAForever/server)
[![docs](https://img.shields.io/badge/docs-latest-purple)](https://faforever.github.io/server/)
[![license](https://img.shields.io/badge/license-GPLv3-blue)](license.txt)
![python](https://img.shields.io/badge/python-3.7-blue)
![python](https://img.shields.io/badge/python-3.7-3776AB)

This is the source code for the
[Forged Alliance Forever](https://www.faforever.com/) lobby server.
Expand Down
99 changes: 92 additions & 7 deletions server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,90 @@
"""
Forged Alliance Forever server project

Copyright (c) 2012-2014 Gael Honorez
Copyright (c) 2015-2016 Michael Søndergaard <sheeo@faforever.com>
Forged Alliance Forever lobby server.

# Overview
The lobby server handles live state information for the FAF ecosystem. This
includes maintaining a list of online players, a list of hosted and ongoing
games, and a number of matchmakers. It also performs certain post-game actions
like computing and persisting rating changes and updating achievements. Every
online player maintains an active TCP connection to the server through which
the server syncronizes the current state.

## Social
The social components of the lobby server are relatively limited, as the
primary social element, chat, is handled by a separate server. The social
features handled by the lobby server are therefore limited to:

- Syncronizing online player state
- Enforcing global bans
- Modifying a list of friends and a list of foes
- Modifying the currently selected avatar

## Games
The server supports two ways of discovering games with other players: custom
lobbies and matchmakers. Ultimately however, the lobby server is only able to
help players discover eachother, and maintain certain meta information about
games. Game simulation happens entirely on the client side, and is completely
un-controlled by the server. Certain messages sent between clients throughout
the course of a game will also be relayed to the server. These can be used to
determine if clients were able to connect to eachother, and what the outcome of
the game was.

### Custom Games
Historically, the standard way to play FAF has been for one player to host a
game lobby, setup the desired map and game settings, and for other players to
voluntarily join that lobby until the host is satisfied with the players and
launches the game. The lobby server facilitates sending certain information
about these custom lobbies to all online players (subject to friend/foe rules)
as well as managing a game id that can be used to join a specific lobby. This
information includes, but is not necessarily limited to:

- Auto generated game uid
- Host specified game name
- Host selected map
- List of connected players (non-AI only) and their team setup

### Matchmaker games
Players may also choose to join a matchmaker queue, instead of hosting a game
and finding people to play with manually. The matchmaker will attempt to create
balanced games using players TrueSkill rating, and choose a game host for
hosting an automatch lobby. From the server perspective, automatch games behave
virtually identical to custom games, the exception being that players may not
request to join them by game id. The exchange of game messages and connectivity
establishment happens identically to custom games.

### Connectivity Establishment
When a player requests to join a game, the lobby server initiates connection
establishment between the joining player and the host, and then the joining
player and all other players in the match. Connections are then established
using the Interactive Connectivity Establishment (ICE) protocol, using the
lobby server as a medium of exchanging candidate addresses between clients. If
clients require a relay in order to connect to eachother, they will
authenticate with a separate coturn server using credentials supplied by the
lobby server.

## Achievements
When a game ends, each client will report a summary of the game in the form of
a stat report. These stats are then parsed to extract information about events
that occurred during the game, like units built, units killed, etc. and used to
unlock or progress achievements for the players.

# Technical Overview
This section is intended for developers and will outline technical details of
how to interact with the server. It will remain relatively high level and
implementation agnostic, instead linking to other sections of the documentation
that go into more detail.

## Protocol
TODO

# Legal
- Copyright © 2012-2014 Gael Honorez
- Copyright © 2015-2016 Michael Søndergaard <sheeo@faforever.com>
- Copyright © 2021 Forged Alliance Forever

Distributed under GPLv3, see license.txt
"""

import asyncio
import logging
from typing import Dict, Optional, Set, Tuple, Type
Expand Down Expand Up @@ -54,7 +133,6 @@
"RatingService",
"RatingService",
"ServerInstance",
"abc",
"control",
"game_service",
"protocol",
Expand All @@ -71,7 +149,7 @@

class ServerInstance(object):
"""
A class representing a shared server state. Each ServerInstance may be
A class representing a shared server state. Each `ServerInstance` may be
exposed on multiple ports, but each port will share the same internal server
state, i.e. the same players, games, etc.
"""
Expand Down Expand Up @@ -113,7 +191,14 @@ def __init__(
party_service=self.services["party_service"]
)

def write_broadcast(self, message, predicate=lambda conn: conn.authenticated):
def write_broadcast(
self,
message,
predicate=lambda conn: conn.authenticated
):
"""
Queue a message to be sent to all connected clients.
"""
self._logger.log(TRACE, "]]: %s", message)
metrics.server_broadcasts.inc()

Expand Down
3 changes: 0 additions & 3 deletions server/abc/__init__.py

This file was deleted.

15 changes: 0 additions & 15 deletions server/abc/base_game.py

This file was deleted.

9 changes: 6 additions & 3 deletions server/asyncio_extensions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Some helper functions for common async tasks.
Some helper functions for common async tasks
"""

import asyncio
import inspect
import logging
Expand Down Expand Up @@ -125,7 +126,8 @@ def synchronized(*args):
"""
Ensure that a function will only execute in serial.

:param lock: An instance of asyncio.Lock to use for synchronization.
# Params
- `lock`: An instance of asyncio.Lock to use for synchronization.
"""
# Invoked like @synchronized
if args and inspect.isfunction(args[0]):
Expand Down Expand Up @@ -165,7 +167,8 @@ def synchronizedmethod(*args):
"""
Create a method that will be wrapped with an async lock.

:param attrname: The name of the lock attribute that will be used. If the
# Params
- `attrname`: The name of the lock attribute that will be used. If the
attribute doesn't exist or is None, a lock will be created. The default
is to use a value based on the decorated function name.
"""
Expand Down
4 changes: 4 additions & 0 deletions server/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Server config variables
"""

import asyncio
import logging
import os
Expand Down
4 changes: 4 additions & 0 deletions server/configuration_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Manages periodic reloading of config variables
"""

import asyncio

from .config import config
Expand Down
2 changes: 1 addition & 1 deletion server/control.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Tiny local-only http server for getting stats and performing various tasks
Tiny http server for introspecting state
"""

import socket
Expand Down
7 changes: 7 additions & 0 deletions server/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""
Server framework

This module is completely self contained and could be extracted to its own
project.
"""

from .dependency_injector import DependencyInjector
from .service import Service, create_services

Expand Down
44 changes: 22 additions & 22 deletions server/core/dependency_injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,28 @@ def __init__(self, hello, world):
instance of the object called `hello` (whether that is an injectable, or
another class in the class list).

# Example
```
class SomeClass(object):
def __init__(self, external):
self.external = external

class SomeOtherClass(object):
def __init__(self, some_class):
self.some_class = some_class

injector = DependencyInjector()
injector.add_injectables(external=object())
classes = injector.build_classes({
"some_class": SomeClass,
"other": SomeOtherClass
})

assert isinstance(classes["some_class"], SomeClass)
assert isinstance(classes["other"], SomeOtherClass)
assert classes["other"].some_class is classes["some_class"]
```

# Examples
Create a class that depends on some external injectable.
>>> class SomeClass(object):
... def __init__(self, external):
... self.external = external

Create a class that depends on the first class.
>>> class SomeOtherClass(object):
... def __init__(self, some_class):
... self.some_class = some_class

Do the dependency injection.
>>> injector = DependencyInjector()
>>> injector.add_injectables(external=object())
>>> classes = injector.build_classes({
... "some_class": SomeClass,
... "other": SomeOtherClass
... })

>>> assert isinstance(classes["some_class"], SomeClass)
>>> assert isinstance(classes["other"], SomeOtherClass)
>>> assert classes["other"].some_class is classes["some_class"]
"""

def __init__(self) -> None:
Expand Down
4 changes: 4 additions & 0 deletions server/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Database interaction
"""

import asyncio
import logging

Expand Down
29 changes: 28 additions & 1 deletion server/decorators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Helper decorators
"""

import logging
import time
from functools import wraps
Expand All @@ -6,6 +10,16 @@


def with_logger(cls):
"""
Add a `_logger` attribute to a class. The logger name will be the same as
the class name.

# Examples
>>> @with_logger
... class Foo:
... pass
>>> assert Foo._logger.name == "Foo"
"""
attr_name = "_logger"
cls_name = cls.__qualname__
setattr(cls, attr_name, logging.getLogger(cls_name))
Expand All @@ -19,13 +33,26 @@ def wrapper(*args, **kwargs):
result = f(*args, **kwargs)
elapsed = (time.time() - start)
if elapsed >= limit:
logger.warning("%s took %s s to finish" % (f.__name__, str(elapsed)))
logger.warning("%s took %s s to finish", f.__name__, str(elapsed))
return result

return wrapper


def timed(*args, **kwargs):
"""
Record the execution time of a function and log a warning if the time
exceeds a limit.

# Examples
>>> import time, mock
>>> log = mock.Mock()
>>> @timed(logger=log, limit=0.05)
... def foo():
... time.sleep(0.1)
>>> foo()
>>> log.warning.assert_called_once()
"""
if len(args) == 1 and callable(args[0]):
return _timed_decorator(args[0])
else:
Expand Down
23 changes: 17 additions & 6 deletions server/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""
Common exception definitions
"""

from datetime import datetime

import humanize


class ClientError(Exception):
"""
Represents a ClientError
Represents a protocol violation by the client.

If recoverable is False, it is expected that the
connection be terminated immediately.
If recoverable is False, it is expected that the connection be terminated
immediately.
"""
def __init__(self, message, recoverable=True, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -17,15 +21,19 @@ def __init__(self, message, recoverable=True, *args, **kwargs):


class BanError(Exception):
"""
Signals that an operation could not be completed because the user is banned.
"""
def __init__(self, ban_expiry, ban_reason, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ban_expiry = ban_expiry
self.ban_reason = ban_reason

def message(self):
return (f"You are banned from FAF {self._ban_duration_text()}. <br>"
f"Reason : <br>"
f"{self.ban_reason}")
return (
f"You are banned from FAF {self._ban_duration_text()}. <br>"
f"Reason: <br>{self.ban_reason}"
)

def _ban_duration_text(self):
ban_duration = self.ban_expiry - datetime.utcnow()
Expand All @@ -39,6 +47,9 @@ def _ban_duration_text(self):


class AuthenticationError(Exception):
"""
The operation failed to authenticate.
"""
def __init__(self, message, *args, **kwargs):
super().__init__(*args, **kwargs)
self.message = message