Remove code for the lobby bots from SVN

This removes the code for the lobby bots from SVN to avoid confusion as
the code is now hosted on Github at https://github.com/0ad/lobby-bots.

Differential Revision: https://code.wildfiregames.com/D4155
This was SVN commit r27094.
This commit is contained in:
Dunedan
2022-09-11 13:21:41 +00:00
parent 7f5e664eec
commit a72fa8f7c7
21 changed files with 0 additions and 4215 deletions
-641
View File
@@ -1,641 +0,0 @@
# 0 A.D. / Pyrogenesis Multiplayer Lobby Setup
This README explains how to setup a custom Pyrogenesis Multiplayer Lobby server that can be used with the Pyrogenesis game.
## Service description
The Pyrogenesis Multiplayer Lobby consists of three components:
* **XMPP server: ejabberd**:
The XMPP server provides the platform where users can register accounts, chat in a public room, and can interact with lobby bots.
ejabberd is recommended.
* **Gamelist bot: XpartaMuPP**:
This bot allows players to host and join online multiplayer matches.
* **Rating bot: EcheLOn**:
This bot allows players to gain a rating that reflects their skill based on online multiplayer matches.
It is by no means necessary for the operation of a lobby in terms of match-making and chatting.
## Service choices
Before installing the service, you have to make some decisions:
#### Choice: Domain Name
Decide on a domain name where the service will be provided.
This document will use `lobby.wildfiregames.com` as an example.
If you intend to use the server only for local testing, you may choose `localhost`.
#### Choice: Rating service
Decide whether or not you want to employ the rating service.
If you decide to not provide the rating service, you may skip the instructions for the rating bot in this document.
#### Choice: Pyrogenesis version compatibility
Decide whether you want to support serving multiple Pyrogenesis versions.
Serving multiple versions of Pyrogenesis allows for seamless version upgrading on the backend and
allows players that don't have the most recent version of Pyrogenesis yet to continue to play until
the new release is available for their platform (applies mostly to linux distributions).
If you decide to do so, you should use a naming pattern that includes the targetted Pyrogenesis version.
For example to provide a Multiplayer Lobby for Pyrogenesis Alpha 23 "Ken Wood",
name the lobby room `arena23` instead of `arena` and use `xpartamupp23` and `echelon23` as lobby bot names.
Then when a version 24 of Pyrogenesis is employed, you can easily add `arena24`, `xpartamupp24` and `echelon24`.
If you only want to use the service for local testing, you can stick to a single room and a single gamelist and rating bot.
## 1. Install dependencies
This section explains how to install the required software on a Debian-based linux distribution.
For other operating systems, use the according package manager or consult the official documentation of the software.
### 1.1 Install ejabberd
The version requirement for ejabberd is 17.03 or later (due to the ipstamp module format).
* Install `ejabberd` using the following command. Alternatively see <https://docs.ejabberd.im/admin/installation/>.
```
$ apt-get install ejabberd
```
* Confirm that the ejabberd version you installed is the one mentioned above or later:
```
$ ejabberdctl status
```
* Configure ejabberd by setting the domain name of your choice and add an `admin` user.:
```
$ dpkg-reconfigure ejabberd
````
You should now be able to connect to this XMPP server using any XMPP client.
### 1.2 Install python3 and SleekXmpp
* The lobby bots are programmed in python3 and use SleekXMPP to connect to the lobby. Install these dependencies using:
```
$ apt-get install python3 python3-sleekxmpp
```
* Confirm that the SleekXmpp version is 1.3.1 or later:
```
pip3 show sleekxmpp
```
* If you would like to run the rating bot, you will need to install SQLAlchemy for python3:
```
$ apt-get install python3-sqlalchemy
```
## 2 (Optional) Install ejabberd ipstamp module
### 2.1 Copy mod_ipstamp files.
The ejabberd ipstamp module is used as a fallback for users without STUN capabilities.
It inserts the IP to GameList "register" stanzas, which XpartMuPP sends back to the host.
STUN-enabled users do not require it to host, so this is optional.
* Adjust `/etc/ejabberd/ejabberdctl.cfg` and set `CONTRIB_MODULES_PATH` to the directory where you want to store `mod_ipstamp`:
```
CONTRIB_MODULES_PATH=/opt/ejabberd-modules
```
* Ensure the target directory is readable by ejabberd.
* Copy the `mod_ipstamp` directory from `XpartaMuPP/` to `CONTRIB_MODULES_PATH/sources/`.
* Check that the module is available and compatible with your ejabberd:
```
$ ejabberdctl modules_available
$ ejabberdctl module_check mod_ipstamp
```
* Install `mod_ipstamp`:
```
$ ejabberdctl module_install mod_ipstamp
```
## 2.2. Configure ejabberd mod_ipstamp
The ejabberd configuration in the remainder of this document is performed by editing `/etc/ejabberd/ejabberd.yml`.
The directory containing this README includes a preconfigured `ejabberd_example.yml` that only needs few setting changes to work with your setup.
For a full documentation of the ejabberd configuration, see <https://docs.ejabberd.im/admin/configuration/>.
If something goes wrong with ejabberd, check `/var/log/ejabberd/ejabberd.log`
* Add `mod_ipstamp` to the modules ejabberd should load:
```
modules:
mod_ipstamp: {}
```
* Reload the ejabberd config.
This should be done every few steps, so that configuration errors can be identified as soon as possible.
```
$ ejabberdctl reload_config
```
## 3. Configure ejabberd connectivity
The settings in this section ensure that connections can be built where intended, and only where intended.
### 3.1 Disable IPv6
* Since the enet library which Pyrogenesis uses for multiplayer mode does not support IPv6, ejabberd must be configured to not use IPv6:
```
listen:
ip: "0.0.0.0"
```
### 3.2 Enable STUN
* ejabberd and Pyrogenesis support the STUN protocol. This allows players to connect to each others games even if the host did not configure the router and forward the UDP port.
0 A.D. uses STUN to let hosts find their IP.
```
listen:
-
port: 3478
transport: udp
module: ejabberd_stun
```
### 3.3 Enable keep-alive
* This helps with users becoming disconnected:
```
modules:
mod_ping:
send_pings: true
```
### 3.3 Disable unused services
* Disable the currently unused server-to-server communication:
```
listen:
## -
## port: 5269
## ip: "::"
## module: ejabberd_s2s_in
```
* Protect the administrative webinterface at <https://localhost:5280/admin> from external access by disabling or restriction to `localhost`:
```
listen:
-
port: 5280
ip: "127.0.0.1"
```
* Disable some unused modules:
```
modules:
## mod_echo: {}
## mod_irc: {}
## mod_shared_roster: {}
## mod_vcard: {}
## mod_vcard_xupdate: {}
```
### 3.4 Setup TLS encryption
Depending on whether you use the server for a player audience or only for local testing,
you may have to either obtain and install a certificate with ejabberd or disable TLS encryption.
#### Choice A: No encryption
* If you intend to use the server solely for local testing, you may disable TLS encryption in the ejabberd config:
```
listen:
starttls_required: false
```
#### Choice B: Self-signed certificate
If you want to use the server for local testing only, you may use a self-signed certificate to test encryption.
Notice the lobby bots currently reject self-signed certificates.
* Enable TLS over the default port:
```
listen:
starttls: true
```
* Create the key file for certificate:
```
openssl genrsa -out key.pem 2048
```
* Create the certificate file. “common name” should match the domainname.
```
openssl req -new -key key.pem -out request.pem
```
* Sign the certificate:
```
openssl x509 -req -days 900 -in request.pem -signkey key.pem -out certificate.pem
```
* Store it as the ejabberd certificate:
```
$ cat key.pem request.pem > /etc/ejabberd/ejabberd.pem
```
#### Choice C: Let's Encrypt certificate
To secure user authentication and communication with modern encryption and to comply with privacy laws,
ejabberd should be configured to use TLS with a proper, trusted certificate.
* A free, valid, and trusted TLS certificate may be obtained from some certificate authorites, such as Let's Encrypt:
<https://letsencrypt.org/getting-started/>
<https://blog.process-one.net/securing-ejabberd-with-tls-encryption/>
* Enable TLS over the default port:
```
listen:
starttls: true
```
* Setup the contact address if Let's Encrypt found an authentication issue:
```
acme:
contact: "mailto:admin@example.com"
```
* Ensure old, vulnerable SSL/TLS protocols are disabled:
```
define_macro:
'TLS_OPTIONS':
- "no_sslv2"
- "no_sslv3"
- "no_tlsv1"
```
## 3. Configure ejabberd use policy
The settings in this section grant or restrict user access rights.
* Prevent the rooms from being destroyed if the last client leaves it:
```
access_rules:
muc_admin:
- allow: admin
modules:
mod_muc:
access_persistent: muc_admin
default_room_options:
persistent: true
```
* Allow users to create accounts using the game via in-band registration.
```
access_rules:
register:
- all: allow
```
### Optional use policies
* (Optional) It is recommended to restrict usernames to alphanumeric characters (so that playernames are easily typeable for every participant).
The username may be restricted in length (because very long usernames are uncomfortably time-consuming to read and may not fit into the playername fields).
Notice the username regex below is also used by the 0 A.D. client to indicate invalid names to the user.
```
acl:
validname:
user_regexp: "^[0-9A-Za-z._-]{1,20}$"
access_rules:
register:
- allow: validname
modules:
mod_register:
access: register
```
* (Optional) Prevent users from creating new rooms:
```
modules:
mod_muc:
access_create: muc_admin
```
* (Optional) Increase the maximum number of users from the default 200:
```
mod_muc:
max_users: 5000
default_room_options:
max_users: 1000
```
* (Optional) Prevent users from sending too large stanzas.
Notice the bots can send large stanzas as well, so don't restrict it too much.
```
max_stanza_size: 1048576
```
* (Optional) Prevent users from changing the room topic:
```
mod_muc:
default_room_options:
allow_change_subj: false
```
* (Optional) Prevent malicious users from registering new accounts quickly if they were banned.
Notice this also prevents players using the same internet router from registering for that time if they want to play together.
```
registration_timeout: 3600
```
* (Optional) Enable room chatlogging.
Make sure to mention this collection and the purposes in the Terms and Conditions to comply with personal data laws.
Ensure that ejabberd has write access to the given folder.
Notice that `ejabberd.service` by default prevents write access to some directories (PrivateTmp, ProtectHome, ProtectSystem).
```
modules:
mod_muc_log:
outdir: "/lobby/logs"
file_format: plaintext
timezone: universal
mod_muc:
default_room_options:
logging: true
```
* (Optional) Grant specific moderators administrator rights to see the IP address of a user:
See also `https://xmpp.org/extensions/xep-0133.html#get-user-stats`.
```
acl:
admin:
user:
- "username@lobby.wildfiregames.com"
```
* (Optional) Grant specific moderators to :
See also `https://xmpp.org/extensions/xep-0133.html#get-user-stats`.
```
modules:
mod_muc:
access_admin: muc_admin
```
* (Optional) Ban specific IP addresses or subnet masks for persons that create new accounts after having been banned from the room:
```
acl:
blocked:
ip:
- "12.34.56.78"
- "12.34.56.0/8"
- "12.34.0.0/16"
...
access_rules:
c2s:
- deny: blocked
- allow
register:
- deny: blocked
- allow
```
## 4. Setup lobby bots
### 4.1 Register lobby bot accounts
* Check list of registered users:
```
$ ejabberdctl registered_users lobby.wildfiregames.com
```
* Register the accounts of the lobby bots.
The rating account is only needed if you decided to enable the rating service.
```
$ ejabberdctl register echelon23 lobby.wildfiregames.com secure_password
$ ejabberdctl register xpartamupp23 lobby.wildfiregames.com secure_password
```
### 4.2 Authorize lobby bots to see real JIDs
* The bots need to be able to see real JIDs of users.
So either the room must be configured as non-anonymous, i.e. real JIDs are visible to all users of the room,
or the bots need to receive muc administrator rights.
#### Choice A: Non-anonymous room
* (Recommended) This method has the advantage that bots do not gain administrative access that they don't use.
The only possible downside is that room users may not hide their username behind arbitrary nicknames anymore.
```
modules:
mod_muc:
default_room_options:
anonymous: false
```
#### Choice B: Non-anonymous room
* If you for any reason wish to configure the room as semi-anonymous (only muc administrators can see real JIDs),
then the bots need to be authorized as muc administrators:
```
access_rules:
muc_admin:
- allow: bots
modules:
mod_muc:
access_admin: muc_admin
```
### 4.3 Authorize lobby bots with ejabberd
* The bots need an ACL to be able to get the IPs of users hosting a match (which is what `mod_ipstamp` does).
```
acl:
## Don't use a regex, to prevent others from obtaining permissions after registering such an account.
bots:
- user: "xpartamupp23@lobby.wildfiregames.com"
- user: "echelon23@lobby.wildfiregames.com"
```
* Add an access rule for `ipbots` and a rule allowing bots to create PubSub nodes:
```
access_rules:
## Expected by the ipstamp module for XpartaMuPP
ipbots:
- allow: bots
pubsub_createnode:
- allow: bots
```
* Due to the amount of traffic the bot may process, give the group containing bots either unlimited or a very high traffic shaper:
```
shaper_rules:
c2s_shaper:
- none: admin, bots
- normal
```
* Finally reload ejabberd's configuration:
```
$ ejabberdctl reload_config
```
### 4.4 Running XpartaMuPP - XMPP Multiplayer Game Manager
* Execute the following command to run the gamelist bot:
```
$ python3 XpartaMuPP.py --domain lobby.wildfiregames.com --login xpartamupp23 --password XXXXXX --nickname GamelistBot --room arena --elo echelon23
```
If you want to run XpartaMuPP without a rating bot, the `--elo` argument should be omitted.
Pass `--disable-tls` if you did not setup valid TLS encryption on the server.
Run `python3 XpartaMuPP.py --help` for the full list of options
* If the connection and authentication succeeded, you should see the following messages in the console:
```
INFO JID set to: xpartamupp23@lobby.wildfiregames.com/CC
INFO XpartaMuPP started
```
### 4.5 Running EcheLOn - XMPP Multiplayer Rating Manager
This bot can be thought of as a module of XpartaMuPP in that IQs stanzas sent to XpartaMuPP are
forwarded onto EcheLOn if its corresponding EcheLOn is online and ignored otherwise.
EcheLOn handles all aspects of operation related to ELO, the chess rating system invented by Arpad Elo.
Players gain a rating after a rated 1v1 match.
The score difference after a completed match is relative to the rating difference of the players.
* (Optional) Some constants of the algorithm may be edited by experienced administrators at the head of `ELO.py`:
```
# Difference between two ratings such that it is
# regarded as a "sure win" for the higher player.
# No points are gained or lost for such a game.
elo_sure_win_difference = 600.0
# Lower ratings "move faster" and change more
# dramatically than higher ones. Anything rating above
# this value moves at the same rate as this value.
elo_k_factor_constant_rating = 2200.0
```
* To initialize the `lobby_rankings.sqlite3` database, execute the following command:
```
$ python3 LobbyRanking.py
```
* Execute the following command to run the rating bot:
```
$ python3 EcheLOn.py --domain lobby.wildfiregames.com --login echelon23 --password XXXXXX --nickname RatingBot --room arena23
```
Run `python3 EcheLOn.py --help` for the full list of options
## 5. Configure Pyrogenesis for the new Multiplayer Lobby
The Pyrogenesis client is now going to be configured to become able to connect to the new Multiplayer Lobby.
The Pyrogenesis documentation of configuration files can be found at <https://trac.wildfiregames.com/wiki/Manual_Settings>.
Available Pyrogenesis configuration settings are specified in `default.cfg`, see <https://code.wildfiregames.com/source/0ad/browse/ps/trunk/binaries/data/config/default.cfg>.
### 5.1 Local Configuration
* Visit <https://trac.wildfiregames.com/wiki/GameDataPaths> to identify the local user's Pyrogenesis configuration path depending on the operating system.
* Create or open `local.cfg` in the configuration path.
* Add the following settings that determine the lobby server connection:
```
lobby.room = "arena23" ; Default MUC room to join
lobby.server = "lobby.wildfiregames.com" ; Address of lobby server
lobby.stun.server = "lobby.wildfiregames.com" ; Address of the STUN server.
lobby.require_tls = true ; Whether to reject connecting to the lobby if TLS encryption is unavailable.
lobby.verify_certificate = true ; Whether to reject connecting to the lobby if the TLS certificate is invalid.
lobby.xpartamupp = "xpartamupp23" ; Name of the server-side XMPP-account that manage games
lobby.echelon = "echelon23" ; Name of the server-side XMPP-account that manages ratings
```
If you disabled TLS encryption, set `require_tls` to `false`.
If you employed a self-signed certificate, set `verify_certificate` to `false`.
### 5.2 Test the Multiplayer Lobby
You should now be able to join the new multiplayer lobby with the Pyrogenesis client and play multiplayer matches.
* To confirm that the match hosting works as intended, create two user accounts, host a game with one, join the game with the other account.
* To confirm that the rating service works as intended, resign a rated 1v1 match with two accounts.
### 5.3 Terms and Conditions
Players joining public servers are subject to Terms and Conditions of the service provider and subject to privacy laws such as GDPR.
If you intend to use the server only for local testing, you may skip this step.
* The following files should be created by the service provider:
`Terms_of_Service.txt` to explain the service and the contract.
`Terms_of_Use.txt` to explain what the user should and should not do.
`Privacy_Policy.txt` to explain how personal data is handled.
* To use Wildfire Games Terms as a template, obtain our Terms from a copy of the game or from or from
<https://trac.wildfiregames.com/browser/ps/trunk/binaries/data/mods/public/gui/prelobby/common/terms/>
* Replace all occurrences of `Wildfire Games` in the files with the one providing the new server.
* Update the `Terms_of_Use.txt` depending on which behavior you would like to (not) see on your service.
* Update the `Privacy_Policy.txt` depending on the user data processing in relation to the usage policies.
Make sure to not violate privacy laws such as GDPR or COPPA while doing so.
* The retention times of ejabberd logs are relevant to GDPR.
Visit <https://www.ejabberd.im/Rotating%20logs%20with%20ejabberd/index.html> for details.
* The terms should be published online, so users can save and print them.
Add to your `local.cfg`:
```
lobby.terms_url = "https://lobby.wildfiregames.com/terms/"; Allows the user to save the text and print the terms
```
### 5.4 Distribute the configuration
To make this a public server, distribute your `local.cfg`, `Terms_of_Service.txt`, `Terms_of_Use.txt`, `Privacy_Policy.txt`.
It may be advisable to create a mod with a modified `default.cfg` and the new terms documents,
see <https://trac.wildfiregames.com/wiki/Modding_Guide>.
Congratulations, you are now running a custom Pyrogenesis Multiplayer Lobby!
-855
View File
@@ -1,855 +0,0 @@
###
###' ejabberd configuration file
###
###
### The parameters used in this configuration file are explained in more detail
### in the ejabberd Installation and Operation Guide.
### Please consult the Guide in case of doubts, it is included with
### your copy of ejabberd, and is also available online at
### http://www.process-one.net/en/ejabberd/docs/
### The configuration file is written in YAML.
### Refer to http://en.wikipedia.org/wiki/YAML for the brief description.
### However, ejabberd treats different literals as different types:
###
### - unquoted or single-quoted strings. They are called "atoms".
### Example: dog, 'Jupiter', '3.14159', YELLOW
###
### - numeric literals. Example: 3, -45.0, .0
###
### - quoted or folded strings.
### Examples of quoted string: "Lizzard", "orange".
### Example of folded string:
### > Art thou not Romeo,
### and a Montague?
---
###. =======
###' LOGGING
##
## loglevel: Verbosity of log files generated by ejabberd.
## 0: No ejabberd log at all (not recommended)
## 1: Critical
## 2: Error
## 3: Warning
## 4: Info
## 5: Debug
##
loglevel: 4
##
## rotation: Disable ejabberd's internal log rotation, as the Debian package
## uses logrotate(8).
log_rotate_size: 0
log_rotate_date: ""
##
## overload protection: If you want to limit the number of messages per second
## allowed from error_logger, which is a good idea if you want to avoid a flood
## of messages when system is overloaded, you can set a limit.
## 100 is ejabberd's default.
log_rate_limit: 100
##
## watchdog_admins: Only useful for developers: if an ejabberd process
## consumes a lot of memory, send live notifications to these XMPP
## accounts.
##
## watchdog_admins:
## - "bob@example.com"
###. ===============
###' NODE PARAMETERS
##
## net_ticktime: Specifies net_kernel tick time in seconds. This options must have
## identical value on all nodes, and in most cases shouldn't be changed at all from
## default value.
##
## net_ticktime: 60
###. ================
###' SERVED HOSTNAMES
##
## hosts: Domains served by ejabberd.
## You can define one or several, for example:
## hosts:
## - "example.net"
## - "example.com"
## - "example.org"
##
hosts:
- "localhost"
##
## route_subdomains: Delegate subdomains to other XMPP servers.
## For example, if this ejabberd serves example.org and you want
## to allow communication with an XMPP server called im.example.org.
##
## route_subdomains: s2s
###. ============
###' Certificates
## List all available PEM files containing certificates for your domains,
## chains of certificates or certificate keys. Full chains will be built
## automatically by ejabberd.
##
certfiles:
- "/etc/ejabberd/ejabberd.pem"
## If your system provides only a single CA file (CentOS/FreeBSD):
## ca_file: "/etc/ssl/certs/ca-bundle.pem"
###. =================
###' TLS configuration
## Note that the following configuration is the default
## configuration of the TLS driver, so you don't need to
## uncomment it.
##
define_macro:
'TLS_CIPHERS': "HIGH:!aNULL:!eNULL:!3DES:@STRENGTH"
'TLS_OPTIONS':
- "no_sslv2"
- "no_sslv3"
- "no_tlsv1"
- "cipher_server_preference"
- "no_compression"
## 'DH_FILE': "/path/to/dhparams.pem" # generated with: openssl dhparam -out dhparams.pem 2048
## c2s_dhfile: 'DH_FILE'
## s2s_dhfile: 'DH_FILE'
c2s_ciphers: 'TLS_CIPHERS'
s2s_ciphers: 'TLS_CIPHERS'
c2s_protocol_options: 'TLS_OPTIONS'
s2s_protocol_options: 'TLS_OPTIONS'
###. ===============
###' LISTENING PORTS
##
## listen: The ports ejabberd will listen on, which service each is handled
## by and what options to start it with.
##
listen:
-
port: 5222
ip: "0.0.0.0"
module: ejabberd_c2s
starttls: true
starttls_required: false
protocol_options: 'TLS_OPTIONS'
max_stanza_size: 1048576
shaper: c2s_shaper
access: c2s
## port: 5269
## ip: "::"
## module: ejabberd_s2s_in
-
port: 5280
ip: "127.0.0.1"
module: ejabberd_http
request_handlers:
"/ws": ejabberd_http_ws
"/bosh": mod_bosh
"/api": mod_http_api
## "/pub/archive": mod_http_fileserver
web_admin: true
## register: true
## captcha: true
tls: true
protocol_options: 'TLS_OPTIONS'
##
## ejabberd_service: Interact with external components (transports, ...)
##
## -
## port: 8888
## ip: "::"
## module: ejabberd_service
## access: all
## shaper_rule: fast
## ip: "127.0.0.1"
## privilege_access:
## roster: "both"
## message: "outgoing"
## presence: "roster"
## delegations:
## "urn:xmpp:mam:1":
## filtering: ["node"]
## "http://jabber.org/protocol/pubsub":
## filtering: []
## hosts:
## "icq.example.org":
## password: "secret"
## "sms.example.org":
## password: "secret"
##
## ejabberd_stun: Handles STUN Binding requests
##
-
port: 3478
transport: udp
module: ejabberd_stun
##
## To handle XML-RPC requests that provide admin credentials:
##
## -
## port: 4560
## ip: "::"
## module: ejabberd_xmlrpc
## maxsessions: 10
## timeout: 5000
## access_commands:
## admin:
## commands: all
## options: []
##
## To enable secure http upload
##
## -
## port: 5444
## ip: "::"
## module: ejabberd_http
## request_handlers:
## "": mod_http_upload
## tls: true
## protocol_options: 'TLS_OPTIONS'
## dhfile: 'DH_FILE'
## ciphers: 'TLS_CIPHERS'
## Disabling digest-md5 SASL authentication. digest-md5 requires plain-text
## password storage (see auth_password_format option).
disable_sasl_mechanisms: "digest-md5"
###. ==================
###' S2S GLOBAL OPTIONS
##
## s2s_use_starttls: Enable STARTTLS for S2S connections.
## Allowed values are: false, optional or required
## You must specify 'certfiles' option
##
s2s_use_starttls: required
##
## S2S whitelist or blacklist
##
## Default s2s policy for undefined hosts.
##
## s2s_access: s2s
##
## Outgoing S2S options
##
## Preferred address families (which to try first) and connect timeout
## in seconds.
##
## outgoing_s2s_families:
## - ipv4
## - ipv6
## outgoing_s2s_timeout: 190
###. ==============
###' AUTHENTICATION
##
## auth_method: Method used to authenticate the users.
## The default method is the internal.
## If you want to use a different method,
## comment this line and enable the correct ones.
##
auth_method: internal
##
## Store the plain passwords or hashed for SCRAM:
## auth_password_format: plain
auth_password_format: scram
##
## Define the FQDN if ejabberd doesn't detect it:
## fqdn: "server3.example.com"
##
## Authentication using external script
## Make sure the script is executable by ejabberd.
##
## auth_method: external
## extauth_program: "/path/to/authentication/script"
##
## Authentication using SQL
## Remember to setup a database in the next section.
##
## auth_method: sql
##
## Authentication using PAM
##
## auth_method: pam
## pam_service: "pamservicename"
##
## Authentication using LDAP
##
## auth_method: ldap
##
## List of LDAP servers:
## ldap_servers:
## - "lw"
##
## Encryption of connection to LDAP servers:
## ldap_encrypt: none
## ldap_encrypt: tls
##
## Port to connect to on LDAP servers:
## ldap_port: 389
## ldap_port: 636
##
## LDAP manager:
## ldap_rootdn: "dc=example,dc=com"
##
## Password of LDAP manager:
## ldap_password: "******"
##
## Search base of LDAP directory:
## ldap_base: "dc=example,dc=com"
##
## LDAP attribute that holds user ID:
## ldap_uids:
## - "mail": "%u@mail.example.org"
##
## LDAP filter:
## ldap_filter: "(objectClass=shadowAccount)"
##
## Anonymous login support:
## auth_method: anonymous
## anonymous_protocol: sasl_anon | login_anon | both
## allow_multiple_connections: true | false
##
## host_config:
## "public.example.org":
## auth_method: anonymous
## allow_multiple_connections: false
## anonymous_protocol: sasl_anon
##
## To use both anonymous and internal authentication:
##
## host_config:
## "public.example.org":
## auth_method:
## - internal
## - anonymous
###. ==============
###' DATABASE SETUP
## ejabberd by default uses the internal Mnesia database,
## so you do not necessarily need this section.
## This section provides configuration examples in case
## you want to use other database backends.
## Please consult the ejabberd Guide for details on database creation.
##
## MySQL server:
##
## sql_type: mysql
## sql_server: "server"
## sql_database: "database"
## sql_username: "username"
## sql_password: "password"
##
## If you want to specify the port:
## sql_port: 1234
##
## PostgreSQL server:
##
## sql_type: pgsql
## sql_server: "server"
## sql_database: "database"
## sql_username: "username"
## sql_password: "password"
##
## If you want to specify the port:
## sql_port: 1234
##
## If you use PostgreSQL, have a large database, and need a
## faster but inexact replacement for "select count(*) from users"
##
## pgsql_users_number_estimate: true
##
## SQLite:
##
## sql_type: sqlite
## sql_database: "/path/to/database.db"
##
## ODBC compatible or MSSQL server:
##
## sql_type: odbc
## sql_server: "DSN=ejabberd;UID=ejabberd;PWD=ejabberd"
##
## Number of connections to open to the database for each virtual host
##
## sql_pool_size: 10
##
## Interval to make a dummy SQL request to keep the connections to the
## database alive. Specify in seconds: for example 28800 means 8 hours
##
## sql_keepalive_interval: undefined
###. ===============
###' TRAFFIC SHAPERS
shaper:
##
## The "normal" shaper limits traffic speed to 1000 B/s
##
normal: 1000
##
## The "fast" shaper limits traffic speed to 50000 B/s
##
fast: 50000
##
## This option specifies the maximum number of elements in the queue
## of the FSM. Refer to the documentation for details.
##
max_fsm_queue: 10000
###. ====================
###' ACCESS CONTROL LISTS
acl:
##
## The 'admin' ACL grants administrative privileges to XMPP accounts.
## You can put here as many accounts as you want.
##
admin:
user:
- "admin@localhost"
## Don't use a regex, to prevent others from obtaining permissions after registering such an account.
bots:
- user: "echelon23@localhost"
- user: "wfgbot23@localhost"
# Keep playernames short and easily typeable for everyone
validname:
user_regexp: "^[0-9A-Za-z._-]{1,20}$"
##
## Blocked users
##
## blocked:
## user:
## - "baduser@example.org"
## - "test"
## Local users: don't modify this.
##
local:
user_regexp: ""
##
## More examples of ACLs
##
## jabberorg:
## server:
## - "jabber.org"
## aleksey:
## user:
## - "aleksey@jabber.ru"
## test:
## user_regexp: "^test"
## user_glob: "test*"
##
## Loopback network
##
loopback:
ip:
- "127.0.0.0/8"
- "::1/128"
- "::FFFF:127.0.0.1/128"
##
## Bad XMPP servers
##
## bad_servers:
## server:
## - "xmpp.zombie.org"
## - "xmpp.spam.com"
##
## Define specific ACLs in a virtual host.
##
## host_config:
## "localhost":
## acl:
## admin:
## user:
## - "bob-local@localhost"
###. ============
###' SHAPER RULES
shaper_rules:
## Maximum number of simultaneous sessions allowed for a single user:
max_user_sessions: 10
## Maximum number of offline messages that users can have:
max_user_offline_messages:
- 5000: admin
- 100
## For C2S connections, all users except admins use the "normal" shaper
c2s_shaper:
- none: admin
- none: bots
- normal
## All S2S connections use the "fast" shaper
s2s_shaper: fast
###. ============
###' ACCESS RULES
access_rules:
## This rule allows access only for local users:
local:
- allow: local
## Only non-blocked users can use c2s connections:
c2s:
- deny: blocked
- allow
## Only admins can send announcement messages:
announce:
- allow: admin
## Only admins can use the configuration interface:
configure:
- allow: admin
## Expected by the ipstamp module for XpartaMuPP
ipbots:
- allow: bots
muc_admin:
- allow: admin
## Bots must be able to create nodes for games, ratings and boards lists
pubsub_createnode:
- allow: admin
- allow: bots
## In-band registration allows registration of any possible username.
## To disable in-band registration, replace 'allow' with 'deny'.
register:
- deny: blocked
- allow: validname
## Only allow to register from localhost
trusted_network:
- allow: loopback
## Do not establish S2S connections with bad servers
## If you enable this you also have to uncomment "s2s_access: s2s"
## s2s:
## - deny:
## - ip: "XXX.XXX.XXX.XXX/32"
## - deny:
## - ip: "XXX.XXX.XXX.XXX/32"
## - allow
## ===============
## API PERMISSIONS
## ===============
##
## This section allows you to define who and using what method
## can execute commands offered by ejabberd.
##
## By default "console commands" section allow executing all commands
## issued using ejabberdctl command, and "admin access" section allows
## users in admin acl that connect from 127.0.0.1 to execute all
## commands except start and stop with any available access method
## (ejabberdctl, http-api, xmlrpc depending what is enabled on server).
##
## If you remove "console commands" there will be one added by
## default allowing executing all commands, but if you just change
## permissions in it, version from config file will be used instead
## of default one.
##
api_permissions:
"console commands":
from:
- ejabberd_ctl
who: all
what: "*"
"admin access":
who:
- access:
- allow:
- acl: loopback
- acl: admin
- oauth:
- scope: "ejabberd:admin"
- access:
- allow:
- acl: loopback
- acl: admin
what:
- "*"
- "!stop"
- "!start"
"public commands":
who:
- ip: "127.0.0.1/8"
what:
- "status"
- "connected_users_number"
## By default the frequency of account registrations from the same IP
## is limited to 1 account every 10 minutes. To disable, specify: infinity
registration_timeout: 3600
##
## Define specific Access Rules in a virtual host.
##
## host_config:
## "localhost":
## access:
## c2s:
## - allow: admin
## - deny
## register:
## - deny
###. ================
###' DEFAULT LANGUAGE
##
## language: Default language used for server messages.
##
language: "en"
##
## Set a different default language in a virtual host.
##
## host_config:
## "localhost":
## language: "ru"
###. =======
###' CAPTCHA
##
## Full path to a script that generates the image.
##
## captcha_cmd: "/usr/share/ejabberd/captcha.sh"
##
## Host for the URL and port where ejabberd listens for CAPTCHA requests.
##
## captcha_host: "example.org:5280"
##
## Limit CAPTCHA calls per minute for JID/IP to avoid DoS.
##
## captcha_limit: 5
###. ====
###' ACME
##
## In order to use the acme certificate acquiring through "Let's Encrypt"
## an http listener has to be configured to listen to port 80 so that
## the authorization challenges posed by "Let's Encrypt" can be solved.
##
## A simple way of doing this would be to add the following in the listening
## section and to configure port forwarding from 80 to 5281 either via NAT
## (for ipv4 only) or using frontends such as haproxy/nginx/sslh/etc.
## -
## port: 5281
## ip: "::"
## module: ejabberd_http
acme:
## A contact mail that the ACME Certificate Authority can contact in case of
## an authorization issue, such as a server-initiated certificate revocation.
## It is not mandatory to provide an email address but it is highly suggested.
contact: "mailto:example-admin@example.com"
## The ACME Certificate Authority URL.
## This could either be:
## - https://acme-v01.api.letsencrypt.org - (Default) for the production CA
## - https://acme-staging.api.letsencrypt.org - for the staging CA
## - http://localhost:4000 - for a local version of the CA
ca_url: "https://acme-v01.api.letsencrypt.org"
###. =======
###' MODULES
##
## Modules enabled in all ejabberd virtual hosts.
##
modules:
mod_adhoc: {}
mod_admin_extra: {}
mod_announce: # recommends mod_adhoc
access: announce
mod_blocking: {} # requires mod_privacy
mod_caps: {}
mod_carboncopy: {}
mod_client_state: {}
mod_configure: {} # requires mod_adhoc
## mod_delegation: {} # for xep0356
mod_disco: {}
## mod_echo: {}
## ipstamp module used by XpartaMuPP to insert IP addresses into the gamelist
mod_ipstamp: {}
## mod_irc: {}
mod_bosh: {}
## mod_http_fileserver:
## docroot: "/var/www"
## accesslog: "/var/log/ejabberd/access.log"
## mod_http_upload:
## # docroot: "@HOME@/upload"
## put_url: "https://@HOST@:5444"
## thumbnail: false # otherwise needs the identify command from ImageMagick installed
## mod_http_upload_quota:
## max_days: 30
mod_last: {}
## XEP-0313: Message Archive Management
## You might want to setup a SQL backend for MAM because the mnesia database is
## limited to 2GB which might be exceeded on large servers
## mod_mam: {} # for xep0313, mnesia is limited to 2GB, better use an SQL backend
mod_muc:
## host: "conference.@HOST@"
access:
- allow
access_admin: muc_admin
access_create: muc_admin
access_persistent: muc_admin
max_users: 5000
default_room_options:
allow_change_subj: false
logging: true
max_users: 1000
persistent: true
mod_muc_admin: {}
mod_muc_log:
outdir: "/lobby/logs"
dirtype: plain
file_format: plaintext
timezone: universal
## mod_multicast: {}
mod_offline:
access_max_user_messages: max_user_offline_messages
mod_ping:
send_pings: true
## mod_pres_counter:
## count: 5
## interval: 60
mod_privacy: {}
mod_private: {}
## mod_proxy65: {}
mod_pubsub:
access_createnode: pubsub_createnode
## reduces resource comsumption, but XEP incompliant
ignore_pep_from_offline: true
## XEP compliant, but increases resource comsumption
## ignore_pep_from_offline: false
last_item_cache: false
plugins:
- "flat"
- "hometree"
- "pep" # pep requires mod_caps
mod_push: {}
mod_push_keepalive: {}
mod_register:
##
## Protect In-Band account registrations with CAPTCHA.
##
## captcha_protected: true
##
## Set the minimum informational entropy for passwords.
##
## password_strength: 32
##
## After successful registration, the user receives
## a message with this subject and body.
##
## welcome_message:
## subject: "Welcome!"
## body: |-
## Hi.
## Welcome to this XMPP server.
##
## When a user registers, send a notification to
## these XMPP accounts.
##
## registration_watchers:
## - "admin1@example.org"
##
## Only clients in the server machine can register accounts
##
## ip_access: trusted_network
##
## Local c2s or remote s2s users cannot register accounts
##
## access_from: deny
access: register
mod_roster:
versioning: true
## mod_shared_roster: {}
mod_stats: {}
mod_time: {}
## mod_vcard:
## search: false
## mod_vcard_xupdate: {}
## Convert all avatars posted by Android clients from WebP to JPEG
## mod_avatar: # this module needs compile option --enable-graphics
## convert:
## webp: jpeg
mod_version: {}
mod_stream_mgmt:
resend_on_timeout: if_offline
## Non-SASL Authentication (XEP-0078) is now disabled by default
## because it's obsoleted and is used mostly by abandoned
## client software
## mod_legacy_auth: {}
## The module for S2S dialback (XEP-0220). Please note that you cannot
## rely solely on dialback if you want to federate with other servers,
## because a lot of servers have dialback disabled and instead rely on
## PKIX authentication. Make sure you have proper certificates installed
## and check your accessibility at https://check.messaging.one/
mod_s2s_dialback: {}
mod_http_api: {}
##
## Enable modules with custom options in a specific virtual host
##
## host_config:
## "localhost":
## modules:
## mod_echo:
## host: "mirror.localhost"
##
## Enable modules management via ejabberdctl for installation and
## uninstallation of public/private contributed modules
## (enabled by default)
##
allow_contrib_modules: true
###.
###'
### Local Variables:
### mode: yaml
### End:
### vim: set filetype=yaml tabstop=8 foldmarker=###',###. foldmethod=marker:
-339
View File
@@ -1,339 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
@@ -1,7 +0,0 @@
mod_ipstamp
===========
mod_ipstamp is an ejabberd module for 0ad which adds ip addresses of a
game host to game registration stanzas.
For it to work the 0ad XMPP bots need to have the ACL "ipbots".
@@ -1,5 +0,0 @@
author: "Wildfire Games"
category: "log"
summary: "Add senders IP address to game registration stanzas for 0ad"
home: "undefined"
url: "https://play0ad.com"
@@ -1,72 +0,0 @@
%% Copyright (C) 2018 Wildfire Games.
%% This file is part of 0 A.D.
%%
%% 0 A.D. is free software: you can redistribute it and/or modify
%% it under the terms of the GNU General Public License as published by
%% the Free Software Foundation, either version 2 of the License, or
%% (at your option) any later version.
%%
%% 0 A.D. is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU General Public License for more details.
%%
%% You should have received a copy of the GNU General Public License
%% along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
-module(mod_ipstamp).
-behaviour(gen_mod).
-include("ejabberd.hrl").
-include("logger.hrl").
-include("xmpp.hrl").
-export([start/2,
stop/1,
depends/2,
mod_opt_type/1,
reload/3,
on_filter_packet/1]).
start(_Host, _Opts) ->
ejabberd_hooks:add(filter_packet, global, ?MODULE, on_filter_packet, 50).
stop(_Host) ->
ejabberd_hooks:delete(filter_packet, global, ?MODULE, on_filter_packet, 50).
depends(_Host, _Opts) -> [].
mod_opt_type(_) -> [].
reload(_Host, _NewOpts, _OldOpts) -> ok.
-spec on_filter_packet(Input :: iq()) -> iq() | drop.
on_filter_packet(#iq{type = set, to = To, sub_els = [SubEl]} = Input) ->
% We only want to do something for the bots
case acl:match_rule(global, ipbots, To) of
allow ->
NS = xmpp:get_ns(SubEl),
if NS == <<"jabber:iq:gamelist">> ->
SCommand = fxml:get_path_s(SubEl, [{elem, <<"command">>}, cdata]),
if SCommand == <<"register">> ->
% Get the sender's IP.
Ip = xmpp:get_meta(Input, ip),
SIp = inet_parse:ntoa(Ip),
?INFO_MSG(string:concat("Inserting IP into game registration "
"stanza: ", SIp), []),
Game = fxml:get_subtag(SubEl, <<"game">>),
GameWithIp = fxml:replace_tag_attr(<<"ip">>, SIp, Game),
SubEl2 = fxml:replace_subtag(GameWithIp, SubEl),
xmpp:set_els(Input, [SubEl2]);
true ->
Input
end;
true ->
Input
end;
_ -> Input
end;
on_filter_packet(Input) ->
Input.
-3
View File
@@ -1,3 +0,0 @@
dnspython
sleekxmpp
sqlalchemy
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""setup.py for 0ad XMPP lobby bots."""
from setuptools import find_packages, setup
setup(
name='XpartaMuPP',
version='0.24',
description='Multiplayer lobby bots for 0ad',
packages=find_packages(),
entry_points={
'console_scripts': [
'echelon=xpartamupp.echelon:main',
'xpartamupp=xpartamupp.xpartamupp:main',
'echelon-db=xpartamupp.lobby_ranking:main',
]
},
install_requires=[
'dnspython',
'sleekxmpp',
'sqlalchemy',
],
tests_require=[
'coverage',
'hypothesis',
'parameterized',
],
classifiers=[
'Development Status :: 3 - Alpha',
'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Games/Entertainment',
'Topic :: Internet :: XMPP',
],
zip_safe=False,
test_suite='tests',
)
@@ -1,176 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
# pylint: disable=no-self-use
"""Tests for EcheLOn."""
import sys
from argparse import Namespace
from unittest import TestCase
from unittest.mock import Mock, call, patch
from parameterized import parameterized
from sleekxmpp.jid import JID
from sqlalchemy import create_engine
from xpartamupp.echelon import main, parse_args, Leaderboard
from xpartamupp.lobby_ranking import Base
class TestLeaderboard(TestCase):
"""Test Leaderboard functionality."""
def setUp(self):
"""Set up a leaderboard instance."""
db_url = 'sqlite://'
engine = create_engine(db_url)
Base.metadata.create_all(engine)
with patch('xpartamupp.echelon.create_engine') as create_engine_mock:
create_engine_mock.return_value = engine
self.leaderboard = Leaderboard(db_url)
def test_create_player(self):
"""Test creating a new player."""
player = self.leaderboard.get_or_create_player(JID('john@localhost'))
self.assertEqual(player.id, 1)
self.assertEqual(player.jid, 'john@localhost')
self.assertEqual(player.rating, -1)
self.assertEqual(player.highest_rating, None)
self.assertEqual(player.games, [])
self.assertEqual(player.games_info, [])
self.assertEqual(player.games_won, [])
def test_get_profile_no_player(self):
"""Test profile retrieval fro not existing player."""
profile = self.leaderboard.get_profile(JID('john@localhost'))
self.assertEqual(profile, dict())
def test_get_profile_player_without_games(self):
"""Test profile retrieval for existing player."""
self.leaderboard.get_or_create_player(JID('john@localhost'))
profile = self.leaderboard.get_profile(JID('john@localhost'))
self.assertDictEqual(profile, {'highestRating': None, 'losses': 0, 'totalGamesPlayed': 0,
'wins': 0})
class TestReportManager(TestCase):
"""Test ReportManager functionality."""
pass
class TestArgumentParsing(TestCase):
"""Test handling of parsing command line parameters."""
@parameterized.expand([
([], Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=30, xserver=None, xdisabletls=False,
nickname='RatingsBot', password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['--debug'],
Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=10, xserver=None,xdisabletls=False,
nickname='RatingsBot', password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['--quiet'],
Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=40, xserver=None,xdisabletls=False,
nickname='RatingsBot', password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['--verbose'],
Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=20, xserver=None, xdisabletls=False,
nickname='RatingsBot', password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['-m', 'lobby.domain.tld'],
Namespace(domain='lobby.domain.tld', login='EcheLOn', log_level=30, nickname='RatingsBot', xserver=None, xdisabletls=False,
password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['--domain=lobby.domain.tld'],
Namespace(domain='lobby.domain.tld', login='EcheLOn', log_level=30, nickname='RatingsBot', xserver=None, xdisabletls=False,
password='XXXXXX', room='arena',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['-m' 'lobby.domain.tld', '-l', 'bot', '-p', '123456', '-n', 'Bot', '-r', 'arena123',
'-v'],
Namespace(domain='lobby.domain.tld', login='bot', log_level=20, nickname='Bot', xserver=None, xdisabletls=False,
password='123456', room='arena123',
database_url='sqlite:///lobby_rankings.sqlite3')),
(['--domain=lobby.domain.tld', '--login=bot', '--password=123456', '--nickname=Bot',
'--room=arena123', '--database-url=sqlite:////tmp/db.sqlite3', '--verbose'],
Namespace(domain='lobby.domain.tld', login='bot', log_level=20, nickname='Bot', xserver=None, xdisabletls=False,
password='123456', room='arena123',
database_url='sqlite:////tmp/db.sqlite3')),
])
def test_valid(self, cmd_args, expected_args):
"""Test valid parameter combinations."""
self.assertEqual(parse_args(cmd_args), expected_args)
@parameterized.expand([
(['-f'],),
(['--foo'],),
(['--debug', '--quiet'],),
(['--quiet', '--verbose'],),
(['--debug', '--verbose'],),
(['--debug', '--quiet', '--verbose'],),
])
def test_invalid(self, cmd_args):
"""Test invalid parameter combinations."""
with self.assertRaises(SystemExit):
parse_args(cmd_args)
class TestMain(TestCase):
"""Test main method."""
def test_success(self):
"""Test successful execution."""
with patch('xpartamupp.echelon.parse_args') as args_mock, \
patch('xpartamupp.echelon.Leaderboard') as leaderboard_mock, \
patch('xpartamupp.echelon.EcheLOn') as xmpp_mock:
args_mock.return_value = Mock(log_level=30, login='EcheLOn',
domain='lobby.wildfiregames.com', password='XXXXXX',
room='arena', nickname='RatingsBot',
database_url='sqlite:///lobby_rankings.sqlite3',
xserver=None, xdisabletls=False)
main()
args_mock.assert_called_once_with(sys.argv[1:])
leaderboard_mock.assert_called_once_with('sqlite:///lobby_rankings.sqlite3')
xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'),
call('xep_0045'), call('xep_0060'),
call('xep_0199', {'keepalive': True})],
any_order=True)
xmpp_mock().connect.assert_called_once_with(None, True, True)
xmpp_mock().process.assert_called_once_with()
def test_failing_connect(self):
"""Test failing connect to XMPP server."""
with patch('xpartamupp.echelon.parse_args') as args_mock, \
patch('xpartamupp.echelon.Leaderboard') as leaderboard_mock, \
patch('xpartamupp.echelon.EcheLOn') as xmpp_mock:
args_mock.return_value = Mock(log_level=30, login='EcheLOn',
domain='lobby.wildfiregames.com', password='XXXXXX',
room='arena', nickname='RatingsBot',
database_url='sqlite:///lobby_rankings.sqlite3',
xserver=None, xdisabletls=False)
xmpp_mock().connect.return_value = False
main()
args_mock.assert_called_once_with(sys.argv[1:])
leaderboard_mock.assert_called_once_with('sqlite:///lobby_rankings.sqlite3')
xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'),
call('xep_0045'), call('xep_0060'),
call('xep_0199', {'keepalive': True})],
any_order=True)
xmpp_mock().connect.assert_called_once_with(None, True, True)
xmpp_mock().process.assert_not_called()
-150
View File
@@ -1,150 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""Tests for the ELO-implementation."""
from unittest import TestCase
from hypothesis import assume, example, given
from hypothesis import strategies as st
from parameterized import parameterized
from xpartamupp.elo import (get_rating_adjustment, ANTI_INFLATION, ELO_K_FACTOR_CONSTANT_RATING,
ELO_SURE_WIN_DIFFERENCE, VOLATILITY_CONSTANT)
class TestELO(TestCase):
"""Test behavior of ELO calculation."""
@parameterized.expand([
([1000, 1000, 0, 0, 1], 82),
([1000, 1000, 0, 0, -1], -83),
([1000, 1000, 0, 0, 0], 0),
([1200, 1200, 0, 0, 1], 78),
([1200, 1200, 0, 0, -1], -78),
([1200, 1200, 0, 0, 0], 0),
([1200, 1200, 1, 0, 1], 65),
([1200, 1200, 1, 0, 0], 0),
([1200, 1200, 1, 0, -1], -65),
([1200, 1200, 100, 0, 1], 16),
([1200, 1200, 100, 0, 0], 0),
([1200, 1200, 100, 0, -1], -16),
([1200, 1200, 1000, 0, 1], 16),
([1200, 1200, 1000, 0, 0], 0),
([1200, 1200, 1000, 0, -1], -16),
([1200, 1200, 0, 1, 1], 78),
([1200, 1200, 0, 1, 0], 0),
([1200, 1200, 0, 1, -1], -78),
([1200, 1200, 0, 100, 1], 78),
([1200, 1200, 0, 100, 0], 0),
([1200, 1200, 0, 100, -1], -78),
([1200, 1200, 0, 1000, 1], 78),
([1200, 1200, 0, 1000, 0], 0),
([1200, 1200, 0, 1000, -1], -78),
([1400, 1000, 0, 0, 1], 24),
([1400, 1000, 0, 0, 0], -49),
([1400, 1000, 0, 0, -1], -122),
([1000, 1400, 0, 0, 1], 137),
([1000, 1400, 0, 0, 0], 55),
([1000, 1400, 0, 0, -1], -28),
([2200, 2300, 0, 0, 1], 70),
([2200, 2300, 0, 0, 0], 10),
([2200, 2300, 0, 0, -1], -50),
])
def test_valid_adjustments(self, args, expected_adjustment):
"""Test correctness of valid rating adjustments."""
self.assertEqual(get_rating_adjustment(*args), expected_adjustment)
@given(st.integers(min_value=ELO_K_FACTOR_CONSTANT_RATING),
st.integers(min_value=-2099, max_value=ELO_SURE_WIN_DIFFERENCE - 1), st.integers(),
st.integers(),
st.integers(min_value=-1, max_value=1))
@example(ELO_K_FACTOR_CONSTANT_RATING + 300, 0, 0, 0, 1)
def test_constant_rating(self, rating_player1, difference_player2, played_games_player1,
played_games_player2, result):
"""Test that points gained are constant above a threshold."""
volatility = 50.0 * (min(max(0, played_games_player1), VOLATILITY_CONSTANT) /
VOLATILITY_CONSTANT + 0.25) / 1.25
rating_adjustment = (difference_player2 + result * ELO_SURE_WIN_DIFFERENCE) / volatility \
- ANTI_INFLATION
if result == 1:
expected_adjustment = max(0.0, rating_adjustment)
elif result == -1:
expected_adjustment = min(0.0, rating_adjustment)
else:
expected_adjustment = rating_adjustment
self.assertEqual(get_rating_adjustment(rating_player1, rating_player1 + difference_player2,
played_games_player1, played_games_player2, result),
round(expected_adjustment))
@given(st.data())
def test_sure_win(self, data):
"""Test behavior if winning player 1 has >600 points more.
In this case the winning player shouldn't gain points, as it
was a "sure win" and the loosing player shouldn't loose
points.
"""
rating_player1 = data.draw(st.integers(min_value=-1599))
difference_player2 = data.draw(st.integers(min_value=ELO_SURE_WIN_DIFFERENCE))
assume(rating_player1 - difference_player2 > -2200)
played_games_player1 = data.draw(st.integers())
played_games_player2 = data.draw(st.integers())
self.assertEqual(get_rating_adjustment(rating_player1,
rating_player1 - difference_player2,
played_games_player1, played_games_player2, 1),
0)
self.assertEqual(get_rating_adjustment(rating_player1 - difference_player2,
rating_player1, played_games_player2,
played_games_player1, -1), 0)
@given(st.integers(min_value=-2199), st.integers(min_value=ELO_SURE_WIN_DIFFERENCE),
st.integers(),
st.integers())
@example(1000, ELO_SURE_WIN_DIFFERENCE, 0, 0)
def test_sure_loss(self, rating_player1, difference_player2, played_games_player1,
played_games_player2):
"""Test behavior if winning player 2 has >600 points more.
In this case the winning player shouldn't gain points, as it
was a "sure win" and the loosing player shouldn't loose
points.
"""
self.assertEqual(get_rating_adjustment(rating_player1,
rating_player1 - difference_player2 * -1,
played_games_player1, played_games_player2, -1),
0)
self.assertEqual(get_rating_adjustment(rating_player1 - difference_player2 * -1,
rating_player1, played_games_player2,
played_games_player1, 1), 0)
@given(st.integers(max_value=-2200), st.integers(),
st.integers(),
st.integers(),
st.one_of(st.just(1), st.just(-1)))
@example(-2200, 2000, 0, 0, 1)
@example(2000, -2200, 0, 0, 1)
def test_minus_2200_bug_workaround(self, rating_player1, rating_player2,
played_games_player1, played_games_player2, result):
"""Test workaround for -2200 bug."""
with self.assertRaises(ValueError):
get_rating_adjustment(rating_player1, rating_player2, played_games_player1,
played_games_player2, result)
with self.assertRaises(ValueError):
get_rating_adjustment(rating_player2, rating_player1, played_games_player1,
played_games_player2, result)
@@ -1,70 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
# pylint: disable=no-self-use
"""Tests for the database schema."""
import sys
from argparse import Namespace
from unittest import TestCase
from unittest.mock import Mock, patch
from parameterized import parameterized
from xpartamupp.lobby_ranking import main, parse_args
class TestArgumentParsing(TestCase):
"""Test handling of parsing command line parameters."""
@parameterized.expand([
(['create'], Namespace(action='create', database_url='sqlite:///lobby_rankings.sqlite3')),
(['--database-url', 'sqlite:////tmp/db.sqlite3', 'create'],
Namespace(action='create', database_url='sqlite:////tmp/db.sqlite3')),
])
def test_valid(self, cmd_args, expected_args):
"""Test valid parameter combinations."""
self.assertEqual(parse_args(cmd_args), expected_args)
@parameterized.expand([
([],),
(['--database-url=sqlite:////tmp/db.sqlite3'],),
])
def test_missing_action(self, cmd_args):
"""Test invalid parameter combinations."""
with self.assertRaises(SystemExit):
parse_args(cmd_args)
class TestMain(TestCase):
"""Test main method."""
def test_success(self):
"""Test successful execution."""
with patch('xpartamupp.lobby_ranking.parse_args') as args_mock, \
patch('xpartamupp.lobby_ranking.create_engine') as create_engine_mock, \
patch('xpartamupp.lobby_ranking.Base') as declarative_base_mock:
args_mock.return_value = Mock(action='create',
database_url='sqlite:///lobby_rankings.sqlite3')
engine_mock = Mock()
create_engine_mock.return_value = engine_mock
main()
args_mock.assert_called_once_with(sys.argv[1:])
create_engine_mock.assert_called_once_with(
'sqlite:///lobby_rankings.sqlite3')
declarative_base_mock.metadata.create_all.assert_any_call(engine_mock)
@@ -1,45 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""Tests for utility functions."""
from unittest import TestCase
from hypothesis import given
from hypothesis import strategies as st
from xpartamupp.utils import LimitedSizeDict
class TestLimitedSizeDict(TestCase):
"""Test limited size dict."""
@given(st.integers(min_value=2, max_value=2**10))
def test_max_items(self, size_limit):
"""Test max items of dicts.
Test that the dict doesn't grow indefinitely and that the
oldest entries are removed first.
"""
test_dict = LimitedSizeDict(size_limit=size_limit)
for i in range(size_limit):
test_dict[i] = i
self.assertEqual(size_limit, len(test_dict))
test_dict[size_limit + 1] = size_limit + 1
self.assertEqual(size_limit, len(test_dict))
self.assertFalse(0 in test_dict.values())
self.assertTrue(1 in test_dict.values())
self.assertTrue(size_limit + 1 in test_dict.values())
@@ -1,179 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
# pylint: disable=no-self-use
"""Tests for XPartaMuPP."""
import sys
from argparse import Namespace
from unittest import TestCase
from unittest.mock import Mock, call, patch
from parameterized import parameterized
from sleekxmpp.jid import JID
from xpartamupp.xpartamupp import Games, main, parse_args
class TestGames(TestCase):
"""Test Games class responsible for holding active games."""
def test_add(self):
"""Test successfully adding a game."""
games = Games()
jid = JID(jid='player1@domain.tld')
# TODO: Check how the real format of data looks like
game_data = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'}
self.assertTrue(games.add_game(jid, game_data))
all_games = games.get_all_games()
game_data.update({'players-init': game_data['players'], 'nbp-init': game_data['nbp'],
'state': game_data['state']})
self.assertDictEqual(all_games, {jid: game_data})
@parameterized.expand([
('', {}),
('player1@domain.tld', {}),
('player1@domain.tld', None),
('player1@domain.tld', ''),
])
def test_add_invalid(self, jid, game_data):
"""Test trying to add games with invalid data."""
games = Games()
self.assertFalse(games.add_game(jid, game_data))
def test_remove(self):
"""Test removal of games."""
games = Games()
jid1 = JID(jid='player1@domain.tld')
jid2 = JID(jid='player3@domain.tld')
# TODO: Check how the real format of data looks like
game_data1 = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'}
games.add_game(jid1, game_data1)
game_data2 = {'players': ['player3', 'player4'], 'nbp': 'bar', 'state': 'init'}
games.add_game(jid2, game_data2)
game_data1.update({'players-init': game_data1['players'], 'nbp-init': game_data1['nbp'],
'state': game_data1['state']})
game_data2.update({'players-init': game_data2['players'], 'nbp-init': game_data2['nbp'],
'state': game_data2['state']})
self.assertDictEqual(games.get_all_games(), {jid1: game_data1, jid2: game_data2})
games.remove_game(jid1)
self.assertDictEqual(games.get_all_games(), {jid2: game_data2})
games.remove_game(jid2)
self.assertDictEqual(games.get_all_games(), dict())
def test_remove_unknown(self):
"""Test removal of a game, which doesn't exist."""
games = Games()
jid = JID(jid='player1@domain.tld')
# TODO: Check how the real format of data looks like
game_data = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'}
games.add_game(jid, game_data)
self.assertFalse(games.remove_game(JID('foo@bar.tld')))
def test_change_state(self):
"""Test state changes of a games."""
pass
# slightly unknown how to do that properly, as some data structures aren't known
class TestArgumentParsing(TestCase):
"""Test handling of parsing command line parameters."""
@parameterized.expand([
([], Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=30, xserver=None, xdisabletls=False,
nickname='WFGBot', password='XXXXXX', room='arena')),
(['--debug'],
Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=10, xserver=None, xdisabletls=False,
nickname='WFGBot', password='XXXXXX', room='arena')),
(['--quiet'],
Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=40, xserver=None, xdisabletls=False,
nickname='WFGBot', password='XXXXXX', room='arena')),
(['--verbose'],
Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=20, xserver=None, xdisabletls=False,
nickname='WFGBot', password='XXXXXX', room='arena')),
(['-m', 'lobby.domain.tld'],
Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', xserver=None, xdisabletls=False,
password='XXXXXX', room='arena')),
(['--domain=lobby.domain.tld'],
Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', xserver=None, xdisabletls=False,
password='XXXXXX', room='arena')),
(['-m' 'lobby.domain.tld', '-l', 'bot', '-p', '123456', '-n', 'Bot', '-r', 'arena123',
'-v'],
Namespace(domain='lobby.domain.tld', login='bot', log_level=20, xserver=None, xdisabletls=False,
nickname='Bot', password='123456', room='arena123')),
(['--domain=lobby.domain.tld', '--login=bot', '--password=123456', '--nickname=Bot',
'--room=arena123', '--verbose'],
Namespace(domain='lobby.domain.tld', login='bot', log_level=20, xserver=None, xdisabletls=False,
nickname='Bot', password='123456', room='arena123')),
])
def test_valid(self, cmd_args, expected_args):
"""Test valid parameter combinations."""
self.assertEqual(parse_args(cmd_args), expected_args)
@parameterized.expand([
(['-f'],),
(['--foo'],),
(['--debug', '--quiet'],),
(['--quiet', '--verbose'],),
(['--debug', '--verbose'],),
(['--debug', '--quiet', '--verbose'],),
])
def test_invalid(self, cmd_args):
"""Test invalid parameter combinations."""
with self.assertRaises(SystemExit):
parse_args(cmd_args)
class TestMain(TestCase):
"""Test main method."""
def test_success(self):
"""Test successful execution."""
with patch('xpartamupp.xpartamupp.parse_args') as args_mock, \
patch('xpartamupp.xpartamupp.XpartaMuPP') as xmpp_mock:
args_mock.return_value = Mock(log_level=30, login='xpartamupp',
domain='lobby.wildfiregames.com', password='XXXXXX',
room='arena', nickname='WFGBot',
xserver=None, xdisabletls=False)
main()
args_mock.assert_called_once_with(sys.argv[1:])
xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'),
call('xep_0045'), call('xep_0060'),
call('xep_0199', {'keepalive': True})],
any_order=True)
xmpp_mock().connect.assert_called_once_with(None, True, True)
xmpp_mock().process.assert_called_once_with()
def test_failing_connect(self):
"""Test failing connect to XMPP server."""
with patch('xpartamupp.xpartamupp.parse_args') as args_mock, \
patch('xpartamupp.xpartamupp.XpartaMuPP') as xmpp_mock:
args_mock.return_value = Mock(log_level=30, login='xpartamupp',
domain='lobby.wildfiregames.com', password='XXXXXX',
room='arena', nickname='WFGBot',
xserver=None, xdisabletls=False)
xmpp_mock().connect.return_value = False
main()
args_mock.assert_called_once_with(sys.argv[1:])
xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'),
call('xep_0045'), call('xep_0060'),
call('xep_0199', {'keepalive': True})],
any_order=True)
xmpp_mock().connect.assert_called_once_with(None, True, True)
xmpp_mock().process.assert_not_called()
@@ -1,803 +0,0 @@
#!/usr/bin/env python3
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""0ad XMPP-bot responsible for managing game ratings."""
import argparse
import difflib
import logging
import sys
from collections import deque
import sleekxmpp
from sleekxmpp.stanza import Iq
from sleekxmpp.xmlstream.handler import Callback
from sleekxmpp.xmlstream.matcher import StanzaPath
from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin
from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker
from xpartamupp.elo import get_rating_adjustment
from xpartamupp.lobby_ranking import Game, Player, PlayerInfo
from xpartamupp.stanzas import (BoardListXmppPlugin, GameReportXmppPlugin, ProfileXmppPlugin)
from xpartamupp.utils import LimitedSizeDict
# Rating that new players should be inserted into the
# database with, before they've played any games.
LEADERBOARD_DEFAULT_RATING = 1200
class Leaderboard(object):
"""Class that provides and manages leaderboard data."""
def __init__(self, db_url):
"""Initialize the leaderboard."""
self.rating_messages = deque()
engine = create_engine(db_url)
session_factory = sessionmaker(bind=engine)
self.db = scoped_session(session_factory)
def get_or_create_player(self, jid):
"""Get a player from the leaderboard database.
Get player information from the leaderboard database and
create him first, if he doesn't exist yet.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player to get
Returns:
Player instance representing the player specified by the
supplied JID
"""
player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first()
if player:
return player
player = Player(jid=str(jid), rating=-1)
self.db.add(player)
self.db.commit()
logging.debug("Created player %s", jid)
return player
def get_profile(self, jid):
"""Get the leaderboard profile for the specified player.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player to retrieve the
profile for
Returns:
dict with statistics about the requested player or None if
the player isn't known
"""
stats = {}
player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first()
if not player:
logging.debug("Couldn't find profile for player %s", jid)
return {}
if player.rating != -1:
stats['rating'] = player.rating
rank = self.db.query(Player).filter(Player.rating >= player.rating).count()
stats['rank'] = rank
if player.highest_rating != -1:
stats['highestRating'] = player.highest_rating
games_played = self.db.query(PlayerInfo).filter_by(player_id=player.id).count()
wins = self.db.query(Game).filter_by(winner_id=player.id).count()
stats['totalGamesPlayed'] = games_played
stats['wins'] = wins
stats['losses'] = games_played - wins
return stats
def _add_game(self, game_report): # pylint: disable=too-many-locals
"""Add a game to the database.
Add a game to the database and update the data on a
player from game results.
Arguments:
game_report (dict): a report about a game
Returns:
Game object for the created game or None if the creation
failed for any reason.
"""
# Discard any games still in progress. We shouldn't get
# reports from those games anyway.
if 'active' in dict.values(game_report['playerStates']):
logging.warning("Received a game report for an unfinished game")
return None
players = self.db.query(Player).filter(func.lower(Player.jid).in_(
dict.keys(game_report['playerStates'])))
winning_jid = [jid for jid, state in game_report['playerStates'].items()
if state == 'won'][0]
# single_stats = {'timeElapsed', 'mapName', 'teamsLocked', 'matchID'}
total_score_stats = {'economyScore', 'militaryScore', 'totalScore'}
resource_stats = {'foodGathered', 'foodUsed', 'woodGathered', 'woodUsed', 'stoneGathered',
'stoneUsed', 'metalGathered', 'metalUsed', 'vegetarianFoodGathered',
'treasuresCollected', 'lootCollected', 'tributesSent',
'tributesReceived'}
units_stats = {'totalUnitsTrained', 'totalUnitsLost', 'enemytotalUnitsKilled',
'infantryUnitsTrained', 'infantryUnitsLost', 'enemyInfantryUnitsKilled',
'workerUnitsTrained', 'workerUnitsLost', 'enemyWorkerUnitsKilled',
'femaleCitizenUnitsTrained', 'femaleCitizenUnitsLost',
'enemyFemaleCitizenUnitsKilled', 'cavalryUnitsTrained', 'cavalryUnitsLost',
'enemyCavalryUnitsKilled', 'championUnitsTrained', 'championUnitsLost',
'enemyChampionUnitsKilled', 'heroUnitsTrained', 'heroUnitsLost',
'enemyHeroUnitsKilled', 'shipUnitsTrained', 'shipUnitsLost',
'enemyShipUnitsKilled', 'traderUnitsTrained', 'traderUnitsLost',
'enemyTraderUnitsKilled'}
buildings_stats = {'totalBuildingsConstructed', 'totalBuildingsLost',
'enemytotalBuildingsDestroyed', 'civCentreBuildingsConstructed',
'civCentreBuildingsLost', 'enemyCivCentreBuildingsDestroyed',
'houseBuildingsConstructed', 'houseBuildingsLost',
'enemyHouseBuildingsDestroyed', 'economicBuildingsConstructed',
'economicBuildingsLost', 'enemyEconomicBuildingsDestroyed',
'outpostBuildingsConstructed', 'outpostBuildingsLost',
'enemyOutpostBuildingsDestroyed', 'militaryBuildingsConstructed',
'militaryBuildingsLost', 'enemyMilitaryBuildingsDestroyed',
'fortressBuildingsConstructed', 'fortressBuildingsLost',
'enemyFortressBuildingsDestroyed', 'wonderBuildingsConstructed',
'wonderBuildingsLost', 'enemyWonderBuildingsDestroyed'}
market_stats = {'woodBought', 'foodBought', 'stoneBought', 'metalBought', 'tradeIncome'}
misc_stats = {'civs', 'teams', 'percentMapExplored'}
stats = total_score_stats | resource_stats | units_stats | buildings_stats | market_stats \
| misc_stats
player_infos = []
for player in players:
player_jid = sleekxmpp.jid.JID(player.jid)
player_info = PlayerInfo(player=player)
for report_name in stats:
setattr(player_info, report_name, game_report[report_name][player_jid])
player_infos.append(player_info)
game = Game(map=game_report['mapName'], duration=int(game_report['timeElapsed']),
teamsLocked=bool(game_report['teamsLocked']), matchID=game_report['matchID'])
game.player_info.extend(player_infos)
game.winner = self.db.query(Player).filter(Player.jid.ilike(str(winning_jid))).first()
self.db.add(game)
self.db.commit()
return game
@staticmethod
def _verify_game(game_report):
"""Check whether or not the game should be rated.
The criteria for rated games can be specified here.
Arguments:
game_report (dict): a report about a game
Returns:
True if the game should be rated, false otherwise.
"""
winning_jids = [jid for jid, state in game_report['playerStates'].items()
if state == 'won']
# We only support 1v1s right now.
if len(winning_jids) > 1 or len(dict.keys(game_report['playerStates'])) != 2:
return False
return True
def _rate_game(self, game):
"""Update player ratings based on game outcome.
Take a game with 2 players and alters their ratings based on
the result of the game.
Adjusts the players ratings in the database.
Arguments:
game (Game): game to rate
"""
player1 = game.players[0]
player2 = game.players[1]
# Since it's impossible to draw in the game currently, the
# database model, and therefore this code, requires a winner.
# The Elo implementation does not, however.
result = 1 if player1 == game.winner else -1
# Player's ratings are -1 unless they have played a rated game.
if player1.rating == -1:
player1.rating = LEADERBOARD_DEFAULT_RATING
if player2.rating == -1:
player2.rating = LEADERBOARD_DEFAULT_RATING
try:
rating_adjustment1 = int(get_rating_adjustment(player1.rating, player2.rating,
len(player1.games), len(player2.games),
result))
rating_adjustment2 = int(get_rating_adjustment(player2.rating, player1.rating,
len(player2.games), len(player1.games),
result * -1))
except ValueError:
rating_adjustment1 = 0
rating_adjustment2 = 0
if result == 1:
result_qualitative = 'won'
elif result == 0:
result_qualitative = 'drew'
else:
result_qualitative = 'lost'
name1 = sleekxmpp.jid.JID(player1.jid).local
name2 = sleekxmpp.jid.JID(player2.jid).local
self.rating_messages.append("A rated game has ended. %s %s against %s. Rating "
"Adjustment: %s (%s -> %s) and %s (%s -> %s)." %
(name1, result_qualitative, name2, name1, player1.rating,
player1.rating + rating_adjustment1, name2, player2.rating,
player2.rating + rating_adjustment2))
player1.rating += rating_adjustment1
player2.rating += rating_adjustment2
if not player1.highest_rating:
player1.highest_rating = -1
if not player2.highest_rating:
player2.highest_rating = -1
player1.highest_rating = max(player1.rating, player1.highest_rating)
player2.highest_rating = max(player2.rating, player2.highest_rating)
self.db.commit()
def get_rating_messages(self):
"""Get messages announcing rated games.
Returns:
list with the a messages about rated games
"""
return self.rating_messages
def add_and_rate_game(self, game_report):
"""Add and rate a game.
If the game has only two players, rate the game.
Arguments:
game_report (dict): a report about a game
Returns:
Game object
"""
game = self._add_game(game_report)
if game and self._verify_game(game_report):
self._rate_game(game)
return game
def get_board(self, limit=100):
"""Return the ratings of the highest ranked players.
Arguments:
limit (int): Number of players to return
Returns:
dict with player JIDs, nicks and ratings
"""
ratings = {}
players = self.db.query(Player).filter(Player.rating != -1) \
.order_by(Player.rating.desc()).limit(limit)
for player in players:
ratings[player.jid] = {'name': sleekxmpp.jid.JID(player.jid).local,
'rating': player.rating}
return ratings
def get_rating_list(self, nicks):
"""Return the ratings of all online players.
The returned dictionary is by nick because the client can't
link JID to nick conveniently.
Arguments:
nicks (dict): Players currently online
Returns:
dict with player JIDs, nicks and ratings
"""
ratings = {}
if nicks:
player_filter = func.lower(Player.jid).in_([str(jid).lower() for jid in list(nicks)])
players = self.db.query(Player.jid, Player.rating).filter(player_filter)
for player in players:
rating = str(player.rating) if player.rating != -1 else ''
for jid in list(nicks):
if jid == sleekxmpp.jid.JID(player.jid):
ratings[nicks[str(jid)]] = {'name': nicks[jid], 'rating': rating}
break
return ratings
class ReportManager(object):
"""Class which manages different game reports from clients.
Calls leaderboard functions as appropriate.
"""
def __init__(self, leaderboard):
"""Initialize the report manager.
Arguments:
leaderboard (Leaderboard): Leaderboard the manager is for
"""
self.leaderboard = leaderboard
self.interim_report_tracker = LimitedSizeDict(size_limit=2**12)
def add_report(self, jid, raw_game_report):
"""Add a game to the interface between a raw report and the leaderboard database.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player who submitted
the report
raw_game_report (dict): Game report generated by 0ad
"""
player_index = int(raw_game_report['playerID']) - 1
del raw_game_report['playerID']
match_id = raw_game_report['matchID']
if match_id not in self.interim_report_tracker:
self.interim_report_tracker[match_id] = {
'report': raw_game_report,
'jids': {player_index: str(jid)}
}
else:
current_match = self.interim_report_tracker[match_id]
if raw_game_report != current_match['report']:
report_diff = self._get_report_diff(raw_game_report, current_match['report'])
logging.warning("Retrieved reports for match %s differ:\n %s", match_id,
report_diff)
return
player_jids = current_match['jids']
if player_index in player_jids:
if player_jids[player_index] == jid:
logging.warning("Received a report for match %s from player %s twice.",
match_id, jid)
else:
logging.warning("Retrieved a report for match %s for the same player twice, "
"but from two different XMPP accounts: %s vs. %s", match_id,
player_jids[player_index], jid)
return
else:
player_jids[player_index] = str(jid)
num_players = self._get_num_players(raw_game_report)
num_retrieved_reports = len(player_jids)
if num_retrieved_reports == num_players:
try:
self.leaderboard.add_and_rate_game(self._expand_report(
current_match))
except Exception:
logging.exception("Failed to add and rate a game.")
del current_match
elif num_retrieved_reports < num_players:
logging.warning("Haven't received all reports for the game yet. %i/%i",
num_retrieved_reports, num_players)
elif num_retrieved_reports > num_players:
logging.warning("Retrieved more reports than players. This shouldn't happen.")
@staticmethod
def _expand_report(game_report):
"""Re-formats a game report into Python data structures.
Player specific values from the report are replaced with a
dict where the JID of the player is the key.
Arguments:
game_report (dict): wrapped game report from 0ad
Returns a processed gameReport of type dict.
"""
processed_game_report = {}
for key, value in game_report['report'].items():
if ',' not in value:
processed_game_report[key] = value
else:
stat_to_jid = {}
for i, part in enumerate(game_report['report'][key].split(",")[:-1]):
stat_to_jid[game_report['jids'][i]] = part
processed_game_report[key] = stat_to_jid
return processed_game_report
@staticmethod
def _get_num_players(raw_game_report):
"""Compute the number of players from a raw game report.
Get the number of players who played a game from the
playerStates field in a raw game report.
Arguments:
raw_game_report (dict): Game report generated by 0ad
Returns:
int with the number of players in the game
Raises:
ValueError if the number of players couldn't be determined
"""
if 'playerStates' in raw_game_report and ',' in raw_game_report['playerStates']:
return len(list(filter(None, raw_game_report['playerStates'].split(","))))
raise ValueError()
@staticmethod
def _get_report_diff(report1, report2):
"""Get differences between two reports.
Arguments:
report1 (dict): Game report
report2 (dict): Game report
Returns:
str with a textual representation of the differences
between the two reports
"""
report1_list = ['{ %s: %s }' % (key, value) for key, value in report1.items()]
report2_list = ['{ %s: %s }' % (key, value) for key, value in report2.items()]
return '\n'.join(difflib.ndiff(report1_list, report2_list))
class EcheLOn(sleekxmpp.ClientXMPP):
"""Main class which handles IQ data and sends new data."""
def __init__(self, sjid, password, room, nick, leaderboard):
"""Initialize EcheLOn."""
sleekxmpp.ClientXMPP.__init__(self, sjid, password)
self.whitespace_keepalive = False
self.sjid = sleekxmpp.jid.JID(sjid)
self.room = room
self.nick = nick
self.leaderboard = leaderboard
self.report_manager = ReportManager(self.leaderboard)
register_stanza_plugin(Iq, BoardListXmppPlugin)
register_stanza_plugin(Iq, GameReportXmppPlugin)
register_stanza_plugin(Iq, ProfileXmppPlugin)
self.register_handler(Callback('Iq Boardlist', StanzaPath('iq@type=get/boardlist'),
self._iq_board_list_handler))
self.register_handler(Callback('Iq GameReport', StanzaPath('iq@type=set/gamereport'),
self._iq_game_report_handler))
self.register_handler(Callback('Iq Profile', StanzaPath('iq@type=get/profile'),
self._iq_profile_handler))
self.add_event_handler('session_start', self._session_start)
self.add_event_handler('muc::%s::got_online' % self.room, self._muc_online)
self.add_event_handler('muc::%s::got_offline' % self.room, self._muc_offline)
self.add_event_handler('groupchat_message', self._muc_message)
def _session_start(self, event): # pylint: disable=unused-argument
"""Join MUC channel and announce presence.
Arguments:
event (dict): empty dummy dict
"""
self.plugin['xep_0045'].joinMUC(self.room, self.nick)
self.send_presence()
self.get_roster()
logging.info("EcheLOn started")
def _muc_online(self, presence):
"""Add joining players to the list of players.
Arguments:
presence (sleekxmpp.stanza.presence.Presence): Received
presence stanza.
"""
nick = str(presence['muc']['nick'])
jid = sleekxmpp.jid.JID(presence['muc']['jid'])
if nick == self.nick:
return
if jid.resource != '0ad':
return
self.leaderboard.get_or_create_player(jid)
self._broadcast_rating_list()
logging.debug("Client '%s' connected with a nick of '%s'.", jid, nick)
def _muc_offline(self, presence):
"""Remove leaving players from the list of players.
Arguments:
presence (sleekxmpp.stanza.presence.Presence): Received
presence stanza.
"""
nick = str(presence['muc']['nick'])
jid = sleekxmpp.jid.JID(presence['muc']['jid'])
if nick == self.nick:
return
logging.debug("Client '%s' with nick '%s' disconnected", jid, nick)
def _muc_message(self, msg):
"""Process messages in the MUC room.
Respond to messages highlighting the bots name with an
informative message.
Arguments:
msg (sleekxmpp.stanza.message.Message): Received MUC
message
"""
if msg['mucnick'] != self.nick and self.nick.lower() in msg['body'].lower():
self.send_message(mto=msg['from'].bare,
mbody="I am just a bot and provide the rating functionality for "
"this lobby. Please don't disturb me, calculating these "
"ratings is already difficult enough.",
mtype='groupchat')
def _iq_board_list_handler(self, iq):
"""Handle incoming leaderboard list requests.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza
"""
if iq['from'].resource not in ['0ad']:
return
command = iq['boardlist']['command']
self.leaderboard.get_or_create_player(iq['from'])
if command == 'getleaderboard':
try:
self._send_leaderboard(iq)
except Exception:
logging.exception("Failed to process get leaderboard request from %s",
iq['from'].bare)
elif command == 'getratinglist':
try:
self._send_rating_list(iq)
except Exception:
logging.exception("Failed to send the rating list to %s", iq['from'])
def _iq_game_report_handler(self, iq):
"""Handle end of game reports from clients.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza
"""
if iq['from'].resource not in ['0ad']:
return
try:
self.report_manager.add_report(iq['from'], iq['gamereport']['game'])
except Exception:
logging.exception("Failed to update game statistics for %s", iq['from'].bare)
rating_messages = self.leaderboard.get_rating_messages()
if rating_messages:
while rating_messages:
message = rating_messages.popleft()
self.send_message(mto=self.room, mbody=message, mtype='groupchat', mnick=self.nick)
self._broadcast_rating_list()
def _iq_profile_handler(self, iq):
"""Handle profile requests from clients.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza
"""
if iq['from'].resource not in ['0ad']:
return
try:
self._send_profile(iq, iq['profile']['command'])
except Exception:
logging.exception("Failed to send profile about %s to %s", iq['profile']['command'],
iq['from'].bare)
def _send_leaderboard(self, iq):
"""Send the whole leaderboard.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to
"""
ratings = self.leaderboard.get_board()
iq = iq.reply(clear=True)
stanza = BoardListXmppPlugin()
stanza.add_command('boardlist')
for player in ratings.values():
stanza.add_item(player['name'], player['rating'])
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send leaderboard to %s", iq['to'])
def _send_rating_list(self, iq):
"""Send the ratings of all online players.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to
"""
nicks = {}
for nick in self.plugin['xep_0045'].getRoster(self.room):
if nick == self.nick:
continue
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid')
jid = sleekxmpp.jid.JID(jid_str)
nicks[jid] = nick
ratings = self.leaderboard.get_rating_list(nicks)
iq = iq.reply(clear=True)
stanza = BoardListXmppPlugin()
stanza.add_command('ratinglist')
for player in ratings.values():
stanza.add_item(player['name'], player['rating'])
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send rating list to %s", iq['to'])
def _broadcast_rating_list(self):
"""Broadcast the ratings of all online players."""
nicks = {}
for nick in self.plugin['xep_0045'].getRoster(self.room):
if nick == self.nick:
continue
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid')
jid = sleekxmpp.jid.JID(jid_str)
nicks[jid] = nick
ratings = self.leaderboard.get_rating_list(nicks)
stanza = BoardListXmppPlugin()
stanza.add_command('ratinglist')
for player in ratings.values():
stanza.add_item(player['name'], player['rating'])
for jid in nicks:
iq = self.make_iq_result(ito=jid)
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send rating list to %s", jid)
def _send_profile(self, iq, player_nick):
"""Send the player profile to a specified target.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to
player_nick (str): The nick of the player to get the
profile for
"""
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, player_nick, 'jid')
player_jid = sleekxmpp.jid.JID(jid_str) if jid_str else None
# The player the profile got requested for is not online, so
# let's assume the JID contains the nick as local part.
if not player_jid:
player_jid = sleekxmpp.jid.JID('%s@%s/%s' % (player_nick, self.sjid.domain, '0ad'))
try:
stats = self.leaderboard.get_profile(player_jid)
except Exception:
logging.exception("Failed to get leaderboard profile for player %s", player_jid)
stats = {}
iq = iq.reply(clear=True)
stanza = ProfileXmppPlugin()
if stats:
stanza.add_item(player_nick, stats['rating'], stats['highestRating'],
stats['rank'], stats['totalGamesPlayed'], stats['wins'],
stats['losses'])
else:
stanza.add_item(player_nick, -2)
stanza.add_command(player_nick)
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send profile to %s", iq['to'])
def parse_args(args):
"""Parse command line arguments.
Arguments:
args (dict): Raw command line arguments given to the script
Returns:
Parsed command line arguments
"""
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description="EcheLOn - XMPP Rating Bot")
log_settings = parser.add_mutually_exclusive_group()
log_settings.add_argument('-q', '--quiet', help="only log errors", action='store_const',
dest='log_level', const=logging.ERROR)
log_settings.add_argument('-d', '--debug', help="log debug messages", action='store_const',
dest='log_level', const=logging.DEBUG)
log_settings.add_argument('-v', '--verbose', help="log more informative messages",
action='store_const', dest='log_level', const=logging.INFO)
log_settings.set_defaults(log_level=logging.WARNING)
parser.add_argument('-m', '--domain', help="XMPP server to connect to",
default='lobby.wildfiregames.com')
parser.add_argument('-l', '--login', help="username for login", default='EcheLOn')
parser.add_argument('-p', '--password', help="password for login", default='XXXXXX')
parser.add_argument('-n', '--nickname', help="nickname shown to players", default='RatingsBot')
parser.add_argument('-r', '--room', help="XMPP MUC room to join", default='arena')
parser.add_argument('--database-url', help="URL for the leaderboard database",
default='sqlite:///lobby_rankings.sqlite3')
parser.add_argument('-s', '--server', help='address of the ejabberd server',
action='store', dest='xserver', default=None)
parser.add_argument('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption',
action='store_true', dest='xdisabletls', default=False)
return parser.parse_args(args)
def main():
"""Entry point a console script."""
args = parse_args(sys.argv[1:])
logging.basicConfig(level=args.log_level,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
leaderboard = Leaderboard(args.database_url)
xmpp = EcheLOn(sleekxmpp.jid.JID('%s@%s/%s' % (args.login, args.domain, 'CC')), args.password,
args.room + '@conference.' + args.domain, args.nickname, leaderboard)
xmpp.register_plugin('xep_0030') # Service Discovery
xmpp.register_plugin('xep_0004') # Data Forms
xmpp.register_plugin('xep_0045') # Multi-User Chat
xmpp.register_plugin('xep_0060') # Publish-Subscribe
xmpp.register_plugin('xep_0199', {'keepalive': True}) # XMPP Ping
if xmpp.connect((args.xserver, 5222) if args.xserver else None, True, not args.xdisabletls):
xmpp.process()
else:
logging.error("Unable to connect")
if __name__ == '__main__':
main()
-82
View File
@@ -1,82 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""Implementation of the ELO-rating algorithm for 0ad games."""
# Difference between two ratings such that it is regarded as a "sure
# win" for the higher player. No points are gained or lost for such a
# game.
ELO_SURE_WIN_DIFFERENCE = 600
# Lower ratings "move faster" and change more
# dramatically than higher ones. Anything rating above
# this value moves at the same rate as this value.
ELO_K_FACTOR_CONSTANT_RATING = 2200
# This preset number of games is the number of games where a player is
# considered "stable". Rating volatility is constant after this number.
VOLATILITY_CONSTANT = 20
# Fair rating adjustment loses against inflation.
# This constant will battle inflation.
# NOTE: This can be adjusted as needed by a bot/server administrator
ANTI_INFLATION = 0.015
def get_rating_adjustment(rating, opponent_rating, games_played,
opponent_games_played, result): # pylint: disable=unused-argument
"""Calculate the rating adjustment after rated 1v1 games.
The rating adjustment is calculated using a simplified
ELO-algorithm.
The given implementation doesn't work for negative ratings below
-2199. This is a known limitation which is currently considered
to be not relevant in day-to-day use.
Arguments:
rating (int): Rating of the first player before the game.
opponent_rating (int): Rating of the second player before the
game.
games_played (int): Number of games the first player has played
before this game.
opponent_games_played (int): Number of games the second player
has played before this game.
result (int): 1 if the first player won, 0 if draw or -1 if the
second player won.
Returns:
int: the adjustment which should be applied to the rating of
the first player
"""
if rating < -2199 or opponent_rating < -2199:
raise ValueError('Too small rating given: rating: %i, opponent rating: %i' %
(rating, opponent_rating))
rating_k_factor = 50.0 * (min(rating, ELO_K_FACTOR_CONSTANT_RATING) /
ELO_K_FACTOR_CONSTANT_RATING + 1.0) / 2.0
player_volatility = (min(max(0, games_played), VOLATILITY_CONSTANT) /
VOLATILITY_CONSTANT + 0.25) / 1.25
volatility = rating_k_factor * player_volatility
rating_difference = opponent_rating - rating
rating_adjustment = (rating_difference + result * ELO_SURE_WIN_DIFFERENCE) / volatility - \
ANTI_INFLATION
if result == 1:
return round(max(0.0, rating_adjustment))
elif result == -1:
return round(min(0.0, rating_adjustment))
return round(rating_adjustment)
@@ -1,175 +0,0 @@
#!/usr/bin/env python3
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""Database schema used by the XMPP bots to store game information."""
import argparse
import sys
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, create_engine
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Player(Base):
"""Model representing players."""
__tablename__ = 'players'
id = Column(Integer, primary_key=True)
jid = Column(String(255))
rating = Column(Integer)
highest_rating = Column(Integer)
games = relationship('Game', secondary='players_info')
# These two relations really only exist to satisfy the linkage
# between PlayerInfo and Player and Game and player.
games_info = relationship('PlayerInfo', backref='player')
games_won = relationship('Game', backref='winner')
class PlayerInfo(Base):
"""Model representing game results."""
__tablename__ = 'players_info'
id = Column(Integer, primary_key=True)
player_id = Column(Integer, ForeignKey('players.id'))
game_id = Column(Integer, ForeignKey('games.id'))
civs = Column(String(20))
teams = Column(Integer)
economyScore = Column(Integer)
militaryScore = Column(Integer)
totalScore = Column(Integer)
foodGathered = Column(Integer)
foodUsed = Column(Integer)
woodGathered = Column(Integer)
woodUsed = Column(Integer)
stoneGathered = Column(Integer)
stoneUsed = Column(Integer)
metalGathered = Column(Integer)
metalUsed = Column(Integer)
vegetarianFoodGathered = Column(Integer)
treasuresCollected = Column(Integer)
lootCollected = Column(Integer)
tributesSent = Column(Integer)
tributesReceived = Column(Integer)
totalUnitsTrained = Column(Integer)
totalUnitsLost = Column(Integer)
enemytotalUnitsKilled = Column(Integer)
infantryUnitsTrained = Column(Integer)
infantryUnitsLost = Column(Integer)
enemyInfantryUnitsKilled = Column(Integer)
workerUnitsTrained = Column(Integer)
workerUnitsLost = Column(Integer)
enemyWorkerUnitsKilled = Column(Integer)
femaleCitizenUnitsTrained = Column(Integer)
femaleCitizenUnitsLost = Column(Integer)
enemyFemaleCitizenUnitsKilled = Column(Integer)
cavalryUnitsTrained = Column(Integer)
cavalryUnitsLost = Column(Integer)
enemyCavalryUnitsKilled = Column(Integer)
championUnitsTrained = Column(Integer)
championUnitsLost = Column(Integer)
enemyChampionUnitsKilled = Column(Integer)
heroUnitsTrained = Column(Integer)
heroUnitsLost = Column(Integer)
enemyHeroUnitsKilled = Column(Integer)
shipUnitsTrained = Column(Integer)
shipUnitsLost = Column(Integer)
enemyShipUnitsKilled = Column(Integer)
traderUnitsTrained = Column(Integer)
traderUnitsLost = Column(Integer)
enemyTraderUnitsKilled = Column(Integer)
totalBuildingsConstructed = Column(Integer)
totalBuildingsLost = Column(Integer)
enemytotalBuildingsDestroyed = Column(Integer)
civCentreBuildingsConstructed = Column(Integer)
civCentreBuildingsLost = Column(Integer)
enemyCivCentreBuildingsDestroyed = Column(Integer)
houseBuildingsConstructed = Column(Integer)
houseBuildingsLost = Column(Integer)
enemyHouseBuildingsDestroyed = Column(Integer)
economicBuildingsConstructed = Column(Integer)
economicBuildingsLost = Column(Integer)
enemyEconomicBuildingsDestroyed = Column(Integer)
outpostBuildingsConstructed = Column(Integer)
outpostBuildingsLost = Column(Integer)
enemyOutpostBuildingsDestroyed = Column(Integer)
militaryBuildingsConstructed = Column(Integer)
militaryBuildingsLost = Column(Integer)
enemyMilitaryBuildingsDestroyed = Column(Integer)
fortressBuildingsConstructed = Column(Integer)
fortressBuildingsLost = Column(Integer)
enemyFortressBuildingsDestroyed = Column(Integer)
wonderBuildingsConstructed = Column(Integer)
wonderBuildingsLost = Column(Integer)
enemyWonderBuildingsDestroyed = Column(Integer)
woodBought = Column(Integer)
foodBought = Column(Integer)
stoneBought = Column(Integer)
metalBought = Column(Integer)
tradeIncome = Column(Integer)
percentMapExplored = Column(Integer)
class Game(Base):
"""Model representing games."""
__tablename__ = 'games'
id = Column(Integer, primary_key=True)
map = Column(String(80))
duration = Column(Integer)
teamsLocked = Column(Boolean)
matchID = Column(String(20))
winner_id = Column(Integer, ForeignKey('players.id'))
player_info = relationship('PlayerInfo', backref='game')
players = relationship('Player', secondary='players_info')
def parse_args(args):
"""Parse command line arguments.
Arguments:
args (dict): Raw command line arguments given to the script
Returns:
Parsed command line arguments
"""
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description="Helper command for database creation")
parser.add_argument('action', help='Action to apply to the database',
choices=['create'])
parser.add_argument('--database-url', help='URL for the leaderboard database',
default='sqlite:///lobby_rankings.sqlite3')
return parser.parse_args(args)
def main():
"""Entry point a console script."""
args = parse_args(sys.argv[1:])
engine = create_engine(args.database_url)
if args.action == 'create':
Base.metadata.create_all(engine)
if __name__ == '__main__':
main()
@@ -1,159 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""0ad-specific XMPP-stanzas."""
from sleekxmpp.xmlstream import ElementBase, ET
class BoardListXmppPlugin(ElementBase):
"""Class for custom boardlist and ratinglist stanza extension."""
name = 'query'
namespace = 'jabber:iq:boardlist'
interfaces = {'board', 'command'}
sub_interfaces = interfaces
plugin_attrib = 'boardlist'
def add_command(self, command):
"""Add a command to the extension.
Arguments:
command (str): Command to add
"""
self.xml.append(ET.fromstring('<command>%s</command>' % command))
def add_item(self, name, rating):
"""Add an item to the extension.
Arguments:
name (str): Name of the player to add
rating (int): Rating of the player to add
"""
self.xml.append(ET.Element('board', {'name': name, 'rating': str(rating)}))
class GameListXmppPlugin(ElementBase):
"""Class for custom gamelist stanza extension."""
name = 'query'
namespace = 'jabber:iq:gamelist'
interfaces = {'game', 'command'}
sub_interfaces = interfaces
plugin_attrib = 'gamelist'
def add_game(self, data):
"""Add a game to the extension.
Arguments:
data (dict): game data to add
"""
try: del data['ip'] # Don't send the IP address with the gamelist.
except: pass
self.xml.append(ET.Element('game', data))
def get_game(self):
"""Get game from stanza.
Required to parse incoming stanzas with this extension.
Returns:
dict with game data
"""
game = self.xml.find('{%s}game' % self.namespace)
data = {}
if game is not None:
for key, item in game.items():
data[key] = item
return data
class GameReportXmppPlugin(ElementBase):
"""Class for custom gamereport stanza extension."""
name = 'report'
namespace = 'jabber:iq:gamereport'
plugin_attrib = 'gamereport'
interfaces = 'game'
sub_interfaces = interfaces
def add_game(self, game_report):
"""Add a game to the extension.
Arguments:
game_report (dict): a report about a game
"""
self.xml.append(ET.fromstring(str(game_report)).find('{%s}game' % self.namespace))
def get_game(self):
"""Get game from stanza.
Required to parse incoming stanzas with this extension.
Returns:
dict with game information
"""
game = self.xml.find('{%s}game' % self.namespace)
data = {}
if game is not None:
for key, item in game.items():
data[key] = item
return data
class ProfileXmppPlugin(ElementBase):
"""Class for custom profile."""
name = 'query'
namespace = 'jabber:iq:profile'
interfaces = {'profile', 'command'}
sub_interfaces = interfaces
plugin_attrib = 'profile'
def add_command(self, player_nick):
"""Add a command to the extension.
Arguments:
player_nick (str): the nick of the player the profile is about
"""
self.xml.append(ET.fromstring('<command>%s</command>' % player_nick))
def add_item(self, player, rating, highest_rating=0, # pylint: disable=too-many-arguments
rank=0, total_games_played=0, wins=0, losses=0):
"""Add an item to the extension.
Arguments:
player (str): Name of the player
rating (int): Current rating of the player
highest_rating (int): Highest rating the player had
rank (int): Rank of the player
total_games_played (int): Total number of games the player
played
wins (int): Number of won games the player had
losses (int): Number of lost games the player had
"""
item_xml = ET.Element('profile', {'player': player, 'rating': str(rating),
'highestRating': str(highest_rating), 'rank': str(rank),
'totalGamesPlayed': str(total_games_played),
'wins': str(wins), 'losses': str(losses)})
self.xml.append(item_xml)
@@ -1,48 +0,0 @@
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""Collection of utility functions used by the XMPP-bots."""
from collections import OrderedDict
class LimitedSizeDict(OrderedDict):
"""Dictionary with limited size and FIFO characteristics."""
def __init__(self, *args, **kwargs):
"""Initialize the dictionary.
Set the limit to which size the dict should be able to grow.
"""
self.size_limit = kwargs.pop('size_limit', None)
OrderedDict.__init__(self, *args, **kwargs)
self._check_size_limit()
def __setitem__(self, key, value): # pylint: disable=signature-differs
"""Overwrite default method to add size limit check."""
OrderedDict.__setitem__(self, key, value)
self._check_size_limit()
def _check_size_limit(self):
"""Ensure dict is not larger than the size limit.
Compares the current size of the dict with the size limit and
removes items from the dict until the size is equal the size
limit.
"""
if self.size_limit:
while len(self) > self.size_limit:
self.popitem(last=False)
@@ -1,364 +0,0 @@
#!/usr/bin/env python3
# Copyright (C) 2021 Wildfire Games.
# This file is part of 0 A.D.
#
# 0 A.D. is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# 0 A.D. is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
"""0ad XMPP-bot responsible for managing game listings."""
import argparse
import logging
import time
import sys
import sleekxmpp
from sleekxmpp.stanza import Iq
from sleekxmpp.xmlstream.handler import Callback
from sleekxmpp.xmlstream.matcher import StanzaPath
from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin
from xpartamupp.stanzas import GameListXmppPlugin
from xpartamupp.utils import LimitedSizeDict
class Games(object):
"""Class to tracks all games in the lobby."""
def __init__(self):
"""Initialize with empty games."""
self.games = LimitedSizeDict(size_limit=2**7)
def add_game(self, jid, data):
"""Add a game.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player who started the
game
data (dict): information about the game
Returns:
True if adding the game succeeded, False if not
"""
try:
data['players-init'] = data['players']
data['nbp-init'] = data['nbp']
data['state'] = 'init'
except (KeyError, TypeError, ValueError):
logging.warning("Received invalid data for add game from 0ad: %s", data)
return False
else:
self.games[jid] = data
return True
def remove_game(self, jid):
"""Remove a game attached to a JID.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player whose game to
remove.
Returns:
True if removing the game succeeded, False if not
"""
try:
del self.games[jid]
except KeyError:
logging.warning("Game for jid %s didn't exist", jid)
return False
else:
return True
def get_all_games(self):
"""Return all games.
Returns:
dict containing all games with the JID of the player who
started the game as key.
"""
return self.games
def change_game_state(self, jid, data):
"""Switch game state between running and waiting.
Arguments:
jid (sleekxmpp.jid.JID): JID of the player whose game to
change
data (dict): information about the game
Returns:
True if changing the game state succeeded, False if not
"""
if jid not in self.games:
logging.warning("Tried to change state for non-existent game %s", jid)
return False
try:
if self.games[jid]['nbp-init'] > data['nbp']:
logging.debug("change game (%s) state from %s to %s", jid,
self.games[jid]['state'], 'waiting')
self.games[jid]['state'] = 'waiting'
else:
logging.debug("change game (%s) state from %s to %s", jid,
self.games[jid]['state'], 'running')
self.games[jid]['state'] = 'running'
self.games[jid]['nbp'] = data['nbp']
self.games[jid]['players'] = data['players']
except (KeyError, ValueError):
logging.warning("Received invalid data for change game state from 0ad: %s", data)
return False
else:
if 'startTime' not in self.games[jid]:
self.games[jid]['startTime'] = str(round(time.time()))
return True
class XpartaMuPP(sleekxmpp.ClientXMPP):
"""Main class which handles IQ data and sends new data."""
def __init__(self, sjid, password, room, nick):
"""Initialize XpartaMuPP.
Arguments:
sjid (sleekxmpp.jid.JID): JID to use for authentication
password (str): password to use for authentication
room (str): XMPP MUC room to join
nick (str): Nick to use in MUC
"""
sleekxmpp.ClientXMPP.__init__(self, sjid, password)
self.whitespace_keepalive = False
self.room = room
self.nick = nick
self.games = Games()
register_stanza_plugin(Iq, GameListXmppPlugin)
self.register_handler(Callback('Iq Gamelist', StanzaPath('iq@type=set/gamelist'),
self._iq_game_list_handler))
self.add_event_handler('session_start', self._session_start)
self.add_event_handler('muc::%s::got_online' % self.room, self._muc_online)
self.add_event_handler('muc::%s::got_offline' % self.room, self._muc_offline)
self.add_event_handler('groupchat_message', self._muc_message)
def _session_start(self, event): # pylint: disable=unused-argument
"""Join MUC channel and announce presence.
Arguments:
event (dict): empty dummy dict
"""
self.plugin['xep_0045'].joinMUC(self.room, self.nick)
self.send_presence()
self.get_roster()
logging.info("XpartaMuPP started")
def _muc_online(self, presence):
"""Add joining players to the list of players.
Also send a list of games to them, so they see which games
are currently there.
Arguments:
presence (sleekxmpp.stanza.presence.Presence): Received
presence stanza.
"""
nick = str(presence['muc']['nick'])
jid = sleekxmpp.jid.JID(presence['muc']['jid'])
if nick == self.nick:
return
if jid.resource not in ['0ad', 'CC']:
return
self._send_game_list(jid)
logging.debug("Client '%s' connected with a nick '%s'.", jid, nick)
def _muc_offline(self, presence):
"""Remove leaving players from the list of players.
Also remove the potential game this player was hosting, so we
don't end up with stale games.
Arguments:
presence (sleekxmpp.stanza.presence.Presence): Received
presence stanza.
"""
nick = str(presence['muc']['nick'])
jid = sleekxmpp.jid.JID(presence['muc']['jid'])
if nick == self.nick:
return
if self.games.remove_game(jid):
self._send_game_list()
logging.debug("Client '%s' with nick '%s' disconnected", jid, nick)
def _muc_message(self, msg):
"""Process messages in the MUC room.
Respond to messages highlighting the bots name with an
informative message.
Arguments:
msg (sleekxmpp.stanza.message.Message): Received MUC
message
"""
if msg['mucnick'] != self.nick and self.nick.lower() in msg['body'].lower():
self.send_message(mto=msg['from'].bare,
mbody="I am just a bot and I'm responsible to ensure that your're"
"able to see the list of games in here. Aside from that I'm"
"just chilling.",
mtype='groupchat')
def _iq_game_list_handler(self, iq):
"""Handle game state change requests.
Arguments:
iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza
"""
if iq['from'].resource != '0ad':
return
success = False
command = iq['gamelist']['command']
if command == 'register':
success = self.games.add_game(iq['from'], iq['gamelist']['game'])
elif command == 'unregister':
success = self.games.remove_game(iq['from'])
elif command == 'changestate':
success = self.games.change_game_state(iq['from'], iq['gamelist']['game'])
else:
logging.info('Received unknown game command: "%s"', command)
iq.reply(clear=not success)
if not success: iq['error']['condition'] = "undefined-condition"
iq.send()
if success:
try:
self._send_game_list()
except Exception:
logging.exception('Failed to send game list after "%s" command', command)
def _send_game_list(self, to=None):
"""Send a massive stanza with the whole game list.
If no target is passed the gamelist is broadcasted to all
clients.
Arguments:
to (sleekxmpp.jid.JID): Player to send the game list to.
If None, the game list will be broadcasted
"""
games = self.games.get_all_games()
stanza = GameListXmppPlugin()
for jid in games:
stanza.add_game(games[jid])
if not to:
for nick in self.plugin['xep_0045'].getRoster(self.room):
if nick == self.nick:
continue
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid')
jid = sleekxmpp.jid.JID(jid_str)
iq = self.make_iq_result(ito=jid)
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send game list to %s", jid)
else:
iq = self.make_iq_result(ito=to)
iq.set_payload(stanza)
try:
iq.send(block=False)
except Exception:
logging.exception("Failed to send game list to %s", to)
def parse_args(args):
"""Parse command line arguments.
Arguments:
args (dict): Raw command line arguments given to the script
Returns:
Parsed command line arguments
"""
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description="XpartaMuPP - XMPP Multiplayer Game Manager")
log_settings = parser.add_mutually_exclusive_group()
log_settings.add_argument('-q', '--quiet', help="only log errors", action='store_const',
dest='log_level', const=logging.ERROR)
log_settings.add_argument('-d', '--debug', help="log debug messages", action='store_const',
dest='log_level', const=logging.DEBUG)
log_settings.add_argument('-v', '--verbose', help="log more informative messages",
action='store_const', dest='log_level', const=logging.INFO)
log_settings.set_defaults(log_level=logging.WARNING)
parser.add_argument('-m', '--domain', help="XMPP server to connect to",
default='lobby.wildfiregames.com')
parser.add_argument('-l', '--login', help="username for login", default='xpartamupp')
parser.add_argument('-p', '--password', help="password for login", default='XXXXXX')
parser.add_argument('-n', '--nickname', help="nickname shown to players", default='WFGBot')
parser.add_argument('-r', '--room', help="XMPP MUC room to join", default='arena')
parser.add_argument('-s', '--server', help='address of the ejabberd server',
action='store', dest='xserver', default=None)
parser.add_argument('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption',
action='store_true', dest='xdisabletls', default=False)
return parser.parse_args(args)
def main():
"""Entry point a console script."""
args = parse_args(sys.argv[1:])
logging.basicConfig(level=args.log_level,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
xmpp = XpartaMuPP(sleekxmpp.jid.JID('%s@%s/%s' % (args.login, args.domain, 'CC')),
args.password, args.room + '@conference.' + args.domain, args.nickname)
xmpp.register_plugin('xep_0030') # Service Discovery
xmpp.register_plugin('xep_0004') # Data Forms
xmpp.register_plugin('xep_0045') # Multi-User Chat
xmpp.register_plugin('xep_0060') # Publish-Subscribe
xmpp.register_plugin('xep_0199', {'keepalive': True}) # XMPP Ping
if xmpp.connect((args.xserver, 5222) if args.xserver else None, True, not args.xdisabletls):
xmpp.process()
else:
logging.error("Unable to connect")
if __name__ == '__main__':
main()